From 151676265c0a963a5c966949eeb1cc060ff554d1 Mon Sep 17 00:00:00 2001 From: ponzischeme89 Date: Thu, 30 Apr 2026 22:27:36 +1200 Subject: [PATCH] Seed additional products, improve left rail, improve search box, add visuals to buttons, rename working documents to working docs and improve left rail nav --- backend/app/seed.py | 617 ++++++++++++++++-- backend/app/services/costing_engine.py | 2 +- backend/pyproject.toml | 3 +- backend/tests/test_costing_engine.py | 24 +- frontend/package.json | 2 +- frontend/src/app.html | 2 +- .../src/lib/components/ClientShell.svelte | 255 ++++++-- .../components/MixCalculatorWorkspace.svelte | 21 + frontend/src/routes/+page.svelte | 18 +- frontend/static/favicon.png | Bin 0 -> 14926 bytes 10 files changed, 816 insertions(+), 128 deletions(-) create mode 100644 frontend/static/favicon.png diff --git a/backend/app/seed.py b/backend/app/seed.py index c88c227..35dbd1a 100644 --- a/backend/app/seed.py +++ b/backend/app/seed.py @@ -1,5 +1,11 @@ -from datetime import date, datetime +from __future__ import annotations +from collections import Counter +from datetime import date, datetime +from pathlib import Path +import re + +from openpyxl import load_workbook from sqlalchemy import select from app.db.session import Base, SessionLocal, engine @@ -11,13 +17,543 @@ from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role +TENANT_ID = "hunter-premium-produce" +WORKBOOK_EFFECTIVE_DATE = date(2025, 9, 1) +WORKBOOK_SENTINEL_ITEM_ID = "404266" +WORKBOOK_PATH = Path(__file__).resolve().parents[2] / "Input Cost Spreadsheet(1).xlsx" + + +def _text(value) -> str | None: + if value is None: + return None + if isinstance(value, str): + normalized = value.strip() + if not normalized: + return None + if normalized.lower() in {"#n/a", "#value!", "n/a", "na", "none"}: + return None + return normalized + return str(value).strip() or None + + +def _number(value) -> float | None: + if value is None: + return None + if isinstance(value, bool): + return float(value) + if isinstance(value, (int, float)): + return float(value) + if isinstance(value, str): + normalized = value.strip().replace(",", "") + if not normalized or normalized.lower() in {"#n/a", "#value!", "n/a", "na", "none"}: + return None + try: + return float(normalized) + except ValueError: + return None + return None + + +def _format_quantity(value: float | int | None) -> str: + if value is None: + return "0" + numeric = float(value) + if abs(numeric - round(numeric)) < 1e-9: + return str(int(round(numeric))) + return f"{numeric:.4f}".rstrip("0").rstrip(".") + + +def _slug(value: str | None, *, fallback: str) -> str: + base = _text(value) or fallback + slug = re.sub(r"[^a-z0-9]+", "_", base.lower()).strip("_") + return slug or fallback + + +def _normalize_sale_type(value) -> str: + label = (_text(value) or "standard").lower() + if label == "per unit": + return "per_unit" + return re.sub(r"[^a-z0-9]+", "_", label) + + +def _sheet_own_bag_to_model(value) -> bool: + label = (_text(value) or "").lower() + return label == "no bag" + + +def _normalize_raw_material_unit(unit_label, kg_per_unit: float | None) -> str: + label = (_text(unit_label) or "").lower() + if label in {"per ton", "per tonne", "ton", "tonne"}: + return "tonne" + if label == "kg": + return "kg" + if label == "per bag 20kg": + return "20kg bag" + if "20 kg" in label: + return "20kg bag" + if "kg" in label and kg_per_unit: + return f"{_format_quantity(kg_per_unit)}kg bag" + if kg_per_unit == 1000: + return "tonne" + return _text(unit_label) or "kg" + + +def _build_base_unit_label(sale_type: str, std_unit: float, own_bag: bool) -> str: + if sale_type == "standard": + return f"{_format_quantity(std_unit)}kg no bag" if own_bag else f"{_format_quantity(std_unit)}kg bag" + if sale_type == "bulka": + return f"{_format_quantity(std_unit)}kg bulka" + if sale_type == "per_unit": + return f"{_format_quantity(std_unit)} unit" + return f"{_format_quantity(std_unit)}kg" + + +def _derive_margin(finished_cost: float, sell_price) -> float | None: + price = _number(sell_price) + if price is None or price <= 0 or finished_cost <= 0 or price <= finished_cost: + return None + margin = 1 - (finished_cost / price) + if margin <= 0 or margin >= 1: + return None + return round(margin, 6) + + +def _build_process_key(label, grading_cost: float, bagging_cost: float, cracking_cost: float) -> str | None: + if abs(grading_cost) < 1e-9 and abs(bagging_cost) < 1e-9 and abs(cracking_cost) < 1e-9: + return None + base = _slug(label, fallback="custom_process") + return f"{base}_g{int(round(grading_cost * 1000))}_b{int(round(bagging_cost * 1000))}_c{int(round(cracking_cost * 1000))}" + + +def _load_workbook(): + if not WORKBOOK_PATH.exists(): + raise FileNotFoundError(f"Workbook not found at {WORKBOOK_PATH}") + return load_workbook(WORKBOOK_PATH, data_only=True) + + +def _read_raw_material_rows(workbook) -> list[dict]: + rows: list[dict] = [] + worksheet = workbook["C- Raw Products Costs"] + for row in worksheet.iter_rows(min_row=3, values_only=True): + name = _text(row[0]) + if not name: + continue + + market_value = _number(row[1]) + kg_per_unit = _number(row[3]) + waste_percentage = _number(row[4]) or 0.0 + cost_per_kg = _number(row[7]) + + if cost_per_kg is None and market_value is None: + continue + + if kg_per_unit is None or kg_per_unit <= 0: + kg_per_unit = 1.0 + + if market_value is None and cost_per_kg is not None: + market_value = round(cost_per_kg * kg_per_unit, 4) + + rows.append( + { + "name": name, + "unit_of_measure": _normalize_raw_material_unit(row[2], kg_per_unit), + "kg_per_unit": kg_per_unit, + "market_value": round(market_value, 4) if market_value is not None else None, + "waste_percentage": waste_percentage, + } + ) + return rows + + +def _read_mix_rows(workbook) -> dict[tuple[str, str], dict]: + worksheet = workbook["M - All"] + header_row = next(worksheet.iter_rows(min_row=1, max_row=1, values_only=True)) + ingredient_names = [_text(value) for value in header_row[3:] if _text(value)] + best_rows: dict[tuple[str, str], dict] = {} + + for row in worksheet.iter_rows(min_row=2, values_only=True): + client_name = _text(row[0]) + mix_name = _text(row[1]) + if not client_name or not mix_name: + continue + + ingredients = [] + for ingredient_name, quantity in zip(ingredient_names, row[3 : 3 + len(ingredient_names)]): + numeric_quantity = _number(quantity) + if ingredient_name and numeric_quantity and numeric_quantity > 0: + ingredients.append({"raw_material_name": ingredient_name, "quantity_kg": numeric_quantity}) + + if not ingredients: + continue + + total_kg = _number(row[2]) or round(sum(item["quantity_kg"] for item in ingredients), 4) + score = (len(ingredients), 1 if _number(row[2]) is not None else 0, total_kg) + key = (client_name, mix_name) + current = best_rows.get(key) + if current is None or score > current["score"]: + best_rows[key] = { + "client_name": client_name, + "name": mix_name, + "ingredients": ingredients, + "total_kg": total_kg, + "score": score, + } + + return best_rows + + +def _read_product_rows(workbook) -> list[dict]: + worksheet = workbook["Product Cost - Price"] + raw_rows: list[dict] = [] + unit_variants: dict[tuple[str, bool, float], Counter[tuple[float, float]]] = {} + + for row in worksheet.iter_rows(min_row=5, values_only=True): + item_id = _text(row[1]) + name = _text(row[2]) + mix_name = _text(row[3]) + if not item_id or not name or not mix_name: + continue + + sale_type = _normalize_sale_type(row[4]) + own_bag = _sheet_own_bag_to_model(row[5]) + std_unit = _number(row[6]) or 1.0 + bag_cost = round(_number(row[15]) or 0.0, 4) + freight_cost = round(_number(row[16]) or 0.0, 4) + base_unit_key = (sale_type, own_bag, std_unit) + + unit_variants.setdefault(base_unit_key, Counter())[(bag_cost, freight_cost)] += 1 + raw_rows.append( + { + "client_name": _text(row[0]) or "General", + "item_id": item_id, + "name": name, + "mix_name": mix_name, + "sale_type": sale_type, + "own_bag": own_bag, + "std_unit": std_unit, + "items_per_pallet": int(round(_number(row[7]) or 1)), + "grading_cost": round(_number(row[12]) or 0.0, 4), + "bagging_cost": round(_number(row[13]) or 0.0, 4), + "cracking_cost": round(_number(row[14]) or 0.0, 4), + "bag_cost": bag_cost, + "freight_cost": freight_cost, + "finished_product_delivered": round(_number(row[17]) or 0.0, 4), + "distributor_margin": _derive_margin(round(_number(row[17]) or 0.0, 4), row[19]), + "wholesale_margin": _derive_margin(round(_number(row[17]) or 0.0, 4), row[20]), + "process_label": _text(row[8]), + "sheet_own_bag": _text(row[5]), + } + ) + + products: list[dict] = [] + for row in raw_rows: + base_unit_key = (row["sale_type"], row["own_bag"], row["std_unit"]) + unit_label = _build_base_unit_label(row["sale_type"], row["std_unit"], row["own_bag"]) + variant_counts = unit_variants[base_unit_key] + if len(variant_counts) > 1: + current_variant = (row["bag_cost"], row["freight_cost"]) + primary_variant = variant_counts.most_common(1)[0][0] + if current_variant != primary_variant: + if row["sheet_own_bag"] == "Yes": + unit_label = f"{unit_label} (Own Bag)" + elif row["client_name"] == "Peckish": + unit_label = f"{unit_label} (Peckish)" + elif row["client_name"] == "Uncategorized": + unit_label = f"{unit_label} (Bulk)" + else: + unit_label = f"{unit_label} ({row['client_name']})" + + process_key = _build_process_key( + row["process_label"], + row["grading_cost"], + row["bagging_cost"], + row["cracking_cost"], + ) + row["unit_of_measure"] = unit_label + row["bagging_process"] = process_key + products.append(row) + + return products + + +def _upsert_raw_materials(db, rows: list[dict]) -> dict[str, RawMaterial]: + existing_map = { + material.name: material + for material in db.scalars(select(RawMaterial).where(RawMaterial.tenant_id == TENANT_ID)).all() + } + + for row in rows: + material = existing_map.get(row["name"]) + if material is None: + material = RawMaterial( + tenant_id=TENANT_ID, + name=row["name"], + supplier="Workbook Import", + unit_of_measure=row["unit_of_measure"], + kg_per_unit=row["kg_per_unit"], + status="active", + notes="Seeded from Input Cost Spreadsheet(1).xlsx", + ) + db.add(material) + db.flush() + existing_map[row["name"]] = material + else: + material.unit_of_measure = row["unit_of_measure"] + material.kg_per_unit = row["kg_per_unit"] + material.status = "active" + material.notes = "Seeded from Input Cost Spreadsheet(1).xlsx" + + active_price = next((price for price in material.price_versions if price.status == "active"), None) + if row["market_value"] is not None and row["market_value"] > 0: + if active_price is None: + material.price_versions.append( + RawMaterialPriceVersion( + tenant_id=TENANT_ID, + market_value=row["market_value"], + waste_percentage=row["waste_percentage"], + effective_date=WORKBOOK_EFFECTIVE_DATE, + status="active", + notes="Seeded from Input Cost Spreadsheet(1).xlsx", + ) + ) + else: + active_price.market_value = row["market_value"] + active_price.waste_percentage = row["waste_percentage"] + active_price.effective_date = WORKBOOK_EFFECTIVE_DATE + active_price.status = "active" + active_price.notes = "Seeded from Input Cost Spreadsheet(1).xlsx" + elif active_price is not None and active_price.market_value <= 0: + active_price.status = "inactive" + active_price.notes = "Disabled during workbook import because market value was non-positive" + + db.flush() + return existing_map + + +def _upsert_process_rules(db, products: list[dict]) -> None: + existing_rules = { + rule.process_name: rule + for rule in db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == TENANT_ID)).all() + } + + for product in products: + process_name = product["bagging_process"] + if not process_name: + continue + + rule = existing_rules.get(process_name) + if rule is None: + rule = ProcessCostRule( + tenant_id=TENANT_ID, + process_name=process_name, + grading_cost=product["grading_cost"], + bagging_cost=product["bagging_cost"], + cracking_cost=product["cracking_cost"], + ) + db.add(rule) + existing_rules[process_name] = rule + else: + rule.grading_cost = product["grading_cost"] + rule.bagging_cost = product["bagging_cost"] + rule.cracking_cost = product["cracking_cost"] + + +def _upsert_packaging_and_freight_rules(db, products: list[dict]) -> None: + packaging_rules = { + (rule.sale_type, rule.unit_of_measure, rule.own_bag): rule + for rule in db.scalars(select(PackagingCostRule).where(PackagingCostRule.tenant_id == TENANT_ID)).all() + } + freight_rules = { + (rule.sale_type, rule.unit_of_measure): rule + for rule in db.scalars(select(FreightCostRule).where(FreightCostRule.tenant_id == TENANT_ID)).all() + } + + for product in products: + packaging_key = (product["sale_type"], product["unit_of_measure"], product["own_bag"]) + packaging_rule = packaging_rules.get(packaging_key) + if packaging_rule is None: + packaging_rule = PackagingCostRule( + tenant_id=TENANT_ID, + sale_type=product["sale_type"], + unit_of_measure=product["unit_of_measure"], + own_bag=product["own_bag"], + bag_cost=product["bag_cost"], + ) + db.add(packaging_rule) + packaging_rules[packaging_key] = packaging_rule + else: + packaging_rule.bag_cost = product["bag_cost"] + + freight_key = (product["sale_type"], product["unit_of_measure"]) + freight_rule = freight_rules.get(freight_key) + if freight_rule is None: + freight_rule = FreightCostRule( + tenant_id=TENANT_ID, + sale_type=product["sale_type"], + unit_of_measure=product["unit_of_measure"], + cost_per_unit=product["freight_cost"], + ) + db.add(freight_rule) + freight_rules[freight_key] = freight_rule + else: + freight_rule.cost_per_unit = product["freight_cost"] + + +def _upsert_mix( + db, + *, + client_name: str, + mix_name: str, + ingredients: list[dict], + raw_material_map: dict[str, RawMaterial], + mix_cache: dict[tuple[str, str], Mix], +) -> Mix: + key = (client_name, mix_name) + mix = mix_cache.get(key) + if mix is None: + mix = db.scalar( + select(Mix).where( + Mix.tenant_id == TENANT_ID, + Mix.client_name == client_name, + Mix.name == mix_name, + ) + ) + if mix is None: + mix = Mix( + tenant_id=TENANT_ID, + client_name=client_name, + name=mix_name, + status="active", + version=1, + notes="Seeded from Input Cost Spreadsheet(1).xlsx", + ) + db.add(mix) + db.flush() + mix_cache[key] = mix + + existing_ingredients = { + ingredient.raw_material_id: ingredient + for ingredient in db.scalars(select(MixIngredient).where(MixIngredient.mix_id == mix.id)).all() + } + desired_ids = set() + for ingredient_row in ingredients: + raw_material = raw_material_map.get(ingredient_row["raw_material_name"]) + if raw_material is None: + continue + + desired_ids.add(raw_material.id) + ingredient = existing_ingredients.get(raw_material.id) + if ingredient is None: + db.add( + MixIngredient( + tenant_id=TENANT_ID, + mix_id=mix.id, + raw_material_id=raw_material.id, + quantity_kg=ingredient_row["quantity_kg"], + ) + ) + else: + ingredient.quantity_kg = ingredient_row["quantity_kg"] + + for raw_material_id, ingredient in existing_ingredients.items(): + if raw_material_id not in desired_ids: + db.delete(ingredient) + + return mix + + +def _ensure_single_material_mix( + db, + *, + client_name: str, + raw_material_name: str, + raw_material_map: dict[str, RawMaterial], + mix_cache: dict[tuple[str, str], Mix], +) -> Mix: + raw_material = raw_material_map[raw_material_name] + return _upsert_mix( + db, + client_name=client_name, + mix_name=raw_material_name, + ingredients=[ + { + "raw_material_name": raw_material_name, + "quantity_kg": raw_material.kg_per_unit or 1.0, + } + ], + raw_material_map=raw_material_map, + mix_cache=mix_cache, + ) + + +def _upsert_products(db, products: list[dict], mix_lookup: dict[tuple[str, str], Mix], raw_material_map: dict[str, RawMaterial]) -> None: + mix_cache = dict(mix_lookup) + mixes_by_name: dict[str, list[Mix]] = {} + for mix in mix_cache.values(): + mixes_by_name.setdefault(mix.name, []).append(mix) + existing_products = { + product.item_id: product + for product in db.scalars(select(Product).where(Product.tenant_id == TENANT_ID)).all() + if product.item_id + } + + for row in products: + mix = mix_cache.get((row["client_name"], row["mix_name"])) + if mix is None: + named_mixes = mixes_by_name.get(row["mix_name"], []) + if len(named_mixes) == 1: + mix = named_mixes[0] + if mix is None and row["mix_name"] in raw_material_map: + mix = _ensure_single_material_mix( + db, + client_name=row["client_name"], + raw_material_name=row["mix_name"], + raw_material_map=raw_material_map, + mix_cache=mix_cache, + ) + if mix is None: + continue + + product = existing_products.get(row["item_id"]) + if product is None: + product = Product( + tenant_id=TENANT_ID, + client_name=row["client_name"], + item_id=row["item_id"], + name=row["name"], + mix_id=mix.id, + sale_type=row["sale_type"], + own_bag=row["own_bag"], + unit_of_measure=row["unit_of_measure"], + items_per_pallet=row["items_per_pallet"], + bagging_process=row["bagging_process"], + distributor_margin=row["distributor_margin"], + wholesale_margin=row["wholesale_margin"], + notes="Seeded from Input Cost Spreadsheet(1).xlsx", + ) + db.add(product) + existing_products[row["item_id"]] = product + else: + product.client_name = row["client_name"] + product.name = row["name"] + product.mix_id = mix.id + product.sale_type = row["sale_type"] + product.own_bag = row["own_bag"] + product.unit_of_measure = row["unit_of_measure"] + product.items_per_pallet = row["items_per_pallet"] + product.bagging_process = row["bagging_process"] + product.distributor_margin = row["distributor_margin"] + product.wholesale_margin = row["wholesale_margin"] + product.notes = "Seeded from Input Cost Spreadsheet(1).xlsx" + + def seed_client_access(db): existing = db.scalar(select(ClientAccount.id)) if existing is not None: return specialty = ClientAccount( - tenant_id="hunter-premium-produce", + tenant_id=TENANT_ID, name="Hunter Premium Produce", client_code="HPP", status="active", @@ -72,7 +608,7 @@ def seed_client_access(db): ) enabled_feature_map = { - "hunter-premium-produce": {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"}, + TENANT_ID: {"dashboard", "raw_materials", "mix_master", "mix_calculator", "products", "scenarios", "powerbi_export", "client_access"}, "loft-grains": {"dashboard", "mix_calculator", "products", "powerbi_export"}, } @@ -118,65 +654,28 @@ def seed_client_access(db): def seed_costing_workspace(db): - existing = db.scalar(select(RawMaterial.id)) - if existing is not None: - return + workbook = _load_workbook() + raw_material_rows = _read_raw_material_rows(workbook) + mix_rows = _read_mix_rows(workbook) + product_rows = _read_product_rows(workbook) - tenant_id = "hunter-premium-produce" + raw_material_map = _upsert_raw_materials(db, raw_material_rows) + _upsert_process_rules(db, product_rows) + _upsert_packaging_and_freight_rules(db, product_rows) - maize = RawMaterial(tenant_id=tenant_id, name="Maize", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active") - barley = RawMaterial(tenant_id=tenant_id, name="Barley", supplier="Example Supplier", unit_of_measure="tonne", kg_per_unit=1000, status="active") - acid_buf = RawMaterial(tenant_id=tenant_id, name="Acid Buf", supplier="Example Supplier", unit_of_measure="bag", kg_per_unit=25, status="active") - - maize.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=520, waste_percentage=0.02, effective_date=date(2026, 4, 1))) - barley.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=470, waste_percentage=0.015, effective_date=date(2026, 4, 1))) - acid_buf.price_versions.append(RawMaterialPriceVersion(tenant_id=tenant_id, market_value=39, waste_percentage=0.0, effective_date=date(2026, 4, 1))) - - db.add_all([maize, barley, acid_buf]) - db.flush() - - db.add_all( - [ - ProcessCostRule(tenant_id=tenant_id, process_name="standard_bagging", grading_cost=0.055, bagging_cost=0.04, cracking_cost=0.0), - ProcessCostRule(tenant_id=tenant_id, process_name="bulk_loadout", grading_cost=0.03, bagging_cost=0.0, cracking_cost=0.0), - PackagingCostRule(tenant_id=tenant_id, sale_type="standard", unit_of_measure="20kg bag", own_bag=False, bag_cost=0.63), - PackagingCostRule(tenant_id=tenant_id, sale_type="bulka", unit_of_measure="550kg bulka", own_bag=False, bag_cost=7.5), - FreightCostRule(tenant_id=tenant_id, sale_type="standard", unit_of_measure="20kg bag", cost_per_unit=1.45), - FreightCostRule(tenant_id=tenant_id, sale_type="bulka", unit_of_measure="550kg bulka", cost_per_unit=18.0), - ] - ) - db.flush() - - mix = Mix(tenant_id=tenant_id, client_name="Hunter Premium Produce", name="Hunter Orchard Blend", status="active", version=1, notes="Seed recipe for MVP") - db.add(mix) - db.flush() - - db.add_all( - [ - MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=maize.id, quantity_kg=180), - MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=barley.id, quantity_kg=95), - MixIngredient(tenant_id=tenant_id, mix_id=mix.id, raw_material_id=acid_buf.id, quantity_kg=5), - ] - ) - db.flush() - - db.add( - Product( - tenant_id=tenant_id, - client_name="Hunter Premium Produce", - item_id="SKU-001", - name="Hunter Orchard Blend 20kg", - mix_id=mix.id, - sale_type="standard", - own_bag=False, - unit_of_measure="20kg bag", - items_per_pallet=50, - bagging_process="standard_bagging", - distributor_margin=0.225, - wholesale_margin=0.18, - notes="Reference product for formula parity work", + mix_cache: dict[tuple[str, str], Mix] = {} + for mix_row in mix_rows.values(): + mix = _upsert_mix( + db, + client_name=mix_row["client_name"], + mix_name=mix_row["name"], + ingredients=mix_row["ingredients"], + raw_material_map=raw_material_map, + mix_cache=mix_cache, ) - ) + mix_cache[(mix_row["client_name"], mix_row["name"])] = mix + + _upsert_products(db, product_rows, mix_cache, raw_material_map) def seed_if_empty(): diff --git a/backend/app/services/costing_engine.py b/backend/app/services/costing_engine.py index 61f7e56..ee50709 100644 --- a/backend/app/services/costing_engine.py +++ b/backend/app/services/costing_engine.py @@ -27,7 +27,7 @@ def calculate_raw_material_cost(raw_material: RawMaterial, price: RawMaterialPri def get_active_price(raw_material: RawMaterial) -> RawMaterialPriceVersion | None: - active_prices = [price for price in raw_material.price_versions if price.status == "active"] + active_prices = [price for price in raw_material.price_versions if price.status == "active" and price.market_value > 0] if not active_prices: return None active_prices.sort(key=lambda item: item.effective_date, reverse=True) diff --git a/backend/pyproject.toml b/backend/pyproject.toml index 36d1c32..29ffa8d 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "data-entry-app-backend" -version = "0.1.2" +version = "0.1.3" description = "Costing platform MVP backend" requires-python = ">=3.11" dependencies = [ "fastapi>=0.115,<1.0", + "openpyxl>=3.1,<4.0", "uvicorn[standard]>=0.30,<1.0", "sqlalchemy>=2.0,<3.0", "pydantic>=2.8,<3.0", diff --git a/backend/tests/test_costing_engine.py b/backend/tests/test_costing_engine.py index 1553301..0e5cc26 100644 --- a/backend/tests/test_costing_engine.py +++ b/backend/tests/test_costing_engine.py @@ -16,7 +16,7 @@ from app.models.mix import Mix, MixIngredient from app.models.product import Product from app.models.raw_material import RawMaterial, RawMaterialPriceVersion from app.services.client_access_service import build_client_access_export, ensure_user_module_permissions, serialize_client_account -from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost +from app.services.costing_engine import calculate_mix_cost, calculate_product_cost, calculate_raw_material_cost, serialize_raw_material from app.services.mix_calculator_service import calculate_mix_calculator_preview @@ -38,6 +38,15 @@ def test_calculate_raw_material_cost(): assert result.cost_per_kg == 0.51 +def test_serialize_raw_material_ignores_non_positive_active_price(): + raw_material = RawMaterial(name="Apple Flavouring", unit_of_measure="kg", kg_per_unit=1, status="active") + raw_material.price_versions.append(RawMaterialPriceVersion(market_value=0, waste_percentage=0, effective_date=date(2026, 4, 1), status="active")) + + serialized = serialize_raw_material(raw_material) + + assert serialized["current_price"] is None + + def test_mix_and_product_cost_breakdown(): db = build_session() @@ -286,14 +295,17 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): options_response = client.get("/api/mix-calculator/options", headers=superadmin_headers) assert options_response.status_code == 200 - assert options_response.json()["products"][0]["product_name"] == "Hunter Orchard Blend 20kg" + options_payload = options_response.json() + assert len(options_payload["products"]) >= 100 + seeded_product = next(product for product in options_payload["products"] if product["product_name"] == "Specialty Pigeon Breeder 20kg") + assert seeded_product["unit_size_kg"] == 20 create_response = client.post( "/api/mix-calculator", json={ "mix_date": "2026-04-29", - "client_name": "Hunter Premium Produce", - "product_id": 1, + "client_name": seeded_product["client_name"], + "product_id": seeded_product["product_id"], "batch_size_kg": 560, "prepared_by_name": "Amelia Hart", "notes": "Morning production run", @@ -302,9 +314,11 @@ def test_mix_calculator_endpoints_respect_owner_visibility(): ) assert create_response.status_code == 201 created = create_response.json() + assert created["product_name"] == seeded_product["product_name"] assert created["session_number"].startswith("HPP-20260429-") assert created["total_bags"] == 28 - assert created["lines"][0]["required_kg"] == 360 + assert len(created["lines"]) > 0 + assert created["lines"][0]["required_kg"] > 0 patch_response = client.patch( f"/api/mix-calculator/{created['id']}", diff --git a/frontend/package.json b/frontend/package.json index 19459f9..d736a83 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "data-entry-app-frontend", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/app.html b/frontend/src/app.html index fa74e64..8f99454 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -3,10 +3,10 @@ + %sveltekit.head%
%sveltekit.body%
- diff --git a/frontend/src/lib/components/ClientShell.svelte b/frontend/src/lib/components/ClientShell.svelte index ad57673..afe3af4 100644 --- a/frontend/src/lib/components/ClientShell.svelte +++ b/frontend/src/lib/components/ClientShell.svelte @@ -39,7 +39,7 @@ { href: '/scenarios', label: 'Planning View', shortLabel: 'PV' } ]; - const searchItems: SearchItem[] = [ + const baseSearchItems: SearchItem[] = [ { href: '/', label: 'Open Dashboard', @@ -104,10 +104,12 @@ let quickMenuOpen = $state(false); let userMenuOpen = $state(false); let navOpen = $state(false); - let workingDocumentsExpanded = $state(true); + let workingDocumentsExpanded = $state(false); let showBottomNav = $state(false); let isRestoringSession = $state(false); let restoredToken = $state(null); + let seededSearchItems = $state([]); + let seededSearchToken = $state(null); let paletteInput: HTMLInputElement | null = $state(null); const appVersion = `v${packageInfo.version}`; const releaseStage = 'Alpha'; @@ -138,6 +140,10 @@ ...visibleWorkingDocumentItems.slice(0, 2) ] ); + const workingDocumentsActive = $derived( + visibleWorkingDocumentItems.some((item) => matchesRoute(item.href, page.url.pathname)) + ); + const searchItems = $derived([...baseSearchItems, ...seededSearchItems]); function matchesRoute(href: string, pathname: string) { return href === '/' ? pathname === '/' : pathname.startsWith(href); @@ -255,6 +261,61 @@ }); }); + $effect(() => { + const hydrated = $sessionHydrated; + const session = $clientSession; + const token = session?.token ?? null; + + if (!hydrated || !session || !token) { + seededSearchItems = []; + seededSearchToken = null; + return; + } + + if (seededSearchToken === token) { + return; + } + + seededSearchToken = token; + + Promise.all([ + hasModuleAccess(session, 'products') ? api.products() : Promise.resolve([]), + hasModuleAccess(session, 'mix_master') ? api.mixes() : Promise.resolve([]), + hasModuleAccess(session, 'mix_calculator') ? api.mixCalculatorSessions() : Promise.resolve([]) + ]) + .then(([products, mixes, sessions]) => { + if (seededSearchToken !== token) { + return; + } + + seededSearchItems = [ + ...products.map((product) => ({ + href: '/products', + label: product.name, + description: `Product · ${product.client_name} · ${product.mix_name}`, + keywords: `product ${product.name} ${product.client_name} ${product.mix_name} ${product.unit_of_measure}` + })), + ...mixes.map((mix) => ({ + href: `/mixes/${mix.id}`, + label: mix.name, + description: `Mix · ${mix.client_name} · ${mix.total_mix_kg}kg`, + keywords: `mix ${mix.name} ${mix.client_name} ${mix.notes ?? ''} ${mix.ingredients.map((ingredient) => ingredient.raw_material_name).join(' ')}` + })), + ...sessions.map((savedSession) => ({ + href: `/mix-calculator/${savedSession.id}`, + label: `${savedSession.session_number} · ${savedSession.product_name}`, + description: `Mix Session · ${savedSession.prepared_by_name} · ${savedSession.mix_date}`, + keywords: `mix calculator session ${savedSession.session_number} ${savedSession.product_name} ${savedSession.mix_name} ${savedSession.client_name} ${savedSession.prepared_by_name} ${savedSession.notes ?? ''}` + })) + ]; + }) + .catch(() => { + if (seededSearchToken === token) { + seededSearchItems = []; + } + }); + }); + $effect(() => { if ($sessionHydrated && !$clientSession && !isRootRoute) { goto('/', { replaceState: true }); @@ -370,11 +431,15 @@ {#if workingDocumentsExpanded} @@ -421,37 +486,12 @@
- - + +
+ {#if quickMenuOpen} + + {/if} + + +
{#if showBottomNav} @@ -629,11 +696,15 @@ {#if workingDocumentsExpanded} @@ -710,7 +781,7 @@ >
- + Esc
@@ -785,7 +856,7 @@ .app-shell { display: grid; - grid-template-columns: 228px minmax(0, 1fr); + grid-template-columns: 244px minmax(0, 1fr); min-height: 100vh; } @@ -1003,25 +1074,31 @@ justify-content: space-between; gap: 0.75rem; width: 100%; - padding: 0 0.68rem; + padding: 0.72rem 0.68rem; border: none; + border-radius: 0.82rem; background: transparent; - color: inherit; + color: #304038; cursor: pointer; + transition: background-color 160ms ease; } - .nav-group-label, - .drawer-group-label { - margin: 0; - color: var(--muted); - font-size: 0.72rem; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; + .nav-group-toggle:hover, + .nav-group-toggle.active { + background: var(--green-soft); } - .nav-group-label { - padding: 0; + .nav-group-toggle.active { + color: var(--green-deep); + font-weight: 600; + } + + .nav-group-toggle-copy { + display: inline-flex; + align-items: center; + gap: 0.68rem; + min-width: 0; + white-space: nowrap; } .nav-sublist { @@ -1141,9 +1218,12 @@ .topbar-middle { min-width: 0; + display: flex; + justify-content: center; } .topbar-search { + width: min(100%, 36rem); min-height: 3rem; background: #fff; } @@ -1186,15 +1266,6 @@ position: relative; } - .action-button { - display: inline-flex; - align-items: center; - gap: 0.62rem; - border-radius: 0.88rem; - padding: 0.68rem 0.84rem; - color: #304038; - } - .user-trigger { min-width: 14rem; display: inline-flex; @@ -1291,6 +1362,71 @@ backdrop-filter: blur(10px); } + .quick-fab-wrap { + position: fixed; + right: max(1rem, env(safe-area-inset-right)); + bottom: max(1rem, env(safe-area-inset-bottom)); + z-index: 46; + display: grid; + justify-items: end; + gap: 0.6rem; + } + + .quick-fab { + display: inline-flex; + align-items: center; + gap: 0.72rem; + padding: 0.88rem 1.05rem; + border: none; + border-radius: 999px; + background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%); + color: #fff; + box-shadow: 0 18px 36px rgba(23, 75, 45, 0.26); + font-weight: 700; + letter-spacing: 0.01em; + cursor: pointer; + } + + .quick-fab-panel { + position: static; + min-width: 15rem; + padding: 0.45rem; + border-radius: 1rem; + } + + .quick-fab-plus { + position: relative; + width: 0.92rem; + height: 0.92rem; + flex-shrink: 0; + } + + .quick-fab-plus::before, + .quick-fab-plus::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0.92rem; + height: 2px; + border-radius: 999px; + background: currentColor; + transform: translate(-50%, -50%); + transition: transform 140ms ease; + } + + .quick-fab-plus::after { + transform: translate(-50%, -50%) rotate(90deg); + } + + .quick-fab-plus.open::before { + transform: translate(-50%, -50%) rotate(45deg); + } + + .quick-fab-plus.open::after { + transform: translate(-50%, -50%) rotate(-45deg); + } + .menu-panel a, .menu-panel button { padding: 0.72rem 0.78rem; @@ -1482,6 +1618,10 @@ backdrop-filter: blur(16px); } + .quick-fab-wrap { + bottom: calc(max(0.8rem, env(safe-area-inset-bottom)) + 5.9rem); + } + .bottom-nav a, .bottom-nav button { min-width: 0; @@ -1599,11 +1739,7 @@ } .drawer-group-toggle { - padding: 0 0.2rem; - } - - .drawer-group-label { - padding: 0; + padding-inline: 0.2rem; } .drawer-sublist { @@ -1649,6 +1785,7 @@ .topbar-middle { order: 3; + width: 100%; } .topbar-actions { diff --git a/frontend/src/lib/components/MixCalculatorWorkspace.svelte b/frontend/src/lib/components/MixCalculatorWorkspace.svelte index 138716d..458f6f4 100644 --- a/frontend/src/lib/components/MixCalculatorWorkspace.svelte +++ b/frontend/src/lib/components/MixCalculatorWorkspace.svelte @@ -598,6 +598,11 @@ border: 1px solid var(--line-strong); font-weight: 600; cursor: pointer; + transition: + transform 140ms ease, + box-shadow 140ms ease, + background-color 140ms ease, + border-color 140ms ease; } .primary-button { @@ -611,6 +616,22 @@ color: #304038; } + .primary-button:hover:not(:disabled), + .secondary-button:hover:not(:disabled) { + transform: translateY(-1px); + } + + .primary-button:hover:not(:disabled) { + box-shadow: 0 14px 28px rgba(23, 75, 45, 0.22); + filter: brightness(1.04); + } + + .secondary-button:hover:not(:disabled) { + border-color: #9fb0a6; + background: #f6faf7; + box-shadow: 0 10px 22px rgba(24, 38, 29, 0.08); + } + button:disabled { cursor: wait; opacity: 0.7; diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index be2682e..0d6734d 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -100,6 +100,22 @@ }).format(new Date(value)); } + function greetingForAst() { + const astHour = Number( + new Intl.DateTimeFormat('en-AU', { + hour: 'numeric', + hour12: false, + timeZone: 'Australia/Brisbane' + }).format(new Date()) + ); + + return astHour < 12 ? 'Good morning' : 'Good evening'; + } + + function firstName(name: string | null | undefined) { + return name?.trim().split(/\s+/)[0] ?? 'there'; + } + function findHighestProduct(products: ProductCostBreakdown[]) { return [...products].sort((left, right) => right.finished_product_delivered - left.finished_product_delivered)[0]; } @@ -393,7 +409,7 @@

Client Workspace

{releaseStage} -

Hunter Premium Produce costing overview.

+

{greetingForAst()}, {firstName($clientSession?.name)}

Track input pricing, mix performance, and delivered product outcomes from one client-facing workspace.

diff --git a/frontend/static/favicon.png b/frontend/static/favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..f372b59288aae953039c64606c22bb6aed89e0b5 GIT binary patch literal 14926 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

*8u?r0V4KJMTIc6V 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