Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
"""WESP-shaped reports API for copied static UI (/api/reports)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
|
||||
from app.core.database import session_scope
|
||||
from app.modules.sync.tenant import TenantContext, require_enterprise_zootech
|
||||
from app.modules.zootech.report_models import ZootechLoadingReport, ZootechUnloadingReport
|
||||
from sqlalchemy import select
|
||||
|
||||
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_payload(row: ZootechLoadingReport | ZootechUnloadingReport) -> dict[str, Any]:
|
||||
if not row.payload_json:
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(row.payload_json)
|
||||
except json.JSONDecodeError:
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def _parse_date_range(
|
||||
date_from: str | None, date_to: str | None
|
||||
) -> tuple[datetime, datetime] | None:
|
||||
if not date_from or not date_to:
|
||||
end = datetime.now(UTC)
|
||||
return end - timedelta(hours=24), end + timedelta(seconds=1)
|
||||
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 _parse_report_time(value: Any) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value if value.tzinfo else value.replace(tzinfo=UTC)
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
||||
|
||||
|
||||
def _in_range(start_time: datetime | None, window: tuple[datetime, datetime]) -> bool:
|
||||
if start_time is None:
|
||||
return False
|
||||
start, end = window
|
||||
return start <= start_time < end
|
||||
|
||||
|
||||
def _serialize_unloading_id(unloading_id: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
groups = payload.get("unloading_groups")
|
||||
if not isinstance(groups, list):
|
||||
groups = payload.get("groups") if isinstance(payload.get("groups"), list) else []
|
||||
return {
|
||||
"id": unloading_id,
|
||||
"start_time": payload.get("start_time"),
|
||||
"end_time": payload.get("end_time"),
|
||||
"total_weight": payload.get("total_weight"),
|
||||
"total_unloaded_weight": payload.get("total_unloaded_weight"),
|
||||
"remaining_weight": payload.get("remaining_weight"),
|
||||
"unloading_groups": groups,
|
||||
}
|
||||
|
||||
|
||||
def _serialize_unloading(row: ZootechUnloadingReport, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return _serialize_unloading_id(row.id, payload)
|
||||
|
||||
|
||||
@router.get("/reports")
|
||||
def list_reports_wesp(
|
||||
enterprise_id: str = Query(...),
|
||||
date_from: str | None = Query(None),
|
||||
date_to: str | None = Query(None),
|
||||
limit: int = Query(500, ge=1, le=2000),
|
||||
offset: int = Query(0, ge=0),
|
||||
tenant: TenantContext = Depends(require_enterprise_zootech),
|
||||
):
|
||||
"""Legacy-compatible aggregated loading reports for reports.html."""
|
||||
_require_ent(enterprise_id, tenant)
|
||||
window = _parse_date_range(date_from, date_to)
|
||||
|
||||
with session_scope() as db:
|
||||
loading_rows = list(
|
||||
db.scalars(
|
||||
select(ZootechLoadingReport).where(
|
||||
ZootechLoadingReport.enterprise_id == enterprise_id,
|
||||
ZootechLoadingReport.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
unloading_rows = list(
|
||||
db.scalars(
|
||||
select(ZootechUnloadingReport).where(
|
||||
ZootechUnloadingReport.enterprise_id == enterprise_id,
|
||||
ZootechUnloadingReport.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
)
|
||||
loading_data = [(row.id, _parse_payload(row)) for row in loading_rows]
|
||||
unloading_data: list[tuple[str, str, dict[str, Any]]] = []
|
||||
for row in unloading_rows:
|
||||
payload = _parse_payload(row)
|
||||
loading_id = str(payload.get("loading_report_id") or "").strip()
|
||||
if loading_id:
|
||||
unloading_data.append((loading_id, row.id, payload))
|
||||
|
||||
unloading_by_loading: dict[str, tuple[str, dict[str, Any]]] = {
|
||||
loading_id: (unloading_id, payload) for loading_id, unloading_id, payload in unloading_data
|
||||
}
|
||||
|
||||
payload: list[dict[str, Any]] = []
|
||||
for row_id, data in loading_data:
|
||||
start_time = _parse_report_time(data.get("start_time"))
|
||||
if window and not _in_range(start_time, window):
|
||||
continue
|
||||
unloading = unloading_by_loading.get(row_id)
|
||||
unloading_payload = (
|
||||
_serialize_unloading_id(unloading[0], unloading[1]) if unloading else None
|
||||
)
|
||||
payload.append(
|
||||
{
|
||||
"id": row_id,
|
||||
"recipe_id": data.get("recipe_id"),
|
||||
"recipe_name": data.get("recipe_name"),
|
||||
"start_time": data.get("start_time"),
|
||||
"end_time": data.get("end_time"),
|
||||
"target_mixing_time": data.get("target_mixing_time"),
|
||||
"actual_mixing_time": data.get("actual_mixing_time"),
|
||||
"total_weight": data.get("total_weight"),
|
||||
"dispenser_type": data.get("dispenser_type") or "dispenser",
|
||||
"components": data.get("components") if isinstance(data.get("components"), list) else [],
|
||||
"component_loading_times": (
|
||||
data.get("component_loading_times")
|
||||
if isinstance(data.get("component_loading_times"), list)
|
||||
else []
|
||||
),
|
||||
"unloading_data": unloading_payload,
|
||||
}
|
||||
)
|
||||
|
||||
payload.sort(key=lambda item: str(item.get("start_time") or ""), reverse=True)
|
||||
result = payload[offset : offset + limit]
|
||||
|
||||
# #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":"A,B","location":"wesp_compat_reports.py:list","message":"reports api response","data":{"date_from":date_from,"date_to":date_to,"total_before_filter":len(payload),"returned":len(result),"component_counts":[len(r.get("components") or []) for r in result[:5]]},"timestamp":int(datetime.now(UTC).timestamp()*1000)}, ensure_ascii=False) + "\n")
|
||||
except Exception:
|
||||
pass
|
||||
# #endregion
|
||||
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/reports/ping")
|
||||
def reports_ping_wesp():
|
||||
return {"status": "ok"}
|
||||
Reference in New Issue
Block a user