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
+ + + + + + + + + + + + + + {% if agreement.end_date %} + + + + + {% endif %} + + + + +
Kunde ID:{{ agreement.customer_id }}
Kunde:{{ agreement.customer_name }}
Start Dato:{{ agreement.start_date }}
Slut Dato:{{ agreement.end_date }}
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
+
+
+
+
+
+ + +
+
+
+
+ + + + + + + + + + + + + {% for period in periods %} + + + + + + + + + {% endfor %} + +
PeriodeStatusBrugte TimerResterende TimerOvertidMånedlig Værdi
+ {{ 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
+
+
+
+
+ + +
+
+
+ {% if sager %} +
+ + + + + + + + + + + + {% for sag in sager %} + + + + + + + + {% endfor %} + +
Sag IDTitelStatusOprettetHandlinger
#{{ 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 + +
+
+ {% else %} +
+ +

Ingen sager endnu

+
+ {% endif %} +
+
+
+ + +
+
+
+ {% if time_entries %} +
+ + + + + + + + + + + + {% for entry in time_entries %} + + + + + + + + {% endfor %} + +
DatoSagBeskrivelseTimerAfrundet
{{ 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 %} +
+
+ {% 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

+
+
+ + Rapporter + + +
+
+ + +
+
+
+
+
+
+
+ +
+
+
+

Aktive Aftaler

+

-

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

Total Omsætning

+

-

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

Total Profit

+

-

+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+

Brugte Timer

+

-

+
+
+
+
+
+
+ + +
+
+
+
+
Alle Aftaler
+
+
+ +
+
+
+
+
+ + + + + + + + + + + + + + + + + +
Aftale Nr.KundeMånedlige TimerStatusDenne MånedStartHandlinger
+
+ 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 +
+
+
+
+ + + + + +
+ +
+
+
+
Aftale Performance
+ Sorteret efter profit margin +
+
+ {% if performance %} +
+ + + + + + + + + + + + + + {% for agr in performance %} + + + + + + + + + + {% endfor %} + +
AftaleKundeTotal TimerMånedlig VærdiProfitMarginUdnyttelse
+ + {{ agr.agreement_number }} + + {{ agr.customer_name }}{{ '%.1f'|format(agr.total_used_hours or 0) }}t{{ "{:,.0f}".format(agr.total_base_revenue or 0) }} kr + {% if agr.estimated_profit and agr.estimated_profit > 0 %} + {{ "{:,.0f}".format(agr.estimated_profit) }} kr + {% else %} + {{ "{:,.0f}".format(agr.estimated_profit or 0) }} kr + {% endif %} + + {% if agr.profit_margin and agr.profit_margin >= 30 %} + {{ '%.1f'|format(agr.profit_margin) }}% + {% elif agr.profit_margin and agr.profit_margin >= 15 %} + {{ '%.1f'|format(agr.profit_margin) }}% + {% else %} + {{ '%.1f'|format(agr.profit_margin or 0) }}% + {% endif %} + + {% set utilization = ((agr.total_used_hours or 0) / (agr.total_allocated_hours or 1) * 100) if agr.total_allocated_hours else 0 %} + {% if utilization >= 80 %} + {{ '%.0f'|format(utilization) }}% + {% elif utilization >= 50 %} + {{ '%.0f'|format(utilization) }}% + {% else %} + {{ '%.0f'|format(utilization) }}% + {% endif %} +
+
+ {% else %} +
+ +

Ingen performance data tilgængelig

+
+ {% endif %} +
+
+
+ + + + + +
+
+
+
Top Kunder
+ Sorteret efter total forbrug +
+
+ {% if customers %} +
+ + + + + + + + + + + + + {% for customer in customers %} + + + + + + + + + {% endfor %} + +
KundeAftalerTotal TimerOvertidTotal VærdiAvg Margin
{{ 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 %} +
+
+ {% 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 @@ +