This commit is contained in:
2026-06-09 21:28:53 +12:00
parent daa6e60a69
commit 349e4a4b5b
61 changed files with 6404 additions and 1382 deletions
+215 -1
View File
@@ -16,12 +16,26 @@ from app.models.assumption import FreightCostRule, PackagingCostRule, ProcessCos
from app.models.client_access import ClientAccessAuditEvent, ClientAccount, ClientFeatureAccess, ClientUser, ClientUserModulePermission
from app.models.mix import Mix, MixIngredient
from app.models.product import Product, ProductIngredient
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.models.raw_material import RawMaterial, RawMaterialPriceVersion
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.seed_access import seed_access
from app.services.client_access_service import MODULE_CATALOG, default_access_level_for_role
from app.services.throughput_service import import_workbook as import_throughput_workbook
from app.services.throughput_service import resolve_workbook_path as resolve_throughput_workbook_path
from app.services.product_costing_service import (
BAG_INPUTS,
FREIGHT_INPUTS,
PROCESS_NAMES,
recalculate_all_product_cost_items,
)
TENANT_ID = "hunter-premium-produce"
@@ -691,7 +705,36 @@ def _upsert_product_ingredients(
for key, formula in product_ingredient_rows.items():
matched_products = products_by_formula_key.get(key, [])
if not matched_products:
continue
client_name, formula_name = key
mix_cache: dict[tuple[str, str], Mix] = {}
mix = _upsert_mix(
db,
client_name=client_name,
mix_name=formula_name,
ingredients=formula["ingredients"],
raw_material_map=raw_material_map,
mix_cache=mix_cache,
)
product = Product(
tenant_id=TENANT_ID,
client_name=client_name,
item_id=f"mix-calculator:{_slug(client_name, fallback='client')}:{_slug(formula_name, fallback='mix')}",
name=formula_name,
mix_id=mix.id,
sale_type="standard",
own_bag=True,
visible=True,
unit_of_measure="kg",
items_per_pallet=1,
bagging_process=None,
distributor_margin=None,
wholesale_margin=None,
notes="Seeded as a Mix Calculator source row from workbook formulas",
)
db.add(product)
db.flush()
products_by_formula_key[key] = [product]
matched_products = [product]
for product in matched_products:
existing_ingredients = {
@@ -1060,6 +1103,174 @@ def seed_throughput_products(db):
return
def _unit_type_from_product(product: Product) -> str:
sale_type = (product.sale_type or "").lower()
unit = (product.unit_of_measure or "").lower()
if sale_type == "bulka" or "bulka" in unit:
return "Bulka"
if "1.5kg" in unit or "1.5 kg" in unit:
return "1.5 kg"
if sale_type == "per_unit":
return "Per Unit"
return "Standard"
def _own_bag_label(product: Product) -> str | None:
if product.own_bag:
return "No Bag" if "no bag" in (product.unit_of_measure or "").lower() else "Yes"
return None
def seed_product_costing_module(db) -> dict[str, int]:
tenant_id = TENANT_ID
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
if base is None:
process_rules = db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all()
grading_per_kg = max((rule.grading_cost for rule in process_rules), default=0.0)
cracking_per_kg = max((rule.cracking_cost for rule in process_rules), default=0.0)
base = ProductCostBaseInput(
tenant_id=tenant_id,
grading_per_tonne=round(grading_per_kg * 1000, 4),
grading_per_kg=round(grading_per_kg, 4),
cracking_per_tonne=round(cracking_per_kg * 1000, 4),
cracking_per_kg=round(cracking_per_kg, 4),
)
db.add(base)
existing_processes = {
row.process_name: row
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()
}
process_rule_map = {
rule.process_name: rule
for rule in db.scalars(select(ProcessCostRule).where(ProcessCostRule.tenant_id == tenant_id)).all()
}
for process_name in PROCESS_NAMES:
if process_name in existing_processes:
continue
normalized_key = _build_process_key(process_name, 0.0, 0.0, 0.0)
rule = process_rule_map.get(normalized_key or process_name) or process_rule_map.get(process_name)
db.add(
ProductCostProcessInput(
tenant_id=tenant_id,
process_name=process_name,
cost_per_kg=round(rule.bagging_cost, 4) if rule else 0.0,
)
)
for process_name, rule in process_rule_map.items():
if process_name not in existing_processes:
db.add(
ProductCostProcessInput(
tenant_id=tenant_id,
process_name=process_name,
cost_per_kg=round(rule.bagging_cost, 4),
)
)
bag_defaults = {
"20kg_bag": 0.0,
"bulka_bag": 0.0,
"own_bag_credit": 0.0,
"1_5kg_bagging": 0.0,
"peckish_bag": 0.0,
}
for rule in db.scalars(select(PackagingCostRule).where(PackagingCostRule.tenant_id == tenant_id)).all():
unit = (rule.unit_of_measure or "").lower()
if "1.5kg" in unit or "1.5 kg" in unit:
bag_defaults["1_5kg_bagging"] = max(bag_defaults["1_5kg_bagging"], rule.bag_cost)
elif "peckish" in unit:
bag_defaults["peckish_bag"] = max(bag_defaults["peckish_bag"], rule.bag_cost)
elif "bulka" in unit:
bag_defaults["bulka_bag"] = max(bag_defaults["bulka_bag"], rule.bag_cost)
elif "20kg" in unit:
bag_defaults["20kg_bag"] = max(bag_defaults["20kg_bag"], rule.bag_cost)
existing_bags = {
row.input_key
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()
}
for key, label in BAG_INPUTS.items():
if key not in existing_bags:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(bag_defaults.get(key, 0.0), 4)))
freight_defaults = {
"freight_per_pallet": 0.0,
"peckish_freight_per_pallet": 0.0,
"hay_straw_freight_per_pallet": 0.0,
}
for rule in db.scalars(select(FreightCostRule).where(FreightCostRule.tenant_id == tenant_id)).all():
unit = (rule.unit_of_measure or "").lower()
if "peckish" in unit:
freight_defaults["peckish_freight_per_pallet"] = max(freight_defaults["peckish_freight_per_pallet"], rule.cost_per_unit)
elif "hay" in unit or "straw" in unit:
freight_defaults["hay_straw_freight_per_pallet"] = max(freight_defaults["hay_straw_freight_per_pallet"], rule.cost_per_unit)
else:
freight_defaults["freight_per_pallet"] = max(freight_defaults["freight_per_pallet"], rule.cost_per_unit)
existing_freight = {
row.input_key
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()
}
for key, label in FREIGHT_INPUTS.items():
if key not in existing_freight:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=round(freight_defaults.get(key, 0.0), 4)))
existing_clients = {
row.client_category
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()
}
products = db.scalars(select(Product).where(Product.tenant_id == tenant_id).options(selectinload(Product.mix))).all()
margins: dict[str, list[tuple[float | None, float | None]]] = {}
for product in products:
margins.setdefault(product.client_name, []).append((product.distributor_margin, product.wholesale_margin))
for client_name, rows in margins.items():
if client_name in existing_clients:
continue
distributor_values = [value for value, _ in rows if value is not None]
wholesale_values = [value for _, value in rows if value is not None]
db.add(
ProductCostClientInput(
tenant_id=tenant_id,
client_category=client_name,
distributor_margin=round(sum(distributor_values) / len(distributor_values), 6) if distributor_values else None,
wholesale_margin=round(sum(wholesale_values) / len(wholesale_values), 6) if wholesale_values else None,
)
)
existing_items = {
item.item_id: item
for item in db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == tenant_id)).all()
if item.item_id
}
created = 0
for product in products:
if not product.item_id:
continue
item = existing_items.get(product.item_id)
if item is not None:
continue
item = ProductCostItem(
tenant_id=tenant_id,
client_category=product.client_name,
item_id=product.item_id,
product_name=product.name,
mix_product_name=product.mix.name if product.mix else product.name,
unit_type=_unit_type_from_product(product),
own_bag=_own_bag_label(product),
unit_kg=_infer_throughput_bag_size(product) or 1.0,
items_per_pallet=product.items_per_pallet,
bagging_process=product.bagging_process,
manual_distributor_margin=product.distributor_margin,
manual_wholesale_margin=product.wholesale_margin,
)
db.add(item)
created += 1
db.flush()
recalculated = recalculate_all_product_cost_items(db, tenant_id)
return {"created": created, "recalculated": recalculated}
def seed_startup_basics():
Base.metadata.create_all(bind=engine)
with SessionLocal() as db:
@@ -1069,6 +1280,9 @@ def seed_startup_basics():
report = seed_product_ingredients_from_workbook(db)
if report["backfilled"]:
logger.info("Product ingredients backfilled from workbook: %s", report)
product_costing_report = seed_product_costing_module(db)
if any(product_costing_report.values()):
logger.info("Product costing module seeded: %s", product_costing_report)
db.commit()