Files
2026-06-18 15:15:46 +12:00

241 lines
9.4 KiB
Python

"""The Mix Editor must resolve and edit the SAME formula the Mix Calculator reads.
These cover `resolve_editor_mix_formula` / `resolve_representative_product`: a mix
whose product carries its own formula resolves to that product (not the shared
mix master), percentages are computed against the total, and the representative
product is chosen the way the calculator chooses it.
"""
from __future__ import annotations
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker
import pytest
from fastapi import HTTPException
from app.api.deps import AuthSession
from app.api.editor import delete_editor_mix, replace_editor_mix_formula
from app.db.session import Base
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial
from app.schemas.editor import EditorMixFormulaReplace, EditorMixFormulaRowInput
from app.services.mix_calculator_service import (
resolve_editor_mix_formula,
resolve_representative_product,
)
TENANT = "hunter-premium-produce"
def _editor_session() -> AuthSession:
return AuthSession(
role="internal",
email="editor@hunter.test",
name="Editor",
tenant_id=TENANT,
client_role="admin",
user_id=1,
)
def _session() -> Session:
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine)
return sessionmaker(bind=engine, expire_on_commit=False)()
def _raw(db: Session, name: str) -> RawMaterial:
material = RawMaterial(tenant_id=TENANT, name=name, unit_of_measure="kg", kg_per_unit=1, status="active")
db.add(material)
db.flush()
return material
def test_resolves_product_formula_not_mix_master():
db = _session()
bayley = _raw(db, "Bayley")
filler = _raw(db, "Filler")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Pigeon Mix")
db.add(mix)
db.flush()
# Shared mix master says something different from the product formula.
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=bayley.id, quantity_kg=100))
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=filler.id, quantity_kg=100))
product = Product(tenant_id=TENANT, client_name="Hunter", name="Pigeon 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add(product)
db.flush()
# The calculator's real numbers live here: 787.5 / 1320.41 ~ 59.6%.
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=787.5, sort_order=1))
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=532.91, sort_order=2))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "product"
assert formula["product_id"] == product.id
assert formula["total_kg"] == 1320.41
by_name = {row["raw_material_name"]: row for row in formula["ingredients"]}
assert by_name["Bayley"]["quantity_kg"] == 787.5
# Percentage matches the worked example (787.5 / 1320.41 * 100).
assert abs(by_name["Bayley"]["mix_percentage"] - 59.6406) < 0.001
def test_falls_back_to_mix_master_when_no_product_formula():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
db.commit()
formula = resolve_editor_mix_formula(db, tenant_id=TENANT, mix=mix)
assert formula["source"] == "mix"
assert formula["total_kg"] == 50
assert formula["ingredients"][0]["mix_percentage"] == 100.0
def test_replace_mix_master_formula_returns_fresh_rows():
"""PUT formula on a mix without a product writes the mix master and the
response reflects the just-saved rows (not the stale pre-save collection).
Regression: the diff path read the resolved formula by attribute, but the
resolver returns dicts, which raised AttributeError -> HTTP 500 on save.
"""
db = _session()
maize = _raw(db, "Maize")
barley = _raw(db, "Barley")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=100))
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=100))
db.commit()
# Percentages need not total 100% — kg is canonical.
payload = EditorMixFormulaReplace(
rows=[
EditorMixFormulaRowInput(raw_material_id=maize.id, quantity_kg=330.0, notes=None),
EditorMixFormulaRowInput(raw_material_id=barley.id, quantity_kg=140.0, notes="confirmed"),
]
)
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
assert result["source"] == "mix"
assert result["total_kg"] == 470.0
by_name = {row["raw_material_name"]: row for row in result["ingredients"]}
assert by_name["Maize"]["quantity_kg"] == 330.0
assert by_name["Barley"]["quantity_kg"] == 140.0
persisted = db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix.id)).all()
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
(maize.id, 330.0),
(barley.id, 140.0),
]
def test_replace_product_formula_writes_product_ingredients():
"""When a representative product owns the formula, PUT replaces the product's
ingredients (the source the calculator reads) and returns the fresh rows."""
db = _session()
bayley = _raw(db, "Bayley")
filler = _raw(db, "Filler")
canola = _raw(db, "Canola")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
db.add(mix)
db.flush()
product = Product(
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
unit_of_measure="20kg bag", visible=True,
)
db.add(product)
db.flush()
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=bayley.id, quantity_kg=10, sort_order=1))
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=filler.id, quantity_kg=10, sort_order=2))
db.commit()
payload = EditorMixFormulaReplace(
rows=[
EditorMixFormulaRowInput(raw_material_id=bayley.id, quantity_kg=600.0, notes=None),
EditorMixFormulaRowInput(raw_material_id=canola.id, quantity_kg=200.0, notes=None),
]
)
result = replace_editor_mix_formula(mix.id, payload, session=_editor_session(), db=db)
assert result["source"] == "product"
assert result["product_id"] == product.id
assert result["total_kg"] == 800.0
persisted = db.scalars(select(ProductIngredient).where(ProductIngredient.product_id == product.id)).all()
# Filler dropped, Canola added; mix master is untouched.
assert sorted((row.raw_material_id, row.quantity_kg) for row in persisted) == [
(bayley.id, 600.0),
(canola.id, 200.0),
]
def test_delete_mix_without_products_removes_mix_and_ingredients():
"""A product-less mix can be deleted; its ingredient rows cascade away."""
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Plain Mix")
db.add(mix)
db.flush()
db.add(MixIngredient(tenant_id=TENANT, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=50))
db.commit()
mix_id = mix.id
delete_editor_mix(mix_id, session=_editor_session(), db=db)
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is None
assert db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix_id)).first() is None
def test_delete_mix_with_products_is_refused():
"""A mix that still drives products can't be deleted (409) — products must
keep a mix, so the user marks it inactive instead."""
db = _session()
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Layer Mix")
db.add(mix)
db.flush()
db.add(
Product(
tenant_id=TENANT, client_name="Hunter", name="Layer 20kg", mix_id=mix.id,
unit_of_measure="20kg bag", visible=True,
)
)
db.commit()
mix_id = mix.id
with pytest.raises(HTTPException) as excinfo:
delete_editor_mix(mix_id, session=_editor_session(), db=db)
assert excinfo.value.status_code == 409
assert "linked product" in excinfo.value.detail
# The mix is left intact.
assert db.scalar(select(Mix).where(Mix.id == mix_id)) is not None
def test_representative_product_prefers_20kg_bag():
db = _session()
maize = _raw(db, "Maize")
mix = Mix(tenant_id=TENANT, client_name="Hunter", name="Dual Mix")
db.add(mix)
db.flush()
bulka = Product(tenant_id=TENANT, client_name="Hunter", name="Dual Bulka", mix_id=mix.id, unit_of_measure="500kg bulka", visible=True)
bag = Product(tenant_id=TENANT, client_name="Hunter", name="Dual 20kg", mix_id=mix.id, unit_of_measure="20kg bag", visible=True)
db.add_all([bulka, bag])
db.flush()
for product in (bulka, bag):
db.add(ProductIngredient(tenant_id=TENANT, product_id=product.id, raw_material_id=maize.id, quantity_kg=20, sort_order=1))
db.commit()
representative = resolve_representative_product(db, tenant_id=TENANT, mix_id=mix.id)
assert representative is not None
assert representative.unit_of_measure == "20kg bag"