339 lines
14 KiB
Python
339 lines
14 KiB
Python
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,
|
|
}
|