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

354 lines
14 KiB
Python

"""Analytics helpers for WESP compat (plan-fact, stock forecast, export)."""
from __future__ import annotations
import csv
import io
from datetime import UTC, date, datetime, timedelta
from typing import Any
from sqlalchemy import select
from app.core.database import session_scope
from app.modules.zootech.models import ZootechComponent, ZootechIngredient, ZootechRecipe
from app.modules.zootech.report_models import ZootechLoadingReport
from app.modules.zootech import wesp_daily_plan_service as dp_service
from app.modules.zootech import wesp_sklad_service as sklad_service
from app.modules.zootech.analytics.export_sections import export_filename_base, parse_export_sections
from app.modules.zootech.pdf_export import build_table_pdf
from app.modules.zootech.wesp_compat_reports import _parse_payload, _parse_report_time
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
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
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 _recipe_plan_kg(enterprise_id: str, recipe_id: str) -> dict[str, float]:
totals: dict[str, float] = {}
with session_scope() as db:
recipe = db.scalar(
select(ZootechRecipe).where(
ZootechRecipe.enterprise_id == enterprise_id,
ZootechRecipe.id == recipe_id,
ZootechRecipe.is_deleted.is_(False),
)
)
if not recipe:
return totals
heads = float(recipe.heads_per_trip or 1)
trip_pct = float(recipe.trip_percent or 100) / 100.0
ingredients = list(
db.scalars(
select(ZootechIngredient).where(
ZootechIngredient.enterprise_id == enterprise_id,
ZootechIngredient.recipe_id == recipe_id,
ZootechIngredient.is_deleted.is_(False),
)
)
)
for ing in ingredients:
if not ing.component_id:
continue
wph = float(ing.weight_per_head or 0)
if wph > 0:
totals[ing.component_id] = round(wph * heads * trip_pct, 2)
return totals
def build_plan_fact_rows(
enterprise_id: str,
*,
date_from: str | None,
date_to: str | None,
recipe_id: str | None = None,
recipe_ids: str | None = None,
client_id: str | None = None,
) -> dict[str, Any]:
window = _parse_date_window(date_from, date_to)
reports = _loading_reports_in_range(enterprise_id, window)
allowed: set[str] | None = None
if recipe_id:
allowed = {recipe_id.strip()}
elif recipe_ids:
allowed = {part.strip() for part in recipe_ids.split(",") if part.strip()}
plan_cache: dict[str, dict[str, float]] = {}
items: list[dict[str, Any]] = []
for report_id, payload in reports:
rid = str(payload.get("recipe_id") or "").strip()
if allowed and rid not in allowed:
continue
if client_id and str(payload.get("client_id") or "").strip() != client_id.strip():
continue
start_time = payload.get("start_time")
iso = ""
if isinstance(start_time, str):
iso = start_time[:10]
elif isinstance(start_time, datetime):
iso = start_time.date().isoformat()
plan_by_comp = plan_cache.get(iso)
if plan_by_comp is None and iso:
try:
plan_by_comp = dp_service.plan_kg_by_component(enterprise_id, iso)
except Exception:
plan_by_comp = {}
plan_cache[iso] = plan_by_comp
recipe_plan = _recipe_plan_kg(enterprise_id, rid) if rid else {}
components_out: list[dict[str, Any]] = []
for comp in payload.get("components") or []:
if not isinstance(comp, dict):
continue
cid = str(comp.get("component_id") or "").strip()
name = str(comp.get("component_name") or "—")
target = float(comp.get("target_weight") or 0)
actual = float(comp.get("actual_weight") or 0)
plan_today = float((plan_by_comp or {}).get(cid, 0)) if cid else 0.0
base = float(recipe_plan.get(cid, 0)) if cid else 0.0
components_out.append(
{
"componentId": cid or None,
"name": name,
"baseKg": round(base, 2),
"planTodayKg": round(plan_today, 2),
"targetKg": round(target, 2),
"actualKg": round(actual, 2),
"deviationKg": round(actual - target, 2),
}
)
items.append(
{
"loadingReportId": report_id,
"recipeId": rid,
"recipeName": payload.get("recipe_name") or "—",
"date": iso,
"startTime": start_time if isinstance(start_time, str) else None,
"clientId": payload.get("client_id"),
"components": components_out,
"unloadingGroups": [],
"mixerRemainder": None,
}
)
return {"items": items, "reportCount": len(items)}
def _format_days_label(days: float | None) -> str:
if days is None:
return "—"
if days < 1:
return f"~{round(days * 24)} ч"
d = round(days, 1)
if d == int(d):
return f"~{int(d)} дн"
return f"~{d} дн"
def build_stock_forecast(
enterprise_id: str,
*,
plan_date: str | None = None,
lookback_days: int = 7,
) -> dict[str, Any]:
today = date.today()
iso = (plan_date or today.isoformat())[:10]
try:
plan_d = date.fromisoformat(iso)
except ValueError:
plan_d = today
iso = today.isoformat()
from_d = (plan_d - timedelta(days=max(1, lookback_days))).isoformat()
to_d = plan_d.isoformat()
sklad = sklad_service.list_sklad_items(enterprise_id, date_from=from_d, date_to=to_d)
plan_by_cid = dp_service.plan_kg_by_component(enterprise_id, iso)
items: list[dict[str, Any]] = []
alerts: list[str] = []
for item in sklad.get("items") or []:
cid = str(item.get("component_id") or "").strip()
name = str(item.get("name") or "—")
remaining = float(item.get("remaining_kg") or 0)
plan_recipe = float(item.get("planned_consumption_per_day_kg") or 0)
plan_today = plan_by_cid.get(cid, plan_recipe) if cid else plan_recipe
consumed = float(item.get("consumed_kg") or item.get("consumed_total_kg") or 0)
avg_daily = round(consumed / max(1, lookback_days), 2) if consumed > 0 else 0.0
expected_daily = max(avg_daily, plan_today) if avg_daily > 0 else (plan_today or plan_recipe)
days_recipe = item.get("days_left_plan")
days_adjusted = None
if expected_daily > 0 and remaining > 0:
days_adjusted = round(min(remaining / expected_daily, 9999.9), 1)
row = dict(item)
row.update(
{
"planTodayKgPerDay": round(plan_today, 2),
"avgConsumptionKgPerDay": avg_daily,
"expectedDailyKg": round(expected_daily, 2),
"days_left_adjusted": days_adjusted,
"daysLeftAdjusted": days_adjusted,
"daysLeftAdjustedLabel": _format_days_label(days_adjusted),
"daysLeftRecipeLabel": _format_days_label(days_recipe),
"removedFromPlanToday": plan_today < plan_recipe - 0.01 and plan_recipe > 0,
}
)
items.append(row)
if days_adjusted is not None and days_adjusted <= 2.0:
alerts.append(f"{name} — хватит {_format_days_label(days_adjusted)}")
return {
"planDate": iso,
"items": items,
"alerts": alerts,
"alertBanner": " · ".join(alerts[:5]) if alerts else "",
}
def _finance_rows(enterprise_id: str, date_from: str, date_to: str) -> list[list[Any]]:
from app.modules.zootech.models import ZootechComponent
window = _parse_date_window(date_from, date_to)
reports = _loading_reports_in_range(enterprise_id, window)
with session_scope() as db:
comp_rows = list(
db.scalars(
select(ZootechComponent).where(
ZootechComponent.enterprise_id == enterprise_id,
ZootechComponent.is_deleted.is_(False),
)
)
)
prices_by_id = {row.id: float(row.price or 0) for row in comp_rows}
prices_by_name = {(row.name or "").strip().lower(): float(row.price or 0) for row in comp_rows if row.name}
overload_total = 0.0
underload_total = 0.0
for _report_id, payload in reports:
for comp in payload.get("components") or []:
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
cid = comp.get("component_id")
name = str(comp.get("component_name") or "")
price = prices_by_id.get(cid, 0.0) if cid else prices_by_name.get(name.strip().lower(), 0.0)
dev_rub = (actual - target) * price
if dev_rub > 0:
overload_total += dev_rub
elif dev_rub < 0:
underload_total += abs(dev_rub)
summary = {
"overloadRub": round(overload_total, 2),
"underloadRub": round(underload_total, 2),
"netRub": round(overload_total - underload_total, 2),
"reportCount": len(reports),
}
rows: list[list[Any]] = [
["Перегруз, руб", summary.get("overloadRub", 0)],
["Недогруз, руб", summary.get("underloadRub", 0)],
["Итого, руб", summary.get("netRub", 0)],
["Отчётов", summary.get("reportCount", 0)],
]
for comp in summary.get("topComponents") or []:
rows.append([comp.get("name", "—"), comp.get("netRub", 0)])
return rows
def build_analytics_export(
enterprise_id: str,
*,
date_from: str,
date_to: str,
fmt: str,
section: str,
) -> tuple[bytes, str, str]:
sections = parse_export_sections(section)
base_name = export_filename_base(date_from=date_from, date_to=date_to, sections=sections)
if fmt == "pdf":
headers = ["Показатель", "Значение"]
rows = _finance_rows(enterprise_id, date_from, date_to)
if "comparison" in sections:
pf = build_plan_fact_rows(enterprise_id, date_from=date_from, date_to=date_to)
rows.append(["", ""])
rows.append(["Сравнение plan-fact", f"отчётов: {pf.get('reportCount', 0)}"])
for item in (pf.get("items") or [])[:20]:
rows.append([item.get("recipeName", "—"), item.get("date", "")])
data = build_table_pdf(
title="Аналитика",
subtitle_lines=[f"Период: {date_from}{date_to}"],
headers=headers,
rows=rows,
)
return data, "application/pdf", f"{base_name}.pdf"
headers = ["Показатель", "Значение"]
rows = _finance_rows(enterprise_id, date_from, date_to)
try:
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Analytics"
ws.append(headers)
for row in rows:
ws.append(list(row))
if "comparison" in sections:
ws.append([])
ws.append(["Plan-fact"])
pf = build_plan_fact_rows(enterprise_id, date_from=date_from, date_to=date_to)
ws.append(["recipe", "date", "component", "target", "actual"])
for item in pf.get("items") or []:
for comp in item.get("components") or []:
ws.append(
[
item.get("recipeName"),
item.get("date"),
comp.get("name"),
comp.get("targetKg"),
comp.get("actualKg"),
]
)
buf = io.BytesIO()
wb.save(buf)
return (
buf.getvalue(),
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
f"{base_name}.xlsx",
)
except ImportError:
buf = io.StringIO()
writer = csv.writer(buf)
writer.writerow(headers)
writer.writerows(rows)
csv_bytes = buf.getvalue().encode("utf-8-sig")
return csv_bytes, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", f"{base_name}.xlsx"