""" 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