feat(repairs): add repair estimates
This commit is contained in:
parent
6e7e75f864
commit
4436fe5f73
25 changed files with 1802 additions and 9 deletions
|
|
@ -3,6 +3,8 @@ from typing import Literal
|
|||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
|
||||
|
||||
from app.schemas.repair_estimate import PublicEstimateResponse
|
||||
|
||||
RepairStatus = Literal[
|
||||
"new",
|
||||
"accepted",
|
||||
|
|
@ -202,6 +204,7 @@ class RepairPublicStatusResponse(BaseModel):
|
|||
device_model: str
|
||||
status_history_public: list[RepairPublicStatusHistoryItem]
|
||||
updated_at: datetime
|
||||
estimate: PublicEstimateResponse | None = None
|
||||
|
||||
|
||||
class RepairNotificationTemplateResponse(BaseModel):
|
||||
|
|
|
|||
168
backend/hermes/app/schemas/repair_estimate.py
Normal file
168
backend/hermes/app/schemas/repair_estimate.py
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue