"""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 from datetime import date import json from fastapi import APIRouter, Depends from sqlalchemy import select from sqlalchemy.orm import Session, selectinload 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, 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") 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")), 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] = [] 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, }, "operations": operations_summary, }