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
+2 -2
View File
@@ -239,7 +239,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
current_y,
detail_width,
detail_height,
"Product",
"Mix",
session_record.product_name,
value_font_size=12,
)
@@ -251,7 +251,7 @@ def build_mix_calculator_pdf(session_record: MixCalculatorSession | dict) -> byt
current_y,
detail_width,
detail_height,
"Mix source",
"Formula source",
session_record.mix_name,
value_font_size=11,
)
+45 -5
View File
@@ -76,6 +76,21 @@ def _fractional_bag_warning(batch_size_kg: float, total_bags: float, unit_of_mea
)
def _mix_calculator_label(product: Product) -> str:
return product.mix.name if product.mix else product.name
def _mix_calculator_option_rank(product: Product) -> tuple[int, int, float, int]:
unit_label = (product.unit_of_measure or "").lower()
unit_size = extract_unit_quantity_kg(product.unit_of_measure)
return (
0 if abs(unit_size - 20) < 1e-9 and "bag" in unit_label and "bulka" not in unit_label else 1,
0 if "bulka" not in unit_label else 1,
unit_size if unit_size > 0 else 999999,
product.id,
)
def calculate_mix_calculator_preview(
db: Session,
*,
@@ -117,12 +132,15 @@ def calculate_mix_calculator_preview(
}
)
mix_label = _mix_calculator_label(product)
return {
"client_name": product.client_name,
"product_id": product.id,
"product_name": product.name,
# The source workbook labels this as Product, but for the calculator
# it is the mix/formula being produced.
"product_name": mix_label,
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else product.name,
"mix_name": mix_label,
"mix_date": values["mix_date"],
"batch_size_kg": round(batch_size_kg, 4),
"total_bags": total_bags,
@@ -156,21 +174,43 @@ def build_mix_calculator_options(db: Session, *, tenant_id: str) -> dict:
).all()
mix_totals: dict[int, float] = {mix_id: round(total or 0.0, 4) for mix_id, total in mix_totals_rows}
product_ids_with_formulas = select(ProductIngredient.product_id).where(ProductIngredient.tenant_id == tenant_id)
products = db.scalars(
select(Product)
.where(Product.tenant_id == tenant_id, Product.visible.is_(True))
.where(
Product.tenant_id == tenant_id,
Product.visible.is_(True),
Product.id.in_(product_ids_with_formulas),
)
.options(joinedload(Product.mix))
.order_by(Product.client_name, Product.name)
).all()
representative_products: dict[tuple[str, str], Product] = {}
for product in products:
mix_label = _mix_calculator_label(product)
key = (product.client_name, mix_label)
current = representative_products.get(key)
if current is None:
representative_products[key] = product
continue
if _mix_calculator_option_rank(product) < _mix_calculator_option_rank(current):
representative_products[key] = product
products = sorted(
representative_products.values(),
key=lambda product: (product.client_name, _mix_calculator_label(product), product.id),
)
clients = sorted({product.client_name for product in products})
product_rows = [
{
"product_id": product.id,
"client_name": product.client_name,
"product_name": product.name,
"product_name": _mix_calculator_label(product),
"mix_id": product.mix_id,
"mix_name": product.mix.name if product.mix else "",
"mix_name": _mix_calculator_label(product),
"unit_of_measure": product.unit_of_measure,
"unit_size_kg": round(extract_unit_quantity_kg(product.unit_of_measure), 4),
"mix_total_kg": product_totals.get(product.id, mix_totals.get(product.mix_id, 0.0)),
@@ -0,0 +1,338 @@
from __future__ import annotations
from dataclasses import dataclass
import json
import math
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.product import Product
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.services.costing_engine import calculate_product_cost
UNIT_TYPES = ("Standard", "Bulka", "1.5 kg", "Per Unit")
OWN_BAG_VALUES = ("Yes", "No Bag")
ZERO_GRADING_CLIENTS = {"PHF Horse Mixes", "Peckish", "Hay & Straw"}
PROCESS_NAMES = ("Bagging + Grading", "Standard Bagging", "PHF Horse Mixes", "Peckish", "Hay & Straw")
BAG_INPUTS = {
"20kg_bag": "20kg bag",
"bulka_bag": "Bulka bag",
"own_bag_credit": "Own bag credit",
"1_5kg_bagging": "1.5kg bagging",
"peckish_bag": "Peckish bag",
}
FREIGHT_INPUTS = {
"freight_per_pallet": "Freight per pallet",
"peckish_freight_per_pallet": "Peckish freight per pallet",
"hay_straw_freight_per_pallet": "Hay & Straw freight per pallet",
}
@dataclass(frozen=True)
class ProductCostInputItem:
client_category: str
product_name: str
mix_product_name: str
unit_type: str
own_bag: str | None
unit_kg: float | None
items_per_pallet: int | None
bagging_process: str | None
manual_distributor_margin: float | None
manual_wholesale_margin: float | None
@dataclass(frozen=True)
class ProductCostAssumptions:
grading_per_kg: float
cracking_per_kg: float
process_costs: dict[str, float]
client_margins: dict[str, dict[str, float | None]]
bag_costs: dict[str, float]
freight_costs: dict[str, float]
@dataclass(frozen=True)
class ProductCostCalculation:
cleaned_product_cost_per_kg: float | None
grading_cost_per_kg: float | None
bagging_cost_per_kg: float | None
cracking_cost_per_kg: float | None
bag_cost_per_unit: float | None
freight_cost_per_unit: float | None
finished_product_delivered_cost: float | None
distributor_price: float | None
wholesale_price: float | None
warnings: list[str]
def _round4(value: float | None) -> float | None:
return None if value is None else round(value, 4)
def _ceil_to(value: float, digits: int) -> float:
factor = 10**digits
return math.ceil((value * factor) - 1e-9) / factor
def _valid_margin(value: float | None, label: str, warnings: list[str]) -> float | None:
if value is None:
return None
if value < 0 or value >= 1:
warnings.append(f"Invalid {label} margin")
return None
return value
def calculate_product_cost_item(
item: ProductCostInputItem,
assumptions: ProductCostAssumptions,
cleaned_product_cost_per_kg: float | None,
) -> ProductCostCalculation:
warnings: list[str] = []
unit_type = item.unit_type or "Standard"
unit_kg = item.unit_kg
items_per_pallet = item.items_per_pallet
if unit_type not in UNIT_TYPES:
warnings.append("Invalid unit type")
if cleaned_product_cost_per_kg is None:
warnings.append("Missing mix/product cost lookup")
if unit_kg is None or unit_kg <= 0:
warnings.append("Missing unit kg")
if items_per_pallet is None or items_per_pallet <= 0:
warnings.append("Missing pallet quantity")
grading_cost_per_kg = 0.0
if item.client_category not in ZERO_GRADING_CLIENTS and item.bagging_process:
grading_cost_per_kg = assumptions.grading_per_kg
bagging_cost_per_kg = assumptions.process_costs.get(item.bagging_process or "", 0.0)
if item.bagging_process and item.bagging_process not in assumptions.process_costs:
warnings.append("Missing bagging process cost")
cracking_cost_per_kg = assumptions.cracking_per_kg if "cracked" in item.product_name.lower() else 0.0
bag_cost_per_unit = 0.0
if item.client_category == "Peckish":
bag_cost_per_unit = assumptions.bag_costs.get("peckish_bag", 0.0)
elif unit_type == "1.5 kg":
bag_cost_per_unit = assumptions.bag_costs.get("1_5kg_bagging", 0.0)
elif item.own_bag == "No Bag":
bag_cost_per_unit = 0.0
elif unit_type == "Standard":
bag_cost_per_unit = assumptions.bag_costs.get("20kg_bag", 0.0)
elif unit_type == "Bulka":
bag_cost_per_unit = assumptions.bag_costs.get("bulka_bag", 0.0) / unit_kg if unit_kg and unit_kg > 0 else None
if bag_cost_per_unit is not None and item.own_bag == "Yes":
bag_cost_per_unit -= assumptions.bag_costs.get("own_bag_credit", 0.0)
freight_cost_per_unit: float | None
if item.client_category == "Peckish":
freight_cost_per_unit = assumptions.freight_costs.get("peckish_freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
elif item.client_category == "Hay & Straw":
freight_cost_per_unit = assumptions.freight_costs.get("hay_straw_freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
elif unit_type in {"Standard", "Per Unit"}:
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / items_per_pallet if items_per_pallet and items_per_pallet > 0 else None
elif unit_type == "Bulka":
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / unit_kg if unit_kg and unit_kg > 0 else None
else:
freight_cost_per_unit = assumptions.freight_costs.get("freight_per_pallet", 0.0) / 1000 * unit_kg if unit_kg and unit_kg > 0 else None
finished_cost = None
components = [cleaned_product_cost_per_kg, grading_cost_per_kg, bagging_cost_per_kg, cracking_cost_per_kg, bag_cost_per_unit, freight_cost_per_unit]
if all(value is not None for value in components) and unit_kg and unit_kg > 0:
per_kg_cost = cleaned_product_cost_per_kg + grading_cost_per_kg + bagging_cost_per_kg + cracking_cost_per_kg # type: ignore[operator]
if unit_type == "Standard":
finished_cost = per_kg_cost * unit_kg + bag_cost_per_unit + freight_cost_per_unit # type: ignore[operator]
elif unit_type in {"Bulka", "Per Unit"}:
finished_cost = per_kg_cost + bag_cost_per_unit + freight_cost_per_unit # type: ignore[operator]
else:
finished_cost = (per_kg_cost * unit_kg + bag_cost_per_unit + freight_cost_per_unit) * 8 # type: ignore[operator]
client_margin = assumptions.client_margins.get(item.client_category, {})
distributor_margin = _valid_margin(
item.manual_distributor_margin if item.manual_distributor_margin is not None else client_margin.get("distributor_margin"),
"distributor",
warnings,
)
wholesale_margin = _valid_margin(
item.manual_wholesale_margin if item.manual_wholesale_margin is not None else client_margin.get("wholesale_margin"),
"wholesale",
warnings,
)
distributor_price = finished_cost / (1 - distributor_margin) if finished_cost is not None and distributor_margin is not None else None
wholesale_price = finished_cost / (1 - wholesale_margin) if finished_cost is not None and wholesale_margin is not None else None
if wholesale_price is not None:
wholesale_price = _ceil_to(wholesale_price, 2 if item.client_category == "Straight Grain" and unit_type == "Bulka" else 1)
return ProductCostCalculation(
cleaned_product_cost_per_kg=_round4(cleaned_product_cost_per_kg),
grading_cost_per_kg=_round4(grading_cost_per_kg),
bagging_cost_per_kg=_round4(bagging_cost_per_kg),
cracking_cost_per_kg=_round4(cracking_cost_per_kg),
bag_cost_per_unit=_round4(bag_cost_per_unit),
freight_cost_per_unit=_round4(freight_cost_per_unit),
finished_product_delivered_cost=_round4(finished_cost),
distributor_price=_round4(distributor_price),
wholesale_price=_round4(wholesale_price),
warnings=warnings,
)
def _item_input(item: ProductCostItem) -> ProductCostInputItem:
return ProductCostInputItem(
client_category=item.client_category,
product_name=item.product_name,
mix_product_name=item.mix_product_name,
unit_type=item.unit_type,
own_bag=item.own_bag,
unit_kg=item.unit_kg,
items_per_pallet=item.items_per_pallet,
bagging_process=item.bagging_process,
manual_distributor_margin=item.manual_distributor_margin,
manual_wholesale_margin=item.manual_wholesale_margin,
)
def get_product_costing_assumptions(db: Session, tenant_id: str) -> ProductCostAssumptions:
base = db.scalar(select(ProductCostBaseInput).where(ProductCostBaseInput.tenant_id == tenant_id))
if base is None:
base = ProductCostBaseInput(tenant_id=tenant_id)
db.add(base)
db.flush()
process_costs = {
row.process_name: row.cost_per_kg
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()
}
client_margins = {
row.client_category: {
"distributor_margin": row.distributor_margin,
"wholesale_margin": row.wholesale_margin,
}
for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()
}
bag_costs = {
row.input_key: row.cost
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()
}
freight_costs = {
row.input_key: row.cost
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()
}
return ProductCostAssumptions(
grading_per_kg=base.grading_per_kg or ((base.grading_per_tonne or 0.0) / 1000),
cracking_per_kg=base.cracking_per_kg or ((base.cracking_per_tonne or 0.0) / 1000),
process_costs=process_costs,
client_margins=client_margins,
bag_costs=bag_costs,
freight_costs=freight_costs,
)
def lookup_cleaned_product_cost_per_kg(db: Session, item: ProductCostItem) -> float | None:
product = db.scalar(
select(Product)
.where(
Product.tenant_id == item.tenant_id,
Product.client_name == item.client_category,
Product.name == item.mix_product_name,
)
.limit(1)
)
if product is None:
product = db.scalar(
select(Product)
.where(
Product.tenant_id == item.tenant_id,
Product.client_name == item.client_category,
Product.name == item.product_name,
)
.limit(1)
)
if product is None:
return None
try:
result = calculate_product_cost(db, product.id)
except ValueError:
return None
mix = (result.get("inputs") or {}).get("mix") or {}
return mix.get("mix_cost_per_kg")
def apply_calculation(item: ProductCostItem, calculation: ProductCostCalculation) -> ProductCostItem:
item.cleaned_product_cost_per_kg = calculation.cleaned_product_cost_per_kg
item.grading_cost_per_kg = calculation.grading_cost_per_kg
item.bagging_cost_per_kg = calculation.bagging_cost_per_kg
item.cracking_cost_per_kg = calculation.cracking_cost_per_kg
item.bag_cost_per_unit = calculation.bag_cost_per_unit
item.freight_cost_per_unit = calculation.freight_cost_per_unit
item.finished_product_delivered_cost = calculation.finished_product_delivered_cost
item.distributor_price = calculation.distributor_price
item.wholesale_price = calculation.wholesale_price
item.warnings = json.dumps(calculation.warnings)
return item
def recalculate_product_cost_item(db: Session, item: ProductCostItem) -> ProductCostItem:
assumptions = get_product_costing_assumptions(db, item.tenant_id)
cleaned_cost = lookup_cleaned_product_cost_per_kg(db, item)
calculation = calculate_product_cost_item(_item_input(item), assumptions, cleaned_cost)
return apply_calculation(item, calculation)
def recalculate_all_product_cost_items(db: Session, tenant_id: str) -> int:
items = db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == tenant_id)).all()
for item in items:
recalculate_product_cost_item(db, item)
return len(items)
def serialize_product_cost_item(item: ProductCostItem) -> dict:
warnings = []
if item.warnings:
try:
warnings = json.loads(item.warnings)
except json.JSONDecodeError:
warnings = [item.warnings]
return {
"id": item.id,
"tenant_id": item.tenant_id,
"client_category": item.client_category,
"item_id": item.item_id,
"product_name": item.product_name,
"mix_product_name": item.mix_product_name,
"unit_type": item.unit_type,
"own_bag": item.own_bag,
"unit_kg": item.unit_kg,
"items_per_pallet": item.items_per_pallet,
"bagging_process": item.bagging_process,
"manual_distributor_margin": item.manual_distributor_margin,
"manual_wholesale_margin": item.manual_wholesale_margin,
"cleaned_product_cost_per_kg": item.cleaned_product_cost_per_kg,
"grading_cost_per_kg": item.grading_cost_per_kg,
"bagging_cost_per_kg": item.bagging_cost_per_kg,
"cracking_cost_per_kg": item.cracking_cost_per_kg,
"bag_cost_per_unit": item.bag_cost_per_unit,
"freight_cost_per_unit": item.freight_cost_per_unit,
"finished_product_delivered_cost": item.finished_product_delivered_cost,
"distributor_price": item.distributor_price,
"wholesale_price": item.wholesale_price,
"warnings": warnings,
"created_at": item.created_at,
"updated_at": item.updated_at,
}