Files
site/WESP_REL/app/services/sklad_metrics.py
T
2026-07-17 12:57:18 +03:00

576 lines
20 KiB
Python

"""Расчёты склада и расхода кормов (общие для sklad и feed_accounting)."""
from __future__ import annotations
import calendar
from collections import defaultdict
from datetime import date, datetime
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import and_, func, or_
from app.models import ComponentStock
def _norm_name(name: Optional[str]) -> str:
return (name or "").strip().lower()
def effective_consumed_kg(
actual_weight: Optional[float],
overload: Optional[float] = None,
target_weight: Optional[float] = None,
) -> float:
"""Расход для склада и СП-20: факт + перерасход; при факте 0 — план терминала."""
actual = float(actual_weight or 0)
if actual > 0:
return actual + max(0.0, float(overload or 0))
target = float(target_weight or 0)
if target > 0:
return target
return 0.0
def _resolve_component_id(db, component_id: Optional[str], component_name: Optional[str]) -> Optional[str]:
if component_id:
return str(component_id)
name = (component_name or "").strip().lower()
if not name:
return None
row = (
active_stock_query(db)
.filter(func.lower(func.trim(ComponentStock.component_name)) == name)
.first()
)
return str(row.component_id) if row else None
def _parse_date_range(
date_from: Optional[str], date_to: Optional[str]
) -> Tuple[Optional[datetime], Optional[datetime]]:
if not date_from or not date_to:
return None, None
try:
from_dt = datetime.strptime(date_from, "%Y-%m-%d")
to_dt = datetime.strptime(date_to, "%Y-%m-%d").replace(
hour=23, minute=59, second=59, microsecond=999999
)
return from_dt, to_dt
except ValueError:
return None, None
def _loading_component_rows_query(
db,
LoadingReport,
LoadingReportComponent,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
):
from_dt, to_dt = _parse_date_range(date_from, date_to)
q = (
db.session.query(
LoadingReportComponent.component_id,
LoadingReportComponent.component_name,
LoadingReportComponent.actual_weight,
LoadingReportComponent.overload,
LoadingReportComponent.target_weight,
)
.join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id)
)
if hasattr(LoadingReport, "is_deleted"):
q = q.filter(LoadingReport.is_deleted.is_(False))
if hasattr(LoadingReportComponent, "is_deleted"):
q = q.filter(LoadingReportComponent.is_deleted.is_(False))
if from_dt and to_dt:
q = q.filter(LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt)
return q
def consumed_totals(
db,
LoadingReport,
LoadingReportComponent,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
) -> Tuple[Dict[str, float], Dict[str, float], Dict[str, str]]:
"""Расход по component_id, по нормализованному имени и карта имён для отображения."""
by_id: Dict[str, float] = defaultdict(float)
by_name: Dict[str, float] = defaultdict(float)
display_names: Dict[str, str] = {}
for cid, cname, actual, overload, target in _loading_component_rows_query(
db, LoadingReport, LoadingReportComponent, date_from=date_from, date_to=date_to
).all():
kg = effective_consumed_kg(actual, overload, target)
if kg <= 0:
continue
if cid:
by_id[str(cid)] += kg
name_lo = _norm_name(cname)
if name_lo:
by_name[name_lo] += kg
if name_lo not in display_names and (cname or "").strip():
display_names[name_lo] = str(cname).strip()
return dict(by_id), dict(by_name), display_names
def consumed_by_component_id(
db,
LoadingReport,
LoadingReportComponent,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
) -> Dict[str, float]:
by_id, by_name, _ = consumed_totals(
db, LoadingReport, LoadingReportComponent, date_from=date_from, date_to=date_to
)
out = dict(by_id)
for row in active_stock_query(db).all():
cid = str(row.component_id)
if cid in out:
continue
name_lo = _norm_name(row.component_name)
if name_lo and name_lo in by_name:
out[cid] = by_name[name_lo]
return out
def consumed_for_stock(
component_id: str,
component_name: str,
by_id: Dict[str, float],
by_name: Dict[str, float],
) -> float:
name_lo = _norm_name(component_name)
if name_lo and name_lo in by_name:
return by_name[name_lo]
cid = str(component_id or "").strip()
if cid and cid in by_id:
return by_id[cid]
return 0.0
def consumed_for(component_id: str, by_id: Dict[str, float]) -> float:
if component_id and component_id in by_id:
return by_id[component_id]
return 0.0
def planned_kg_per_day(db, Ingredient, Recipe, component_id: str) -> float:
if not Ingredient or not Recipe or not component_id:
return 0.0
try:
q = (
db.session.query(Ingredient, Recipe)
.join(Recipe, Recipe.id == Ingredient.recipe_id)
.filter(Ingredient.component_id == component_id)
)
if hasattr(Ingredient, "is_deleted"):
q = q.filter(Ingredient.is_deleted.is_(False))
if hasattr(Recipe, "is_deleted"):
q = q.filter(Recipe.is_deleted.is_(False))
rows = q.all()
except Exception:
return 0.0
total_kg_per_day = 0.0
for ing, recipe in rows:
wph = float(ing.weight_per_head or 0)
hpt = int(recipe.heads_per_trip or 0)
tp = float(recipe.trip_percent or 100) / 100.0
total_kg_per_day += wph * hpt * tp
return round(total_kg_per_day, 2)
def recipe_heads_for_component(db, Ingredient, Recipe, component_id: str) -> int:
if not Ingredient or not Recipe or not component_id:
return 0
try:
q = (
db.session.query(Recipe.heads_per_trip)
.join(Ingredient, Ingredient.recipe_id == Recipe.id)
.filter(Ingredient.component_id == component_id)
)
if hasattr(Ingredient, "is_deleted"):
q = q.filter(Ingredient.is_deleted.is_(False))
if hasattr(Recipe, "is_deleted"):
q = q.filter(Recipe.is_deleted.is_(False))
total = 0
for (hpt,) in q.all():
total += int(hpt or 0)
return total
except Exception:
return 0
def norm_per_head_for_component(db, Ingredient, Recipe, component_id: str) -> Optional[float]:
if not Ingredient or not Recipe or not component_id:
return None
try:
q = (
db.session.query(Ingredient.weight_per_head)
.join(Recipe, Recipe.id == Ingredient.recipe_id)
.filter(Ingredient.component_id == component_id)
)
if hasattr(Ingredient, "is_deleted"):
q = q.filter(Ingredient.is_deleted.is_(False))
if hasattr(Recipe, "is_deleted"):
q = q.filter(Recipe.is_deleted.is_(False))
vals = [float(v or 0) for (v,) in q.all() if v]
if not vals:
return None
return round(sum(vals) / len(vals), 3)
except Exception:
return None
def stock_balance_row(
row: ComponentStock,
by_id_display: Dict[str, float],
by_id_remaining: Dict[str, float],
*,
by_name_display: Optional[Dict[str, float]] = None,
by_name_remaining: Optional[Dict[str, float]] = None,
db=None,
Ingredient=None,
Recipe=None,
) -> Dict[str, Any]:
cid = str(row.component_id)
cname = str(row.component_name or "")
total_kg = max(0.0, float(row.total_kg))
inflow_kg = max(0.0, float(row.inflow_kg or 0))
if by_name_display is not None:
consumed_period_raw = consumed_for_stock(cid, cname, by_id_display, by_name_display)
consumed_alltime_raw = consumed_for_stock(
cid, cname, by_id_remaining, by_name_remaining or {}
)
else:
consumed_period_raw = consumed_for(cid, by_id_display)
consumed_alltime_raw = consumed_for(cid, by_id_remaining)
consumed_total_kg = round(max(0.0, consumed_period_raw), 2)
baseline = abs(float(row.baseline_consumed_kg or 0))
consumed_since_stocktake = max(0.0, consumed_alltime_raw - baseline)
available_kg = total_kg + inflow_kg
remaining_raw = available_kg - consumed_since_stocktake
remaining_kg = round(max(0.0, min(available_kg, remaining_raw)), 2)
planned = planned_kg_per_day(db, Ingredient, Recipe, cid)
days_left_plan = None
if planned > 0 and remaining_kg > 0:
d = remaining_kg / planned
days_left_plan = round(min(d, 9999.9), 1) if d < 9999.9 else 9999.9
return {
"component_id": cid,
"name": str(row.component_name or ""),
"name_key": _norm_name(row.component_name),
"type": "",
"total_kg": round(total_kg, 2),
"inflow_kg": round(inflow_kg, 2),
"consumed_kg": consumed_total_kg,
"consumed_total_kg": consumed_total_kg,
"remaining_kg": remaining_kg,
"planned_consumption_per_day_kg": planned,
"days_left_plan": days_left_plan,
"stocktake_at": row.stocktake_at.isoformat() if row.stocktake_at else None,
"has_stock": True,
}
def active_stock_query(db):
return ComponentStock.query.filter(
db.or_(ComponentStock.is_deleted.is_(None), ComponentStock.is_deleted == False)
)
def resolve_component_id_by_name(db, Component, name: Optional[str]) -> Optional[str]:
"""Сопоставление имени из отчёта с компонентом справочника."""
if not Component or not name:
return None
name_lo = _norm_name(name)
if not name_lo:
return None
q = db.session.query(Component.id).filter(
func.lower(func.trim(Component.name)) == name_lo
)
if hasattr(Component, "is_deleted"):
q = q.filter(Component.is_deleted.is_(False))
row = q.first()
return str(row[0]) if row else None
def stock_item_key(item: Dict[str, Any]) -> str:
cid = str(item.get("component_id") or "").strip()
if cid:
return cid
return str(item.get("name_key") or "").strip()
def _to_date(val) -> Optional[date]:
if val is None:
return None
if hasattr(val, "date") and callable(getattr(val, "date", None)):
return val.date()
if isinstance(val, str):
try:
return datetime.strptime(val[:10], "%Y-%m-%d").date()
except (ValueError, TypeError):
return None
return None
def month_date_bounds(year: int, month: int) -> Tuple[date, date]:
last = calendar.monthrange(year, month)[1]
return date(year, month, 1), date(year, month, last)
def daily_consumption_by_component(
db,
LoadingReport,
LoadingReportComponent,
component_id: str,
component_name: str,
year: int,
month: int,
) -> Dict[int, float]:
start_d, end_d = month_date_bounds(year, month)
from_dt = datetime.combine(start_d, datetime.min.time())
to_dt = datetime.combine(end_d, datetime.max.time())
cid = str(component_id).strip()
comp_name_lo = (component_name or "").strip().lower()
id_or_name = (
or_(
LoadingReportComponent.component_id == cid,
and_(
LoadingReportComponent.component_id.is_(None),
func.lower(func.trim(LoadingReportComponent.component_name)) == comp_name_lo,
),
)
if comp_name_lo
else (LoadingReportComponent.component_id == cid)
)
q = (
db.session.query(
LoadingReport.start_time,
LoadingReportComponent.actual_weight,
LoadingReportComponent.overload,
LoadingReportComponent.target_weight,
)
.join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id)
.filter(id_or_name)
.filter(LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt)
)
if hasattr(LoadingReport, "is_deleted"):
q = q.filter(LoadingReport.is_deleted.is_(False))
if hasattr(LoadingReportComponent, "is_deleted"):
q = q.filter(LoadingReportComponent.is_deleted.is_(False))
daily: Dict[int, float] = defaultdict(float)
for start_time, actual_weight, overload, target_weight in q.all():
d = _to_date(start_time)
if d is None or d.year != year or d.month != month:
continue
consumed = effective_consumed_kg(actual_weight, overload, target_weight)
if consumed > 0:
daily[d.day] += consumed
return dict(daily)
def month_consumption_by_component(
db,
LoadingReport,
LoadingReportComponent,
year: int,
month: int,
) -> Dict[str, Dict[str, Any]]:
"""Все компоненты с расходом за месяц (для СП-20), не только со склада."""
start_d, end_d = month_date_bounds(year, month)
from_dt = datetime.combine(start_d, datetime.min.time())
to_dt = datetime.combine(end_d, datetime.max.time())
q = (
db.session.query(
LoadingReportComponent.component_id,
LoadingReportComponent.component_name,
LoadingReport.start_time,
LoadingReportComponent.actual_weight,
LoadingReportComponent.overload,
LoadingReportComponent.target_weight,
)
.join(LoadingReport, LoadingReport.id == LoadingReportComponent.report_id)
.filter(LoadingReport.start_time >= from_dt, LoadingReport.start_time <= to_dt)
)
if hasattr(LoadingReport, "is_deleted"):
q = q.filter(LoadingReport.is_deleted.is_(False))
if hasattr(LoadingReportComponent, "is_deleted"):
q = q.filter(LoadingReportComponent.is_deleted.is_(False))
agg: Dict[str, Dict[str, Any]] = {}
for cid, cname, start_time, actual, overload, target in q.all():
kg = effective_consumed_kg(actual, overload, target)
if kg <= 0:
continue
resolved = _resolve_component_id(db, cid, cname) or (
str(cid).strip() if cid else f"name:{(cname or '').strip().lower()}"
)
name = str(cname or "").strip() or resolved
bucket = agg.setdefault(
resolved,
{"component_id": str(cid) if cid else "", "name": name, "daily": defaultdict(float)},
)
if not bucket["name"] and name:
bucket["name"] = name
d = _to_date(start_time)
if d and d.year == year and d.month == month:
bucket["daily"][d.day] += kg
for item in agg.values():
item["daily"] = dict(item["daily"])
return agg
def daily_unloading_by_group(
db,
UnloadingReport,
UnloadingReportGroup,
year: int,
month: int,
) -> List[Dict[str, Any]]:
start_d, end_d = month_date_bounds(year, month)
from_dt = datetime.combine(start_d, datetime.min.time())
to_dt = datetime.combine(end_d, datetime.max.time())
q = (
db.session.query(
UnloadingReportGroup.name,
UnloadingReport.recipe_name,
UnloadingReportGroup.distribution_type,
UnloadingReportGroup.distribution_value,
UnloadingReport.start_time,
UnloadingReportGroup.unloaded_weight,
)
.join(UnloadingReport, UnloadingReport.id == UnloadingReportGroup.report_id)
.filter(UnloadingReport.start_time >= from_dt, UnloadingReport.start_time <= to_dt)
)
if hasattr(UnloadingReport, "is_deleted"):
q = q.filter(UnloadingReport.is_deleted.is_(False))
if hasattr(UnloadingReportGroup, "is_deleted"):
q = q.filter(UnloadingReportGroup.is_deleted.is_(False))
grouped: Dict[str, Dict[str, Any]] = {}
for gname, recipe_name, dist_type, dist_val, start_time, unloaded in q.all():
key = str(gname or "")
if key not in grouped:
grouped[key] = {
"group_name": key,
"recipe_name": str(recipe_name or ""),
"distribution_type": str(dist_type or ""),
"distribution_value": float(dist_val or 0),
"daily": defaultdict(float),
}
d = _to_date(start_time)
if d and d.year == year and d.month == month:
grouped[key]["daily"][d.day] += float(unloaded or 0)
rows = []
for item in grouped.values():
daily = dict(item["daily"])
item["daily"] = daily
item["month_total"] = round(sum(daily.values()), 2)
rows.append(item)
rows.sort(key=lambda r: r["group_name"].lower())
return rows
def period_consumption_rows(
db,
LoadingReport,
LoadingReportComponent,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Строки расхода по компонентам за период (из отчётов загрузки)."""
_, by_name, display_names = consumed_totals(
db, LoadingReport, LoadingReportComponent, date_from=date_from, date_to=date_to
)
rows = []
for name_lo, kg in by_name.items():
if kg <= 0:
continue
rows.append(
{
"name": display_names.get(name_lo) or name_lo,
"name_key": name_lo,
"consumed_kg": round(kg, 2),
}
)
rows.sort(key=lambda r: r["name"].lower())
return rows
def list_stock_balance_items(
db,
LoadingReport,
LoadingReportComponent,
Ingredient,
Recipe,
*,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
include_report_only: bool = True,
Component=None,
) -> List[Dict[str, Any]]:
by_id_display, by_name_display, display_names = consumed_totals(
db, LoadingReport, LoadingReportComponent, date_from=date_from, date_to=date_to
)
if date_from and date_to:
by_id_remaining, by_name_remaining, _ = consumed_totals(
db, LoadingReport, LoadingReportComponent
)
else:
by_id_remaining, by_name_remaining = by_id_display, by_name_display
rows = (
active_stock_query(db)
.order_by(ComponentStock.sort_order.asc(), ComponentStock.component_name.asc())
.all()
)
stock_names: set[str] = set()
items = [
stock_balance_row(
r,
by_id_display,
by_id_remaining,
by_name_display=by_name_display,
by_name_remaining=by_name_remaining,
db=db,
Ingredient=Ingredient,
Recipe=Recipe,
)
for r in rows
]
for item in items:
stock_names.add(_norm_name(item.get("name")))
if include_report_only:
for name_lo, kg in sorted(by_name_display.items(), key=lambda x: x[0]):
if name_lo in stock_names:
continue
display_name = display_names.get(name_lo) or name_lo
resolved_cid = resolve_component_id_by_name(db, Component, display_name) or ""
planned = (
planned_kg_per_day(db, Ingredient, Recipe, resolved_cid)
if resolved_cid
else 0.0
)
items.append(
{
"component_id": resolved_cid,
"name": display_name,
"name_key": name_lo,
"type": "",
"total_kg": 0.0,
"inflow_kg": 0.0,
"consumed_kg": round(kg, 2),
"consumed_total_kg": round(kg, 2),
"remaining_kg": 0.0,
"planned_consumption_per_day_kg": planned,
"days_left_plan": None,
"stocktake_at": None,
"has_stock": False,
}
)
return items