"""WESP-shaped analytics and feed-quality endpoints for /reports tabs.""" from __future__ import annotations import json from collections import defaultdict from datetime import UTC, datetime, timedelta from typing import Any from fastapi import APIRouter, Depends, HTTPException, Query, status from sqlalchemy import select from app.core.database import session_scope from app.modules.sync.tenant import TenantContext, require_enterprise_zootech from app.modules.zootech.models import ZootechComponent from app.modules.zootech.report_models import ZootechFeedAlert, ZootechLoadingReport from app.modules.zootech.wesp_compat_reports import _parse_payload, _parse_report_time router = APIRouter() def _require_ent(enterprise_id: str, tenant: TenantContext) -> None: if tenant.enterprise_id != enterprise_id: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="ENTERPRISE_FORBIDDEN") def _parse_date_window(date_from: str | None, date_to: str | None) -> tuple[datetime, datetime] | None: if not date_from or not date_to: return None try: start = datetime.strptime(date_from.strip(), "%Y-%m-%d").replace(tzinfo=UTC) end = datetime.strptime(date_to.strip(), "%Y-%m-%d").replace(tzinfo=UTC) + timedelta(days=1) return start, end except ValueError as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"error": True, "message": "Некорректный формат date_from/date_to (YYYY-MM-DD)"}, ) from exc def _loading_reports_in_range( enterprise_id: str, window: tuple[datetime, datetime] | None ) -> list[tuple[str, dict[str, Any]]]: with session_scope() as db: rows = list( db.scalars( select(ZootechLoadingReport).where( ZootechLoadingReport.enterprise_id == enterprise_id, ZootechLoadingReport.is_deleted.is_(False), ) ) ) out: list[tuple[str, dict[str, Any]]] = [] for row in rows: payload = _parse_payload(row) start_time = _parse_report_time(payload.get("start_time")) if window and start_time and not (window[0] <= start_time < window[1]): continue if window and start_time is None: continue out.append((row.id, payload)) return out def _component_prices(enterprise_id: str) -> dict[str, dict[str, float]]: with session_scope() as db: rows = list( db.scalars( select(ZootechComponent).where( ZootechComponent.enterprise_id == enterprise_id, ZootechComponent.is_deleted.is_(False), ) ) ) by_id = {row.id: float(row.price or 0) for row in rows} by_name = {(row.name or "").strip().lower(): float(row.price or 0) for row in rows if row.name} return {"id": by_id, "name": by_name} def _price_for(prices: dict[str, dict[str, float]], component_id: str | None, name: str) -> float: if component_id and component_id in prices["id"]: return prices["id"][component_id] key = (name or "").strip().lower() if key and key in prices["name"]: return prices["name"][key] return 0.0 @router.get("/analytics/finance") def analytics_finance_wesp( enterprise_id: str = Query(...), date_from: str | None = Query(None), date_to: str | None = Query(None), tenant: TenantContext = Depends(require_enterprise_zootech), ): _require_ent(enterprise_id, tenant) window = _parse_date_window(date_from, date_to) reports = _loading_reports_in_range(enterprise_id, window) if not reports: return { "overloadRub": 0.0, "underloadRub": 0.0, "netRub": 0.0, "dominantIssue": "balanced", "topComponents": [], "reportCount": 0, } prices = _component_prices(enterprise_id) by_component: dict[str, dict[str, Any]] = defaultdict( lambda: {"name": "", "overloadRub": 0.0, "underloadRub": 0.0, "netRub": 0.0} ) overload_total = 0.0 underload_total = 0.0 for _report_id, payload in reports: components = payload.get("components") if not isinstance(components, list): continue for comp in components: if not isinstance(comp, dict): continue target = float(comp.get("target_weight") or 0) actual = float(comp.get("actual_weight") or 0) if target <= 0 and actual <= 0: continue name = str(comp.get("component_name") or "—") price = _price_for(prices, comp.get("component_id"), name) dev_kg = actual - target dev_rub = dev_kg * price key = str(comp.get("component_id") or name) row = by_component[key] row["name"] = name row["componentId"] = comp.get("component_id") if dev_rub > 0: row["overloadRub"] += dev_rub overload_total += dev_rub elif dev_rub < 0: row["underloadRub"] += abs(dev_rub) underload_total += abs(dev_rub) row["netRub"] += dev_rub top = sorted( by_component.values(), key=lambda item: max(item["overloadRub"], item["underloadRub"]), reverse=True, )[:3] top_out = [ { "name": item["name"], "componentId": item.get("componentId"), "overloadRub": round(item["overloadRub"], 2), "underloadRub": round(item["underloadRub"], 2), "netRub": round(item["netRub"], 2), } for item in top if max(item["overloadRub"], item["underloadRub"]) > 0 ] net = overload_total - underload_total if overload_total > underload_total: dominant = "overload" elif underload_total > overload_total: dominant = "underload" else: dominant = "balanced" result = { "overloadRub": round(overload_total, 2), "underloadRub": round(underload_total, 2), "netRub": round(net, 2), "dominantIssue": dominant, "topComponents": top_out, "reportCount": len(reports), } # #region agent log try: import pathlib _log_path = pathlib.Path("/Users/vlad/Documents/wesp new (1)/.cursor/debug-785e22.log") _log_path.parent.mkdir(parents=True, exist_ok=True) with _log_path.open("a", encoding="utf-8") as _lf: _lf.write(json.dumps({"sessionId":"785e22","hypothesisId":"E","location":"wesp_compat_analytics.py:finance","message":"finance summary","data":{"date_from":date_from,"date_to":date_to,"report_count":len(reports),"underloadRub":result["underloadRub"],"top_count":len(top_out)},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n") except Exception: pass # #endregion return result @router.get("/analytics/plan-fact") def analytics_plan_fact_wesp( enterprise_id: str = Query(...), date_from: str | None = Query(None), date_to: str | None = Query(None), tenant: TenantContext = Depends(require_enterprise_zootech), ): _require_ent(enterprise_id, tenant) return {"items": []} def _parse_alert_payload_json(raw: str | None) -> dict[str, Any]: if not raw: return {} try: parsed = json.loads(raw) except json.JSONDecodeError: return {} return parsed if isinstance(parsed, dict) else {} def _alert_payload(row: ZootechFeedAlert) -> dict[str, Any]: return _parse_alert_payload_json(row.payload_json) def _serialize_feed_alert( alert_id: str, alert_type: str, severity: str, message: str, created_at: datetime | None, payload: dict[str, Any], ) -> dict[str, Any]: event_type = payload.get("event_type") or alert_type or "" created = payload.get("created_at") or (created_at.isoformat() if created_at else None) return { "id": alert_id, "eventType": event_type, "severity": payload.get("severity") or severity or "warning", "loadingReportId": payload.get("loading_report_id") or "", "unloadingReportId": payload.get("unloading_report_id"), "recipeId": payload.get("recipe_id") or "", "recipeName": payload.get("recipe_name") or "", "componentName": payload.get("component_name"), "groupName": payload.get("group_name"), "detail": payload.get("detail") or message or "", "deviationKg": payload.get("deviation_kg"), "deviationPct": payload.get("deviation_pct"), "costDeviationRub": payload.get("cost_deviation_rub"), "clientId": payload.get("client_id"), "createdAt": created, "linkKind": "report_loading", "linkId": payload.get("loading_report_id") or "", "targetKg": None, "actualKg": None, "durationSec": None, } def _feed_alerts_for_range( enterprise_id: str, window: tuple[datetime, datetime] | None, *, severity: str | None = None, limit: int = 200, ) -> list[dict[str, Any]]: loading_by_id = { report_id: payload for report_id, payload in _loading_reports_in_range(enterprise_id, window) } alert_rows: list[tuple[str, str, str, str, datetime | None, dict[str, Any]]] = [] with session_scope() as db: rows = list( db.scalars( select(ZootechFeedAlert).where( ZootechFeedAlert.enterprise_id == enterprise_id, ZootechFeedAlert.is_deleted.is_(False), ) ) ) for row in rows: payload = _parse_alert_payload_json(row.payload_json) alert_rows.append( (row.id, row.alert_type, row.severity, row.message, row.created_at, payload) ) items: list[dict[str, Any]] = [] for alert_id, alert_type, sev, message, created_at, payload in alert_rows: loading_id = str(payload.get("loading_report_id") or "").strip() if window and loading_id and loading_id not in loading_by_id: continue item = _serialize_feed_alert(alert_id, alert_type, sev, message, created_at, payload) if severity and (item.get("severity") or "").lower() != severity.strip().lower(): continue items.append(item) items.sort(key=lambda item: str(item.get("createdAt") or ""), reverse=True) return items[: max(1, min(limit, 500))] @router.get("/feed-quality/alerts/summary") def feed_quality_alerts_summary_wesp( enterprise_id: str = Query(...), date_from: str | None = Query(None), date_to: str | None = Query(None), tenant: TenantContext = Depends(require_enterprise_zootech), ): _require_ent(enterprise_id, tenant) window = _parse_date_window(date_from, date_to) items = _feed_alerts_for_range(enterprise_id, window, limit=500) by_severity = {"warning": 0, "error": 0, "info": 0} for item in items: sev = (item.get("severity") or "warning").lower() if sev in by_severity: by_severity[sev] += 1 return { "total": len(items), "warning": by_severity["warning"], "error": by_severity["error"], "info": by_severity["info"], } @router.get("/feed-quality/alerts") def feed_quality_alerts_wesp( enterprise_id: str = Query(...), date_from: str | None = Query(None), date_to: str | None = Query(None), severity: str | None = Query(None), limit: int = Query(200, ge=1, le=500), tenant: TenantContext = Depends(require_enterprise_zootech), ): _require_ent(enterprise_id, tenant) window = _parse_date_window(date_from, date_to) items = _feed_alerts_for_range(enterprise_id, window, severity=severity, limit=limit) by_severity = {"warning": 0, "error": 0, "info": 0} for item in items: sev = (item.get("severity") or "warning").lower() if sev in by_severity: by_severity[sev] += 1 return {"items": items, "total": len(items), "bySeverity": by_severity}