Initial commit: site monorepo with API, web, and infra.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
влад
2026-07-16 10:11:54 +03:00
co-authored by Cursor
commit 016910ffb7
447 changed files with 73972 additions and 0 deletions
@@ -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}