"""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"