@@ -0,0 +1,184 @@
|
||||
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,
|
||||
}
|
||||
Reference in New Issue
Block a user