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