v0.1.30 - Throughput overview today-only mix cards; mix formula save 500 fix
Throughput Overview: Horse Mix and Grain Mix are now the first two cards and
show TODAY's output only. Removed the 7d/4w/6w/12w range selector; the cards
are fixed to Horse mix today, Grain mix today, Today, This week, 4-week average.
Mix Editor formula save: fix HTTP 500 on PUT /editor/mixes/{id}/formula. The
audit-diff path read the resolved formula by attribute, but the resolver returns
dicts -> AttributeError. Read by key and expire stale ORM state so the response
reflects the just-saved rows. Adds regression tests for both save branches.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -7,13 +7,16 @@ product is chosen the way the calculator chooses it.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy import create_engine, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.api.deps import AuthSession
|
||||
from app.api.editor import 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,
|
||||
@@ -22,6 +25,17 @@ from app.services.mix_calculator_service import (
|
||||
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)
|
||||
@@ -82,6 +96,85 @@ def test_falls_back_to_mix_master_when_no_product_formula():
|
||||
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_representative_product_prefers_20kg_bag():
|
||||
db = _session()
|
||||
maize = _raw(db, "Maize")
|
||||
|
||||
Reference in New Issue
Block a user