From 1062c038e85b997f8fde8aa9c8d2d16f1fabef3c Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Thu, 18 Jun 2026 14:04:49 +1200 Subject: [PATCH] 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 --- backend/app/api/editor.py | 20 ++-- backend/pyproject.toml | 2 +- backend/tests/test_editor_formula.py | 95 ++++++++++++++++++- frontend/package-lock.json | 4 +- frontend/package.json | 2 +- .../throughput/ThroughputSummary.svelte | 83 ++++------------ .../src/lib/components/throughput/utils.ts | 7 -- frontend/src/routes/throughput/+page.svelte | 10 +- 8 files changed, 133 insertions(+), 90 deletions(-) diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py index e015131..8b111f1 100644 --- a/backend/app/api/editor.py +++ b/backend/app/api/editor.py @@ -27,7 +27,6 @@ from app.schemas.editor import ( EditorProductRow, EditorProductUpdate, EditorResolvedMixFormula, - EditorResolvedMixIngredient, ) from app.services.change_log import ( ENTITY_INGREDIENT, @@ -162,12 +161,16 @@ def _format_kg(value: float) -> str: def _formula_deltas( - before: list[EditorResolvedMixIngredient] | list, - after: list[EditorResolvedMixIngredient] | list, + before: list[dict], + after: list[dict], ) -> list[dict]: - """Per-ingredient before/after deltas between two resolved formulas.""" - before_map = {row.raw_material_name: row.quantity_kg for row in before} - after_map = {row.raw_material_name: row.quantity_kg for row in after} + """Per-ingredient before/after deltas between two resolved formulas. + + `resolve_editor_mix_formula` returns plain dicts (ingredients are dicts too), + so read the rows by key, not attribute. + """ + before_map = {row["raw_material_name"]: row["quantity_kg"] for row in before} + after_map = {row["raw_material_name"]: row["quantity_kg"] for row in after} deltas: list[dict] = [] for name in sorted(set(before_map) | set(after_map)): old = before_map.get(name) @@ -640,9 +643,12 @@ def replace_editor_mix_formula( ) db.flush() + # Drop now-stale ORM state so the re-resolve reads the rows we just wrote + # rather than the formerly-loaded ingredient collections from the identity map. + db.expire_all() mix = _load_editor_mix_formula(db, mix_id=mix_id, tenant_id=tenant_id) after_formula = resolve_editor_mix_formula(db, tenant_id=tenant_id, mix=mix) - deltas = _formula_deltas(before_formula.ingredients, after_formula.ingredients) + deltas = _formula_deltas(before_formula["ingredients"], after_formula["ingredients"]) if deltas: record_change( db, diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 3c590f6..695daf3 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "hunter-backend" -version = "0.1.29" +version = "0.1.30" description = "Costing platform MVP backend (API for Hunter)" requires-python = ">=3.11" dependencies = [ diff --git a/backend/tests/test_editor_formula.py b/backend/tests/test_editor_formula.py index b01c5bf..93e721f 100644 --- a/backend/tests/test_editor_formula.py +++ b/backend/tests/test_editor_formula.py @@ -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") diff --git a/frontend/package-lock.json b/frontend/package-lock.json index be4a094..30b45b0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.29", + "version": "0.1.30", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.29", + "version": "0.1.30", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index c2c72a4..6d0a208 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.29", + "version": "0.1.30", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/components/throughput/ThroughputSummary.svelte b/frontend/src/lib/components/throughput/ThroughputSummary.svelte index 64216c6..4b1de6a 100644 --- a/frontend/src/lib/components/throughput/ThroughputSummary.svelte +++ b/frontend/src/lib/components/throughput/ThroughputSummary.svelte @@ -1,14 +1,11 @@