v0.1.12
This commit is contained in:
+215
-1
@@ -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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user