from datetime import date, datetime from decimal import Decimal, ROUND_HALF_UP from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled"] EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"] EstimateActorType = Literal["user", "customer", "system"] EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question"] def normalize_text(value: object) -> str: if value is None: return "" return str(value).strip() class RepairEstimateItemPayload(BaseModel): item_type: EstimateItemType = "other" inventory_item_id: int | None = None inventory_price_overridden: bool = False title: str = Field(default="", max_length=255) description: str | None = None quantity: Decimal = Field(gt=Decimal("0")) unit: str = Field(default="Stk.", max_length=40) unit_price_cents: int = Field(ge=0) @field_validator("title", "description", "unit", mode="before") @classmethod def normalize_strings(cls, value: object) -> str | None: if value is None: return None return normalize_text(value) @field_validator("quantity", mode="before") @classmethod def normalize_quantity(cls, value: object) -> object: if isinstance(value, str): return value.strip().replace(",", ".") return value @model_validator(mode="after") def validate_inventory_or_title(self): if self.inventory_item_id is None and not self.title.strip(): raise ValueError("Titel ist erforderlich") return self class RepairEstimatePayload(BaseModel): title: str = Field(min_length=1, max_length=255) customer_message: str = "" internal_note: str | None = None tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0")) currency: str = Field(default="EUR", max_length=3) valid_until: date | None = None items: list[RepairEstimateItemPayload] @field_validator("title", "customer_message", "internal_note", "currency", mode="before") @classmethod def normalize_strings(cls, value: object) -> str | None: if value is None: return None return normalize_text(value) @field_validator("currency") @classmethod def normalize_currency(cls, value: str) -> str: return value.upper() or "EUR" @field_validator("tax_rate_percent", mode="before") @classmethod def normalize_tax_rate(cls, value: object) -> object: if isinstance(value, str): return value.strip().replace(",", ".") return value @model_validator(mode="after") def validate_items(self): if not self.items: raise ValueError("Mindestens eine Position ist erforderlich") return self class RepairEstimateCreate(RepairEstimatePayload): pass class RepairEstimateUpdate(RepairEstimatePayload): pass class RepairEstimateItemResponse(BaseModel): id: int estimate_id: int inventory_item_id: int | None inventory_snapshot_name: str inventory_snapshot_sku: str inventory_snapshot_manufacturer: str | None inventory_snapshot_part_number: str | None position: int item_type: EstimateItemType title: str description: str | None quantity: Decimal unit: str unit_price_cents: int total_cents: int created_at: datetime updated_at: datetime model_config = ConfigDict(from_attributes=True) class RepairEstimateEventResponse(BaseModel): id: int estimate_id: int event_type: str actor_type: str actor_user_id: int | None note: str | None created_at: datetime model_config = ConfigDict(from_attributes=True) class RepairEstimateResponse(BaseModel): id: int repair_id: int estimate_number: str status: EstimateStatus title: str customer_message: str internal_note: str | None subtotal_cents: int tax_rate_percent: Decimal tax_cents: int total_cents: int currency: str valid_until: date | None sent_at: datetime | None approved_at: datetime | None declined_at: datetime | None customer_response_message: str | None created_by_user_id: int | None created_at: datetime updated_at: datetime items: list[RepairEstimateItemResponse] model_config = ConfigDict(from_attributes=True) class PublicEstimateItemResponse(BaseModel): item_type: EstimateItemType inventory_snapshot_name: str inventory_snapshot_sku: str inventory_snapshot_manufacturer: str | None inventory_snapshot_part_number: str | None title: str description: str | None quantity: Decimal unit: str unit_price_cents: int total_cents: int class PublicEstimateResponse(BaseModel): estimate_number: str status: EstimateStatus title: str customer_message: str subtotal_cents: int tax_cents: int total_cents: int currency: str valid_until: date | None items: list[PublicEstimateItemResponse] class PublicEstimateDecisionRequest(BaseModel): message: str | None = Field(default=None, max_length=2000) @field_validator("message", mode="before") @classmethod def normalize_message(cls, value: object) -> str | None: if value is None: return None text = normalize_text(value) return text or None def calculate_item_total(quantity: Decimal, unit_price_cents: int) -> int: total = (quantity * Decimal(unit_price_cents)).quantize(Decimal("1"), rounding=ROUND_HALF_UP) return int(total) def calculate_tax(subtotal_cents: int, tax_rate_percent: Decimal) -> int: tax = (Decimal(subtotal_cents) * tax_rate_percent / Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP) return int(tax)