from __future__ import annotations from datetime import datetime, timezone from typing import Any from app.modules.zootech.lab.calc.compound import calculate_compound_feed from app.modules.zootech.lab.calc.derived import apply_content_derived from app.modules.zootech.lab.calc.nutrients import daily_intake_total, get_nutrient_value, norm_diff, weighted_average from app.modules.zootech.lab.constants import RATION_TOTAL_KEYS from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS def _active_lines(lines: list[dict[str, Any]]) -> list[dict[str, Any]]: return [ l for l in lines if l.get("in_ration") and (l.get("daily_kg") or 0) > 0 ] def _sum_kg(lines: list[dict[str, Any]]) -> float: return sum(float(l.get("daily_kg") or 0) for l in lines) def _sum_cost(lines: list[dict[str, Any]]) -> float | None: total = 0.0 any_cost = False for line in lines: kg = line.get("daily_kg") price = line.get("price_per_kg") if kg is None or price is None: continue total += float(kg) * float(price) any_cost = True return total if any_cost else None def _sum_ration_percent(lines: list[dict[str, Any]], total_kg: float) -> float | None: if total_kg <= 0: return None return sum((float(l.get("daily_kg") or 0) / total_kg) * 100 for l in lines) def _compute_totals( ration_type: str, active: list[dict[str, Any]], total_kg: float, ) -> list[dict[str, Any]]: defs = RATION_TOTAL_KEYS.get(ration_type, RATION_TOTAL_KEYS["BEEF"]) ration_kg = _sum_kg(active) values = { "total_kg": total_kg if total_kg > 0 else None, "ration_kg": ration_kg if ration_kg > 0 else None, "ration_pct_sum": _sum_ration_percent(active, total_kg), "cost_total": _sum_cost(active), } return [{"key": d["key"], "label": d["label"], "value": values.get(d["key"])} for d in defs] def _indicator_content( defn: dict[str, Any], active: list[dict[str, Any]], total_kg: float, *, heads_per_trip: int, ) -> float | None: key = defn.get("key") if defn.get("aggregation") == "weighted_avg": return weighted_average( active, total_kg, defn["nutrient_keys"], indicator_key=key, ) return daily_intake_total( active, defn["nutrient_keys"], heads_per_trip=heads_per_trip, indicator_key=key, ) def _compute_indicators( active: list[dict[str, Any]], total_kg: float, norms: dict[str, dict[str, float | None]], *, heads_per_trip: int = 1, profile_mass_kg: float | None = None, ) -> list[dict[str, Any]]: content_by_key: dict[str, float | None] = {} for defn in RATION_ALL_INDICATORS: if defn.get("derived"): continue content_by_key[defn["key"]] = _indicator_content( defn, active, total_kg, heads_per_trip=heads_per_trip ) for defn in RATION_ALL_INDICATORS: if not defn.get("derived"): continue content_by_key[defn["key"]] = apply_content_derived( content_by_key, defn, total_kg=total_kg, heads_per_trip=heads_per_trip, profile_mass_kg=profile_mass_kg, ) rows = [] for defn in RATION_ALL_INDICATORS: key = defn["key"] content = content_by_key.get(key) bounds = norms.get(key, {}) min_v = bounds.get("min") max_v = bounds.get("max") diff = norm_diff(content, min_v, max_v) if content is None and min_v is None and max_v is None: continue rows.append( { "key": key, "label": defn["label"], "unit": defn["unit"], "min": min_v, "max": max_v, "content": content, "diff": diff, } ) return rows def _missing_nutrient_warnings(active: list[dict[str, Any]]) -> list[str]: warnings: list[str] = [] for line in active: nutrients = line.get("nutrients") or {} cp = get_nutrient_value( line.get("dry_matter"), nutrients, ["Сыр. Протеин"], indicator_key="crude_protein", ) if cp is not None: continue name = line.get("ingredient_name") or line.get("component_id") or "?" warnings.append(f"nutrients_missing:{name}") return warnings def calculate_ration( ration_type: str, lines: list[dict[str, Any]], norms: dict[str, dict[str, float | None]] | None = None, *, heads_per_trip: int = 1, profile_mass_kg: float | None = None, ) -> dict[str, Any]: norms = norms or {} errors: list[str] = [] active = _active_lines(lines) total_kg = _sum_kg(active) heads = max(int(heads_per_trip or 1), 1) if not active: errors.append("Нет строк сырья «в рационе» с дозировкой кг/день") warnings = _missing_nutrient_warnings(active) compound = calculate_compound_feed( lines, norms, profile_mass_kg=profile_mass_kg, heads_per_trip=heads ) return { "calculated_at": datetime.now(timezone.utc).isoformat(), "engine": "native", "totals": _compute_totals(ration_type, active, total_kg), "indicators": _compute_indicators( active, total_kg, norms, heads_per_trip=heads, profile_mass_kg=profile_mass_kg, ), "compound": compound, "errors": errors, "warnings": warnings, }