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
+167
View File
@@ -7,6 +7,9 @@ breakdowns, scenarios, data-quality) and only used summaries from each.
"""
from __future__ import annotations
from datetime import date
import json
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
@@ -15,7 +18,9 @@ from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.mix import Mix
from app.models.product import Product
from app.models.product_costing import ProductCostItem
from app.models.raw_material import RawMaterial
from app.models.throughput import ProductionThroughput, ThroughputProduct
from app.services.client_access_service import has_access_level
from app.services.costing_engine import (
calculate_mix_cost,
@@ -33,6 +38,166 @@ def _can(session: AuthSession, module_key: str) -> bool:
return has_access_level(permissions.get(module_key), "view")
def _month_start(today: date) -> date:
return today.replace(day=1)
def _warnings(item: ProductCostItem) -> list[str]:
if not item.warnings:
return []
try:
parsed = json.loads(item.warnings)
return parsed if isinstance(parsed, list) else [str(parsed)]
except json.JSONDecodeError:
return [item.warnings]
def _pricing_key(value: str | None) -> str:
return (value or "").strip().lower()
def _find_pricing_item(
entry: ProductionThroughput,
product: ThroughputProduct | None,
by_item_id: dict[str, ProductCostItem],
by_name: dict[str, ProductCostItem],
) -> ProductCostItem | None:
if product and product.item_id and product.item_id in by_item_id:
return by_item_id[product.item_id]
return by_name.get(_pricing_key(entry.product_name_snapshot)) or by_name.get(_pricing_key(product.name if product else None))
def _operations_summary(session: AuthSession, db: Session) -> dict | None:
if not (_can(session, "operations_throughput") or _can(session, "products") or _can(session, "dashboard")):
return None
today = date.today()
start = _month_start(today)
entries = db.scalars(
select(ProductionThroughput)
.where(
ProductionThroughput.tenant_id == session.tenant_id,
ProductionThroughput.production_date >= start,
ProductionThroughput.production_date <= today,
)
.options(selectinload(ProductionThroughput.product))
.order_by(ProductionThroughput.production_date.desc())
).all()
pricing_items = db.scalars(select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)).all()
by_item_id = {item.item_id: item for item in pricing_items if item.item_id}
by_name: dict[str, ProductCostItem] = {}
for item in pricing_items:
by_name.setdefault(_pricing_key(item.product_name), item)
by_name.setdefault(_pricing_key(item.mix_product_name), item)
product_totals: dict[str, dict] = {}
client_totals: dict[str, float] = {}
produced_not_priced: dict[str, dict] = {}
total_kg = 0.0
total_bags = 0.0
estimated_wholesale_value = 0.0
wholesale_rows = 0
for entry in entries:
kg = entry.calculated_kg or 0.0
bags = entry.quantity if entry.quantity_type == "bags" else 0.0
total_kg += kg
total_bags += bags
product = entry.product
name = entry.product_name_snapshot or product.name if product else entry.product_name_snapshot
bucket = product_totals.setdefault(
name,
{"product_name": name, "client_name": product.client_name if product else None, "kg": 0.0, "bags": 0.0, "entries": 0},
)
bucket["kg"] += kg
bucket["bags"] += bags
bucket["entries"] += 1
client = product.client_name if product and product.client_name else "Unassigned"
client_totals[client] = client_totals.get(client, 0.0) + kg
pricing = _find_pricing_item(entry, product, by_item_id, by_name)
pricing_warnings = _warnings(pricing) if pricing else ["Missing product pricing"]
wholesale_price = pricing.wholesale_price if pricing else None
unit_kg = pricing.unit_kg if pricing else None
if wholesale_price is not None:
units = kg / unit_kg if unit_kg and unit_kg > 0 else entry.quantity
estimated_wholesale_value += units * wholesale_price
wholesale_rows += 1
if pricing is None or pricing_warnings or wholesale_price is None:
missing = produced_not_priced.setdefault(
name,
{
"product_name": name,
"kg": 0.0,
"status": "Missing pricing" if pricing is None else "Needs review",
"warnings": pricing_warnings[:2],
},
)
missing["kg"] += kg
issue_counts = {
"missing_lookup": 0,
"missing_unit_kg": 0,
"missing_pallet_qty": 0,
"missing_price": 0,
"invalid_margin": 0,
}
for item in pricing_items:
warnings = " ".join(_warnings(item)).lower()
if "lookup" in warnings:
issue_counts["missing_lookup"] += 1
if "unit kg" in warnings:
issue_counts["missing_unit_kg"] += 1
if "pallet" in warnings:
issue_counts["missing_pallet_qty"] += 1
if item.distributor_price is None or item.wholesale_price is None:
issue_counts["missing_price"] += 1
if "margin" in warnings:
issue_counts["invalid_margin"] += 1
top_products = sorted(product_totals.values(), key=lambda row: row["kg"], reverse=True)[:5]
clients = [
{"client_name": client, "kg": round(kg, 2)}
for client, kg in sorted(client_totals.items(), key=lambda item: item[1], reverse=True)[:5]
]
produced_not_priced_rows = sorted(produced_not_priced.values(), key=lambda row: row["kg"], reverse=True)[:5]
return {
"period_label": "This month",
"total_kg": round(total_kg, 2),
"total_bags": round(total_bags, 2),
"entry_count": len(entries),
"estimated_wholesale_value": round(estimated_wholesale_value, 2),
"priced_entry_count": wholesale_rows,
"top_products": [
{
"product_name": row["product_name"],
"client_name": row["client_name"],
"kg": round(row["kg"], 2),
"bags": round(row["bags"], 2),
"entries": row["entries"],
}
for row in top_products
],
"client_totals": clients,
"pricing_issues": {
**issue_counts,
"total": sum(issue_counts.values()),
},
"produced_not_priced": [
{
"product_name": row["product_name"],
"kg": round(row["kg"], 2),
"status": row["status"],
"warnings": row["warnings"],
}
for row in produced_not_priced_rows
],
}
@router.get("/summary")
def dashboard_summary(
session: AuthSession = Depends(require_client_module_access("dashboard")),
@@ -44,6 +209,7 @@ def dashboard_summary(
raw_series: list[float] = []
mix_series: list[float] = []
product_series: list[float] = []
operations_summary = _operations_summary(session, db)
if _can(session, "raw_materials") or _can(session, "dashboard"):
materials = db.scalars(
@@ -147,4 +313,5 @@ def dashboard_summary(
"mix_cost_per_kg": mix_series,
"product_finished_delivered": product_series,
},
"operations": operations_summary,
}
+247
View File
@@ -0,0 +1,247 @@
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from app.api.deps import AuthSession, require_client_module_access
from app.db.session import get_db
from app.models.product_costing import (
ProductCostBagInput,
ProductCostBaseInput,
ProductCostClientInput,
ProductCostFreightInput,
ProductCostItem,
ProductCostProcessInput,
)
from app.schemas.product_costing import (
ProductCostInputsRead,
ProductCostInputsUpdate,
ProductCostItemCreate,
ProductCostItemRead,
ProductCostItemUpdate,
ProductCostRecalculateAllRead,
)
from app.services.product_costing_service import (
BAG_INPUTS,
FREIGHT_INPUTS,
PROCESS_NAMES,
recalculate_all_product_cost_items,
recalculate_product_cost_item,
serialize_product_cost_item,
)
router = APIRouter(prefix="/api/product-costing", tags=["product-costing"])
def _load_item(db: Session, tenant_id: str, item_id: int) -> ProductCostItem | None:
return db.scalar(select(ProductCostItem).where(ProductCostItem.id == item_id, ProductCostItem.tenant_id == tenant_id))
def _ensure_inputs(db: Session, tenant_id: str) -> ProductCostBaseInput:
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()
for process_name in PROCESS_NAMES:
if db.scalar(select(ProductCostProcessInput.id).where(ProductCostProcessInput.tenant_id == tenant_id, ProductCostProcessInput.process_name == process_name)) is None:
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=process_name, cost_per_kg=0.0))
for key, label in BAG_INPUTS.items():
if db.scalar(select(ProductCostBagInput.id).where(ProductCostBagInput.tenant_id == tenant_id, ProductCostBagInput.input_key == key)) is None:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
for key, label in FREIGHT_INPUTS.items():
if db.scalar(select(ProductCostFreightInput.id).where(ProductCostFreightInput.tenant_id == tenant_id, ProductCostFreightInput.input_key == key)) is None:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=key, label=label, cost=0.0))
db.flush()
return base
def _serialize_inputs(db: Session, tenant_id: str) -> dict:
base = _ensure_inputs(db, tenant_id)
return {
"base": {
"grading_per_tonne": base.grading_per_tonne,
"grading_per_kg": base.grading_per_kg,
"cracking_per_tonne": base.cracking_per_tonne,
"cracking_per_kg": base.cracking_per_kg,
},
"processes": [
{"key": row.process_name, "label": row.process_name, "cost": row.cost_per_kg}
for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id).order_by(ProductCostProcessInput.process_name)).all()
],
"clients": [
{
"client_category": 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).order_by(ProductCostClientInput.client_category)).all()
],
"bags": [
{"key": row.input_key, "label": row.label, "cost": row.cost}
for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id).order_by(ProductCostBagInput.input_key)).all()
],
"freight": [
{"key": row.input_key, "label": row.label, "cost": row.cost}
for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id).order_by(ProductCostFreightInput.input_key)).all()
],
}
@router.get("/items", response_model=list[ProductCostItemRead])
def list_product_cost_items(
q: str | None = Query(default=None),
client_category: str | None = Query(default=None),
limit: int = Query(default=250, ge=1, le=1000),
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
statement = select(ProductCostItem).where(ProductCostItem.tenant_id == session.tenant_id)
if client_category:
statement = statement.where(ProductCostItem.client_category == client_category)
if q:
term = f"%{q}%"
statement = statement.where(
or_(
ProductCostItem.client_category.ilike(term),
ProductCostItem.item_id.ilike(term),
ProductCostItem.product_name.ilike(term),
ProductCostItem.mix_product_name.ilike(term),
)
)
items = db.scalars(statement.order_by(ProductCostItem.client_category, ProductCostItem.product_name).limit(limit)).all()
return [serialize_product_cost_item(item) for item in items]
@router.post("/items", response_model=ProductCostItemRead, status_code=status.HTTP_201_CREATED)
def create_product_cost_item(
payload: ProductCostItemCreate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = ProductCostItem(tenant_id=session.tenant_id or "default", **payload.model_dump())
db.add(item)
db.flush()
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.get("/items/{item_id}", response_model=ProductCostItemRead)
def get_product_cost_item(
item_id: int,
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
return serialize_product_cost_item(item)
@router.patch("/items/{item_id}", response_model=ProductCostItemRead)
def update_product_cost_item(
item_id: int,
payload: ProductCostItemUpdate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(item, field, value)
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.post("/items/{item_id}/recalculate", response_model=ProductCostItemRead)
def recalculate_one(
item_id: int,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
item = _load_item(db, session.tenant_id or "default", item_id)
if item is None:
raise HTTPException(status_code=404, detail="Product cost item not found")
recalculate_product_cost_item(db, item)
db.commit()
db.refresh(item)
return serialize_product_cost_item(item)
@router.post("/recalculate-all", response_model=ProductCostRecalculateAllRead)
def recalculate_all(
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
count = recalculate_all_product_cost_items(db, session.tenant_id or "default")
db.commit()
return {"recalculated": count}
@router.get("/inputs", response_model=ProductCostInputsRead)
def get_product_cost_inputs(
session: AuthSession = Depends(require_client_module_access("products")),
db: Session = Depends(get_db),
):
return _serialize_inputs(db, session.tenant_id or "default")
@router.patch("/inputs", response_model=ProductCostInputsRead)
def update_product_cost_inputs(
payload: ProductCostInputsUpdate,
session: AuthSession = Depends(require_client_module_access("products", "edit")),
db: Session = Depends(get_db),
):
tenant_id = session.tenant_id or "default"
base = _ensure_inputs(db, tenant_id)
if payload.base is not None:
for field, value in payload.base.model_dump().items():
setattr(base, field, value)
if payload.processes is not None:
existing = {row.process_name: row for row in db.scalars(select(ProductCostProcessInput).where(ProductCostProcessInput.tenant_id == tenant_id)).all()}
for row in payload.processes:
target = existing.get(row.key)
if target is None:
db.add(ProductCostProcessInput(tenant_id=tenant_id, process_name=row.key, cost_per_kg=row.cost))
else:
target.cost_per_kg = row.cost
if payload.clients is not None:
existing = {row.client_category: row for row in db.scalars(select(ProductCostClientInput).where(ProductCostClientInput.tenant_id == tenant_id)).all()}
for row in payload.clients:
target = existing.get(row.client_category)
if target is None:
db.add(ProductCostClientInput(tenant_id=tenant_id, client_category=row.client_category, distributor_margin=row.distributor_margin, wholesale_margin=row.wholesale_margin))
else:
target.distributor_margin = row.distributor_margin
target.wholesale_margin = row.wholesale_margin
if payload.bags is not None:
existing = {row.input_key: row for row in db.scalars(select(ProductCostBagInput).where(ProductCostBagInput.tenant_id == tenant_id)).all()}
for row in payload.bags:
target = existing.get(row.key)
if target is None:
db.add(ProductCostBagInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
else:
target.label = row.label
target.cost = row.cost
if payload.freight is not None:
existing = {row.input_key: row for row in db.scalars(select(ProductCostFreightInput).where(ProductCostFreightInput.tenant_id == tenant_id)).all()}
for row in payload.freight:
target = existing.get(row.key)
if target is None:
db.add(ProductCostFreightInput(tenant_id=tenant_id, input_key=row.key, label=row.label, cost=row.cost))
else:
target.label = row.label
target.cost = row.cost
db.flush()
recalculate_all_product_cost_items(db, tenant_id)
db.commit()
return _serialize_inputs(db, tenant_id)
+1 -1
View File
@@ -62,7 +62,7 @@ class Settings:
@classmethod
def from_env(cls) -> "Settings":
settings = cls(
app_name=os.getenv("APP_NAME", "Data Entry App API"),
app_name=os.getenv("APP_NAME", "Hunter App"),
app_env=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")),
host=os.getenv("HOST", "0.0.0.0"),
port=int(os.getenv("PORT", "8000")),
+6
View File
@@ -29,6 +29,12 @@ TENANT_TABLES = {
"mix_calculator_sessions": None,
"mix_calculator_session_lines": None,
"products": None,
"product_cost_items": None,
"product_cost_base_inputs": None,
"product_cost_process_inputs": None,
"product_cost_client_inputs": None,
"product_cost_bag_inputs": None,
"product_cost_freight_inputs": None,
"scenarios": None,
"costing_results": None,
"process_cost_rules": None,
+2
View File
@@ -26,6 +26,7 @@ from app.api.editor import router as editor_router
from app.api.mix_calculator import router as mix_calculator_router
from app.api.mixes import router as mixes_router
from app.api.powerbi import router as powerbi_router
from app.api.product_costing import router as product_costing_router
from app.api.products import router as products_router
from app.api.raw_materials import router as raw_materials_router
from app.api.scenarios import router as scenarios_router
@@ -199,6 +200,7 @@ app.include_router(editor_router)
app.include_router(raw_materials_router)
app.include_router(mixes_router)
app.include_router(mix_calculator_router)
app.include_router(product_costing_router)
app.include_router(products_router)
app.include_router(scenarios_router)
app.include_router(throughput_router)
+14
View File
@@ -4,6 +4,14 @@ from app.models.client_access import ClientAccessAuditEvent, ClientAccount, Clie
from app.models.mix_calculator import MixCalculatorSession, MixCalculatorSessionLine
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.scenario import CostingResult, Scenario
from app.models.throughput import ProductionThroughput, ThroughputProduct
@@ -24,6 +32,12 @@ __all__ = [
"Permission",
"ProcessCostRule",
"Product",
"ProductCostBagInput",
"ProductCostBaseInput",
"ProductCostClientInput",
"ProductCostFreightInput",
"ProductCostItem",
"ProductCostProcessInput",
"ProductIngredient",
"ProductionThroughput",
"ThroughputProduct",
+99
View File
@@ -0,0 +1,99 @@
from __future__ import annotations
from datetime import datetime
from sqlalchemy import DateTime, Float, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.db.session import Base
class ProductCostItem(Base):
__tablename__ = "product_cost_items"
__table_args__ = (UniqueConstraint("tenant_id", "item_id", name="uq_product_cost_item_tenant_item"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_category: Mapped[str] = mapped_column(String(255), index=True)
item_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
product_name: Mapped[str] = mapped_column(String(255), index=True)
mix_product_name: Mapped[str] = mapped_column(String(255), index=True)
unit_type: Mapped[str] = mapped_column(String(32), default="Standard")
own_bag: Mapped[str | None] = mapped_column(String(32), nullable=True)
unit_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
items_per_pallet: Mapped[int | None] = mapped_column(Integer, nullable=True)
bagging_process: Mapped[str | None] = mapped_column(String(128), nullable=True)
manual_distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
manual_wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
cleaned_product_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
grading_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
bagging_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
cracking_cost_per_kg: Mapped[float | None] = mapped_column(Float, nullable=True)
bag_cost_per_unit: Mapped[float | None] = mapped_column(Float, nullable=True)
freight_cost_per_unit: Mapped[float | None] = mapped_column(Float, nullable=True)
finished_product_delivered_cost: Mapped[float | None] = mapped_column(Float, nullable=True)
distributor_price: Mapped[float | None] = mapped_column(Float, nullable=True)
wholesale_price: Mapped[float | None] = mapped_column(Float, nullable=True)
warnings: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostBaseInput(Base):
__tablename__ = "product_cost_base_inputs"
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", unique=True, index=True)
grading_per_tonne: Mapped[float] = mapped_column(Float, default=0.0)
grading_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
cracking_per_tonne: Mapped[float] = mapped_column(Float, default=0.0)
cracking_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostProcessInput(Base):
__tablename__ = "product_cost_process_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "process_name", name="uq_product_cost_process_tenant_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
process_name: Mapped[str] = mapped_column(String(128), index=True)
cost_per_kg: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostClientInput(Base):
__tablename__ = "product_cost_client_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "client_category", name="uq_product_cost_client_tenant_name"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
client_category: Mapped[str] = mapped_column(String(255), index=True)
distributor_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
wholesale_margin: Mapped[float | None] = mapped_column(Float, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostBagInput(Base):
__tablename__ = "product_cost_bag_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "input_key", name="uq_product_cost_bag_tenant_key"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
input_key: Mapped[str] = mapped_column(String(64), index=True)
label: Mapped[str] = mapped_column(String(128))
cost: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class ProductCostFreightInput(Base):
__tablename__ = "product_cost_freight_inputs"
__table_args__ = (UniqueConstraint("tenant_id", "input_key", name="uq_product_cost_freight_tenant_key"),)
id: Mapped[int] = mapped_column(primary_key=True)
tenant_id: Mapped[str] = mapped_column(String(64), default="default", index=True)
input_key: Mapped[str] = mapped_column(String(64), index=True)
label: Mapped[str] = mapped_column(String(128))
cost: Mapped[float] = mapped_column(Float, default=0.0)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+97
View File
@@ -0,0 +1,97 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ProductCostItemBase(BaseModel):
client_category: str = Field(min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
product_name: str = Field(min_length=1, max_length=255)
mix_product_name: str = Field(min_length=1, max_length=255)
unit_type: str = "Standard"
own_bag: str | None = None
unit_kg: float | None = Field(default=None, gt=0)
items_per_pallet: int | None = Field(default=None, gt=0)
bagging_process: str | None = Field(default=None, max_length=128)
manual_distributor_margin: float | None = Field(default=None, ge=0, lt=1)
manual_wholesale_margin: float | None = Field(default=None, ge=0, lt=1)
class ProductCostItemCreate(ProductCostItemBase):
pass
class ProductCostItemUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
client_category: str | None = Field(default=None, min_length=1, max_length=255)
item_id: str | None = Field(default=None, max_length=128)
product_name: str | None = Field(default=None, min_length=1, max_length=255)
mix_product_name: str | None = Field(default=None, min_length=1, max_length=255)
unit_type: str | None = None
own_bag: str | None = None
unit_kg: float | None = Field(default=None, gt=0)
items_per_pallet: int | None = Field(default=None, gt=0)
bagging_process: str | None = Field(default=None, max_length=128)
manual_distributor_margin: float | None = Field(default=None, ge=0, lt=1)
manual_wholesale_margin: float | None = Field(default=None, ge=0, lt=1)
class ProductCostItemRead(ProductCostItemBase):
id: int
tenant_id: str
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]
created_at: datetime
updated_at: datetime
class ProductCostBaseInputRead(BaseModel):
grading_per_tonne: float
grading_per_kg: float
cracking_per_tonne: float
cracking_per_kg: float
class ProductCostBaseInputUpdate(ProductCostBaseInputRead):
pass
class ProductCostNamedInputRead(BaseModel):
key: str
label: str
cost: float
class ProductCostClientInputRead(BaseModel):
client_category: str
distributor_margin: float | None
wholesale_margin: float | None
class ProductCostInputsRead(BaseModel):
base: ProductCostBaseInputRead
processes: list[ProductCostNamedInputRead]
clients: list[ProductCostClientInputRead]
bags: list[ProductCostNamedInputRead]
freight: list[ProductCostNamedInputRead]
class ProductCostInputsUpdate(BaseModel):
model_config = ConfigDict(extra="forbid")
base: ProductCostBaseInputUpdate | None = None
processes: list[ProductCostNamedInputRead] | None = None
clients: list[ProductCostClientInputRead] | None = None
bags: list[ProductCostNamedInputRead] | None = None
freight: list[ProductCostNamedInputRead] | None = None
class ProductCostRecalculateAllRead(BaseModel):
recalculated: int
+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()
+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,
}