354 lines
14 KiB
Python
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"
|