bmc_hub/app/fixed_price/backend/models.py
Christian e4b9091a1b feat: Implement fixed-price agreements frontend views and related templates
- Added views for listing fixed-price agreements, displaying agreement details, and a reporting dashboard.
- Created HTML templates for listing, detailing, and reporting on fixed-price agreements.
- Introduced API endpoint to fetch active customers for agreement creation.
- Added migration scripts for creating necessary database tables and views for fixed-price agreements, billing periods, and reporting.
- Implemented triggers for auto-generating agreement numbers and updating timestamps.
- Enhanced ticket management with archived ticket views and filtering capabilities.
2026-02-08 01:45:00 +01:00

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