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)
|
||||
Reference in New Issue
Block a user