# /opt/docker/dev/service_finder/backend/app/schemas/asset_cost.py from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator from typing import Optional, Dict, Any from datetime import datetime, date from decimal import Decimal from uuid import UUID class AssetCostBase(BaseModel): """Base schema — matches the AssetCost SQLAlchemy model fields. GROSS-FIRST (Masterbook 2.0.1): amount_gross is the primary/required field. In EU accounting, the gross amount (Bruttó) is the absolute Source of Truth. amount_net is optional — if only gross is provided, net = gross (0% VAT). If vat_rate is also provided, net is calculated back from gross. """ amount_gross: Optional[Decimal] = Field(None, description="Bruttó összeg (ÁFA-val) — ELSŐDLEGES forrás, de lehet NULL legacy rekordoknál") amount_net: Optional[Decimal] = Field(None, description="Nettó összeg (ÁFA nélkül) — OPCIONÁLIS, ha nincs megadva, bruttóból számolva") @model_validator(mode='before') @classmethod def safety_net_gross_from_net(cls, data: Any) -> Any: """SAFETY NET (Graceful Degradation): If the incoming payload contains amount_net but amount_gross is missing/None, automatically copy amount_net to amount_gross. This protects against legacy/cached browsers that still send amount_net in their payloads. GROSS-FIRST (Masterbook 2.0.1): The backend is strictly Gross-first. """ if isinstance(data, dict): has_net = data.get('amount_net') is not None has_gross = data.get('amount_gross') is not None if has_net and not has_gross: data['amount_gross'] = data['amount_net'] return data vat_rate: Optional[Decimal] = Field(None, description="ÁFA kulcs (pl. 27.00)") currency: str = "HUF" cost_date: datetime = Field(default_factory=lambda: datetime.now(), validation_alias="date", description="Költség rögzítésének dátuma/időpontja") invoice_number: Optional[str] = None data: Dict[str, Any] = Field(default_factory=dict) # nyugta adatai, GPS koordináták category_id: int # FK a CostCategory táblához mileage_at_cost: Optional[int] = None # Stored inside data JSONB description: Optional[str] = None # Stored inside data JSONB # === BESZÁLLÍTÓI ADATOK (B2B expansion) === vendor_organization_id: Optional[int] = Field(None, description="Beszállító szervezet ID (FK fleet.organizations)") external_vendor_name: Optional[str] = Field(None, max_length=200, description="Szabadon gépelhető beszállító név (ha nincs a rendszerben)") # === KÖNYVELÉSI DÁTUMOK === invoice_date: Optional[date] = Field(None, description="Számla kiállításának dátuma") fulfillment_date: Optional[date] = Field(None, description="Teljesítés dátuma") @model_validator(mode='after') def validate_gross_first(self): """Gross-First validation: ensure amount_gross is the primary source. If amount_net is not provided but vat_rate is, calculate net from gross. If neither amount_net nor vat_rate is provided, net = gross (0% VAT). If amount_gross is None (legacy data), default to 0 to prevent 500 errors. """ gross = self.amount_gross net = self.amount_net vat = self.vat_rate # Fault tolerance: if gross is None (legacy NULL), default to 0 if gross is None: self.amount_gross = Decimal("0") gross = self.amount_gross if net is None and vat is not None and vat > 0: # Net will be calculated in the service layer from gross & vat pass elif net is None: # No net and no vat → net = gross (0% VAT content) self.amount_net = gross self.vat_rate = Decimal("0") return self class AssetCostCreate(AssetCostBase): asset_id: UUID organization_id: Optional[int] = None # Auto-resolved from asset if not provided status: Optional[str] = Field(None, description="Költség státusz (DRAFT, PENDING_APPROVAL, APPROVED) - auto-set by backend") linked_asset_event_id: Optional[UUID] = Field(None, description="Kapcsolódó AssetEvent ID (kétirányú hivatkozáshoz)") class AssetCostUpdate(BaseModel): """Schema for updating an existing expense via PUT /expenses/{expense_id}. All fields are optional — only provided fields will be updated. The expense_id is passed via path parameter, asset_id and organization_id are immutable after creation. """ category_id: Optional[int] = None amount_gross: Optional[Decimal] = None amount_net: Optional[Decimal] = None vat_rate: Optional[Decimal] = None currency: Optional[str] = None date: Optional[datetime] = None mileage_at_cost: Optional[int] = None description: Optional[str] = None invoice_number: Optional[str] = None vendor_organization_id: Optional[int] = None external_vendor_name: Optional[str] = None invoice_date: Optional[date] = None fulfillment_date: Optional[date] = None data: Optional[Dict[str, Any]] = None class AssetCostResponse(AssetCostBase): """Response schema — matches DB columns from vehicle.asset_costs.""" id: UUID asset_id: UUID organization_id: int status: str = "DRAFT" linked_asset_event_id: Optional[UUID] = None # Derived / enriched fields (not in DB model directly) category_name: Optional[str] = None # Resolved from CostCategory relationship category_code: Optional[str] = None # Resolved from CostCategory relationship description: Optional[str] = None # Extracted from data['description'] mileage_at_cost: Optional[int] = None # Extracted from data['mileage_at_cost'] # Enriched vendor name (resolved from vendor_organization_id relationship) vendor_name: Optional[str] = None # Resolved from Organization.name via vendor_organization_id model_config = ConfigDict(from_attributes=True)