168 lines
4.7 KiB
Python
168 lines
4.7 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"]
|
|
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"
|
|
title: str = Field(min_length=1, 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)
|
|
|
|
|
|
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"
|
|
|
|
@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
|
|
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
|
|
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)
|