Seed additional products, improve left rail, improve search box, add visuals to buttons, rename working documents to working docs and improve left rail nav

This commit is contained in:
2026-04-30 22:27:36 +12:00
parent 4f876372c2
commit 151676265c
10 changed files with 816 additions and 128 deletions
+558 -59
View File
@@ -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():
+1 -1
View File
@@ -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)
+2 -1
View File
@@ -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",
+19 -5
View File
@@ -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']}",