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 0000000..f372b59 Binary files /dev/null and b/frontend/static/favicon.png differ