Compare commits

...
2 Commits
Author SHA1 Message Date
adminandClaude Opus 4.8 1062c038e8 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>
2026-06-18 14:04:49 +12:00
adminandClaude Opus 4.8 e7a7b11589 v0.1.29 - Mix Editor % edits no longer rebalance other ingredients
Editing one ingredient's % now converts only that row to kg against the
Total mix anchor; other ingredients are left untouched (no proportional
redistribution). Removing a row likewise leaves the rest as-is. The hard
"percentages must total 100%" save guard is relaxed (kg is canonical and
the backend does not require 100%); the % chip remains as live feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:49:15 +12:00
9 changed files with 147 additions and 141 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.28" 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.28", "version": "0.1.30",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hunter-app", "name": "hunter-app",
"version": "0.1.28", "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.28", "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> </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 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' });
} }
+14 -51
View File
@@ -103,57 +103,24 @@
ingredientBaseline = ingredientDrafts.map((row) => ({ ...row })); ingredientBaseline = ingredientDrafts.map((row) => ({ ...row }));
} }
// kg overrides %: recompute the reference total from the kg column, then // Editing a row's % converts only that row to kilograms against the Total mix
// re-derive every row's percentage so they always sum to 100. // anchor. Every other ingredient is left exactly as it is — changing one
function applyKgEdit() { // ingredient's share never rebalances or recalculates the rest of the recipe.
const total = ingredientDrafts.reduce((sum, row) => sum + Number(row.quantity_kg || 0), 0); // Percentages are free to sum to anything; the chip reports the running total
totalReference = round4(total); // and kg stays the canonical saved value.
ingredientDrafts = ingredientDrafts.map((row) => ({
...row,
percentage: total > 0 ? round4((Number(row.quantity_kg || 0) / total) * 100) : 0
}));
}
// Editing a row's % sets it to that share of the mix and spreads the rest
// across the other rows in their existing proportions, so the column always
// re-totals 100 and kg stays the canonical value. The total mix kg is held
// constant; only the split moves. Without this rebalance a single % edit
// would leave the total off 100 and the save guard would block the change.
function applyPercentEdit(index: number) { function applyPercentEdit(index: number) {
const total = Number(totalReference || 0); const total = Number(totalReference || 0);
const rows = ingredientDrafts;
const count = rows.length;
// Clamp to a sane 0100 share. // Clamp to a sane 0100 share for the edited row only.
let target = Number(rows[index].percentage || 0); let target = Number(ingredientDrafts[index].percentage || 0);
if (!Number.isFinite(target) || target < 0) target = 0; if (!Number.isFinite(target) || target < 0) target = 0;
if (target > 100) target = 100; if (target > 100) target = 100;
// A single ingredient is always the whole mix. ingredientDrafts = ingredientDrafts.map((row, rowIndex) =>
if (count === 1) { rowIndex === index
ingredientDrafts = rows.map((row) => ({ ? { ...row, percentage: round4(target), quantity_kg: round4((target / 100) * total) }
...row, : row
percentage: 100,
quantity_kg: round4(total)
}));
return;
}
const remaining = 100 - target;
const otherOldSum = rows.reduce(
(sum, row, rowIndex) => (rowIndex === index ? sum : sum + Number(row.percentage || 0)),
0
); );
ingredientDrafts = rows.map((row, rowIndex) => {
if (rowIndex === index) {
return { ...row, percentage: round4(target), quantity_kg: round4((target / 100) * total) };
}
// Keep the other rows' relative split; if they were all at 0, share evenly.
const share =
otherOldSum > 0 ? (Number(row.percentage || 0) / otherOldSum) * remaining : remaining / (count - 1);
return { ...row, percentage: round4(share), quantity_kg: round4((share / 100) * total) };
});
} }
// Editing the Total mix (kg) rescales every row's kg from its current %. // Editing the Total mix (kg) rescales every row's kg from its current %.
@@ -293,9 +260,10 @@
} }
function removeIngredient(index: number) { function removeIngredient(index: number) {
// Drop the row and leave the remaining ingredients' % and kg exactly as they
// are — removing one ingredient never recalculates the others.
ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index); ingredientDrafts = ingredientDrafts.filter((_, rowIndex) => rowIndex !== index);
if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()]; if (!ingredientDrafts.length) ingredientDrafts = [emptyIngredient()];
applyKgEdit();
} }
function ingredientWarnings() { function ingredientWarnings() {
@@ -312,11 +280,6 @@
if (Number(row.quantity_kg) <= 0) return [`Ingredient row ${index + 1} needs a quantity greater than zero.`]; if (Number(row.quantity_kg) <= 0) return [`Ingredient row ${index + 1} needs a quantity greater than zero.`];
} }
// Percentages must add up to 100 before a change can be saved.
if (Math.abs(percentTotal - 100) > 0.1) {
return [`Percentages must total 100% (currently ${percentTotal.toFixed(2)}%).`];
}
return []; return [];
} }
@@ -687,7 +650,7 @@
<div class="ingredient-footer"> <div class="ingredient-footer">
<span class="footer-total">Total {ingredientTotalKg.toFixed(2)} kg</span> <span class="footer-total">Total {ingredientTotalKg.toFixed(2)} kg</span>
<button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button> <button class="clear-button" type="button" onclick={addIngredient}>Add ingredient</button>
<button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}` || !percentBalanced} onclick={saveIngredients}> <button class="apply-button" type="button" disabled={savingKey === `mix-save:${row.id}`} onclick={saveIngredients}>
{savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'} {savingKey === `mix-save:${row.id}` ? 'Saving...' : 'Save ingredients'}
</button> </button>
</div> </div>
+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}
/> />