v0.1.12
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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")),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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