Files
data-entry-app/backend/app/services/mix_calculator_service.py
T
admin 7db95e2027 v0.1.27
Fix: Throughput API v1 available - Details posted to Irving. POWERBI_KEY was missing from the .ENV file, so was not live.
Add: Editor now supports editing a mix's resolved formula directly, with % and kg dual entry on ingredient rows
Fix: Mix Editor should bring through correct ingredients. New resolved formula (same logic we use in Mix Calculator).
Fix: Security headers on all API responses (hardening)
Add: New mix button available on the Mix Editor.
Add: New ingredient button available on the Ingredient Editor
2026-06-16 14:43:17 +12:00

494 lines
21 KiB
Python

from __future__ import annotations
from datetime import date
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload, selectinload
from app.api.deps import AuthSession
from app.models.mix import Mix, MixIngredient
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
from app.models.product import Product, ProductIngredient
from app.schemas.mix_calculator import MixCalculatorSessionCreate, MixCalculatorSessionUpdate
from app.services.costing_engine import extract_unit_quantity_kg
def can_view_all_mix_calculator_sessions(session: AuthSession) -> bool:
return session.client_role in {"superadmin", "admin"}
def _build_session_access_query(session: AuthSession):
query = select(MixCalculatorSession).where(MixCalculatorSession.tenant_id == session.tenant_id)
if can_view_all_mix_calculator_sessions(session):
return query
return query.where(MixCalculatorSession.prepared_by_user_id == session.user_id)
def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) -> Product | None:
return db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True))
.options(
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
)
)
def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]:
if product.ingredients:
rows = [
{
"raw_material_id": ingredient.raw_material_id,
"raw_material_name": ingredient.raw_material.name,
"quantity_kg": ingredient.quantity_kg,
"unit": ingredient.raw_material.unit_of_measure,
"rounding_decimals": ingredient.raw_material.rounding_decimals,
"sort_order": ingredient.sort_order,
}
for ingredient in product.ingredients
if ingredient.raw_material is not None
]
elif product.mix is not None:
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,
"sort_order": index,
}
for index, ingredient in enumerate(product.mix.ingredients, start=1)
]
else:
rows = []
rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"]))
return rows, round(sum(row["quantity_kg"] for row in rows), 4)
def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_measure: str) -> str | None:
rounded_bags = round(total_bags)
if abs(total_bags - rounded_bags) < 1e-9:
return None
return (
f"Batch size {batch_size_kg:g}kg produces {total_bags:.2f} bags for {unit_of_measure}. "
"This is not a whole-bag quantity."
)
def _mix_calculator_label(product: Product) -> str:
return product.mix.name if product.mix else product.name
def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]:
unit_label = (product.unit_of_measure or "").lower()
unit_size = extract_unit_quantity_kg(product.unit_of_measure)
return (
0 if abs(unit_size - 20) < 1e-9 and "bag" in unit_label and "bulka" not in unit_label else 1,
0 if "bulka" not in unit_label else 1,
unit_size if unit_size > 0 else 999999,
product.id,
)
def resolve_representative_product(db: Session, *, tenant_id: str, mix_id: int) -> Product | None:
"""The single product the Mix Calculator surfaces for a given mix.
The calculator lists one representative product per (client, mix) and reads
its formula. The Mix Editor reuses this so it edits exactly what the
calculator shows. Preference order mirrors `build_mix_calculator_options`:
visible products that already have a product-specific formula, ranked by
`_mix_calculator_option_rank`; then any visible product; then any product.
"""
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id, Product.mix_id == mix_id)
.options(
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
)
).all()
if not products:
return None
with_formula = [product for product in products if product.visible and product.ingredients]
pool = with_formula or [product for product in products if product.visible] or list(products)
return min(pool, key=_mix_calculator_option_rank)
def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict:
"""Resolve a mix's formula the way the calculator does, for the editor.
Returns the resolved ingredient rows (with each row's share of the total as
`mix_percentage`), the total kg, and where the formula lives:
`source='product'` (a representative product's own formula) or `source='mix'`
(the shared mix master fallback). `product_id` names the product that owns the
formula when `source='product'`. The save path writes back to that same source.
"""
product = resolve_representative_product(db, tenant_id=tenant_id, mix_id=mix.id)
if product is not None and product.ingredients:
rows, total_kg = _resolved_formula_rows(product)
# Carry each ingredient's note through so saving doesn't wipe it.
notes_by_raw_material = {
ingredient.raw_material_id: ingredient.notes for ingredient in product.ingredients
}
for row in rows:
row["notes"] = notes_by_raw_material.get(row["raw_material_id"])
source = "product"
product_id = product.id
else:
# No product-specific formula: the calculator reads the shared mix master,
# so the editor shows and edits that.
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",
"sort_order": index,
"notes": ingredient.notes,
}
for index, ingredient in enumerate(
sorted(mix.ingredients, key=lambda item: item.raw_material.name if item.raw_material else ""),
start=1,
)
]
total_kg = round(sum(row["quantity_kg"] for row in rows), 4)
source = "mix"
product_id = product.id if product is not None else None
ingredients = [
{
"raw_material_id": row["raw_material_id"],
"raw_material_name": row["raw_material_name"],
"quantity_kg": round(row["quantity_kg"], 4),
"mix_percentage": round((row["quantity_kg"] / total_kg) * 100, 4) if total_kg > 0 else 0.0,
"unit": row["unit"],
"notes": row.get("notes"),
}
for row in rows
]
return {
"id": mix.id,
"tenant_id": mix.tenant_id,
"client_name": mix.client_name,
"name": mix.name,
"source": source,
"product_id": product_id,
"ingredients": ingredients,
"total_kg": total_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 = _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")
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)
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)
lines = []
for index, ingredient in enumerate(formula_rows, start=1):
mix_percentage = round((ingredient["quantity_kg"] / source_total_kg) * 100, 4)
required_kg = round(ingredient["quantity_kg"] * scale_factor, 4)
lines.append(
{
"raw_material_id": ingredient["raw_material_id"],
"raw_material_name": ingredient["raw_material_name"],
"required_kg": required_kg,
"mix_percentage": mix_percentage,
"unit": ingredient["unit"],
"rounding_decimals": ingredient.get("rounding_decimals", 2),
"sort_order": ingredient["sort_order"] or index,
}
)
mix_label = _mix_calculator_label(product)
return {
"client_name": product.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_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_size_kg": round(unit_size_kg, 4),
"prepared_by_name": values["prepared_by_name"],
"status": values.get("status") or "saved",
"notes": values.get("notes"),
"warnings": warnings,
"lines": lines,
}
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.
product_totals_rows = db.execute(
select(ProductIngredient.product_id, func.coalesce(func.sum(ProductIngredient.quantity_kg), 0.0))
.join(Product, Product.id == ProductIngredient.product_id)
.where(Product.tenant_id == tenant_id)
.group_by(ProductIngredient.product_id)
).all()
product_totals: dict[int, float] = {product_id: round(total or 0.0, 4) for product_id, total in product_totals_rows}
mix_totals_rows = db.execute(
select(MixIngredient.mix_id, func.coalesce(func.sum(MixIngredient.quantity_kg), 0.0))
.join(Mix, Mix.id == MixIngredient.mix_id)
.where(Mix.tenant_id == tenant_id)
.group_by(MixIngredient.mix_id)
).all()
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows}
product_ids_with_formulas = select(ProductIngredient.product_id).where(ProductIngredient.tenant_id == tenant_id)
products = db.scalars(
select(Product)
.where(
Product.tenant_id == tenant_id,
Product.visible.is_(True),
Product.id.in_(product_ids_with_formulas),
)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name)
).all()
representative_products: dict[tuple[str, str], Product] = {}
for product in products:
mix_label = _mix_calculator_label(product)
key = (product.client_name, mix_label)
current = representative_products.get(key)
if current is None:
representative_products[key] = product
continue
if _mix_calculator_option_rank(product) < _mix_calculator_option_rank(current):
representative_products[key] = product
products = sorted(
representative_products.values(),
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,
"client_name": product.client_name,
"product_name": _mix_calculator_label(product),
"mix_id": product.mix_id,
"mix_name": _mix_calculator_label(product),
"unit_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
}
for product in products
]
return {"clients": clients, "products": product_rows}
def serialize_mix_calculator_session(session_record: MixCalculatorSession, auth_session: AuthSession) -> dict:
total_bags = round(session_record.total_bags, 4)
warnings: list[str] = []
bag_warning = _fractional_bag_warning(session_record.batch_size_kg, total_bags, session_record.product_unit_of_measure)
if bag_warning:
warnings.append(bag_warning)
return {
"id": session_record.id,
"tenant_id": session_record.tenant_id,
"session_number": session_record.session_number,
"client_name": session_record.client_name,
"product_id": session_record.product_id,
"product_name": session_record.product_name,
"mix_id": session_record.mix_id,
"mix_name": session_record.mix_name,
"mix_date": session_record.mix_date,
"batch_size_kg": round(session_record.batch_size_kg, 4),
"total_bags": total_bags,
"total_kg": round(session_record.total_kg, 4),
"product_unit_of_measure": session_record.product_unit_of_measure,
"product_unit_size_kg": round(session_record.product_unit_size_kg, 4),
"prepared_by_user_id": session_record.prepared_by_user_id,
"prepared_by_name": session_record.prepared_by_name,
"created_by": session_record.created_by,
"status": session_record.status,
"notes": session_record.notes,
"created_at": session_record.created_at,
"updated_at": session_record.updated_at,
"warnings": warnings,
"is_owner": session_record.prepared_by_user_id == auth_session.user_id,
"lines": [
{
"id": line.id,
"raw_material_id": line.raw_material_id,
"raw_material_name": line.raw_material_name,
"required_kg": round(line.required_kg, 4),
"mix_percentage": round(line.mix_percentage, 4),
"unit": line.unit,
"rounding_decimals": line.rounding_decimals,
"sort_order": line.sort_order,
}
for line in session_record.lines
],
}
def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession, limit: int = 100) -> list[dict]:
sessions = db.scalars(
_build_session_access_query(auth_session)
.options(selectinload(MixCalculatorSession.lines))
.order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc())
.limit(limit)
).all()
return [serialize_mix_calculator_session(session_record, auth_session) for session_record in sessions]
def get_mix_calculator_session(db: Session, *, auth_session: AuthSession, session_id: int) -> MixCalculatorSession | None:
return db.scalar(
_build_session_access_query(auth_session)
.where(MixCalculatorSession.id == session_id)
.options(selectinload(MixCalculatorSession.lines))
)
def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str:
prefix = f"HPP-{mix_date.strftime('%Y%m%d')}-"
existing = db.scalars(
select(MixCalculatorSession.session_number)
.where(
MixCalculatorSession.tenant_id == tenant_id,
MixCalculatorSession.mix_date == mix_date,
MixCalculatorSession.session_number.like(f"{prefix}%"),
)
).all()
sequence = 1
if existing:
sequence = max(int(value.rsplit("-", 1)[-1]) for value in existing) + 1
return f"{prefix}{sequence:04d}"
def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict:
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",
session_number=_next_session_number(db, tenant_id=auth_session.tenant_id or "default", mix_date=payload.mix_date),
client_name=preview["client_name"],
product_id=preview["product_id"],
product_name=preview["product_name"],
mix_id=preview["mix_id"],
mix_name=preview["mix_name"],
mix_date=preview["mix_date"],
batch_size_kg=preview["batch_size_kg"],
total_bags=preview["total_bags"],
total_kg=preview["total_kg"],
product_unit_of_measure=preview["product_unit_of_measure"],
product_unit_size_kg=preview["product_unit_size_kg"],
prepared_by_user_id=auth_session.user_id,
prepared_by_name=preview["prepared_by_name"],
created_by=auth_session.email,
status=preview["status"],
notes=preview["notes"],
)
session_record.lines = [
MixCalculatorSessionLine(
tenant_id=auth_session.tenant_id or "default",
raw_material_id=line["raw_material_id"],
raw_material_name=line["raw_material_name"],
required_kg=line["required_kg"],
mix_percentage=line["mix_percentage"],
unit=line["unit"],
rounding_decimals=line.get("rounding_decimals", 2),
sort_order=line["sort_order"],
)
for line in preview["lines"]
]
db.add(session_record)
db.commit()
db.refresh(session_record)
db.refresh(session_record, attribute_names=["lines"])
return serialize_mix_calculator_session(session_record, auth_session)
def update_mix_calculator_session(
db: Session,
*,
auth_session: AuthSession,
session_record: MixCalculatorSession,
payload: MixCalculatorSessionUpdate,
) -> dict:
merged_values = {
"mix_date": session_record.mix_date,
"client_name": session_record.client_name,
"product_id": session_record.product_id,
"batch_size_kg": session_record.batch_size_kg,
"prepared_by_name": session_record.prepared_by_name,
"status": session_record.status,
"notes": session_record.notes,
}
merged_values.update(payload.model_dump(exclude_unset=True))
preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=merged_values)
session_record.client_name = preview["client_name"]
session_record.product_id = preview["product_id"]
session_record.product_name = preview["product_name"]
session_record.mix_id = preview["mix_id"]
session_record.mix_name = preview["mix_name"]
session_record.mix_date = preview["mix_date"]
session_record.batch_size_kg = preview["batch_size_kg"]
session_record.total_bags = preview["total_bags"]
session_record.total_kg = preview["total_kg"]
session_record.product_unit_of_measure = preview["product_unit_of_measure"]
session_record.product_unit_size_kg = preview["product_unit_size_kg"]
session_record.prepared_by_name = preview["prepared_by_name"]
session_record.status = preview["status"]
session_record.notes = preview["notes"]
session_record.lines.clear()
session_record.lines.extend(
[
MixCalculatorSessionLine(
tenant_id=auth_session.tenant_id or "default",
raw_material_id=line["raw_material_id"],
raw_material_name=line["raw_material_name"],
required_kg=line["required_kg"],
mix_percentage=line["mix_percentage"],
unit=line["unit"],
sort_order=line["sort_order"],
)
for line in preview["lines"]
]
)
db.commit()
db.refresh(session_record)
db.refresh(session_record, attribute_names=["lines"])
return serialize_mix_calculator_session(session_record, auth_session)