2026-06-16 14:43:17 +12:00
|
|
|
"""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
|
|
|
|
|
|
2026-06-18 14:04:49 +12:00
|
|
|
from sqlalchemy import create_engine, select
|
2026-06-16 14:43:17 +12:00
|
|
|
from sqlalchemy.orm import Session, sessionmaker
|
|
|
|
|
|
2026-06-18 14:04:49 +12:00
|
|
|
from app.api.deps import AuthSession
|
|
|
|
|
from app.api.editor import replace_editor_mix_formula
|
2026-06-16 14:43:17 +12:00
|
|
|
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
|
2026-06-18 14:04:49 +12:00
|
|
|
from app.schemas.editor import EditorMixFormulaReplace, EditorMixFormulaRowInput
|
2026-06-16 14:43:17 +12:00
|
|
|
from app.services.mix_calculator_service import (
|
|
|
|
|
resolve_editor_mix_formula,
|
|
|
|
|
resolve_representative_product,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
TENANT = "hunter-premium-produce"
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 14:04:49 +12:00
|
|
|
def _editor_session() -> AuthSession:
|
|
|
|
|
return AuthSession(
|
|
|
|
|
role="internal",
|
|
|
|
|
email="editor@hunter.test",
|
|
|
|
|
name="Editor",
|
|
|
|
|
tenant_id=TENANT,
|
|
|
|
|
client_role="admin",
|
|
|
|
|
user_id=1,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-06-18 14:04:49 +12:00
|
|
|
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),
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
2026-06-16 14:43:17 +12:00
|
|
|
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"
|