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