Olympus/backend/hermes/app/schemas/repair_estimate.py
2026-07-05 13:41:33 +02:00

208 lines
6.2 KiB
Python

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", "revoked"]
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", "revoked"]
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
lexware_invoice_id: str | None
lexware_invoice_number: str | None
lexware_invoice_status: str | None
lexware_synced_at: datetime | None
accounting_export_status: str | None
accounting_note: str | None
accounting_transferred_at: datetime | None
accounting_transferred_by_user_id: int | 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
status_label: str
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)