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:
2026-06-18 14:04:49 +12:00
co-authored by Claude Opus 4.8
parent e7a7b11589
commit 1062c038e8
8 changed files with 133 additions and 90 deletions
+13 -7
View File
@@ -27,7 +27,6 @@ from app.schemas.editor import (
EditorProductRow, EditorProductRow,
EditorProductUpdate, EditorProductUpdate,
EditorResolvedMixFormula, EditorResolvedMixFormula,
EditorResolvedMixIngredient,
) )
from app.services.change_log import ( from app.services.change_log import (
ENTITY_INGREDIENT, ENTITY_INGREDIENT,
@@ -162,12 +161,16 @@ def _format_kg(value: float) -> str:
def _formula_deltas( def _formula_deltas(
before: list[EditorResolvedMixIngredient] | list, before: list[dict],
after: list[EditorResolvedMixIngredient] | list, after: list[dict],
) -> list[dict]: ) -> list[dict]:
"""Per-ingredient before/after deltas between two resolved formulas.""" """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} `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] = [] deltas: list[dict] = []
for name in sorted(set(before_map) | set(after_map)): for name in sorted(set(before_map) | set(after_map)):
old = before_map.get(name) old = before_map.get(name)
@@ -640,9 +643,12 @@ def replace_editor_mix_formula(
) )
db.flush() 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) 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) 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: if deltas:
record_change( record_change(
db, db,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "hunter-backend" name = "hunter-backend"
version = "0.1.29" version = "0.1.30"
description = "Costing platform MVP backend (API for Hunter)" description = "Costing platform MVP backend (API for Hunter)"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+94 -1
View File
@@ -7,13 +7,16 @@ product is chosen the way the calculator chooses it.
""" """
from __future__ import annotations from __future__ import annotations
from sqlalchemy import create_engine from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session, sessionmaker 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.db.session import Base
from app.models.mix import Mix, MixIngredient from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient from app.models.product import Product, ProductIngredient
from app.models.raw_material import RawMaterial from app.models.raw_material import RawMaterial
from app.schemas.editor import EditorMixFormulaReplace, EditorMixFormulaRowInput
from app.services.mix_calculator_service import ( from app.services.mix_calculator_service import (
resolve_editor_mix_formula, resolve_editor_mix_formula,
resolve_representative_product, resolve_representative_product,
@@ -22,6 +25,17 @@ from app.services.mix_calculator_service import (
TENANT = "hunter-premium-produce" 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: def _session() -> Session:
engine = create_engine("sqlite:///:memory:") engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(bind=engine) 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 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(): def test_representative_product_prefers_20kg_bag():
db = _session() db = _session()
maize = _raw(db, "Maize") maize = _raw(db, "Maize")
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.29", "version": "0.1.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.29", "version": "0.1.30",
"dependencies": { "dependencies": {
"@fontsource/inter": "^5.2.8", "@fontsource/inter": "^5.2.8",
"lucide-svelte": "^1.0.1" "lucide-svelte": "^1.0.1"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.29", "version": "0.1.30",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -1,14 +1,11 @@
<script lang="ts"> <script lang="ts">
import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte'; import { CalendarDays, CalendarRange, Carrot, Gauge, TrendingUp, Wheat } from 'lucide-svelte';
import { MIX_RANGES } from '$lib/components/throughput/utils';
let { let {
today, today,
weekRangeLabel, weekRangeLabel,
heroStats, heroStats,
mixTotals, mixTotals,
mixRangeKey = $bindable<(typeof MIX_RANGES)[number]['key']>('4w'),
formatDate, formatDate,
formatNumber formatNumber
}: { }: {
@@ -16,7 +13,6 @@
weekRangeLabel: string; weekRangeLabel: string;
heroStats: { today: number; thisWeek: number; avgFourWeek: number }; heroStats: { today: number; thisWeek: number; avgFourWeek: number };
mixTotals: { horse: number; grain: number }; mixTotals: { horse: number; grain: number };
mixRangeKey?: (typeof MIX_RANGES)[number]['key'];
formatDate: (value: string) => string; formatDate: (value: string) => string;
formatNumber: (value: number | null | undefined, digits?: number) => string; formatNumber: (value: number | null | undefined, digits?: number) => string;
} = $props(); } = $props();
@@ -26,18 +22,19 @@
<div class="summary-heading"> <div class="summary-heading">
<span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span> <span class="summary-icon"><Gauge size={17} strokeWidth={2.2} /></span>
<h2>Throughput Overview</h2> <h2>Throughput Overview</h2>
<div class="range-select" role="group" aria-label="Customer mix date range">
{#each MIX_RANGES as range (range.key)}
<button
type="button"
class="range-option"
class:active={mixRangeKey === range.key}
aria-pressed={mixRangeKey === range.key}
onclick={() => (mixRangeKey = range.key)}
>{range.label}</button>
{/each}
</div> </div>
<dl class="mix-facts" aria-label="Throughput by customer today">
<div class="fact">
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">PHF Horsemix · {formatDate(today)}</p>
</div> </div>
<div class="fact">
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">All other customers · {formatDate(today)}</p>
</div>
</dl>
<dl class="facts"> <dl class="facts">
<div class="fact"> <div class="fact">
<dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt> <dt><span class="fact-icon"><CalendarDays size={16} strokeWidth={2.2} /></span>Today</dt>
@@ -55,18 +52,6 @@
<p class="fact-sub">Per week, last 4 weeks</p> <p class="fact-sub">Per week, last 4 weeks</p>
</div> </div>
</dl> </dl>
<dl class="mix-facts" aria-label="Throughput by customer">
<div class="fact">
<dt><span class="fact-icon"><Carrot size={16} strokeWidth={2.2} /></span>Horse Mix</dt>
<dd>{formatNumber(mixTotals.horse)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">PHF Horsemix · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
<div class="fact">
<dt><span class="fact-icon"><Wheat size={16} strokeWidth={2.2} /></span>Grain Mix</dt>
<dd>{formatNumber(mixTotals.grain)} <span class="fact-unit">kg</span></dd>
<p class="fact-sub">All other customers · {MIX_RANGES.find((r) => r.key === mixRangeKey)?.sub ?? 'last 4 weeks'}</p>
</div>
</dl>
</header> </header>
<style> <style>
@@ -106,41 +91,6 @@
color: var(--color-brand); color: var(--color-brand);
} }
.range-select {
display: inline-flex;
align-items: center;
gap: 0.2rem;
margin-left: auto;
padding: 0.28rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, var(--color-border));
border-radius: 0.8rem;
background: color-mix(in srgb, var(--color-bg-surface) 55%, transparent);
}
.range-option {
padding: 0.5rem 0.95rem;
border: 0;
border-radius: 0.6rem;
background: transparent;
color: var(--color-text-secondary);
font-size: 0.95rem;
font-weight: 600;
line-height: 1;
white-space: nowrap;
cursor: pointer;
transition: background-color 140ms ease, color 140ms ease, box-shadow 140ms ease;
}
.range-option:hover {
color: var(--color-text-primary);
}
.range-option.active {
background: var(--color-brand);
color: #fff;
box-shadow: 0 8px 18px -14px color-mix(in srgb, var(--color-brand) 85%, transparent);
}
.facts, .facts,
.mix-facts { .mix-facts {
display: grid; display: grid;
@@ -149,8 +99,13 @@
padding: 1rem; padding: 1rem;
} }
/* Mix cards lead the overview, so they carry the top padding; the hero
figures follow and hug up against them. */
.mix-facts { .mix-facts {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
}
.facts {
padding-top: 0; padding-top: 0;
} }
@@ -243,12 +198,12 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.facts { .mix-facts {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 0.9rem; padding: 0.9rem;
} }
.mix-facts { .facts {
grid-template-columns: 1fr; grid-template-columns: 1fr;
padding: 0 0.9rem 0.9rem; padding: 0 0.9rem 0.9rem;
} }
@@ -15,13 +15,6 @@ export type ConfettiPiece = {
export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444']; export const CONFETTI_COLORS = ['#16a34a', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#ef4444'];
export const MIX_RANGES = [
{ key: '7d', label: '7 days', sub: 'last 7 days', days: 7 },
{ key: '4w', label: '4 weeks', sub: 'last 4 weeks', days: 28 },
{ key: '6w', label: '6 weeks', sub: 'last 6 weeks', days: 42 },
{ key: '12w', label: '12 weeks', sub: 'last 12 weeks', days: 84 }
] as const;
export function compareText(a: string | null | undefined, b: string | null | undefined) { export function compareText(a: string | null | undefined, b: string | null | undefined) {
return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' }); return (a ?? '').localeCompare(b ?? '', undefined, { sensitivity: 'base' });
} }
+3 -7
View File
@@ -12,7 +12,6 @@
import ThroughputSuccessOverlay from '$lib/components/throughput/ThroughputSuccessOverlay.svelte'; import ThroughputSuccessOverlay from '$lib/components/throughput/ThroughputSuccessOverlay.svelte';
import ThroughputSummary from '$lib/components/throughput/ThroughputSummary.svelte'; import ThroughputSummary from '$lib/components/throughput/ThroughputSummary.svelte';
import { import {
MIX_RANGES,
addDays, addDays,
ausToday, ausToday,
buildConfetti, buildConfetti,
@@ -431,15 +430,13 @@
return norm.includes('phf') && norm.includes('horse'); return norm.includes('phf') && norm.includes('horse');
} }
let mixRangeKey = $state<(typeof MIX_RANGES)[number]['key']>('4w'); // Today's split only: Horse Mix (PHF Horsemix) vs Grain Mix (everyone else).
const mixRange = $derived(MIX_RANGES.find((r) => r.key === mixRangeKey) ?? MIX_RANGES[1]);
const mixTotals = $derived.by(() => { const mixTotals = $derived.by(() => {
const cutoff = toISODate(addDays(ausToday(), -(mixRange.days - 1))); const todayStr = toISODate(ausToday());
let horse = 0; let horse = 0;
let grain = 0; let grain = 0;
for (const entry of statsEntries) { for (const entry of statsEntries) {
if (entry.production_date < cutoff) continue; if (entry.production_date !== todayStr) continue;
const kg = entry.calculated_kg || 0; const kg = entry.calculated_kg || 0;
const client = entry.product_id != null ? productClientById.get(entry.product_id) ?? null : null; const client = entry.product_id != null ? productClientById.get(entry.product_id) ?? null : null;
if (isHorseMixClient(client)) horse += kg; if (isHorseMixClient(client)) horse += kg;
@@ -531,7 +528,6 @@
{weekRangeLabel} {weekRangeLabel}
{heroStats} {heroStats}
{mixTotals} {mixTotals}
bind:mixRangeKey
{formatDate} {formatDate}
{formatNumber} {formatNumber}
/> />