2026-06-13 10:00:15 +12:00
|
|
|
from datetime import datetime
|
|
|
|
|
|
2026-06-03 00:17:12 +12:00
|
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorProductRow(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
tenant_id: str
|
|
|
|
|
client_name: str
|
|
|
|
|
item_id: str | None
|
|
|
|
|
name: str
|
|
|
|
|
mix_id: int
|
|
|
|
|
mix_client_name: str
|
|
|
|
|
mix_name: str
|
|
|
|
|
sale_type: str
|
|
|
|
|
unit_of_measure: str
|
|
|
|
|
visible: bool
|
|
|
|
|
product_notes: str | None
|
|
|
|
|
mix_notes: str | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorProductUpdate(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
client_name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
|
|
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
|
|
|
item_id: str | None = Field(default=None, max_length=128)
|
|
|
|
|
mix_id: int | None = None
|
|
|
|
|
sale_type: str | None = Field(default=None, min_length=1, max_length=64)
|
|
|
|
|
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
|
|
|
|
|
visible: bool | None = None
|
|
|
|
|
notes: str | None = Field(default=None, max_length=2000)
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
class EditorMixCreate(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
client_name: str = Field(min_length=1, max_length=255)
|
|
|
|
|
name: str = Field(min_length=1, max_length=255)
|
|
|
|
|
notes: str | None = Field(default=None, max_length=2000)
|
|
|
|
|
|
|
|
|
|
|
2026-06-03 00:17:12 +12:00
|
|
|
class EditorMixUpdate(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
client_name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
|
|
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
|
|
|
notes: str | None = Field(default=None, max_length=2000)
|
2026-06-13 10:00:15 +12:00
|
|
|
# Toggling a mix's status fans out to the visibility of every product under it.
|
|
|
|
|
visible: bool | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorMixRow(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
tenant_id: str
|
|
|
|
|
client_name: str
|
|
|
|
|
name: str
|
|
|
|
|
# A mix reads as "Active" when at least one of its products is visible.
|
|
|
|
|
visible: bool
|
|
|
|
|
product_count: int
|
|
|
|
|
visible_product_count: int
|
|
|
|
|
notes: str | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorMixIngredientRead(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
raw_material_id: int
|
|
|
|
|
raw_material_name: str
|
|
|
|
|
quantity_kg: float
|
|
|
|
|
notes: str | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorMixFormulaRead(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
tenant_id: str
|
|
|
|
|
client_name: str
|
|
|
|
|
name: str
|
|
|
|
|
ingredients: list[EditorMixIngredientRead]
|
|
|
|
|
total_kg: float
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
class EditorResolvedMixIngredient(BaseModel):
|
|
|
|
|
raw_material_id: int
|
|
|
|
|
raw_material_name: str
|
|
|
|
|
quantity_kg: float
|
|
|
|
|
# This row's share of the mix total, matching the Mix Calculator.
|
|
|
|
|
mix_percentage: float
|
|
|
|
|
unit: str
|
|
|
|
|
notes: str | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorResolvedMixFormula(BaseModel):
|
|
|
|
|
"""A mix formula resolved the way the Mix Calculator reads it.
|
|
|
|
|
|
|
|
|
|
`source` is `product` when the numbers come from a representative product's
|
|
|
|
|
own formula, or `mix` when they come from the shared mix master fallback.
|
|
|
|
|
The PUT endpoint writes back to whichever source produced these rows.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
id: int
|
|
|
|
|
tenant_id: str
|
|
|
|
|
client_name: str
|
|
|
|
|
name: str
|
|
|
|
|
source: str
|
|
|
|
|
product_id: int | None
|
|
|
|
|
ingredients: list[EditorResolvedMixIngredient]
|
|
|
|
|
total_kg: float
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorMixFormulaRowInput(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
raw_material_id: int
|
|
|
|
|
quantity_kg: float = Field(gt=0)
|
|
|
|
|
notes: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorMixFormulaReplace(BaseModel):
|
|
|
|
|
"""Full replacement of a mix's formula in one save.
|
|
|
|
|
|
|
|
|
|
The frontend keeps kilograms as the canonical value (percentages are an
|
|
|
|
|
entry aid that resolve back to kg against the total), so the API only needs
|
|
|
|
|
the resolved kg per row.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
rows: list[EditorMixFormulaRowInput] = Field(min_length=1)
|
|
|
|
|
|
|
|
|
|
|
2026-06-13 10:00:15 +12:00
|
|
|
class EditorMixIngredientCreate(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
raw_material_id: int
|
|
|
|
|
quantity_kg: float = Field(gt=0)
|
|
|
|
|
notes: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorMixIngredientUpdate(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
quantity_kg: float | None = Field(default=None, gt=0)
|
|
|
|
|
notes: str | None = Field(default=None, max_length=1000)
|
2026-06-03 15:09:21 +12:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorProductIngredientCreate(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
raw_material_id: int
|
|
|
|
|
quantity_kg: float = Field(gt=0)
|
|
|
|
|
notes: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorProductIngredientUpdate(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
quantity_kg: float | None = Field(default=None, gt=0)
|
|
|
|
|
notes: str | None = Field(default=None, max_length=1000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorProductIngredientRead(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
raw_material_id: int
|
|
|
|
|
raw_material_name: str
|
|
|
|
|
quantity_kg: float
|
|
|
|
|
sort_order: int
|
|
|
|
|
notes: str | None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorProductFormulaRead(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
tenant_id: str
|
|
|
|
|
client_name: str
|
|
|
|
|
name: str
|
|
|
|
|
mix_id: int
|
|
|
|
|
mix_name: str
|
|
|
|
|
ingredients: list[EditorProductIngredientRead]
|
|
|
|
|
total_kg: float
|
2026-06-13 10:00:15 +12:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# --- Ingredients (raw materials) catalogue -----------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorIngredientRow(BaseModel):
|
|
|
|
|
id: int
|
|
|
|
|
name: str
|
|
|
|
|
supplier: str | None
|
|
|
|
|
unit_of_measure: str
|
|
|
|
|
kg_per_unit: float
|
|
|
|
|
status: str
|
|
|
|
|
# Decimal places this ingredient is rounded to in the mix calculator output.
|
|
|
|
|
rounding_decimals: int
|
|
|
|
|
notes: str | None
|
|
|
|
|
cost_per_kg: float | None
|
|
|
|
|
# How many product/mix formulas currently reference this ingredient.
|
|
|
|
|
usage_count: int
|
|
|
|
|
created_at: datetime
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorIngredientCreate(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
name: str = Field(min_length=1, max_length=255)
|
|
|
|
|
supplier: str | None = Field(default=None, max_length=255)
|
|
|
|
|
unit_of_measure: str = Field(min_length=1, max_length=64)
|
|
|
|
|
kg_per_unit: float = Field(gt=0)
|
|
|
|
|
status: str = Field(default="active", max_length=32)
|
|
|
|
|
rounding_decimals: int = Field(default=2, ge=0, le=6)
|
|
|
|
|
notes: str | None = Field(default=None, max_length=2000)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class EditorIngredientUpdate(BaseModel):
|
|
|
|
|
model_config = ConfigDict(extra="forbid")
|
|
|
|
|
|
|
|
|
|
name: str | None = Field(default=None, min_length=1, max_length=255)
|
|
|
|
|
supplier: str | None = Field(default=None, max_length=255)
|
|
|
|
|
unit_of_measure: str | None = Field(default=None, min_length=1, max_length=64)
|
|
|
|
|
kg_per_unit: float | None = Field(default=None, gt=0)
|
|
|
|
|
status: str | None = Field(default=None, max_length=32)
|
|
|
|
|
rounding_decimals: int | None = Field(default=None, ge=0, le=6)
|
|
|
|
|
notes: str | None = Field(default=None, max_length=2000)
|