This commit is contained in:
2026-05-31 20:19:44 +12:00
parent 2f2466ecac
commit 84792c0947
59 changed files with 5412 additions and 898 deletions
+57 -21
View File
@@ -8,7 +8,7 @@ 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
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
@@ -28,10 +28,44 @@ def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int)
return db.scalar(
select(Product)
.where(Product.id == product_id, Product.tenant_id == tenant_id, Product.visible.is_(True))
.options(selectinload(Product.mix).selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material))
.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,
"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)
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:
@@ -54,12 +88,9 @@ def calculate_mix_calculator_preview(
raise ValueError("Product not found")
if product.client_name != values["client_name"]:
raise ValueError("Selected product does not belong to the chosen client")
if product.mix is None:
raise ValueError("Product mix is not configured")
source_total_kg = round(sum(ingredient.quantity_kg for ingredient in product.mix.ingredients), 4)
formula_rows, source_total_kg = _resolved_formula_rows(product)
if source_total_kg <= 0:
raise ValueError("Product mix has no source kilograms to scale")
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
@@ -72,18 +103,17 @@ def calculate_mix_calculator_preview(
warnings.append(bag_warning)
lines = []
for index, ingredient in enumerate(product.mix.ingredients, start=1):
mix_percentage = round((ingredient.quantity_kg / source_total_kg) * 100, 4)
required_kg = round(ingredient.quantity_kg * scale_factor, 4)
raw_material = ingredient.raw_material
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": raw_material.id if raw_material is not None else ingredient.raw_material_id,
"raw_material_name": raw_material.name if raw_material is not None else f"Raw material {ingredient.raw_material_id}",
"raw_material_id": ingredient["raw_material_id"],
"raw_material_name": ingredient["raw_material_name"],
"required_kg": required_kg,
"mix_percentage": mix_percentage,
"unit": raw_material.unit_of_measure if raw_material is not None else "kg",
"sort_order": index,
"unit": ingredient["unit"],
"sort_order": ingredient["sort_order"] or index,
}
)
@@ -92,7 +122,7 @@ def calculate_mix_calculator_preview(
"product_id": product.id,
"product_name": product.name,
"mix_id": product.mix_id,
"mix_name": product.mix.name,
"mix_name": product.mix.name if product.mix else product.name,
"mix_date": values["mix_date"],
"batch_size_kg": round(batch_size_kg, 4),
"total_bags": total_bags,
@@ -108,10 +138,16 @@ def calculate_mix_calculator_preview(
def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
# Aggregate mix totals in a single query instead of loading every
# ingredient row for every product. The previous implementation was the
# main slow path on first Mix Calculator open — it streamed the entire
# tenant's recipe table just to compute one sum per product.
# 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)
@@ -137,7 +173,7 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
"mix_name": product.mix.name if product.mix else "",
"unit_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"mix_total_kg": mix_totals.get(product.mix_id, 0.0),
"mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
}
for product in products
]