"""Расчёты склада и расхода кормов (общие для 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