Files
site/apps/api/app/modules/zootech/wesp_compat_analytics.py
T
2026-07-17 12:57:18 +03:00

411 lines
15 KiB
Python

"""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}