Files
data-entry-app/backend/app/services/mix_calculator_service.py
T

399 lines
16 KiB
Python
Raw Normal View History

2026-04-29 23:05:27 +12:00
from __future__ import annotations
from datetime import date
from sqlalchemy import func, select
from sqlalchemy.orm import Session, joinedload, selectinload
2026-04-29 23:05:27 +12:00
from app.api.deps import AuthSession
from app.models.mix import Mix, MixIngredient
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
2026-05-31 20:19:44 +12:00
from app.models.product import Product, ProductIngredient
2026-04-29 23:05:27 +12:00
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)
2026-05-10 09:46:07 +12:00
.where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True))
2026-05-31 20:19:44 +12:00
.options(
selectinload(Product.ingredients).selectinload(ProductIngredient.raw_material),
selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material),
)
2026-04-29 23:05:27 +12:00
)
2026-05-31 20:19:44 +12:00
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,
"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",
"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)
2026-04-29 23:05:27 +12:00
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."
)
2026-06-09 21:28:53 +12:00
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,
)
2026-04-29 23:05:27 +12:00
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")
2026-05-31 20:19:44 +12:00
formula_rows, source_total_kg = _resolved_formula_rows(product)
2026-04-29 23:05:27 +12:00
if source_total_kg <= 0:
2026-05-31 20:19:44 +12:00
raise ValueError("Product has no source kilograms to scale")
2026-04-29 23:05:27 +12:00
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 = []
2026-05-31 20:19:44 +12:00
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)
2026-04-29 23:05:27 +12:00
lines.append(
{
2026-05-31 20:19:44 +12:00
"raw_material_id": ingredient["raw_material_id"],
"raw_material_name": ingredient["raw_material_name"],
2026-04-29 23:05:27 +12:00
"required_kg": required_kg,
"mix_percentage": mix_percentage,
2026-05-31 20:19:44 +12:00
"unit": ingredient["unit"],
"sort_order": ingredient["sort_order"] or index,
2026-04-29 23:05:27 +12:00
}
)
2026-06-09 21:28:53 +12:00
mix_label = _mix_calculator_label(product)
2026-04-29 23:05:27 +12:00
return {
"client_name": product.client_name,
"product_id": product.id,
2026-06-09 21:28:53 +12:00
# The source workbook labels this as Product, but for the calculator
# it is the mix/formula being produced.
"product_name": mix_label,
2026-04-29 23:05:27 +12:00
"mix_id": product.mix_id,
2026-06-09 21:28:53 +12:00
"mix_name": mix_label,
2026-04-29 23:05:27 +12:00
"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:
2026-05-31 20:19:44 +12:00
# 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}
2026-06-09 21:28:53 +12:00
product_ids_with_formulas = select(ProductIngredient.product_id).where(ProductIngredient.tenant_id == tenant_id)
2026-04-29 23:05:27 +12:00
products = db.scalars(
select(Product)
2026-06-09 21:28:53 +12:00
.where(
Product.tenant_id == tenant_id,
Product.visible.is_(True),
Product.id.in_(product_ids_with_formulas),
)
.options(joinedload(Product.mix))
2026-04-29 23:05:27 +12:00
.order_by(Product.client_name, Product.name)
).all()
2026-06-09 21:28:53 +12:00
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),
)
2026-04-29 23:05:27 +12:00
clients = sorted({product.client_name for product in products})
product_rows = [
{
"product_id": product.id,
"client_name": product.client_name,
2026-06-09 21:28:53 +12:00
"product_name": _mix_calculator_label(product),
"mix_id": product.mix_id,
2026-06-09 21:28:53 +12:00
"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),
2026-05-31 20:19:44 +12:00
"mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
}
for product in products
]
2026-04-29 23:05:27 +12:00
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,
"sort_order": line.sort_order,
}
for line in session_record.lines
],
}
2026-05-10 09:46:07 +12:00
def list_mix_calculator_sessions(db: Session, *, auth_session: AuthSession, limit: int = 100) -> list[dict]:
2026-04-29 23:05:27 +12:00
sessions = db.scalars(
_build_session_access_query(auth_session)
.options(selectinload(MixCalculatorSession.lines))
.order_by(MixCalculatorSession.created_at.desc(), MixCalculatorSession.id.desc())
2026-05-10 09:46:07 +12:00
.limit(limit)
2026-04-29 23:05:27 +12:00
).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"],
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)