195 lines
6.2 KiB
Python
195 lines
6.2 KiB
Python
|
|
"""
|
||
|
|
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
|