576 lines
20 KiB
Python
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
|