from datetime import datetime from decimal import Decimal from typing import Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator InventoryMovementType = Literal[ "initial", "purchase", "adjustment", "reservation", "release", "consumption", "return", "correction", ] def normalize_text(value: object) -> str: if value is None: return "" return str(value).strip() def normalize_optional_text(value: object) -> str | None: text = normalize_text(value) return text or None class InventoryCategoryPayload(BaseModel): name: str = Field(min_length=1, max_length=160) description: str | None = None @field_validator("name", "description", mode="before") @classmethod def normalize_strings(cls, value: object) -> str | None: if value is None: return None return normalize_text(value) class InventoryLocationPayload(BaseModel): name: str = Field(min_length=1, max_length=160) description: str | None = None @field_validator("name", "description", mode="before") @classmethod def normalize_strings(cls, value: object) -> str | None: if value is None: return None return normalize_text(value) class InventorySupplierPayload(BaseModel): name: str = Field(min_length=1, max_length=180) contact_name: str | None = None email: str | None = None phone: str | None = None website: str | None = None customer_number: str | None = None notes: str | None = None @field_validator("*", mode="before") @classmethod def normalize_strings(cls, value: object) -> str | None: if value is None: return None return normalize_optional_text(value) class InventoryItemPayload(BaseModel): sku: str | None = Field(default=None, max_length=40) name: str = Field(min_length=1, max_length=255) description: str | None = None category_id: int | None = None manufacturer: str | None = None manufacturer_part_number: str | None = None supplier_id: int | None = None supplier_part_number: str | None = None compatible_devices: str | None = None location_id: int | None = None quantity_on_hand: int = Field(default=0, ge=0) quantity_reserved: int = Field(default=0, ge=0) reorder_level: int = Field(default=0, ge=0) unit: str = Field(default="Stk.", max_length=40) purchase_price_cents: int | None = Field(default=None, ge=0) selling_price_cents: int | None = Field(default=None, ge=0) currency: str = Field(default="EUR", max_length=3) tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0")) notes: str | None = None is_active: bool = True @field_validator( "sku", "name", "description", "manufacturer", "manufacturer_part_number", "supplier_part_number", "compatible_devices", "unit", "currency", "notes", 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_reserved_quantity(self): if self.quantity_reserved > self.quantity_on_hand: raise ValueError("Reservierter Bestand darf den Lagerbestand nicht überschreiten") return self class InventoryItemCreate(InventoryItemPayload): pass class InventoryItemUpdate(InventoryItemPayload): pass class InventoryStockAction(BaseModel): quantity: int = Field(ge=0) reason: str = Field(min_length=1, max_length=255) note: str | None = None reference_type: str | None = Field(default=None, max_length=80) reference_id: int | None = None @field_validator("reason", "note", "reference_type", mode="before") @classmethod def normalize_strings(cls, value: object) -> str | None: if value is None: return None return normalize_text(value) class InventoryCategoryResponse(InventoryCategoryPayload): id: int slug: str created_at: datetime updated_at: datetime model_config = ConfigDict(from_attributes=True) class InventoryLocationResponse(InventoryLocationPayload): id: int created_at: datetime updated_at: datetime model_config = ConfigDict(from_attributes=True) class InventorySupplierResponse(InventorySupplierPayload): id: int created_at: datetime updated_at: datetime model_config = ConfigDict(from_attributes=True) class InventoryItemResponse(BaseModel): id: int sku: str name: str description: str | None category_id: int | None manufacturer: str | None manufacturer_part_number: str | None supplier_id: int | None supplier_part_number: str | None compatible_devices: str | None location_id: int | None quantity_on_hand: int quantity_reserved: int quantity_available: int reorder_level: int unit: str purchase_price_cents: int | None selling_price_cents: int | None currency: str tax_rate_percent: Decimal notes: str | None is_active: bool created_at: datetime updated_at: datetime category: InventoryCategoryResponse | None supplier: InventorySupplierResponse | None location: InventoryLocationResponse | None model_config = ConfigDict(from_attributes=True) class InventoryItemListResponse(BaseModel): items: list[InventoryItemResponse] total: int limit: int offset: int class InventoryStockMovementResponse(BaseModel): id: int item_id: int movement_type: str quantity: int reason: str reference_type: str | None reference_id: int | None note: str | None actor_user_id: int | None actor_username: str created_at: datetime model_config = ConfigDict(from_attributes=True)