diff --git a/app/core/auth_service.py b/app/core/auth_service.py
index 24f02e1..28cfc2c 100644
--- a/app/core/auth_service.py
+++ b/app/core/auth_service.py
@@ -175,7 +175,7 @@ class AuthService:
# Store session for token revocation (skip for shadow admin)
if not is_shadow_admin:
- execute_insert(
+ execute_update(
"""INSERT INTO sessions (user_id, token_jti, expires_at)
VALUES (%s, %s, %s)""",
(user_id, jti, expire)
diff --git a/app/core/config.py b/app/core/config.py
index da79d7c..5f1f20a 100644
--- a/app/core/config.py
+++ b/app/core/config.py
@@ -126,6 +126,12 @@ class Settings(BaseSettings):
SIMPLYCRM_URL: str = ""
SIMPLYCRM_USERNAME: str = ""
SIMPLYCRM_API_KEY: str = ""
+ SIMPLYCRM_TICKET_MODULE: str = "Tickets"
+ SIMPLYCRM_TICKET_COMMENT_MODULE: str = "ModComments"
+ SIMPLYCRM_TICKET_COMMENT_RELATION_FIELD: str = "related_to"
+ SIMPLYCRM_TICKET_EMAIL_MODULE: str = "Emails"
+ SIMPLYCRM_TICKET_EMAIL_RELATION_FIELD: str = "parent_id"
+ SIMPLYCRM_TICKET_EMAIL_FALLBACK_RELATION_FIELD: str = "related_to"
# Backup System Configuration
BACKUP_ENABLED: bool = True
@@ -178,6 +184,9 @@ class Settings(BaseSettings):
REMINDERS_CHECK_INTERVAL_MINUTES: int = 5
REMINDERS_MAX_PER_USER_PER_HOUR: int = 5
REMINDERS_QUEUE_BATCH_SIZE: int = 10
+
+ # Dev-only shortcuts
+ DEV_ALLOW_ARCHIVED_IMPORT: bool = False
# Deployment Configuration (used by Docker/Podman)
POSTGRES_USER: str = "bmc_hub"
diff --git a/app/fixed_price/__init__.py b/app/fixed_price/__init__.py
new file mode 100644
index 0000000..e239e50
--- /dev/null
+++ b/app/fixed_price/__init__.py
@@ -0,0 +1 @@
+"""Fixed-Price Agreement Module"""
diff --git a/app/fixed_price/backend/__init__.py b/app/fixed_price/backend/__init__.py
new file mode 100644
index 0000000..6eb112d
--- /dev/null
+++ b/app/fixed_price/backend/__init__.py
@@ -0,0 +1 @@
+"""Backend package"""
diff --git a/app/fixed_price/backend/models.py b/app/fixed_price/backend/models.py
new file mode 100644
index 0000000..a299ad8
--- /dev/null
+++ b/app/fixed_price/backend/models.py
@@ -0,0 +1,194 @@
+"""
+Fixed-Price Agreement Models
+Pydantic schemas for API validation
+"""
+from typing import Optional, Literal
+from pydantic import BaseModel, Field, field_validator, model_validator
+from datetime import date, datetime
+from decimal import Decimal
+
+
+# Type aliases
+PeriodType = Literal['calendar_month', 'rolling_30days', 'quarterly', 'yearly']
+AgreementStatus = Literal['active', 'suspended', 'expired', 'cancelled', 'pending_cancellation']
+BillingPeriodStatus = Literal['active', 'pending_approval', 'ready_to_bill', 'billed', 'cancelled']
+
+
+class FixedPriceAgreementBase(BaseModel):
+ """Base schema with common fields"""
+ customer_id: int
+ customer_name: Optional[str] = None
+ monthly_hours: Decimal = Field(gt=0)
+ monthly_amount: Optional[Decimal] = Field(default=None, ge=0) # Fast månedspris
+ hourly_rate: Optional[Decimal] = Field(default=None, ge=0) # Beregnes fra monthly_amount hvis ikke givet
+ overtime_rate: Decimal = Field(ge=0)
+ internal_cost_rate: Decimal = Field(default=Decimal("350.00"), ge=0)
+ rounding_minutes: int = Field(default=0, ge=0, le=60)
+ notes: Optional[str] = None
+
+ @field_validator('rounding_minutes')
+ @classmethod
+ def validate_rounding(cls, v: int) -> int:
+ if v not in (0, 15, 30, 60):
+ raise ValueError('rounding_minutes must be 0, 15, 30, or 60')
+ return v
+
+ @model_validator(mode='after')
+ def compute_hourly_rate(self):
+ """Beregn hourly_rate fra monthly_amount hvis ikke direkte angivet"""
+ if self.hourly_rate is None:
+ if self.monthly_amount is not None and self.monthly_hours:
+ self.hourly_rate = self.monthly_amount / self.monthly_hours
+ else:
+ raise ValueError('Either hourly_rate or monthly_amount must be provided')
+ return self
+
+
+class FixedPriceAgreementCreate(FixedPriceAgreementBase):
+ """Schema for creating new agreement"""
+ subscription_id: Optional[int] = None
+
+ # Contract terms
+ start_date: date
+ binding_months: int = Field(default=0, ge=0)
+ end_date: Optional[date] = None
+ notice_period_days: int = Field(default=30, ge=0)
+ auto_renew: bool = False
+
+ # e-conomic integration
+ economic_product_number: Optional[str] = None
+ economic_overtime_product_number: Optional[str] = None
+
+ @field_validator('end_date')
+ @classmethod
+ def validate_end_date(cls, v: Optional[date], info) -> Optional[date]:
+ if v and 'start_date' in info.data and v < info.data['start_date']:
+ raise ValueError('end_date must be after start_date')
+ return v
+
+
+class FixedPriceAgreementUpdate(BaseModel):
+ """Schema for updating existing agreement"""
+ monthly_hours: Optional[Decimal] = Field(default=None, gt=0)
+ hourly_rate: Optional[Decimal] = Field(default=None, ge=0)
+ overtime_rate: Optional[Decimal] = Field(default=None, ge=0)
+ internal_cost_rate: Optional[Decimal] = Field(default=None, ge=0)
+ rounding_minutes: Optional[int] = Field(default=None, ge=0, le=60)
+ end_date: Optional[date] = None
+ notice_period_days: Optional[int] = Field(default=None, ge=0)
+ auto_renew: Optional[bool] = None
+ billing_enabled: Optional[bool] = None
+ notes: Optional[str] = None
+
+ @field_validator('rounding_minutes')
+ @classmethod
+ def validate_rounding(cls, v: Optional[int]) -> Optional[int]:
+ if v is not None and v not in (0, 15, 30, 60):
+ raise ValueError('rounding_minutes must be 0, 15, 30, or 60')
+ return v
+
+
+class FixedPriceAgreement(FixedPriceAgreementBase):
+ """Full agreement response schema"""
+ id: int
+ agreement_number: str
+ subscription_id: Optional[int]
+
+ start_date: date
+ binding_months: int
+ binding_end_date: Optional[date]
+ end_date: Optional[date]
+ notice_period_days: int
+ auto_renew: bool
+
+ status: AgreementStatus
+ cancellation_requested_date: Optional[date]
+ cancellation_effective_date: Optional[date]
+ cancelled_by_user_id: Optional[int]
+ cancellation_reason: Optional[str]
+
+ billing_enabled: bool
+ last_billed_period: Optional[date]
+
+ economic_product_number: Optional[str]
+ economic_overtime_product_number: Optional[str]
+
+ created_by_user_id: Optional[int]
+ created_at: datetime
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class CancellationRequest(BaseModel):
+ """Schema for agreement cancellation"""
+ reason: str = Field(min_length=1)
+ effective_date: Optional[date] = None
+ force: bool = False # Admin override for binding period
+
+ @field_validator('reason')
+ @classmethod
+ def validate_reason(cls, v: str) -> str:
+ if not v or not v.strip():
+ raise ValueError('cancellation reason is required')
+ return v.strip()
+
+
+class BillingPeriodBase(BaseModel):
+ """Base schema for billing periods"""
+ agreement_id: int
+ period_start: date
+ period_end: date
+ period_type: PeriodType = 'calendar_month'
+ included_hours: Decimal = Field(gt=0)
+ base_amount: Decimal = Field(ge=0)
+
+
+class BillingPeriodCreate(BillingPeriodBase):
+ """Schema for creating billing period"""
+ pass
+
+
+class BillingPeriod(BillingPeriodBase):
+ """Full billing period response"""
+ id: int
+ used_hours: Decimal
+ overtime_hours: Decimal
+ remaining_hours: Decimal
+ overtime_amount: Decimal
+ overtime_approved: bool
+ status: BillingPeriodStatus
+ billed_at: Optional[datetime]
+ economic_invoice_number: Optional[str]
+ invoice_id: Optional[int]
+ created_at: datetime
+ updated_at: datetime
+
+ class Config:
+ from_attributes = True
+
+
+class BillingPeriodApproval(BaseModel):
+ """Schema for approving overtime"""
+ overtime_hours: Decimal = Field(ge=0)
+ approved: bool
+ approved_by_user_id: Optional[int] = None
+ notes: Optional[str] = None
+
+
+class AgreementPerformance(BaseModel):
+ """Schema for performance metrics"""
+ id: int
+ agreement_number: str
+ customer_name: str
+ status: AgreementStatus
+ total_periods: int
+ total_used_hours: Decimal
+ total_revenue: Decimal
+ total_internal_cost: Decimal
+ total_profit: Decimal
+ utilization_percent: Decimal
+
+ class Config:
+ from_attributes = True
diff --git a/app/fixed_price/backend/router.py b/app/fixed_price/backend/router.py
new file mode 100644
index 0000000..b90bb2b
--- /dev/null
+++ b/app/fixed_price/backend/router.py
@@ -0,0 +1,769 @@
+"""
+Fixed-Price Agreement Router
+CRUD operations, billing period management, and reporting
+"""
+from fastapi import APIRouter, HTTPException, Response
+from fastapi.responses import StreamingResponse
+from app.core.database import execute_query
+from app.fixed_price.backend.models import (
+ FixedPriceAgreement,
+ FixedPriceAgreementCreate,
+ FixedPriceAgreementUpdate,
+ CancellationRequest,
+ BillingPeriod,
+ BillingPeriodCreate,
+ BillingPeriodApproval,
+)
+from typing import List, Optional, Dict, Any
+from datetime import date, datetime, timedelta
+from decimal import Decimal, ROUND_CEILING
+from calendar import monthrange
+import logging
+import csv
+from io import StringIO
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+
+
+def _apply_rounding(hours: Decimal, rounding_minutes: int) -> Decimal:
+ """Apply rounding to hours based on interval (same as prepaid cards)"""
+ if rounding_minutes <= 0:
+ return hours
+
+ interval = Decimal(rounding_minutes) / Decimal(60)
+ rounded = (hours / interval).to_integral_value(ROUND_CEILING) * interval
+ return rounded
+
+
+def _last_day_of_month(dt: date) -> date:
+ """Get last day of month for given date"""
+ last_day = monthrange(dt.year, dt.month)[1]
+ return date(dt.year, dt.month, last_day)
+
+
+def _calculate_prorated_amount(monthly_hours: float, hourly_rate: float,
+ period_start: date, period_end: date) -> float:
+ """
+ Calculate pro-rated amount based on actual days in period.
+
+ If period is a full calendar month, returns full monthly amount.
+ Otherwise, calculates daily rate and multiplies by days in period.
+ """
+ # Full month amount
+ monthly_amount = monthly_hours * hourly_rate
+
+ # Check if period is a full month (starts on 1st and ends on last day)
+ last_day_of_month = monthrange(period_start.year, period_start.month)[1]
+ if period_start.day == 1 and period_end.day == last_day_of_month and period_start.month == period_end.month:
+ return monthly_amount
+
+ # Calculate pro-rated amount for partial month
+ days_in_month = monthrange(period_start.year, period_start.month)[1]
+ days_in_period = (period_end - period_start).days + 1 # +1 to include both start and end
+
+ daily_rate = monthly_amount / days_in_month
+ prorated_amount = daily_rate * days_in_period
+
+ return prorated_amount
+
+
+# ============================================================================
+# CRUD Operations
+# ============================================================================
+
+@router.get("/fixed-price-agreements", response_model=List[Dict[str, Any]])
+async def get_agreements(
+ customer_id: Optional[int] = None,
+ status: Optional[str] = None,
+ include_current_period: bool = True
+):
+ """
+ Get all fixed-price agreements with optional filters
+ """
+ try:
+ filters = []
+ params = []
+
+ if customer_id:
+ filters.append("customer_id = %s")
+ params.append(customer_id)
+
+ if status:
+ filters.append("status = %s")
+ params.append(status)
+
+ where_clause = "WHERE " + " AND ".join(filters) if filters else ""
+
+ agreements = execute_query(f"""
+ SELECT * FROM customer_fixed_price_agreements
+ {where_clause}
+ ORDER BY created_at DESC
+ """, params if params else None)
+
+ # Enrich with current period info
+ if include_current_period and agreements:
+ for agr in agreements:
+ period = execute_query("""
+ SELECT
+ used_hours,
+ remaining_hours,
+ overtime_hours,
+ status
+ FROM fixed_price_billing_periods
+ WHERE agreement_id = %s
+ AND period_start <= CURRENT_DATE
+ AND period_end >= CURRENT_DATE
+ ORDER BY period_start DESC
+ LIMIT 1
+ """, (agr['id'],))
+
+ if period and len(period) > 0:
+ agr['current_period'] = period[0]
+ agr['remaining_hours_this_month'] = float(period[0]['remaining_hours'])
+ else:
+ agr['current_period'] = None
+ agr['remaining_hours_this_month'] = float(agr['monthly_hours'])
+
+ return agreements or []
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching agreements: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/fixed-price-agreements/{agreement_id}", response_model=Dict[str, Any])
+async def get_agreement(agreement_id: int):
+ """
+ Get single agreement with full details including periods and timelogs
+ """
+ try:
+ agreements = execute_query("""
+ SELECT * FROM customer_fixed_price_agreements
+ WHERE id = %s
+ """, (agreement_id,))
+
+ if not agreements or len(agreements) == 0:
+ raise HTTPException(status_code=404, detail="Agreement not found")
+
+ agreement = agreements[0]
+
+ # Get billing periods
+ periods = execute_query("""
+ SELECT * FROM fixed_price_billing_periods
+ WHERE agreement_id = %s
+ ORDER BY period_start DESC
+ """, (agreement_id,))
+
+ agreement['billing_periods'] = periods or []
+
+ # Get timelogs (similar to prepaid card detail)
+ sag_logs = execute_query("""
+ SELECT
+ t.id,
+ t.worked_date,
+ t.original_hours as actual_hours,
+ t.approved_hours as rounded_hours,
+ t.description,
+ t.sag_id as source_id,
+ 'sag' as source,
+ s.titel as source_title
+ FROM tmodule_times t
+ LEFT JOIN tmodule_sag s ON t.sag_id = s.id
+ WHERE t.fixed_price_agreement_id = %s
+ ORDER BY t.worked_date DESC
+ """, (agreement_id,))
+
+ ticket_logs = execute_query("""
+ SELECT
+ w.id,
+ w.created_at::date as worked_date,
+ w.hours as actual_hours,
+ w.rounded_hours,
+ w.description,
+ w.ticket_id as source_id,
+ 'ticket' as source,
+ t.ticket_number,
+ t.subject as source_title
+ FROM tticket_worklog w
+ LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
+ WHERE w.fixed_price_agreement_id = %s
+ ORDER BY w.created_at DESC
+ """, (agreement_id,))
+
+ # Combine and sort timelogs
+ timelogs = []
+ for log in (sag_logs or []):
+ timelogs.append(log)
+ for log in (ticket_logs or []):
+ timelogs.append(log)
+
+ timelogs.sort(key=lambda x: x['worked_date'], reverse=True)
+ agreement['timelogs'] = timelogs
+
+ return agreement
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error fetching agreement {agreement_id}: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/fixed-price-agreements", response_model=Dict[str, Any])
+async def create_agreement(data: FixedPriceAgreementCreate):
+ """
+ Create new fixed-price agreement and initialize first billing period
+ """
+ try:
+ # Validate rounding
+ if data.rounding_minutes not in (0, 15, 30, 60):
+ raise HTTPException(status_code=400, detail="Invalid rounding_minutes")
+
+ # Insert agreement
+ from app.core.database import get_db_connection, release_db_connection
+ from psycopg2.extras import RealDictCursor
+
+ conn = get_db_connection()
+ try:
+ with conn.cursor(cursor_factory=RealDictCursor) as cursor:
+ # Create agreement
+ cursor.execute("""
+ INSERT INTO customer_fixed_price_agreements (
+ customer_id, customer_name, subscription_id,
+ monthly_hours, hourly_rate, overtime_rate, internal_cost_rate,
+ rounding_minutes, start_date, binding_months, end_date,
+ notice_period_days, auto_renew, economic_product_number,
+ economic_overtime_product_number, notes
+ ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """, (
+ data.customer_id,
+ data.customer_name,
+ data.subscription_id,
+ data.monthly_hours,
+ data.hourly_rate,
+ data.overtime_rate,
+ data.internal_cost_rate,
+ data.rounding_minutes,
+ data.start_date,
+ data.binding_months,
+ data.end_date,
+ data.notice_period_days,
+ data.auto_renew,
+ data.economic_product_number,
+ data.economic_overtime_product_number,
+ data.notes
+ ))
+ agreement = cursor.fetchone()
+
+ # Create first billing period
+ period_start = data.start_date
+ period_end = _last_day_of_month(period_start)
+
+ # Calculate pro-rated amount for first period
+ base_amount = _calculate_prorated_amount(
+ data.monthly_hours,
+ data.hourly_rate,
+ period_start,
+ period_end
+ )
+
+ # Pro-rate included hours as well for partial months
+ days_in_month = monthrange(period_start.year, period_start.month)[1]
+ days_in_period = (period_end - period_start).days + 1
+ last_day_of_month = monthrange(period_start.year, period_start.month)[1]
+
+ if period_start.day == 1 and period_end.day == last_day_of_month:
+ # Full month
+ included_hours = data.monthly_hours
+ else:
+ # Pro-rate hours for partial month
+ included_hours = (data.monthly_hours / days_in_month) * days_in_period
+
+ cursor.execute("""
+ INSERT INTO fixed_price_billing_periods (
+ agreement_id, period_start, period_end,
+ included_hours, base_amount
+ ) VALUES (%s, %s, %s, %s, %s)
+ RETURNING *
+ """, (
+ agreement['id'],
+ period_start,
+ period_end,
+ included_hours,
+ base_amount
+ ))
+ first_period = cursor.fetchone()
+
+ conn.commit()
+
+ logger.info(f"✅ Created fixed-price agreement {agreement['agreement_number']} for customer {data.customer_id}")
+
+ agreement['first_period'] = first_period
+ return agreement
+
+ finally:
+ release_db_connection(conn)
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error creating agreement: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.patch("/fixed-price-agreements/{agreement_id}", response_model=Dict[str, Any])
+async def update_agreement(agreement_id: int, data: FixedPriceAgreementUpdate):
+ """
+ Update agreement terms (does not affect existing periods)
+ """
+ try:
+ # Build UPDATE query dynamically
+ updates = []
+ params = []
+
+ for field, value in data.model_dump(exclude_unset=True).items():
+ if value is not None:
+ updates.append(f"{field} = %s")
+ params.append(value)
+
+ if not updates:
+ raise HTTPException(status_code=400, detail="No fields to update")
+
+ params.append(agreement_id)
+
+ result = execute_query(f"""
+ UPDATE customer_fixed_price_agreements
+ SET {", ".join(updates)}
+ WHERE id = %s
+ RETURNING *
+ """, params)
+
+ if not result or len(result) == 0:
+ raise HTTPException(status_code=404, detail="Agreement not found")
+
+ logger.info(f"✅ Updated fixed-price agreement {agreement_id}")
+ return result[0]
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error updating agreement {agreement_id}: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/fixed-price-agreements/{agreement_id}/status")
+async def update_status(agreement_id: int, status: str):
+ """
+ Update agreement status (active, suspended, etc.)
+ """
+ try:
+ valid_statuses = ['active', 'suspended', 'expired', 'cancelled', 'pending_cancellation']
+ if status not in valid_statuses:
+ raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of {valid_statuses}")
+
+ result = execute_query("""
+ UPDATE customer_fixed_price_agreements
+ SET status = %s
+ WHERE id = %s
+ RETURNING *
+ """, (status, agreement_id))
+
+ if not result or len(result) == 0:
+ raise HTTPException(status_code=404, detail="Agreement not found")
+
+ logger.info(f"✅ Agreement {agreement_id} status → {status}")
+ return result[0]
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error updating status: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.post("/fixed-price-agreements/{agreement_id}/cancel")
+async def cancel_agreement(agreement_id: int, request: CancellationRequest):
+ """
+ Request cancellation with binding period validation
+ """
+ try:
+ agreements = execute_query("""
+ SELECT * FROM customer_fixed_price_agreements
+ WHERE id = %s
+ """, (agreement_id,))
+
+ if not agreements or len(agreements) == 0:
+ raise HTTPException(status_code=404, detail="Agreement not found")
+
+ agreement = agreements[0]
+ today = date.today()
+ binding_end = agreement['binding_end_date']
+
+ # Check binding period
+ if binding_end and today < binding_end and not request.force:
+ raise HTTPException(
+ status_code=400,
+ detail=f"Aftale er bundet til {binding_end}. Kontakt administrator for tvungen opsigelse."
+ )
+
+ # Calculate effective date
+ effective_date = request.effective_date or (today + timedelta(days=agreement['notice_period_days']))
+
+ # Update agreement
+ result = execute_query("""
+ UPDATE customer_fixed_price_agreements
+ SET status = 'pending_cancellation',
+ cancellation_requested_date = %s,
+ cancellation_effective_date = %s,
+ cancellation_reason = %s
+ WHERE id = %s
+ RETURNING *
+ """, (today, effective_date, request.reason, agreement_id))
+
+ logger.info(f"⚠️ Agreement {agreement_id} cancellation requested, effective {effective_date}")
+ return result[0]
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error cancelling agreement: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# Billing Period Management
+# ============================================================================
+
+@router.get("/fixed-price-agreements/{agreement_id}/periods", response_model=List[Dict[str, Any]])
+async def get_periods(agreement_id: int):
+ """Get all billing periods for agreement"""
+ try:
+ periods = execute_query("""
+ SELECT * FROM fixed_price_billing_periods
+ WHERE agreement_id = %s
+ ORDER BY period_start DESC
+ """, (agreement_id,))
+
+ return periods or []
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching periods: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.put("/fixed-price-agreements/{agreement_id}/periods/{period_id}/approve-overtime")
+async def approve_overtime(agreement_id: int, period_id: int, approval: BillingPeriodApproval):
+ """
+ Approve overtime hours for billing
+ """
+ try:
+ # Calculate overtime amount
+ agreements = execute_query("""
+ SELECT overtime_rate FROM customer_fixed_price_agreements
+ WHERE id = %s
+ """, (agreement_id,))
+
+ if not agreements or len(agreements) == 0:
+ raise HTTPException(status_code=404, detail="Agreement not found")
+
+ overtime_rate = agreements[0]['overtime_rate']
+ overtime_amount = approval.overtime_hours * Decimal(str(overtime_rate))
+
+ # Update period
+ result = execute_query("""
+ UPDATE fixed_price_billing_periods
+ SET overtime_amount = %s,
+ overtime_approved = %s,
+ status = CASE
+ WHEN %s THEN 'ready_to_bill'
+ ELSE status
+ END
+ WHERE id = %s AND agreement_id = %s
+ RETURNING *
+ """, (overtime_amount, approval.approved, approval.approved, period_id, agreement_id))
+
+ if not result or len(result) == 0:
+ raise HTTPException(status_code=404, detail="Period not found")
+
+ logger.info(f"✅ Overtime approved for period {period_id}: {approval.overtime_hours}t = {overtime_amount} DKK")
+ return result[0]
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error approving overtime: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+# ============================================================================
+# Reporting & Analytics
+# ============================================================================
+
+@router.get("/fixed-price-agreements/stats/summary")
+async def get_stats_summary():
+ """
+ Overall fixed-price system statistics
+ """
+ try:
+ result = execute_query("""
+ SELECT
+ COUNT(DISTINCT id) as total_agreements,
+ COUNT(DISTINCT id) FILTER (WHERE status = 'active') as active_agreements,
+ COUNT(DISTINCT id) FILTER (WHERE status = 'cancelled') as cancelled_agreements,
+ COUNT(DISTINCT id) FILTER (WHERE status = 'expired') as expired_agreements,
+
+ SUM(monthly_hours) FILTER (WHERE status = 'active') as total_active_monthly_hours,
+ AVG(hourly_rate) FILTER (WHERE status = 'active') as avg_hourly_rate,
+
+ COUNT(DISTINCT customer_id) as unique_customers
+ FROM customer_fixed_price_agreements
+ """)[0]
+
+ # Get revenue and profit from performance view
+ performance = execute_query("""
+ SELECT
+ COALESCE(SUM(total_revenue), 0) as total_revenue,
+ COALESCE(SUM(total_internal_cost), 0) as total_cost,
+ COALESCE(SUM(total_profit), 0) as total_profit,
+ COALESCE(AVG(utilization_percent), 0) as avg_utilization
+ FROM fixed_price_agreement_performance
+ WHERE status = 'active'
+ """)[0]
+
+ return {**result, **performance}
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching stats: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/fixed-price-agreements/reports/profitability")
+async def get_profitability_report(
+ customer_id: Optional[int] = None,
+ start_date: Optional[date] = None,
+ end_date: Optional[date] = None
+):
+ """
+ Detailed profitability analysis with filters
+ """
+ try:
+ filters = []
+ params = []
+
+ if customer_id:
+ filters.append("a.customer_id = %s")
+ params.append(customer_id)
+ if start_date:
+ filters.append("bp.period_start >= %s")
+ params.append(start_date)
+ if end_date:
+ filters.append("bp.period_end <= %s")
+ params.append(end_date)
+
+ where_clause = "WHERE " + " AND ".join(filters) if filters else ""
+
+ return execute_query(f"""
+ SELECT
+ a.id,
+ a.agreement_number,
+ a.customer_name,
+ a.monthly_hours,
+ a.hourly_rate,
+ a.internal_cost_rate,
+
+ COUNT(bp.id) as period_count,
+ COALESCE(SUM(bp.used_hours), 0) as total_hours,
+ COALESCE(SUM(bp.overtime_hours) FILTER (WHERE bp.overtime_approved), 0) as overtime_hours,
+
+ COALESCE(SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed'), 0) as base_revenue,
+ COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved), 0) as overtime_revenue,
+ COALESCE(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed'), 0) as total_revenue,
+
+ COALESCE(SUM(bp.used_hours), 0) * a.internal_cost_rate as internal_cost,
+ COALESCE(SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed'), 0) -
+ COALESCE(SUM(bp.used_hours), 0) * a.internal_cost_rate as profit,
+
+ CASE
+ WHEN SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') > 0
+ THEN ROUND((
+ (SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') -
+ SUM(bp.used_hours) * a.internal_cost_rate) /
+ SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') * 100
+ )::numeric, 1)
+ ELSE 0
+ END as profit_margin_percent
+
+ FROM customer_fixed_price_agreements a
+ LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
+ {where_clause}
+ GROUP BY a.id
+ ORDER BY profit DESC
+ """, params if params else None) or []
+
+ except Exception as e:
+ logger.error(f"❌ Error generating profitability report: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/fixed-price-agreements/reports/monthly-trends")
+async def get_monthly_trends(months: int = 12):
+ """
+ Month-over-month trend analysis
+ """
+ try:
+ return execute_query("""
+ SELECT * FROM fixed_price_monthly_trends
+ ORDER BY month DESC
+ LIMIT %s
+ """, (months,)) or []
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching trends: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/fixed-price-agreements/reports/customer-breakdown")
+async def get_customer_breakdown():
+ """
+ Per-customer revenue and profitability
+ """
+ try:
+ return execute_query("""
+ SELECT * FROM fixed_price_customer_summary
+ ORDER BY total_revenue DESC
+ """) or []
+
+ except Exception as e:
+ logger.error(f"❌ Error fetching customer breakdown: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/fixed-price-agreements/reports/overtime-analysis")
+async def get_overtime_analysis():
+ """
+ Analyze overtime patterns to identify agreements with frequent overruns
+ """
+ try:
+ return execute_query("""
+ SELECT
+ a.id,
+ a.agreement_number,
+ a.customer_name,
+ a.monthly_hours,
+
+ COUNT(bp.id) as total_periods,
+ COUNT(bp.id) FILTER (WHERE bp.overtime_hours > 0) as periods_with_overtime,
+ ROUND((COUNT(bp.id) FILTER (WHERE bp.overtime_hours > 0)::numeric /
+ NULLIF(COUNT(bp.id), 0) * 100), 1) as overtime_frequency_percent,
+
+ AVG(bp.overtime_hours) FILTER (WHERE bp.overtime_hours > 0) as avg_overtime_per_period,
+ MAX(bp.overtime_hours) as max_overtime_single_period,
+ COALESCE(SUM(bp.overtime_hours), 0) as total_overtime_hours,
+
+ COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.overtime_approved), 0) as total_overtime_revenue
+
+ FROM customer_fixed_price_agreements a
+ LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
+ WHERE a.status = 'active'
+ GROUP BY a.id
+ HAVING COUNT(bp.id) > 0
+ ORDER BY overtime_frequency_percent DESC, total_overtime_hours DESC
+ """) or []
+
+ except Exception as e:
+ logger.error(f"❌ Error analyzing overtime: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/fixed-price-agreements/{agreement_id}/reports/period-detail")
+async def get_period_detail_report(agreement_id: int):
+ """
+ Detailed period-by-period breakdown for single agreement
+ """
+ try:
+ return execute_query("""
+ SELECT
+ bp.id,
+ bp.period_start,
+ bp.period_end,
+ bp.included_hours,
+ bp.used_hours,
+ bp.overtime_hours,
+ bp.base_amount,
+ bp.overtime_amount,
+ bp.overtime_approved,
+ bp.status,
+ bp.economic_invoice_number,
+
+ -- Calculate profit for this period
+ a.internal_cost_rate,
+ bp.used_hours * a.internal_cost_rate as period_cost,
+ (bp.base_amount + COALESCE(bp.overtime_amount, 0)) -
+ (bp.used_hours * a.internal_cost_rate) as period_profit,
+
+ -- Time entry breakdown
+ (SELECT COUNT(*) FROM tmodule_times
+ WHERE fixed_price_agreement_id = a.id
+ AND worked_date BETWEEN bp.period_start AND bp.period_end) as sag_entries,
+ (SELECT COUNT(*) FROM tticket_worklog
+ WHERE fixed_price_agreement_id = a.id
+ AND created_at::date BETWEEN bp.period_start AND bp.period_end) as ticket_entries
+
+ FROM fixed_price_billing_periods bp
+ JOIN customer_fixed_price_agreements a ON bp.agreement_id = a.id
+ WHERE a.id = %s
+ ORDER BY bp.period_start DESC
+ """, (agreement_id,)) or []
+
+ except Exception as e:
+ logger.error(f"❌ Error generating period detail: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/fixed-price-agreements/reports/export/csv")
+async def export_profitability_csv():
+ """
+ Export full profitability report as CSV
+ """
+ try:
+ data = execute_query("""
+ SELECT
+ a.agreement_number,
+ a.customer_name,
+ a.status,
+ a.monthly_hours,
+ a.hourly_rate,
+ a.overtime_rate,
+ a.internal_cost_rate,
+ a.start_date,
+ perf.total_periods,
+ perf.total_used_hours,
+ perf.total_approved_overtime,
+ perf.total_revenue,
+ perf.total_internal_cost,
+ perf.total_profit,
+ perf.utilization_percent
+ FROM customer_fixed_price_agreements a
+ LEFT JOIN fixed_price_agreement_performance perf ON a.id = perf.id
+ ORDER BY a.customer_name, a.agreement_number
+ """)
+
+ # Generate CSV
+ output = StringIO()
+ if data and len(data) > 0:
+ writer = csv.DictWriter(output, fieldnames=data[0].keys())
+ writer.writeheader()
+ writer.writerows(data)
+
+ return Response(
+ content=output.getvalue(),
+ media_type="text/csv",
+ headers={"Content-Disposition": "attachment; filename=fixed_price_profitability.csv"}
+ )
+
+ except Exception as e:
+ logger.error(f"❌ Error exporting CSV: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
diff --git a/app/fixed_price/frontend/detail.html b/app/fixed_price/frontend/detail.html
new file mode 100644
index 0000000..7dd1a07
--- /dev/null
+++ b/app/fixed_price/frontend/detail.html
@@ -0,0 +1,322 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}{{ agreement.agreement_number }} - Fastpris Aftale{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
📋 {{ agreement.agreement_number }}
+
{{ agreement.customer_name }}
+
+
+ {% if agreement.status == 'active' %}
+ Aktiv
+ {% elif agreement.status == 'suspended' %}
+ Suspenderet
+ {% elif agreement.status == 'expired' %}
+ Udløbet
+ {% elif agreement.status == 'cancelled' %}
+ Annulleret
+ {% endif %}
+
+
+
+
+
+
+
+
+
Månedlige Timer
+ {{ '%.0f'|format(agreement.monthly_hours or 0) }} t
+
+
+
+
+
+
+
Timepris
+ {{ '%.0f'|format(agreement.hourly_rate or 0) }} kr
+
+
+
+
+
+
+
Denne Måned
+ {{ '%.1f'|format(agreement.current_used_hours or 0) }} / {{ '%.0f'|format(agreement.monthly_hours or 0) }} t
+ {{ '%.1f'|format(agreement.current_remaining_hours or 0) }}t tilbage
+
+
+
+
+
+
+
Binding
+ {{ agreement.binding_months }} mdr
+ {% if agreement.binding_end_date %}
+ Til {{ agreement.binding_end_date }}
+ {% endif %}
+
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
Aftale Information
+
+
+ | Kunde ID: |
+ {{ agreement.customer_id }} |
+
+
+ | Kunde: |
+ {{ agreement.customer_name }} |
+
+
+ | Start Dato: |
+ {{ agreement.start_date }} |
+
+ {% if agreement.end_date %}
+
+ | Slut Dato: |
+ {{ agreement.end_date }} |
+
+ {% endif %}
+
+ | Oprettet: |
+ {{ agreement.created_at }} |
+
+
+
+
+
Priser & Vilkår
+
+
+ | Månedlige Timer: |
+ {{ '%.0f'|format(agreement.monthly_hours or 0) }} timer |
+
+
+ | Normal Timepris: |
+ {{ '%.0f'|format(agreement.hourly_rate or 0) }} kr |
+
+
+ | Overtid Timepris: |
+ {{ '%.0f'|format(agreement.overtime_rate or 0) }} kr {% if agreement.overtime_rate and agreement.hourly_rate %}({{ '%.0f'|format((agreement.overtime_rate / agreement.hourly_rate - 1) * 100) }}%){% endif %} |
+
+
+ | Afrunding: |
+ {% if agreement.rounding_minutes == 0 %}Ingen{% else %}{{ agreement.rounding_minutes }} min{% endif %} |
+
+
+ | Bindingsperiode: |
+ {{ agreement.binding_months }} måneder |
+
+
+ | Opsigelsesfrist: |
+ {{ agreement.notice_period_days }} dage |
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Periode |
+ Status |
+ Brugte Timer |
+ Resterende Timer |
+ Overtid |
+ Månedlig Værdi |
+
+
+
+ {% for period in periods %}
+
+ |
+ {{ period.period_start }} til {{ period.period_end }}
+ |
+
+ {% if period.status == 'active' %}
+ Aktiv
+ {% elif period.status == 'pending_approval' %}
+ ⚠️ Overtid
+ {% elif period.status == 'ready_to_bill' %}
+ Klar til faktura
+ {% elif period.status == 'billed' %}
+ Faktureret
+ {% endif %}
+ |
+ {{ '%.1f'|format(period.used_hours or 0) }}t |
+ {{ '%.1f'|format(period.remaining_hours or 0) }}t |
+
+ {% if period.overtime_hours and period.overtime_hours > 0 %}
+ +{{ '%.1f'|format(period.overtime_hours) }}t
+ {% else %}
+ -
+ {% endif %}
+ |
+ {{ '%.0f'|format(period.base_amount or 0) }} kr |
+
+ {% endfor %}
+
+
+
+
+
+
+
+
+
+
+
+ {% if sager %}
+
+
+
+
+ | Sag ID |
+ Titel |
+ Status |
+ Oprettet |
+ Handlinger |
+
+
+
+ {% for sag in sager %}
+
+ | #{{ sag.id }} |
+ {{ sag.titel }} |
+
+ {% if sag.status == 'open' %}
+ Åben
+ {% elif sag.status == 'in_progress' %}
+ I gang
+ {% elif sag.status == 'closed' %}
+ Lukket
+ {% endif %}
+ |
+ {{ sag.created_at.strftime('%Y-%m-%d') if sag.created_at else '-' }} |
+
+
+ Vis
+
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
+
+
+
+ {% if time_entries %}
+
+
+
+
+ | Dato |
+ Sag |
+ Beskrivelse |
+ Timer |
+ Afrundet |
+
+
+
+ {% for entry in time_entries %}
+
+ | {{ entry.created_at.strftime('%Y-%m-%d') if entry.created_at else '-' }} |
+
+ {% if entry.sag_id %}
+ #{{ entry.sag_id }}
+ {% if entry.sag_titel %}
+ {{ entry.sag_titel[:30] }}
+ {% endif %}
+ {% else %}
+ -
+ {% endif %}
+ |
+
+ {{ entry.note[:50] if entry.note else '-' }}
+ |
+ {{ '%.2f'|format(entry.approved_hours or entry.original_hours or 0) }}t |
+
+ {% if entry.rounded_to %}
+ {{ entry.rounded_to }} min
+ {% else %}
+ -
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
Ingen tidsregistreringer endnu
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/app/fixed_price/frontend/list.html b/app/fixed_price/frontend/list.html
new file mode 100644
index 0000000..b17322f
--- /dev/null
+++ b/app/fixed_price/frontend/list.html
@@ -0,0 +1,616 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Fastpris Aftaler - BMC Hub{% endblock %}
+
+{% block content %}
+
+
+
+
+
📋 Fastpris Aftaler
+
Månedlige timer aftaler med overtid håndtering
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Aftale Nr. |
+ Kunde |
+ Månedlige Timer |
+ Status |
+ Denne Måned |
+ Start |
+ Handlinger |
+
+
+
+
+ |
+
+ Loading...
+
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/app/fixed_price/frontend/reports.html b/app/fixed_price/frontend/reports.html
new file mode 100644
index 0000000..9b196a5
--- /dev/null
+++ b/app/fixed_price/frontend/reports.html
@@ -0,0 +1,285 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Fastpris Rapporter - BMC Hub{% endblock %}
+
+{% block content %}
+
+
+
+
+
+
📊 Fastpris Rapporter
+
Profitabilitet og performance analyse
+
+
+
+ {% if error %}
+
+
+ Fejl: {{ error }}
+
+ {% endif %}
+
+
+
+
+
+
+
Aktive Aftaler
+ {{ stats.active_agreements or 0 }}
+ af {{ stats.total_agreements or 0 }} total
+
+
+
+
+
+
+
Total Omsætning
+ {{ "{:,.0f}".format(stats.total_revenue or 0) }} kr
+ Månedlig værdi
+
+
+
+
+
+
+
Estimeret Profit
+ {{ "{:,.0f}".format(stats.estimated_profit or 0) }} kr
+ Ved 300 kr/t kostpris
+
+
+
+
+
+
+
Profit Margin
+ {% set profit_margin = ((stats.estimated_profit|float / stats.total_revenue|float * 100)|round(1)) if stats.total_revenue and stats.total_revenue > 0 else 0 %}
+ {{ profit_margin }}%
+ Gennemsnitlig margin
+
+
+
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if trends %}
+
+
+
+
+ | Måned |
+ Aktive Aftaler |
+ Brugte Timer |
+ Overtid Timer |
+ Total Værdi |
+ Profit |
+ Margin |
+
+
+
+ {% for trend in trends %}
+
+ | {{ trend.period_month }} |
+ {{ trend.active_agreements }} |
+ {{ '%.1f'|format(trend.total_used_hours or 0) }}t |
+
+ {% if trend.total_overtime_hours and trend.total_overtime_hours > 0 %}
+ {{ '%.1f'|format(trend.total_overtime_hours) }}t
+ {% else %}
+ -
+ {% endif %}
+ |
+ {{ "{:,.0f}".format(trend.monthly_total_revenue or 0) }} kr |
+
+ {% if trend.total_profit and trend.total_profit > 0 %}
+ {{ "{:,.0f}".format(trend.total_profit) }} kr
+ {% else %}
+ {{ "{:,.0f}".format(trend.total_profit or 0) }} kr
+ {% endif %}
+ |
+
+ {% if trend.avg_profit_margin and trend.avg_profit_margin >= 30 %}
+ {{ '%.1f'|format(trend.avg_profit_margin) }}%
+ {% elif trend.avg_profit_margin and trend.avg_profit_margin >= 15 %}
+ {{ '%.1f'|format(trend.avg_profit_margin) }}%
+ {% else %}
+ {{ '%.1f'|format(trend.avg_profit_margin or 0) }}%
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
Ingen trend data tilgængelig
+
+ {% endif %}
+
+
+
+
+
+
+
+
+
+ {% if customers %}
+
+
+
+
+ | Kunde |
+ Aftaler |
+ Total Timer |
+ Overtid |
+ Total Værdi |
+ Avg Margin |
+
+
+
+ {% for customer in customers %}
+
+ | {{ customer.customer_name }} |
+ {{ customer.agreement_count }} |
+ {{ '%.1f'|format(customer.total_used_hours or 0) }}t |
+
+ {% if customer.total_overtime_hours and customer.total_overtime_hours > 0 %}
+ {{ '%.1f'|format(customer.total_overtime_hours) }}t
+ {% else %}
+ -
+ {% endif %}
+ |
+ {{ "{:,.0f}".format(customer.total_revenue or 0) }} kr |
+
+ {% if customer.avg_profit_margin and customer.avg_profit_margin >= 30 %}
+ {{ '%.1f'|format(customer.avg_profit_margin) }}%
+ {% elif customer.avg_profit_margin and customer.avg_profit_margin >= 15 %}
+ {{ '%.1f'|format(customer.avg_profit_margin) }}%
+ {% else %}
+ {{ '%.1f'|format(customer.avg_profit_margin or 0) }}%
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+ {% else %}
+
+
+
Ingen kunde data tilgængelig
+
+ {% endif %}
+
+
+
+
+
+{% endblock %}
diff --git a/app/fixed_price/frontend/views.py b/app/fixed_price/frontend/views.py
new file mode 100644
index 0000000..ca7b6b9
--- /dev/null
+++ b/app/fixed_price/frontend/views.py
@@ -0,0 +1,220 @@
+"""
+Fixed-Price Agreement Frontend Views
+"""
+from fastapi import APIRouter, Request
+from fastapi.responses import HTMLResponse, JSONResponse
+from fastapi.templating import Jinja2Templates
+from app.core.database import execute_query
+import logging
+
+logger = logging.getLogger(__name__)
+
+router = APIRouter()
+templates = Jinja2Templates(directory="app")
+
+
+@router.get("/fixed-price-agreements", response_class=HTMLResponse)
+async def list_agreements(request: Request):
+ """List all fixed-price agreements"""
+ # Load customers for the create modal
+ try:
+ customers_query = """
+ SELECT
+ id,
+ name,
+ cvr_number,
+ email,
+ phone,
+ city,
+ is_active
+ FROM customers
+ WHERE deleted_at IS NULL
+ AND is_active = true
+ ORDER BY name
+ LIMIT 1000
+ """
+ customers = execute_query(customers_query)
+ logger.info(f"📋 Loaded {len(customers)} customers for modal")
+ except Exception as e:
+ logger.error(f"❌ Error loading customers: {e}")
+ customers = []
+
+ return templates.TemplateResponse("fixed_price/frontend/list.html", {
+ "request": request,
+ "customers": customers
+ })
+
+
+@router.get("/fixed-price-agreements/{agreement_id}", response_class=HTMLResponse)
+async def agreement_detail(request: Request, agreement_id: int):
+ """Agreement detail page with periods and related sager"""
+ from fastapi import HTTPException
+
+ try:
+ # Fetch agreement
+ agr_query = """
+ SELECT a.*,
+ COALESCE(p.used_hours, 0) as current_used_hours,
+ COALESCE(p.remaining_hours, a.monthly_hours) as current_remaining_hours,
+ p.status as current_period_status
+ FROM customer_fixed_price_agreements a
+ LEFT JOIN fixed_price_billing_periods p ON p.agreement_id = a.id
+ AND p.period_start <= CURRENT_DATE
+ AND p.period_end >= CURRENT_DATE
+ WHERE a.id = %s
+ """
+ agreement = execute_query(agr_query, (agreement_id,))
+
+ if not agreement:
+ raise HTTPException(status_code=404, detail="Aftale ikke fundet")
+
+ agreement = agreement[0]
+
+ # Fetch all billing periods
+ periods_query = """
+ SELECT * FROM fixed_price_billing_periods
+ WHERE agreement_id = %s
+ ORDER BY period_start DESC
+ """
+ periods = execute_query(periods_query, (agreement_id,))
+
+ # Fetch related sager
+ sager_query = """
+ SELECT DISTINCT s.id, s.titel, s.status, s.created_at
+ FROM sag_sager s
+ INNER JOIN tmodule_times t ON t.sag_id = s.id
+ WHERE t.fixed_price_agreement_id = %s
+ AND s.deleted_at IS NULL
+ ORDER BY s.created_at DESC
+ LIMIT 50
+ """
+ sager = execute_query(sager_query, (agreement_id,))
+
+ # Fetch time entries
+ time_query = """
+ SELECT t.*, s.titel as sag_titel
+ FROM tmodule_times t
+ LEFT JOIN sag_sager s ON s.id = t.sag_id
+ WHERE t.fixed_price_agreement_id = %s
+ ORDER BY t.created_at DESC
+ LIMIT 100
+ """
+ time_entries = execute_query(time_query, (agreement_id,))
+
+ return templates.TemplateResponse("fixed_price/frontend/detail.html", {
+ "request": request,
+ "agreement": agreement,
+ "periods": periods,
+ "sager": sager,
+ "time_entries": time_entries
+ })
+
+ except Exception as e:
+ logger.error(f"❌ Error loading agreement detail: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/fixed-price-agreements/reports/dashboard", response_class=HTMLResponse)
+async def reports_dashboard(request: Request):
+ """Reporting dashboard with profitability analysis"""
+ try:
+ # Get summary stats
+ stats_query = """
+ SELECT
+ COUNT(*) FILTER (WHERE status = 'active') as active_agreements,
+ COUNT(*) as total_agreements,
+ SUM(monthly_hours * hourly_rate) as total_revenue,
+ SUM(monthly_hours * (hourly_rate - 300)) as estimated_profit
+ FROM customer_fixed_price_agreements
+ """
+ stats = execute_query(stats_query)
+
+ # Get performance data from view
+ performance_query = """
+ SELECT
+ *,
+ CASE
+ WHEN total_revenue > 0 THEN (total_profit / total_revenue * 100)
+ ELSE 0
+ END as profit_margin
+ FROM fixed_price_agreement_performance
+ ORDER BY total_profit DESC
+ LIMIT 50
+ """
+ performance = execute_query(performance_query)
+
+ # Get monthly trends
+ trends_query = """
+ SELECT
+ *,
+ month as period_month,
+ CASE
+ WHEN monthly_total_revenue > 0 THEN (monthly_profit / monthly_total_revenue * 100)
+ ELSE 0
+ END as avg_profit_margin
+ FROM fixed_price_monthly_trends
+ ORDER BY month DESC
+ LIMIT 12
+ """
+ trends = execute_query(trends_query)
+
+ # Get customer breakdown
+ customer_query = """
+ SELECT
+ *,
+ total_hours_used as total_used_hours,
+ CASE
+ WHEN total_revenue > 0 THEN (total_profit / total_revenue * 100)
+ ELSE 0
+ END as avg_profit_margin
+ FROM fixed_price_customer_summary
+ ORDER BY total_used_hours DESC
+ LIMIT 20
+ """
+ customers = execute_query(customer_query)
+
+ return templates.TemplateResponse("fixed_price/frontend/reports.html", {
+ "request": request,
+ "stats": stats[0] if stats else {},
+ "performance": performance,
+ "trends": trends,
+ "customers": customers
+ })
+
+ except Exception as e:
+ logger.error(f"❌ Error loading reports: {e}")
+ return templates.TemplateResponse("fixed_price/frontend/reports.html", {
+ "request": request,
+ "stats": {},
+ "performance": [],
+ "trends": [],
+ "customers": [],
+ "error": str(e)
+ })
+
+
+@router.get("/api/fixed-price-agreements/customers")
+async def get_customers_for_agreements():
+ """Get all active customers for fixed-price agreement creation"""
+ try:
+ query = """
+ SELECT
+ id,
+ name,
+ cvr_number,
+ email,
+ phone,
+ city,
+ is_active
+ FROM customers
+ WHERE deleted_at IS NULL
+ AND is_active = true
+ ORDER BY name
+ LIMIT 1000
+ """
+ customers = execute_query(query)
+ logger.info(f"📋 Loaded {len(customers)} customers for fixed-price agreements")
+ return customers
+ except Exception as e:
+ logger.error(f"❌ Error loading customers: {e}")
+ return []
diff --git a/app/modules/locations/frontend/views.py b/app/modules/locations/frontend/views.py
index 011fe66..3128551 100644
--- a/app/modules/locations/frontend/views.py
+++ b/app/modules/locations/frontend/views.py
@@ -18,12 +18,12 @@ Each view:
"""
from fastapi import APIRouter, Query, HTTPException, Path, Request
-from fastapi.responses import HTMLResponse
+from fastapi.responses import HTMLResponse, RedirectResponse
from jinja2 import Environment, FileSystemLoader, TemplateNotFound
from pathlib import Path as PathlibPath
-import requests
import logging
from typing import Optional
+from app.core.database import execute_query, execute_update
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -43,9 +43,7 @@ env = Environment(
lstrip_blocks=True
)
-# Backend API base URL
-# Inside container: localhost:8000, externally: localhost:8001
-API_BASE_URL = "http://localhost:8000"
+# Use direct database access instead of API calls to avoid auth issues
# Location type options for dropdowns
LOCATION_TYPES = [
@@ -83,36 +81,6 @@ def render_template(template_name: str, **context) -> str:
raise HTTPException(status_code=500, detail=f"Error rendering template: {str(e)}")
-def call_api(method: str, endpoint: str, **kwargs) -> dict:
- """
- Call backend API endpoint using requests (synchronous).
-
- Args:
- method: HTTP method (GET, POST, PATCH, DELETE)
- endpoint: API endpoint path (e.g., "/api/v1/locations")
- **kwargs: Additional arguments for requests call (params, json, etc.)
-
- Returns:
- Response JSON or dict
-
- Raises:
- HTTPException: If API call fails
- """
- try:
- url = f"{API_BASE_URL}{endpoint}" if not endpoint.startswith("http") else endpoint
- response = requests.request(method, url, timeout=30, **kwargs)
- response.raise_for_status()
- return response.json()
- except requests.exceptions.HTTPError as e:
- if e.response.status_code == 404:
- logger.warning(f"⚠️ API 404: {method} {endpoint}")
- raise HTTPException(status_code=404, detail="Resource not found")
- logger.error(f"❌ API error {e.response.status_code}: {method} {endpoint}")
- raise HTTPException(status_code=500, detail=f"API error: {e.response.status_code}")
- except requests.exceptions.RequestException as e:
- logger.error(f"❌ API call failed {method} {endpoint}: {str(e)}")
- raise HTTPException(status_code=500, detail=f"API connection error: {str(e)}")
-
def calculate_pagination(total: int, limit: int, skip: int) -> dict:
"""
@@ -147,7 +115,7 @@ def calculate_pagination(total: int, limit: int, skip: int) -> dict:
@router.get("/app/locations", response_class=HTMLResponse)
def list_locations_view(
location_type: Optional[str] = Query(None, description="Filter by type"),
- is_active: Optional[bool] = Query(None, description="Filter by active status"),
+ is_active: Optional[str] = Query(None, description="Filter by active status"),
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100)
):
@@ -169,18 +137,35 @@ def list_locations_view(
try:
logger.info(f"🔍 Rendering locations list view (skip={skip}, limit={limit})")
- # Build API call parameters
- params = {
- "skip": skip,
- "limit": limit,
- }
- if location_type:
- params["location_type"] = location_type
- if is_active is not None:
- params["is_active"] = is_active
+ # Convert is_active from string to boolean or None
+ is_active_bool = None
+ if is_active and is_active.lower() in ('true', '1', 'yes'):
+ is_active_bool = True
+ elif is_active and is_active.lower() in ('false', '0', 'no'):
+ is_active_bool = False
- # Call backend API to get locations
- locations = call_api("GET", "/api/v1/locations", params=params)
+ # Query locations directly from database
+ where_clauses = []
+ query_params = []
+
+ if location_type:
+ where_clauses.append("location_type = %s")
+ query_params.append(location_type)
+ if is_active_bool is not None:
+ where_clauses.append("is_active = %s")
+ query_params.append(is_active_bool)
+
+ where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
+
+ query = f"""
+ SELECT * FROM locations_locations
+ WHERE {where_sql}
+ ORDER BY name
+ LIMIT %s OFFSET %s
+ """
+ query_params.extend([limit, skip])
+
+ locations = execute_query(query, tuple(query_params))
def build_tree(items: list) -> list:
nodes = {}
@@ -234,7 +219,7 @@ def list_locations_view(
skip=skip,
limit=limit,
location_type=location_type,
- is_active=is_active,
+ is_active=is_active_bool, # Use boolean value for template
page_number=pagination["page_number"],
total_pages=pagination["total_pages"],
has_prev=pagination["has_prev"],
@@ -281,53 +266,23 @@ def create_location_view():
try:
logger.info("🆕 Rendering create location form")
- parent_locations = call_api(
- "GET",
- "/api/v1/locations",
- params={"skip": 0, "limit": 1000}
- )
+ # Query parent locations
+ parent_locations = execute_query("""
+ SELECT id, name, location_type
+ FROM locations_locations
+ WHERE is_active = true
+ ORDER BY name
+ LIMIT 1000
+ """)
- customers = call_api(
- "GET",
- "/api/v1/customers",
- params={"offset": 0, "limit": 1000}
- )
-
- customers = call_api(
- "GET",
- "/api/v1/customers",
- params={"offset": 0, "limit": 1000}
- )
-
- customers = call_api(
- "GET",
- "/api/v1/customers",
- params={"offset": 0, "limit": 1000}
- )
-
- customers = call_api(
- "GET",
- "/api/v1/customers",
- params={"offset": 0, "limit": 1000}
- )
-
- customers = call_api(
- "GET",
- "/api/v1/customers",
- params={"offset": 0, "limit": 1000}
- )
-
- customers = call_api(
- "GET",
- "/api/v1/customers",
- params={"offset": 0, "limit": 1000}
- )
-
- customers = call_api(
- "GET",
- "/api/v1/customers",
- params={"offset": 0, "limit": 1000}
- )
+ # Query customers
+ customers = execute_query("""
+ SELECT id, name, email, phone
+ FROM customers
+ WHERE deleted_at IS NULL AND is_active = true
+ ORDER BY name
+ LIMIT 1000
+ """)
# Render template with context
html = render_template(
@@ -374,19 +329,27 @@ def detail_location_view(id: int = Path(..., gt=0)):
try:
logger.info(f"📍 Rendering detail view for location {id}")
- # Call backend API to get location details
- location = call_api("GET", f"/api/v1/locations/{id}")
-
- customers = call_api(
- "GET",
- "/api/v1/customers",
- params={"offset": 0, "limit": 1000}
+ # Query location details directly
+ location = execute_query(
+ "SELECT * FROM locations_locations WHERE id = %s",
+ (id,)
)
if not location:
logger.warning(f"⚠️ Location {id} not found")
raise HTTPException(status_code=404, detail=f"Location {id} not found")
+ location = location[0] # Get first result
+
+ # Query customers
+ customers = execute_query("""
+ SELECT id, name, email, phone
+ FROM customers
+ WHERE deleted_at IS NULL AND is_active = true
+ ORDER BY name
+ LIMIT 1000
+ """)
+
# Optionally fetch related data if available from API
# contacts = call_api("GET", f"/api/v1/locations/{id}/contacts")
# hours = call_api("GET", f"/api/v1/locations/{id}/hours")
@@ -429,30 +392,36 @@ def edit_location_view(id: int = Path(..., gt=0)):
try:
logger.info(f"✏️ Rendering edit form for location {id}")
- # Call backend API to get current location data
- location = call_api("GET", f"/api/v1/locations/{id}")
-
- parent_locations = call_api(
- "GET",
- "/api/v1/locations",
- params={"skip": 0, "limit": 1000}
- )
-
- parent_locations = [
- loc for loc in parent_locations
- if isinstance(loc, dict) and loc.get("id") != id
- ]
-
- customers = call_api(
- "GET",
- "/api/v1/customers",
- params={"offset": 0, "limit": 1000}
+ # Query location details
+ location = execute_query(
+ "SELECT * FROM locations_locations WHERE id = %s",
+ (id,)
)
if not location:
logger.warning(f"⚠️ Location {id} not found for edit")
raise HTTPException(status_code=404, detail=f"Location {id} not found")
+ location = location[0] # Get first result
+
+ # Query parent locations (exclude self)
+ parent_locations = execute_query("""
+ SELECT id, name, location_type
+ FROM locations_locations
+ WHERE is_active = true AND id != %s
+ ORDER BY name
+ LIMIT 1000
+ """, (id,))
+
+ # Query customers
+ customers = execute_query("""
+ SELECT id, name, email, phone
+ FROM customers
+ WHERE deleted_at IS NULL AND is_active = true
+ ORDER BY name
+ LIMIT 1000
+ """)
+
# Render template with context
# Note: HTML forms don't support PATCH, so we use POST with a hidden _method field
html = render_template(
@@ -487,24 +456,44 @@ async def update_location_view(request: Request, id: int = Path(..., gt=0)):
"""Handle edit form submission and redirect to detail page."""
try:
form = await request.form()
- payload = {
- "name": form.get("name"),
- "location_type": form.get("location_type"),
- "parent_location_id": int(form.get("parent_location_id")) if form.get("parent_location_id") else None,
- "customer_id": int(form.get("customer_id")) if form.get("customer_id") else None,
- "is_active": form.get("is_active") == "on",
- "address_street": form.get("address_street"),
- "address_city": form.get("address_city"),
- "address_postal_code": form.get("address_postal_code"),
- "address_country": form.get("address_country"),
- "phone": form.get("phone"),
- "email": form.get("email"),
- "latitude": float(form.get("latitude")) if form.get("latitude") else None,
- "longitude": float(form.get("longitude")) if form.get("longitude") else None,
- "notes": form.get("notes"),
- }
-
- call_api("PATCH", f"/api/v1/locations/{id}", json=payload)
+
+ # Update location directly in database
+ execute_update("""
+ UPDATE locations_locations SET
+ name = %s,
+ location_type = %s,
+ parent_location_id = %s,
+ customer_id = %s,
+ is_active = %s,
+ address_street = %s,
+ address_city = %s,
+ address_postal_code = %s,
+ address_country = %s,
+ phone = %s,
+ email = %s,
+ latitude = %s,
+ longitude = %s,
+ notes = %s,
+ updated_at = CURRENT_TIMESTAMP
+ WHERE id = %s
+ """, (
+ form.get("name"),
+ form.get("location_type"),
+ int(form.get("parent_location_id")) if form.get("parent_location_id") else None,
+ int(form.get("customer_id")) if form.get("customer_id") else None,
+ form.get("is_active") == "on",
+ form.get("address_street"),
+ form.get("address_city"),
+ form.get("address_postal_code"),
+ form.get("address_country"),
+ form.get("phone"),
+ form.get("email"),
+ float(form.get("latitude")) if form.get("latitude") else None,
+ float(form.get("longitude")) if form.get("longitude") else None,
+ form.get("notes"),
+ id
+ ))
+
return RedirectResponse(url=f"/app/locations/{id}", status_code=303)
except HTTPException:
@@ -535,16 +524,24 @@ def map_locations_view(
try:
logger.info("🗺️ Rendering map view")
- # Build API call parameters
- params = {
- "skip": 0,
- "limit": 1000, # Get all locations for map
- }
- if location_type:
- params["location_type"] = location_type
+ # Query all locations with filters
+ where_clauses = []
+ query_params = []
- # Call backend API to get all locations
- locations = call_api("GET", "/api/v1/locations", params=params)
+ if location_type:
+ where_clauses.append("location_type = %s")
+ query_params.append(location_type)
+
+ where_sql = " AND ".join(where_clauses) if where_clauses else "1=1"
+
+ query = f"""
+ SELECT * FROM locations_locations
+ WHERE {where_sql}
+ ORDER BY name
+ LIMIT 1000
+ """
+
+ locations = execute_query(query, tuple(query_params) if query_params else None)
# Filter to locations with coordinates
locations_with_coords = [
diff --git a/app/modules/sag/frontend/views.py b/app/modules/sag/frontend/views.py
index 2f20922..7b5df52 100644
--- a/app/modules/sag/frontend/views.py
+++ b/app/modules/sag/frontend/views.py
@@ -303,16 +303,42 @@ async def sag_detaljer(request: Request, sag_id: int):
logger.info(f"🔎 Looking up prepaid cards for Sag {sag_id}, Customer ID: {cid} (Type: {type(cid)})")
pc_query = """
- SELECT id, card_number, CAST(remaining_hours AS FLOAT) as remaining_hours
+ SELECT id, card_number, CAST(remaining_hours AS FLOAT) as remaining_hours, expires_at
FROM tticket_prepaid_cards
- WHERE customer_id = %s
- AND status = 'active'
+ WHERE customer_id = %s
+ AND status = 'active'
AND remaining_hours > 0
ORDER BY created_at DESC
"""
prepaid_cards = execute_query(pc_query, (cid,))
logger.info(f"💳 Found {len(prepaid_cards)} prepaid cards for customer {cid}")
+ # Fetch fixed-price agreements for customer
+ fixed_price_agreements = []
+ if sag.get('customer_id'):
+ cid = sag.get('customer_id')
+ logger.info(f"🔎 Looking up fixed-price agreements for Sag {sag_id}, Customer ID: {cid}")
+
+ fpa_query = """
+ SELECT
+ a.id,
+ a.agreement_number,
+ a.monthly_hours,
+ COALESCE(bp.remaining_hours, a.monthly_hours) as remaining_hours_this_month
+ FROM customer_fixed_price_agreements a
+ LEFT JOIN fixed_price_billing_periods bp ON (
+ a.id = bp.agreement_id
+ AND bp.period_start <= CURRENT_DATE
+ AND bp.period_end >= CURRENT_DATE
+ )
+ WHERE a.customer_id = %s
+ AND a.status = 'active'
+ AND (a.end_date IS NULL OR a.end_date >= CURRENT_DATE)
+ ORDER BY a.created_at DESC
+ """
+ fixed_price_agreements = execute_query(fpa_query, (cid,))
+ logger.info(f"📋 Found {len(fixed_price_agreements)} fixed-price agreements for customer {cid}")
+
# Fetch Nextcloud Instance for this customer
nextcloud_instance = None
if customer:
@@ -397,6 +423,7 @@ async def sag_detaljer(request: Request, sag_id: int):
"contacts": contacts,
"customers": customers,
"prepaid_cards": prepaid_cards,
+ "fixed_price_agreements": fixed_price_agreements,
"tags": tags,
"relationer": relationer,
diff --git a/app/modules/sag/templates/detail.html b/app/modules/sag/templates/detail.html
index 557fb1b..15f466c 100644
--- a/app/modules/sag/templates/detail.html
+++ b/app/modules/sag/templates/detail.html
@@ -1909,6 +1909,21 @@
+
+
Klippekort
+ {% if prepaid_cards %}
+
+ {% for card in prepaid_cards %}
+
+ #{{ card.card_number or card.id }}
+ {{ '%.2f' % card.remaining_hours }}t
+
+ {% endfor %}
+
+ {% else %}
+
Ingen aktive klippekort
+ {% endif %}
+
@@ -2987,6 +3002,74 @@
+
+
+
+
+
+
@@ -3147,12 +3230,19 @@
const billingSelect = document.getElementById('time_billing_method');
let billingMethod = billingSelect ? billingSelect.value : 'invoice';
let prepaidCardId = null;
+ let fixedPriceAgreementId = null;
// Handle prepaid card selection formatting (card_123)
if (billingMethod.startsWith('card_')) {
prepaidCardId = parseInt(billingMethod.split('_')[1]);
billingMethod = 'prepaid';
}
+
+ // Handle fixed-price agreement selection formatting (fpa_123)
+ if (billingMethod.startsWith('fpa_')) {
+ fixedPriceAgreementId = parseInt(billingMethod.split('_')[1]);
+ billingMethod = 'fixed_price';
+ }
const workTypeSelect = document.getElementById('time_work_type');
const internalCheck = document.getElementById('time_internal');
@@ -3170,6 +3260,10 @@
if (prepaidCardId) {
data.prepaid_card_id = prepaidCardId;
}
+
+ if (fixedPriceAgreementId) {
+ data.fixed_price_agreement_id = fixedPriceAgreementId;
+ }
try {
const res = await fetch(`/api/v1/timetracking/entries/internal`, {
diff --git a/app/prepaid/backend/router.py b/app/prepaid/backend/router.py
index 1123ece..f9fa97d 100644
--- a/app/prepaid/backend/router.py
+++ b/app/prepaid/backend/router.py
@@ -3,6 +3,7 @@ from app.core.database import execute_query
from typing import List, Optional, Dict, Any
from pydantic import BaseModel
from datetime import datetime, date
+from decimal import Decimal, ROUND_CEILING
import logging
logger = logging.getLogger(__name__)
@@ -25,6 +26,7 @@ class PrepaidCard(BaseModel):
economic_invoice_number: Optional[str] = None
economic_product_number: Optional[str] = None
notes: Optional[str] = None
+ rounding_minutes: Optional[int] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@@ -34,6 +36,26 @@ class PrepaidCardCreate(BaseModel):
price_per_hour: float
expires_at: Optional[date] = None
notes: Optional[str] = None
+ rounding_minutes: Optional[int] = None
+
+
+class PrepaidCardRoundingUpdate(BaseModel):
+ rounding_minutes: int
+
+
+def _normalize_rounding_minutes(value: Optional[int]) -> int:
+ if value is None:
+ return 0
+ return int(value)
+
+
+def _apply_rounding(hours: float, rounding_minutes: int) -> float:
+ if rounding_minutes <= 0:
+ return float(hours)
+ interval = Decimal(rounding_minutes) / Decimal(60)
+ raw = Decimal(str(hours))
+ rounded = (raw / interval).to_integral_value(rounding=ROUND_CEILING) * interval
+ return float(rounded)
@router.get("/prepaid-cards", response_model=List[Dict[str, Any]])
@@ -65,6 +87,46 @@ async def get_prepaid_cards(status: Optional[str] = None, customer_id: Optional[
query += " ORDER BY pc.created_at DESC"
cards = execute_query(query, tuple(params) if params else None)
+
+ # Recalculate used_hours and remaining_hours from actual timelogs for each card
+ for card in cards or []:
+ card_id = card['id']
+ rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
+
+ # Get sag timelogs
+ sag_logs = execute_query("""
+ SELECT original_hours, approved_hours
+ FROM tmodule_times
+ WHERE prepaid_card_id = %s
+ """, (card_id,))
+
+ # Get ticket timelogs
+ ticket_logs = execute_query("""
+ SELECT hours, rounded_hours
+ FROM tticket_worklog
+ WHERE prepaid_card_id = %s
+ """, (card_id,))
+
+ # Calculate total rounded hours
+ total_rounded = 0.0
+ for log in sag_logs or []:
+ actual = float(log['original_hours'])
+ if log.get('approved_hours'):
+ total_rounded += float(log['approved_hours'])
+ else:
+ total_rounded += _apply_rounding(actual, rounding_minutes)
+
+ for log in ticket_logs or []:
+ if log.get('rounded_hours'):
+ total_rounded += float(log['rounded_hours'])
+ else:
+ total_rounded += float(log['hours'])
+
+ # Override with calculated values
+ purchased = float(card['purchased_hours'])
+ card['used_hours'] = total_rounded
+ card['remaining_hours'] = purchased - total_rounded
+
logger.info(f"✅ Retrieved {len(cards) if cards else 0} prepaid cards")
return cards or []
@@ -93,6 +155,8 @@ async def get_prepaid_card(card_id: int):
raise HTTPException(status_code=404, detail="Prepaid card not found")
card = result[0]
+
+ rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
# Get transactions
transactions = execute_query("""
@@ -108,6 +172,92 @@ async def get_prepaid_card(card_id: int):
""", (card_id,))
card['transactions'] = transactions or []
+
+ # Timelogs from Sag + Ticket worklog (all entries tied to this prepaid card)
+ sag_logs = execute_query("""
+ SELECT
+ tm.id,
+ tm.sag_id,
+ tm.worked_date,
+ tm.description,
+ tm.original_hours,
+ tm.approved_hours,
+ tm.created_at,
+ tm.user_name,
+ s.titel AS sag_title
+ FROM tmodule_times tm
+ LEFT JOIN sag_sager s ON tm.sag_id = s.id
+ WHERE tm.prepaid_card_id = %s
+ """, (card_id,))
+
+ ticket_logs = execute_query("""
+ SELECT
+ w.id,
+ w.ticket_id,
+ w.work_date,
+ w.description,
+ w.hours,
+ w.rounded_hours,
+ w.created_at,
+ t.subject AS ticket_title,
+ t.ticket_number
+ FROM tticket_worklog w
+ LEFT JOIN tticket_tickets t ON w.ticket_id = t.id
+ WHERE w.prepaid_card_id = %s
+ """, (card_id,))
+
+ timelogs = []
+ for log in sag_logs or []:
+ actual_hours = float(log['original_hours'])
+ # Use stored approved_hours if available, else calculate
+ rounded_hours = float(log.get('approved_hours') or 0) if log.get('approved_hours') else _apply_rounding(actual_hours, rounding_minutes)
+ timelogs.append({
+ "source": "sag",
+ "source_id": log.get("sag_id"),
+ "source_title": log.get("sag_title"),
+ "worked_date": log.get("worked_date"),
+ "created_at": log.get("created_at"),
+ "description": log.get("description"),
+ "user_name": log.get("user_name"),
+ "actual_hours": actual_hours,
+ "rounded_hours": rounded_hours
+ })
+
+ for log in ticket_logs or []:
+ actual_hours = float(log['hours'])
+ # Use stored rounded_hours if available, else use actual
+ rounded_hours = float(log.get('rounded_hours') or 0) if log.get('rounded_hours') else actual_hours
+ ticket_label = log.get("ticket_number") or log.get("ticket_id")
+ timelogs.append({
+ "source": "ticket",
+ "source_id": log.get("ticket_id"),
+ "source_title": log.get("ticket_title") or "Ticket",
+ "ticket_number": ticket_label,
+ "worked_date": log.get("work_date"),
+ "created_at": log.get("created_at"),
+ "description": log.get("description"),
+ "user_name": None,
+ "actual_hours": actual_hours,
+ "rounded_hours": rounded_hours
+ })
+
+ timelogs.sort(
+ key=lambda item: (
+ item.get("worked_date") or date.min,
+ item.get("created_at") or datetime.min
+ ),
+ reverse=True
+ )
+
+ # Calculate actual totals from timelogs (sum of rounded hours)
+ total_rounded_hours = sum(log['rounded_hours'] for log in timelogs)
+ purchased_hours = float(card['purchased_hours'])
+
+ # Override DB values with calculated values based on actual timelogs
+ card['used_hours'] = total_rounded_hours
+ card['remaining_hours'] = purchased_hours - total_rounded_hours
+ card['timelogs'] = timelogs
+ card['rounding_minutes'] = rounding_minutes
return card
@@ -126,6 +276,10 @@ async def create_prepaid_card(card: PrepaidCardCreate):
Note: As of migration 065, customers can have multiple active cards simultaneously.
"""
try:
+ rounding_minutes = _normalize_rounding_minutes(card.rounding_minutes)
+ if rounding_minutes not in (0, 15, 30, 60):
+ raise HTTPException(status_code=400, detail="Invalid rounding minutes")
+
# Calculate total amount
total_amount = card.purchased_hours * card.price_per_hour
@@ -139,8 +293,8 @@ async def create_prepaid_card(card: PrepaidCardCreate):
with conn.cursor(cursor_factory=RealDictCursor) as cursor:
cursor.execute("""
INSERT INTO tticket_prepaid_cards
- (customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes)
- VALUES (%s, %s, %s, %s, %s, %s)
+ (customer_id, purchased_hours, price_per_hour, total_amount, expires_at, notes, rounding_minutes)
+ VALUES (%s, %s, %s, %s, %s, %s, %s)
RETURNING *
""", (
card.customer_id,
@@ -149,6 +303,7 @@ async def create_prepaid_card(card: PrepaidCardCreate):
total_amount,
card.expires_at,
card.notes
+ , rounding_minutes
))
conn.commit()
result = cursor.fetchone()
@@ -207,6 +362,38 @@ async def update_card_status(card_id: int, status: str):
raise HTTPException(status_code=500, detail=str(e))
+@router.put("/prepaid-cards/{card_id}/rounding", response_model=Dict[str, Any])
+async def update_card_rounding(card_id: int, payload: PrepaidCardRoundingUpdate):
+ """
+ Update rounding interval for a prepaid card (minutes)
+ """
+ try:
+ rounding_minutes = _normalize_rounding_minutes(payload.rounding_minutes)
+ if rounding_minutes not in (0, 15, 30, 60):
+ raise HTTPException(status_code=400, detail="Invalid rounding minutes")
+
+ result = execute_query(
+ """
+ UPDATE tticket_prepaid_cards
+ SET rounding_minutes = %s
+ WHERE id = %s
+ RETURNING *
+ """,
+ (rounding_minutes, card_id)
+ )
+
+ if not result:
+ raise HTTPException(status_code=404, detail="Card not found")
+
+ return result[0]
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Error updating rounding for card {card_id}: {e}", exc_info=True)
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.delete("/prepaid-cards/{card_id}")
async def delete_prepaid_card(card_id: int):
"""
@@ -240,23 +427,78 @@ async def delete_prepaid_card(card_id: int):
@router.get("/prepaid-cards/stats/summary", response_model=Dict[str, Any])
async def get_prepaid_stats():
"""
- Get prepaid cards statistics
+ Get prepaid cards statistics (calculated from actual timelogs)
"""
try:
- result = execute_query("""
- SELECT
- COUNT(*) FILTER (WHERE status = 'active') as active_count,
- COUNT(*) FILTER (WHERE status = 'depleted') as depleted_count,
- COUNT(*) FILTER (WHERE status = 'expired') as expired_count,
- COUNT(*) FILTER (WHERE status = 'cancelled') as cancelled_count,
- COALESCE(SUM(remaining_hours) FILTER (WHERE status = 'active'), 0) as total_remaining_hours,
- COALESCE(SUM(used_hours), 0) as total_used_hours,
- COALESCE(SUM(purchased_hours), 0) as total_purchased_hours,
- COALESCE(SUM(total_amount), 0) as total_revenue
+ # Get all cards
+ cards = execute_query("""
+ SELECT id, status, purchased_hours, total_amount, rounding_minutes
FROM tticket_prepaid_cards
""")
- return result[0] if result and len(result) > 0 else {}
+ stats = {
+ 'active_count': 0,
+ 'depleted_count': 0,
+ 'expired_count': 0,
+ 'cancelled_count': 0,
+ 'total_remaining_hours': 0.0,
+ 'total_used_hours': 0.0,
+ 'total_purchased_hours': 0.0,
+ 'total_revenue': 0.0
+ }
+
+ for card in cards or []:
+ card_id = card['id']
+ status = card['status']
+ purchased = float(card['purchased_hours'])
+ rounding_minutes = _normalize_rounding_minutes(card.get('rounding_minutes'))
+
+ # Count by status
+ if status == 'active':
+ stats['active_count'] += 1
+ elif status == 'depleted':
+ stats['depleted_count'] += 1
+ elif status == 'expired':
+ stats['expired_count'] += 1
+ elif status == 'cancelled':
+ stats['cancelled_count'] += 1
+
+ # Calculate actual used hours from timelogs
+ sag_logs = execute_query("""
+ SELECT original_hours, approved_hours
+ FROM tmodule_times
+ WHERE prepaid_card_id = %s
+ """, (card_id,))
+
+ ticket_logs = execute_query("""
+ SELECT hours, rounded_hours
+ FROM tticket_worklog
+ WHERE prepaid_card_id = %s
+ """, (card_id,))
+
+ used = 0.0
+ for log in sag_logs or []:
+ if log.get('approved_hours'):
+ used += float(log['approved_hours'])
+ else:
+ used += _apply_rounding(float(log['original_hours']), rounding_minutes)
+
+ for log in ticket_logs or []:
+ if log.get('rounded_hours'):
+ used += float(log['rounded_hours'])
+ else:
+ used += float(log['hours'])
+
+ remaining = purchased - used
+
+ stats['total_purchased_hours'] += purchased
+ stats['total_used_hours'] += used
+ stats['total_revenue'] += float(card['total_amount'])
+
+ if status == 'active':
+ stats['total_remaining_hours'] += remaining
+
+ return stats
except Exception as e:
logger.error(f"❌ Error fetching prepaid stats: {e}", exc_info=True)
diff --git a/app/prepaid/frontend/detail.html b/app/prepaid/frontend/detail.html
index 6e6923b..0c1253c 100644
--- a/app/prepaid/frontend/detail.html
+++ b/app/prepaid/frontend/detail.html
@@ -92,24 +92,24 @@
-
+
-
+
| Dato |
- Ticket |
+ Sag / Ticket |
Beskrivelse |
- Timer |
- Beløb |
+ Faktisk tid |
+ Afrundet |
-
+
|
@@ -118,6 +118,13 @@
|
+
+
+ | I alt: |
+ - |
+ - |
+
+
@@ -211,6 +218,18 @@ async function loadCardDetails() {
${parseFloat(card.price_per_hour).toFixed(2)} kr
+
+
+
+
+
+
+
${new Date(card.purchased_at).toLocaleDateString('da-DK')}
@@ -245,8 +264,13 @@ async function loadCardDetails() {
document.getElementById('actionButtons').innerHTML = actions.join('') ||
'
Ingen handlinger tilgængelige
';
- // Render transactions
- renderTransactions(card.transactions || []);
+ const roundingSelect = document.getElementById('roundingMinutes');
+ if (roundingSelect) {
+ roundingSelect.value = String(card.rounding_minutes || 0);
+ }
+
+ // Render timelogs
+ renderTimelogs(card.timelogs || []);
} catch (error) {
console.error('Error loading card:', error);
@@ -262,32 +286,53 @@ async function loadCardDetails() {
}
}
-function renderTransactions(transactions) {
- const tbody = document.getElementById('transactionsBody');
+function renderTimelogs(timelogs) {
+ const tbody = document.getElementById('timelogsBody');
- if (!transactions || transactions.length === 0) {
+ if (!timelogs || timelogs.length === 0) {
tbody.innerHTML = `
|
- Ingen transaktioner endnu
+ Ingen timelogs endnu
|
`;
+ document.getElementById('totalActualHours').textContent = '0.00 t';
+ document.getElementById('totalRoundedHours').textContent = '0.00 t';
return;
}
- tbody.innerHTML = transactions.map(t => `
-
- | ${new Date(t.created_at).toLocaleDateString('da-DK')} |
-
- ${t.ticket_id ?
- `
- #${t.ticket_id} - ${t.ticket_title || 'Ticket'}
- ` : '-'}
- |
- ${t.description || '-'} |
- ${parseFloat(t.hours_used).toFixed(2)} t |
- ${parseFloat(t.amount).toFixed(2)} kr |
-
- `).join('');
+ let totalActual = 0;
+ let totalRounded = 0;
+
+ tbody.innerHTML = timelogs.map(t => {
+ const dateValue = t.worked_date || t.created_at;
+ const dateText = dateValue ? new Date(dateValue).toLocaleDateString('da-DK') : '-';
+ let sourceHtml = '-';
+ if (t.source === 'sag' && t.source_id) {
+ sourceHtml = `
Sag #${t.source_id}${t.source_title ? ' - ' + t.source_title : ''}`;
+ } else if (t.source === 'ticket' && t.source_id) {
+ const ticketLabel = t.ticket_number ? `#${t.ticket_number}` : `#${t.source_id}`;
+ sourceHtml = `
${ticketLabel} - ${t.source_title || 'Ticket'}`;
+ }
+
+ const actual = parseFloat(t.actual_hours) || 0;
+ const rounded = parseFloat(t.rounded_hours) || 0;
+ totalActual += actual;
+ totalRounded += rounded;
+
+ return `
+
+ | ${dateText} |
+ ${sourceHtml} |
+ ${t.description || '-'} |
+ ${actual.toFixed(2)} t |
+ ${rounded.toFixed(2)} t |
+
+ `;
+ }).join('');
+
+ // Update totals in footer
+ document.getElementById('totalActualHours').textContent = totalActual.toFixed(2) + ' t';
+ document.getElementById('totalRoundedHours').textContent = totalRounded.toFixed(2) + ' t';
}
function getStatusBadge(status) {
@@ -322,6 +367,28 @@ async function cancelCard() {
alert('❌ Fejl: ' + error.message);
}
}
+
+async function saveRounding() {
+ const select = document.getElementById('roundingMinutes');
+ if (!select) return;
+ const roundingMinutes = parseInt(select.value, 10) || 0;
+
+ try {
+ const response = await fetch(`/api/v1/prepaid-cards/${cardId}/rounding`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ rounding_minutes: roundingMinutes })
+ });
+
+ if (!response.ok) throw new Error('Fejl ved opdatering');
+
+ alert('✅ Afrunding opdateret');
+ loadCardDetails();
+ } catch (error) {
+ console.error('Error updating rounding:', error);
+ alert('❌ Fejl: ' + error.message);
+ }
+}
+{% endblock %}
+
+{% block content %}
+
+
+
+
+ Arkiveret Ticket
+
+
Detaljer fra Simply-CRM import
+
+
+ Tilbage til liste
+
+
+
+
+
+
+ {% if ticket.ticket_number %}
+ {{ ticket.ticket_number }}
+ {% endif %}
+
{{ ticket.title or 'Ingen titel' }}
+
+
+ {% if ticket.status %}
+ {{ ticket.status.replace('_', ' ').title() }}
+ {% endif %}
+
+
+
+
+
+
Organisation
+
{{ ticket.organization_name or '-' }}
+
+
+
Kontakt
+
{{ ticket.contact_name or '-' }}
+
+
+
Email From
+
{{ ticket.email_from or '-' }}
+
+
+
Tid brugt
+
+ {% if ticket.time_spent_hours is not none %}
+ {{ '%.2f'|format(ticket.time_spent_hours) }} t
+ {% else %}
+ -
+ {% endif %}
+
+
+
+
Prioritet
+
{{ ticket.priority or '-' }}
+
+
+
Oprettet
+
+ {% if ticket.source_created_at %}
+ {{ ticket.source_created_at.strftime('%Y-%m-%d %H:%M') }}
+ {% else %}
+ -
+ {% endif %}
+
+
+
+
Opdateret
+
+ {% if ticket.source_updated_at %}
+ {{ ticket.source_updated_at.strftime('%Y-%m-%d %H:%M') }}
+ {% else %}
+ -
+ {% endif %}
+
+
+
+
+
+
+
Beskrivelse
+ {% if description_html %}
+
{{ description_html | safe }}
+ {% elif ticket.description %}
+
{{ ticket.description | e | replace('\n', '
') | safe }}
+ {% else %}
+
Ingen beskrivelse
+ {% endif %}
+
+
+
+
Løsning
+ {% if solution_html %}
+
{{ solution_html | safe }}
+ {% elif ticket.solution %}
+
{{ ticket.solution | e | replace('\n', '
') | safe }}
+ {% else %}
+
Ingen løsning angivet
+ {% endif %}
+
+
+
+
Kommentarer og Emails
+ {% if messages %}
+ {% for message in messages %}
+
+
+
+ {{ message.message_type }}
+ {% if message.subject %}
+ {{ message.subject }}
+ {% endif %}
+
+
+ {% if message.source_created_at %}
+ {{ message.source_created_at.strftime('%Y-%m-%d %H:%M') }}
+ {% else %}
+ -
+ {% endif %}
+
+
+
+ {% if message.author_name %}
+ {{ message.author_name }}
+ {% endif %}
+ {% if message.author_email %}
+ ({{ message.author_email }})
+ {% endif %}
+
+
+ {% if message.body_html %}
+
{{ message.body_html | safe }}
+ {% elif message.body %}
+
{{ message.body | e | replace('\n', '
') | safe }}
+ {% else %}
+
Ingen tekst
+ {% endif %}
+
+
+ {% endfor %}
+ {% else %}
+
Ingen kommentarer eller emails fundet.
+ {% endif %}
+
+
+{% endblock %}
diff --git a/app/ticket/frontend/archived_ticket_list.html b/app/ticket/frontend/archived_ticket_list.html
new file mode 100644
index 0000000..b3e18fc
--- /dev/null
+++ b/app/ticket/frontend/archived_ticket_list.html
@@ -0,0 +1,341 @@
+{% extends "shared/frontend/base.html" %}
+
+{% block title %}Arkiverede Tickets - BMC Hub{% endblock %}
+
+{% block extra_css %}
+
+{% endblock %}
+
+{% block content %}
+
+
+
+
+
+ Arkiverede Tickets
+
+
Historiske tickets importeret fra Simply-CRM
+
+
+
+
+
+
+
+
+ {% if tickets %}
+
+
+
+
+
+ | Ticket |
+ Organisation |
+ Kontakt |
+ Email From |
+ Tid brugt |
+ Status |
+ Oprettet |
+
+
+
+ {% for ticket in tickets %}
+
+
+ {% if ticket.ticket_number %}
+ {{ ticket.ticket_number }}
+
+ {% endif %}
+ {{ ticket.title or '-' }}
+ |
+ {{ ticket.organization_name or '-' }} |
+ {{ ticket.contact_name or '-' }} |
+ {{ ticket.email_from or '-' }} |
+
+ {% if ticket.time_spent_hours is not none %}
+ {{ '%.2f'|format(ticket.time_spent_hours) }} t
+ {% else %}
+ -
+ {% endif %}
+ |
+
+ {% if ticket.status %}
+
+ {{ ticket.status.replace('_', ' ').title() }}
+
+ {% else %}
+ -
+ {% endif %}
+ |
+
+ {% if ticket.source_created_at %}
+ {{ ticket.source_created_at.strftime('%Y-%m-%d') }}
+ {% else %}
+ -
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+
+
Ingen arkiverede tickets fundet
+
Prøv at justere din søgning eller importer data fra Simply-CRM.
+
+ {% endif %}
+
+
+{% endblock %}
+
+{% block extra_js %}
+
+{% endblock %}
diff --git a/app/ticket/frontend/views.py b/app/ticket/frontend/views.py
index 322587a..e8113d3 100644
--- a/app/ticket/frontend/views.py
+++ b/app/ticket/frontend/views.py
@@ -18,6 +18,63 @@ router = APIRouter()
templates = Jinja2Templates(directory="app")
+def _format_long_text(value: Optional[str]) -> str:
+ if not value:
+ return ""
+ lines = value.splitlines()
+ blocks = []
+ buffer = []
+ in_list = False
+
+ def flush_paragraph():
+ nonlocal buffer
+ if buffer:
+ blocks.append("
" + " ".join(buffer).strip() + "
")
+ buffer = []
+
+ def close_list():
+ nonlocal in_list
+ if in_list:
+ blocks.append("")
+ in_list = False
+
+ for raw_line in lines:
+ line = raw_line.strip()
+ if not line:
+ flush_paragraph()
+ close_list()
+ continue
+
+ if line.startswith(("- ", "* ", "• ")):
+ flush_paragraph()
+ if not in_list:
+ blocks.append("
")
+ in_list = True
+ item = line[2:].strip()
+ blocks.append(f"- {item}
")
+ continue
+
+ if line[0].isdigit() and "." in line[:4]:
+ flush_paragraph()
+ if not in_list:
+ blocks.append("")
+ in_list = True
+ item = line.split(".", 1)[1].strip()
+ blocks.append(f"- {item}
")
+ continue
+
+ close_list()
+ buffer.append(line)
+
+ flush_paragraph()
+ close_list()
+
+ return "\n".join(blocks)
+
+
+templates.env.filters["format_long_text"] = _format_long_text
+
+
# ============================================================================
# MOCKUP ROUTES (TEMPORARY)
# ============================================================================
@@ -443,6 +500,141 @@ async def ticket_list_page(
raise HTTPException(status_code=500, detail=str(e))
+@router.get("/archived", response_class=HTMLResponse)
+async def archived_ticket_list_page(
+ request: Request,
+ search: Optional[str] = None,
+ organization: Optional[str] = None,
+ contact: Optional[str] = None,
+ date_from: Optional[str] = None,
+ date_to: Optional[str] = None
+):
+ """
+ Archived ticket list page (Simply-CRM import)
+ """
+ try:
+ query = """
+ SELECT
+ id,
+ ticket_number,
+ title,
+ organization_name,
+ contact_name,
+ email_from,
+ time_spent_hours,
+ status,
+ priority,
+ source_created_at,
+ description
+ FROM tticket_archived_tickets
+ WHERE 1=1
+ """
+ params = []
+
+ if search:
+ query += " AND (ticket_number ILIKE %s OR title ILIKE %s OR description ILIKE %s)"
+ search_pattern = f"%{search}%"
+ params.extend([search_pattern] * 3)
+
+ if organization:
+ query += " AND organization_name ILIKE %s"
+ params.append(f"%{organization}%")
+
+ if contact:
+ query += " AND contact_name ILIKE %s"
+ params.append(f"%{contact}%")
+
+ if date_from:
+ query += " AND source_created_at >= %s"
+ params.append(date_from)
+
+ if date_to:
+ query += " AND source_created_at <= %s"
+ params.append(date_to)
+
+ query += " ORDER BY source_created_at DESC NULLS LAST, imported_at DESC LIMIT 200"
+
+ tickets = execute_query(query, tuple(params)) if params else execute_query(query)
+
+ return templates.TemplateResponse(
+ "ticket/frontend/archived_ticket_list.html",
+ {
+ "request": request,
+ "tickets": tickets,
+ "search_query": search,
+ "organization_query": organization,
+ "contact_query": contact,
+ "date_from": date_from,
+ "date_to": date_to
+ }
+ )
+
+ except Exception as e:
+ logger.error(f"❌ Failed to load archived ticket list: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
+@router.get("/archived/{archived_ticket_id}", response_class=HTMLResponse)
+async def archived_ticket_detail_page(request: Request, archived_ticket_id: int):
+ """
+ Archived ticket detail page with messages
+ """
+ try:
+ ticket = execute_query_single(
+ "SELECT * FROM tticket_archived_tickets WHERE id = %s",
+ (archived_ticket_id,)
+ )
+
+ if not ticket:
+ raise HTTPException(status_code=404, detail="Archived ticket not found")
+
+ messages = execute_query(
+ """
+ SELECT * FROM tticket_archived_messages
+ WHERE archived_ticket_id = %s
+ ORDER BY source_created_at ASC NULLS LAST, imported_at ASC
+ """,
+ (archived_ticket_id,)
+ )
+
+ formatted_messages = []
+ for message in messages or []:
+ formatted = dict(message)
+ body_value = message.get("body") or ""
+ body_html = _format_long_text(body_value)
+ if body_value and not body_html:
+ body_html = f"{html.escape(body_value)}
"
+ formatted["body_html"] = body_html
+ formatted_messages.append(formatted)
+
+ description_value = ticket.get("description") or ""
+ description_html = _format_long_text(description_value)
+ if description_value and not description_html:
+ description_html = f"{html.escape(description_value)}
"
+
+ solution_value = ticket.get("solution") or ""
+ solution_html = _format_long_text(solution_value)
+ if solution_value and not solution_html:
+ solution_html = f"{html.escape(solution_value)}
"
+
+ return templates.TemplateResponse(
+ "ticket/frontend/archived_ticket_detail.html",
+ {
+ "request": request,
+ "ticket": ticket,
+ "messages": formatted_messages,
+ "description_html": description_html,
+ "solution_html": solution_html
+ }
+ )
+
+ except HTTPException:
+ raise
+ except Exception as e:
+ logger.error(f"❌ Failed to load archived ticket detail: {e}")
+ raise HTTPException(status_code=500, detail=str(e))
+
+
@router.get("/tickets/{ticket_id}", response_class=HTMLResponse)
async def ticket_detail_page(request: Request, ticket_id: int):
"""
diff --git a/app/timetracking/backend/router.py b/app/timetracking/backend/router.py
index 2b98ebc..e29a42e 100644
--- a/app/timetracking/backend/router.py
+++ b/app/timetracking/backend/router.py
@@ -8,7 +8,8 @@ Isoleret routing uden påvirkning af existing Hub endpoints.
import logging
from typing import Optional, List, Dict, Any
-from datetime import datetime
+from datetime import datetime, date
+from calendar import monthrange
from fastapi import APIRouter, HTTPException, Depends, Body
from fastapi.responses import JSONResponse
@@ -1779,6 +1780,7 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
worked_date = entry.get("worked_date") or datetime.now().date()
user_name = entry.get("user_name", "Hub User")
prepaid_card_id = entry.get("prepaid_card_id")
+ fixed_price_agreement_id = entry.get("fixed_price_agreement_id")
work_type = entry.get("work_type", "support")
is_internal = entry.get("is_internal", False)
@@ -1828,13 +1830,22 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
card = execute_query_single("SELECT * FROM tticket_prepaid_cards WHERE id = %s", (prepaid_card_id,))
if not card:
raise HTTPException(status_code=404, detail="Prepaid card not found")
-
- if float(card['remaining_hours']) < hours_decimal:
+
+ rounding_minutes = int(card.get('rounding_minutes') or 0)
+ rounded_hours = hours_decimal
+ rounded_to = None
+ if rounding_minutes > 0:
+ from decimal import Decimal, ROUND_CEILING
+ interval = Decimal(rounding_minutes) / Decimal(60)
+ rounded_hours = float((Decimal(str(hours_decimal)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval)
+ rounded_to = float(interval)
+
+ if float(card['remaining_hours']) < rounded_hours:
# Optional: Allow overdraft? For now, block.
raise HTTPException(status_code=400, detail=f"Insufficient hours on prepaid card (Remaining: {card['remaining_hours']})")
# Deduct hours (remaining_hours is generated; update used_hours instead)
- new_used = float(card['used_hours']) + hours_decimal
+ new_used = float(card['used_hours']) + rounded_hours
execute_update(
"UPDATE tticket_prepaid_cards SET used_hours = %s WHERE id = %s",
(new_used, prepaid_card_id)
@@ -1848,11 +1859,107 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
"UPDATE tticket_prepaid_cards SET status = 'depleted' WHERE id = %s",
(prepaid_card_id,)
)
- logger.info(f"💳 Deducted {hours_decimal} hours from prepaid card {prepaid_card_id}")
+ if rounded_to:
+ entry['approved_hours'] = rounded_hours
+ entry['rounded_to'] = rounded_to
+ logger.info(f"💳 Deducted {rounded_hours} hours from prepaid card {prepaid_card_id}")
billing_method = 'prepaid'
status = 'billed' # Mark as processed/billed so it skips invoicing
+ elif fixed_price_agreement_id:
+ # Verify agreement
+ agreement = execute_query_single("""
+ SELECT a.*, bp.id as period_id, bp.used_hours, bp.included_hours
+ FROM customer_fixed_price_agreements a
+ LEFT JOIN fixed_price_billing_periods bp ON (
+ a.id = bp.agreement_id
+ AND bp.period_start <= CURRENT_DATE
+ AND bp.period_end >= CURRENT_DATE
+ )
+ WHERE a.id = %s AND a.status = 'active'
+ """, (fixed_price_agreement_id,))
+
+ if not agreement:
+ raise HTTPException(status_code=404, detail="Fixed-price agreement not found or inactive")
+
+ if not agreement.get('period_id'):
+ # Auto-create billing period for current month
+ today = datetime.now().date()
+ period_start = date(today.year, today.month, 1)
+ last_day = monthrange(today.year, today.month)[1]
+ period_end = date(today.year, today.month, last_day)
+
+ # Full month amount (auto-created periods are always full months)
+ base_amount = float(agreement['monthly_hours']) * float(agreement['hourly_rate'])
+ included_hours = float(agreement['monthly_hours'])
+
+ execute_query("""
+ INSERT INTO fixed_price_billing_periods (
+ agreement_id, period_start, period_end,
+ included_hours, base_amount, status
+ ) VALUES (%s, %s, %s, %s, %s, %s)
+ RETURNING *
+ """, (
+ fixed_price_agreement_id,
+ period_start,
+ period_end,
+ included_hours,
+ base_amount,
+ 'active'
+ ))
+
+ # Re-query to get the new period
+ agreement = execute_query_single("""
+ SELECT a.*, bp.id as period_id, bp.used_hours, bp.included_hours
+ FROM customer_fixed_price_agreements a
+ LEFT JOIN fixed_price_billing_periods bp ON (
+ a.id = bp.agreement_id
+ AND bp.period_start <= CURRENT_DATE
+ AND bp.period_end >= CURRENT_DATE
+ )
+ WHERE a.id = %s AND a.status = 'active'
+ """, (fixed_price_agreement_id,))
+
+ logger.info(f"📅 Auto-created billing period for agreement {fixed_price_agreement_id}: {period_start} to {period_end}")
+
+ # Apply rounding
+ rounding_minutes = int(agreement.get('rounding_minutes') or 0)
+ rounded_hours = hours_decimal
+ rounded_to = None
+ if rounding_minutes > 0:
+ from decimal import Decimal, ROUND_CEILING
+ interval = Decimal(rounding_minutes) / Decimal(60)
+ rounded_hours = float((Decimal(str(hours_decimal)) / interval).to_integral_value(rounding=ROUND_CEILING) * interval)
+ rounded_to = float(interval)
+
+ # Update period used_hours
+ new_used = float(agreement['used_hours'] or 0) + rounded_hours
+ execute_update(
+ "UPDATE fixed_price_billing_periods SET used_hours = %s WHERE id = %s",
+ (new_used, agreement['period_id'])
+ )
+
+ # Check if overtime
+ if new_used > float(agreement['included_hours']):
+ overtime = new_used - float(agreement['included_hours'])
+ logger.warning(f"⚠️ Fixed-price agreement {fixed_price_agreement_id} has {overtime:.2f}h overtime")
+ # Update period status to pending_approval if not already
+ execute_update("""
+ UPDATE fixed_price_billing_periods
+ SET status = 'pending_approval'
+ WHERE id = %s AND status = 'active'
+ """, (agreement['period_id'],))
+
+ if rounded_to:
+ entry['approved_hours'] = rounded_hours
+ entry['rounded_to'] = rounded_to
+
+ logger.info(f"📋 Logged {rounded_hours} hours to fixed-price agreement {fixed_price_agreement_id}")
+
+ billing_method = 'fixed_price'
+ status = 'billed' # Tracked in agreement, not invoiced separately
+
elif billing_method == 'internal' or billing_method == 'warranty':
billable = False
@@ -1860,18 +1967,21 @@ async def create_internal_time_entry(entry: Dict[str, Any] = Body(...)):
INSERT INTO tmodule_times (
sag_id, solution_id, customer_id, description,
original_hours, worked_date, user_name,
- status, billable, billing_method, prepaid_card_id, work_type
+ status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
+ approved_hours, rounded_to
) VALUES (
%s, %s, %s, %s,
%s, %s, %s,
- %s, %s, %s, %s, %s
+ %s, %s, %s, %s, %s, %s,
+ %s, %s
) RETURNING *
"""
params = (
sag_id, solution_id, customer_id, description,
hours, worked_date, user_name,
- status, billable, billing_method, prepaid_card_id, work_type
+ status, billable, billing_method, prepaid_card_id, fixed_price_agreement_id, work_type,
+ entry.get('approved_hours'), entry.get('rounded_to')
)
result = execute_query(query, params)
if result:
diff --git a/main.py b/main.py
index 6f0da98..b9f17bd 100644
--- a/main.py
+++ b/main.py
@@ -37,6 +37,8 @@ from app.dashboard.backend import views as dashboard_views
from app.dashboard.backend import router as dashboard_api
from app.prepaid.backend import router as prepaid_api
from app.prepaid.backend import views as prepaid_views
+from app.fixed_price.backend import router as fixed_price_api
+from app.fixed_price.frontend import views as fixed_price_views
from app.ticket.backend import router as ticket_api
from app.ticket.frontend import views as ticket_views
from app.vendors.backend import router as vendors_api
@@ -75,6 +77,7 @@ from app.modules.locations.backend import router as locations_api
from app.modules.locations.frontend import views as locations_views
from app.modules.nextcloud.backend import router as nextcloud_api
from app.modules.search.backend import router as search_api
+from app.fixed_price.backend import router as fixed_price_api
# Configure logging
logging.basicConfig(
@@ -160,6 +163,12 @@ async def auth_middleware(request: Request, call_next):
"/api/v1/auth/login"
}
+ if settings.DEV_ALLOW_ARCHIVED_IMPORT:
+ public_paths.add("/api/v1/ticket/archived/simply/import")
+ public_paths.add("/api/v1/ticket/archived/simply/modules")
+ public_paths.add("/api/v1/ticket/archived/simply/ticket")
+ public_paths.add("/api/v1/ticket/archived/simply/record")
+
if path in public_paths or path.startswith("/static") or path.startswith("/docs"):
return await call_next(request)
@@ -225,6 +234,7 @@ app.include_router(system_api.router, prefix="/api/v1", tags=["System"])
app.include_router(dashboard_api.router, prefix="/api/v1", tags=["Dashboard"])
app.include_router(sync_router.router, prefix="/api/v1/system", tags=["System Sync"])
app.include_router(prepaid_api.router, prefix="/api/v1", tags=["Prepaid Cards"])
+app.include_router(fixed_price_api.router, prefix="/api/v1", tags=["Fixed-Price Agreements"])
app.include_router(ticket_api.router, prefix="/api/v1/ticket", tags=["Tickets"])
app.include_router(vendors_api.router, prefix="/api/v1", tags=["Vendors"])
app.include_router(contacts_api.router, prefix="/api/v1", tags=["Contacts"])
@@ -252,6 +262,7 @@ app.include_router(search_api.router, prefix="/api/v1", tags=["Search"])
app.include_router(dashboard_views.router, tags=["Frontend"])
app.include_router(customers_views.router, tags=["Frontend"])
app.include_router(prepaid_views.router, tags=["Frontend"])
+app.include_router(fixed_price_views.router, tags=["Frontend"])
app.include_router(vendors_views.router, tags=["Frontend"])
app.include_router(timetracking_views.router, tags=["Frontend"])
app.include_router(billing_views.router, tags=["Frontend"])
diff --git a/migrations/097_archived_tickets_simplycrm.sql b/migrations/097_archived_tickets_simplycrm.sql
new file mode 100644
index 0000000..b72a3cb
--- /dev/null
+++ b/migrations/097_archived_tickets_simplycrm.sql
@@ -0,0 +1,48 @@
+-- ==========================================================================
+-- Migration 097: Archived Tickets (Simply-CRM import)
+-- ==========================================================================
+
+CREATE TABLE IF NOT EXISTS tticket_archived_tickets (
+ id SERIAL PRIMARY KEY,
+ source_system VARCHAR(50) NOT NULL DEFAULT 'simplycrm',
+ external_id VARCHAR(100) NOT NULL,
+ ticket_number VARCHAR(100),
+ title VARCHAR(500),
+ organization_name VARCHAR(255),
+ contact_name VARCHAR(255),
+ email_from VARCHAR(255),
+ time_spent_hours DECIMAL(10,2),
+ description TEXT,
+ solution TEXT,
+ status VARCHAR(50),
+ priority VARCHAR(50),
+ source_created_at TIMESTAMP,
+ source_updated_at TIMESTAMP,
+ imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ last_synced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ sync_hash VARCHAR(64),
+ raw_data JSONB DEFAULT '{}'::jsonb,
+ UNIQUE (source_system, external_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_tticket_archived_external ON tticket_archived_tickets(source_system, external_id);
+CREATE INDEX IF NOT EXISTS idx_tticket_archived_ticket_number ON tticket_archived_tickets(ticket_number);
+CREATE INDEX IF NOT EXISTS idx_tticket_archived_org ON tticket_archived_tickets(organization_name);
+CREATE INDEX IF NOT EXISTS idx_tticket_archived_created ON tticket_archived_tickets(source_created_at DESC);
+
+CREATE TABLE IF NOT EXISTS tticket_archived_messages (
+ id SERIAL PRIMARY KEY,
+ archived_ticket_id INTEGER NOT NULL REFERENCES tticket_archived_tickets(id) ON DELETE CASCADE,
+ message_type VARCHAR(50) NOT NULL,
+ subject VARCHAR(500),
+ body TEXT,
+ author_name VARCHAR(255),
+ author_email VARCHAR(255),
+ source_created_at TIMESTAMP,
+ imported_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ raw_data JSONB DEFAULT '{}'::jsonb
+);
+
+CREATE INDEX IF NOT EXISTS idx_tticket_archived_messages_ticket ON tticket_archived_messages(archived_ticket_id);
+CREATE INDEX IF NOT EXISTS idx_tticket_archived_messages_type ON tticket_archived_messages(message_type);
+CREATE INDEX IF NOT EXISTS idx_tticket_archived_messages_created ON tticket_archived_messages(source_created_at DESC);
diff --git a/migrations/098_prepaid_rounding_minutes.sql b/migrations/098_prepaid_rounding_minutes.sql
new file mode 100644
index 0000000..137e36b
--- /dev/null
+++ b/migrations/098_prepaid_rounding_minutes.sql
@@ -0,0 +1,17 @@
+-- Migration: Add rounding interval to prepaid cards
+
+ALTER TABLE tticket_prepaid_cards
+ADD COLUMN IF NOT EXISTS rounding_minutes INTEGER DEFAULT 0;
+
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint WHERE conname = 'chk_tticket_prepaid_rounding_minutes'
+ ) THEN
+ ALTER TABLE tticket_prepaid_cards
+ ADD CONSTRAINT chk_tticket_prepaid_rounding_minutes
+ CHECK (rounding_minutes IN (0, 15, 30, 60));
+ END IF;
+END $$;
+
+COMMENT ON COLUMN tticket_prepaid_cards.rounding_minutes IS 'Round up prepaid usage to this interval in minutes (0 = no rounding)';
diff --git a/migrations/099_worklog_rounded_hours.sql b/migrations/099_worklog_rounded_hours.sql
new file mode 100644
index 0000000..99beb03
--- /dev/null
+++ b/migrations/099_worklog_rounded_hours.sql
@@ -0,0 +1,13 @@
+-- Migration: Add rounded_hours to tticket_worklog for prepaid card billing
+
+-- Add rounded_hours column (actual hours deducted from prepaid card after rounding)
+ALTER TABLE tticket_worklog
+ADD COLUMN IF NOT EXISTS rounded_hours DECIMAL(5,2);
+
+COMMENT ON COLUMN tticket_worklog.rounded_hours IS 'Rounded hours (actual amount deducted from prepaid card). NULL if not prepaid or no rounding applied.';
+
+-- Backfill: Set rounded_hours = hours for existing prepaid entries
+UPDATE tticket_worklog
+SET rounded_hours = hours
+WHERE prepaid_card_id IS NOT NULL
+ AND rounded_hours IS NULL;
diff --git a/migrations/100_fixed_price_agreements.sql b/migrations/100_fixed_price_agreements.sql
new file mode 100644
index 0000000..3a9bfaa
--- /dev/null
+++ b/migrations/100_fixed_price_agreements.sql
@@ -0,0 +1,108 @@
+-- Migration 100: Fixed-Price Agreements
+-- Creates table for monthly fixed-price hour agreements with binding periods
+-- Ready for future subscription system integration (v2)
+
+CREATE TABLE IF NOT EXISTS customer_fixed_price_agreements (
+ id SERIAL PRIMARY KEY,
+ agreement_number VARCHAR(50) UNIQUE NOT NULL,
+
+ -- Future subscription system integration
+ subscription_id INTEGER DEFAULT NULL,
+
+ -- Customer linkage
+ customer_id INTEGER NOT NULL,
+ customer_name VARCHAR(255),
+
+ -- Product definition (v2: move to subscription_products table)
+ monthly_hours DECIMAL(8,2) NOT NULL CHECK (monthly_hours > 0),
+ hourly_rate DECIMAL(10,2) NOT NULL CHECK (hourly_rate >= 0),
+ overtime_rate DECIMAL(10,2) NOT NULL CHECK (overtime_rate >= 0),
+ internal_cost_rate DECIMAL(10,2) DEFAULT 350.00, -- For profit calculation
+ rounding_minutes INTEGER DEFAULT 0 CHECK (rounding_minutes IN (0, 15, 30, 60)),
+
+ -- Contract lifecycle
+ start_date DATE NOT NULL,
+ binding_months INTEGER DEFAULT 0 CHECK (binding_months >= 0),
+ binding_end_date DATE,
+ end_date DATE,
+ notice_period_days INTEGER DEFAULT 30 CHECK (notice_period_days >= 0),
+ auto_renew BOOLEAN DEFAULT false,
+
+ -- Status tracking
+ status VARCHAR(20) DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'expired', 'cancelled', 'pending_cancellation')),
+ cancellation_requested_date DATE,
+ cancellation_effective_date DATE,
+ cancelled_by_user_id INTEGER,
+ cancellation_reason TEXT,
+
+ -- Billing integration (v2: subscription_billing handles this)
+ billing_enabled BOOLEAN DEFAULT true,
+ last_billed_period DATE,
+
+ -- e-conomic integration (v2: move to subscription product mapping)
+ economic_product_number VARCHAR(50),
+ economic_overtime_product_number VARCHAR(50),
+
+ -- Metadata
+ notes TEXT,
+ created_by_user_id INTEGER,
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Indexes for performance
+CREATE INDEX IF NOT EXISTS idx_fpa_customer ON customer_fixed_price_agreements(customer_id);
+CREATE INDEX IF NOT EXISTS idx_fpa_subscription ON customer_fixed_price_agreements(subscription_id);
+CREATE INDEX IF NOT EXISTS idx_fpa_status ON customer_fixed_price_agreements(status);
+CREATE INDEX IF NOT EXISTS idx_fpa_dates ON customer_fixed_price_agreements(start_date, end_date);
+
+-- Trigger to auto-generate agreement_number
+CREATE OR REPLACE FUNCTION generate_fpa_number()
+RETURNS TRIGGER AS $$
+DECLARE
+ new_number VARCHAR(50);
+ day_count INTEGER;
+BEGIN
+ -- Count agreements created today
+ SELECT COUNT(*) INTO day_count
+ FROM customer_fixed_price_agreements
+ WHERE DATE(created_at) = CURRENT_DATE;
+
+ -- Generate FPA-YYYYMMDD-XXX
+ new_number := 'FPA-' || TO_CHAR(CURRENT_DATE, 'YYYYMMDD') || '-' || LPAD((day_count + 1)::TEXT, 3, '0');
+
+ NEW.agreement_number := new_number;
+
+ -- Calculate binding_end_date
+ IF NEW.binding_months > 0 THEN
+ NEW.binding_end_date := NEW.start_date + (NEW.binding_months || ' months')::INTERVAL;
+ ELSE
+ NEW.binding_end_date := NULL;
+ END IF;
+
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+CREATE TRIGGER trigger_generate_fpa_number
+ BEFORE INSERT OR UPDATE ON customer_fixed_price_agreements
+ FOR EACH ROW
+ EXECUTE FUNCTION generate_fpa_number();
+
+-- Update timestamp trigger
+CREATE TRIGGER trigger_fpa_updated_at
+ BEFORE UPDATE ON customer_fixed_price_agreements
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+COMMENT ON TABLE customer_fixed_price_agreements IS
+ 'Fixed-price monthly hour agreements. Ready for v2 subscription system integration via subscription_id column.';
+
+COMMENT ON COLUMN customer_fixed_price_agreements.subscription_id IS
+ 'Future: Links to unified subscription system (Phase 2)';
+
+COMMENT ON COLUMN customer_fixed_price_agreements.internal_cost_rate IS
+ 'Internal hourly cost for profit margin calculation in reporting';
+
+COMMENT ON COLUMN customer_fixed_price_agreements.binding_end_date IS
+ 'Auto-calculated from start_date + binding_months. Enforced during cancellation.';
diff --git a/migrations/101_add_fixed_price_to_worklog.sql b/migrations/101_add_fixed_price_to_worklog.sql
new file mode 100644
index 0000000..0332ecc
--- /dev/null
+++ b/migrations/101_add_fixed_price_to_worklog.sql
@@ -0,0 +1,22 @@
+-- Migration 101: Add fixed_price_agreement_id to time tracking tables
+-- Links time entries to fixed-price agreements for billing and reporting
+
+-- Add to ticket worklog
+ALTER TABLE tticket_worklog
+ADD COLUMN IF NOT EXISTS fixed_price_agreement_id INTEGER
+REFERENCES customer_fixed_price_agreements(id) ON DELETE SET NULL;
+
+CREATE INDEX IF NOT EXISTS idx_worklog_fpa ON tticket_worklog(fixed_price_agreement_id);
+
+-- Add to sag/solutions time tracking
+ALTER TABLE tmodule_times
+ADD COLUMN IF NOT EXISTS fixed_price_agreement_id INTEGER
+REFERENCES customer_fixed_price_agreements(id) ON DELETE SET NULL;
+
+CREATE INDEX IF NOT EXISTS idx_times_fpa ON tmodule_times(fixed_price_agreement_id);
+
+COMMENT ON COLUMN tticket_worklog.fixed_price_agreement_id IS
+ 'Links worklog entry to fixed-price agreement for billing tracking';
+
+COMMENT ON COLUMN tmodule_times.fixed_price_agreement_id IS
+ 'Links time entry to fixed-price agreement for billing tracking';
diff --git a/migrations/102_fixed_price_billing_periods.sql b/migrations/102_fixed_price_billing_periods.sql
new file mode 100644
index 0000000..a031145
--- /dev/null
+++ b/migrations/102_fixed_price_billing_periods.sql
@@ -0,0 +1,65 @@
+-- Migration 102: Fixed-Price Billing Periods
+-- Tracks monthly usage and billing status per agreement
+-- Structure compatible with future subscription_billing_periods table
+
+CREATE TABLE IF NOT EXISTS fixed_price_billing_periods (
+ id SERIAL PRIMARY KEY,
+ agreement_id INTEGER NOT NULL REFERENCES customer_fixed_price_agreements(id) ON DELETE CASCADE,
+
+ -- Period definition (compatible with future subscription_billing_periods)
+ period_start DATE NOT NULL,
+ period_end DATE NOT NULL,
+ period_type VARCHAR(20) DEFAULT 'calendar_month',
+
+ -- Usage tracking
+ included_hours DECIMAL(8,2) NOT NULL,
+ used_hours DECIMAL(8,2) DEFAULT 0,
+ overtime_hours DECIMAL(8,2) GENERATED ALWAYS AS (
+ CASE WHEN used_hours > included_hours THEN used_hours - included_hours ELSE 0 END
+ ) STORED,
+ remaining_hours DECIMAL(8,2) GENERATED ALWAYS AS (
+ CASE WHEN used_hours < included_hours THEN included_hours - used_hours ELSE 0 END
+ ) STORED,
+
+ -- Billing amounts (v2: integrate with subscription_line_items)
+ base_amount DECIMAL(12,2) NOT NULL,
+ overtime_amount DECIMAL(12,2) DEFAULT 0,
+ overtime_approved BOOLEAN DEFAULT false,
+
+ -- Billing status (compatible with subscription billing workflow)
+ status VARCHAR(20) DEFAULT 'active' CHECK (status IN (
+ 'active', -- Currently accumulating usage
+ 'pending_approval', -- Overtime needs approval
+ 'ready_to_bill', -- Approved, ready for invoice generation
+ 'billed', -- Invoice created
+ 'cancelled' -- Period cancelled (e.g. early termination)
+ )),
+
+ -- Invoice tracking
+ billed_at TIMESTAMP,
+ economic_invoice_number VARCHAR(50),
+ invoice_id INTEGER,
+
+ -- Metadata
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+
+ UNIQUE(agreement_id, period_start)
+);
+
+-- Indexes
+CREATE INDEX IF NOT EXISTS idx_fpbp_agreement ON fixed_price_billing_periods(agreement_id);
+CREATE INDEX IF NOT EXISTS idx_fpbp_period ON fixed_price_billing_periods(period_start, period_end);
+CREATE INDEX IF NOT EXISTS idx_fpbp_status ON fixed_price_billing_periods(status);
+
+-- Update timestamp trigger
+CREATE TRIGGER trigger_fpbp_updated_at
+ BEFORE UPDATE ON fixed_price_billing_periods
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
+
+COMMENT ON TABLE fixed_price_billing_periods IS
+ 'Billing periods for fixed-price agreements. Structure compatible with future unified subscription_billing_periods table.';
+
+COMMENT ON COLUMN fixed_price_billing_periods.overtime_approved IS
+ 'Overtime must be approved before billing. Periods with unapproved overtime → status=pending_approval';
diff --git a/migrations/103_fixed_price_reporting_views.sql b/migrations/103_fixed_price_reporting_views.sql
new file mode 100644
index 0000000..7360b24
--- /dev/null
+++ b/migrations/103_fixed_price_reporting_views.sql
@@ -0,0 +1,113 @@
+-- Migration 103: Fixed-Price Reporting Views
+-- Pre-aggregated views for dashboard and reporting performance
+
+-- Agreement performance aggregation
+CREATE OR REPLACE VIEW fixed_price_agreement_performance AS
+SELECT
+ a.id,
+ a.agreement_number,
+ a.customer_id,
+ a.customer_name,
+ a.status,
+ a.monthly_hours,
+ a.hourly_rate,
+ a.overtime_rate,
+ a.internal_cost_rate,
+
+ -- Period counts
+ COUNT(DISTINCT bp.id) as total_periods,
+ COUNT(DISTINCT bp.id) FILTER (WHERE bp.status = 'billed') as billed_periods,
+
+ -- Hour aggregations
+ COALESCE(SUM(bp.included_hours), 0) as total_included_hours,
+ COALESCE(SUM(bp.used_hours), 0) as total_used_hours,
+ COALESCE(SUM(bp.overtime_hours) FILTER (WHERE bp.overtime_approved), 0) as total_approved_overtime,
+
+ -- Revenue (only billed periods)
+ COALESCE(SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed'), 0) as total_base_revenue,
+ COALESCE(SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved), 0) as total_overtime_revenue,
+ COALESCE(
+ SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed') +
+ SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved),
+ 0
+ ) as total_revenue,
+
+ -- Cost calculation
+ COALESCE(SUM(bp.used_hours) * a.internal_cost_rate, 0) as total_internal_cost,
+
+ -- Profit
+ COALESCE(
+ SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed') +
+ SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved) -
+ SUM(bp.used_hours) * a.internal_cost_rate,
+ 0
+ ) as total_profit,
+
+ -- Utilization (used hours / included hours)
+ CASE
+ WHEN SUM(bp.included_hours) > 0
+ THEN ROUND((SUM(bp.used_hours) / SUM(bp.included_hours) * 100)::numeric, 1)
+ ELSE 0
+ END as utilization_percent,
+
+ -- Latest period
+ MAX(bp.period_end) as latest_period_end
+
+FROM customer_fixed_price_agreements a
+LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
+GROUP BY a.id;
+
+-- Monthly trend view
+CREATE OR REPLACE VIEW fixed_price_monthly_trends AS
+SELECT
+ DATE_TRUNC('month', bp.period_start)::date as month,
+ COUNT(DISTINCT a.id) as active_agreements,
+ COUNT(DISTINCT bp.id) as total_periods,
+
+ SUM(bp.included_hours) as total_included_hours,
+ SUM(bp.used_hours) as total_used_hours,
+ SUM(bp.overtime_hours) FILTER (WHERE bp.overtime_approved) as total_approved_overtime,
+
+ SUM(bp.base_amount) FILTER (WHERE bp.status = 'billed') as monthly_base_revenue,
+ SUM(bp.overtime_amount) FILTER (WHERE bp.status = 'billed' AND bp.overtime_approved) as monthly_overtime_revenue,
+ SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') as monthly_total_revenue,
+
+ AVG(a.internal_cost_rate) as avg_internal_cost_rate,
+ SUM(bp.used_hours * a.internal_cost_rate) as monthly_internal_cost,
+
+ SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') -
+ SUM(bp.used_hours * a.internal_cost_rate) as monthly_profit
+
+FROM fixed_price_billing_periods bp
+JOIN customer_fixed_price_agreements a ON bp.agreement_id = a.id
+GROUP BY DATE_TRUNC('month', bp.period_start)
+ORDER BY month DESC;
+
+-- Customer aggregation
+CREATE OR REPLACE VIEW fixed_price_customer_summary AS
+SELECT
+ a.customer_id,
+ a.customer_name,
+ COUNT(DISTINCT a.id) as agreement_count,
+ COUNT(DISTINCT a.id) FILTER (WHERE a.status = 'active') as active_agreements,
+
+ SUM(bp.used_hours) as total_hours_used,
+ SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') as total_revenue,
+ SUM(bp.used_hours * a.internal_cost_rate) as total_cost,
+ SUM(bp.base_amount + COALESCE(bp.overtime_amount, 0)) FILTER (WHERE bp.status = 'billed') -
+ SUM(bp.used_hours * a.internal_cost_rate) as total_profit,
+
+ MAX(bp.period_end) as last_billing_date
+
+FROM customer_fixed_price_agreements a
+LEFT JOIN fixed_price_billing_periods bp ON a.id = bp.agreement_id
+GROUP BY a.customer_id, a.customer_name;
+
+COMMENT ON VIEW fixed_price_agreement_performance IS
+ 'Aggregated performance metrics per agreement for reporting';
+
+COMMENT ON VIEW fixed_price_monthly_trends IS
+ 'Month-over-month revenue and profitability trends';
+
+COMMENT ON VIEW fixed_price_customer_summary IS
+ 'Customer-level aggregation of all fixed-price agreements';
diff --git a/requirements.txt b/requirements.txt
index 0a8006f..e199d3a 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -9,3 +9,10 @@ python-dateutil==2.8.2
jinja2==3.1.4
aiohttp==3.10.10
aiosmtplib==3.0.2
+PyJWT==2.10.1
+pyotp==2.9.0
+passlib[bcrypt]==1.7.4
+pandas==2.2.3
+msal==1.31.0
+paramiko==3.5.0
+APScheduler==3.10.4