411 lines
15 KiB
Python
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}
|