Files
data-entry-app/backend/app/api/dashboard.py
T

318 lines
12 KiB
Python
Raw Normal View History

"""Dashboard summary endpoint.
Returns only the aggregates the homepage actually renders — counts, top items,
totals, and a trend-chart series. Replaces a Dashboard load that previously
fetched five full collections (raw materials, mixes, all product cost
breakdowns, scenarios, data-quality) and only used summaries from each.
"""
from __future__ import annotations
2026-06-09 21:28:53 +12:00
from datetime import date
import json
from fastapi import APIRouter, Depends
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload
2026-05-10 09:46:07 +12:00
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
2026-06-09 21:28:53 +12:00
from app.models.product_costing import ProductCostItem
from app.models.raw_material import RawMaterial
2026-06-09 21:28:53 +12:00
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,
calculate_product_cost,
get_active_price,
calculate_raw_material_cost,
)
router = APIRouter(prefix="/api/dashboard", tags=["dashboard"])
def _can(session: AuthSession, module_key: str) -> bool:
permissions = session.module_permissions or {}
return has_access_level(permissions.get(module_key), "view")
2026-06-09 21:28:53 +12:00
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(
2026-05-10 09:46:07 +12:00
session: AuthSession = Depends(require_client_module_access("dashboard")),
db: Session = Depends(get_db),
):
raw_materials_summary: dict | None = None
mixes_summary: dict | None = None
products_summary: dict | None = None
raw_series: list[float] = []
mix_series: list[float] = []
product_series: list[float] = []
2026-06-09 21:28:53 +12:00
operations_summary = _operations_summary(session, db)
if _can(session, "raw_materials") or _can(session, "dashboard"):
materials = db.scalars(
select(RawMaterial)
.where(RawMaterial.tenant_id == session.tenant_id)
.options(selectinload(RawMaterial.price_versions))
).all()
total_market_value = 0.0
latest = None
latest_date = None
for material in materials:
active = get_active_price(material)
if active is None:
continue
comp = calculate_raw_material_cost(material, active)
raw_series.append(comp.cost_per_kg)
total_market_value += active.market_value
if latest_date is None or active.effective_date > latest_date:
latest_date = active.effective_date
latest = {
"id": material.id,
"name": material.name,
"market_value": active.market_value,
"cost_per_kg": comp.cost_per_kg,
"effective_date": active.effective_date.isoformat() if active.effective_date else None,
}
raw_materials_summary = {
"count": len(materials),
"total_market_value": round(total_market_value, 4),
"latest": latest,
}
if _can(session, "mix_master") or _can(session, "dashboard"):
mix_rows = db.scalars(
select(Mix).where(Mix.tenant_id == session.tenant_id).order_by(Mix.name)
).all()
cost_sum = 0.0
cost_count = 0
top_mix: dict | None = None
for mix in mix_rows:
result = calculate_mix_cost(db, mix.id)
cost_per_kg = result.get("mix_cost_per_kg")
if cost_per_kg is not None:
mix_series.append(cost_per_kg)
cost_sum += cost_per_kg
cost_count += 1
if top_mix is None or (cost_per_kg or 0) > (top_mix.get("mix_cost_per_kg") or 0):
top_mix = {
"id": result["id"],
"name": result["name"],
"client_name": result["client_name"],
"ingredients_count": len(result["ingredients"]),
"total_mix_kg": result["total_mix_kg"],
"total_mix_cost": result["total_mix_cost"],
"mix_cost_per_kg": cost_per_kg,
"warnings": result["warnings"],
}
mixes_summary = {
"count": len(mix_rows),
"average_cost_per_kg": round(cost_sum / cost_count, 4) if cost_count else 0.0,
"top": top_mix,
}
if _can(session, "products") or _can(session, "dashboard"):
products = db.scalars(
select(Product).where(Product.tenant_id == session.tenant_id)
).all()
rows: list[dict] = []
for product in products:
result = calculate_product_cost(db, product.id)
finished = result.get("finished_product_delivered") or 0.0
product_series.append(finished)
rows.append(
{
"id": product.id,
"product_name": result["product_name"],
"client_name": result["client_name"],
"finished_product_delivered": finished,
"warnings": result["warnings"],
}
)
rows.sort(key=lambda row: row["finished_product_delivered"], reverse=True)
products_summary = {
"count": len(products),
"top": rows[0] if rows else None,
"top_products": rows[:4],
}
return {
"raw_materials": raw_materials_summary,
"mixes": mixes_summary,
"products": products_summary,
# Pre-computed numeric series for the homepage trend chart so the
# client doesn't need full collections to draw it.
"trend_seeds": {
"raw_material_cost_per_kg": raw_series,
"mix_cost_per_kg": mix_series,
"product_finished_delivered": product_series,
},
2026-06-09 21:28:53 +12:00
"operations": operations_summary,
}