diff --git a/backend/app/api/editor.py b/backend/app/api/editor.py index 128d207..c183580 100644 --- a/backend/app/api/editor.py +++ b/backend/app/api/editor.py @@ -838,6 +838,7 @@ def _serialize_ingredient(material: RawMaterial, usage_count: int) -> dict: "kg_per_unit": material.kg_per_unit, "status": material.status, "rounding_decimals": material.rounding_decimals, + "category": material.category, "notes": material.notes, "cost_per_kg": cost_per_kg, "usage_count": usage_count, @@ -885,6 +886,7 @@ def create_editor_ingredient( kg_per_unit=payload.kg_per_unit, status=payload.status.strip() or "active", rounding_decimals=payload.rounding_decimals, + category=(payload.category or "").strip() or None, notes=payload.notes, ) db.add(material) @@ -928,6 +930,8 @@ def update_editor_ingredient( updates["supplier"] = (updates["supplier"] or "").strip() or None if "unit_of_measure" in updates and updates["unit_of_measure"] is not None: updates["unit_of_measure"] = updates["unit_of_measure"].strip() + if "category" in updates: + updates["category"] = (updates["category"] or "").strip() or None before = {field: getattr(material, field) for field in updates} for field, value in updates.items(): setattr(material, field, value) @@ -941,6 +945,7 @@ def update_editor_ingredient( "kg_per_unit": "Kg per unit", "status": "Status", "rounding_decimals": "Rounding (dp)", + "category": "Category", "notes": "Notes", }, ) diff --git a/backend/app/db/migrations.py b/backend/app/db/migrations.py index bef7592..9704f16 100644 --- a/backend/app/db/migrations.py +++ b/backend/app/db/migrations.py @@ -139,6 +139,7 @@ _LEGACY_COLUMN_PATCHES: tuple[tuple[str, str, str], ...] = ( ("production_throughput_entries", "job_number", "VARCHAR(64)"), ("production_throughput_entries", "stock_quantity", "FLOAT"), ("raw_materials", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"), + ("raw_materials", "category", "VARCHAR(128)"), ("mix_calculator_session_lines", "rounding_decimals", "INTEGER NOT NULL DEFAULT 2"), ) diff --git a/backend/app/models/raw_material.py b/backend/app/models/raw_material.py index 48452e0..5e0a02c 100644 --- a/backend/app/models/raw_material.py +++ b/backend/app/models/raw_material.py @@ -18,6 +18,9 @@ class RawMaterial(Base): unit_of_measure: Mapped[str] = mapped_column(String(64)) kg_per_unit: Mapped[float] = mapped_column(Float) status: Mapped[str] = mapped_column(String(32), default="active") + # Manually-assigned grouping used to order ingredients in the Mix Calculator + # output (e.g. "Grains", "Additives"). Optional; uncategorised rows sort last. + category: Mapped[str | None] = mapped_column(String(128), nullable=True) # Decimal places this ingredient's required-kg is rounded to in the mix # calculator output. Set per-ingredient from the Ingredients Editor. rounding_decimals: Mapped[int] = mapped_column(Integer, default=2) diff --git a/backend/app/schemas/editor.py b/backend/app/schemas/editor.py index 120b7f6..aa145e3 100644 --- a/backend/app/schemas/editor.py +++ b/backend/app/schemas/editor.py @@ -188,6 +188,8 @@ class EditorIngredientRow(BaseModel): unit_of_measure: str kg_per_unit: float status: str + # Manual grouping used to order ingredients in the Mix Calculator output. + category: str | None # Decimal places this ingredient is rounded to in the mix calculator output. rounding_decimals: int notes: str | None @@ -206,6 +208,7 @@ class EditorIngredientCreate(BaseModel): kg_per_unit: float = Field(gt=0) status: str = Field(default="active", max_length=32) rounding_decimals: int = Field(default=2, ge=0, le=6) + category: str | None = Field(default=None, max_length=128) notes: str | None = Field(default=None, max_length=2000) @@ -218,6 +221,7 @@ class EditorIngredientUpdate(BaseModel): kg_per_unit: float | None = Field(default=None, gt=0) status: str | None = Field(default=None, max_length=32) rounding_decimals: int | None = Field(default=None, ge=0, le=6) + category: str | None = Field(default=None, max_length=128) notes: str | None = Field(default=None, max_length=2000) diff --git a/backend/app/schemas/mix_calculator.py b/backend/app/schemas/mix_calculator.py index b46ab17..68dca1a 100644 --- a/backend/app/schemas/mix_calculator.py +++ b/backend/app/schemas/mix_calculator.py @@ -27,6 +27,8 @@ class MixCalculatorSessionLineRead(BaseModel): mix_percentage: float unit: str rounding_decimals: int = 2 + # Manual ingredient grouping used to order the calculator output. + category: str | None = None sort_order: int diff --git a/backend/app/services/mix_calculator_service.py b/backend/app/services/mix_calculator_service.py index 8bc933e..e5f79fa 100644 --- a/backend/app/services/mix_calculator_service.py +++ b/backend/app/services/mix_calculator_service.py @@ -35,6 +35,33 @@ def _load_product_for_calculation(db: Session, tenant_id: str, product_id: int) ) +def _category_sort_key(category: str | None) -> tuple[int, str]: + """Order ingredients by their manual category; uncategorised rows sort last.""" + cleaned = (category or "").strip() + if not cleaned: + return (1, "") + return (0, cleaned.lower()) + + +def _order_formula_rows(rows: list[dict]) -> list[dict]: + """Sort rows by category (then their original order/name) and renumber. + + Category is the primary key so the Mix Calculator groups ingredients by their + manually-assigned category. `sort_order` is reassigned sequentially after the + sort so every downstream consumer (lines, PDF) follows the same order. + """ + rows.sort( + key=lambda row: ( + _category_sort_key(row.get("category")), + row.get("sort_order") or 0, + row["raw_material_name"].lower(), + ) + ) + for index, row in enumerate(rows, start=1): + row["sort_order"] = index + return rows + + def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]: if product.ingredients: rows = [ @@ -44,6 +71,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]: "quantity_kg": ingredient.quantity_kg, "unit": ingredient.raw_material.unit_of_measure, "rounding_decimals": ingredient.raw_material.rounding_decimals, + "category": ingredient.raw_material.category, "sort_order": ingredient.sort_order, } for ingredient in product.ingredients @@ -57,6 +85,7 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]: "quantity_kg": ingredient.quantity_kg, "unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg", "rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2, + "category": ingredient.raw_material.category if ingredient.raw_material is not None else None, "sort_order": index, } for index, ingredient in enumerate(product.mix.ingredients, start=1) @@ -64,7 +93,29 @@ def _resolved_formula_rows(product: Product) -> tuple[list[dict], float]: else: rows = [] - rows.sort(key=lambda row: (row["sort_order"], row["raw_material_name"])) + _order_formula_rows(rows) + return rows, round(sum(row["quantity_kg"] for row in rows), 4) + + +def _mix_formula_rows(mix: Mix) -> tuple[list[dict], float]: + """Resolve a mix's own (mix-master) formula rows, category-ordered. + + Used by the Mix Calculator for mixes that have a formula but no representative + product yet — the formula lives directly on the mix. + """ + rows = [ + { + "raw_material_id": ingredient.raw_material_id, + "raw_material_name": ingredient.raw_material.name if ingredient.raw_material is not None else f"Raw material {ingredient.raw_material_id}", + "quantity_kg": ingredient.quantity_kg, + "unit": ingredient.raw_material.unit_of_measure if ingredient.raw_material is not None else "kg", + "rounding_decimals": ingredient.raw_material.rounding_decimals if ingredient.raw_material is not None else 2, + "category": ingredient.raw_material.category if ingredient.raw_material is not None else None, + "sort_order": index, + } + for index, ingredient in enumerate(mix.ingredients, start=1) + ] + _order_formula_rows(rows) return rows, round(sum(row["quantity_kg"] for row in rows), 4) @@ -183,31 +234,34 @@ def resolve_editor_mix_formula(db: Session, *, tenant_id: str, mix: Mix) -> dict } -def calculate_mix_calculator_preview( - db: Session, +def _scale_preview( *, - tenant_id: str, - payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict, -): - values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False) - product = _load_product_for_calculation(db, tenant_id, int(values["product_id"])) - if product is None: - raise ValueError("Product not found") - if product.client_name != values["client_name"]: - raise ValueError("Selected product does not belong to the chosen client") - formula_rows, source_total_kg = _resolved_formula_rows(product) - if source_total_kg <= 0: - raise ValueError("Product has no source kilograms to scale") + values: dict, + formula_rows: list[dict], + source_total_kg: float, + client_name: str, + product_id: int, + mix_label: str, + mix_id: int, + unit_of_measure: str, +) -> dict: + """Scale a resolved formula to the requested batch size and shape the preview. + Shared by the product-backed path and the formula-only mix path; only the + inputs (where the formula and unit come from) differ. + """ batch_size_kg = float(values["batch_size_kg"]) scale_factor = batch_size_kg / source_total_kg - unit_size_kg = extract_unit_quantity_kg(product.unit_of_measure) + unit_size_kg = extract_unit_quantity_kg(unit_of_measure) total_bags = round(batch_size_kg / unit_size_kg, 4) if unit_size_kg > 0 else 0.0 warnings: list[str] = [] - bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, product.unit_of_measure) - if bag_warning: - warnings.append(bag_warning) + # A bag warning only makes sense when the unit resolves to a bag size; a + # formula-only mix sells in bulk kg, so there's nothing to round to whole bags. + if unit_size_kg > 0: + bag_warning = _fractional_bag_warning(batch_size_kg, total_bags, unit_of_measure) + if bag_warning: + warnings.append(bag_warning) lines = [] for index, ingredient in enumerate(formula_rows, start=1): @@ -221,24 +275,24 @@ def calculate_mix_calculator_preview( "mix_percentage": mix_percentage, "unit": ingredient["unit"], "rounding_decimals": ingredient.get("rounding_decimals", 2), + "category": ingredient.get("category"), "sort_order": ingredient["sort_order"] or index, } ) - mix_label = _mix_calculator_label(product) return { - "client_name": product.client_name, - "product_id": product.id, + "client_name": client_name, + "product_id": product_id, # The source workbook labels this as Product, but for the calculator # it is the mix/formula being produced. "product_name": mix_label, - "mix_id": product.mix_id, + "mix_id": mix_id, "mix_name": mix_label, "mix_date": values["mix_date"], "batch_size_kg": round(batch_size_kg, 4), "total_bags": total_bags, "total_kg": round(batch_size_kg, 4), - "product_unit_of_measure": product.unit_of_measure, + "product_unit_of_measure": unit_of_measure, "product_unit_size_kg": round(unit_size_kg, 4), "prepared_by_name": values["prepared_by_name"], "status": values.get("status") or "saved", @@ -248,6 +302,70 @@ def calculate_mix_calculator_preview( } +def _calculate_mix_only_preview(db: Session, *, tenant_id: str, mix_id: int, values: dict) -> dict: + """Preview for a mix that has a formula but no representative product. + + The Mix Calculator surfaces these via a negative `product_id` sentinel + (`-mix_id`); the formula is read straight off the mix master and there's no + product unit, so output is bulk kg with no bag split. + """ + mix = db.scalar( + select(Mix) + .where(Mix.id == mix_id, Mix.tenant_id == tenant_id) + .options(selectinload(Mix.ingredients).selectinload(MixIngredient.raw_material)) + ) + if mix is None: + raise ValueError("Mix not found") + if mix.client_name != values["client_name"]: + raise ValueError("Selected mix does not belong to the chosen client") + formula_rows, source_total_kg = _mix_formula_rows(mix) + if source_total_kg <= 0: + raise ValueError("Mix has no formula to scale") + return _scale_preview( + values=values, + formula_rows=formula_rows, + source_total_kg=source_total_kg, + client_name=mix.client_name, + product_id=-mix.id, + mix_label=mix.name, + mix_id=mix.id, + unit_of_measure="kg", + ) + + +def calculate_mix_calculator_preview( + db: Session, + *, + tenant_id: str, + payload: MixCalculatorSessionCreate | MixCalculatorSessionUpdate | dict, +): + values = payload if isinstance(payload, dict) else payload.model_dump(exclude_unset=False) + product_id = int(values["product_id"]) + # Negative ids are the sentinel for a formula-only mix (no product yet). + if product_id < 0: + return _calculate_mix_only_preview(db, tenant_id=tenant_id, mix_id=-product_id, values=values) + + product = _load_product_for_calculation(db, tenant_id, product_id) + if product is None: + raise ValueError("Product not found") + if product.client_name != values["client_name"]: + raise ValueError("Selected product does not belong to the chosen client") + formula_rows, source_total_kg = _resolved_formula_rows(product) + if source_total_kg <= 0: + raise ValueError("Product has no source kilograms to scale") + + return _scale_preview( + values=values, + formula_rows=formula_rows, + source_total_kg=source_total_kg, + client_name=product.client_name, + product_id=product.id, + mix_label=_mix_calculator_label(product), + mix_id=product.mix_id, + unit_of_measure=product.unit_of_measure, + ) + + def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: # Prefer product-specific formulas where present; fall back to the shared # mix master for legacy rows that have not been migrated yet. @@ -296,7 +414,6 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: key=lambda product: (product.client_name, _mix_calculator_label(product), product.id), ) - clients = sorted({product.client_name for product in products}) product_rows = [ { "product_id": product.id, @@ -311,6 +428,44 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict: for product in products ] + # Surface mixes that have a formula but no product at all yet (e.g. a freshly + # created mix). They're selected via a negative `product_id` sentinel (-mix_id) + # and calculated straight off the mix master — bulk kg, no bag split. A mix + # whose only product is hidden is intentionally excluded (it HAS a product), + # so check every product, not just the visible representatives. + covered_mix_ids = set( + db.scalars( + select(Product.mix_id).where(Product.tenant_id == tenant_id).distinct() + ).all() + ) + formula_only_mix_ids = [ + mix_id for mix_id, total in mix_totals.items() if total > 0 and mix_id not in covered_mix_ids + ] + if formula_only_mix_ids: + formula_only_mixes = db.scalars( + select(Mix).where( + Mix.tenant_id == tenant_id, + Mix.id.in_(formula_only_mix_ids), + Mix.status == "active", + ) + ).all() + product_rows.extend( + { + "product_id": -mix.id, + "client_name": mix.client_name, + "product_name": mix.name, + "mix_id": mix.id, + "mix_name": mix.name, + "unit_of_measure": "kg", + "unit_size_kg": 0.0, + "mix_total_kg": mix_totals.get(mix.id, 0.0), + } + for mix in formula_only_mixes + ) + + product_rows.sort(key=lambda row: (row["client_name"], row["product_name"], row["product_id"])) + clients = sorted({row["client_name"] for row in product_rows}) + return {"clients": clients, "products": product_rows} @@ -396,6 +551,10 @@ def _next_session_number(db: Session, *, tenant_id: str, mix_date: date) -> str: def create_mix_calculator_session(db: Session, *, auth_session: AuthSession, payload: MixCalculatorSessionCreate) -> dict: + if payload.product_id < 0: + # Sessions reference a real product (FK). A formula-only mix has none yet — + # it can still be previewed and printed, just not saved as a session. + raise ValueError("Add a product to this mix before saving a calculator session.") preview = calculate_mix_calculator_preview(db, tenant_id=auth_session.tenant_id or "", payload=payload) session_record = MixCalculatorSession( tenant_id=auth_session.tenant_id or "default", diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py index 502b3eb..1f645c6 100644 --- a/backend/tests/test_costing_engine.py +++ b/backend/tests/test_costing_engine.py @@ -482,7 +482,13 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): options_response = client.get("/api/mix-calculator/options", cookies=superadmin_cookies) assert options_response.status_code == 200 options_payload = options_response.json() - assert len(options_payload["products"]) == 84 + # 83 product-backed mixes + 1 formula-only mix ("Hi Carb Popcorn", which + # has a mix-master formula but no product yet, surfaced via a negative + # product_id sentinel so a new mix is usable before a product is linked). + assert len(options_payload["products"]) == 84 + 1 + formula_only = [product for product in options_payload["products"] if product["product_id"] < 0] + assert len(formula_only) == 1 + assert formula_only[0]["unit_size_kg"] == 0 seeded_product = next( product for product in options_payload["products"] diff --git a/frontend/package-lock.json b/frontend/package-lock.json index de3b135..99a9721 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "hunter-app", - "version": "0.1.31", + "version": "0.1.32", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "hunter-app", - "version": "0.1.31", + "version": "0.1.32", "dependencies": { "@fontsource/inter": "^5.2.8", "lucide-svelte": "^1.0.1" diff --git a/frontend/package.json b/frontend/package.json index 35f1e8e..c906d63 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "hunter-app", - "version": "0.1.31", + "version": "0.1.32", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/changelog.ts b/frontend/src/lib/changelog.ts index 7e54ed2..4f59ae0 100644 --- a/frontend/src/lib/changelog.ts +++ b/frontend/src/lib/changelog.ts @@ -17,6 +17,14 @@ export type ChangelogEntry = { export const APP_VERSION: string = packageInfo.version; export const changelog: ChangelogEntry[] = [ + { + version: '0.1.32', + date: '2026-06-21', + highlights: [ + 'App: Mix Calculator & Ingredients improvements.', + 'App: Bug fixes & improvements.' + ] + }, { version: '0.1.31', date: '2026-06-18', diff --git a/frontend/src/lib/components/editor/ChangeHistoryModal.svelte b/frontend/src/lib/components/editor/ChangeHistoryModal.svelte index 0a6cae2..f3aacee 100644 --- a/frontend/src/lib/components/editor/ChangeHistoryModal.svelte +++ b/frontend/src/lib/components/editor/ChangeHistoryModal.svelte @@ -46,15 +46,16 @@ } function formatWhen(value: string) { - // The server records audit times in its own (Australian) local time. Show - // those wall-clock values verbatim — do NOT re-interpret them as UTC or - // shift them into the viewer's timezone, or a 2pm edit reads as 12am. - // Parse the date parts directly so the display never moves with the - // viewer's location (AU, NZ, or anywhere else all see the server time). - const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})/); + // Audit times are stored in UTC on the server (datetime.utcnow), serialized + // without a timezone suffix. Parse the parts as UTC and let the browser + // render them in the viewer's local time, so an edit made at midday in + // Australia reads as midday rather than the raw 02:00 UTC value. + const match = value.match(/(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2})(?::(\d{2}))?/); if (!match) return value; - const [, year, month, day, hour, minute] = match.map(Number); - const date = new Date(year, month - 1, day, hour, minute); + const [, year, month, day, hour, minute, second] = match; + const date = new Date( + Date.UTC(+year, +month - 1, +day, +hour, +minute, second ? +second : 0) + ); if (Number.isNaN(date.getTime())) return value; return date.toLocaleString('en-AU', { year: 'numeric', diff --git a/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte b/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte index e363313..31902b5 100644 --- a/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte +++ b/frontend/src/lib/components/mix-calculator/MixCalculatorEditor.svelte @@ -13,6 +13,7 @@ MixCalculatorSession } from '$lib/types'; import MixCalculatorResultsPanel from './MixCalculatorResultsPanel.svelte'; + import MixCalculatorMixPicker from './MixCalculatorMixPicker.svelte'; let { options, initialSession = null }: { options: MixCalculatorOptions; initialSession?: MixCalculatorSession | null } = $props(); @@ -304,7 +305,7 @@