feat(inventory): add spare parts management

This commit is contained in:
Schubert Ferenc 2026-07-05 11:32:06 +02:00
parent abc6e308dd
commit fb5c2cc26a
43 changed files with 2950 additions and 2 deletions

View file

@ -0,0 +1,231 @@
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)