@@ -0,0 +1,353 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user