"""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 pydantic import BaseModel, Field 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 from app.modules.zootech import wesp_feed_quality_settings_service as fq_settings_service 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), } 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), recipe_id: str | None = Query(None), recipe_ids: str | None = Query(None), client_id: str | None = Query(None), tenant: TenantContext = Depends(require_enterprise_zootech), ): _require_ent(enterprise_id, tenant) from app.modules.zootech import wesp_analytics_service as analytics_service return analytics_service.build_plan_fact_rows( enterprise_id, date_from=date_from, date_to=date_to, recipe_id=recipe_id, recipe_ids=recipe_ids, client_id=client_id, ) @router.get("/analytics/stock-forecast") def analytics_stock_forecast_wesp( enterprise_id: str = Query(...), plan_date: str | None = Query(None), tenant: TenantContext = Depends(require_enterprise_zootech), ): _require_ent(enterprise_id, tenant) from app.modules.zootech import wesp_analytics_service as analytics_service return analytics_service.build_stock_forecast(enterprise_id, plan_date=plan_date) @router.get("/analytics/export") def analytics_export_wesp( enterprise_id: str = Query(...), date_from: str | None = Query(None), date_to: str | None = Query(None), fmt: str = Query("xlsx"), section: str = Query("all"), tenant: TenantContext = Depends(require_enterprise_zootech), ): _require_ent(enterprise_id, tenant) if not date_from or not date_to: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"error": True, "message": "Укажите date_from и date_to (YYYY-MM-DD)"}, ) normalized_fmt = (fmt or "xlsx").strip().lower() if normalized_fmt not in ("xlsx", "excel", "pdf"): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"error": True, "message": "format должен быть xlsx или pdf"}, ) from app.modules.zootech import wesp_analytics_service as analytics_service data, mime, filename = analytics_service.build_analytics_export( enterprise_id, date_from=date_from, date_to=date_to, fmt="pdf" if normalized_fmt == "pdf" else "xlsx", section=section, ) from fastapi.responses import Response return Response( content=data, media_type=mime, headers={"Content-Disposition": f'attachment; filename="{filename}"'}, ) 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))] class FeedQualitySettingsBody(BaseModel): settings: dict[str, Any] = Field(default_factory=dict) @router.get("/feed-quality/settings") def feed_quality_settings_get_wesp( enterprise_id: str = Query(...), tenant: TenantContext = Depends(require_enterprise_zootech), ): _require_ent(enterprise_id, tenant) return fq_settings_service.settings_for_api(enterprise_id) @router.put("/feed-quality/settings") def feed_quality_settings_put_wesp( body: FeedQualitySettingsBody, enterprise_id: str = Query(...), tenant: TenantContext = Depends(require_enterprise_zootech), ): _require_ent(enterprise_id, tenant) partial = body.settings if isinstance(body.settings, dict) else {} try: saved = fq_settings_service.save_feed_quality_settings(enterprise_id, partial) except (TypeError, ValueError) as exc: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={"error": True, "message": str(exc)}, ) from exc return {"saved": True, "settings": saved, "reevaluatedReports": 0} @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}