Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
"""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}
|
||||
Reference in New Issue
Block a user