v0.1.32 - Mix Calculator search, ingredient categories, throughput tidy-up
- Mix Calculator: searchable Mix Name picker (mirrors Throughput search) - Ingredients Editor: add manual Category column; used to order Mix Calculator output - Mix Calculator: surface formula-only mixes (no product yet) via -mix_id sentinel - Throughput: remove unused For order / For stock destination controls from composer - Editor change history: show timestamps in local time (stored UTC) instead of raw UTC Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -838,6 +838,7 @@ def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict:
|
||||
"kg_per_unit": material.kg_per_unit,
|
||||
"status": material.status,
|
||||
"rounding_decimals": material.rounding_decimals,
|
||||
"category": material.category,
|
||||
"notes": material.notes,
|
||||
"cost_per_kg": cost_per_kg,
|
||||
"usage_count": usage_count,
|
||||
@@ -885,6 +886,7 @@ def create_editor_ingredient(
|
||||
kg_per_unit=payload.kg_per_unit,
|
||||
status=payload.status.strip() or "active",
|
||||
rounding_decimals=payload.rounding_decimals,
|
||||
category=(payload.category or "").strip() or None,
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(material)
|
||||
@@ -928,6 +930,8 @@ def update_editor_ingredient(
|
||||
updates["supplier"] = (updates["supplier"] or "").strip() or None
|
||||
if "unit_of_measure" in updates and updates["unit_of_measure"] is not None:
|
||||
updates["unit_of_measure"] = updates["unit_of_measure"].strip()
|
||||
if "category" in updates:
|
||||
updates["category"] = (updates["category"] or "").strip() or None
|
||||
before = {field: getattr(material, field) for field in updates}
|
||||
for field, value in updates.items():
|
||||
setattr(material, field, value)
|
||||
@@ -941,6 +945,7 @@ def update_editor_ingredient(
|
||||
"kg_per_unit": "Kg per unit",
|
||||
"status": "Status",
|
||||
"rounding_decimals": "Rounding (dp)",
|
||||
"category": "Category",
|
||||
"notes": "Notes",
|
||||
},
|
||||
)
|
||||
|
||||
@@ -139,6 +139,7 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = (
|
||||
("production_throughput_entries", "job_number", "VARCHAR(64)"),
|
||||
("production_throughput_entries", "stock_quantity", "FLOAT"),
|
||||
("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
|
||||
("raw_materials", "category", "VARCHAR(128)"),
|
||||
("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"),
|
||||
)
|
||||
|
||||
|
||||
@@ -18,6 +18,9 @@ class RawMaterial(Base):
|
||||
unit_of_measure: Mapped[str] = mapped_column(String(64))
|
||||
kg_per_unit: Mapped[float] = mapped_column(Float)
|
||||
status: Mapped[str] = mapped_column(String(32), default="active")
|
||||
# Manually-assigned grouping used to order ingredients in the Mix Calculator
|
||||
# output (e.g. "Grains", "Additives"). Optional; uncategorised rows sort last.
|
||||
category: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Decimal places this ingredient's required-kg is rounded to in the mix
|
||||
# calculator output. Set per-ingredient from the Ingredients Editor.
|
||||
rounding_decimals: Mapped[int] = mapped_column(Integer, default=2)
|
||||
|
||||
@@ -188,6 +188,8 @@ class EditorIngredientRow(BaseModel):
|
||||
unit_of_measure: str
|
||||
kg_per_unit: float
|
||||
status: str
|
||||
# Manual grouping used to order ingredients in the Mix Calculator output.
|
||||
category: str | None
|
||||
# Decimal places this ingredient is rounded to in the mix calculator output.
|
||||
rounding_decimals: int
|
||||
notes: str | None
|
||||
@@ -206,6 +208,7 @@ class EditorIngredientCreate(BaseModel):
|
||||
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)
|
||||
category: str | None = Field(default=None, max_length=128)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
@@ -218,6 +221,7 @@ class EditorIngredientUpdate(BaseModel):
|
||||
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)
|
||||
category: str | None = Field(default=None, max_length=128)
|
||||
notes: str | None = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ class MixCalculatorSessionLineRead(BaseModel):
|
||||
mix_percentage: float
|
||||
unit: str
|
||||
rounding_decimals: int = 2
|
||||
# Manual ingredient grouping used to order the calculator output.
|
||||
category: str | None = None
|
||||
sort_order: int
|
||||
|
||||
|
||||
|
||||
@@ -35,6 +35,33 @@ def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int)
|
||||
)
|
||||
|
||||
|
||||
def _category_sort_key(category: str | None) -> tuple[int, str]:
|
||||
"""Order ingredients by their manual category; uncategorised rows sort last."""
|
||||
cleaned = (category or "").strip()
|
||||
if not cleaned:
|
||||
return (1, "")
|
||||
return (0, cleaned.lower())
|
||||
|
||||
|
||||
def _order_formula_rows(rows: list[dict]) -> list[dict]:
|
||||
"""Sort rows by category (then their original order/name) and renumber.
|
||||
|
||||
Category is the primary key so the Mix Calculator groups ingredients by their
|
||||
manually-assigned category. `sort_order` is reassigned sequentially after the
|
||||
sort so every downstream consumer (lines, PDF) follows the same order.
|
||||
"""
|
||||
rows.sort(
|
||||
key=lambda row: (
|
||||
_category_sort_key(row.get("category")),
|
||||
row.get("sort_order") or 0,
|
||||
row["raw_material_name"].lower(),
|
||||
)
|
||||
)
|
||||
for index, row in enumerate(rows, start=1):
|
||||
row["sort_order"] = index
|
||||
return rows
|
||||
|
||||
|
||||
def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||
if product.ingredients:
|
||||
rows = [
|
||||
@@ -44,6 +71,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"unit": ingredient.raw_material.unit_of_measure,
|
||||
"rounding_decimals": ingredient.raw_material.rounding_decimals,
|
||||
"category": ingredient.raw_material.category,
|
||||
"sort_order": ingredient.sort_order,
|
||||
}
|
||||
for ingredient in product.ingredients
|
||||
@@ -57,6 +85,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
|
||||
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
|
||||
"sort_order": index,
|
||||
}
|
||||
for index, ingredient in enumerate(product.mix.ingredients, start=1)
|
||||
@@ -64,7 +93,29 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
|
||||
else:
|
||||
rows = []
|
||||
|
||||
rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"]))
|
||||
_order_formula_rows(rows)
|
||||
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
|
||||
|
||||
|
||||
def _mix_formula_rows(mix: Mix) -> tuple[list[dict], float]:
|
||||
"""Resolve a mix's own (mix-master) formula rows, category-ordered.
|
||||
|
||||
Used by the Mix Calculator for mixes that have a formula but no representative
|
||||
product yet — the formula lives directly on the mix.
|
||||
"""
|
||||
rows = [
|
||||
{
|
||||
"raw_material_id": ingredient.raw_material_id,
|
||||
"raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}",
|
||||
"quantity_kg": ingredient.quantity_kg,
|
||||
"unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg",
|
||||
"rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2,
|
||||
"category": ingredient.raw_material.category if ingredient.raw_material is not None else None,
|
||||
"sort_order": index,
|
||||
}
|
||||
for index, ingredient in enumerate(mix.ingredients, start=1)
|
||||
]
|
||||
_order_formula_rows(rows)
|
||||
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
|
||||
|
||||
|
||||
@@ -183,31 +234,34 @@ def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict
|
||||
}
|
||||
|
||||
|
||||
def calculate_mix_calculator_preview(
|
||||
db: Session,
|
||||
def _scale_preview(
|
||||
*,
|
||||
tenant_id: str,
|
||||
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
|
||||
):
|
||||
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
|
||||
product = _load_product_for_calculation(db, tenant_id, int(values["product_id"]))
|
||||
if product is None:
|
||||
raise ValueError("Product not found")
|
||||
if product.client_name != values["client_name"]:
|
||||
raise ValueError("Selected product does not belong to the chosen client")
|
||||
formula_rows, source_total_kg = _resolved_formula_rows(product)
|
||||
if source_total_kg <= 0:
|
||||
raise ValueError("Product has no source kilograms to scale")
|
||||
values: dict,
|
||||
formula_rows: list[dict],
|
||||
source_total_kg: float,
|
||||
client_name: str,
|
||||
product_id: int,
|
||||
mix_label: str,
|
||||
mix_id: int,
|
||||
unit_of_measure: str,
|
||||
) -> dict:
|
||||
"""Scale a resolved formula to the requested batch size and shape the preview.
|
||||
|
||||
Shared by the product-backed path and the formula-only mix path; only the
|
||||
inputs (where the formula and unit come from) differ.
|
||||
"""
|
||||
batch_size_kg = float(values["batch_size_kg"])
|
||||
scale_factor = batch_size_kg / source_total_kg
|
||||
unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure)
|
||||
unit_size_kg = extract_unit_quantity_kg(unit_of_measure)
|
||||
total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0
|
||||
|
||||
warnings: list[str] = []
|
||||
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure)
|
||||
if bag_warning:
|
||||
warnings.append(bag_warning)
|
||||
# A bag warning only makes sense when the unit resolves to a bag size; a
|
||||
# formula-only mix sells in bulk kg, so there's nothing to round to whole bags.
|
||||
if unit_size_kg > 0:
|
||||
bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, unit_of_measure)
|
||||
if bag_warning:
|
||||
warnings.append(bag_warning)
|
||||
|
||||
lines = []
|
||||
for index, ingredient in enumerate(formula_rows, start=1):
|
||||
@@ -221,24 +275,24 @@ def calculate_mix_calculator_preview(
|
||||
"mix_percentage": mix_percentage,
|
||||
"unit": ingredient["unit"],
|
||||
"rounding_decimals": ingredient.get("rounding_decimals", 2),
|
||||
"category": ingredient.get("category"),
|
||||
"sort_order": ingredient["sort_order"] or index,
|
||||
}
|
||||
)
|
||||
|
||||
mix_label = _mix_calculator_label(product)
|
||||
return {
|
||||
"client_name": product.client_name,
|
||||
"product_id": product.id,
|
||||
"client_name": client_name,
|
||||
"product_id": product_id,
|
||||
# The source workbook labels this as Product, but for the calculator
|
||||
# it is the mix/formula being produced.
|
||||
"product_name": mix_label,
|
||||
"mix_id": product.mix_id,
|
||||
"mix_id": mix_id,
|
||||
"mix_name": mix_label,
|
||||
"mix_date": values["mix_date"],
|
||||
"batch_size_kg": round(batch_size_kg, 4),
|
||||
"total_bags": total_bags,
|
||||
"total_kg": round(batch_size_kg, 4),
|
||||
"product_unit_of_measure": product.unit_of_measure,
|
||||
"product_unit_of_measure": unit_of_measure,
|
||||
"product_unit_size_kg": round(unit_size_kg, 4),
|
||||
"prepared_by_name": values["prepared_by_name"],
|
||||
"status": values.get("status") or "saved",
|
||||
@@ -248,6 +302,70 @@ def calculate_mix_calculator_preview(
|
||||
}
|
||||
|
||||
|
||||
def _calculate_mix_only_preview(db: Session, *, tenant_id: str, mix_id: int, values: dict) -> dict:
|
||||
"""Preview for a mix that has a formula but no representative product.
|
||||
|
||||
The Mix Calculator surfaces these via a negative `product_id` sentinel
|
||||
(`-mix_id`); the formula is read straight off the mix master and there's no
|
||||
product unit, so output is bulk kg with no bag split.
|
||||
"""
|
||||
mix = db.scalar(
|
||||
select(Mix)
|
||||
.where(Mix.id == mix_id, Mix.tenant_id == tenant_id)
|
||||
.options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
|
||||
)
|
||||
if mix is None:
|
||||
raise ValueError("Mix not found")
|
||||
if mix.client_name != values["client_name"]:
|
||||
raise ValueError("Selected mix does not belong to the chosen client")
|
||||
formula_rows, source_total_kg = _mix_formula_rows(mix)
|
||||
if source_total_kg <= 0:
|
||||
raise ValueError("Mix has no formula to scale")
|
||||
return _scale_preview(
|
||||
values=values,
|
||||
formula_rows=formula_rows,
|
||||
source_total_kg=source_total_kg,
|
||||
client_name=mix.client_name,
|
||||
product_id=-mix.id,
|
||||
mix_label=mix.name,
|
||||
mix_id=mix.id,
|
||||
unit_of_measure="kg",
|
||||
)
|
||||
|
||||
|
||||
def calculate_mix_calculator_preview(
|
||||
db: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict,
|
||||
):
|
||||
values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False)
|
||||
product_id = int(values["product_id"])
|
||||
# Negative ids are the sentinel for a formula-only mix (no product yet).
|
||||
if product_id < 0:
|
||||
return _calculate_mix_only_preview(db, tenant_id=tenant_id, mix_id=-product_id, values=values)
|
||||
|
||||
product = _load_product_for_calculation(db, tenant_id, product_id)
|
||||
if product is None:
|
||||
raise ValueError("Product not found")
|
||||
if product.client_name != values["client_name"]:
|
||||
raise ValueError("Selected product does not belong to the chosen client")
|
||||
formula_rows, source_total_kg = _resolved_formula_rows(product)
|
||||
if source_total_kg <= 0:
|
||||
raise ValueError("Product has no source kilograms to scale")
|
||||
|
||||
return _scale_preview(
|
||||
values=values,
|
||||
formula_rows=formula_rows,
|
||||
source_total_kg=source_total_kg,
|
||||
client_name=product.client_name,
|
||||
product_id=product.id,
|
||||
mix_label=_mix_calculator_label(product),
|
||||
mix_id=product.mix_id,
|
||||
unit_of_measure=product.unit_of_measure,
|
||||
)
|
||||
|
||||
|
||||
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||
# Prefer product-specific formulas where present; fall back to the shared
|
||||
# mix master for legacy rows that have not been migrated yet.
|
||||
@@ -296,7 +414,6 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||
key=lambda product: (product.client_name, _mix_calculator_label(product), product.id),
|
||||
)
|
||||
|
||||
clients = sorted({product.client_name for product in products})
|
||||
product_rows = [
|
||||
{
|
||||
"product_id": product.id,
|
||||
@@ -311,6 +428,44 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
|
||||
for product in products
|
||||
]
|
||||
|
||||
# Surface mixes that have a formula but no product at all yet (e.g. a freshly
|
||||
# created mix). They're selected via a negative `product_id` sentinel (-mix_id)
|
||||
# and calculated straight off the mix master — bulk kg, no bag split. A mix
|
||||
# whose only product is hidden is intentionally excluded (it HAS a product),
|
||||
# so check every product, not just the visible representatives.
|
||||
covered_mix_ids = set(
|
||||
db.scalars(
|
||||
select(Product.mix_id).where(Product.tenant_id == tenant_id).distinct()
|
||||
).all()
|
||||
)
|
||||
formula_only_mix_ids = [
|
||||
mix_id for mix_id, total in mix_totals.items() if total > 0 and mix_id not in covered_mix_ids
|
||||
]
|
||||
if formula_only_mix_ids:
|
||||
formula_only_mixes = db.scalars(
|
||||
select(Mix).where(
|
||||
Mix.tenant_id == tenant_id,
|
||||
Mix.id.in_(formula_only_mix_ids),
|
||||
Mix.status == "active",
|
||||
)
|
||||
).all()
|
||||
product_rows.extend(
|
||||
{
|
||||
"product_id": -mix.id,
|
||||
"client_name": mix.client_name,
|
||||
"product_name": mix.name,
|
||||
"mix_id": mix.id,
|
||||
"mix_name": mix.name,
|
||||
"unit_of_measure": "kg",
|
||||
"unit_size_kg": 0.0,
|
||||
"mix_total_kg": mix_totals.get(mix.id, 0.0),
|
||||
}
|
||||
for mix in formula_only_mixes
|
||||
)
|
||||
|
||||
product_rows.sort(key=lambda row: (row["client_name"], row["product_name"], row["product_id"]))
|
||||
clients = sorted({row["client_name"] for row in product_rows})
|
||||
|
||||
return {"clients": clients, "products": product_rows}
|
||||
|
||||
|
||||
@@ -396,6 +551,10 @@ def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str:
|
||||
|
||||
|
||||
def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict:
|
||||
if payload.product_id < 0:
|
||||
# Sessions reference a real product (FK). A formula-only mix has none yet —
|
||||
# it can still be previewed and printed, just not saved as a session.
|
||||
raise ValueError("Add a product to this mix before saving a calculator session.")
|
||||
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload)
|
||||
session_record = MixCalculatorSession(
|
||||
tenant_id=auth_session.tenant_id or "default",
|
||||
|
||||
@@ -482,7 +482,13 @@ def test_mix_calculator_endpoints_respect_owner_visibility():
|
||||
options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies)
|
||||
assert options_response.status_code == 200
|
||||
options_payload = options_response.json()
|
||||
assert len(options_payload["products"]) == 84
|
||||
# 83 product-backed mixes + 1 formula-only mix ("Hi Carb Popcorn", which
|
||||
# has a mix-master formula but no product yet, surfaced via a negative
|
||||
# product_id sentinel so a new mix is usable before a product is linked).
|
||||
assert len(options_payload["products"]) == 84 + 1
|
||||
formula_only = [product for product in options_payload["products"] if product["product_id"] < 0]
|
||||
assert len(formula_only) == 1
|
||||
assert formula_only[0]["unit_size_kg"] == 0
|
||||
seeded_product = next(
|
||||
product
|
||||
for product in options_payload["products"]
|
||||
|
||||
Reference in New Issue
Block a user