Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -0,0 +1 @@
"""Zootech lab module (Neoton tab integration)."""
@@ -0,0 +1,4 @@
from .engine import calculate_ration
from .diff import compare_master_execution
__all__ = ["calculate_ration", "compare_master_execution"]
@@ -0,0 +1,112 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.derived import apply_content_derived
from app.modules.zootech.lab.calc.nutrients import norm_diff, weighted_average
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
def _compound_active(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
l
for l in lines
if l.get("in_compound") 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 _compound_indicators(
lines: list[dict[str, Any]],
total_kg: float,
norms: dict[str, dict[str, float | 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"]] = weighted_average(
lines,
total_kg,
defn["nutrient_keys"],
indicator_key=defn.get("key"),
)
for defn in RATION_ALL_INDICATORS:
if not defn.get("derived"):
continue
content_by_key[defn["key"]] = apply_content_derived(
content_by_key,
defn,
compound_mode=True,
)
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 calculate_compound_feed(
lines: list[dict[str, Any]],
norms: dict[str, dict[str, float | None]] | None = None,
*,
profile_mass_kg: float | None = None,
heads_per_trip: int = 1,
) -> dict[str, Any] | None:
del profile_mass_kg, heads_per_trip
active = _compound_active(lines)
if not active:
return None
total_kg = _sum_kg(active)
cost = None
any_cost = False
for line in active:
kg = line.get("daily_kg")
price = line.get("price_per_kg")
if kg is None or price is None:
continue
cost = (cost or 0) + float(kg) * float(price)
any_cost = True
return {
"totals": [
{"key": "compound_kg", "label": "Масса комбикорма, кг", "value": total_kg},
{
"key": "compound_cost",
"label": "Стоимость комбикорма",
"value": cost if any_cost else None,
},
],
"indicators": _compound_indicators(active, total_kg, norms or {}),
"lines": [
{
"ingredient_name": line.get("ingredient_name") or "",
"daily_kg": line.get("daily_kg"),
"share_pct": (
(float(line["daily_kg"]) / total_kg) * 100
if total_kg > 0 and line.get("daily_kg") is not None
else None
),
}
for line in active
],
}
@@ -0,0 +1,64 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.nutrients import rnb
def apply_content_derived(
content_by_key: dict[str, float | None],
defn: dict[str, Any],
*,
total_kg: float = 0,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
compound_mode: bool = False,
) -> float | None:
derived = defn.get("derived")
if derived == "alias":
return content_by_key.get(defn.get("alias_of"))
if derived == "pct_of_dm":
src = content_by_key.get(defn.get("from_key"))
dm = content_by_key.get("dry_matter")
if src is None or not dm or dm <= 0:
return None
return src / dm * 100.0
if derived == "g_per_kg_dm":
src = content_by_key.get(defn.get("from_key"))
dm = content_by_key.get("dry_matter")
if src is None or not dm or dm <= 0:
return None
if compound_mode:
return src
return src / (dm / 1000.0)
if derived == "nel_per_kg_dm":
dm = content_by_key.get("dry_matter")
nel = content_by_key.get("nel")
if not dm or dm <= 0 or nel is None:
return None
if compound_mode:
return nel
return nel / (dm / 1000.0)
if derived == "ratio":
num = content_by_key.get(defn.get("ratio_num"))
den = content_by_key.get(defn.get("ratio_den"))
if num is None or den is None or den == 0:
return None
return num / den
if derived == "dm_pct_bw":
dm = content_by_key.get("dry_matter")
if dm is None or not profile_mass_kg or profile_mass_kg <= 0:
return None
return (dm / 1000.0 / profile_mass_kg) * 100.0
if derived == "ration_pct_bw":
if not profile_mass_kg or profile_mass_kg <= 0 or total_kg <= 0:
return None
heads = max(int(heads_per_trip or 1), 1)
kg_per_head = total_kg / heads
return (kg_per_head / profile_mass_kg) * 100.0
if derived in ("rnb", "bra_rnb"):
return rnb(
content_by_key.get("crude_protein"),
content_by_key.get("usp"),
)
return None
@@ -0,0 +1,53 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.constants import DIFF_TOLERANCE_KG
def compare_master_execution(
master_lines: list[dict[str, Any]],
execution_lines: list[dict[str, Any]],
) -> dict[str, Any]:
master_map: dict[str, float] = {}
for line in master_lines:
if not line.get("in_ration"):
continue
cid = line.get("component_id")
if not cid:
continue
master_map[str(cid)] = float(line.get("daily_kg") or 0)
exec_map: dict[str, float] = {}
for line in execution_lines:
cid = line.get("component_id")
if not cid:
continue
exec_map[str(cid)] = float(line.get("daily_kg_total") or 0)
all_ids = set(master_map) | set(exec_map)
diff_lines = []
has_changes = False
for cid in sorted(all_ids):
m = master_map.get(cid)
e = exec_map.get(cid)
reasons = []
if m is None:
reasons.append("missing_in_master")
has_changes = True
if e is None:
reasons.append("missing_in_execution")
has_changes = True
if m is not None and e is not None and abs(m - e) > DIFF_TOLERANCE_KG:
reasons.append("kg_mismatch")
has_changes = True
if reasons:
diff_lines.append(
{
"component_id": cid,
"master_kg": m,
"execution_kg": e,
"reasons": reasons,
}
)
return {"has_changes": has_changes, "lines": diff_lines}
@@ -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,
}
@@ -0,0 +1,211 @@
"""Группы кормов для авторациона — канонический тип компонента + legacy-маппинг."""
from __future__ import annotations
from typing import Any
from app.modules.zootech.wesp_bridge_models import Component
FEED_GROUPS: tuple[dict[str, Any], ...] = (
{
"id": "rough",
"label": "База — грубые",
"shortLabel": "База",
"required": True,
"minPick": 1,
"hint": "Без каркаса партия не взлетит. Я не бизнесмен — я специалист.",
"step": 1,
},
{
"id": "succulent",
"label": "Влага — сочные",
"shortLabel": "Влага",
"required": False,
"minPick": 0,
"hint": "Сочное сырьё. Можно не мешать — но чистота пострадает.",
"step": 2,
},
{
"id": "concentrate",
"label": "Энергия — концентраты",
"shortLabel": "Энергия",
"required": False,
"minPick": 0,
"hint": "Концентрат дозируй как реагент — точно. Держись подальше от моей территории.",
"step": 3,
},
{
"id": "other",
"label": "Добавки",
"shortLabel": "Добавки",
"required": False,
"minPick": 0,
"hint": "Минералы и премикс. Необязательно. Но Хайзенберг бы не пропустил.",
"step": 4,
},
)
# Канонические значения component.type (выбор в /components)
FEED_COMPONENT_TYPES: tuple[dict[str, str], ...] = (
{
"value": "Грубые корма",
"feedGroup": "rough",
"description": "Сено, солома.",
},
{
"value": "Сочные корма",
"feedGroup": "succulent",
"description": "Силос, корнеплоды.",
},
{
"value": "Концентрированные",
"feedGroup": "concentrate",
"description": "Зерно, комбикорм, жмых, шрот",
},
{
"value": "Добавки",
"feedGroup": "other",
"description": "Премикс, минералы, витамины, КЖП",
},
)
_GROUP_BY_ID = {g["id"]: g for g in FEED_GROUPS}
_LEGACY_TYPE_TO_GROUP: dict[str, str] = {
"зерновые": "concentrate",
"энергетические": "concentrate",
"белковые": "concentrate",
"минеральные": "other",
"витаминные": "other",
}
# Старый тип «Объемные корма» — уточните до Грубые/Сочные; эвристика по имени
_ROUGH_NAME_KEYS = ("солом", "сено", "hay", "straw")
_SUCCULENT_NAME_KEYS = ("силос", "сенаж", "сочн", "корнеплод", "свекл", "морков", "тыкв", "зелен")
_CONCENTRATE_NAME_KEYS = ("зерн", "концентр", "комбикорм", "комбик", "жмых", "шрот", "дробин", "пивн")
def _norm(text: str | None) -> str:
return (text or "").strip().lower()
def _canonical_type_map() -> dict[str, str]:
return {_norm(t["value"]): t["feedGroup"] for t in FEED_COMPONENT_TYPES}
def _canonical_values() -> list[str]:
return [t["value"] for t in FEED_COMPONENT_TYPES]
def list_component_feed_types() -> list[dict[str, str]]:
return [
{
"value": t["value"],
"feedGroup": t["feedGroup"],
"description": t["description"],
"groupLabel": _GROUP_BY_ID[t["feedGroup"]]["label"],
}
for t in FEED_COMPONENT_TYPES
]
def is_canonical_feed_type(component_type: str | None) -> bool:
return _norm(component_type) in _canonical_type_map()
def feed_group_for_type(component_type: str | None) -> str | None:
"""Группа авторациона по component.type (канон или legacy)."""
ctype = _norm(component_type)
if not ctype:
return None
canonical = _canonical_type_map().get(ctype)
if canonical:
return canonical
if ctype in ("объемные корма", "объёмные корма"):
return None
return _LEGACY_TYPE_TO_GROUP.get(ctype)
def classify_feed_group(comp: Component) -> str:
"""Группа для авторациона: сначала component.type, иначе эвристика по имени (legacy)."""
by_type = feed_group_for_type(comp.type)
if by_type:
return by_type
name = _norm(comp.name)
def has_any(keys: tuple[str, ...]) -> bool:
return any(k in name for k in keys)
if has_any(_ROUGH_NAME_KEYS):
return "rough"
if has_any(_SUCCULENT_NAME_KEYS):
return "succulent"
if has_any(_CONCENTRATE_NAME_KEYS):
return "concentrate"
if _norm(comp.type) in ("объемные корма", "объёмные корма"):
return "succulent"
return "other"
def list_feed_groups_api() -> list[dict[str, Any]]:
return [
{
"id": g["id"],
"label": g["label"],
"shortLabel": g["shortLabel"],
"required": g["required"],
"minPick": g["minPick"],
"hint": g["hint"],
"step": g["step"],
}
for g in FEED_GROUPS
]
def parse_group_selections(raw: Any) -> dict[str, list[str]]:
if not isinstance(raw, dict):
return {}
out: dict[str, list[str]] = {}
for gid in _GROUP_BY_ID:
vals = raw.get(gid) or []
if isinstance(vals, list):
out[gid] = [str(x) for x in vals if x]
return out
def flatten_group_selections(selections: dict[str, list[str]]) -> list[str]:
seen: list[str] = []
for gid in _GROUP_BY_ID:
for cid in selections.get(gid) or []:
if cid not in seen:
seen.append(cid)
return seen
def validate_group_selections(selections: dict[str, list[str]]) -> list[str]:
errors: list[str] = []
for g in FEED_GROUPS:
gid = g["id"]
picked = selections.get(gid) or []
if g["required"] and len(picked) < int(g["minPick"]):
errors.append(f"{gid}:need_{g['minPick']}")
total = len(flatten_group_selections(selections))
if total < 3:
errors.append("pool:need_3")
return errors
def triplet_meets_group_rules(
triplet_ids: set[str],
selections: dict[str, list[str]],
) -> bool:
for g in FEED_GROUPS:
if not g["required"]:
continue
pool = set(selections.get(g["id"]) or [])
if not pool:
continue
if not triplet_ids & pool:
return False
return True
@@ -0,0 +1,417 @@
from __future__ import annotations
import itertools
import time
from dataclasses import dataclass, field
from typing import Any
from app.modules.zootech.lab.calc.engine import calculate_ration
from app.modules.zootech.lab.calc.feed_groups import (
FEED_GROUPS,
flatten_group_selections,
triplet_meets_group_rules,
validate_group_selections,
)
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group as _classify_feed_group
from app.modules.zootech.lab.calc.formulate_optimize import optimize_shares_for_triplet
from app.modules.zootech.lab.calc.formulate_score import (
build_calc_lines,
build_triplet_score_model,
score_model_shares,
violation_score,
)
from app.modules.zootech.lab.calc.formulate_validate import validate_component, validate_components
from app.modules.zootech.lab.calc.norms_resolver import NormsParams, NormsResolveRequest, normalize_norms_method, resolve_norms
from app.modules.zootech.lab.calc.nutrients import get_nutrient_value
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.services.component_nutrients import nutrients_calc_dict_batch
from app.modules.zootech.lab.services.profile_norms import load_norms_dict
from app.modules.zootech.wesp_bridge_models import Component
@dataclass
class FormulateRequest:
profile_id: str
candidate_ids: list[str] = field(default_factory=list)
group_selections: dict[str, list[str]] = field(default_factory=dict)
main_feed_ids: list[str] = field(default_factory=list) # legacy → rough
mass_kg: float | None = None
milk_yield_kg: float | None = None
heads_per_trip: int | None = None
total_kg_per_head: float = 7.3
optimize_keys: list[str] = field(default_factory=list)
objective: str = "min_cost"
cost_weight: float = 100.0
grid_step: float = 0.1
prefilter_k: int = 18
min_share: float = 0.05
norms_method: str = "wesp"
norms_params: dict[str, Any] = field(default_factory=dict)
_DEFAULT_OPTIMIZE_KEYS = (
"dry_matter",
"usp",
"nel",
"crude_protein",
"rnb",
"nfc_pct_dm_uk",
)
def _indicator_def(key: str) -> dict[str, Any] | None:
for defn in RATION_ALL_INDICATORS:
if defn["key"] == key:
return defn
return None
def _resolve_norms(profile: LabAnimalProfile, req: FormulateRequest) -> tuple[dict, dict]:
stored = load_norms_dict(profile.id)
mass = req.mass_kg if req.mass_kg is not None else profile.mass_kg
milk = req.milk_yield_kg if req.milk_yield_kg is not None else profile.milk_yield_kg
from app.modules.zootech.lab.services.norms_params import load_norms_params
method = normalize_norms_method(req.norms_method or profile.norms_method)
params = load_norms_params(profile)
if req.norms_params:
merged = {
"milkFatPct": params.milk_fat_pct,
"lactationNo": params.lactation_no,
"lactationStage": params.lactation_stage,
"bodyCondition": params.body_condition,
"housingSystem": params.housing_system,
"koncOeSv": params.konc_oe_sv,
}
merged.update(req.norms_params)
params = NormsParams.from_dict(merged)
resolved, meta = resolve_norms(
NormsResolveRequest(
method=method,
stored=stored,
mass_kg=mass,
milk_yield_kg=milk,
ration_type=profile.ration_type,
force_dynamic=method == "wesp",
params=params,
)
)
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
return resolved, {"normsMethod": method, "dynamicNorms": dynamic, "normsMeta": meta.get("meta")}
def _rough_component_value(
comp: Component,
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
nutrient_cache: dict[str, dict[str, float]],
) -> float | None:
nutrients = nutrient_cache.get(comp.id, {})
dm_pct = comp.dry_matter
total = 0.0
counted = 0
for key in optimize_keys:
defn = _indicator_def(key)
if defn is None or defn.get("derived"):
continue
bounds = norms.get(key) or {}
target_min = bounds.get("min")
target_max = bounds.get("max")
if target_min is None and target_max is None:
continue
target = None
if target_min is not None and target_max is not None:
target = (float(target_min) + float(target_max)) / 2.0
elif target_max is not None:
target = float(target_max) * 0.9
elif target_min is not None:
target = float(target_min) * 1.1
if target is None or target == 0:
continue
val = get_nutrient_value(
dm_pct,
nutrients,
defn.get("nutrient_keys") or [],
indicator_key=key,
)
if val is None:
continue
rel = (float(val) - target) / abs(target)
total += rel * rel
counted += 1
return total / counted if counted else None
def _prefilter_candidates(
candidates: list[Component],
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
nutrient_cache: dict[str, dict[str, float]],
*,
prefilter_k: int,
must_keep_ids: set[str] | None = None,
) -> list[Component]:
must_keep_ids = must_keep_ids or set()
pinned = [c for c in candidates if c.id in must_keep_ids]
rest = [c for c in candidates if c.id not in must_keep_ids]
slots = max(prefilter_k - len(pinned), 0)
if len(candidates) <= prefilter_k:
return candidates
if slots <= 0:
return pinned[:prefilter_k]
prices = [float(c.price) for c in rest if c.price is not None]
median_price = sorted(prices)[len(prices) // 2] if prices else 1.0
if median_price <= 0:
median_price = 1.0
scored: list[tuple[float, Component]] = []
for comp in rest:
price = float(comp.price) if comp.price is not None else median_price
cost_part = price / median_price
nutrient_part = _rough_component_value(comp, optimize_keys, norms, nutrient_cache)
if nutrient_part is None:
score = cost_part
else:
score = 0.4 * cost_part + 0.6 * nutrient_part
scored.append((score, comp))
scored.sort(key=lambda x: x[0])
return pinned + [comp for _, comp in scored[:slots]]
def _load_profile(profile_id: str) -> LabAnimalProfile:
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
if profile is None:
raise LookupError("Профиль не найден")
return profile
def _load_eligible_components(candidate_ids: list[str]) -> tuple[list[Component], list[dict[str, Any]]]:
unique = list(dict.fromkeys(candidate_ids))
validations = validate_components(unique)
ineligible = [v for v in validations if not v["eligible"]]
if ineligible:
raise ValueError("ineligible_components", ineligible)
comps: list[Component] = []
for cid in unique:
comp = Component.query.filter_by(id=cid, is_deleted=False).first()
if comp is None:
raise ValueError("component_not_found", cid)
comps.append(comp)
return comps, validations
def _resolve_group_selections(req: FormulateRequest) -> dict[str, list[str]]:
selections = dict(req.group_selections or {})
if req.main_feed_ids and not selections.get("rough"):
selections["rough"] = list(req.main_feed_ids)
return selections
def formulate(req: FormulateRequest) -> dict[str, Any]:
group_selections = _resolve_group_selections(req)
candidate_ids = list(req.candidate_ids)
if group_selections:
pool_errors = validate_group_selections(group_selections)
if pool_errors:
raise ValueError("group_selection_invalid", pool_errors)
candidate_ids = flatten_group_selections(group_selections)
if len(candidate_ids) < 3:
raise ValueError("candidate_ids_min_3")
if len(set(candidate_ids)) != len(candidate_ids):
raise ValueError("candidate_ids_duplicate")
profile = _load_profile(req.profile_id)
candidates, _ = _load_eligible_components(candidate_ids)
nutrient_cache = nutrients_calc_dict_batch(candidate_ids)
norms, dynamic = _resolve_norms(profile, req)
optimize_keys = req.optimize_keys or list(_DEFAULT_OPTIMIZE_KEYS)
heads = max(int(req.heads_per_trip or 10), 1)
herd_scale = float(req.total_kg_per_head) * heads
profile_mass_kg = req.mass_kg if req.mass_kg is not None else profile.mass_kg
must_keep: set[str] = set()
for g in FEED_GROUPS:
if g["required"]:
must_keep.update(group_selections.get(g["id"]) or [])
prefilter_applied = len(candidates) > req.prefilter_k
shortlist = _prefilter_candidates(
candidates,
optimize_keys,
norms,
nutrient_cache,
prefilter_k=req.prefilter_k,
must_keep_ids=must_keep,
)
started = time.perf_counter()
evaluations = 0
triplets_evaluated = 0
triplet_winners: list[dict[str, Any]] = []
def _eval_triplet(
triplet: tuple[Component, Component, Component],
) -> dict[str, Any] | None:
nonlocal evaluations
model = build_triplet_score_model(
triplet,
nutrient_cache=nutrient_cache,
norms=norms,
optimize_keys=optimize_keys,
herd_scale=herd_scale,
heads=heads,
cost_weight=req.cost_weight,
profile_mass_kg=profile_mass_kg,
)
def score_fn(shares: tuple[float, float, float]) -> float:
_violation, _cost_head, score = score_model_shares(model, shares)
return score
opt = optimize_shares_for_triplet(
triplet,
score_fn,
min_share=req.min_share,
grid_step=req.grid_step,
)
if opt is None:
return None
evaluations += opt.evaluations
violation, cost_head, score = score_model_shares(model, opt.shares)
lines = build_calc_lines(triplet, opt.shares, herd_scale, nutrient_cache)
daily = [opt.shares[i] * herd_scale for i in range(3)]
return {
"score": score,
"violation": violation,
"costHead": cost_head,
"costTotal": cost_head * heads,
"triplet": triplet,
"shares": opt.shares,
"daily": daily,
"lines": lines,
}
for triplet in itertools.combinations(shortlist, 3):
triplet_ids = {c.id for c in triplet}
if not triplet_meets_group_rules(triplet_ids, group_selections):
continue
triplets_evaluated += 1
best_triplet_result = _eval_triplet(triplet)
if best_triplet_result is None:
continue
triplet_winners.append(best_triplet_result)
if not triplet_winners:
if group_selections:
raise ValueError("no_feasible_solution_groups")
raise ValueError("no_feasible_solution")
triplet_winners.sort(key=lambda x: x["score"])
best = triplet_winners[0]
full_calc = calculate_ration(
profile.ration_type or "DAIRY",
best["lines"],
norms,
heads_per_trip=heads,
profile_mass_kg=profile_mass_kg,
)
best["calc"] = full_calc
best["violation"] = violation_score(
full_calc.get("indicators") or [],
optimize_keys,
norms,
)
best["score"] = best["violation"] * req.cost_weight + best["costHead"]
duration_ms = int((time.perf_counter() - started) * 1000)
alternatives = [
{
"componentIds": [c.id for c in item["triplet"]],
"names": [c.name for c in item["triplet"]],
"score": item["score"],
"violation": item["violation"],
"costPerHead": item["costHead"],
}
for item in triplet_winners[:3]
]
alternatives.sort(key=lambda x: x["score"])
alternatives = alternatives[:3]
result_lines = []
for i, comp in enumerate(best["triplet"]):
s1, s2, s3 = best["shares"]
share = (s1, s2, s3)[i]
result_lines.append(
{
"componentId": comp.id,
"name": comp.name,
"dailyKg": round(best["daily"][i], 4),
"sharePct": round(share * 100, 2),
"pricePerKg": comp.price,
"dryMatterPct": comp.dry_matter,
}
)
violation = best["violation"]
return {
"lines": result_lines,
"candidatePoolSize": len(candidates),
"groupSelections": group_selections,
"shortlistedIds": [c.id for c in shortlist],
"costTotal": best["costTotal"],
"costPerHead": best["costHead"],
"score": best["score"],
"violation": violation,
"feasible": violation < 0.01,
"indicators": best["calc"].get("indicators") or [],
"totals": best["calc"].get("totals") or [],
"optimizeKeys": optimize_keys,
"dynamicNorms": dynamic.get("dynamicNorms") or None,
"normsMethod": dynamic.get("normsMethod"),
"normsMeta": dynamic.get("normsMeta"),
"alternatives": alternatives,
"searchStats": {
"evaluations": evaluations,
"tripletsEvaluated": triplets_evaluated,
"durationMs": duration_ms,
"prefilterApplied": prefilter_applied,
"prefilterK": req.prefilter_k,
"scoreEngine": "fast",
"optimizer": "slsqp",
"normsMethod": dynamic.get("normsMethod"),
},
}
def list_formulate_components() -> list[dict[str, Any]]:
rows = (
Component.query.filter_by(is_active=True, is_deleted=False)
.order_by(Component.name)
.all()
)
out: list[dict[str, Any]] = []
for comp in rows:
v = validate_component(comp.id)
feed_group = _classify_feed_group(comp)
out.append(
{
"id": comp.id,
"name": comp.name,
"type": comp.type,
"feedGroup": feed_group,
"eligible": v["eligible"],
"missing": v["missing"],
"warnings": v["warnings"],
"dryMatterPct": v["dryMatterPct"],
"hasPrice": v["hasPrice"],
"price": v["price"],
"mainFeedDmGPerKg": v.get("mainFeedDmGPerKg"),
"isMainFeed": v.get("isMainFeed", False),
}
)
return out
@@ -0,0 +1,112 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any, Callable
from scipy.optimize import minimize
from app.modules.zootech.wesp_bridge_models import Component
ShareTuple = tuple[float, float, float]
ScoreFn = Callable[[ShareTuple], float]
@dataclass
class OptimizeSharesResult:
shares: ShareTuple
score: float
evaluations: int
def _normalize_shares(s1: float, s2: float, min_share: float) -> ShareTuple | None:
s3 = 1.0 - s1 - s2
ms = max(min_share, 0.0)
if s1 < ms - 1e-9 or s2 < ms - 1e-9 or s3 < ms - 1e-9:
return None
if abs(s1 + s2 + s3 - 1.0) > 1e-6:
return None
return (round(s1, 8), round(s2, 8), round(s3, 8))
def _start_points(min_share: float, grid_step: float) -> list[tuple[float, float]]:
"""Multi-start seeds: simplex center, corners, and a few grid_step hints."""
ms = max(min_share, 0.0)
max_pair = max(1.0 - 2 * ms, ms)
center = round((1.0 - ms) / 3.0, 6)
points: list[tuple[float, float]] = [
(center, center),
(ms, ms),
(max_pair, ms),
(ms, max_pair),
(max_pair, max_pair),
]
step = max(grid_step, 0.1)
if ms <= step <= max_pair:
points.append((step, ms))
points.append((ms, step))
deduped: list[tuple[float, float]] = []
seen: set[tuple[float, float]] = set()
for s1, s2 in points:
if s1 + s2 > 1.0 - ms + 1e-9:
continue
key = (round(s1, 6), round(s2, 6))
if key in seen:
continue
seen.add(key)
deduped.append(key)
return deduped
def optimize_shares_for_triplet(
triplet: tuple[Component, Component, Component],
score_fn: ScoreFn,
*,
min_share: float,
grid_step: float = 0.1,
) -> OptimizeSharesResult | None:
del triplet
ms = max(min_share, 0.0)
max_s1 = max(1.0 - 2 * ms, ms)
evaluations = 0
def objective(x: Any) -> float:
nonlocal evaluations
evaluations += 1
shares = _normalize_shares(float(x[0]), float(x[1]), ms)
if shares is None:
return 1e18
return score_fn(shares)
bounds = [(ms, max_s1), (ms, max_s1)]
constraints = [{"type": "ineq", "fun": lambda x: 1.0 - ms - float(x[0]) - float(x[1])}]
best_score: float | None = None
best_shares: ShareTuple | None = None
for s1, s2 in _start_points(ms, grid_step):
if s1 + s2 > 1.0 - ms + 1e-9:
continue
try:
res = minimize(
objective,
[s1, s2],
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"ftol": 1e-8, "maxiter": 40},
)
except Exception:
continue
if not res.success and res.fun >= 1e17:
continue
shares = _normalize_shares(float(res.x[0]), float(res.x[1]), ms)
if shares is None:
continue
score = float(res.fun)
if best_score is None or score < best_score:
best_score = score
best_shares = shares
if best_shares is None or best_score is None:
return None
return OptimizeSharesResult(shares=best_shares, score=best_score, evaluations=evaluations)
@@ -0,0 +1,371 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
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.indicators import RATION_ALL_INDICATORS, indicator_by_key
from app.modules.zootech.wesp_bridge_models import Component
def resolve_score_closure(optimize_keys: list[str]) -> frozenset[str]:
"""Collect base + derived indicator keys needed to score optimize_keys."""
needed: set[str] = set(optimize_keys)
changed = True
while changed:
changed = False
for key in list(needed):
defn = indicator_by_key(key)
if defn is None:
continue
derived = defn.get("derived")
if derived == "alias":
dep = defn.get("alias_of")
if dep and dep not in needed:
needed.add(dep)
changed = True
elif derived in ("pct_of_dm", "g_per_kg_dm", "nel_per_kg_dm"):
dep = defn.get("from_key")
if dep and dep not in needed:
needed.add(dep)
changed = True
elif derived in ("rnb", "bra_rnb"):
for dep in ("crude_protein", "usp"):
if dep not in needed:
needed.add(dep)
changed = True
elif derived == "ratio":
for dep in (defn.get("ratio_num"), defn.get("ratio_den")):
if dep and dep not in needed:
needed.add(dep)
changed = True
elif derived == "dm_pct_bw":
if "dry_matter" not in needed:
needed.add("dry_matter")
changed = True
return frozenset(needed)
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_score_indicators(
active: list[dict[str, Any]],
total_kg: float,
norms: dict[str, dict[str, float | None]],
optimize_keys: list[str],
*,
heads_per_trip: int = 1,
profile_mass_kg: float | None = None,
) -> list[dict[str, Any]]:
closure = resolve_score_closure(optimize_keys)
content_by_key: dict[str, float | None] = {}
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in closure or defn.get("derived"):
continue
content_by_key[key] = _indicator_content(
defn, active, total_kg, heads_per_trip=heads_per_trip
)
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in closure or not defn.get("derived"):
continue
content_by_key[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: list[dict[str, Any]] = []
for key in optimize_keys:
if key not in closure:
continue
defn = indicator_by_key(key)
if defn is None:
continue
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 violation_score(
indicators: list[dict[str, Any]],
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
) -> float:
by_key = {row.get("key"): row for row in indicators if row.get("key")}
total = 0.0
counted = 0
for key in optimize_keys:
bounds = norms.get(key) or {}
if bounds.get("min") is None and bounds.get("max") is None:
continue
row = by_key.get(key)
if row is None:
continue
diff = row.get("diff")
if diff is None:
diff = norm_diff(row.get("content"), bounds.get("min"), bounds.get("max"))
if diff is None:
continue
total += float(diff) ** 2
counted += 1
return total if counted else 0.0
def _violation_from_content(
content_by_key: dict[str, float | None],
optimize_keys: list[str],
norms: dict[str, dict[str, float | None]],
) -> float:
total = 0.0
counted = 0
for key in optimize_keys:
bounds = norms.get(key) or {}
if bounds.get("min") is None and bounds.get("max") is None:
continue
content = content_by_key.get(key)
diff = norm_diff(content, bounds.get("min"), bounds.get("max"))
if diff is None:
continue
total += float(diff) ** 2
counted += 1
return total if counted else 0.0
@dataclass
class TripletScoreModel:
triplet: tuple[Component, Component, Component]
nutrient_cache: dict[str, dict[str, float]]
herd_scale: float
heads: int
cost_weight: float
profile_mass_kg: float | None
optimize_keys: list[str]
norms: dict[str, dict[str, float | None]]
closure: frozenset[str]
intake_unit: dict[str, tuple[float | None, float | None, float | None]]
weighted_vals: dict[str, tuple[float | None, float | None, float | None]]
prices: tuple[float, float, float]
def build_triplet_score_model(
triplet: tuple[Component, Component, Component],
*,
nutrient_cache: dict[str, dict[str, float]],
norms: dict[str, dict[str, float | None]],
optimize_keys: list[str],
herd_scale: float,
heads: int,
cost_weight: float,
profile_mass_kg: float | None,
) -> TripletScoreModel:
closure = resolve_score_closure(optimize_keys)
kg_per_share = herd_scale / max(heads, 1)
intake_unit: dict[str, tuple[float | None, float | None, float | None]] = {}
weighted_vals: dict[str, tuple[float | None, float | None, float | None]] = {}
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in closure or defn.get("derived"):
continue
vals: list[float | None] = []
for comp in triplet:
nutrients = nutrient_cache.get(comp.id, {})
vals.append(
get_nutrient_value(
comp.dry_matter,
nutrients,
defn["nutrient_keys"],
indicator_key=key,
)
)
tup = (vals[0], vals[1], vals[2])
if defn.get("aggregation") == "weighted_avg":
weighted_vals[key] = tup
else:
intake_unit[key] = tuple(v * kg_per_share if v is not None else None for v in vals)
prices = tuple(
float(comp.price) if comp.price is not None else 0.0 for comp in triplet
)
return TripletScoreModel(
triplet=triplet,
nutrient_cache=nutrient_cache,
herd_scale=herd_scale,
heads=heads,
cost_weight=cost_weight,
profile_mass_kg=profile_mass_kg,
optimize_keys=optimize_keys,
norms=norms,
closure=closure,
intake_unit=intake_unit,
weighted_vals=weighted_vals,
prices=prices,
)
def _content_from_shares(
model: TripletScoreModel,
shares: tuple[float, float, float],
) -> dict[str, float | None]:
content_by_key: dict[str, float | None] = {}
total_kg = model.herd_scale
for key, coeffs in model.intake_unit.items():
parts = [
shares[i] * coeffs[i]
for i in range(3)
if coeffs[i] is not None
]
content_by_key[key] = sum(parts) if parts else None
for key, vals in model.weighted_vals.items():
num = 0.0
den = 0.0
for i in range(3):
if vals[i] is None:
continue
num += shares[i] * vals[i]
den += shares[i]
content_by_key[key] = (num / den) if den > 0 else None
for defn in RATION_ALL_INDICATORS:
key = defn["key"]
if key not in model.closure or not defn.get("derived"):
continue
content_by_key[key] = apply_content_derived(
content_by_key,
defn,
total_kg=total_kg,
heads_per_trip=model.heads,
profile_mass_kg=model.profile_mass_kg,
)
return content_by_key
def score_model_shares(
model: TripletScoreModel,
shares: tuple[float, float, float],
) -> tuple[float, float, float]:
"""Return (violation, cost_head, score) without building line dicts."""
content = _content_from_shares(model, shares)
violation = _violation_from_content(content, model.optimize_keys, model.norms)
cost_total = sum(shares[i] * model.prices[i] * model.herd_scale for i in range(3))
cost_head = cost_total / max(model.heads, 1)
score = violation * model.cost_weight + cost_head
return violation, cost_head, score
def build_calc_lines(
triplet: tuple[Component, Component, Component],
shares: tuple[float, float, float],
herd_scale: float,
nutrient_cache: dict[str, dict[str, float]],
) -> list[dict[str, Any]]:
lines: list[dict[str, Any]] = []
for i, comp in enumerate(triplet):
daily_kg = shares[i] * herd_scale
lines.append(
{
"component_id": comp.id,
"ingredient_name": comp.name,
"daily_kg": daily_kg,
"in_ration": True,
"in_compound": False,
"dry_matter": comp.dry_matter,
"price_per_kg": comp.price,
"nutrients": nutrient_cache.get(comp.id, {}),
}
)
return lines
def cost_per_head(
triplet: tuple[Component, Component, Component],
shares: tuple[float, float, float],
herd_scale: float,
heads: int,
) -> float:
total = 0.0
any_cost = False
for i, comp in enumerate(triplet):
kg = shares[i] * herd_scale
price = comp.price
if kg is None or price is None:
continue
total += float(kg) * float(price)
any_cost = True
if not any_cost:
return 0.0
return total / max(heads, 1)
def score_triplet_shares(
triplet: tuple[Component, Component, Component],
shares: tuple[float, float, float],
*,
nutrient_cache: dict[str, dict[str, float]],
norms: dict[str, dict[str, float | None]],
optimize_keys: list[str],
herd_scale: float,
heads: int,
cost_weight: float,
profile_mass_kg: float | None,
) -> tuple[float, float, float, list[dict[str, Any]]]:
"""Return (violation, cost_head, score, lines)."""
model = build_triplet_score_model(
triplet,
nutrient_cache=nutrient_cache,
norms=norms,
optimize_keys=optimize_keys,
herd_scale=herd_scale,
heads=heads,
cost_weight=cost_weight,
profile_mass_kg=profile_mass_kg,
)
violation, cost_head, score = score_model_shares(model, shares)
lines = build_calc_lines(triplet, shares, herd_scale, nutrient_cache)
return violation, cost_head, score, lines
@@ -0,0 +1,86 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
from app.modules.zootech.lab.calc.gfe_policies import default_om_digestibility_pct
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
from app.modules.zootech.lab.services.component_nutrients import derive_context_for_component, nutrients_full_dict, nutrients_is_empty
from app.modules.zootech.wesp_bridge_models import Component
_REQUIRED_EAV_KEYS = ("Сыр. Протеин", "Сырая клетч", "Сырой жир")
_MAIN_FEED_KEYS = ("Осн.Корм", "СВ Основной корм")
_OMD_KEYS = ("ВРХ Орг Вещ", "КРС Орг Вещ")
def main_feed_dm_g_per_kg(component_id: str | None) -> float | None:
if not component_id:
return None
full = nutrients_full_dict(component_id)
val = read_from_mapping(full, _MAIN_FEED_KEYS)
return float(val) if val is not None else None
def validate_component(component_id: str) -> dict[str, Any]:
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
if comp is None:
return {
"id": component_id,
"name": None,
"eligible": False,
"missing": ["component_not_found"],
"warnings": [],
"dryMatterPct": None,
"hasPrice": False,
"price": None,
}
missing: list[str] = []
warnings: list[str] = []
dry_matter_pct = comp.dry_matter
if dry_matter_pct is None or float(dry_matter_pct) <= 0:
missing.append("dry_matter")
full = nutrients_full_dict(component_id)
if nutrients_is_empty(component_id):
missing.append("nutrients_empty")
else:
for key in _REQUIRED_EAV_KEYS:
if read_from_mapping(full, (key,)) is None:
missing.append(key)
has_price = comp.price is not None and float(comp.price) >= 0
if not has_price:
warnings.append("price_missing")
main_feed_dm = read_from_mapping(full, _MAIN_FEED_KEYS)
if main_feed_dm is None:
warnings.append("main_feed_unset")
feed_group = classify_feed_group(comp)
omd = read_from_mapping(full, _OMD_KEYS)
if feed_group in ("rough", "succulent") and omd is None:
warnings.append("omd_missing")
elif omd is None and comp.dry_matter and float(comp.dry_matter) > 0:
ctx = derive_context_for_component(component_id, full)
warnings.append(
f"omd_defaulted:{default_om_digestibility_pct(ctx):.0f}"
)
return {
"id": comp.id,
"name": comp.name,
"eligible": len(missing) == 0,
"missing": missing,
"warnings": warnings,
"dryMatterPct": dry_matter_pct,
"hasPrice": has_price,
"price": comp.price,
"mainFeedDmGPerKg": main_feed_dm,
"isMainFeed": main_feed_dm is not None and float(main_feed_dm) > 0,
"feedGroup": feed_group,
}
def validate_components(component_ids: list[str]) -> list[dict[str, Any]]:
return [validate_component(cid) for cid in component_ids]
@@ -0,0 +1,94 @@
"""Динамические нормы по уравнениям GfE (Германия)."""
from __future__ import annotations
from typing import Any
USP_DYNAMIC_KEY = "usp"
NEL_DYNAMIC_KEY = "nel"
# GfE 2001 Milchkühe — Erhaltung + Milch (Standardmilch / FCM)
NEL_MAINTENANCE_COEFF = 0.293 # MJ NEL / (kg LM)^0,75 / Tag
NEL_PER_KG_MILK_MJ = 3.3 # MJ NEL / kg Milch (FCM)
def gfe_usp_min_g(
mass_kg: float | None,
milk_yield_kg: float | None = None,
) -> float | None:
"""
Минимальная суточная потребность в усвояемом протеине (уСП / nXP), г/сут.
GfE: 0,09 × (масса^0,75) × 6,25 + удой × 85 г.
"""
if mass_kg is None or mass_kg <= 0:
return None
maintenance = 0.09 * (mass_kg**0.75) * 6.25
milk = max(float(milk_yield_kg or 0), 0.0) * 85.0
return maintenance + milk
def gfe_nel_min_mj(
mass_kg: float | None,
milk_yield_kg: float | None = None,
) -> float | None:
"""
Минимальная суточная потребность в ЧЭЛ (NEL), МДж/сут.
GfE 2001: 0,293 × LM^0,75 + удой × 3,3 (MJ NEL на кг молока).
"""
if mass_kg is None or mass_kg <= 0:
return None
maintenance = NEL_MAINTENANCE_COEFF * (mass_kg**0.75)
milk = max(float(milk_yield_kg or 0), 0.0) * NEL_PER_KG_MILK_MJ
return maintenance + milk
def preview_dynamic_norms(
mass_kg: float | None,
milk_yield_kg: float | None = None,
) -> dict[str, Any]:
"""Расчётные min по GfE для UI (без учёта сохранённых норм)."""
_, dynamic = apply_dynamic_norms(
{},
mass_kg=mass_kg,
milk_yield_kg=milk_yield_kg,
)
return dynamic
def apply_dynamic_norms(
stored: dict[str, dict[str, float | None]],
*,
mass_kg: float | None,
milk_yield_kg: float | None,
ration_type: str | None = None,
force_dynamic: bool = False,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""
Заполняет нормы по GfE, если в БД min не задан.
force_dynamic=True — пересчитать min уСП/ЧЭЛ по массе и удою даже при нормах в БД
(автоготовка с явными mass_kg / milk_yield_kg).
Возвращает (resolved_norms, dynamic_meta).
"""
del ration_type
resolved: dict[str, dict[str, float | None]] = {
key: {"min": bounds.get("min"), "max": bounds.get("max")}
for key, bounds in stored.items()
}
dynamic: dict[str, Any] = {}
for key, compute, formula in (
(USP_DYNAMIC_KEY, gfe_usp_min_g, "0.09×масса^0.75×6.25 + удой×85"),
(NEL_DYNAMIC_KEY, gfe_nel_min_mj, "0.293×масса^0.75 + удой×3.3"),
):
bounds = resolved.get(key, {"min": None, "max": None})
if force_dynamic or bounds.get("min") is None:
computed = compute(mass_kg, milk_yield_kg)
if computed is not None:
entry = dict(bounds)
entry["min"] = computed
resolved[key] = entry
dynamic[key] = {"min": computed, "formula": formula}
return resolved, dynamic
@@ -0,0 +1,84 @@
"""WESP-политики расчёта на базе GfE 2001 (отличия от zootech Excel — осознанные)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
FeedGroup = Literal["rough", "succulent", "concentrate", "other", "unknown"]
# --- GfE 2001 константы (формулы не меняем) ---
NEL_Q_COEFF = 0.004
NEL_Q_REF_PCT = 57.0
NEL_BASE = 0.6
GE_CP = 0.0239
GE_FAT = 0.0398
GE_FIBER = 0.0201
GE_NFE = 0.0175
ME_FAT = 0.0312
ME_FIBER = 0.0136
ME_OR_RESIDUE = 0.0147
ME_CP = 0.00234
USP_FAT_THRESHOLD_G_PER_KG_DM = 70.0 # 7% СЖ/кг СВ
# DCAB (Na+K)-(Cl+S), мэкв при минералах в г/кг СВ
DCAB_NA = 43.5
DCAB_K = 25.6
DCAB_CL = 28.2
DCAB_S = 62.4
# Дефолты переваримости при пустых коэфф. (Excel legacy, кроме ОВ)
DEFAULT_CP_DIGEST_PCT = 86.0
DEFAULT_FAT_DIGEST_PCT = 75.0
DEFAULT_FIBER_DIGEST_PCT = 86.0
DEFAULT_NFE_DIGEST_PCT = 94.0
DEFAULT_INSOLUBLE_PROTEIN_PCT = 15.0
DEFAULT_PROTEIN_FRACTION_PCT = 18.9
# WESP: дефолт ВРХ орг. вещ. при пустом поле — по классу корма
DEFAULT_OMD_ROUGH_PCT = 65.0
DEFAULT_OMD_SUCCULENT_PCT = 72.0
DEFAULT_OMD_CONCENTRATE_PCT = 91.0
@dataclass(frozen=True)
class DeriveContext:
feed_group: FeedGroup = "unknown"
is_main_feed: bool = False
@classmethod
def from_cells(cls, *, main_feed_g: float = 0.0, feed_group: FeedGroup = "unknown") -> DeriveContext:
return cls(
feed_group=feed_group,
is_main_feed=main_feed_g > 0,
)
@classmethod
def infer_from_cells(cls, cells: dict[str, float], feed_group: FeedGroup = "unknown") -> DeriveContext:
main_feed = cells.get("E", 0.0)
return cls.from_cells(main_feed_g=main_feed, feed_group=feed_group)
def default_om_digestibility_pct(ctx: DeriveContext | None) -> float:
"""Дефолт переваримости ОВ (%) при отсутствии ВРХ Орг Вещ."""
if ctx is None:
return DEFAULT_OMD_CONCENTRATE_PCT
if ctx.is_main_feed or ctx.feed_group == "rough":
return DEFAULT_OMD_ROUGH_PCT
if ctx.feed_group == "succulent":
return DEFAULT_OMD_SUCCULENT_PCT
return DEFAULT_OMD_CONCENTRATE_PCT
def default_digestibility_coefficients() -> dict[str, float]:
return {
"cp": DEFAULT_CP_DIGEST_PCT,
"fat": DEFAULT_FAT_DIGEST_PCT,
"fiber": DEFAULT_FIBER_DIGEST_PCT,
"nfe": DEFAULT_NFE_DIGEST_PCT,
"insoluble_protein": DEFAULT_INSOLUBLE_PROTEIN_PCT,
"protein_fraction": DEFAULT_PROTEIN_FRACTION_PCT,
}
@@ -0,0 +1,187 @@
"""Каталог показателей zootech «База сырья» (109 колонок)."""
from __future__ import annotations
from openpyxl.utils import column_index_from_string, get_column_letter
# Заголовки row 3 в xlsx_extracted/data_csv_cleaned/База сырья.csv
INGREDIENT_HEADERS: tuple[str, ...] = (
"",
"Наименование",
"Цена 1 кг",
"СВ",
"Осн.Корм",
"Сыр. Протеин",
"уСП",
"БРА",
"ЧЭЛ- КРС",
"ОЭ-КРС",
"Сырая клетч",
"Структур клетч",
"Сырой жир",
"НДК",
"КДК",
"NFC",
"Ca",
"P",
"Mg",
"Fe",
"Zn",
"Cu",
"Co",
"Mn",
"Se",
"J",
"Na",
"K",
"CL",
"S",
"DCAB Форм",
"Сахар и Крохм",
"Нераств Крохм",
"Сахар",
"Крахмал",
"Нераств крахмал",
"Вит А",
"Вит D",
"Вит Е",
"Вит В1",
"Вит В2",
"Вит В6",
"Вит В12",
"Пант Кальц",
"Никот Ки-та",
"Фол ки-та",
"Холин",
"Биотин",
"Сырая зола",
"БЕР",
"Лизин",
"Метионин",
"Треонин",
"Триптофан",
"Изолейцин",
"Лейцин",
"Валин",
"Каротин",
"b -Каротин",
"Линолевая к-та",
"Линоленовая ки-та",
"Масляная ки-та",
"Арахидоновая ки-та",
"Полиэновая ки-та",
"Мочевина",
"СВ Основной корм",
"ВРХ Орг Вещ",
"Перевар Орг Вещ",
"КРС Протеин",
"Переварим Протеин",
"КРС Сырой жир",
"Переварим Сырой жир",
"КРС Сырая клетч",
"Переварим сырая клетч",
"КРС БЭВ",
"Переварим БЭВ",
"ВЕ",
"OЭ КРС форм",
"ЧЭЛ - КРС Форм",
"НДК Общ",
"НДК Осн. Корм",
"КДК общ",
"% нераствор протеин",
"Нерастворим прот",
"СЖ/кг СВ",
"НСП/кг СВ",
"СП/кг СВ",
"пОВ/кг СВ",
"пСЖ/кг СВ",
"уСП<7%",
"уСП>7%",
"уСП/кг СВ формул",
"уСП в ОР",
"уСП формул",
"БРА",
"Нераств крохмал",
"доля крохмала",
"Доля белка",
"% перев в кишках",
"OEB",
"Синтез Мдж",
"Промеж рез 1",
"Промеж рез 2",
"Метаб ОЕТ",
"Метаб лизин",
"Метабол Треон",
"Метабол Лейцин",
"Метабол Изолейц",
"Метабол Валин",
)
HEADER_TO_LETTER: dict[str, str] = {
header: get_column_letter(i + 1) for i, header in enumerate(INGREDIENT_HEADERS)
}
LETTER_TO_HEADER: dict[str, str] = {
get_column_letter(i + 1): header for i, header in enumerate(INGREDIENT_HEADERS)
}
# Колонки с формулами в шаблонной строке 6 (native port, не Excel runtime).
DERIVED_LETTERS: frozenset[str] = frozenset(
{
"AE",
"AF",
"AG",
"AX",
"BN",
"BP",
"BR",
"BT",
"BV",
"BX",
"BY",
"BZ",
"CA",
"CF",
"CG",
"CH",
"CI",
"CJ",
"CK",
"CL",
"CM",
"CN",
"CP",
"CQ",
"CX",
"CY",
"CZ",
"DA",
"DB",
"DC",
"DD",
"DE",
}
)
# Отображаемые поля G–J синхронизируются с расчётными CP/CQ/CA/BZ.
DISPLAY_SYNC: tuple[tuple[str, str], ...] = (
("G", "CP"), # уСП
("H", "CQ"), # RNB (legacy заголовок «БРА», дубль CQ)
("I", "CA"), # ЧЭЛ- КРС
("J", "BZ"), # ОЭ-КРС
)
DERIVED_HEADERS: frozenset[str] = frozenset(
LETTER_TO_HEADER[letter] for letter in DERIVED_LETTERS
) | frozenset(LETTER_TO_HEADER[g] for g, _ in DISPLAY_SYNC)
INPUT_HEADERS: frozenset[str] = frozenset(INGREDIENT_HEADERS) - DERIVED_HEADERS - frozenset(
("", "Наименование", "Цена 1 кг")
)
# Дублирующий заголовок «БРА»/RNB (H и CQ) — в derive используем CQ.
DUPLICATE_HEADERS: frozenset[str] = frozenset({"БРА"})
def letter_index(letter: str) -> int:
return column_index_from_string(letter) - 1
@@ -0,0 +1,216 @@
"""Native derive формул zootech «База сырья» — WESP GfE 2001 engine."""
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.gfe_policies import (
DCAB_CL,
DCAB_K,
DCAB_NA,
DCAB_S,
DEFAULT_CP_DIGEST_PCT,
DEFAULT_FAT_DIGEST_PCT,
DEFAULT_FIBER_DIGEST_PCT,
DEFAULT_INSOLUBLE_PROTEIN_PCT,
DEFAULT_NFE_DIGEST_PCT,
DEFAULT_PROTEIN_FRACTION_PCT,
DeriveContext,
GE_CP,
GE_FAT,
GE_FIBER,
GE_NFE,
ME_CP,
ME_FAT,
ME_FIBER,
ME_OR_RESIDUE,
NEL_BASE,
NEL_Q_COEFF,
NEL_Q_REF_PCT,
USP_FAT_THRESHOLD_G_PER_KG_DM,
default_om_digestibility_pct,
)
from app.modules.zootech.lab.calc.ingredient_catalog import (
DISPLAY_SYNC,
HEADER_TO_LETTER,
INGREDIENT_HEADERS,
LETTER_TO_HEADER,
)
def _parse_num(value: Any) -> float | None:
if value is None or value == "":
return None
try:
n = float(value)
except (TypeError, ValueError):
return None
return None if n != n else n
def _normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def _v(cells: dict[str, float], letter: str, default: float = 0.0) -> float:
return cells.get(letter, default)
def _if_pos(test: float, when_true, when_false: float = 0.0) -> float:
"""Excel IF(test>0, …) — ветка when_true не вычисляется при test<=0."""
if test > 0:
return when_true() if callable(when_true) else when_true
return when_false
def dict_to_cells(data: dict[str, Any] | None) -> dict[str, float]:
"""Словарь {заголовок: значение} → {буква колонки: значение}."""
cells: dict[str, float] = {}
if not data:
return cells
norm_index = {_normalize_key(h): h for h in INGREDIENT_HEADERS}
for raw_key, raw_val in data.items():
n = _parse_num(raw_val)
if n is None:
continue
nk = _normalize_key(str(raw_key))
header = norm_index.get(nk)
if header is None:
continue
letter = HEADER_TO_LETTER.get(header)
if letter:
cells[letter] = n
return cells
def cells_to_dict(cells: dict[str, float]) -> dict[str, float]:
out: dict[str, float] = {}
for letter, value in cells.items():
header = LETTER_TO_HEADER.get(letter)
if header and header not in ("", "Наименование", "Цена 1 кг"):
out[header] = value
return out
def derive_cells(
cells: dict[str, float],
*,
context: DeriveContext | None = None,
) -> dict[str, float]:
"""Пересчёт derived-колонок по цепочке формул row 6 «База сырья»."""
c = dict(cells)
ctx = context or DeriveContext.infer_from_cells(c)
omd_default = default_om_digestibility_pct(ctx)
c["AE"] = DCAB_NA * _v(c, "AA") + DCAB_K * _v(c, "AB") - DCAB_CL * _v(c, "AC") - DCAB_S * _v(c, "AD")
c["AG"] = _v(c, "AJ") * _v(c, "AI") / 100.0
c["AF"] = _v(c, "AI") - c["AG"] + _v(c, "AH")
c["AX"] = _v(c, "D") - _v(c, "F") - _v(c, "K") - _v(c, "M") - _v(c, "AW")
c["BN"] = _if_pos(_v(c, "E"), lambda: _v(c, "D") / _v(c, "E") * 1000.0)
c["BP"] = _if_pos(
_v(c, "BO"),
lambda: (_v(c, "D") - _v(c, "AW")) * _v(c, "BO") / 100.0,
(_v(c, "D") - _v(c, "AW")) * omd_default / 100.0,
)
c["BR"] = _if_pos(
_v(c, "BQ"), lambda: _v(c, "F") * _v(c, "BQ") / 100.0, _v(c, "F") * DEFAULT_CP_DIGEST_PCT / 100.0
)
c["BT"] = _if_pos(
_v(c, "BS"), lambda: _v(c, "M") * _v(c, "BS") / 100.0, _v(c, "M") * DEFAULT_FAT_DIGEST_PCT / 100.0
)
c["BV"] = _if_pos(
_v(c, "BU"), lambda: _v(c, "K") * _v(c, "BU") / 100.0, _v(c, "K") * DEFAULT_FIBER_DIGEST_PCT / 100.0
)
c["BX"] = _if_pos(
_v(c, "BW"), lambda: c["AX"] * _v(c, "BW") / 100.0, c["AX"] * DEFAULT_NFE_DIGEST_PCT / 100.0
)
c["BY"] = GE_CP * _v(c, "F") + GE_FAT * _v(c, "M") + GE_FIBER * _v(c, "K") + GE_NFE * c["AX"]
c["BZ"] = (
ME_FAT * c["BT"]
+ ME_FIBER * c["BV"]
+ ME_OR_RESIDUE * (c["BP"] - c["BT"] - c["BV"])
+ ME_CP * _v(c, "F")
)
c["CA"] = _if_pos(
c["BY"],
lambda: (NEL_BASE * (1.0 + NEL_Q_COEFF * (c["BZ"] / c["BY"] * 100.0 - NEL_Q_REF_PCT)) * c["BZ"]),
)
c["CF"] = _if_pos(
_v(c, "CE"),
lambda: _v(c, "F") * _v(c, "CE") / 100.0,
_v(c, "F") * DEFAULT_INSOLUBLE_PROTEIN_PCT / 100.0,
)
c["CG"] = _if_pos(_v(c, "D"), lambda: _v(c, "M") * 1000.0 / _v(c, "D"))
c["CH"] = _if_pos(_v(c, "D"), lambda: c["CF"] * 1000.0 / _v(c, "D"))
c["CI"] = _if_pos(_v(c, "D"), lambda: _v(c, "F") * 1000.0 / _v(c, "D"))
c["CJ"] = _if_pos(_v(c, "D"), lambda: c["BP"] / _v(c, "D"))
c["CK"] = _if_pos(_v(c, "D"), lambda: c["BT"] / _v(c, "D"))
c["CL"] = _if_pos(c["CI"], lambda: (187.7 - 115.4 * c["CH"] / c["CI"]) * c["CJ"] + 1.03 * c["CH"])
c["CM"] = _if_pos(c["CI"], lambda: (196.1 - 127.5 * c["CH"] / c["CI"]) * (c["CJ"] - c["CK"]) + 1.03 * c["CH"])
co = _v(c, "CO")
if c["CG"] < USP_FAT_THRESHOLD_G_PER_KG_DM + 0.01:
c["CN"] = c["CL"]
elif c["CG"] > USP_FAT_THRESHOLD_G_PER_KG_DM:
c["CN"] = c["CM"]
else:
c["CN"] = 0.0
if co < 1.01:
c["CP"] = c["CN"] * _v(c, "D") / 1000.0
elif co > 1.0:
c["CP"] = co
else:
c["CP"] = 0.0
c["CQ"] = (_v(c, "F") - c["CP"]) / 6.25
c["CX"] = _if_pos(
_v(c, "CT"),
lambda: _v(c, "F") * _v(c, "CT") / 100.0,
_v(c, "F") * DEFAULT_PROTEIN_FRACTION_PCT / 100.0,
)
nel = c["CA"]
c["CY"] = nel * _v(c, "CW")
f_val = _v(c, "F")
bo = _v(c, "BO")
if f_val == 0.0:
c["CZ"] = 0.0
c["DA"] = 0.0
c["DB"] = 0.0
c["DC"] = 0.0
c["DD"] = 0.0
c["DE"] = 0.0
else:
c["DA"] = c["CX"] * (_v(c, "AY") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.071 * 0.8
c["CZ"] = _if_pos(
bo,
lambda: c["CX"] * (_v(c, "AZ") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.018 * 0.8,
)
c["DB"] = _if_pos(
bo,
lambda: c["CX"] * (_v(c, "BA") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.044 * 0.8,
)
c["DC"] = _if_pos(
_v(c, "BD"),
lambda: c["CX"] * (_v(c, "BD") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.063 * 0.8,
)
c["DD"] = _if_pos(
_v(c, "BC"),
lambda: c["CX"] * (_v(c, "BC") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.049 * 0.8,
)
c["DE"] = _if_pos(
_v(c, "BE"),
lambda: c["CX"] * (_v(c, "BE") / 10.0) / (f_val / 10.0) * (bo / 100.0) + c["CY"] * 0.048 * 0.8,
)
for display, source in DISPLAY_SYNC:
c[display] = c[source]
return c
def derive_ingredient_nutrients(
data: dict[str, Any] | None,
*,
context: DeriveContext | None = None,
) -> dict[str, float]:
"""Полный набор показателей: входные + пересчитанные derived."""
cells = dict_to_cells(data)
return cells_to_dict(derive_cells(cells, context=context))
@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.lab.constants import NORM_COLUMN_ALIASES, RATION_QUALITY_INDICATORS
def merge_norms_from_profile(profile_data: Any, ration_type: str) -> dict[str, dict[str, float | None]]:
del ration_type
if not profile_data or not isinstance(profile_data, dict):
return {}
indicators = profile_data.get("indicators")
if isinstance(indicators, dict):
out: dict[str, dict[str, float | None]] = {}
for key, bounds in indicators.items():
if not isinstance(bounds, dict):
continue
out[str(key)] = {
"min": parse_num(bounds.get("min")),
"max": parse_num(bounds.get("max")),
}
return out
out = {}
for defn in RATION_QUALITY_INDICATORS:
key = defn["key"]
aliases = NORM_COLUMN_ALIASES.get(key)
if not aliases:
continue
for alias in aliases:
entry = profile_data.get(alias)
if isinstance(entry, dict):
out[key] = {
"min": parse_num(entry.get("min")),
"max": parse_num(entry.get("max")),
}
break
return out
@@ -0,0 +1,86 @@
"""Производные min/max норм из базовых показателей RACION."""
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, indicator_by_key
def _norm_min(bounds: dict[str, float | None] | None) -> float | None:
if not bounds:
return None
v = bounds.get("min")
return float(v) if v is not None else None
def _apply_derived_min(
norms: dict[str, dict[str, float | None]],
defn: dict[str, Any],
*,
mass_kg: float | None = None,
) -> float | None:
derived = defn.get("derived")
key = defn["key"]
if derived == "alias":
src = norms.get(defn.get("alias_of") or "")
if src and src.get("min") is not None:
return src["min"]
return None
if derived == "pct_of_dm":
src = _norm_min(norms.get(defn.get("from_key") or ""))
dm = _norm_min(norms.get("dry_matter"))
if src is None or not dm or dm <= 0:
return None
return src / dm * 100.0
if derived == "g_per_kg_dm":
src = _norm_min(norms.get(defn.get("from_key") or ""))
dm = _norm_min(norms.get("dry_matter"))
if src is None or not dm or dm <= 0:
return None
return src / (dm / 1000.0)
if derived == "nel_per_kg_dm":
dm = _norm_min(norms.get("dry_matter"))
nel = _norm_min(norms.get("nel"))
if not dm or dm <= 0 or nel is None:
return None
return nel / (dm / 1000.0)
if derived == "ratio":
num = _norm_min(norms.get(defn.get("ratio_num") or ""))
den = _norm_min(norms.get(defn.get("ratio_den") or ""))
if num is None or den is None or den == 0:
return None
return num / den
if derived == "dm_pct_bw":
dm = _norm_min(norms.get("dry_matter"))
if dm is None or not mass_kg or mass_kg <= 0:
return None
return (dm / 1000.0 / mass_kg) * 100.0
if derived == "ration_pct_bw":
return None
if derived in ("rnb", "bra_rnb"):
return _norm_min(norms.get(key))
return None
def apply_derived_norms(
norms: dict[str, dict[str, float | None]],
*,
mass_kg: float | None = None,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""Дополняет norms производными min; max не трогает."""
out = {k: dict(v) for k, v in norms.items()}
dynamic: dict[str, Any] = {}
for defn in RATION_ALL_INDICATORS:
if not defn.get("derived"):
continue
key = defn["key"]
if _norm_min(out.get(key)) is not None:
continue
val = _apply_derived_min(out, defn, mass_kg=mass_kg)
if val is None:
continue
rounded = round(val, 3)
out[key] = {"min": rounded, "max": out.get(key, {}).get("max")}
dynamic[key] = {"min": rounded, "derived": defn.get("derived")}
return out, dynamic
@@ -0,0 +1,192 @@
"""Роутер методик суточных норм: WESP / Москва / Петербург."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Literal
from app.modules.zootech.lab.calc.gfe_norms import apply_dynamic_norms
from app.modules.zootech.lab.calc.racion.moscow import MoscowDairyParams, resolve_moscow_dairy_norms
from app.modules.zootech.lab.calc.racion.piter import PiterDairyParams, PiterPrepError, resolve_piter_dairy_norms
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
NormsMethod = Literal["wesp", "racion_moscow", "racion_piter"]
VALID_NORMS_METHODS: frozenset[str] = frozenset({"wesp", "racion_moscow", "racion_piter"})
@dataclass
class NormsParams:
milk_fat_pct: float | None = None
lactation_no: int | None = None
lactation_stage: int | None = None
body_condition: int | None = None
housing_system: int | None = None
konc_oe_sv: float | None = None
@classmethod
def from_dict(cls, raw: dict[str, Any] | None) -> NormsParams:
if not raw:
return cls()
return cls(
milk_fat_pct=_flt(raw.get("milkFatPct", raw.get("milk_fat_pct"))),
lactation_no=_int(raw.get("lactationNo", raw.get("lactation_no"))),
lactation_stage=_int(raw.get("lactationStage", raw.get("lactation_stage"))),
body_condition=_int(raw.get("bodyCondition", raw.get("body_condition"))),
housing_system=_int(raw.get("housingSystem", raw.get("housing_system"))),
konc_oe_sv=_flt(raw.get("koncOeSv", raw.get("konc_oe_sv"))),
)
def _flt(v: Any) -> float | None:
if v is None or v == "":
return None
try:
return float(v)
except (TypeError, ValueError):
return None
def _int(v: Any) -> int | None:
if v is None or v == "":
return None
try:
return int(v)
except (TypeError, ValueError):
return None
def normalize_norms_method(method: str | None) -> NormsMethod:
m = (method or "wesp").strip().lower()
if m not in VALID_NORMS_METHODS:
return "wesp"
return m # type: ignore[return-value]
@dataclass
class NormsResolveRequest:
method: NormsMethod = "wesp"
stored: dict[str, dict[str, float | None]] = field(default_factory=dict)
mass_kg: float | None = None
milk_yield_kg: float | None = None
ration_type: str | None = None
force_dynamic: bool = False
params: NormsParams = field(default_factory=NormsParams)
def merge_hybrid_norms(
racion_resolved: dict[str, dict[str, float | None]],
stored: dict[str, dict[str, float | None]],
*,
dynamic_meta: dict[str, Any] | None = None,
) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
"""
RACION + derived имеют приоритет по min.
stored дополняет ключи без RACION-min; max всегда из stored, если задан.
"""
out: dict[str, dict[str, float | None]] = {}
coverage: dict[str, list[str]] = {
"racion": [],
"derived": [],
"fallback": [],
"missing": [],
}
dynamic = dynamic_meta or {}
all_keys = {d["key"] for d in RATION_ALL_INDICATORS}
all_keys.update(racion_resolved.keys())
all_keys.update(stored.keys())
for key in sorted(all_keys):
rac = racion_resolved.get(key) or {}
st = stored.get(key) or {}
rac_min = rac.get("min")
st_min = st.get("min")
st_max = st.get("max")
source: str | None = None
min_v: float | None = None
if rac_min is not None:
min_v = rac_min
src = (dynamic.get(key) or {}).get("source")
source = "derived" if src == "derived" else "racion"
elif st_min is not None:
min_v = st_min
source = "fallback"
max_v = st_max if st_max is not None else rac.get("max")
if min_v is not None or max_v is not None:
out[key] = {"min": min_v, "max": max_v}
if source == "racion":
coverage["racion"].append(key)
elif source == "derived":
coverage["derived"].append(key)
elif source == "fallback":
coverage["fallback"].append(key)
elif key in {d["key"] for d in RATION_ALL_INDICATORS}:
coverage["missing"].append(key)
coverage["withMin"] = len([k for k, b in out.items() if b.get("min") is not None])
coverage["total"] = len(RATION_ALL_INDICATORS)
return out, coverage
def _require_dairy_params(req: NormsResolveRequest) -> tuple[float, float]:
if req.mass_kg is None or req.mass_kg <= 0:
raise ValueError("Для методики Москва/Петербург укажите живую массу, кг")
if req.milk_yield_kg is None or req.milk_yield_kg <= 0:
raise ValueError("Для методики Москва/Петербург укажите суточный удой, кг")
return float(req.mass_kg), float(req.milk_yield_kg)
def _moscow_params(req: NormsResolveRequest) -> MoscowDairyParams:
mass, milk = _require_dairy_params(req)
p = req.params
return MoscowDairyParams(
mass_kg=mass,
milk_yield_kg=milk,
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
body_condition=p.body_condition if p.body_condition is not None else 1,
housing_system=p.housing_system if p.housing_system is not None else 1,
)
def _piter_params(req: NormsResolveRequest) -> PiterDairyParams:
mass, milk = _require_dairy_params(req)
p = req.params
konc = p.konc_oe_sv
if konc is None or konc <= 0:
raise ValueError("Для методики Петербург укажите концентрацию ОЭ/СВ, МДж/кг СВ")
return PiterDairyParams(
mass_kg=mass,
milk_yield_kg=milk,
milk_fat_pct=p.milk_fat_pct if p.milk_fat_pct is not None else 4.0,
lactation_no=p.lactation_no if p.lactation_no is not None else 2,
body_condition=p.body_condition if p.body_condition is not None else 1,
housing_system=p.housing_system if p.housing_system is not None else 1,
konc_oe_sv=float(konc),
)
def resolve_norms(req: NormsResolveRequest) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
method = normalize_norms_method(req.method)
if method == "wesp":
resolved, dynamic = apply_dynamic_norms(
req.stored,
mass_kg=req.mass_kg,
milk_yield_kg=req.milk_yield_kg,
ration_type=req.ration_type,
force_dynamic=req.force_dynamic,
)
return resolved, {"normsMethod": "wesp", "dynamicNorms": dynamic}
try:
if method == "racion_moscow":
racion, pack = resolve_moscow_dairy_norms(_moscow_params(req))
else:
racion, pack = resolve_piter_dairy_norms(_piter_params(req))
except PiterPrepError as exc:
raise ValueError(str(exc)) from exc
resolved, coverage = merge_hybrid_norms(racion, req.stored, dynamic_meta=pack.get("dynamic"))
return resolved, {"normsMethod": method, "coverage": coverage, **pack}
@@ -0,0 +1,135 @@
from __future__ import annotations
from typing import Any, Mapping
from app.modules.zootech.lab.nutrient_schema import resolve_sv_g_per_kg
def normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def parse_num(value: Any) -> float | None:
if value is None or value == "":
return None
try:
n = float(value)
except (TypeError, ValueError):
return None
return None if n != n else n
def get_nutrient_value(
dry_matter: float | None,
nutrients: Mapping[str, Any] | None,
nutrient_keys: list[str],
*,
indicator_key: str | None = None,
) -> float | None:
"""Только точное совпадение ключа (заголовок или indicator_key slug)."""
nutrients = nutrients or {}
if indicator_key:
for k, v in nutrients.items():
if normalize_key(str(k)) == normalize_key(indicator_key):
n = parse_num(v)
if n is not None:
return n
for search in nutrient_keys:
target = normalize_key(search)
for k, v in nutrients.items():
if normalize_key(str(k)) != target:
continue
n = parse_num(v)
if n is not None:
return n
if any(normalize_key(k) == "св" for k in nutrient_keys):
return resolve_sv_g_per_kg(nutrients, dry_matter)
return None
def weighted_average(
lines: list[dict[str, Any]],
total_kg: float,
nutrient_keys: list[str],
*,
indicator_key: str | None = None,
) -> float | None:
if total_kg <= 0:
return None
total = 0.0
weight = 0.0
for line in lines:
kg = float(line.get("daily_kg") or 0)
if kg <= 0:
continue
v = get_nutrient_value(
line.get("dry_matter"),
line.get("nutrients"),
nutrient_keys,
indicator_key=indicator_key,
)
if v is None:
continue
total += kg * v
weight += kg
if weight <= 0:
return None
return total / weight
def daily_intake_total(
lines: list[dict[str, Any]],
nutrient_keys: list[str],
*,
heads_per_trip: int = 1,
indicator_key: str | None = None,
) -> float | None:
"""Суточная доза на голову (г или МДж): Σ (кг/день/гол × г/кг). Как Excel Рацион КРС."""
heads = max(int(heads_per_trip or 1), 1)
total = 0.0
any_value = False
for line in lines:
herd_kg = float(line.get("daily_kg") or 0)
if herd_kg <= 0:
continue
kg = herd_kg / heads
v = get_nutrient_value(
line.get("dry_matter"),
line.get("nutrients"),
nutrient_keys,
indicator_key=indicator_key,
)
if v is None:
continue
total += kg * v
any_value = True
return total if any_value else None
def rnb(
crude_protein: float | None,
usp: float | None,
) -> float | None:
"""RNB (ruminal nitrogen balance, GfE): (сырой протеин − уСП) / 6,25."""
if crude_protein is None or usp is None:
return None
return (crude_protein - usp) / 6.25
def bra_rnb(crude_protein: float | None, usp: float | None) -> float | None:
"""Deprecated alias for :func:`rnb`."""
return rnb(crude_protein, usp)
def norm_diff(
content: float | None,
min_val: float | None,
max_val: float | None,
) -> float | None:
if content is None:
return None
if min_val is not None and content < min_val:
return content - min_val
if max_val is not None and content > max_val:
return content - max_val
return 0.0
@@ -0,0 +1 @@
"""Российские методики суточных норм (Москва / Петербург)."""
@@ -0,0 +1,25 @@
"""Линейная интерполяция (аналог FRAC в методичке)."""
from __future__ import annotations
def frac(numerator: float, denominator: float) -> float:
if denominator == 0:
return 0.0
return numerator / denominator
def lerp(x: float, x1: float, n1: float, x2: float, n2: float) -> float:
"""Norma = n1 + frac(n2 - n1, x2 - x1) * (x - x1)."""
if x2 == x1:
return n1
return n1 + frac(n2 - n1, x2 - x1) * (x - x1)
def popr_index(udoy: float, boundaries: list[float]) -> int:
"""1-based индекс столбца POPR_K по суточному удою."""
idx = 1
for i, bound in enumerate(boundaries, start=1):
if udoy >= bound:
idx = i + 1
return min(idx, len(boundaries) + 1)
@@ -0,0 +1,107 @@
"""Методика Москва — лактирующие коровы (NORM_1_1_CALC)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.modules.zootech.lab.calc.racion.interp import popr_index
from app.modules.zootech.lab.calc.norms_derived import apply_derived_norms
from app.modules.zootech.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
from app.modules.zootech.lab.calc.racion.tables import load_moskwa_lactir
@dataclass(frozen=True)
class MoscowDairyParams:
mass_kg: float
milk_yield_kg: float
milk_fat_pct: float = 4.0
lactation_no: int = 2
body_condition: int = 1
housing_system: int = 1
def _row_lookup(rows: list[dict], npitv: int) -> dict | None:
for row in rows:
if row["npitv"] == npitv and row["pom"] == 1:
return row
return None
def _popr_value(row: dict, udoy: float, boundaries: list[float]) -> tuple[float | None, float | None]:
popr = row.get("popr_k") or []
if not popr:
return None, None
idx = popr_index(udoy, boundaries) - 1
idx = max(0, min(idx, len(popr) - 1))
p_k = popr[idx]
koef = float(row.get("koef") or 0)
return p_k, koef
def compute_moscow_norm(npitv: int, params: MoscowDairyParams) -> float | None:
data = load_moskwa_lactir()
boundaries = data.get("udoy_boundaries") or []
row = _row_lookup(data.get("rows") or [], npitv)
if row is None:
return None
mass = params.mass_kg
udoy = params.milk_yield_kg
jir = params.milk_fat_pct
wmassa = mass * 1.02 if params.body_condition > 1 else mass
p_k, koef = _popr_value(row, udoy, boundaries)
if p_k is None:
return None
norma: float | None = None
if npitv == 1:
temp = 0.005 if udoy <= 22 else 0.0025
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 148
elif npitv == 2:
temp = 0.09 if udoy <= 22 else 0.065
norma = temp * (wmassa - 500) + p_k * udoy - ((4 - jir) * udoy) / 15
elif npitv == 3:
temp = 0.017 if udoy <= 22 else 0.015
norma = temp * (wmassa - 500) + p_k * udoy
elif 4 <= npitv <= 24:
norma = p_k * udoy + (wmassa - 500) * koef
if npitv == 10:
norma *= 0.393
else:
return None
if norma is None:
return None
if params.lactation_no == 1:
norma *= 0.95
elif params.lactation_no == 3:
norma *= 1.05
if params.housing_system == 2:
norma *= 1.1
return round(norma, 3)
def resolve_moscow_dairy_norms(params: MoscowDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
resolved: dict[str, dict[str, float | None]] = {}
dynamic: dict[str, Any] = {}
for npitv in DAIRY_LACTIR_NPITV:
value = compute_moscow_norm(npitv, params)
if value is None:
continue
key = NPITV_TO_INDICATOR.get(npitv)
if not key:
continue
resolved[key] = {"min": value, "max": None}
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_moscow", "source": "racion"}
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
for k, v in derived_dyn.items():
dynamic[k] = {**v, "method": "racion_moscow", "source": "derived"}
meta = {
"method": "racion_moscow",
"massKg": params.mass_kg,
"milkYieldKg": params.milk_yield_kg,
"milkFatPct": params.milk_fat_pct,
}
return resolved, {"meta": meta, "dynamic": dynamic}
@@ -0,0 +1,42 @@
"""NPitV (справочник питательных веществ) → ключи показателей WESP."""
from __future__ import annotations
# NORMY_MOSKWA_LACTIR / NORM_1_1_CALC: NPitV 124 (pom=1)
NPITV_TO_INDICATOR: dict[int, str] = {
1: "feed_units",
2: "oe",
3: "dry_matter",
4: "crude_protein",
5: "digestible_protein",
6: "crude_fat",
7: "nel",
8: "rnb",
9: "usp",
10: "sodium",
11: "magnesium",
12: "starch",
13: "potassium",
14: "calcium",
15: "phosphorus",
16: "iron",
17: "copper",
18: "zinc",
19: "manganese",
20: "cobalt",
21: "iodine",
22: "carotene",
23: "vitamin_d",
24: "vitamin_e",
}
DAIRY_LACTIR_NPITV: tuple[int, ...] = tuple(range(1, 25))
# Обратная совместимость
DAIRY_CORE_NPITV: tuple[int, ...] = (2, 3, 4, 5, 7, 8, 9, 10, 12, 14, 15)
INDICATOR_TO_NPITV: dict[str, int] = {v: k for k, v in NPITV_TO_INDICATOR.items()}
def indicator_for_npitv(npitv: int) -> str | None:
return NPITV_TO_INDICATOR.get(npitv)
@@ -0,0 +1,193 @@
"""Методика Петербург — лактирующие коровы (NORM_1_2_CALC)."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from app.modules.zootech.lab.calc.norms_derived import apply_derived_norms
from app.modules.zootech.lab.calc.racion.interp import lerp
from app.modules.zootech.lab.calc.racion.moscow import MoscowDairyParams
from app.modules.zootech.lab.calc.racion.npitv_map import DAIRY_LACTIR_NPITV, NPITV_TO_INDICATOR
from app.modules.zootech.lab.calc.racion.piter_prep import PiterPrepError, PiterPrepResult, prepare_piter_calc
from app.modules.zootech.lab.calc.racion.tables import load_piter_lactir
@dataclass(frozen=True)
class PiterDairyParams(MoscowDairyParams):
konc_oe_sv: float = 10.3
def _konc_bracket(konc: float, konc_values: list[float]) -> tuple[float, float]:
sorted_k = sorted(set(konc_values))
positive = [k for k in sorted_k if k > 0]
if not positive:
return konc, konc
if konc <= positive[0]:
return positive[0], positive[min(1, len(positive) - 1)]
for i in range(len(positive) - 1):
if positive[i] <= konc <= positive[i + 1]:
return positive[i], positive[i + 1]
return positive[-2], positive[-1]
def _udoy_bracket(udoy_jir: float, udoys: list[float]) -> tuple[float, float]:
sorted_u = sorted(set(udoys))
if len(sorted_u) < 2:
return sorted_u[0], sorted_u[0]
if udoy_jir <= sorted_u[0]:
return sorted_u[0], sorted_u[1]
for i in range(len(sorted_u) - 1):
if sorted_u[i] <= udoy_jir < sorted_u[i + 1]:
return sorted_u[i], sorted_u[i + 1]
return sorted_u[-2], sorted_u[-1]
def _norm_at_mass(
entry_normy: list[float],
mass_ind: int,
wmassa: float,
m_a: float,
m_b: float,
) -> float | None:
i = mass_ind - 1
if i < 0 or i + 1 >= len(entry_normy):
return None
n_a, n_b = entry_normy[i], entry_normy[i + 1]
if n_a <= 0 or n_b <= 0:
return None
return lerp(wmassa, m_a, n_a, m_b, n_b)
def _entries_for(data: dict, npitv: int) -> list[dict]:
npv = 4 if npitv == 5 else npitv
return [e for e in data.get("entries") or [] if e["npitv"] == npv]
def _compute_with_konc(
entries: list[dict],
npitv: int,
params: PiterDairyParams,
prep: PiterPrepResult,
konc_pred: float,
konc_sled: float,
) -> float | None:
by_konc = {k: [e for e in entries if abs(e["konc"] - k) < 1e-6] for k in (konc_pred, konc_sled)}
def _at_konc(konc: float) -> float | None:
rows = by_konc.get(konc) or []
if not rows:
return None
udoys = [e["udoy"] for e in rows]
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
if ud_pred == ud_sled:
return None
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
if not e_pred or not e_sled:
return None
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
if n01 is None or n02 is None:
return None
return lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
norma1 = _at_konc(konc_pred)
norma2 = _at_konc(konc_sled)
if norma1 is None or norma2 is None or konc_pred == konc_sled:
return None
norma = lerp(params.konc_oe_sv, konc_pred, norma1, konc_sled, norma2)
if npitv == 5:
norma *= 0.65
return round(norma, 3)
def _compute_konc_independent(
entries: list[dict],
params: PiterDairyParams,
prep: PiterPrepResult,
) -> float | None:
rows = [e for e in entries if e["konc"] == -10]
if not rows:
return None
udoys = [e["udoy"] for e in rows]
ud_pred, ud_sled = _udoy_bracket(prep.udoy_jir, udoys)
if ud_pred == ud_sled:
return None
e_pred = next((e for e in rows if e["udoy"] == ud_pred), None)
e_sled = next((e for e in rows if e["udoy"] == ud_sled), None)
if not e_pred or not e_sled:
return None
n01 = _norm_at_mass(e_pred["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
n02 = _norm_at_mass(e_sled["normy"], prep.mass_ind, prep.wmassa, prep.m_a, prep.m_b)
if n01 is None or n02 is None:
return None
norma = lerp(prep.udoy_jir, ud_pred, n01, ud_sled, n02)
if params.housing_system == 2:
norma *= 1.1
return round(norma, 3)
def compute_piter_norm(npitv: int, params: PiterDairyParams, prep: PiterPrepResult | None = None) -> float | None:
data = load_piter_lactir()
entries = _entries_for(data, npitv)
if not entries:
return None
if prep is None:
prep = prepare_piter_calc(
mass_kg=params.mass_kg,
milk_yield_kg=params.milk_yield_kg,
milk_fat_pct=params.milk_fat_pct,
konc_oe_sv=params.konc_oe_sv,
body_condition=params.body_condition,
)
konc_vals = sorted({e["konc"] for e in entries if e["konc"] > 0})
if konc_vals:
min_k = min(konc_vals)
if min_k > 0:
konc_pred, konc_sled = _konc_bracket(params.konc_oe_sv, konc_vals)
return _compute_with_konc(entries, npitv, params, prep, konc_pred, konc_sled)
return _compute_konc_independent(entries, params, prep)
def resolve_piter_dairy_norms(params: PiterDairyParams) -> tuple[dict[str, dict[str, float | None]], dict[str, Any]]:
prep = prepare_piter_calc(
mass_kg=params.mass_kg,
milk_yield_kg=params.milk_yield_kg,
milk_fat_pct=params.milk_fat_pct,
konc_oe_sv=params.konc_oe_sv,
body_condition=params.body_condition,
)
resolved: dict[str, dict[str, float | None]] = {}
dynamic: dict[str, Any] = {}
for npitv in DAIRY_LACTIR_NPITV:
value = compute_piter_norm(npitv, params, prep=prep)
if value is None:
continue
key = NPITV_TO_INDICATOR.get(npitv)
if not key:
continue
resolved[key] = {"min": value, "max": None}
dynamic[key] = {"min": value, "npitv": npitv, "method": "racion_piter", "source": "racion"}
resolved, derived_dyn = apply_derived_norms(resolved, mass_kg=params.mass_kg)
for k, v in derived_dyn.items():
dynamic[k] = {**v, "method": "racion_piter", "source": "derived"}
meta = {
"method": "racion_piter",
"massKg": params.mass_kg,
"milkYieldKg": params.milk_yield_kg,
"koncOeSv": params.konc_oe_sv,
"prep": {
"massInd": prep.mass_ind,
"mA": prep.m_a,
"mB": prep.m_b,
"koncPred": prep.konc_pred,
"koncSled": prep.konc_sled,
},
}
return resolved, {"meta": meta, "dynamic": dynamic}
__all__ = ["PiterDairyParams", "PiterPrepError", "compute_piter_norm", "resolve_piter_dairy_norms"]
@@ -0,0 +1,202 @@
"""Подготовка параметров NORM_1_2_PREP (интервалы массы и концентрации)."""
from __future__ import annotations
from dataclasses import dataclass
from app.modules.zootech.lab.calc.racion.interp import lerp
from app.modules.zootech.lab.calc.racion.tables import load_normy_info
@dataclass(frozen=True)
class PiterPrepResult:
mass_ind: int
konc_pred: float
konc_sled: float
m_a: float
m_b: float
udoy_jir: float
wmassa: float
class PiterPrepError(ValueError):
def __init__(self, code: int, message: str, *, info: str | None = None) -> None:
super().__init__(message)
self.code = code
self.info = info
def _info_values(nperem: int) -> list[float]:
data = load_normy_info()
for row in data.get("rows") or []:
if row.get("nperem") == nperem:
return list(row.get("znachenie") or [])
return []
def _fl_from_str(values: list[float], index: int) -> float | None:
"""1-based index как FlFromStr в RACION."""
i = index - 1
if i < 0 or i >= len(values):
return None
return float(values[i])
def _wmassa(mass_kg: float, body_condition: int) -> float:
if body_condition > 1:
return mass_kg * 1.02
return mass_kg
def prepare_piter_calc(
*,
mass_kg: float,
milk_yield_kg: float,
milk_fat_pct: float,
konc_oe_sv: float,
body_condition: int = 1,
) -> PiterPrepResult:
"""Порт NORM_1_2_PREP: интервалы для NORM_1_2_CALC."""
if milk_yield_kg <= 0:
raise PiterPrepError(-11, "Укажите суточный удой, кг")
if milk_fat_pct <= 0:
raise PiterPrepError(-12, "Укажите жирность молока, %")
if mass_kg <= 0:
raise PiterPrepError(-14, "Укажите живую массу, кг")
if konc_oe_sv <= 0:
raise PiterPrepError(-15, "Укажите концентрацию ОЭ/СВ, МДж/кг СВ")
wmassa = _wmassa(mass_kg, body_condition)
udoy_jir = milk_yield_kg * milk_fat_pct * 0.25
kol_konc_vals = _info_values(7)
kol_mass_vals = _info_values(7)
if len(kol_konc_vals) < 2 or len(kol_mass_vals) < 2:
raise PiterPrepError(-1, "Справочник NORMY_INFO (NPerem=7) не задан")
kol_konc = int(kol_konc_vals[0])
kol_mass = int(kol_mass_vals[1])
if kol_konc < 2 or kol_mass < 2:
raise PiterPrepError(-1, "Некорректные размеры таблицы концентраций/масс")
masses = _info_values(6) or _info_values(14)
koncss = _info_values(3)
if not masses or not koncss or len(masses) < 2 or len(koncss) < 2:
raise PiterPrepError(-1, "Справочник NORMY_INFO: массы или концентрации не заданы")
# Интервал концентрации
konc_ind = 1
for i in range(2, kol_konc):
v = _fl_from_str(koncss, i)
if v is not None and konc_oe_sv >= v:
konc_ind = i
konc_pred = _fl_from_str(koncss, konc_ind)
konc_sled = _fl_from_str(koncss, konc_ind + 1)
if konc_pred is None or konc_sled is None or konc_pred < 0 or konc_sled < 0 or konc_pred == konc_sled:
raise PiterPrepError(-1, "Не удалось определить интервал концентрации")
# Интервал массы
mass_ind = 1
for i in range(2, kol_mass):
v = _fl_from_str(masses, i)
if v is not None and wmassa >= v:
mass_ind = i
m_a = _fl_from_str(masses, mass_ind)
m_b = _fl_from_str(masses, mass_ind + 1)
if m_a is None or m_b is None or m_a < 0 or m_b < 0 or m_a == m_b:
raise PiterPrepError(-1, "Не удалось определить интервал массы")
# Проверка удоя (NPerem=4,5)
udoy_str_4 = _info_values(4)
udoy_str_5 = _info_values(5)
udoy_min = _fl_from_str(udoy_str_4, 1) if udoy_str_4 else None
udoy_max = _fl_from_str(udoy_str_5, kol_konc) if udoy_str_5 else None
if udoy_min is not None and udoy_max is not None:
if udoy_jir < udoy_min or udoy_jir > udoy_max:
jir_str = _info_values(2)
gr_udoy1 = max(udoy_min * 4 / milk_fat_pct, udoy_min)
gr_udoy2 = min(udoy_max * 4 / milk_fat_pct, udoy_max)
gr_jir1 = udoy_min * 4 / milk_yield_kg
gr_jir2 = udoy_max * 4 / milk_yield_kg
if jir_str:
if len(jir_str) >= 1:
gr_jir1 = max(gr_jir1, jir_str[0])
if len(jir_str) >= 2:
gr_jir2 = min(gr_jir2, jir_str[1])
code = -4 if udoy_jir < udoy_min else -5
info = f"{gr_udoy1:.4f};{gr_udoy2:.4f};{gr_jir1:.3f};{gr_jir2:.3f};"
raise PiterPrepError(code, "Удой вне допустимого диапазона для жирности", info=info)
# Допустимый диапазон концентрации для udoy_jir
i = 1
if udoy_jir >= (_fl_from_str(udoy_str_4, 1) or 0):
while i < kol_konc - 1:
nxt = _fl_from_str(udoy_str_4, i + 1)
if nxt is None or nxt > udoy_jir:
break
i += 1
else:
while i < kol_konc - 1:
nxt = _fl_from_str(udoy_str_4, i + 1)
cur = _fl_from_str(udoy_str_4, i)
if nxt is None or cur is None or nxt != cur:
break
i += 1
ud_a = _fl_from_str(udoy_str_4, i)
ud_b = _fl_from_str(udoy_str_4, i + 1)
konc_a = _fl_from_str(koncss, i)
konc_b = _fl_from_str(koncss, i + 1)
if ud_a is not None and ud_b is not None and konc_a is not None and konc_b is not None:
if ud_a == ud_b:
konc_max = konc_b
else:
konc_max = lerp(udoy_jir, ud_a, konc_a, ud_b, konc_b)
i = kol_konc
if udoy_jir <= (_fl_from_str(udoy_str_5, kol_konc) or udoy_jir):
while i > 2:
prev = _fl_from_str(udoy_str_5, i - 1)
if prev is None or prev < udoy_jir:
break
i -= 1
else:
while i > 2:
prev = _fl_from_str(udoy_str_5, i - 1)
last = _fl_from_str(udoy_str_5, kol_konc)
if prev is None or last is None or prev != last:
break
i -= 1
ud_a2 = _fl_from_str(udoy_str_5, i - 1)
ud_b2 = _fl_from_str(udoy_str_5, i)
konc_a2 = _fl_from_str(koncss, i - 1)
konc_b2 = _fl_from_str(koncss, i)
if ud_a2 is not None and ud_b2 is not None and konc_a2 is not None and konc_b2 is not None:
if ud_a2 == ud_b2:
konc_min = konc_b2
else:
konc_min = lerp(udoy_jir, ud_a2, konc_a2, ud_b2, konc_b2)
konc_lo = _fl_from_str(koncss, 1) or konc_min
konc_hi = _fl_from_str(koncss, kol_konc) or konc_max
if konc_min < konc_lo:
konc_min = konc_lo
if konc_max > konc_hi:
konc_max = konc_hi
if round(konc_oe_sv, 1) < round(konc_min, 1) or round(konc_oe_sv, 1) > round(konc_max, 1):
code = -2 if round(konc_oe_sv, 1) < round(konc_min, 1) else -3
info = f"{round(konc_min, 1)};{round(konc_max, 1)};"
raise PiterPrepError(
code,
f"Концентрация ОЭ/СВ вне допустимого диапазона ({round(konc_min, 1)}{round(konc_max, 1)})",
info=info,
)
return PiterPrepResult(
mass_ind=mass_ind,
konc_pred=float(konc_pred),
konc_sled=float(konc_sled),
m_a=float(m_a),
m_b=float(m_b),
udoy_jir=udoy_jir,
wmassa=wmassa,
)
@@ -0,0 +1,75 @@
"""Загрузка справочников норм RACION (БД → JSON fallback)."""
from __future__ import annotations
import json
from functools import lru_cache
from pathlib import Path
_SEED_DIR = Path(__file__).resolve().parents[4] / "data" / "seed" / "racion"
def _read_json(name: str) -> dict:
path = _SEED_DIR / name
if not path.exists():
raise FileNotFoundError(f"Справочник не найден: {path}")
return json.loads(path.read_text(encoding="utf-8"))
def _load_from_db(loader_name: str) -> dict | None:
try:
from flask import has_app_context
if not has_app_context():
return None
from app.modules.zootech.lab.services.racion_reference import (
load_moskwa_lactir_from_db,
load_normy_info_from_db,
load_piter_lactir_from_db,
)
loaders = {
"moskwa": load_moskwa_lactir_from_db,
"piter": load_piter_lactir_from_db,
"info": load_normy_info_from_db,
}
fn = loaders.get(loader_name)
if fn is None:
return None
data = fn()
return data if data else None
except Exception:
return None
@lru_cache(maxsize=1)
def load_moskwa_lactir() -> dict:
data = _load_from_db("moskwa")
if data:
return data
return _read_json("moskwa_lactir.json")
@lru_cache(maxsize=1)
def load_piter_lactir() -> dict:
data = _load_from_db("piter")
if data:
return data
return _read_json("piter_lactir.json")
@lru_cache(maxsize=1)
def load_normy_info() -> dict:
data = _load_from_db("info")
if data:
return data
try:
return _read_json("normy_info.json")
except FileNotFoundError:
return {"rows": [], "mass_kg_values": [400, 450, 500, 550, 600, 650, 700, 750]}
def clear_tables_cache() -> None:
load_moskwa_lactir.cache_clear()
load_piter_lactir.cache_clear()
load_normy_info.cache_clear()
@@ -0,0 +1,21 @@
from .apply_from_master import apply_from_master
from .ensure_empty_master import ensure_empty_master
from .recalculate import recalculate_ration
from .seed_demo_component_nutrients import seed_demo_component_nutrients_once
from .seed_from_execution import seed_from_execution
from .sync_from_execution import sync_from_execution
from .delete_profile import delete_animal_profile
from .upsert_profile import upsert_animal_profile
from .upsert_ration import upsert_ration
__all__ = [
"upsert_ration",
"recalculate_ration",
"apply_from_master",
"seed_demo_component_nutrients_once",
"seed_from_execution",
"ensure_empty_master",
"sync_from_execution",
"delete_animal_profile",
"upsert_animal_profile",
]
@@ -0,0 +1,76 @@
from __future__ import annotations
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.commands.write_audit import write_audit
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.loaders.execution_loader import load_execution
from app.modules.zootech.lab.loaders.ration_loader import load_ration
from app.modules.zootech.wesp_bridge_models import Component, Ingredient, Recipe
from app.modules.zootech.wesp_bridge_models import default_uuid
from app.modules.zootech.wesp_bridge_models import _enqueue_recipe_children_sync
@retry_locked
def apply_from_master(recipe_id: str, user_id: str = "system") -> dict:
snapshot = load_ration(recipe_id)
if not snapshot.exists:
raise LookupError("Мастер рациона не найден")
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
heads = int(recipe.heads_per_trip or 0)
if heads <= 0:
raise ValueError("Количество голов должно быть > 0")
execution = load_execution(recipe_id)
by_component = {str(l.component_id): l for l in execution.lines if l.component_id}
master_by_component = {
str(l.component_id): l
for l in snapshot.lines
if l.component_id and l.in_ration
}
touched_ingredient_ids: list[str] = []
for comp_id, master_line in master_by_component.items():
daily_kg = float(master_line.daily_kg or 0)
weight_per_head = daily_kg / heads
ing = by_component.get(comp_id)
comp = Component.query.get(comp_id)
dm_pct = float(comp.dry_matter or 0) if comp else float(master_line.dry_matter or 0)
if ing:
row = Ingredient.query.get(ing.ingredient_id)
if row is None:
continue
row.weight_per_head = weight_per_head
row.amount = weight_per_head
row.dry_matter = dm_pct
row.updated_by = user_id
touched_ingredient_ids.append(row.id)
else:
comp_name = master_line.ingredient_name or comp_id
new_ing = Ingredient(
id=default_uuid(),
recipe_id=recipe_id,
component_id=comp_id,
name=comp_name[:100],
amount=weight_per_head,
weight_per_head=weight_per_head,
dry_matter=dm_pct,
order=len(touched_ingredient_ids),
created_by=user_id,
updated_by=user_id,
)
db.session.add(new_ing)
touched_ingredient_ids.append(new_ing.id)
master_ids = set(master_by_component)
for line in execution.lines:
if line.component_id and str(line.component_id) not in master_ids:
row = Ingredient.query.get(line.ingredient_id)
if row and not row.is_deleted:
row.soft_delete(user_id)
_enqueue_recipe_children_sync(recipe_id)
write_audit("APPLY_FROM_MASTER", "lab_recipe_ration", recipe_id, user_id)
db.session.commit()
return {"recipeId": recipe_id, "ingredientsUpdated": len(touched_ingredient_ids)}
@@ -0,0 +1,202 @@
"""Аудит данных lab-модуля (профили, нормы, рационы) — без компонентов."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.constants import RATION_QUALITY_INDICATORS
from app.modules.zootech.lab.models import (
LabAnimalProfile,
LabProfileNorm,
LabRationCalcIndicator,
LabRationLine,
LabRecipeRation,
)
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
from app.modules.zootech.lab.services.component_nutrients import nutrients_full_dict
from app.modules.zootech.wesp_bridge_models import Component, Recipe
_OMD_KEYS = ("ВРХ Орг Вещ", "КРС Орг Вещ")
_log = logging.getLogger(__name__)
CALC_NORM_KEYS = tuple(d["key"] for d in RATION_QUALITY_INDICATORS)
@dataclass
class AuditIssue:
level: str # error | warn | info
area: str
message: str
@dataclass
class LabDataAuditReport:
issues: list[AuditIssue] = field(default_factory=list)
profiles: int = 0
profiles_with_norms: int = 0
profile_norms_beef: int = 0
profile_norms_dairy: int = 0
recipe_rations: int = 0
ration_lines: int = 0
calc_indicators: int = 0
components: int = 0
components_omd_missing: int = 0
def add(self, level: str, area: str, message: str) -> None:
self.issues.append(AuditIssue(level, area, message))
def ok(self) -> bool:
return not any(i.level == "error" for i in self.issues)
def _audit_components(report: LabDataAuditReport) -> None:
components = Component.query.filter_by(is_deleted=False).all()
report.components = len(components)
for comp in components:
full = nutrients_full_dict(comp.id)
if not full:
continue
feed_group = classify_feed_group(comp)
omd = read_from_mapping(full, _OMD_KEYS)
if feed_group in ("rough", "succulent") and omd is None:
report.components_omd_missing += 1
report.add(
"warn",
"component",
f"Компонент {comp.name!r} ({feed_group}): нет ВРХ Орг Вещ",
)
oe = read_from_mapping(full, ("ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм"))
nel = read_from_mapping(full, ("ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм"))
if (oe is not None and oe < 0) or (nel is not None and nel < 0):
report.add(
"warn",
"component",
f"Компонент {comp.name!r}: отрицательная энергия (ОЭ={oe}, ЧЭЛ={nel})",
)
def audit_lab_data(*, include_components: bool = False) -> LabDataAuditReport:
report = LabDataAuditReport()
profiles = LabAnimalProfile.query.filter_by(is_deleted=False).all()
report.profiles = len(profiles)
for profile in profiles:
norms = LabProfileNorm.query.filter_by(profile_id=profile.id).all()
norm_keys = {n.indicator_key for n in norms}
if norms:
report.profiles_with_norms += 1
else:
report.add("warn", "profile", f"Профиль {profile.profile_key!r} без норм (lab_profile_norm пуст)")
if profile.mass_kg is None:
report.add("warn", "profile", f"Профиль {profile.profile_key!r}: mass_kg не задан")
missing = [k for k in CALC_NORM_KEYS if k not in norm_keys]
if norms and missing:
report.add(
"info",
"profile",
f"Профиль {profile.profile_key!r}: нет ключей {', '.join(missing[:5])}"
+ ("" if len(missing) > 5 else ""),
)
for norm in norms:
if norm.min_value is None and norm.max_value is None:
report.add(
"warn",
"profile",
f"Профиль {profile.profile_key!r}: {norm.indicator_key} без min и max",
)
for ration_type in ("BEEF", "DAIRY"):
count = (
db.session.query(LabProfileNorm)
.join(LabAnimalProfile, LabAnimalProfile.id == LabProfileNorm.profile_id)
.filter(
LabAnimalProfile.ration_type == ration_type,
LabAnimalProfile.is_deleted.is_(False),
)
.count()
)
if ration_type == "BEEF":
report.profile_norms_beef = count
else:
report.profile_norms_dairy = count
if report.profile_norms_beef == 0 and report.profile_norms_dairy == 0:
report.add(
"warn",
"profile_norm",
"lab_profile_norm пуст — нормы не импортированы (scripts/import_seed.py --norms)",
)
headers = (
LabRecipeRation.query.filter_by(is_deleted=False)
.join(Recipe, Recipe.id == LabRecipeRation.recipe_id)
.filter(Recipe.is_deleted.is_(False))
.all()
)
report.recipe_rations = len(headers)
for header in headers:
recipe = Recipe.query.get(header.recipe_id)
name = recipe.name if recipe else header.recipe_id
if not header.animal_profile_id:
report.add("warn", "ration", f"Рецепт {name!r}: нет animal_profile_id")
elif LabAnimalProfile.query.get(header.animal_profile_id) is None:
report.add("error", "ration", f"Рецепт {name!r}: профиль {header.animal_profile_id} не найден")
lines = LabRationLine.query.filter_by(
recipe_id=header.recipe_id, is_deleted=False
).all()
active = [ln for ln in lines if ln.in_ration and (ln.daily_kg or 0) > 0]
if not active:
report.add("info", "ration", f"Рецепт {name!r}: нет активных строк рациона")
for line in active:
if not line.component_id:
report.add("warn", "ration_line", f"Рецепт {name!r}, строка {line.row_index}: нет component_id")
if line.daily_kg is None:
report.add("warn", "ration_line", f"Рецепт {name!r}, строка {line.row_index}: daily_kg пуст")
if header.calculated_at and not LabRationCalcIndicator.query.filter_by(
recipe_id=header.recipe_id
).first():
report.add("warn", "calc", f"Рецепт {name!r}: calculated_at есть, lab_ration_calc_indicator пуст")
report.ration_lines = LabRationLine.query.filter_by(is_deleted=False).count()
report.calc_indicators = LabRationCalcIndicator.query.count()
if report.profiles == 0:
report.add("warn", "profile", "Нет профилей стада (lab_animal_profile)")
if include_components:
_audit_components(report)
_log.info(
"lab audit: profiles=%s with_norms=%s rations=%s issues=%s",
report.profiles,
report.profiles_with_norms,
report.recipe_rations,
len(report.issues),
)
return report
def format_audit_report(report: LabDataAuditReport) -> str:
lines = [
"=== Lab data audit ===",
f"Профили: {report.profiles} (с нормами: {report.profiles_with_norms})",
f"lab_profile_norm: BEEF={report.profile_norms_beef}, DAIRY={report.profile_norms_dairy}",
f"lab_recipe_ration: {report.recipe_rations}, строк: {report.ration_lines}",
f"lab_ration_calc_indicator: {report.calc_indicators}",
f"components: {report.components} (без ВРХ rough/succulent: {report.components_omd_missing})",
f"Замечаний: {len(report.issues)}",
]
for issue in report.issues:
lines.append(f" [{issue.level}] {issue.area}: {issue.message}")
return "\n".join(lines)
@@ -0,0 +1,84 @@
"""Удаление legacy/fp_* профилей стада (одноразовая ops-команда)."""
from __future__ import annotations
import logging
import re
from dataclasses import dataclass, field
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.models import LabAnimalProfile, LabProfileNorm, LabRecipeRation
_log = logging.getLogger(__name__)
_LEGACY_KEY = re.compile(r"^(dairy|beef)-\d+$", re.IGNORECASE)
_FP_KEY = re.compile(r"^fp_", re.IGNORECASE)
@dataclass
class CleanupReport:
dry_run: bool = True
profiles_to_delete: list[str] = field(default_factory=list)
recipe_ids_cleared: list[str] = field(default_factory=list)
norms_deleted: int = 0
profiles_deleted: int = 0
errors: list[str] = field(default_factory=list)
def _should_delete(profile_key: str | None) -> bool:
key = (profile_key or "").strip()
if not key:
return False
if key.startswith("lab_math_"):
return False
if _LEGACY_KEY.match(key):
return True
if _FP_KEY.match(key):
return True
return False
def cleanup_legacy_profiles(*, dry_run: bool = True, user_id: str = "system") -> CleanupReport:
report = CleanupReport(dry_run=dry_run)
profiles = LabAnimalProfile.query.filter_by(is_deleted=False).all()
to_delete = [p for p in profiles if _should_delete(p.profile_key)]
for profile in to_delete:
report.profiles_to_delete.append(profile.profile_key or profile.id)
norms_count = LabProfileNorm.query.filter_by(profile_id=profile.id).count()
report.norms_deleted += norms_count
rations = LabRecipeRation.query.filter_by(animal_profile_id=profile.id, is_deleted=False).all()
for header in rations:
if header.recipe_id not in report.recipe_ids_cleared:
report.recipe_ids_cleared.append(header.recipe_id)
if dry_run:
continue
LabProfileNorm.query.filter_by(profile_id=profile.id).delete(synchronize_session=False)
for header in rations:
header.animal_profile_id = None
header.updated_by = user_id
if hasattr(profile, "soft_delete"):
profile.soft_delete(user_id)
else:
db.session.delete(profile)
report.profiles_deleted += 1
if not dry_run:
db.session.commit()
_log.info(
"cleanup_legacy_profiles user=%s deleted=%s rations_cleared=%s",
user_id,
report.profiles_deleted,
len(report.recipe_ids_cleared),
)
else:
_log.info(
"cleanup_legacy_profiles dry_run profiles=%s rations=%s",
len(report.profiles_to_delete),
len(report.recipe_ids_cleared),
)
return report
@@ -0,0 +1,30 @@
from __future__ import annotations
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.models import (
LabAnimalProfile,
LabProfileNorm,
LabRecipeRation,
)
@retry_locked
def delete_animal_profile(profile_id: str, user_id: str = "system") -> dict:
pid = (profile_id or "").strip()
if not pid:
raise LookupError("Профиль не найден")
profile = LabAnimalProfile.query.filter_by(id=pid, is_deleted=False).first()
if profile is None:
raise LookupError("Профиль не найден")
LabProfileNorm.query.filter_by(profile_id=profile.id).delete(synchronize_session=False)
rations = LabRecipeRation.query.filter_by(animal_profile_id=profile.id, is_deleted=False).all()
for header in rations:
header.animal_profile_id = None
header.updated_by = user_id
profile.updated_by = user_id
profile.soft_delete(user_id)
db.session.commit()
return {"id": profile.id, "deleted": True}
@@ -0,0 +1,20 @@
from __future__ import annotations
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.models import LabRecipeRation
from app.modules.zootech.wesp_bridge_models import Recipe
@retry_locked
def ensure_empty_master(recipe_id: str, user_id: str = "system") -> dict:
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
existing = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
if existing is not None:
return {"recipeId": recipe_id, "created": False}
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
db.session.commit()
return {"recipeId": recipe_id, "created": True}
@@ -0,0 +1,299 @@
"""Generic ETL: CSV из data/seed/ → lab profiles и component nutrients."""
from __future__ import annotations
import csv
import logging
from dataclasses import dataclass, field
from pathlib import Path
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.ingredient_catalog import INGREDIENT_HEADERS
from app.modules.zootech.lab.calc.ingredient_derive import derive_ingredient_nutrients
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.norm_catalog import norm_header, norm_title_to_key
from app.modules.zootech.lab.seed_paths import norms_dir, nutrients_dir
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
from app.modules.zootech.lab.services.profile_norms import save_norms_from_payload
from app.modules.zootech.wesp_bridge_models import default_uuid
from app.modules.zootech.wesp_bridge_models import Component
_log = logging.getLogger(__name__)
_SKIP_NUTRIENT_HEADERS = frozenset({"", "Наименование", "Цена 1 кг"})
@dataclass
class NormsImportStats:
profiles_created: int = 0
profiles_updated: int = 0
norm_rows: int = 0
skipped: int = 0
unmapped_headers: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
@dataclass
class NutrientsImportStats:
imported: int = 0
skipped: int = 0
unmatched: int = 0
errors: list[str] = field(default_factory=list)
def _parse_min_max_groups(h1: list[str], h2: list[str]) -> list[tuple[str, int, int | None]]:
groups: list[tuple[str, int, int | None]] = []
current_name: str | None = None
limit = min(len(h1), len(h2))
for i in range(limit):
b = (h2[i] or "").strip().lower().replace("і", "и")
n = (h1[i] or "").strip()
if n and n not in ("", "Наименование", "Масса", "КРС"):
current_name = norm_header(n)
if b.startswith("мин") and current_name:
max_i = i + 1 if i + 1 < limit and "мах" in (h2[i + 1] or "").lower() else None
groups.append((current_name, i, max_i))
return groups
def norm_profile_key(ration_type: str, external_no: int) -> str:
return f"norm_{ration_type.lower()}_{external_no:04d}"
def parse_norm_profile_row(
row: list[str],
groups: list[tuple[str, int, int | None]],
ration_type: str,
*,
unmapped: set[str],
profile_key_fn=norm_profile_key,
) -> dict | None:
if len(row) < 2 or not (row[1] or "").strip():
return None
try:
external_no = int(float((row[0] or "").strip()))
except (ValueError, TypeError):
return None
if external_no <= 0:
return None
indicators: dict[str, dict[str, float | None]] = {}
for header, min_i, max_i in groups:
key = norm_title_to_key(header)
if not key:
unmapped.add(header)
continue
def _num(idx: int | None) -> float | None:
if idx is None or idx >= len(row):
return None
raw = (row[idx] or "").strip()
if not raw:
return None
try:
return float(raw)
except ValueError:
return None
min_v, max_v = _num(min_i), _num(max_i)
if min_v is None and max_v is None:
continue
indicators[key] = {"min": min_v, "max": max_v}
mass_kg = None
if len(row) > 2 and (row[2] or "").strip():
try:
mass_kg = float(row[2])
except ValueError:
pass
return {
"external_no": external_no,
"profile_key": profile_key_fn(ration_type, external_no),
"label": row[1].strip(),
"ration_type": ration_type,
"mass_kg": mass_kg,
"indicators": indicators,
}
_IMPORT_BATCH_SIZE = 50
def _lookup_reference_profile(profile_key: str) -> LabAnimalProfile | None:
"""Поиск по profile_key включая soft-deleted (UNIQUE на всю таблицу)."""
return LabAnimalProfile.query.filter_by(profile_key=profile_key).first()
def _restore_reference_profile(profile: LabAnimalProfile) -> None:
if not profile.is_deleted:
return
profile.is_deleted = False
profile.deleted_at = None
profile.deleted_by = None
def _import_norm_sheet(
csv_path: Path,
ration_type: str,
stats: NormsImportStats,
unmapped: set[str],
*,
replace_reference: bool = True,
) -> None:
if not csv_path.is_file():
stats.errors.append(f"Файл не найден: {csv_path}")
return
with csv_path.open(encoding="utf-8") as f:
rows = list(csv.reader(f))
if len(rows) < 8:
stats.errors.append(f"Слишком мало строк: {csv_path}")
return
groups = _parse_min_max_groups(rows[2], rows[3])
batch_count = 0
for row in rows[6:]:
parsed = parse_norm_profile_row(row, groups, ration_type, unmapped=unmapped)
if parsed is None:
stats.skipped += 1
continue
profile = _lookup_reference_profile(parsed["profile_key"])
if profile is not None:
if not replace_reference and not profile.is_deleted:
stats.skipped += 1
continue
if not replace_reference and profile.is_deleted:
stats.skipped += 1
continue
_restore_reference_profile(profile)
stats.profiles_updated += 1
else:
profile = LabAnimalProfile(
id=default_uuid(),
profile_key=parsed["profile_key"],
created_by="seed-import",
)
db.session.add(profile)
stats.profiles_created += 1
profile.label = parsed["label"]
profile.ration_type = ration_type
profile.external_no = parsed["external_no"]
save_norms_from_payload(
profile,
{
"massKg": parsed["mass_kg"],
"externalNo": parsed["external_no"],
"indicators": parsed["indicators"],
},
)
stats.norm_rows += len(parsed["indicators"])
batch_count += 1
if batch_count >= _IMPORT_BATCH_SIZE:
db.session.commit()
batch_count = 0
def import_norm_profiles(
csv_dir: Path | None = None,
*,
replace_reference: bool = True,
) -> NormsImportStats:
stats = NormsImportStats()
base = csv_dir or norms_dir()
unmapped: set[str] = set()
_import_norm_sheet(
base / "Нормы КРС.csv", "BEEF", stats, unmapped, replace_reference=replace_reference
)
_import_norm_sheet(
base / "Нормы Дойн.csv", "DAIRY", stats, unmapped, replace_reference=replace_reference
)
stats.unmapped_headers = sorted(unmapped)
db.session.commit()
_log.info(
"seed norms import: created=%s updated=%s norm_rows=%s",
stats.profiles_created,
stats.profiles_updated,
stats.norm_rows,
)
return stats
def _parse_nutrient_row(row: list[str]) -> dict[str, float]:
nutrients: dict[str, float] = {}
for i, header in enumerate(INGREDIENT_HEADERS):
if header in _SKIP_NUTRIENT_HEADERS or i >= len(row):
continue
raw = (row[i] or "").strip()
if not raw:
continue
try:
nutrients[header] = float(raw)
except ValueError:
continue
return nutrients
def _find_component(name: str, external_no: int | None) -> Component | None:
if external_no is not None:
hit = Component.query.filter_by(external_no=external_no, is_deleted=False).first()
if hit:
return hit
if name:
hit = Component.query.filter(Component.name == name, Component.is_deleted.is_(False)).first()
if hit:
return hit
return None
def import_component_nutrients(
csv_path: Path | None = None,
*,
derive: bool = True,
dry_run: bool = False,
) -> NutrientsImportStats:
path = csv_path or (nutrients_dir() / "База сырья.csv")
stats = NutrientsImportStats()
if not path.is_file():
stats.errors.append(f"CSV not found: {path}")
return stats
with path.open(encoding="utf-8") as f:
rows = list(csv.reader(f))
for row in rows[5:]:
name = row[1].strip() if len(row) > 1 else ""
if not name:
stats.skipped += 1
continue
try:
external_no = int(float(row[0])) if row[0].strip() else None
except (ValueError, IndexError):
external_no = None
comp = _find_component(name, external_no)
if comp is None:
stats.unmatched += 1
continue
nutrients = _parse_nutrient_row(row)
if derive:
nutrients = derive_ingredient_nutrients(nutrients)
if not dry_run:
save_component_nutrients(comp.id, nutrients, user_id="seed-import")
stats.imported += 1
if not dry_run:
db.session.commit()
_log.info(
"seed nutrients import: imported=%s skipped=%s unmatched=%s",
stats.imported,
stats.skipped,
stats.unmatched,
)
return stats
@@ -0,0 +1,69 @@
from __future__ import annotations
import time
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.engine import calculate_ration
from app.modules.zootech.lab.commands.write_audit import write_audit, write_calculation_run
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.commands.seed_demo_component_nutrients import (
ensure_component_nutrients_if_empty,
supplement_component_nutrients_if_sparse,
)
from app.modules.zootech.lab.loaders.ration_loader import load_ration, ration_to_calc_lines
from app.modules.zootech.lab.models import LabAnimalProfile, LabRecipeRation
from app.modules.zootech.lab.services.ration_calc_store import save_calc_result
@retry_locked
def recalculate_ration(recipe_id: str, user_id: str = "system") -> dict:
snapshot = load_ration(recipe_id)
if not snapshot.lines:
raise ValueError("Пустой мастер — добавьте строки рациона")
seeded_any = False
for line in snapshot.lines:
if not line.component_id:
continue
if ensure_component_nutrients_if_empty(line.component_id, user_id=user_id):
seeded_any = True
elif supplement_component_nutrients_if_sparse(line.component_id, user_id=user_id):
seeded_any = True
if seeded_any:
db.session.commit()
snapshot = load_ration(recipe_id)
profile_mass_kg = None
if snapshot.animal_profile_id:
prof = LabAnimalProfile.query.get(snapshot.animal_profile_id)
if prof is not None:
profile_mass_kg = prof.mass_kg
started = time.perf_counter()
result = calculate_ration(
snapshot.ration_type,
ration_to_calc_lines(snapshot),
snapshot.norms,
heads_per_trip=snapshot.heads_per_trip,
profile_mass_kg=profile_mass_kg,
)
duration_ms = int((time.perf_counter() - started) * 1000)
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id).first()
if header is None:
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
save_calc_result(recipe_id, result, header)
header.updated_by = user_id
status = "FAILED" if result.get("errors") else "COMPLETED"
write_calculation_run(
recipe_id,
status,
{"indicators": result.get("indicators", [])},
duration_ms=duration_ms,
error_message="; ".join(result.get("errors") or []) or None,
)
write_audit("RECALCULATE", "lab_recipe_ration", recipe_id, user_id)
db.session.commit()
return result
@@ -0,0 +1,343 @@
"""Стартовые nutrients для компонентов без EAV (демо-шаблоны, не лабораторный анализ).
При старте и пересчёте рациона заполняет только пустые `lab_component_nutrient_value`.
Маркер в data/ — журнал последнего прогона, не блокирует новые компоненты.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.nutrient_schema import read_from_mapping
from app.modules.zootech.lab.services.component_nutrients import nutrients_full_dict, nutrients_is_empty, save_component_nutrients
from app.modules.zootech.wesp_bridge_models import Component
_log = logging.getLogger(__name__)
_MARKER_NAME = ".lab_demo_nutrients_seeded"
# Доп. поля для полной матрицы рациона (демо; заменить лабораторными при наличии).
_MINERAL_SUPPLEMENT: dict[str, float] = {
"Ca": 8.0,
"P": 5.5,
"Mg": 3.2,
"Na": 1.2,
"K": 6.5,
"DCAB Форм": 45.0,
"Сырая зола": 55.0,
"Каротин": 5.0,
}
_CARB_SUPPLEMENT: dict[str, float] = {
"Сахар": 80.0,
"Крахмал": 280.0,
"Сахар и Крохм": 420.0,
"Нераств. Крохмал": 35.0,
}
_ROUGHAGE_TYPES = frozenset({"объемные корма", "грубые корма", "сочные корма"})
_SUPPLEMENT_KEYS: tuple[str, ...] = tuple({**_MINERAL_SUPPLEMENT, **_CARB_SUPPLEMENT}.keys())
_NAMED_SEEDS: dict[str, dict[str, Any]] = {
"комбикорм": {
"dry_matter": 88.0,
"nutrients": {
"Сыр. Протеин": 170,
"уСП": 142,
"БРА": 36,
"ОЭ-КРС": 12.5,
"ЧЭЛ- КРС": 7.8,
"Сырая клетчатка": 95,
"Структур. клетчатка": 28,
"Сырой жир": 38,
},
},
"жом свекловичный гранулы": {
"dry_matter": 90.0,
"nutrients": {
"Сыр. Протеин": 105,
"уСП": 88,
"ОЭ-КРС": 11.8,
"ЧЭЛ- КРС": 7.2,
"Сырая клетчатка": 220,
"Структур. клетчатка": 48,
"Сырой жир": 12,
},
},
"жом": {
"dry_matter": 90.0,
"nutrients": {
"Сыр. Протеин": 105,
"уСП": 88,
"ОЭ-КРС": 11.8,
"ЧЭЛ- КРС": 7.2,
"Сырая клетчатка": 220,
"Структур. клетчатка": 48,
"Сырой жир": 12,
},
},
"дробина": {
"dry_matter": 91.0,
"nutrients": {
"Сыр. Протеин": 100,
"уСП": 88,
"ОЭ-КРС": 11.5,
"ЧЭЛ- КРС": 7.0,
"Сырая клетчатка": 200,
"Структур. клетчатка": 40,
"Сырой жир": 12,
},
},
"добавки": {
"dry_matter": 99.0,
"nutrients": {
"Сыр. Протеин": 8,
"ОЭ-КРС": 2.0,
"ЧЭЛ- КРС": 1.2,
"Сырая клетчатка": 5,
"Сырой жир": 3,
},
},
"сено": {
"dry_matter": 85.0,
"nutrients": {
"Сыр. Протеин": 78,
"уСП": 62,
"ОЭ-КРС": 5.9,
"ЧЭЛ- КРС": 3.4,
"Сырая клетчатка": 328,
"Структур. клетчатка": 265,
"Сырой жир": 22,
},
},
"анионовые соли": {
"dry_matter": 95.0,
"nutrients": {
"Сыр. Протеин": 12,
"ОЭ-КРС": 1.2,
"ЧЭЛ- КРС": 0.8,
"Сырая клетчатка": 8,
"Сырой жир": 2,
},
},
}
_TYPE_DEFAULTS: dict[str, dict[str, Any]] = {
"зерновые": {
"dry_matter": 88.0,
"nutrients": {
"Сыр. Протеин": 120,
"уСП": 105,
"БРА": 28,
"ОЭ-КРС": 11.0,
"ЧЭЛ- КРС": 6.8,
"Сырая клетчатка": 85,
"Структур. клетчатка": 22,
"Сырой жир": 35,
},
},
"белковые": {
"dry_matter": 90.0,
"nutrients": {
"Сыр. Протеин": 380,
"уСП": 300,
"БРА": 80,
"ОЭ-КРС": 11.5,
"ЧЭЛ- КРС": 7.0,
"Сырая клетчатка": 120,
"Сырой жир": 45,
},
},
"минеральные": {
"dry_matter": 95.0,
"nutrients": {
"Сыр. Протеин": 12,
"ОЭ-КРС": 1.2,
"ЧЭЛ- КРС": 0.8,
"Сырая клетчатка": 8,
"Сырой жир": 2,
},
},
"витаминные": {
"dry_matter": 99.0,
"nutrients": {
"Сыр. Протеин": 8,
"ОЭ-КРС": 2.0,
"ЧЭЛ- КРС": 1.2,
"Сырая клетчатка": 5,
"Сырой жир": 3,
},
},
"энергетические": {
"dry_matter": 90.0,
"nutrients": {
"Сыр. Протеин": 100,
"уСП": 88,
"ОЭ-КРС": 11.5,
"ЧЭЛ- КРС": 7.0,
"Сырая клетчатка": 200,
"Структур. клетчатка": 40,
"Сырой жир": 12,
},
},
"объемные корма": {
"dry_matter": 85.0,
"nutrients": {
"Сыр. Протеин": 78,
"уСП": 62,
"ОЭ-КРС": 5.9,
"ЧЭЛ- КРС": 3.4,
"Сырая клетчатка": 328,
"Структур. клетчатка": 265,
"Сырой жир": 22,
},
},
}
_FALLBACK = {
"dry_matter": 88.0,
"nutrients": {
"Сыр. Протеин": 100,
"уСП": 85,
"ОЭ-КРС": 10.0,
"ЧЭЛ- КРС": 6.2,
"Сырая клетчатка": 150,
"Сырой жир": 25,
},
}
def _normalize_name(name: str | None) -> str:
return (name or "").strip().lower()
def _merge_supplement(nutrients: dict[str, Any], component: Component) -> dict[str, float]:
merged = dict(_MINERAL_SUPPLEMENT)
type_key = _normalize_name(component.type)
if type_key not in _ROUGHAGE_TYPES:
merged.update(_CARB_SUPPLEMENT)
merged.update(nutrients)
return {k: float(v) for k, v in merged.items() if v is not None}
def _payload_for_component(component: Component) -> dict[str, Any]:
name_key = _normalize_name(component.name)
if name_key in _NAMED_SEEDS:
payload = _NAMED_SEEDS[name_key]
else:
payload = None
for key in sorted(_NAMED_SEEDS, key=len, reverse=True):
if key in name_key:
payload = _NAMED_SEEDS[key]
break
if payload is None:
type_key = _normalize_name(component.type)
payload = _TYPE_DEFAULTS.get(type_key, _FALLBACK)
return {
"dry_matter": payload.get("dry_matter"),
"nutrients": _merge_supplement(payload.get("nutrients") or {}, component),
}
def _apply_demo_payload(component: Component, payload: dict[str, Any], *, user_id: str) -> None:
if payload.get("dry_matter") is not None:
dm = float(payload["dry_matter"])
current_dm = float(component.dry_matter or 0)
if dm > 0 and (current_dm <= 0 or current_dm > 100):
component.dry_matter = dm
save_component_nutrients(
component.id,
payload["nutrients"],
user_id=user_id,
)
component.protein = 0.0
component.energy = 0.0
component.updated_by = "demo-nutrients-seed"
def supplement_component_nutrients_if_sparse(
component_id: str | None,
*,
user_id: str = "system",
) -> bool:
"""Добавляет Ca/P/сахара и т.д., если EAV уже есть, но без минералов."""
if not component_id or nutrients_is_empty(component_id):
return False
component = Component.query.filter_by(id=component_id, is_deleted=False).first()
if component is None:
return False
full = nutrients_full_dict(component_id)
template = _payload_for_component(component)["nutrients"]
allowed = set(_MINERAL_SUPPLEMENT)
if _normalize_name(component.type) not in _ROUGHAGE_TYPES:
allowed.update(_CARB_SUPPLEMENT)
missing = {
key: float(template[key])
for key in allowed
if key in template and read_from_mapping(full, (key,)) is None
}
if not missing:
return False
save_component_nutrients(component_id, missing, user_id=user_id, pin=True)
return True
def ensure_component_nutrients_if_empty(
component_id: str | None,
*,
user_id: str = "system",
) -> bool:
"""Заполняет EAV демо-шаблоном, если у компонента нет nutrients. Возвращает True при записи."""
if not component_id or not nutrients_is_empty(component_id):
return False
component = Component.query.filter_by(id=component_id, is_deleted=False).first()
if component is None:
return False
_apply_demo_payload(component, _payload_for_component(component), user_id=user_id)
return True
def _marker_path() -> Path:
return Path(__file__).resolve().parents[4] / "data" / _MARKER_NAME
def _already_seeded() -> bool:
return _marker_path().is_file()
def _mark_seeded() -> None:
path = _marker_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("1\n", encoding="utf-8")
def seed_demo_component_nutrients_once(*, user_id: str = "system", force: bool = False) -> dict:
"""Заполняет lab_component_nutrient_value у всех компонентов с пустой матрицей."""
updated = 0
skipped = 0
for component in Component.query.filter_by(is_deleted=False).all():
if not force and not nutrients_is_empty(component.id):
skipped += 1
continue
if force and not nutrients_is_empty(component.id):
skipped += 1
continue
_apply_demo_payload(component, _payload_for_component(component), user_id=user_id)
updated += 1
if updated:
db.session.commit()
_mark_seeded()
_log.info(
"demo component nutrients seed user=%s updated=%s skipped=%s",
user_id,
updated,
skipped,
)
return {"seeded": True, "updated": updated, "skipped": skipped}
return {"seeded": False, "reason": "nothing_to_update", "updated": 0, "skipped": skipped}
@@ -0,0 +1,40 @@
from __future__ import annotations
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.loaders.execution_loader import load_execution
from app.modules.zootech.lab.models import LabRationLine, LabRecipeRation
from app.modules.zootech.wesp_bridge_models import Component
from app.modules.zootech.wesp_bridge_models import default_uuid
@retry_locked
def seed_from_execution(recipe_id: str, user_id: str = "system") -> dict:
existing = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
if existing is not None:
return {"recipeId": recipe_id, "seeded": False, "reason": "already_exists"}
execution = load_execution(recipe_id)
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
for idx, line in enumerate(execution.lines):
comp = Component.query.get(line.component_id) if line.component_id else None
daily_kg = line.daily_kg_total
db.session.add(
LabRationLine(
id=default_uuid(),
recipe_id=recipe_id,
component_id=line.component_id,
ingredient_name=line.name,
row_index=idx,
daily_kg=daily_kg,
in_ration=True,
in_compound=False,
created_by=user_id,
updated_by=user_id,
)
)
header.seed_source = "execution"
db.session.commit()
return {"recipeId": recipe_id, "seeded": True, "lines": len(execution.lines)}
@@ -0,0 +1,358 @@
"""Тестовые рецепты LAB: полная матрица показателей + рацион «в нормах»."""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.commands.recalculate import recalculate_ration
from app.modules.zootech.lab.models import LabAnimalProfile, LabRationLine, LabRecipeRation
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
from app.modules.zootech.wesp_bridge_models import Component, Ingredient, Recipe
from app.modules.zootech.wesp_bridge_models import default_uuid
_log = logging.getLogger(__name__)
PROFILE_KEY = "lab_math_dairy_01"
HEADS = 10
_MARKER = ".lab_math_ration_seeded"
RECIPE_MATRIX = "LAB тест — полная матрица"
RECIPE_IN_NORMS = "LAB тест — в нормах"
# Реалистичные г/кг (и МДж для энергии) — покрывают расширенный каталог indicators.py
_COMPONENTS: tuple[dict[str, Any], ...] = (
{
"name": "LAB тест — сено",
"type": "Грубые корма",
"dry_matter": 85.0,
"price": 12.0,
"nutrients": {
"СВ": 850,
"Осн.Корм": 850,
"Сыр. Протеин": 78,
"уСП": 62,
"ОЭ-КРС": 5.9,
"ЧЭЛ- КРС": 3.4,
"Сырая клетч": 328,
"НДК": 328,
"КДК": 210,
"Структур клетч": 265,
"Сырой жир": 22,
"Ca": 3.5,
"P": 2.1,
"Mg": 1.8,
"Na": 0.5,
"K": 18,
"DCAB Форм": 120,
"Сахар и Крохм": 120,
"Сахар": 40,
"Крахмал": 15,
"Нераств. Крохмал": 5,
"Каротин": 25,
"Сырая зола": 65,
"БЕР": 95,
"Переварим Протеин": 55,
"КРС Протеин": 68,
"Переварим сырая клетч": 195,
"Fe": 40,
"Zn": 22,
"Cu": 6,
"Mn": 35,
"Se": 0.04,
"J": 0.02,
"Вит А": 12000,
"Вит D": 800,
"Вит Е": 30,
"Вит В1": 2.5,
"Вит В2": 4.0,
"Вит В6": 3.0,
"Вит В12": 0.0,
"Лизин": 2.8,
"Метаб лизин": 1.2,
"% уСП/кг СВ": 7.3,
"Нер. СП / кг СВ": 11,
},
},
{
"name": "LAB тест — комбикорм",
"type": "Концентрированные",
"dry_matter": 88.0,
"price": 22.0,
"nutrients": {
"СВ": 880,
"Осн.Корм": 0,
"Сыр. Протеин": 170,
"уСП": 142,
"ОЭ-КРС": 12.5,
"ЧЭЛ- КРС": 7.8,
"Сырая клетч": 95,
"НДК": 95,
"КДК": 42,
"Структур клетч": 28,
"Сырой жир": 38,
"Ca": 8.0,
"P": 5.5,
"Mg": 3.2,
"Na": 1.2,
"K": 6.5,
"DCAB Форм": 45,
"Сахар и Крохм": 420,
"Сахар": 80,
"Крахмал": 280,
"Нераств. Крохмал": 35,
"Каротин": 5,
"Сырая зола": 55,
"БЕР": 520,
"Переварим Протеин": 125,
"КРС Протеин": 155,
"Fe": 85,
"Zn": 55,
"Cu": 12,
"Mn": 28,
"Se": 0.12,
"Вит А": 2500,
"Вит D": 400,
"Вит Е": 45,
"Вит В1": 5.5,
"Вит В2": 6.0,
"Лизин": 8.5,
"Метаб лизин": 4.2,
"% уСП/кг СВ": 16.1,
"Нер. СП / кг СВ": 28,
},
},
{
"name": "LAB тест — силос",
"type": "Сочные корма",
"dry_matter": 34.0,
"price": 4.0,
"nutrients": {
"СВ": 340,
"Осн.Корм": 0,
"Сыр. Протеин": 32,
"уСП": 28,
"ОЭ-КРС": 6.2,
"ЧЭЛ- КРС": 3.8,
"Сырая клетч": 220,
"НДК": 220,
"КДК": 125,
"Структур клетч": 45,
"Сырой жир": 28,
"Ca": 4.5,
"P": 2.8,
"Mg": 1.5,
"Na": 0.3,
"K": 12,
"DCAB Форм": 95,
"Сахар и Крохм": 180,
"Сахар": 60,
"Крахмал": 45,
"Нераств. Крохмал": 12,
"Каротин": 18,
"Сырая зола": 38,
"БЕР": 145,
"Переварим Протеин": 22,
"КРС Протеин": 28,
"Fe": 25,
"Zn": 18,
"Cu": 4,
"Mn": 15,
"Вит А": 3500,
"Вит Е": 18,
"Лизин": 1.1,
"% уСП/кг СВ": 8.2,
},
},
)
_RECIPE_SPECS: tuple[dict[str, Any], ...] = (
{
"name": RECIPE_MATRIX,
"profile_key": PROFILE_KEY,
"description": "перегруз для проверки красных отклонений",
"lines": (
("LAB тест — сено", 5.0),
("LAB тест — комбикорм", 12.0),
("LAB тест — силос", 8.0),
),
},
{
"name": RECIPE_IN_NORMS,
"profile_key": PROFILE_KEY,
"description": "поддержание 300 кг — большинство норм в зелёной зоне",
"lines": (
("LAB тест — сено", 4.5),
("LAB тест — комбикорм", 1.0),
("LAB тест — силос", 1.8),
),
},
)
@dataclass
class LabMathRationStats:
recipe_ids: list[str] = field(default_factory=list)
created: int = 0
updated: int = 0
recalculated: int = 0
errors: list[str] = field(default_factory=list)
@property
def recipe_id(self) -> str | None:
return self.recipe_ids[0] if self.recipe_ids else None
def _marker_path() -> Path:
return Path(__file__).resolve().parents[4] / "data" / _MARKER
def _get_or_create_component(spec: dict[str, Any]) -> Component:
comp = Component.query.filter_by(name=spec["name"], is_deleted=False).first()
if comp is None:
comp = Component(
id=default_uuid(),
name=spec["name"],
type=spec["type"],
dry_matter=float(spec["dry_matter"]),
protein=0.0,
energy=0.0,
price=float(spec.get("price", 0)),
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
db.session.add(comp)
db.session.flush()
else:
comp.dry_matter = float(spec["dry_matter"])
comp.price = float(spec.get("price", comp.price or 0))
comp.updated_by = "lab-math-ration"
nutrients = {k: float(v) for k, v in spec["nutrients"].items()}
save_component_nutrients(comp.id, nutrients, user_id="lab-math-ration", pin=True)
return comp
def _seed_one_recipe(
spec: dict[str, Any],
*,
by_name: dict[str, Component],
profile: LabAnimalProfile,
stats: LabMathRationStats,
) -> str:
recipe = Recipe.query.filter_by(name=spec["name"], is_deleted=False).first()
if recipe is None:
recipe = Recipe(
id=default_uuid(),
name=spec["name"],
heads_per_trip=HEADS,
ration_type="DAIRY",
mixing_time=0,
trip_percent=100.0,
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
db.session.add(recipe)
stats.created += 1
db.session.flush()
else:
recipe.heads_per_trip = HEADS
recipe.ration_type = "DAIRY"
stats.updated += 1
for ing in Ingredient.query.filter_by(recipe_id=recipe.id, is_deleted=False).all():
ing.soft_delete("lab-math-ration")
for order, (cname, wph) in enumerate(spec["lines"]):
comp = by_name[cname]
db.session.add(
Ingredient(
id=default_uuid(),
recipe_id=recipe.id,
component_id=comp.id,
name=comp.name,
amount=wph * HEADS,
weight_per_head=wph,
dry_matter=comp.dry_matter,
order=order,
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
)
header = LabRecipeRation.query.filter_by(recipe_id=recipe.id, is_deleted=False).first()
if header is None:
header = LabRecipeRation(
recipe_id=recipe.id,
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
db.session.add(header)
header.animal_profile_id = profile.id
header.seed_source = "lab_math_test"
for line in LabRationLine.query.filter_by(recipe_id=recipe.id, is_deleted=False).all():
line.soft_delete("lab-math-ration")
for idx, (cname, wph) in enumerate(spec["lines"]):
comp = by_name[cname]
db.session.add(
LabRationLine(
id=default_uuid(),
recipe_id=recipe.id,
component_id=comp.id,
ingredient_name=comp.name,
row_index=idx,
daily_kg=wph * HEADS,
in_ration=True,
in_compound=False,
created_by="lab-math-ration",
updated_by="lab-math-ration",
)
)
return recipe.id
def seed_lab_math_ration(*, force: bool = False, run_calc: bool = True) -> LabMathRationStats:
stats = LabMathRationStats()
if not force and _marker_path().is_file():
for spec in _RECIPE_SPECS:
existing = Recipe.query.filter_by(name=spec["name"], is_deleted=False).first()
if existing:
stats.recipe_ids.append(existing.id)
if stats.recipe_ids:
stats.updated = len(stats.recipe_ids)
return stats
profile = LabAnimalProfile.query.filter_by(profile_key=PROFILE_KEY, is_deleted=False).first()
if profile is None:
stats.errors.append(
f"Профиль {PROFILE_KEY} не найден — запустите scripts/seed_lab_math_profiles.py"
)
return stats
components = [_get_or_create_component(spec) for spec in _COMPONENTS]
by_name = {c.name: c for c in components}
for spec in _RECIPE_SPECS:
rid = _seed_one_recipe(spec, by_name=by_name, profile=profile, stats=stats)
stats.recipe_ids.append(rid)
db.session.commit()
if run_calc:
for rid in stats.recipe_ids:
try:
recalculate_ration(rid, "lab-math-ration")
stats.recalculated += 1
except Exception as exc:
stats.errors.append(f"пересчёт {rid}: {exc}")
_marker_path().parent.mkdir(parents=True, exist_ok=True)
_marker_path().write_text("\n".join(stats.recipe_ids) + "\n", encoding="utf-8")
_log.info(
"lab math rations: ids=%s matrix+in_norms heads=%s",
stats.recipe_ids,
HEADS,
)
return stats
@@ -0,0 +1,90 @@
"""5 профилей норм для проверки математики рациона (строки 1–5 из data/seed/norms)."""
from __future__ import annotations
import csv
import logging
from dataclasses import dataclass, field
from pathlib import Path
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.commands.import_seed import _parse_min_max_groups, parse_norm_profile_row
from app.modules.zootech.lab.seed_paths import norms_dir
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.services.profile_norms import save_norms_from_payload
from app.modules.zootech.wesp_bridge_models import default_uuid
_log = logging.getLogger(__name__)
_MATH_SPECS: tuple[tuple[str, str, int, str], ...] = (
("DAIRY", "Нормы Дойн.csv", 1, "lab_math_dairy_01"),
("DAIRY", "Нормы Дойн.csv", 2, "lab_math_dairy_02"),
("DAIRY", "Нормы Дойн.csv", 3, "lab_math_dairy_03"),
("DAIRY", "Нормы Дойн.csv", 4, "lab_math_dairy_04"),
("DAIRY", "Нормы Дойн.csv", 5, "lab_math_dairy_05"),
)
@dataclass
class MathTestSeedStats:
created: int = 0
updated: int = 0
errors: list[str] = field(default_factory=list)
def _load_csv_row(csv_path: Path, external_no: int, ration_type: str) -> dict | None:
with csv_path.open(encoding="utf-8") as f:
rows = list(csv.reader(f))
if len(rows) < 8:
return None
groups = _parse_min_max_groups(rows[2], rows[3])
data_row = rows[5 + external_no]
eno = external_no
return parse_norm_profile_row(
data_row,
groups,
ration_type,
unmapped=set(),
profile_key_fn=lambda rt, _ext: f"lab_math_{rt.lower()}_{eno:02d}",
)
def seed_math_test_profiles(*, csv_dir: Path | None = None) -> MathTestSeedStats:
stats = MathTestSeedStats()
base = csv_dir or norms_dir()
for ration_type, filename, external_no, profile_key in _MATH_SPECS:
parsed = _load_csv_row(base / filename, external_no, ration_type)
if parsed is None:
stats.errors.append(f"Строка {external_no} не найдена в {filename}")
continue
profile = LabAnimalProfile.query.filter_by(profile_key=profile_key, is_deleted=False).first()
if profile is None:
profile = LabAnimalProfile(
id=default_uuid(),
profile_key=profile_key,
created_by="lab-math-test-seed",
)
db.session.add(profile)
stats.created += 1
else:
stats.updated += 1
profile.label = f"TEST математика #{external_no}{parsed['label'][:48]}"
profile.ration_type = ration_type
profile.external_no = parsed["external_no"]
parsed["indicators"]["rnb"] = {"min": -10.0, "max": 20.0}
save_norms_from_payload(
profile,
{
"massKg": parsed["mass_kg"],
"externalNo": parsed["external_no"],
"indicators": parsed["indicators"],
},
)
db.session.commit()
_log.info("lab math test profiles: created=%s updated=%s", stats.created, stats.updated)
return stats
@@ -0,0 +1,49 @@
from __future__ import annotations
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.commands.write_audit import write_audit
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.loaders.execution_loader import load_execution
from app.modules.zootech.lab.models import LabRationLine, LabRecipeRation
from app.modules.zootech.wesp_bridge_models import Component
from app.modules.zootech.wesp_bridge_models import default_uuid
@retry_locked
def sync_from_execution(recipe_id: str, user_id: str = "system") -> dict:
"""Обновить мастер из execution (обратный перенос /recipes → lab)."""
execution = load_execution(recipe_id)
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
if header is None:
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
else:
header.updated_by = user_id
existing = LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False).all()
for line in existing:
line.soft_delete(user_id)
created = 0
for idx, line in enumerate(execution.lines):
comp = Component.query.get(line.component_id) if line.component_id else None
db.session.add(
LabRationLine(
id=default_uuid(),
recipe_id=recipe_id,
component_id=line.component_id,
ingredient_name=line.name or (comp.name if comp else None),
row_index=idx,
daily_kg=line.daily_kg_total,
in_ration=True,
in_compound=False,
created_by=user_id,
updated_by=user_id,
)
)
created += 1
header.seed_source = "synced_from"
write_audit("SYNC_FROM_EXECUTION", "lab_recipe_ration", recipe_id, user_id)
db.session.commit()
return {"recipeId": recipe_id, "lines": created}
@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.lab.calc.norms_resolver import normalize_norms_method
from app.modules.zootech.lab.services.norms_params import save_norms_params
from app.modules.zootech.lab.services.profile_norms import save_norms_from_payload, sync_racion_norms_to_profile
from app.modules.zootech.wesp_bridge_models import default_uuid
@retry_locked
def upsert_animal_profile(profile_id: str | None, payload: dict[str, Any], user_id: str = "system") -> dict:
pid = (profile_id or payload.get("id") or "").strip() or default_uuid()
profile = LabAnimalProfile.query.filter_by(id=pid, is_deleted=False).first()
is_new = profile is None
if is_new:
profile = LabAnimalProfile(id=pid, created_by=user_id)
db.session.add(profile)
key = (payload.get("profileKey") or payload.get("profile_key") or "").strip()
label = (payload.get("label") or "").strip()
ration_type = (payload.get("rationType") or payload.get("ration_type") or "BEEF").strip().upper()
if not key or not label:
raise ValueError("profileKey и label обязательны")
profile.profile_key = key
profile.label = label
profile.ration_type = ration_type
if "normsMethod" in payload or "norms_method" in payload:
profile.norms_method = normalize_norms_method(
payload.get("normsMethod", payload.get("norms_method"))
)
if "normsParams" in payload or "norms_params" in payload:
raw_params = payload.get("normsParams", payload.get("norms_params"))
save_norms_params(profile, raw_params if isinstance(raw_params, dict) else None)
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
profile.milk_yield_kg = parse_num(payload.get("milkYieldKg", payload.get("milk_yield_kg")))
norms_payload: dict[str, Any] = {}
if "normsData" in payload or "norms_data" in payload:
raw = payload.get("normsData") if "normsData" in payload else payload.get("norms_data")
if isinstance(raw, dict):
norms_payload = dict(raw)
if payload.get("massKg") is not None or payload.get("mass_kg") is not None:
norms_payload["massKg"] = payload.get("massKg", payload.get("mass_kg"))
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
norms_payload["milkYieldKg"] = payload.get("milkYieldKg", payload.get("milk_yield_kg"))
if payload.get("externalNo") is not None or payload.get("external_no") is not None:
norms_payload["externalNo"] = payload.get("externalNo", payload.get("external_no"))
if isinstance(payload.get("indicators"), dict):
norms_payload["indicators"] = payload["indicators"]
if norms_payload:
save_norms_from_payload(profile, norms_payload)
profile.updated_by = user_id
sync_racion_norms_to_profile(profile)
db.session.commit()
return {"id": profile.id, "profileKey": profile.profile_key, "label": profile.label}
@@ -0,0 +1,65 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.db_guard import retry_locked
from app.modules.zootech.lab.models import LabRationLine, LabRecipeRation
from app.modules.zootech.wesp_bridge_models import Recipe
from app.modules.zootech.wesp_bridge_models import default_uuid
@retry_locked
def upsert_ration(recipe_id: str, payload: dict[str, Any], user_id: str = "system") -> dict[str, Any]:
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id).first()
if header is None:
header = LabRecipeRation(recipe_id=recipe_id, created_by=user_id, updated_by=user_id)
db.session.add(header)
header.animal_profile_id = payload.get("animalProfileId") or payload.get("animal_profile_id")
if "params" in payload:
params = payload.get("params") or {}
if isinstance(params, dict):
seeded = params.get("seeded_from") or params.get("synced_from")
if seeded:
header.seed_source = str(seeded)
if "rationType" in payload or "ration_type" in payload:
recipe.ration_type = (payload.get("rationType") or payload.get("ration_type") or "").upper() or None
header.updated_by = user_id
incoming = payload.get("lines") or []
existing = {
str(l.id): l
for l in LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False).all()
}
seen_ids: set[str] = set()
for idx, row in enumerate(incoming):
line_id = str(row.get("id") or "")
line = existing.get(line_id) if line_id else None
if line is None:
line = LabRationLine(
id=default_uuid(),
recipe_id=recipe_id,
created_by=user_id,
updated_by=user_id,
)
db.session.add(line)
line.row_index = int(row.get("rowIndex", row.get("row_index", idx)))
line.component_id = row.get("componentId") or row.get("component_id")
line.ingredient_name = row.get("ingredientName") or row.get("ingredient_name")
line.daily_kg = row.get("dailyKg", row.get("daily_kg"))
line.in_ration = bool(row.get("inRation", row.get("in_ration", True)))
line.in_compound = bool(row.get("inCompound", row.get("in_compound", False)))
line.updated_by = user_id
if line.id:
seen_ids.add(str(line.id))
for lid, line in existing.items():
if lid not in seen_ids:
line.soft_delete(user_id)
db.session.commit()
return {"recipeId": recipe_id, "ok": True}
@@ -0,0 +1,43 @@
from __future__ import annotations
import json
import logging
from typing import Any
_log = logging.getLogger("app.lab.audit")
def write_audit(
action: str,
entity_type: str,
entity_id: str | None,
user_id: str | None = None,
metadata: dict[str, Any] | None = None,
) -> None:
_log.info(
"lab action=%s entity=%s/%s user=%s metadata=%s",
action,
entity_type,
entity_id,
user_id or "system",
json.dumps(metadata or {}, ensure_ascii=False),
)
def write_calculation_run(
recipe_id: str,
status: str,
kpi_results: dict[str, Any],
*,
duration_ms: int | None = None,
error_message: str | None = None,
) -> None:
_log.info(
"lab calculation recipe=%s status=%s duration_ms=%s engine=%s error=%s kpi=%s",
recipe_id,
status,
duration_ms,
"native-1",
error_message or "",
json.dumps(kpi_results, ensure_ascii=False),
)
@@ -0,0 +1,28 @@
"""Lab ration calc constants (ported from tab @neoton/shared)."""
from __future__ import annotations
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, RATION_QUALITY_INDICATORS
RATION_TOTAL_KEYS = {
"DAIRY": [
{"key": "total_kg", "label": "Итого кг/день"},
{"key": "ration_pct_sum", "label": "Сумма % в рационе"},
],
"BEEF": [
{"key": "total_kg", "label": "Итого кг/день"},
{"key": "ration_kg", "label": "Сумма «из рациона», кг"},
{"key": "ration_pct_sum", "label": "Сумма % в рационе"},
{"key": "cost_total", "label": "Стоимость рациона (сумма)"},
],
}
NORM_COLUMN_ALIASES = {
"dry_matter": ["Сухое Вещество", "Сухое вещество"],
"crude_protein": ["Сыр. Протеин"],
"usp": ["уСП"],
"oe": ["ОЭ-КРС", " ОЭ-КРС"],
"nel": ["ЧЭЛ- КРС", " ЧЭЛ- КРС"],
}
DIFF_TOLERANCE_KG = 0.05
@@ -0,0 +1,28 @@
from __future__ import annotations
import time
from functools import wraps
from typing import Any, Callable, TypeVar
from sqlalchemy.exc import OperationalError
F = TypeVar("F", bound=Callable[..., Any])
def retry_locked(fn: F, *, attempts: int = 3, delay: float = 0.15) -> F:
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
last_exc: Exception | None = None
for i in range(attempts):
try:
return fn(*args, **kwargs)
except OperationalError as exc:
if "locked" not in str(exc).lower():
raise
last_exc = exc
time.sleep(delay * (i + 1))
if last_exc:
raise last_exc
return None
return wrapper # type: ignore[return-value]
@@ -0,0 +1,3 @@
from .ration import ExecutionSnapshot, RationSnapshot
__all__ = ["RationSnapshot", "ExecutionSnapshot"]
@@ -0,0 +1,52 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass(frozen=True)
class RationLineSnapshot:
id: str | None
component_id: str | None
ingredient_name: str | None
row_index: int
daily_kg: float | None
in_ration: bool
in_compound: bool
dry_matter: float | None
price_per_kg: float | None
nutrients: dict[str, Any]
@dataclass(frozen=True)
class RationSnapshot:
recipe_id: str
recipe_name: str
ration_type: str
heads_per_trip: int
animal_profile_id: str | None
params: dict[str, Any]
lines: tuple[RationLineSnapshot, ...] = field(default_factory=tuple)
norms: dict[str, dict[str, float | None]] = field(default_factory=dict)
ration_results: dict[str, Any] = field(default_factory=dict)
compound_results: dict[str, Any] = field(default_factory=dict)
calculated_at: str | None = None
exists: bool = True
norms_profile_key: str | None = None
legacy_norms_remapped: bool = False
@dataclass(frozen=True)
class ExecutionLineSnapshot:
ingredient_id: str
component_id: str | None
name: str
weight_per_head: float
daily_kg_total: float
@dataclass(frozen=True)
class ExecutionSnapshot:
recipe_id: str
heads_per_trip: int
lines: tuple[ExecutionLineSnapshot, ...] = field(default_factory=tuple)
@@ -0,0 +1 @@
"""Offline ETL from tab reference PostgreSQL."""
@@ -0,0 +1,88 @@
"""AgroStar PDF (печатная форма анализа) → AgrostarParseResult."""
from __future__ import annotations
import re
from typing import Any
from app.modules.zootech.lab.etl.agrostar_tabular import (
parse_tabular_triplet_lines,
rows_to_label_values,
sample_from_agro_fields,
tabular_rows_to_agro_fields,
)
from app.modules.zootech.lab.etl.agrostar_xml_import import AgrostarParseResult
def _pdf_text(data: bytes) -> str:
try:
import fitz # pymupdf
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
parts = [doc[i].get_text() for i in range(doc.page_count)]
doc.close()
return "\n".join(parts)
def _line_after(lines: list[str], marker: str) -> str:
for i, line in enumerate(lines):
if line == marker and i + 1 < len(lines):
return lines[i + 1]
return ""
def _is_agrostar_pdf(text: str) -> bool:
t = text or ""
return "ИНФОРМАЦИЯ ОБ ОБРАЗЦЕ" in t or "РЕЗУЛЬТАТЫ АНАЛИЗА" in t
def parse_agrostar_pdf(data: bytes) -> AgrostarParseResult:
result = AgrostarParseResult(lab_name="АгроСтар")
try:
text = _pdf_text(data)
except RuntimeError as exc:
result.errors.append(str(exc))
return result
if not _is_agrostar_pdf(text):
result.errors.append("Не похоже на отчёт AgroStar (PDF)")
return result
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
sample_no = _line_after(lines, "Образец №")
farm_name = _line_after(lines, "Клиент")
date_printed = _line_after(lines, "Дата анализа")
desc = _line_after(lines, "Описание")
label_parts: list[str] = []
if "Образец" in lines:
idx = lines.index("Образец")
if idx + 1 < len(lines) and lines[idx + 1] != "Описание":
label_parts.append(lines[idx + 1])
if idx + 2 < len(lines) and lines[idx + 2] not in ("Описание", "Клиент"):
label_parts.append(lines[idx + 2])
triplets = parse_tabular_triplet_lines(lines)
agro_fields = tabular_rows_to_agro_fields(rows_to_label_values(triplets))
if not agro_fields:
result.errors.append("В PDF не найдены показатели анализа")
return result
sample = sample_from_agro_fields(
sample_no=sample_no,
farm_name=farm_name,
date_printed=date_printed,
desc_1=desc or " ".join(label_parts),
agro_fields=agro_fields,
)
result.samples.append(sample)
return result
def looks_like_agrostar_pdf(data: bytes) -> bool:
try:
text = _pdf_text(data)
except RuntimeError:
return False
return _is_agrostar_pdf(text)
@@ -0,0 +1,129 @@
"""AgroStar табличные форматы (PDF, xlsx) → те же AgrostarSample, что и XML."""
from __future__ import annotations
import re
from typing import Any
from app.modules.zootech.lab.etl.agrostar_xml_import import (
AgrostarParseResult,
AgrostarSample,
_parse_float,
_pct_dm_to_g_per_kg_sv,
finalize_agrostar_sample,
)
# Подпись в отчёте → ключ AgroStar XML (дальше — общий _AGROSTAR_MAP)
_TABULAR_LABEL_TO_AGRO: dict[str, str] = {
"Сухое вещество (DM)": "DM",
"Сырой протеин (Crude Protein)": "CP",
"КДК (ADF)": "ADF",
"НДК (aNDF)": "NDF",
"НДК по орг. веществу (aNDFom)": "aNDFom",
"Сырой жир (Fat EE)": "Fat_EE",
"Сырая зола (Ash)": "Ash",
"Кальций (Ca)": "Ca",
"Фосфор (P)": "P",
"Магний (Mg)": "Mg",
"Калий (K)": "K",
"Сера (S)": "S",
"Хлор (Cl)": "Cl",
"Водорастворимый сахар (WSC)": "Sugar_WSC",
"Cпирторастворимый сахар (ESC)": "Sugar_ESC",
"Крахмал (Starch)": "Starch",
"Безволокнистые углеводы (NFC)": "NFC",
"Переваримые питательные вещества (TDN)": "TDN",
"НДК протеин (ND-ICP)": "NDICP_CP",
"Лизин": "Lys",
"Метионин": "Met",
"Лейцин": "Leu",
"Изолейцин": "Ile",
"Валин": "Val",
"Усваиваемость НДК 30 ч (NDFD30)": "NDFDom_IV_30hr",
}
_SECTION_HEADERS = frozenset(
{
"Протеин:",
"Клетчатка:",
"Сахара и крахмал:",
"Жир и жирные кислоты:",
"Минералы:",
"Энергия:",
}
)
def map_tabular_label(label: str) -> str | None:
text = (label or "").strip()
if not text or text in _SECTION_HEADERS:
return None
if text in _TABULAR_LABEL_TO_AGRO:
return _TABULAR_LABEL_TO_AGRO[text]
lowered = text.lower()
for key, ag_key in sorted(_TABULAR_LABEL_TO_AGRO.items(), key=lambda item: len(item[0]), reverse=True):
if key.lower() in lowered:
return ag_key
return None
def tabular_rows_to_agro_fields(rows: list[tuple[str, str]]) -> dict[str, float]:
"""[(label, raw_value), ...] → raw_fields AgroStar."""
out: dict[str, float] = {}
for label, raw in rows:
ag_key = map_tabular_label(label)
if not ag_key:
continue
val = _parse_float(raw)
if val is not None:
out[ag_key] = val
return out
def sample_from_agro_fields(
*,
sample_no: str,
farm_name: str,
farm_id: str = "",
date_printed: str = "",
feed_type: str = "Mixed haylage",
desc_1: str = "",
desc_2: str = "",
desc_3: str = "",
agro_fields: dict[str, float],
) -> AgrostarSample:
sample = AgrostarSample(
sample_no=sample_no,
farm_name=farm_name,
farm_id=farm_id,
date_printed=date_printed,
feed_type=feed_type,
desc_1=desc_1,
desc_2=desc_2,
desc_3=desc_3,
dry_matter_pct=agro_fields.get("DM"),
)
sample.raw_fields = {k: v for k, v in agro_fields.items() if k != "DM"}
return finalize_agrostar_sample(sample)
def parse_tabular_triplet_lines(lines: list[str]) -> list[tuple[str, str, str]]:
"""Строки PDF: name, unit (%DM|%CP|%), value."""
rows: list[tuple[str, str, str]] = []
i = 0
units = frozenset({"%DM", "%CP", "%"})
while i < len(lines):
line = lines[i]
if line in units and i >= 1:
name = lines[i - 1]
val = lines[i + 1] if i + 1 < len(lines) else ""
if val and (val[0].isdigit() or val.startswith("<")):
rows.append((name, line, val))
i += 2
continue
i += 1
return rows
def rows_to_label_values(triplets: list[tuple[str, str, str]]) -> list[tuple[str, str]]:
return [(name, val) for name, _unit, val in triplets]
@@ -0,0 +1,72 @@
"""AgroStar xlsx (сводка проб) → AgrostarParseResult."""
from __future__ import annotations
import io
from typing import Any
from openpyxl import load_workbook
from app.modules.zootech.lab.etl.agrostar_tabular import map_tabular_label, sample_from_agro_fields, tabular_rows_to_agro_fields
from app.modules.zootech.lab.etl.agrostar_xml_import import AgrostarParseResult, _parse_float
def parse_agrostar_xlsx(data: bytes) -> AgrostarParseResult:
result = AgrostarParseResult(lab_name="АгроСтар")
try:
wb = load_workbook(io.BytesIO(data), data_only=True, read_only=False)
except Exception as exc: # noqa: BLE001
result.errors.append(f"Не удалось открыть xlsx: {exc}")
return result
ws = wb.active
if ws.max_row < 4 or ws.max_column < 2:
result.errors.append("Пустой или неузнаваемый xlsx")
return result
sample_nos: list[str] = []
farms: list[str] = []
labels: list[str] = []
for col in range(2, ws.max_column + 1):
no = ws.cell(1, col).value
if no is None:
continue
sample_nos.append(str(no).strip())
farms.append(str(ws.cell(2, col).value or "").strip())
labels.append(str(ws.cell(3, col).value or "").strip())
if not sample_nos:
result.errors.append("В xlsx нет колонок с пробами")
return result
row_values: list[tuple[str, list[str]]] = []
for row in range(4, ws.max_row + 1):
label = ws.cell(row, 1).value
if label is None:
continue
label_str = str(label).strip()
if not label_str or not map_tabular_label(label_str):
continue
vals = []
for col in range(2, 2 + len(sample_nos)):
raw = ws.cell(row, col).value
vals.append("" if raw is None else str(raw))
row_values.append((label_str, vals))
for idx, sample_no in enumerate(sample_nos):
pairs = [(label, vals[idx]) for label, vals in row_values if idx < len(vals)]
agro_fields = tabular_rows_to_agro_fields(pairs)
if "DM" not in agro_fields:
result.errors.append(f"Проба {sample_no}: нет СВ")
continue
sample = sample_from_agro_fields(
sample_no=sample_no,
farm_name=farms[idx] if idx < len(farms) else "",
desc_1=labels[idx] if idx < len(labels) else sample_no,
agro_fields=agro_fields,
)
result.samples.append(sample)
if not result.samples and not result.errors:
result.errors.append("Не удалось разобрать пробы из xlsx")
return result
@@ -0,0 +1,787 @@
"""Импорт лабораторных анализов AgroStar (Standard_XML_Data) → zootech nutrients WESP."""
from __future__ import annotations
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
from app.modules.zootech.wesp_bridge_models import Component
# AgroStar Name → (WESP заголовок «База сырья», kind)
# pct_dm: %DM → г/кг СВ (×10); pct: процент как есть; extra: дублировать в доп. ключи
_AGROSTAR_MAP: tuple[tuple[str, str, str], ...] = (
("CP", "Сыр. Протеин", "pct_dm"),
("NDF", "Сырая клетч", "pct_dm"),
("ADF", "КДК", "pct_dm"),
("Fat_EE", "Сырой жир", "pct_dm"),
("Ash", "Сырая зола", "pct_dm"),
("Ca", "Ca", "pct_dm"),
("P", "P", "pct_dm"),
("Mg", "Mg", "pct_dm"),
("K", "K", "pct_dm"),
("S", "S", "pct_dm"),
("Cl", "CL", "pct_dm"),
("Starch", "Крахмал", "pct_dm"),
("Sugar_WSC", "Сахар", "pct_dm"),
("NFC", "NFC", "pct_dm"),
("Lys", "Лизин", "pct_dm"),
("Met", "Метионин", "pct_dm"),
("Leu", "Лейцин", "pct_dm"),
("Ile", "Изолейцин", "pct_dm"),
("Val", "Валин", "pct_dm"),
("NDICP_CP", "% нераствор протеин", "pct"),
("TDN", "ВРХ Орг Вещ", "pct"),
)
_AGROSTAR_MAPPED_FIELDS = frozenset(key for key, _, _ in _AGROSTAR_MAP) | frozenset({"DM", "Sugar_ESC", "aNDFom"})
_UNSUPPORTED_AGROSTAR: dict[str, str] = {
"NDFDom_IV_30hr": "нет поля для переваримости NDF",
"NDFDom_IV_12hr": "нет поля для переваримости NDF",
"NDFDom_IV_120hr": "нет поля для переваримости NDF",
"NDFDom_IV_240hr": "нет поля для переваримости NDF",
}
# Подписи AgroStar-полей, которые не пишем в WESP (для блока «Не попадёт»)
_AGROSTAR_FIELD_RU: dict[str, str] = {
"Moisture": "Влажность",
"pH": "pH",
"SP_CP": "Растворимый протеин",
"SP": "Растворимый протеин (абс.)",
"ADICP": "КДК-протеин (AD-ICP)",
"ADICP_CP": "КДК-протеин, %CP",
"NDICP": "НДК-протеин (абс.)",
"NH3CPE": "Небелковый азот (NH₃-CP)",
"Lignin": "Лигнин",
"Lignin_NDF": "Лигнин, %NDF",
"TFA": "Жирные кислоты, всего",
"RFV": "RFV (относит. корм. ценность)",
"RFQ": "RFQ (относит. качество)",
"uNDFom_IV_12hr": "Нерасщепляемая NDF 12 ч",
"uNDFom_IV_30hr": "Нерасщепляемая NDF 30 ч",
"uNDFom_IV_120hr": "Нерасщепляемая NDF 120 ч",
"uNDFom_IV_240hr": "Нерасщепляемая NDF 240 ч",
"NDFDom_IV_12hr": "Переваримость NDF 12 ч",
"NDFDom_IV_30hr": "Переваримость NDF 30 ч",
"NDFDom_IV_120hr": "Переваримость NDF 120 ч",
"NDFDom_IV_240hr": "Переваримость NDF 240 ч",
"NELMcallb": "NEL (Mcalf/lb)",
"NELMcalkg": "NEL (Mcal/kg)",
"NELMJkg": "NEL (MJ/kg)",
"NEMMcallb": "NEM (Mcal/lb)",
"NEMMcalkg": "NEM (Mcal/kg)",
"NEMMJkg": "NEM (MJ/kg)",
"NEGMcallb": "NEG (Mcal/lb)",
"NEGMcalkg": "NEG (Mcal/kg)",
"NEGMJkg": "NEG (MJ/kg)",
"MilktonH": "Молоко/тонна (Holstein)",
"Horse_DE_Mcallb": "DE лошади (Mcal/lb)",
"Acetic": "Уксусная кислота",
"Propionic": "Пропионовая кислота",
"Butyric": "Масляная кислота",
"Lactic": "Молочная кислота",
"Total_acid": "Органические кислоты, всего",
"C160": "C16:0",
"C180": "C18:0",
"C181": "C18:1",
"C182": "C18:2",
"C183": "C18:3",
"C160_TFA": "C16:0, %TFA",
"C180_TFA": "C18:0, %TFA",
"C181_TFA": "C18:1, %TFA",
"C182_TFA": "C18:2, %TFA",
"C183_TFA": "C18:3, %TFA",
"His": "Гистидин",
"TAA": "Сумма аминокислот",
"Lys_CP": "Лизин, %CP",
"Met_CP": "Метионин, %CP",
"Leu_CP": "Лейцин, %CP",
"Ile_CP": "Изолейцин, %CP",
"Val_CP": "Валин, %CP",
"His_CP": "Гистидин, %CP",
"TAA_CP": "Сумма аминокислот, %CP",
}
_UNSUPPORTED_GROUP_ORDER: tuple[str, ...] = (
"переваримость NDF",
"энергия",
"кислоты силоса",
"жирные кислоты",
"дробный протеин",
"аминокислоты (%CP)",
"клетчатка и RF",
"качество силоса",
"прочее",
)
def _unsupported_group_key(ag_key: str) -> str:
if ag_key in {"Moisture", "pH"}:
return "качество силоса"
if ag_key.startswith(("NDFDom", "uNDFom")):
return "переваримость NDF"
if ag_key.startswith(("NEL", "NEM", "NEG", "Milk", "Horse")):
return "энергия"
if ag_key in {"Acetic", "Propionic", "Butyric", "Lactic", "Total_acid"}:
return "кислоты силоса"
if ag_key.startswith("C1") or ag_key == "TFA":
return "жирные кислоты"
if ag_key in {"SP_CP", "SP", "ADICP", "ADICP_CP", "NDICP", "NH3CPE"}:
return "дробный протеин"
if ag_key.endswith("_CP") or ag_key in {"His", "TAA"}:
return "аминокислоты (%CP)"
if ag_key.startswith(("Lignin", "RF")):
return "клетчатка и RF"
return "прочее"
def _summarize_unsupported(
unsupported: list[dict[str, str]],
*,
nutrient_count: int,
total_in_report: int,
) -> tuple[list[dict[str, int | str]], str]:
counts: dict[str, int] = {}
for row in unsupported:
group = _unsupported_group_key(row["agroKey"])
counts[group] = counts.get(group, 0) + 1
groups: list[dict[str, int | str]] = []
for label in _UNSUPPORTED_GROUP_ORDER:
count = counts.pop(label, 0)
if count:
groups.append({"label": label, "count": count})
for label, count in sorted(counts.items()):
groups.append({"label": label, "count": count})
skip = len(unsupported)
if skip == 0:
return groups, ""
parts = [f"{g['label']} ({g['count']})" for g in groups]
group_text = ", ".join(parts)
stored = nutrient_count + 0 # nutrients without DM line in count; DM stored separately
return (
groups,
f"В отчёте {total_in_report} показателей — запишем СВ и {stored} в карточку реагента. "
f"Ещё {skip} останутся только в AgroStar: {group_text}.",
)
# WESP nutrient key → AgroStar raw field(s), первый найденный — для колонки «В документе»
_PREVIEW_AGRO_SOURCE: dict[str, tuple[str, ...]] = {
"Сыр. Протеин": ("CP",),
"% нераствор протеин": ("NDICP_CP",),
"Сырая клетч": ("NDF",),
"КДК": ("ADF",),
"Структур. клетч": ("aNDFom", "ADF"),
"Сырой жир": ("Fat_EE",),
"Сырая зола": ("Ash",),
"NFC": ("NFC",),
"Сахар": ("Sugar_WSC", "Sugar_ESC"),
"Крахмал": ("Starch",),
"Ca": ("Ca",),
"P": ("P",),
"Mg": ("Mg",),
"K": ("K",),
"S": ("S",),
"CL": ("Cl",),
"ВРХ Орг Вещ": ("TDN",),
"Лизин": ("Lys",),
"Метионин": ("Met",),
"Лейцин": ("Leu",),
"Изолейцин": ("Ile",),
"Валин": ("Val",),
}
_AGRO_SOURCE_UNIT: dict[str, str] = {
"CP": "%DM",
"NDF": "%DM",
"ADF": "%DM",
"aNDFom": "%DM",
"Fat_EE": "%DM",
"Ash": "%DM",
"Ca": "%DM",
"P": "%DM",
"Mg": "%DM",
"K": "%DM",
"S": "%DM",
"Cl": "%DM",
"Starch": "%DM",
"Sugar_WSC": "%DM",
"Sugar_ESC": "%DM",
"NFC": "%DM",
"TDN": "%DM",
"Lys": "%DM",
"Met": "%DM",
"Leu": "%DM",
"Ile": "%DM",
"Val": "%DM",
"NDICP_CP": "%CP",
}
_META_FIELDS = frozenset({"Sample_No", "Name", "Farm_ID", "Lot_name", "Date_Printed", "Type", "Desc_1", "Desc_2", "Desc_3", "Product_code"})
_FEED_TYPE_RU: dict[str, str] = {
"Mixed haylage": "Смешанный сенаж",
"Haylage": "Сенаж",
"Corn silage": "Кукурузный силос",
"Grass silage": "Травяной силос",
}
_AGROSTAR_FEED_TO_COMPONENT_TYPE: dict[str, str] = {
"Mixed haylage": "Сочные корма",
"Haylage": "Сочные корма",
"Corn silage": "Сочные корма",
"Grass silage": "Сочные корма",
}
_FEED_GROUP_TO_COMPONENT_TYPE: dict[str, str] = {
"rough": "Грубые корма",
"succulent": "Сочные корма",
"concentrate": "Концентрированные",
"other": "Добавки",
}
_WARNING_RU: dict[str, str] = {
"dm_missing": "В файле нет сухого вещества (DM)",
"omd_missing": "Нет данных для ВРХ — при расчёте подставится дефолт WESP",
"omd_from_tdn": "ВРХ орг. вещ. оценили по TDN, не прямой OMD из лаборатории",
}
# Порядок показа в «понюхали»: (ключ WESP, подпись, единица)
_PREVIEW_WRITE_ORDER: tuple[tuple[str, str, str], ...] = (
("__dry_matter__", "Сухое вещество (в карточку компонента)", "%"),
("Сыр. Протеин", "Сырой протеин", "г/кг СВ"),
("% нераствор протеин", "Нерастворимый протеин, %", "%"),
("Сырая клетч", "Сырая клетчатка (NDF)", "г/кг СВ"),
("КДК", "Кислая детергентная клетчатка (ADF)", "г/кг СВ"),
("Структур. клетч", "Структурная клетчатка", "г/кг СВ"),
("Сырой жир", "Сырой жир", "г/кг СВ"),
("Сырая зола", "Сырая зола", "г/кг СВ"),
("NFC", "Безволокнистые углеводы (NFC)", "г/кг СВ"),
("Сахар", "Сахар (WSC/ESC)", "г/кг СВ"),
("Крахмал", "Крахмал", "г/кг СВ"),
("Ca", "Кальций", "г/кг СВ"),
("P", "Фосфор", "г/кг СВ"),
("Mg", "Магний", "г/кг СВ"),
("K", "Калий", "г/кг СВ"),
("S", "Сера", "г/кг СВ"),
("CL", "Хлор", "г/кг СВ"),
("ВРХ Орг Вещ", "Переваримость ОВ (из TDN)", "%"),
("Лизин", "Лизин", "г/кг СВ"),
("Метионин", "Метионин", "г/кг СВ"),
("Лейцин", "Лейцин", "г/кг СВ"),
("Изолейцин", "Изолейцин", "г/кг СВ"),
("Валин", "Валин", "г/кг СВ"),
)
def _parse_float(value: str | None) -> float | None:
if value is None:
return None
text = str(value).strip().replace(",", ".")
if not text or text.startswith("<"):
return None
try:
n = float(text)
except ValueError:
return None
return None if n != n else n
def _normalize_label(value: str) -> str:
return re.sub(r"\s+", " ", (value or "").strip().lower())
def _pct_dm_to_g_per_kg_sv(pct_dm: float) -> float:
return round(pct_dm * 10.0, 4)
@dataclass
class AgrostarSample:
sample_no: str
farm_name: str
farm_id: str
date_printed: str
feed_type: str
desc_1: str
desc_2: str
desc_3: str
dry_matter_pct: float | None
raw_fields: dict[str, float] = field(default_factory=dict)
nutrients: dict[str, float] = field(default_factory=dict)
warnings: list[str] = field(default_factory=list)
@property
def label(self) -> str:
parts = [p for p in (self.desc_1, self.desc_2, self.desc_3) if p]
return parts[0] if parts else self.sample_no
def to_api_dict(self) -> dict[str, Any]:
return {
"sampleNo": self.sample_no,
"farmName": self.farm_name,
"farmId": self.farm_id,
"datePrinted": self.date_printed,
"feedType": self.feed_type,
"desc1": self.desc_1,
"desc2": self.desc_2,
"desc3": self.desc_3,
"label": self.label,
"dryMatterPct": self.dry_matter_pct,
"nutrients": self.nutrients,
"warnings": self.warnings,
"rawFieldCount": len(self.raw_fields),
}
@dataclass
class AgrostarParseResult:
lab_name: str
samples: list[AgrostarSample] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_api_dict(self) -> dict[str, Any]:
return {
"labName": self.lab_name,
"samples": [s.to_api_dict() for s in self.samples],
"errors": self.errors,
"sampleCount": len(self.samples),
}
@dataclass
class ComponentMatch:
component_id: str
name: str
score: float
def to_api_dict(self) -> dict[str, Any]:
return {"componentId": self.component_id, "name": self.name, "score": round(self.score, 3)}
@dataclass
class ApplyAssignmentResult:
sample_no: str
component_id: str | None
component_name: str | None
dry_matter_pct: float | None
nutrient_count: int
warnings: list[str]
skipped: bool = False
reason: str | None = None
def to_api_dict(self) -> dict[str, Any]:
return {
"sampleNo": self.sample_no,
"componentId": self.component_id,
"componentName": self.component_name,
"dryMatterPct": self.dry_matter_pct,
"nutrientCount": self.nutrient_count,
"warnings": self.warnings,
"skipped": self.skipped,
"reason": self.reason,
}
@dataclass
class ApplyResult:
applied: int = 0
skipped: int = 0
results: list[ApplyAssignmentResult] = field(default_factory=list)
dry_run: bool = False
def to_api_dict(self) -> dict[str, Any]:
return {
"applied": self.applied,
"skipped": self.skipped,
"dryRun": self.dry_run,
"results": [r.to_api_dict() for r in self.results],
}
def _apply_nutrients_from_raw(sample: AgrostarSample) -> AgrostarSample:
nutrients: dict[str, float] = {}
for ag_key, wesp_key, kind in _AGROSTAR_MAP:
val = sample.raw_fields.get(ag_key)
if val is None:
continue
if kind == "pct_dm":
nutrients[wesp_key] = _pct_dm_to_g_per_kg_sv(val)
elif kind == "pct":
nutrients[wesp_key] = round(val, 4)
if "Sugar_ESC" in sample.raw_fields and "Sugar_WSC" not in sample.raw_fields:
esc = sample.raw_fields["Sugar_ESC"]
nutrients["Сахар"] = _pct_dm_to_g_per_kg_sv(esc)
struct_raw = sample.raw_fields.get("aNDFom")
if struct_raw is None:
struct_raw = sample.raw_fields.get("ADF")
if struct_raw is not None:
nutrients["Структур. клетч"] = _pct_dm_to_g_per_kg_sv(struct_raw)
if "Структур. клетч" in nutrients:
nutrients["Структур клетч"] = nutrients["Структур. клетч"]
sample.nutrients = nutrients
if sample.dry_matter_pct is None:
sample.warnings.append("dm_missing")
if "ВРХ Орг Вещ" not in nutrients and "TDN" not in sample.raw_fields:
sample.warnings.append("omd_missing")
elif "TDN" in sample.raw_fields:
sample.warnings.append("omd_from_tdn")
ndfd30 = sample.raw_fields.get("NDFDom_IV_30hr")
if ndfd30 is not None:
sample.warnings.append(f"ndfd30={ndfd30}")
return sample
def finalize_agrostar_sample(sample: AgrostarSample) -> AgrostarSample:
"""Общий финал: raw_fields → nutrients + warnings (XML/PDF/xlsx)."""
return _apply_nutrients_from_raw(sample)
def _read_sample_block(block: ET.Element) -> AgrostarSample:
fields: dict[str, str] = {}
for child in block:
name = child.get("Name") or child.tag
fields[name] = child.get("Value") or ""
sample = AgrostarSample(
sample_no=fields.get("Sample_No", ""),
farm_name=fields.get("Name", ""),
farm_id=fields.get("Farm_ID", ""),
date_printed=fields.get("Date_Printed", ""),
feed_type=fields.get("Type", ""),
desc_1=fields.get("Desc_1", ""),
desc_2=fields.get("Desc_2", ""),
desc_3=fields.get("Desc_3", ""),
dry_matter_pct=_parse_float(fields.get("DM")),
)
for key, raw in fields.items():
if key in _META_FIELDS or key == "DM":
continue
val = _parse_float(raw)
if val is not None:
sample.raw_fields[key] = val
return finalize_agrostar_sample(sample)
def parse_agrostar_xml(text: str) -> AgrostarParseResult:
result = AgrostarParseResult(lab_name="")
if not (text or "").strip():
result.errors.append("Пустой файл")
return result
try:
root = ET.fromstring(text)
except ET.ParseError as exc:
result.errors.append(f"Некорректный XML: {exc}")
return result
if root.tag != "Standard_XML_Data":
result.errors.append(f"Ожидался Standard_XML_Data, получен {root.tag}")
for child in root:
tag = child.tag
if tag == "Lab_Name":
result.lab_name = child.get("Value") or ""
elif tag == "Sample_Data":
try:
result.samples.append(_read_sample_block(child))
except Exception as exc: # noqa: BLE001 — собрать все пробы
result.errors.append(f"Ошибка пробы: {exc}")
if not result.samples and not result.errors:
result.errors.append("В файле нет блоков Sample_Data")
return result
def _match_score(label: str, component_name: str) -> float:
a = _normalize_label(label)
b = _normalize_label(component_name)
if not a or not b:
return 0.0
if a in b or b in a:
return 0.95
# ключевые токены: яма, номер, силос
tokens_a = set(re.findall(r"[a-zа-яё0-9]+", a, re.I))
tokens_b = set(re.findall(r"[a-zа-яё0-9]+", b, re.I))
if tokens_a and tokens_b:
overlap = len(tokens_a & tokens_b) / max(len(tokens_a), len(tokens_b))
if overlap >= 0.4:
return 0.5 + overlap * 0.4
return SequenceMatcher(None, a, b).ratio()
def suggest_component_matches(
label: str,
*,
limit: int = 8,
min_score: float = 0.25,
) -> list[ComponentMatch]:
components = (
Component.query.filter(Component.is_deleted.is_(False), Component.is_active.is_(True))
.order_by(Component.name)
.all()
)
scored: list[ComponentMatch] = []
for comp in components:
score = _match_score(label, comp.name or "")
if score >= min_score:
scored.append(ComponentMatch(comp.id, comp.name or "", score))
scored.sort(key=lambda m: (-m.score, m.name.lower()))
return scored[:limit]
def _fmt_preview_num(value: float) -> str:
rounded = round(value, 2)
if abs(rounded - round(rounded)) < 0.01:
return f"{round(rounded):g}"
text = f"{rounded:.2f}".rstrip("0").rstrip(".")
return text or "0"
def _preview_source_for_key(sample: AgrostarSample, wesp_key: str) -> tuple[float | None, str]:
for ag_key in _PREVIEW_AGRO_SOURCE.get(wesp_key, ()):
val = sample.raw_fields.get(ag_key)
if val is not None:
return val, _AGRO_SOURCE_UNIT.get(ag_key, "%DM")
return None, ""
def suggest_canonical_feed_type(sample: AgrostarSample) -> str:
"""Канонический component.type для POST /api/components."""
mapped = _AGROSTAR_FEED_TO_COMPONENT_TYPE.get(sample.feed_type)
if mapped:
return mapped
stub = Component(name=sample.label, type="")
group = classify_feed_group(stub)
return _FEED_GROUP_TO_COMPONENT_TYPE.get(group, "Сочные корма")
def _warning_to_text(code: str) -> str:
if code in _WARNING_RU:
return _WARNING_RU[code]
if code.startswith("ndfd30="):
val = code.split("=", 1)[1]
return _UNSUPPORTED_AGROSTAR.get("NDFDom_IV_30hr", f"ndfd30={val}")
return code
def build_storage_report(sample: AgrostarSample) -> dict[str, Any]:
"""Сводка: что сохранится в WESP и что из AgroStar не мапится."""
unsupported: list[dict[str, str]] = []
for ag_key, val in sample.raw_fields.items():
if ag_key in _AGROSTAR_MAPPED_FIELDS:
continue
label = _AGROSTAR_FIELD_RU.get(ag_key, ag_key)
reason = _UNSUPPORTED_AGROSTAR.get(ag_key, "нет поля в WESP")
unsupported.append(
{
"agroKey": ag_key,
"label": label,
"value": _fmt_preview_num(val),
"reason": reason,
}
)
unsupported.sort(key=lambda row: row["label"].lower())
nutrient_keys = set(sample.nutrients)
stored_count = len(nutrient_keys) + (1 if sample.dry_matter_pct is not None else 0)
total_in_report = len(sample.raw_fields) + (1 if sample.dry_matter_pct is not None else 0)
groups, unsupported_message = _summarize_unsupported(
unsupported,
nutrient_count=len(nutrient_keys),
total_in_report=total_in_report,
)
return {
"storedCount": stored_count,
"nutrientCount": len(nutrient_keys),
"unsupportedCount": len(unsupported),
"unsupportedGroups": groups,
"unsupportedMessage": unsupported_message,
"unsupported": unsupported,
}
def build_sample_preview(sample: AgrostarSample) -> dict[str, Any]:
"""Человекочитаемый разбор пробы для UI «Сначала понюхаем»."""
feed_ru = _FEED_TYPE_RU.get(sample.feed_type, sample.feed_type or "")
desc_parts = [p for p in (sample.desc_1, sample.desc_2, sample.desc_3) if p]
description = " · ".join(desc_parts) if desc_parts else sample.label
meta: list[dict[str, str]] = [
{"label": "№ пробы", "value": sample.sample_no or ""},
{"label": "Хозяйство", "value": sample.farm_name or ""},
{"label": "Код хозяйства", "value": sample.farm_id or ""},
{"label": "Дата отчёта", "value": sample.date_printed or ""},
{"label": "Тип корма", "value": feed_ru},
{"label": "Описание", "value": description},
]
will_write: list[dict[str, str]] = []
if sample.dry_matter_pct is not None:
will_write.append(
{
"label": "Сухое вещество (поле компонента)",
"value": _fmt_preview_num(sample.dry_matter_pct),
"unit": "%",
"sourceValue": _fmt_preview_num(sample.dry_matter_pct),
"sourceUnit": "%",
}
)
for key, label, unit in _PREVIEW_WRITE_ORDER:
if key == "__dry_matter__":
continue
val = sample.nutrients.get(key)
if val is None:
continue
row: dict[str, str] = {"label": label, "value": _fmt_preview_num(val), "unit": unit}
source_val, source_unit = _preview_source_for_key(sample, key)
if source_val is not None:
row["sourceValue"] = _fmt_preview_num(source_val)
row["sourceUnit"] = source_unit
will_write.append(row)
notes = [_warning_to_text(w) for w in sample.warnings]
storage = build_storage_report(sample)
return {
"title": sample.label,
"feedTypeRu": feed_ru,
"suggestedName": sample.label,
"suggestedType": suggest_canonical_feed_type(sample),
"meta": meta,
"willWrite": will_write,
"notes": notes,
"recognizedCount": len(will_write),
"storage": storage,
}
def enrich_parse_with_matches(parse: AgrostarParseResult) -> dict[str, Any]:
body = parse.to_api_dict()
for i, sample in enumerate(parse.samples):
matches = suggest_component_matches(sample.label)
body["samples"][i]["suggestedComponents"] = [m.to_api_dict() for m in matches]
body["samples"][i]["preview"] = build_sample_preview(sample)
return body
def apply_agrostar_import(
assignments: list[dict[str, Any]],
*,
user_id: str = "agrostar-import",
dry_run: bool = False,
) -> ApplyResult:
"""assignments: [{sampleNo, componentId}] — данные проб из предыдущего parse или повторный parse."""
result = ApplyResult(dry_run=dry_run)
by_sample: dict[str, dict[str, Any]] = {
str(a.get("sampleNo") or a.get("sample_no") or ""): a for a in assignments
}
for sample_no, row in by_sample.items():
if not sample_no:
result.skipped += 1
result.results.append(
ApplyAssignmentResult(
sample_no="",
component_id=None,
component_name=None,
dry_matter_pct=None,
nutrient_count=0,
warnings=[],
skipped=True,
reason="missing_sample_no",
)
)
continue
component_id = row.get("componentId") or row.get("component_id")
nutrients = row.get("nutrients") or {}
dry_matter = row.get("dryMatterPct") or row.get("dry_matter_pct")
if not component_id:
result.skipped += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=None,
component_name=None,
dry_matter_pct=dry_matter,
nutrient_count=len(nutrients),
warnings=list(row.get("warnings") or []),
skipped=True,
reason="no_component",
)
)
continue
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
if comp is None:
result.skipped += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=component_id,
component_name=None,
dry_matter_pct=dry_matter,
nutrient_count=len(nutrients),
warnings=[],
skipped=True,
reason="component_not_found",
)
)
continue
warnings = list(row.get("warnings") or [])
if dry_run:
result.applied += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=comp.id,
component_name=comp.name,
dry_matter_pct=dry_matter,
nutrient_count=len(nutrients),
warnings=warnings,
)
)
continue
if dry_matter is not None:
try:
comp.dry_matter = float(dry_matter)
except (TypeError, ValueError):
warnings.append("invalid_dry_matter")
save_component_nutrients(comp.id, nutrients, user_id=user_id)
comp.updated_by = user_id
db.session.commit()
result.applied += 1
result.results.append(
ApplyAssignmentResult(
sample_no=sample_no,
component_id=comp.id,
component_name=comp.name,
dry_matter_pct=comp.dry_matter,
nutrient_count=len(nutrients),
warnings=warnings,
)
)
return result
@@ -0,0 +1,243 @@
"""Единый роутер импорта lab: XML / PDF / xlsx → preview API."""
from __future__ import annotations
import re
from typing import Any
from app.modules.zootech.lab.etl.agrostar_pdf_import import looks_like_agrostar_pdf, parse_agrostar_pdf
from app.modules.zootech.lab.etl.agrostar_xlsx_import import parse_agrostar_xlsx
from app.modules.zootech.lab.etl.agrostar_xml_import import AgrostarParseResult, enrich_parse_with_matches, parse_agrostar_xml
from app.modules.zootech.lab.etl.plinor_pdf_import import (
enrich_plinor_composition,
is_plinor_sos,
is_plinor_zoo,
parse_plinor_sos,
parse_plinor_zoo,
)
SOURCE_LABELS: dict[str, str] = {
"agrostar_xml": "AgroStar XML",
"agrostar_pdf": "AgroStar PDF",
"agrostar_xlsx": "AgroStar Excel",
"plinor_sos": "ПЛИНОР — состав рациона",
"plinor_zoo": "ПЛИНОР — показатели рациона",
}
_PDF_MAGIC = b"%PDF"
_XLSX_MAGIC = b"PK\x03\x04"
_XML_MARKERS = (b"Standard_XML_Data", b"<?xml", b"<Standard_XML_Data")
def _filename_hint_plinor_zoo(name: str) -> bool:
n = (name or "").lower()
return "зоо" in n or re.search(r"(^|[^a-z])zoo([^a-z]|$)", n) is not None
def _filename_hint_plinor_sos(name: str) -> bool:
n = (name or "").lower()
return "сос" in n or re.search(r"(^|[^a-z])sos([^a-z]|$)", n) is not None
def _looks_like_xml_agrostar(data: bytes) -> bool:
head = data[:16384]
return any(marker in head for marker in _XML_MARKERS)
def _looks_like_xlsx(data: bytes) -> bool:
return data.startswith(_XLSX_MAGIC)
def _looks_like_pdf(data: bytes) -> bool:
return data.startswith(_PDF_MAGIC)
def _pdf_import_available() -> bool:
try:
import fitz # noqa: F401
except ImportError:
return False
return True
def detect_source_format(filename: str, data: bytes) -> str:
"""Определение формата по содержимому и имени (расширение — запасной вариант)."""
name = (filename or "").lower()
if _looks_like_pdf(data):
return _detect_pdf_format(data, name)
if _looks_like_xlsx(data):
return "agrostar_xlsx"
if _looks_like_xml_agrostar(data):
return "agrostar_xml"
if name.endswith(".xml"):
return "agrostar_xml"
if name.endswith(".xlsx") or name.endswith(".xls"):
return "agrostar_xlsx"
if name.endswith(".pdf"):
return _detect_pdf_format(data, name)
return "unknown"
def _detect_pdf_format(data: bytes, name: str) -> str:
head = data[:8192]
if b"Standard_XML_Data" in head:
return "agrostar_xml"
if looks_like_agrostar_pdf(data):
return "agrostar_pdf"
pdf_text = ""
if _pdf_import_available():
try:
pdf_text = _peek_pdf_text(data)
except Exception: # noqa: BLE001
pdf_text = ""
if pdf_text:
if is_plinor_zoo(pdf_text):
return "plinor_zoo"
if is_plinor_sos(pdf_text):
return "plinor_sos"
if looks_like_agrostar_pdf(data):
return "agrostar_pdf"
if _filename_hint_plinor_zoo(name):
return "plinor_zoo"
if _filename_hint_plinor_sos(name):
return "plinor_sos"
return "unknown_pdf"
def _peek_pdf_text(data: bytes) -> str:
try:
import fitz
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
text = doc[0].get_text() if doc.page_count else ""
doc.close()
return text
def parse_lab_import(filename: str, data: bytes) -> dict[str, Any]:
"""Разбор файла → единый JSON для /lab комбайна."""
if not data:
return {"error": True, "message": "Пустой файл", "kind": "error"}
fmt = detect_source_format(filename, data)
source_label = SOURCE_LABELS.get(fmt, "Неизвестный формат")
if fmt == "agrostar_xml":
text = _decode_text(data)
parsed = parse_agrostar_xml(text)
if parsed.errors and not parsed.samples:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
**parsed.to_api_dict(),
}
body = enrich_parse_with_matches(parsed)
body["kind"] = "lab_samples"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "agrostar_pdf":
parsed = parse_agrostar_pdf(data)
return _lab_samples_response(parsed, fmt, source_label, filename)
if fmt == "agrostar_xlsx":
parsed = parse_agrostar_xlsx(data)
return _lab_samples_response(parsed, fmt, source_label, filename)
if fmt == "plinor_sos":
parsed = parse_plinor_sos(data)
if parsed.errors and not parsed.feed_lines:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
}
body = enrich_plinor_composition(parsed.to_api_dict())
body["kind"] = "ration_composition"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "plinor_zoo":
parsed = parse_plinor_zoo(data)
if parsed.errors and not parsed.indicators:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
}
body = parsed.to_api_dict()
body["kind"] = "ration_indicators"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
if fmt == "unknown_pdf" and _looks_like_pdf(data) and not _pdf_import_available():
return {
"error": True,
"message": "Для PDF нужен pymupdf — выполните: pip install pymupdf",
"kind": "error",
"sourceFormat": fmt,
"fileName": filename,
}
return {
"error": True,
"message": "Формат не распознан. Поддерживаются: AgroStar xml/pdf/xlsx, ПЛИНОР сос/зоо pdf",
"kind": "error",
"sourceFormat": fmt,
"fileName": filename,
}
def _lab_samples_response(
parsed: AgrostarParseResult,
fmt: str,
source_label: str,
filename: str,
) -> dict[str, Any]:
if parsed.errors and not parsed.samples:
return {
"error": True,
"message": "; ".join(parsed.errors),
"kind": "error",
"sourceFormat": fmt,
**parsed.to_api_dict(),
}
body = enrich_parse_with_matches(parsed)
body["kind"] = "lab_samples"
body["sourceFormat"] = fmt
body["sourceLabel"] = source_label
body["fileName"] = filename
if parsed.errors:
body["warnings"] = parsed.errors
return body
def _decode_text(data: bytes) -> str:
for encoding in ("utf-8", "utf-8-sig", "cp1251"):
try:
return data.decode(encoding)
except UnicodeDecodeError:
continue
return data.decode("utf-8", errors="replace")
@@ -0,0 +1,215 @@
"""ПЛИНОР PDF (сос / зоо) → состав рациона или показатели."""
from __future__ import annotations
import re
from dataclasses import dataclass, field
from typing import Any
from app.modules.zootech.lab.etl.agrostar_xml_import import _parse_float, suggest_component_matches
def _pdf_text(data: bytes) -> str:
try:
import fitz
except ImportError as exc:
raise RuntimeError("Для PDF нужен pymupdf (pip install pymupdf)") from exc
doc = fitz.open(stream=data, filetype="pdf")
parts = [doc[i].get_text() for i in range(doc.page_count)]
doc.close()
return "\n".join(parts)
def _meta_from_lines(lines: list[str]) -> dict[str, str]:
meta: dict[str, str] = {}
for line in lines:
for key, prefix in (
("group", "Группа:"),
("farm", "Хозяйство:"),
("region", "Район:"),
("rationDate", "Дата рациона:"),
("calcDate", "Дата расчетов:"),
):
if line.startswith(prefix):
meta[key] = line.split(":", 1)[1].strip()
if line.startswith("Суточный удой"):
m = re.search(r"(\d+(?:[,\.]\d+)?)", line)
if m:
meta["milkYieldKg"] = m.group(1).replace(",", ".")
for i, line in enumerate(lines):
if line.startswith("Цена (руб.)") and i + 1 < len(lines):
meta["totalCostRub"] = lines[i + 1].replace(",", ".")
if line == "Масса (кг)" and i + 1 < len(lines):
meta["totalMassKg"] = lines[i + 1].replace(",", ".")
return meta
@dataclass
class PlinorFeedLine:
feed_name: str
daily_kg: float
cost_rub: float
def to_api_dict(self) -> dict[str, Any]:
return {"feedName": self.feed_name, "dailyKg": self.daily_kg, "costRub": self.cost_rub}
@dataclass
class PlinorIndicatorRow:
name: str
norm: float | None
current: float | None
def to_api_dict(self) -> dict[str, Any]:
return {"name": self.name, "norm": self.norm, "current": self.current}
@dataclass
class PlinorCompositionResult:
meta: dict[str, str] = field(default_factory=dict)
feed_lines: list[PlinorFeedLine] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_api_dict(self) -> dict[str, Any]:
return {
"meta": self.meta,
"feedLines": [line.to_api_dict() for line in self.feed_lines],
"feedCount": len(self.feed_lines),
"errors": self.errors,
}
@dataclass
class PlinorIndicatorsResult:
meta: dict[str, str] = field(default_factory=dict)
indicators: list[PlinorIndicatorRow] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
def to_api_dict(self) -> dict[str, Any]:
return {
"meta": self.meta,
"indicators": [row.to_api_dict() for row in self.indicators],
"indicatorCount": len(self.indicators),
"errors": self.errors,
}
def _is_plinor(text: str) -> bool:
return "ПЛИНОР" in (text or "") or 'ИАС "РАЦИОНЫ"' in (text or "")
def is_plinor_sos(text: str) -> bool:
if not _is_plinor(text):
return False
if "Состав рациона" in text:
return True
return bool(re.search(r"Таблица 1\.1(?!0)", text))
def is_plinor_zoo(text: str) -> bool:
if not _is_plinor(text):
return False
if "Зоотехнические показатели" in text:
return True
return "Таблица 1.10" in text
def parse_plinor_sos(data: bytes) -> PlinorCompositionResult:
result = PlinorCompositionResult()
try:
text = _pdf_text(data)
except RuntimeError as exc:
result.errors.append(str(exc))
return result
if not is_plinor_sos(text):
result.errors.append("Не похоже на ПЛИНОР «Состав рациона» (табл. 1.1)")
return result
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
result.meta = _meta_from_lines(lines)
feeds: list[PlinorFeedLine] = []
i = 0
while i < len(lines):
if lines[i] == "кг" and i >= 1 and i + 2 < len(lines):
name = lines[i - 1]
amt = _parse_float(lines[i + 1])
cost = _parse_float(lines[i + 2])
if (
amt is not None
and cost is not None
and name not in ("Дача", "изм.", "Корма")
and "кг" not in name.lower()
):
feeds.append(PlinorFeedLine(name, amt, cost))
i += 3
continue
i += 1
if not feeds:
result.errors.append("Не найдены строки кормов")
result.feed_lines = feeds
return result
def parse_plinor_zoo(data: bytes) -> PlinorIndicatorsResult:
result = PlinorIndicatorsResult()
try:
text = _pdf_text(data)
except RuntimeError as exc:
result.errors.append(str(exc))
return result
if not is_plinor_zoo(text):
result.errors.append("Не похоже на ПЛИНОР «Зоотехнические показатели» (табл. 1.10)")
return result
lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
result.meta = _meta_from_lines(lines)
skip_prefixes = (
"Район:",
"Хозяйство:",
"Ферма:",
"Двор:",
"Подразделение:",
"Группа:",
"Суточный",
"Стадия",
"Живая",
"Система",
"Конц.",
"Кр.опт.",
"Дата",
)
skip_exact = frozenset(
{"1", "2", "3", "По польз.", "норме", "Текущий рацион", "Наименование", "Значение"}
)
rows: list[PlinorIndicatorRow] = []
i = 0
while i < len(lines):
line = lines[i]
if line in skip_exact or any(line.startswith(p) for p in skip_prefixes):
i += 1
continue
if i + 2 < len(lines) and len(line) > 8:
norm = _parse_float(lines[i + 1])
cur = _parse_float(lines[i + 2])
if norm is not None and cur is not None:
rows.append(PlinorIndicatorRow(line, norm, cur))
i += 3
continue
i += 1
if not rows:
result.errors.append("Не найдены показатели рациона")
result.indicators = rows
return result
def enrich_plinor_composition(body: dict[str, Any]) -> dict[str, Any]:
for i, line in enumerate(body.get("feedLines") or []):
name = line.get("feedName") or ""
matches = suggest_component_matches(name, limit=5)
body["feedLines"][i]["suggestedComponents"] = [m.to_api_dict() for m in matches]
return body
@@ -0,0 +1,265 @@
"""Import tab reference PostgreSQL → WESP SQLite (offline admin ETL)."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
import psycopg2
from psycopg2.extras import RealDictCursor
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.wesp_bridge_models import Component, Recipe, WESP_SUPPRESS_SYNC_ENQUEUE
from app.modules.zootech.lab.services.profile_norms import import_norms_from_legacy_text, save_norms_from_payload
DEFAULT_PG_URL = "postgresql://neoton:neoton_secret@localhost:5432/neoton"
@dataclass
class ImportStats:
components_enriched: int = 0
components_unmatched: int = 0
tab_components_purged: int = 0
animal_profiles: int = 0
recipe_links: int = 0
errors: list[str] = field(default_factory=list)
def _pg_url() -> str:
return os.environ.get("TAB_REFERENCE_DATABASE_URL", DEFAULT_PG_URL).strip()
def _json_text(value: Any) -> str:
if value is None:
return "{}"
if isinstance(value, str):
return value if value else "{}"
return json.dumps(value, ensure_ascii=False)
def _dt(value: Any) -> datetime | None:
if value is None:
return None
if isinstance(value, datetime):
return value
return None
def _fetch_all(conn, sql: str) -> list[dict[str, Any]]:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(sql)
return list(cur.fetchall())
def _normalize_name(name: str) -> str:
return " ".join((name or "").lower().split())
def _find_wesp_component(row: dict[str, Any]) -> Component | None:
"""Match tab feed_ingredient → existing WESP component (never create)."""
external_no = row.get("external_no")
name = (row.get("name") or "").strip()
norm = _normalize_name(name)
if external_no is not None:
hit = Component.query.filter_by(external_no=external_no, is_deleted=False).first()
if hit is not None:
return hit
if name:
hit = Component.query.filter(
Component.name == name, Component.is_deleted.is_(False)
).first()
if hit is not None:
return hit
if norm:
for comp in Component.query.filter(Component.is_deleted.is_(False)).all():
if _normalize_name(comp.name) == norm:
return comp
if len(name) >= 12:
prefix = name[:20].lower()
for comp in Component.query.filter(Component.is_deleted.is_(False)).all():
if prefix in (comp.name or "").lower():
return comp
return None
def _enrich_wesp_component(component: Component, row: dict[str, Any]) -> None:
"""Copy zootech nutrients from tab; WESP id/name/dry_matter/price stay canonical."""
from app.modules.zootech.lab.services.component_nutrients import save_component_nutrients
raw = row.get("nutrients")
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
raw = {}
if not isinstance(raw, dict):
raw = {}
save_component_nutrients(component.id, raw, user_id="reference-db-import")
if row.get("external_no") is not None:
component.external_no = row.get("external_no")
component.updated_by = "tab-enrich"
def _purge_tab_imported_components(stats: ImportStats) -> None:
"""Remove components created from tab feed_ingredients (not used in WESP calc)."""
tab_ids = [
c.id
for c in Component.query.filter(
Component.created_by == "tab-import", Component.is_deleted.is_(False)
).all()
]
if not tab_ids:
return
for comp in Component.query.filter(Component.id.in_(tab_ids)).all():
comp.soft_delete("tab-import-cleanup")
stats.tab_components_purged += 1
def _import_feed_ingredients(conn, stats: ImportStats) -> None:
"""Map tab feed_ingredient → WESP component; enrich nutrients only."""
rows = _fetch_all(
conn,
"""
SELECT id, external_no, name, price_per_kg, dry_matter, nutrients, row_index
FROM feed_ingredients
ORDER BY row_index NULLS LAST, external_no NULLS LAST
""",
)
for row in rows:
component = _find_wesp_component(row)
if component is None:
stats.components_unmatched += 1
continue
_enrich_wesp_component(component, row)
stats.components_enriched += 1
def build_feed_ingredient_mapping(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> dict[str, str | None]:
st = stats or ImportStats()
mapping: dict[str, str | None] = {}
for row in rows:
tab_id = str(row.get("id") or "")
component = _find_wesp_component(row)
if component is None:
st.components_unmatched += 1
mapping[tab_id] = None
continue
_enrich_wesp_component(component, row)
mapping[tab_id] = component.id
st.components_enriched += 1
return mapping
def apply_animal_profile_rows(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> ImportStats:
"""Импорт профилей из списка строк (PG-формат или fixtures JSON)."""
st = stats or ImportStats()
for row in rows:
_upsert_animal_profile_row(row, st)
return st
def _upsert_animal_profile_row(row: dict[str, Any], stats: ImportStats) -> None:
profile = LabAnimalProfile.query.get(row["id"])
if profile is None:
profile = LabAnimalProfile(id=row["id"])
db.session.add(profile)
profile.profile_key = row["key"]
profile.label = row["label"]
profile.ration_type = str(row["type"])
norms_raw = row.get("norms_data")
if isinstance(norms_raw, dict):
save_norms_from_payload(profile, norms_raw)
else:
import_norms_from_legacy_text(profile, norms_raw)
profile.created_at = _dt(row.get("created_at")) or profile.created_at
profile.updated_at = _dt(row.get("updated_at")) or profile.updated_at
profile.created_by = "tab-import"
profile.updated_by = "tab-import"
stats.animal_profiles += 1
def apply_feed_ingredient_rows(rows: list[dict[str, Any]], stats: ImportStats | None = None) -> ImportStats:
"""Обогащение WESP component из строк feed_ingredients (без PG)."""
st = stats or ImportStats()
for row in rows:
component = _find_wesp_component(row)
if component is None:
st.components_unmatched += 1
continue
_enrich_wesp_component(component, row)
st.components_enriched += 1
return st
def _import_animal_profiles(conn, stats: ImportStats) -> None:
rows = _fetch_all(
conn,
"""
SELECT id, key, label, type, norms_data, created_at, updated_at
FROM animal_profiles
ORDER BY key
""",
)
apply_animal_profile_rows(rows, stats)
def _recipe_by_name(name: str) -> Recipe | None:
if not name:
return None
return Recipe.query.filter(Recipe.name == name, Recipe.is_deleted.is_(False)).first()
def _link_recipes_from_tab_projects(conn, stats: ImportStats) -> None:
"""Только ration_type на recipe по имени — без staging-таблиц."""
rows = _fetch_all(
conn,
"""
SELECT name, type
FROM ration_projects
ORDER BY updated_at DESC NULLS LAST
""",
)
seen: set[str] = set()
for row in rows:
name = (row.get("name") or "").strip()
if not name or name in seen:
continue
linked = _recipe_by_name(name)
if linked is None:
continue
if row.get("type"):
linked.ration_type = str(row["type"])
seen.add(name)
stats.recipe_links += 1
def import_from_reference_db(pg_url: str | None = None) -> ImportStats:
"""Full dump from tab PostgreSQL into WESP SQLite."""
stats = ImportStats()
url = pg_url or _pg_url()
conn = psycopg2.connect(url)
db.session.info[WESP_SUPPRESS_SYNC_ENQUEUE] = True
try:
_purge_tab_imported_components(stats)
db.session.flush()
_import_feed_ingredients(conn, stats)
db.session.flush()
_import_animal_profiles(conn, stats)
_link_recipes_from_tab_projects(conn, stats)
db.session.commit()
except Exception as exc:
db.session.rollback()
stats.errors.append(str(exc))
raise
finally:
db.session.info.pop(WESP_SUPPRESS_SYNC_ENQUEUE, None)
conn.close()
return stats
@@ -0,0 +1,169 @@
"""Все показатели рациона для расчёта и сравнения с нормами профиля."""
from __future__ import annotations
from typing import Any
def _ind(
key: str,
label: str,
unit: str,
nutrient_keys: list[str] | None = None,
*,
aggregation: str = "daily_total",
derived: str | None = None,
**extra: Any,
) -> dict[str, Any]:
row: dict[str, Any] = {
"key": key,
"label": label,
"unit": unit,
"nutrient_keys": nutrient_keys or [],
"aggregation": aggregation,
}
if derived:
row["derived"] = derived
row.update(extra)
return row
# Порядок: базовые → расширенные zootech → производные
RATION_ALL_INDICATORS: list[dict[str, Any]] = [
_ind("dry_matter", "Сухое вещество", "г", ["СВ"]),
_ind("dm_main", "СВ — основной корм", "г", ["Осн.Корм", "СВ Основной корм"]),
_ind("oe", "ОЭ — КРС / Дойн", "MJ", ["ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм"]),
_ind("nel", "ЧЭЛ — КРС / Дойн", "MJ", ["ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм"]),
_ind("nel_per_kg_dm", "ЧЭЛ/кг СВ", "MJ", derived="nel_per_kg_dm"),
_ind("crude_protein", "Сырой протеин", "г", ["Сыр. Протеин"]),
_ind("rup_per_kg_dm", "Нер. СП / кг СВ", "г", ["Нер. СП / кг СВ"], aggregation="weighted_avg"),
_ind("insoluble_protein", "Нерастворимый протеин", "г", ["Нераствор протеин ", "Нерастворим прот ", "Нерастворим прот"]),
_ind("insoluble_protein_pct", "% нераств. протеин", "%", ["% нераств. протеин", "% нераствор протеин"], aggregation="weighted_avg"),
_ind("usp", "уСП", "г", ["уСП", "уСП формул"]),
_ind("usp_pct_dm", "% уСП/кг СВ", "%", ["% уСП/кг СВ", "% уСП/кг СР"], aggregation="weighted_avg"),
_ind("usp_per_kg_dm", "уСП/кг СВ", "г", derived="g_per_kg_dm", from_key="usp"),
_ind("usp_in_om", "уСП в ОВ", "г", ["уСП в ОР", "уСП в ОВ"]),
_ind("usp_low_fat", "уСП <7% СЖ", "г", ["уСП<7%"], aggregation="weighted_avg"),
_ind("usp_high_fat", "уСП >7% СЖ", "г", ["уСП>7%"], aggregation="weighted_avg"),
_ind("ndf", "Сырая клетчатка", "г", ["Сырая клетч", "Сырая клетчатка", "НДК"]),
_ind("ndf_total", "НДК общ", "г", ["НДК Общ", "НДК"]),
_ind("ndf_main_feed", "НДК осн. корм", "г", ["НДК Осн. Корм"]),
_ind("adf_total", "КДК общ", "г", ["КДК общ", "КДК"]),
_ind("structural_fiber", "Структур. клетчатка", "г", ["Структур клетч", "Структур. клетчатка"]),
_ind("structural_fiber_pct", "% стр. клетчатки", "%", ["% стр Сыр. Клетчатк.", "% стр Сыр. Клетч."], aggregation="weighted_avg"),
_ind("crude_fat", "Сырой жир", "г", ["Сырой жир"]),
_ind("rnb", "RNB", "г", ["RNB", "БРА", " БРА "], derived="rnb"),
_ind("calcium", "Ca", "г", ["Ca"]),
_ind("phosphorus", "P", "г", ["P"]),
_ind("magnesium", "Mg", "г", ["Mg"]),
_ind("sodium", "Na", "г", ["Na"]),
_ind("potassium", "K", "г", ["K"]),
_ind("dcab", "DCAB", "мэкв", ["DCAB Форм", "DCAB"]),
_ind("sugar_starch", "Сахар и крахмал", "г", ["Сахар и Крохм"]),
_ind("sugar_digestible_starch", "Сахар и усв. крахмал", "г", ["Сахар и усв. крох"]),
_ind("insoluble_starch", "Нераств. крахмал", "г", ["Нераств. Крохмал", "Нераств Крохм", "Нераств крахмал"]),
_ind("insoluble_starch_alt", "Нераств. крахмал (2)", "г", ["Нераств крохмал"]),
_ind("insoluble_starch_pct", "% нераств. крахмала", "%", ["% Нераств крохм"], aggregation="weighted_avg"),
_ind("sugar", "Сахар", "г", ["Сахар"]),
_ind("starch", "Крахмал", "г", ["Крахмал"]),
_ind("starch_pct_dm", "% крахмала СВ", "%", ["% Крохмал. СВ", "доля крохмала"], aggregation="weighted_avg"),
_ind("carotene", "Каротин", "мг", ["Каротин"]),
_ind("beta_carotene", "β-каротин", "мг", ["b -Каротин"]),
_ind("linoleic_acid", "Линолевая к-та", "г", ["Линолевая к-та"]),
_ind("linolenic_acid", "Линоленовая к-та", "г", ["Линоленовая ки-та"]),
_ind("butyric_acid", "Масляная к-та", "г", ["Масляная ки-та"]),
_ind("arachidonic_acid", "Арахидоновая к-та", "г", ["Арахидоновая ки-та"]),
_ind("polyenoic_acid", "Полиэновая к-та", "г", ["Полиэновая ки-та"]),
_ind("urea", "Мочевина", "г", ["Мочевина"]),
_ind("crude_ash", "Сырая зола", "г", ["Сырая зола"]),
_ind("nfe", "БЭВ", "г", ["БЕР", "БЭВ"]),
_ind("tdn_cattle", "КРС орг. вещество", "г", ["КРС Орг Вещ", "ВРХ Орг Вещ"]),
_ind("digestible_organic_matter", "Перевар. орг. вещество", "г", ["Перевар Орг Вещ", "Переварим Орг Вещ"]),
_ind("cp_cattle", "КРС протеин", "г", ["КРС Протеин"]),
_ind("digestible_protein", "Перевар. протеин", "г", ["Перевар Протеин", "Переварим Протеин"]),
_ind("fat_cattle", "КРС сырой жир", "г", ["КРС Сырой жир", "КРС Сирий жир", "КРС Сирой жир"]),
_ind("digestible_fat", "Перевар. сырой жир", "г", ["Перевар Сырой жир", "Перевар Сирой жир", "Переварим Сырой жир"]),
_ind("fiber_cattle", "КРС сырая клетчатка", "г", ["КРС Сырая клетч"]),
_ind("digestible_fiber", "Перевар. сырая клетчатка", "г", ["Перевар сыр клетч", "Переварим сырая клетч"]),
_ind("nfe_cattle", "КРС БЭВ", "г", ["КРС БЭВ"]),
_ind("digestible_nfe", "Перевар. БЭВ", "г", ["Перевар БЭВ", "Переварим БЭВ"]),
_ind("feed_value", "ВЕ", "", ["ВЕ"], aggregation="weighted_avg"),
_ind("fat_per_kg_dm", "СЖ/кг СВ", "г", derived="g_per_kg_dm", from_key="crude_fat"),
_ind("nsp_per_kg_dm", "НСП/кг СВ", "г", ["НСП/кг СВ"], aggregation="weighted_avg"),
_ind("cp_per_kg_dm", "СП/кг СВ", "г", derived="g_per_kg_dm", from_key="crude_protein"),
_ind("dom_per_kg_dm", "пОВ/кг СВ", "г", derived="g_per_kg_dm", from_key="digestible_organic_matter"),
_ind(
"digestible_fat_per_kg_dm",
"пСЖ/кг СВ",
"г",
derived="g_per_kg_dm",
from_key="digestible_fat",
),
_ind("fiber_pct_per_kg_dm", "%СК/кг СВ", "%", derived="pct_of_dm", from_key="ndf"),
_ind("fat_pct_per_kg_dm", "%-СЖ/кг СВ", "%", derived="pct_of_dm", from_key="crude_fat"),
_ind("usp_pct_per_kg_dm", "%-уСП / кг СВ", "%", derived="pct_of_dm", from_key="usp"),
_ind("ca_pct_per_kg_dm", "%-Ca / кг СВ", "%", derived="pct_of_dm", from_key="calcium"),
_ind("p_pct_per_kg_dm", "%-P / кг СВ", "%", derived="pct_of_dm", from_key="phosphorus"),
_ind("k_pct_per_kg_dm", "%-К / кг СВ", "%", derived="pct_of_dm", from_key="potassium"),
_ind("na_pct_per_kg_dm", "%-Na / кг СВ", "%", derived="pct_of_dm", from_key="sodium"),
_ind("mg_pct_per_kg_dm", "%-Mg / кг СВ", "%", derived="pct_of_dm", from_key="magnesium"),
_ind("ndf_pct_dm", "НДК % от СВ", "%", derived="pct_of_dm", from_key="ndf"),
_ind("ndf_main_pct_dm", "НДК-ОК % от СВ", "%", derived="pct_of_dm", from_key="ndf_main_feed"),
_ind("adf_pct_dm", "КДК % от СВ", "%", derived="pct_of_dm", from_key="adf_total"),
_ind("nfc_pct_dm", "НКВ % от СВ", "%", derived="pct_of_dm", from_key="nfe"),
_ind("sw_per_kg_dm", "SW / кг СВ", "", ["SW / кг СВ ()"], aggregation="weighted_avg"),
_ind("metab_oet", "Метаб. ОЕТ", "г", ["Метаб ОЕТ", "OEB"]),
_ind("metab_lysine", "Метаб. лизин", "г", ["Метаб лизин", "Лизин"]),
_ind("metab_threonine", "Метаб. треонин", "г", ["Метабол Треон", "Треонин"]),
_ind("metab_leucine", "Метаб. лейцин", "г", ["Метаб Лейц", "Метабол Лейцин", "Лейцин"]),
_ind("metab_isoleucine", "Метаб. изолейцин", "г", ["Метаб Изол", "Метабол Изолейц", "Изолейцин"]),
_ind("metab_valine", "Метаб. валин", "г", ["Метаб Вал", "Метабол Валин", "Валин"]),
_ind("vitamin_a", "Витамин А", "МЕ", ["Вит А"]),
_ind("vitamin_d", "Витамин D", "МЕ", ["Вит D"]),
_ind("vitamin_e", "Витамин Е", "мг", ["Вит Е"]),
_ind("vitamin_b1", "Витамин В1", "мг", ["Вит В1"]),
_ind("vitamin_b2", "Витамин В2", "мг", ["Вит В2"]),
_ind("vitamin_b6", "Витамин В6", "мг", ["Вит В6"]),
_ind("vitamin_b12", "Витамин В12", "мкг", ["Вит В12"]),
_ind("calcium_pantothenate", "Пантотенат кальция", "мг", ["Пант Кальц", "Пантеонат Кальция"]),
_ind("niacin", "Никотиновая к-та", "мг", ["Никот Ки-та", "Никотиновая кислота"]),
_ind("folic_acid", "Фолиевая к-та", "мг", ["Фол ки-та", "Фолиева кислота"]),
_ind("choline", "Холин", "мг", ["Холин"]),
_ind("biotin", "Биотин", "мкг", ["Биотин"]),
_ind("iron", "Fe", "мг", ["Fe"]),
_ind("zinc", "Zn", "мг", ["Zn"]),
_ind("copper", "Cu", "мг", ["Cu"]),
_ind("cobalt", "Co", "мг", ["Co"]),
_ind("manganese", "Mn", "мг", ["Mn"]),
_ind("selenium", "Se", "мг", ["Se"]),
_ind("iodine", "J", "мг", ["J"]),
_ind("oet", "OET", "", ["OEB", "OET"]),
_ind("synthesis_mj", "Синтез", "МДж", ["Синтез Мдж"]),
_ind("ca_p_ratio", "Са:P", "", derived="ratio", ratio_num="calcium", ratio_den="phosphorus"),
_ind("k_na_ratio", "K:Na", "", derived="ratio", ratio_num="potassium", ratio_den="sodium"),
_ind("ration_dm_pct_bw", "Рацион % СВ от веса", "%", derived="dm_pct_bw"),
_ind("ration_pct_bw", "Рацион % от веса", "%", derived="ration_pct_bw"),
# Дубликаты ключей норм (укр. заголовки) — тот же расчёт
_ind("ndf_pct_dm_uk", "НДК % від СВ", "%", derived="alias", alias_of="ndf_pct_dm"),
_ind("ndf_main_pct_dm_uk", "НДК-ОК % від СВ", "%", derived="alias", alias_of="ndf_main_pct_dm"),
_ind("adf_pct_dm_uk", "КДК % від СВ", "%", derived="alias", alias_of="adf_pct_dm"),
_ind("nfc_pct_dm_uk", "НКВ % від СВ", "%", derived="alias", alias_of="nfc_pct_dm"),
]
# Обратная совместимость
RATION_QUALITY_INDICATORS = RATION_ALL_INDICATORS
_INDICATOR_BY_KEY = {d["key"]: d for d in RATION_ALL_INDICATORS}
_LABEL_TO_KEY = {d["label"]: d["key"] for d in RATION_ALL_INDICATORS}
def indicator_by_key(key: str) -> dict[str, Any] | None:
return _INDICATOR_BY_KEY.get(key)
def label_to_indicator_key(label: str) -> str | None:
return _LABEL_TO_KEY.get(label)
def calc_indicator_keys() -> frozenset[str]:
return frozenset(_INDICATOR_BY_KEY)
@@ -0,0 +1,5 @@
from .execution_loader import load_execution
from .profile_loader import list_animal_profiles, load_animal_profile
from .ration_loader import load_ration
__all__ = ["load_ration", "load_execution", "list_animal_profiles", "load_animal_profile"]
@@ -0,0 +1,17 @@
from __future__ import annotations
import json
from typing import Any
def parse_json_text(value: Any, default: Any = None) -> Any:
if default is None:
default = {}
if value is None or value == "":
return default
if isinstance(value, dict):
return value
try:
return json.loads(value)
except (TypeError, json.JSONDecodeError):
return default
@@ -0,0 +1,29 @@
from __future__ import annotations
from app.modules.zootech.lab.dto.ration import ExecutionLineSnapshot, ExecutionSnapshot
from app.modules.zootech.wesp_bridge_models import Ingredient, Recipe
def load_execution(recipe_id: str) -> ExecutionSnapshot:
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
heads = int(recipe.heads_per_trip or 1)
ingredients = (
Ingredient.query.filter_by(recipe_id=recipe_id, is_deleted=False)
.order_by(Ingredient.order)
.all()
)
lines = []
for ing in ingredients:
wph = float(ing.weight_per_head or 0)
lines.append(
ExecutionLineSnapshot(
ingredient_id=ing.id,
component_id=ing.component_id,
name=ing.name,
weight_per_head=wph,
daily_kg_total=wph * heads,
)
)
return ExecutionSnapshot(recipe_id=recipe_id, heads_per_trip=heads, lines=tuple(lines))
@@ -0,0 +1,58 @@
from __future__ import annotations
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.services.profile_norms import load_norms_api
def list_animal_profiles(ration_type: str | None = None) -> list[dict]:
q = LabAnimalProfile.query.filter_by(is_deleted=False)
if ration_type:
q = q.filter_by(ration_type=ration_type.upper())
rows = q.order_by(LabAnimalProfile.profile_key).all()
return [_profile_dict(p, include_resolved=False) for p in rows]
def load_animal_profile(profile_id: str) -> dict:
profile = LabAnimalProfile.query.filter_by(id=profile_id, is_deleted=False).first()
if profile is None:
raise LookupError("Профиль не найден")
return _profile_dict(profile, resolve_norms=True, include_resolved=True)
def _profile_dict(
profile: LabAnimalProfile,
*,
resolve_norms: bool = False,
include_resolved: bool = False,
) -> dict:
norms_api = load_norms_api(profile, resolve_dynamic=include_resolved) if resolve_norms else {}
if not resolve_norms:
from app.modules.zootech.lab.services.profile_norms import load_norms_dict
indicators = load_norms_dict(profile.id)
norms_api = {"indicators": indicators}
from app.modules.zootech.lab.services.norms_params import norms_params_api
out: dict = {
"id": profile.id,
"profileKey": profile.profile_key,
"label": profile.label,
"rationType": profile.ration_type,
"massKg": profile.mass_kg,
"milkYieldKg": profile.milk_yield_kg,
"externalNo": profile.external_no,
"normsMethod": profile.norms_method or "wesp",
"normsParams": norms_params_api(profile),
"normsData": norms_api,
"norms": norms_api.get("indicators") or {},
"normsProfileKey": profile.profile_key,
"legacyNormsRemapped": False,
}
if include_resolved:
out["resolvedNorms"] = norms_api.get("resolvedIndicators") or {}
out["dynamicNorms"] = norms_api.get("dynamicNorms") or {}
if norms_api.get("coverage"):
out["coverage"] = norms_api["coverage"]
if norms_api.get("normsMeta"):
out["normsMeta"] = norms_api["normsMeta"]
return out
@@ -0,0 +1,125 @@
from __future__ import annotations
from app.modules.zootech.lab.calc.nutrients import norm_diff
from app.modules.zootech.lab.indicators import label_to_indicator_key
from app.modules.zootech.lab.dto.ration import RationLineSnapshot, RationSnapshot
from app.modules.zootech.lab.models import LabAnimalProfile, LabRationLine, LabRecipeRation
from app.modules.zootech.lab.services.component_nutrients import nutrients_calc_dict
from app.modules.zootech.lab.services.profile_norms import resolve_norms_for_profile
from app.modules.zootech.lab.services.ration_calc_store import (
load_compound_results,
load_params,
load_ration_results,
)
from app.modules.zootech.wesp_bridge_models import Component, Recipe
def _refresh_indicator_norms(
indicators: list[dict],
norms: dict[str, dict[str, float | None]],
) -> list[dict]:
"""min/max/diff в сохранённом расчёте — по текущему профилю норм."""
if not indicators or not norms:
return indicators
out: list[dict] = []
for row in indicators:
item = dict(row)
key = item.get("key") or label_to_indicator_key(str(item.get("label") or ""))
if key and key in norms:
bounds = norms[key]
min_v = bounds.get("min")
max_v = bounds.get("max")
item["min"] = min_v
item["max"] = max_v
item["diff"] = norm_diff(item.get("content"), min_v, max_v)
out.append(item)
return out
def load_ration(recipe_id: str) -> RationSnapshot:
recipe = Recipe.query.filter_by(id=recipe_id, is_deleted=False).first()
if recipe is None:
raise LookupError("Рецепт не найден")
header = LabRecipeRation.query.filter_by(recipe_id=recipe_id, is_deleted=False).first()
ration_type = recipe.ration_type or "BEEF"
norms: dict = {}
params: dict = {}
ration_results: dict = {}
compound_results: dict = {}
calculated_at = None
animal_profile_id = None
norms_profile_key = None
if header:
animal_profile_id = header.animal_profile_id
params = load_params(recipe_id, header)
ration_results = load_ration_results(recipe_id, header)
compound_results = load_compound_results(recipe_id)
calculated_at = header.calculated_at.isoformat() if header.calculated_at else None
if header.animal_profile_id:
profile = LabAnimalProfile.query.filter_by(
id=header.animal_profile_id, is_deleted=False
).first()
if profile:
norms_profile_key = profile.profile_key
norms = resolve_norms_for_profile(profile)
if ration_results.get("indicators") and norms:
ration_results = {
**ration_results,
"indicators": _refresh_indicator_norms(ration_results["indicators"], norms),
}
lines_q = (
LabRationLine.query.filter_by(recipe_id=recipe_id, is_deleted=False)
.order_by(LabRationLine.row_index)
.all()
)
line_snaps = []
for line in lines_q:
comp = Component.query.get(line.component_id) if line.component_id else None
nutrients = nutrients_calc_dict(line.component_id) if line.component_id else {}
dry_matter_pct = comp.dry_matter if comp else None
line_snaps.append(
RationLineSnapshot(
id=line.id,
component_id=line.component_id,
ingredient_name=line.ingredient_name or (comp.name if comp else None),
row_index=line.row_index,
daily_kg=line.daily_kg,
in_ration=bool(line.in_ration),
in_compound=bool(line.in_compound),
dry_matter=dry_matter_pct,
price_per_kg=comp.price if comp else None,
nutrients=nutrients,
)
)
return RationSnapshot(
recipe_id=recipe_id,
recipe_name=recipe.name,
ration_type=ration_type,
heads_per_trip=int(recipe.heads_per_trip or 1),
animal_profile_id=animal_profile_id,
params=params,
lines=tuple(line_snaps),
norms=norms,
ration_results=ration_results,
compound_results=compound_results,
calculated_at=calculated_at,
exists=header is not None,
norms_profile_key=norms_profile_key,
legacy_norms_remapped=False,
)
def ration_to_calc_lines(snapshot: RationSnapshot) -> list[dict]:
return [
{
"daily_kg": line.daily_kg,
"in_ration": line.in_ration,
"in_compound": line.in_compound,
"ingredient_name": line.ingredient_name,
"price_per_kg": line.price_per_kg,
"dry_matter": line.dry_matter,
"nutrients": line.nutrients,
"component_id": line.component_id,
}
for line in snapshot.lines
]
@@ -0,0 +1,17 @@
"""Re-export WESP lab model aliases for ported lab package."""
from app.modules.zootech.wesp_bridge_models import ( # noqa: F401
LabAnimalProfile,
LabComponentNutrientValue,
LabProfileNorm,
LabRacionNormyInfo,
LabRacionNormyMoskwa,
LabRacionNormyMoskwaMeta,
LabRacionNormyPiter,
LabRacionNormyPiterMeta,
LabRationCalcIndicator,
LabRationCalcTotal,
LabRationCompoundLine,
LabRationLine,
LabRecipeRation,
)
@@ -0,0 +1,290 @@
"""Каталог показателей норм профиля стада."""
from __future__ import annotations
import re
import unicodedata
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS, calc_indicator_keys
_CALC_META = {d["key"]: d for d in RATION_ALL_INDICATORS}
# Заголовок колонки CSV «Нормы Дойн/КРС» (после нормализации) → indicator_key в lab_profile_norm
NORM_HEADER_TO_KEY: dict[str, str] = {
"Сухое Вещество": "dry_matter",
"Сыр. Протеин": "crude_protein",
"уСП": "usp",
"RNB": "rnb",
"БРА": "rnb",
"ЧЭЛ- КРС": "nel",
"ОЭ-КРС": "oe",
"Сырая клетч": "ndf",
"Сырая клетчат": "ndf",
"Сырая клетчатка": "ndf",
"Структур клетчатк": "structural_fiber",
"Сырой жир": "crude_fat",
"Сирой жир": "crude_fat",
"Ca": "calcium",
"P": "phosphorus",
"Mg": "magnesium",
"Na": "sodium",
"DCAB": "dcab",
"Сахар и Крохм": "sugar_starch",
"Нераств. Крохмал": "insoluble_starch",
"Нерозч. Крохмал": "insoluble_starch",
"Сахар": "sugar",
"Крохмал": "starch",
"Каротин": "carotene",
"b -Каротин": "beta_carotene",
"Линолевая к-та": "linoleic_acid",
"Линоленовая ки-та": "linolenic_acid",
"Масляная ки-та": "butyric_acid",
"Мочевина": "urea",
"СВ Основной корм": "dm_main",
"КРС Орг Вещ": "tdn_cattle",
"Перевар Орг Вещ": "digestible_organic_matter",
"КРС Протеин": "cp_cattle",
"Перевар Протеин": "digestible_protein",
"КРС Сирий жир": "fat_cattle",
"КРС Сирой жир": "fat_cattle",
"Перевар Сырой жир": "digestible_fat",
"Перевар Сирой жир": "digestible_fat",
"КРС Сырая клетч": "fiber_cattle",
"Перевар сыр клетч": "digestible_fiber",
"КРС БЭВ": "nfe_cattle",
"Перевар БЭВ": "digestible_nfe",
"ВЕ": "feed_value",
"НДК Общ": "ndf_total",
"НДК Осн. Корм": "ndf_main_feed",
"КДК общ": "adf_total",
"% нераств. протеин": "insoluble_protein_pct",
"Нераствор протеин": "insoluble_protein",
"СЖ/кг СВ": "fat_per_kg_dm",
"НСП/кг СВ": "nsp_per_kg_dm",
"СП/кг СВ": "cp_per_kg_dm",
"пОВ/кг СВ": "dom_per_kg_dm",
"пСЖ/кг СВ": "digestible_fat_per_kg_dm",
"уСП<7%": "usp_low_fat",
"уСП>7%": "usp_high_fat",
"уСП/кг СВ": "usp_per_kg_dm",
"уСП в ОВ": "usp_in_om",
"Нераств крохмал": "insoluble_starch_alt",
"Метаб ОЕТ": "metab_oet",
"Метаб лизин": "metab_lysine",
"Метабол Треон": "metab_threonine",
"Метаб Лейц": "metab_leucine",
"Метаб Изол": "metab_isoleucine",
"Метаб Вал": "metab_valine",
"НДК % від СВ ()": "ndf_pct_dm_uk",
"НДК % от СВ ()": "ndf_pct_dm",
"НДК-ОК % від СВ ()": "ndf_main_pct_dm_uk",
"НДК-ОК % от СВ ()": "ndf_main_pct_dm",
"КДК % від СВ ()": "adf_pct_dm_uk",
"КДК % от СВ ()": "adf_pct_dm",
"НКВ % від СВ ()": "nfc_pct_dm_uk",
"НКВ % от СВ ()": "nfc_pct_dm",
"SW / кг СВ ()": "sw_per_kg_dm",
"ЧЕЛ/кг СВ (МДж)": "nel_per_kg_dm",
"Нер. СП / кг СВ": "rup_per_kg_dm",
"%СК/кг СВ ()": "fiber_pct_per_kg_dm",
"%-СЖ/кг СВ ()": "fat_pct_per_kg_dm",
"%-уСП / кг СВ ()": "usp_pct_per_kg_dm",
"% уСП/кг СВ": "usp_pct_dm",
"%-Ca / кг СВ ()": "ca_pct_per_kg_dm",
"%-P / кг СВ ()": "p_pct_per_kg_dm",
"%-К / кг СВ ()": "k_pct_per_kg_dm",
"%-Na / кг СВ ()": "na_pct_per_kg_dm",
"%-Mg / кг СВ ()": "mg_pct_per_kg_dm",
"% стр Сыр. Клетчатк.": "structural_fiber_pct",
"% стр Сыр. Клетч.": "structural_fiber_pct",
"% Крохмал. СВ": "starch_pct_dm",
"% Нераств крохм": "insoluble_starch_pct",
"Са:P": "ca_p_ratio",
"K:Na": "k_na_ratio",
"Рацион % СВ от Веса КРС": "ration_dm_pct_bw",
"Рацион % от Веса КРС": "ration_pct_bw",
"Витамин А": "vitamin_a",
"Витамин D": "vitamin_d",
"Витамин Е": "vitamin_e",
"Витамин В1": "vitamin_b1",
"Витамин В2": "vitamin_b2",
"Витамин В6": "vitamin_b6",
"Витамин В12": "vitamin_b12",
"Пантеонат Кальция": "calcium_pantothenate",
"Пантеонат Кальцию": "calcium_pantothenate",
"Никотиновая кислота": "niacin",
"Фолиева кислота": "folic_acid",
"Фолиевач кислота": "folic_acid",
"Холин": "choline",
"Биотин": "biotin",
"Fe": "iron",
"Zn": "zinc",
"Cu": "copper",
"Co": "cobalt",
"Mn": "manganese",
"Se": "selenium",
"J": "iodine",
"K": "potassium",
"Сахар и усв. крох": "sugar_digestible_starch",
"Сырая зола": "crude_ash",
"Сірая зола": "crude_ash",
"БЭВ": "nfe",
"БЄВ": "nfe",
"Арахидоновая ки-та": "arachidonic_acid",
"Полиэновая ки-та": "polyenoic_acid",
"OET": "oet",
"Синтез Мдж": "synthesis_mj",
}
# Метаданные для UI (label/unit) — показатели вне расчёта рациона
_EXTRA_NORM_META: dict[str, dict[str, str]] = {
"beta_carotene": {"label": "β-каротин", "unit": "мг"},
"linoleic_acid": {"label": "Линолевая к-та", "unit": "г"},
"linolenic_acid": {"label": "Линоленовая к-та", "unit": "г"},
"butyric_acid": {"label": "Масляная к-та", "unit": "г"},
"urea": {"label": "Мочевина", "unit": "г"},
"tdn_cattle": {"label": "КРС орг. вещество", "unit": "г"},
"digestible_organic_matter": {"label": "Перевар. орг. вещество", "unit": "г"},
"cp_cattle": {"label": "КРС протеин", "unit": "г"},
"digestible_protein": {"label": "Перевар. протеин", "unit": "г"},
"fat_cattle": {"label": "КРС сырой жир", "unit": "г"},
"digestible_fat": {"label": "Перевар. сырой жир", "unit": "г"},
"fiber_cattle": {"label": "КРС сырая клетчатка", "unit": "г"},
"digestible_fiber": {"label": "Перевар. сырая клетчатка", "unit": "г"},
"nfe_cattle": {"label": "КРС БЭВ", "unit": "г"},
"digestible_nfe": {"label": "Перевар. БЭВ", "unit": "г"},
"feed_value": {"label": "ВЕ", "unit": ""},
"ndf_total": {"label": "НДК общ", "unit": "г"},
"ndf_main_feed": {"label": "НДК осн. корм", "unit": "г"},
"adf_total": {"label": "КДК общ", "unit": "г"},
"insoluble_protein_pct": {"label": "% нераств. протеин", "unit": "%"},
"fat_per_kg_dm": {"label": "СЖ/кг СВ", "unit": "г"},
"nsp_per_kg_dm": {"label": "НСП/кг СВ", "unit": "г"},
"cp_per_kg_dm": {"label": "СП/кг СВ", "unit": "г"},
"dom_per_kg_dm": {"label": "пОВ/кг СВ", "unit": "г"},
"digestible_fat_per_kg_dm": {"label": "пСЖ/кг СВ", "unit": "г"},
"usp_low_fat": {"label": "уСП <7% СЖ", "unit": "г"},
"usp_high_fat": {"label": "уСП >7% СЖ", "unit": "г"},
"usp_per_kg_dm": {"label": "уСП/кг СВ", "unit": "г"},
"usp_in_om": {"label": "уСП в ОВ", "unit": "г"},
"insoluble_starch_alt": {"label": "Нераств. крахмал (2)", "unit": "г"},
"metab_oet": {"label": "Метаб. ОЕТ", "unit": "г"},
"metab_lysine": {"label": "Метаб. лизин", "unit": "г"},
"metab_threonine": {"label": "Метаб. треонин", "unit": "г"},
"metab_leucine": {"label": "Метаб. лейцин", "unit": "г"},
"metab_isoleucine": {"label": "Метаб. изолейцин", "unit": "г"},
"metab_valine": {"label": "Метаб. валин", "unit": "г"},
"ndf_pct_dm": {"label": "НДК % от СВ", "unit": "%"},
"ndf_pct_dm_uk": {"label": "НДК % від СВ", "unit": "%"},
"ndf_main_pct_dm": {"label": "НДК-ОК % от СВ", "unit": "%"},
"ndf_main_pct_dm_uk": {"label": "НДК-ОК % від СВ", "unit": "%"},
"adf_pct_dm": {"label": "КДК % от СВ", "unit": "%"},
"adf_pct_dm_uk": {"label": "КДК % від СВ", "unit": "%"},
"nfc_pct_dm": {"label": "НКВ % от СВ", "unit": "%"},
"nfc_pct_dm_uk": {"label": "НКВ % від СВ", "unit": "%"},
"sw_per_kg_dm": {"label": "SW / кг СВ", "unit": ""},
"fiber_pct_per_kg_dm": {"label": "%СК/кг СВ", "unit": "%"},
"fat_pct_per_kg_dm": {"label": "%-СЖ/кг СВ", "unit": "%"},
"usp_pct_per_kg_dm": {"label": "%-уСП / кг СВ", "unit": "%"},
"ca_pct_per_kg_dm": {"label": "%-Ca / кг СВ", "unit": "%"},
"p_pct_per_kg_dm": {"label": "%-P / кг СВ", "unit": "%"},
"k_pct_per_kg_dm": {"label": "%-К / кг СВ", "unit": "%"},
"na_pct_per_kg_dm": {"label": "%-Na / кг СВ", "unit": "%"},
"mg_pct_per_kg_dm": {"label": "%-Mg / кг СВ", "unit": "%"},
"structural_fiber_pct": {"label": "% стр. клетчатки", "unit": "%"},
"starch_pct_dm": {"label": "% крахмала СВ", "unit": "%"},
"insoluble_starch_pct": {"label": "% нераств. крахмала", "unit": "%"},
"ca_p_ratio": {"label": "Са:P", "unit": ""},
"k_na_ratio": {"label": "K:Na", "unit": ""},
"ration_dm_pct_bw": {"label": "Рацион % СВ от веса", "unit": "%"},
"ration_pct_bw": {"label": "Рацион % от веса", "unit": "%"},
"vitamin_a": {"label": "Витамин А", "unit": "МЕ"},
"vitamin_d": {"label": "Витамин D", "unit": "МЕ"},
"vitamin_e": {"label": "Витамин Е", "unit": "мг"},
"vitamin_b1": {"label": "Витамин В1", "unit": "мг"},
"vitamin_b2": {"label": "Витамин В2", "unit": "мг"},
"vitamin_b6": {"label": "Витамин В6", "unit": "мг"},
"vitamin_b12": {"label": "Витамин В12", "unit": "мкг"},
"calcium_pantothenate": {"label": "Пантотенат кальция", "unit": "мг"},
"niacin": {"label": "Никотиновая к-та", "unit": "мг"},
"folic_acid": {"label": "Фолиевая к-та", "unit": "мг"},
"choline": {"label": "Холин", "unit": "мг"},
"biotin": {"label": "Биотин", "unit": "мкг"},
"iron": {"label": "Fe", "unit": "мг"},
"zinc": {"label": "Zn", "unit": "мг"},
"copper": {"label": "Cu", "unit": "мг"},
"cobalt": {"label": "Co", "unit": "мг"},
"manganese": {"label": "Mn", "unit": "мг"},
"selenium": {"label": "Se", "unit": "мг"},
"iodine": {"label": "J", "unit": "мг"},
"potassium": {"label": "K", "unit": "г"},
"sugar_digestible_starch": {"label": "Сахар и усв. крахмал", "unit": "г"},
"crude_ash": {"label": "Сырая зола", "unit": "г"},
"nfe": {"label": "БЭВ", "unit": "г"},
"arachidonic_acid": {"label": "Арахидоновая к-та", "unit": "г"},
"polyenoic_acid": {"label": "Полиэновая к-та", "unit": "г"},
"oet": {"label": "OET", "unit": ""},
"synthesis_mj": {"label": "Синтез", "unit": "МДж"},
}
_CALC_KEYS = calc_indicator_keys()
def norm_header(value: str) -> str:
return " ".join((value or "").split()).strip()
def norm_title_to_key(title: str) -> str | None:
"""Заголовок колонки CSV → indicator_key (или None)."""
key = NORM_HEADER_TO_KEY.get(norm_header(title))
if key:
return key
return _fallback_key(title)
def _fallback_key(title: str) -> str | None:
n = norm_header(title)
if not n:
return None
low = n.lower().replace("і", "и").replace("є", "е")
for header, key in NORM_HEADER_TO_KEY.items():
if header.lower() == low:
return key
slug = re.sub(r"[^a-z0-9]+", "_", unicodedata.normalize("NFKD", low).encode("ascii", "ignore").decode())
slug = slug.strip("_")[:48]
return f"norm_{slug}" if slug else None
def norm_indicator_meta(key: str) -> dict[str, str | bool]:
calc = _CALC_META.get(key)
if calc:
return {
"key": key,
"label": str(calc["label"]),
"unit": str(calc.get("unit") or ""),
"inCalc": True,
}
extra = _EXTRA_NORM_META.get(key, {})
return {
"key": key,
"label": extra.get("label", key),
"unit": extra.get("unit", ""),
"inCalc": key in _CALC_KEYS,
}
def list_norm_indicators() -> list[dict[str, str | bool]]:
keys: list[str] = []
seen: set[str] = set()
for key in NORM_HEADER_TO_KEY.values():
if key not in seen:
seen.add(key)
keys.append(key)
for key in sorted(_EXTRA_NORM_META):
if key not in seen:
keys.append(key)
return [norm_indicator_meta(k) for k in keys]
def calc_norm_keys() -> frozenset[str]:
return _CALC_KEYS
@@ -0,0 +1,28 @@
"""Канонические indicator_key для нутриентов компонента (без fuzzy-match)."""
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.norm_catalog import NORM_HEADER_TO_KEY
def augment_with_indicator_keys(data: dict[str, Any] | None) -> dict[str, float]:
"""Дублирует значения под slug indicator_key."""
if not data:
return {}
out: dict[str, float] = {}
for raw_key, raw_val in data.items():
try:
out[str(raw_key)] = float(raw_val)
except (TypeError, ValueError):
continue
for header, indicator_key in NORM_HEADER_TO_KEY.items():
if header in out and indicator_key not in out:
out[indicator_key] = out[header]
return out
def canonicalize_for_storage(data: dict[str, Any] | None) -> dict[str, float]:
"""При записи в EAV: slug indicator_key + исходный заголовок."""
return augment_with_indicator_keys(data)
@@ -0,0 +1,163 @@
"""Канонические поля zootech-показателей компонента (parity tab INGREDIENT_NUTRIENT_EDIT_ROWS)."""
from __future__ import annotations
from typing import Any
# column_name -> ключи в API/calc (первый — канон для UI)
NUTRIENT_FIELD_SPECS: tuple[tuple[str, tuple[str, ...]], ...] = (
("crude_protein", ("Сыр. Протеин",)),
("usp", ("уСП",)),
("rnb", ("RNB", "БРА", " БРА ")),
("nel_cattle", ("ЧЭЛ- КРС", " ЧЭЛ- КРС", "ЧЭЛ - КРС Форм")),
("oe_cattle", ("ОЭ-КРС", " ОЭ-КРС", "OЭ КРС форм")),
("ndf", ("Сырая клетчатка", "Сырая клетч")),
("structural_fiber", ("Структур. клетчатка", "Структур клетч")),
("crude_fat", ("Сырой жир",)),
)
def _normalize_key(value: str) -> str:
return " ".join((value or "").split()).strip().lower()
def _parse_num(value: Any) -> float | None:
if value is None or value == "":
return None
try:
n = float(value)
except (TypeError, ValueError):
return None
return None if n != n else n
def dry_matter_g_per_kg(dry_matter_pct: float | None) -> float | None:
"""СВ г/кг из канонического component.dry_matter (%): 88.7% → 887 г/кг."""
dm = _parse_num(dry_matter_pct)
if dm is None:
return None
if 0 < dm <= 100:
return dm * 10.0
if dm > 100:
return dm
return None
def resolve_sv_g_per_kg(
nutrients: dict[str, Any] | None,
dry_matter_pct: float | None,
) -> float | None:
"""Deprecated alias: СВ только из component.dry_matter (%). nutrients игнорируется."""
return dry_matter_g_per_kg(dry_matter_pct)
def read_from_mapping(data: dict[str, Any] | None, keys: tuple[str, ...]) -> float | None:
if not data:
return None
for search in keys:
target = _normalize_key(search)
for k, v in data.items():
if _normalize_key(str(k)) != target:
continue
n = _parse_num(v)
if n is not None:
return n
return None
def api_dict_to_column_values(data: dict[str, Any] | None) -> dict[str, float | None]:
payload = data or {}
out: dict[str, float | None] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
out[col] = read_from_mapping(payload, keys)
return out
def derived_to_column_values(data: dict[str, Any] | None) -> dict[str, float | None]:
return api_dict_to_column_values(data)
def column_values_to_mapping(values: dict[str, float | None]) -> dict[str, float]:
out: dict[str, float] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
val = values.get(col)
n = _parse_num(val)
if n is not None:
out[keys[0]] = n
return out
def mapping_to_calc_dict(data: dict[str, Any] | None) -> dict[str, float]:
if not data:
return {}
from app.modules.zootech.lab.indicators import RATION_ALL_INDICATORS
result: dict[str, float] = {}
for _col, keys in NUTRIENT_FIELD_SPECS:
val = read_from_mapping(data, keys)
if val is None:
continue
for key in keys:
result[key] = val
seen: set[str] = set()
for defn in RATION_ALL_INDICATORS:
for key in defn.get("nutrient_keys") or []:
if key in seen:
continue
seen.add(key)
val = read_from_mapping(data, (key,))
if val is not None:
result[key] = val
return result
def row_to_calc_dict(row: Any | None) -> dict[str, float]:
if row is None:
return {}
result: dict[str, float] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
val = getattr(row, col, None)
if val is None:
continue
n = _parse_num(val)
if n is None:
continue
for key in keys:
result[key] = n
return result
def row_to_api_dict(row: Any | None) -> dict[str, float]:
"""API nutrients — канонические ключи tab."""
if row is None:
return {}
out: dict[str, float] = {}
for col, keys in NUTRIENT_FIELD_SPECS:
val = getattr(row, col, None)
n = _parse_num(val)
if n is not None:
out[keys[0]] = n
return out
def row_is_empty(row: Any | None) -> bool:
if row is None:
return True
for col, _keys in NUTRIENT_FIELD_SPECS:
if _parse_num(getattr(row, col, None)) is not None:
return False
return True
def legacy_json_to_column_values(raw: str | None) -> dict[str, float | None]:
if not raw or not str(raw).strip() or str(raw).strip() == "{}":
return {col: None for col, _ in NUTRIENT_FIELD_SPECS}
import json
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
data = {}
if not isinstance(data, dict):
data = {}
return api_dict_to_column_values(data)
@@ -0,0 +1,9 @@
"""Справочные zootech-профили (norm_*) vs пользовательские стандарты."""
from __future__ import annotations
_REFERENCE_PREFIX = "norm_"
def is_reference_profile(profile_key: str | None) -> bool:
return bool(profile_key) and profile_key.startswith(_REFERENCE_PREFIX)
@@ -0,0 +1,23 @@
"""Пути к seed-данным WESP (data/seed/)."""
from __future__ import annotations
from pathlib import Path
_API_ROOT = Path(__file__).resolve().parents[4]
_REPO_ROOT = _API_ROOT.parent.parent
def seed_dir() -> Path:
local = _API_ROOT / "data" / "seed"
if local.exists():
return local
return _REPO_ROOT / "WESP_REL" / "data" / "seed"
def norms_dir() -> Path:
return seed_dir() / "norms"
def nutrients_dir() -> Path:
return seed_dir() / "nutrients"
@@ -0,0 +1,3 @@
from .api import ration_to_api
__all__ = ["ration_to_api"]
@@ -0,0 +1,52 @@
from __future__ import annotations
from typing import Any
from app.modules.zootech.lab.dto.ration import RationSnapshot
def ration_to_api(snapshot: RationSnapshot) -> dict[str, Any]:
return {
"recipeId": snapshot.recipe_id,
"recipeName": snapshot.recipe_name,
"rationType": snapshot.ration_type,
"headsPerTrip": snapshot.heads_per_trip,
"exists": snapshot.exists,
"animalProfileId": snapshot.animal_profile_id,
"params": snapshot.params,
"norms": snapshot.norms,
"normsProfileKey": snapshot.norms_profile_key,
"legacyNormsRemapped": snapshot.legacy_norms_remapped,
"rationResults": snapshot.ration_results,
"compoundResults": snapshot.compound_results,
"calculatedAt": snapshot.calculated_at,
"lines": [
{
"id": line.id,
"componentId": line.component_id,
"ingredientName": line.ingredient_name,
"rowIndex": line.row_index,
"dailyKg": line.daily_kg,
"inRation": line.in_ration,
"inCompound": line.in_compound,
"dryMatter": line.dry_matter,
"pricePerKg": line.price_per_kg,
}
for line in snapshot.lines
],
}
def diff_to_api(diff: dict[str, Any]) -> dict[str, Any]:
return {
"hasChanges": diff.get("has_changes", False),
"lines": [
{
"componentId": row.get("component_id"),
"masterKg": row.get("master_kg"),
"executionKg": row.get("execution_kg"),
"reasons": row.get("reasons", []),
}
for row in diff.get("lines", [])
],
}
@@ -0,0 +1,249 @@
from __future__ import annotations
import uuid
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.feed_groups import classify_feed_group
from app.modules.zootech.lab.calc.gfe_policies import DeriveContext
from app.modules.zootech.lab.calc.ingredient_catalog import DERIVED_HEADERS
from app.modules.zootech.lab.calc.ingredient_derive import derive_ingredient_nutrients
from app.modules.zootech.lab.models import LabComponentNutrientValue
from app.modules.zootech.lab.nutrient_keys import augment_with_indicator_keys, canonicalize_for_storage
from app.modules.zootech.lab.nutrient_schema import (
dry_matter_g_per_kg,
mapping_to_calc_dict,
read_from_mapping,
)
from app.modules.zootech.wesp_bridge_models import Component
_DERIVE_INPUT_KEYS = ("СВ", "Сыр. Протеин", "Сырая клетч", "Сырой жир")
_EAV_SKIP_KEYS = frozenset({"СВ"})
_MAIN_FEED_KEYS = ("Осн.Корм", "СВ Основной корм")
def derive_context_for_component(
component_id: str | None,
merged: dict[str, Any] | None = None,
) -> DeriveContext:
"""Контекст derive: тип корма + признак основного корма."""
main_feed = read_from_mapping(merged or {}, _MAIN_FEED_KEYS)
is_main = main_feed is not None and float(main_feed) > 0
feed_group: str = "unknown"
if component_id:
comp = Component.query.filter_by(id=component_id, is_deleted=False).first()
if comp is not None:
feed_group = classify_feed_group(comp)
return DeriveContext(feed_group=feed_group, is_main_feed=is_main) # type: ignore[arg-type]
def _component_dry_matter_pct(component_id: str | None) -> float | None:
if not component_id:
return None
comp = Component.query.get(component_id)
return comp.dry_matter if comp else None
def _inject_sv_for_derive(merged: dict[str, Any], dry_matter_pct: float | None) -> dict[str, Any]:
out = dict(merged)
sv = dry_matter_g_per_kg(dry_matter_pct)
if sv is not None:
out["СВ"] = sv
return out
def _should_derive(merged: dict[str, Any]) -> bool:
"""Derive при полном базовом вводе (СВ из component.dry_matter + 3 показателя)."""
return all(read_from_mapping(merged, (k,)) is not None for k in _DERIVE_INPUT_KEYS)
def nutrients_full_dict(component_id: str | None) -> dict[str, float]:
if not component_id:
return {}
rows = LabComponentNutrientValue.query.filter_by(component_id=component_id).all()
return {
row.nutrient_key: float(row.value)
for row in rows
if row.value is not None and row.nutrient_key not in _EAV_SKIP_KEYS
}
def nutrients_api_dict(component_id: str | None) -> dict[str, float]:
return nutrients_full_dict(component_id)
def _fill_missing_derived(
merged: dict[str, float],
*,
component_id: str | None,
) -> dict[str, float]:
"""Добавляет derived-поля (пОВ, переваримые фракции), не перезаписывая введённые лаб. значения."""
if not _should_derive(merged):
return merged
ctx = derive_context_for_component(component_id, merged)
derived = derive_ingredient_nutrients(merged, context=ctx)
out = dict(merged)
for header in DERIVED_HEADERS:
if read_from_mapping(out, (header,)) is not None:
continue
val = read_from_mapping(derived, (header,))
if val is not None:
out[header] = float(val)
return out
def _repair_for_ration_calc(
full: dict[str, float],
dry_matter_pct: float | None,
component_id: str | None = None,
) -> dict[str, float]:
merged = _inject_sv_for_derive(full, dry_matter_pct)
oe = read_from_mapping(merged, ("ОЭ-КРС", " ОЭ-КРС"))
nel = read_from_mapping(merged, ("ЧЭЛ- КРС", " ЧЭЛ- КРС"))
energy_bad = (oe is not None and oe < 0) or (nel is not None and nel < 0)
if energy_bad:
minimal = {key: read_from_mapping(merged, (key,)) for key in _DERIVE_INPUT_KEYS}
if all(v is not None for v in minimal.values()):
ctx = derive_context_for_component(component_id, merged)
merged.update(derive_ingredient_nutrients(minimal, context=ctx))
else:
merged = _fill_missing_derived(merged, component_id=component_id)
return merged
def _calc_dict_from_full(
full: dict[str, float],
dry_matter_pct: float | None,
component_id: str | None,
) -> dict[str, float]:
repaired = _repair_for_ration_calc(full, dry_matter_pct, component_id)
return mapping_to_calc_dict(augment_with_indicator_keys(repaired))
def nutrients_calc_dict(component_id: str | None) -> dict[str, float]:
full = nutrients_full_dict(component_id)
dry_matter_pct = _component_dry_matter_pct(component_id)
return _calc_dict_from_full(full, dry_matter_pct, component_id)
def nutrients_calc_dict_batch(component_ids: list[str]) -> dict[str, dict[str, float]]:
"""Batch-load EAV + dry_matter for formulate (one SQL round-trip per table)."""
unique = list(dict.fromkeys(cid for cid in component_ids if cid))
if not unique:
return {}
eav_by_id: dict[str, dict[str, float]] = {cid: {} for cid in unique}
rows = LabComponentNutrientValue.query.filter(
LabComponentNutrientValue.component_id.in_(unique),
).all()
for row in rows:
if row.value is None or row.nutrient_key in _EAV_SKIP_KEYS:
continue
eav_by_id.setdefault(row.component_id, {})[row.nutrient_key] = float(row.value)
dm_by_id: dict[str, float | None] = {cid: None for cid in unique}
for comp in Component.query.filter(
Component.id.in_(unique),
Component.is_deleted.is_(False),
).all():
dm_by_id[comp.id] = comp.dry_matter
return {
cid: _calc_dict_from_full(eav_by_id.get(cid, {}), dm_by_id.get(cid), cid)
for cid in unique
}
def nutrients_is_empty(component_id: str | None) -> bool:
if not component_id:
return True
return (
LabComponentNutrientValue.query.filter(
LabComponentNutrientValue.component_id == component_id,
LabComponentNutrientValue.nutrient_key.notin_(_EAV_SKIP_KEYS),
).first()
is None
)
def _upsert_eav(component_id: str, nutrients: dict[str, float]) -> None:
existing = {
row.nutrient_key: row
for row in LabComponentNutrientValue.query.filter_by(component_id=component_id).all()
}
for key in list(existing):
if key in _EAV_SKIP_KEYS:
db.session.delete(existing[key])
existing = {k: v for k, v in existing.items() if k not in _EAV_SKIP_KEYS}
for key, value in nutrients.items():
if value is None or key in _EAV_SKIP_KEYS:
continue
row = existing.get(key)
if row is None:
row = LabComponentNutrientValue(
id=str(uuid.uuid4()),
component_id=component_id,
nutrient_key=key,
value=float(value),
)
db.session.add(row)
existing[key] = row
else:
row.value = float(value)
def _prepare_payload(
component_id: str,
nutrients: dict[str, Any] | None,
*,
pin: bool = False,
) -> dict[str, float]:
dry_matter_pct = _component_dry_matter_pct(component_id)
merged = _inject_sv_for_derive(
{**nutrients_full_dict(component_id), **(nutrients or {})},
dry_matter_pct,
)
if pin:
payload = merged
elif _should_derive(merged):
ctx = derive_context_for_component(component_id, merged)
derived = derive_ingredient_nutrients(merged, context=ctx)
payload = {**merged, **derived}
else:
payload = merged
stored = canonicalize_for_storage(payload)
return {k: v for k, v in stored.items() if k not in _EAV_SKIP_KEYS}
def save_component_nutrients(
component_id: str,
nutrients: dict[str, Any] | None,
*,
user_id: str = "system",
pin: bool = False,
) -> dict[str, Any]:
"""Единственная точка записи EAV + derive. Возвращает stored dict и affectedRecipeIds."""
stored = _prepare_payload(component_id, nutrients, pin=pin)
_upsert_eav(component_id, stored)
from app.modules.zootech.lab.services.ration_recalc import find_rations_by_component
affected = find_rations_by_component(component_id)
return {"stored": stored, "affectedRecipeIds": affected, "userId": user_id}
# Backward-compatible aliases for tests and gradual migration
def upsert_from_api_dict(component_id: str, nutrients: dict[str, Any] | None) -> dict[str, float]:
result = save_component_nutrients(component_id, nutrients)
return result["stored"]
def pin_nutrient_values(component_id: str, values: dict[str, float]) -> None:
save_component_nutrients(component_id, values, pin=True)
def upsert_from_column_values(component_id: str, values: dict[str, float | None]) -> dict[str, float]:
from app.modules.zootech.lab.nutrient_schema import column_values_to_mapping
merged = nutrients_full_dict(component_id)
merged.update(column_values_to_mapping(values))
return upsert_from_api_dict(component_id, merged)
@@ -0,0 +1,52 @@
"""Сериализация параметров методики норм на профиле."""
from __future__ import annotations
import json
from typing import Any
from app.modules.zootech.lab.calc.norms_resolver import NormsParams
from app.modules.zootech.lab.models import LabAnimalProfile
def load_norms_params(profile: LabAnimalProfile) -> NormsParams:
raw = profile.norms_params_json
if not raw:
return NormsParams()
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError):
return NormsParams()
return NormsParams.from_dict(data if isinstance(data, dict) else {})
def save_norms_params(profile: LabAnimalProfile, params: NormsParams | dict[str, Any] | None) -> None:
if params is None:
profile.norms_params_json = None
return
if isinstance(params, NormsParams):
payload = {
k: v
for k, v in (
("milkFatPct", params.milk_fat_pct),
("lactationNo", params.lactation_no),
("lactationStage", params.lactation_stage),
("bodyCondition", params.body_condition),
("housingSystem", params.housing_system),
("koncOeSv", params.konc_oe_sv),
)
if v is not None
}
else:
payload = dict(params)
profile.norms_params_json = json.dumps(payload, ensure_ascii=False) if payload else None
def norms_params_api(profile: LabAnimalProfile) -> dict[str, Any]:
if not profile.norms_params_json:
return {}
try:
data = json.loads(profile.norms_params_json)
return data if isinstance(data, dict) else {}
except (json.JSONDecodeError, TypeError):
return {}
@@ -0,0 +1,226 @@
from __future__ import annotations
import json
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.norms_resolver import NormsResolveRequest, normalize_norms_method, resolve_norms
from app.modules.zootech.lab.services.norms_params import load_norms_params
from app.modules.zootech.lab.calc.norms import merge_norms_from_profile
from app.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.lab.models import LabAnimalProfile, LabProfileNorm
from app.modules.zootech.wesp_bridge_models import default_uuid
def _parse_profile_payload(data: Any) -> dict[str, Any]:
if data is None:
return {}
if isinstance(data, str):
try:
data = json.loads(data or "{}")
except (json.JSONDecodeError, TypeError):
return {}
return data if isinstance(data, dict) else {}
def load_norms_dict(profile_id: str | None) -> dict[str, dict[str, float | None]]:
if not profile_id:
return {}
rows = (
LabProfileNorm.query.filter_by(profile_id=profile_id)
.order_by(LabProfileNorm.indicator_key)
.all()
)
return {
row.indicator_key: {
"min": parse_num(row.min_value),
"max": parse_num(row.max_value),
}
for row in rows
}
def resolve_norms_for_profile(
profile: LabAnimalProfile,
*,
method: str | None = None,
params_override: dict | None = None,
force_dynamic: bool = False,
) -> dict[str, dict[str, float | None]]:
"""Нормы для расчёта рациона по выбранной методике."""
stored = load_norms_dict(profile.id)
norms_method = normalize_norms_method(method or profile.norms_method)
params = load_norms_params(profile)
if params_override:
from app.modules.zootech.lab.calc.norms_resolver import NormsParams
base = {
"milkFatPct": params.milk_fat_pct,
"lactationNo": params.lactation_no,
"lactationStage": params.lactation_stage,
"bodyCondition": params.body_condition,
"housingSystem": params.housing_system,
"koncOeSv": params.konc_oe_sv,
}
base.update(params_override)
params = NormsParams.from_dict(base)
resolved, _ = resolve_norms(
NormsResolveRequest(
method=norms_method,
stored=stored,
mass_kg=profile.mass_kg,
milk_yield_kg=profile.milk_yield_kg,
ration_type=profile.ration_type,
force_dynamic=force_dynamic,
params=params,
)
)
return resolved
def load_norms_api(profile: LabAnimalProfile, *, resolve_dynamic: bool = True) -> dict[str, Any]:
stored = load_norms_dict(profile.id)
payload: dict[str, Any] = {}
if profile.mass_kg is not None:
payload["massKg"] = profile.mass_kg
if profile.milk_yield_kg is not None:
payload["milkYieldKg"] = profile.milk_yield_kg
if profile.external_no is not None:
payload["externalNo"] = profile.external_no
if stored:
payload["indicators"] = stored
if resolve_dynamic:
resolved, meta = resolve_norms(
NormsResolveRequest(
method=normalize_norms_method(profile.norms_method),
stored=stored,
mass_kg=profile.mass_kg,
milk_yield_kg=profile.milk_yield_kg,
ration_type=profile.ration_type,
params=load_norms_params(profile),
)
)
payload["resolvedIndicators"] = resolved
payload["normsMethod"] = meta.get("normsMethod", profile.norms_method or "wesp")
dynamic = meta.get("dynamicNorms") or meta.get("dynamic") or {}
if dynamic:
payload["dynamicNorms"] = dynamic
if meta.get("meta"):
payload["normsMeta"] = meta["meta"]
if meta.get("coverage"):
payload["coverage"] = meta["coverage"]
return payload
def clear_profile_norms(profile_id: str) -> None:
LabProfileNorm.query.filter_by(profile_id=profile_id).delete(synchronize_session=False)
def save_norms_from_payload(profile: LabAnimalProfile, data: Any) -> None:
payload = _parse_profile_payload(data)
profile.mass_kg = parse_num(payload.get("massKg", payload.get("mass_kg")))
if "milkYieldKg" in payload or "milk_yield_kg" in payload:
profile.milk_yield_kg = parse_num(payload.get("milkYieldKg", payload.get("milk_yield_kg")))
ext = payload.get("externalNo", payload.get("external_no"))
profile.external_no = int(ext) if ext is not None and str(ext).strip() != "" else None
merged = merge_norms_from_profile(payload, profile.ration_type)
skip_keys = {"massKg", "mass_kg", "externalNo", "external_no", "indicators"}
for key, bounds in payload.items():
if key in skip_keys or not isinstance(bounds, dict):
continue
if "min" in bounds or "max" in bounds:
merged[str(key)] = {
"min": parse_num(bounds.get("min")),
"max": parse_num(bounds.get("max")),
}
indicators = payload.get("indicators")
if isinstance(indicators, dict):
for key, bounds in indicators.items():
if not isinstance(bounds, dict):
continue
merged[str(key)] = {
"min": parse_num(bounds.get("min")),
"max": parse_num(bounds.get("max")),
}
clear_profile_norms(profile.id)
for indicator_key, bounds in merged.items():
min_v = bounds.get("min")
max_v = bounds.get("max")
if min_v is None and max_v is None:
continue
db.session.add(
LabProfileNorm(
id=default_uuid(),
profile_id=profile.id,
indicator_key=indicator_key,
min_value=min_v,
max_value=max_v,
)
)
def import_norms_from_legacy_text(profile: LabAnimalProfile, raw: str | None) -> None:
save_norms_from_payload(profile, raw)
def _can_sync_racion(profile: LabAnimalProfile) -> bool:
method = normalize_norms_method(profile.norms_method)
if method not in ("racion_moscow", "racion_piter"):
return False
if not profile.mass_kg or profile.mass_kg <= 0:
return False
if not profile.milk_yield_kg or profile.milk_yield_kg <= 0:
return False
if method == "racion_piter":
from app.modules.zootech.lab.services.norms_params import load_norms_params
params = load_norms_params(profile)
if params.konc_oe_sv is None or params.konc_oe_sv <= 0:
return False
return True
def sync_racion_norms_to_profile(profile: LabAnimalProfile) -> int:
"""Вычислить RACION-нормы и upsert min в lab_profile_norm (max сохраняется)."""
if not _can_sync_racion(profile):
return 0
stored = load_norms_dict(profile.id)
resolved, _ = resolve_norms(
NormsResolveRequest(
method=normalize_norms_method(profile.norms_method),
stored=stored,
mass_kg=profile.mass_kg,
milk_yield_kg=profile.milk_yield_kg,
ration_type=profile.ration_type,
params=load_norms_params(profile),
)
)
existing = {
row.indicator_key: row
for row in LabProfileNorm.query.filter_by(profile_id=profile.id).all()
}
updated = 0
for key, bounds in resolved.items():
min_v = bounds.get("min")
if min_v is None:
continue
row = existing.get(key)
if row is None:
db.session.add(
LabProfileNorm(
id=default_uuid(),
profile_id=profile.id,
indicator_key=key,
min_value=min_v,
max_value=bounds.get("max"),
)
)
updated += 1
elif row.min_value != min_v:
row.min_value = min_v
if bounds.get("max") is not None:
row.max_value = bounds.get("max")
updated += 1
return updated
@@ -0,0 +1,156 @@
"""Импорт и загрузка справочников RACION из БД."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.racion.tables import clear_tables_cache
from app.modules.zootech.lab.models import (
LabRacionNormyInfo,
LabRacionNormyMoskwa,
LabRacionNormyMoskwaMeta,
LabRacionNormyPiter,
LabRacionNormyPiterMeta,
)
from app.modules.zootech.lab.seed_paths import seed_dir
from app.modules.zootech.wesp_bridge_models import default_uuid
_SEED_DIR = seed_dir() / "racion"
@dataclass
class RacionReferenceImportStats:
moskwa_rows: int = 0
piter_rows: int = 0
info_rows: int = 0
errors: list[str] = field(default_factory=list)
def _read_seed(name: str) -> dict:
path = _SEED_DIR / name
return json.loads(path.read_text(encoding="utf-8"))
def import_racion_reference(*, replace: bool = True) -> RacionReferenceImportStats:
stats = RacionReferenceImportStats()
try:
moskwa = _read_seed("moskwa_lactir.json")
piter = _read_seed("piter_lactir.json")
info = _read_seed("normy_info.json")
except (OSError, json.JSONDecodeError) as exc:
stats.errors.append(str(exc))
return stats
if replace:
LabRacionNormyMoskwa.query.delete(synchronize_session=False)
LabRacionNormyMoskwaMeta.query.delete(synchronize_session=False)
LabRacionNormyPiter.query.delete(synchronize_session=False)
LabRacionNormyPiterMeta.query.delete(synchronize_session=False)
LabRacionNormyInfo.query.delete(synchronize_session=False)
for row in moskwa.get("rows") or []:
db.session.add(
LabRacionNormyMoskwa(
id=default_uuid(),
npitv=int(row["npitv"]),
pom=int(row.get("pom") or 1),
koef=row.get("koef"),
popr_k_json=json.dumps(row.get("popr_k") or [], ensure_ascii=False),
)
)
stats.moskwa_rows += 1
db.session.add(
LabRacionNormyMoskwaMeta(
id=default_uuid(),
meta_key="udoy_boundaries",
meta_json=json.dumps(moskwa.get("udoy_boundaries") or [], ensure_ascii=False),
)
)
for entry in piter.get("entries") or []:
db.session.add(
LabRacionNormyPiter(
id=default_uuid(),
npitv=int(entry["npitv"]),
konc=float(entry["konc"]),
udoy=float(entry["udoy"]),
normy_json=json.dumps(entry.get("normy") or [], ensure_ascii=False),
)
)
stats.piter_rows += 1
db.session.add(
LabRacionNormyPiterMeta(
id=default_uuid(),
meta_key="mass_kg_values",
meta_json=json.dumps(piter.get("mass_kg_values") or [], ensure_ascii=False),
)
)
for row in info.get("rows") or []:
db.session.add(
LabRacionNormyInfo(
id=default_uuid(),
nperem=int(row["nperem"]),
znachenie_json=json.dumps(row.get("znachenie") or [], ensure_ascii=False),
)
)
stats.info_rows += 1
db.session.commit()
clear_tables_cache()
return stats
def load_moskwa_lactir_from_db() -> dict | None:
if LabRacionNormyMoskwa.query.count() == 0:
return None
rows = []
for r in LabRacionNormyMoskwa.query.order_by(LabRacionNormyMoskwa.npitv, LabRacionNormyMoskwa.pom).all():
rows.append(
{
"npitv": r.npitv,
"pom": r.pom,
"koef": r.koef,
"popr_k": json.loads(r.popr_k_json or "[]"),
}
)
meta = LabRacionNormyMoskwaMeta.query.filter_by(meta_key="udoy_boundaries").first()
boundaries = json.loads(meta.meta_json or "[]") if meta else []
return {"udoy_boundaries": boundaries, "rows": rows}
def load_piter_lactir_from_db() -> dict | None:
if LabRacionNormyPiter.query.count() == 0:
return None
entries = []
for r in LabRacionNormyPiter.query.order_by(
LabRacionNormyPiter.npitv, LabRacionNormyPiter.konc, LabRacionNormyPiter.udoy
).all():
entries.append(
{
"npitv": r.npitv,
"konc": r.konc,
"udoy": r.udoy,
"normy": json.loads(r.normy_json or "[]"),
}
)
meta = LabRacionNormyPiterMeta.query.filter_by(meta_key="mass_kg_values").first()
masses = json.loads(meta.meta_json or "[]") if meta else [400, 450, 500, 550, 600, 650, 700, 750]
return {"mass_kg_values": masses, "entries": entries}
def load_normy_info_from_db() -> dict | None:
if LabRacionNormyInfo.query.count() == 0:
return None
rows = []
for r in LabRacionNormyInfo.query.order_by(LabRacionNormyInfo.nperem).all():
rows.append({"nperem": r.nperem, "znachenie": json.loads(r.znachenie_json or "[]")})
by_nperem = {row["nperem"]: row["znachenie"] for row in rows}
mass_kg_values = by_nperem.get(14) or by_nperem.get(13) or [400, 450, 500, 550, 600, 650, 700, 750]
return {"rows": rows, "mass_kg_values": mass_kg_values}
@@ -0,0 +1,204 @@
from __future__ import annotations
import logging
from datetime import datetime
from typing import Any
from app.modules.zootech.wesp_bridge_db import db
from app.modules.zootech.lab.calc.nutrients import parse_num
from app.modules.zootech.lab.indicators import label_to_indicator_key
from app.modules.zootech.lab.models import (
LabRationCalcIndicator,
LabRationCalcTotal,
LabRationCompoundLine,
LabRecipeRation,
)
from app.modules.zootech.wesp_bridge_models import default_uuid
_log = logging.getLogger("app.lab.calc")
def _indicator_key(row: dict[str, Any]) -> str | None:
key = row.get("key")
if key:
return str(key)
label = row.get("label")
if label:
return label_to_indicator_key(str(label))
return None
def _log_calc_errors(recipe_id: str, errors: list[str] | None) -> None:
for message in errors or []:
text = str(message or "").strip()
if text:
_log.warning("ration calc recipe=%s: %s", recipe_id, text)
def clear_calc(recipe_id: str) -> None:
for model in (
LabRationCalcTotal,
LabRationCalcIndicator,
LabRationCompoundLine,
):
model.query.filter_by(recipe_id=recipe_id).delete(synchronize_session=False)
def _save_totals(recipe_id: str, scope: str, totals: list[dict[str, Any]] | None) -> None:
for idx, row in enumerate(totals or []):
db.session.add(
LabRationCalcTotal(
id=default_uuid(),
recipe_id=recipe_id,
scope=scope,
metric_key=str(row.get("key") or f"metric_{idx}"),
label=str(row.get("label") or ""),
value=parse_num(row.get("value")),
sort_order=idx,
)
)
def _save_indicators(
recipe_id: str,
scope: str,
indicators: list[dict[str, Any]] | None,
) -> None:
for idx, row in enumerate(indicators or []):
db.session.add(
LabRationCalcIndicator(
id=default_uuid(),
recipe_id=recipe_id,
scope=scope,
indicator_key=_indicator_key(row),
label=str(row.get("label") or ""),
unit=str(row.get("unit") or ""),
min_value=parse_num(row.get("min")),
max_value=parse_num(row.get("max")),
content=parse_num(row.get("content")),
diff=parse_num(row.get("diff")),
sort_order=idx,
)
)
def _save_compound_lines(recipe_id: str, lines: list[dict[str, Any]] | None) -> None:
for idx, row in enumerate(lines or []):
db.session.add(
LabRationCompoundLine(
id=default_uuid(),
recipe_id=recipe_id,
row_index=idx,
ingredient_name=row.get("ingredient_name"),
daily_kg=parse_num(row.get("daily_kg")),
share_pct=parse_num(row.get("share_pct")),
)
)
def save_calc_result(recipe_id: str, result: dict[str, Any], header: LabRecipeRation) -> None:
clear_calc(recipe_id)
_log_calc_errors(recipe_id, result.get("errors"))
header.calc_engine = str(result.get("engine") or "native")
calculated_at = result.get("calculated_at")
if calculated_at:
try:
header.calculated_at = datetime.fromisoformat(str(calculated_at).replace("Z", "+00:00"))
except ValueError:
header.calculated_at = datetime.utcnow()
else:
header.calculated_at = datetime.utcnow()
_save_totals(recipe_id, "ration", result.get("totals"))
_save_indicators(recipe_id, "ration", result.get("indicators"))
compound = result.get("compound")
if compound:
_save_totals(recipe_id, "compound", compound.get("totals"))
_save_indicators(recipe_id, "compound", compound.get("indicators"))
_save_compound_lines(recipe_id, compound.get("lines"))
def _load_totals(recipe_id: str, scope: str) -> list[dict[str, Any]]:
rows = (
LabRationCalcTotal.query.filter_by(recipe_id=recipe_id, scope=scope)
.order_by(LabRationCalcTotal.sort_order)
.all()
)
return [
{"key": row.metric_key, "label": row.label, "value": row.value}
for row in rows
]
def _load_indicators(recipe_id: str, scope: str) -> list[dict[str, Any]]:
rows = (
LabRationCalcIndicator.query.filter_by(recipe_id=recipe_id, scope=scope)
.order_by(LabRationCalcIndicator.sort_order)
.all()
)
return [
{
"key": row.indicator_key,
"label": row.label,
"unit": row.unit,
"min": row.min_value,
"max": row.max_value,
"content": row.content,
"diff": row.diff,
}
for row in rows
]
def load_compound_results(recipe_id: str) -> dict[str, Any]:
totals = _load_totals(recipe_id, "compound")
indicators = _load_indicators(recipe_id, "compound")
lines = (
LabRationCompoundLine.query.filter_by(recipe_id=recipe_id)
.order_by(LabRationCompoundLine.row_index)
.all()
)
if not totals and not indicators and not lines:
return {}
return {
"totals": totals,
"indicators": indicators,
"lines": [
{
"ingredient_name": row.ingredient_name,
"daily_kg": row.daily_kg,
"share_pct": row.share_pct,
}
for row in lines
],
}
def load_ration_results(recipe_id: str, header: LabRecipeRation | None) -> dict[str, Any]:
if header is None or header.calculated_at is None:
return {}
totals = _load_totals(recipe_id, "ration")
indicators = _load_indicators(recipe_id, "ration")
if not totals and not indicators:
return {}
compound = load_compound_results(recipe_id)
payload: dict[str, Any] = {
"calculated_at": header.calculated_at.isoformat(),
"engine": header.calc_engine or "native",
"totals": totals,
"indicators": indicators,
}
if compound:
payload["compound"] = compound
return payload
def load_params(recipe_id: str, header: LabRecipeRation | None) -> dict[str, Any]:
if header is None or not header.seed_source:
return {}
if header.seed_source == "execution":
return {"seeded_from": "execution"}
if header.seed_source == "synced_from":
return {"synced_from": "execution"}
return {"source": header.seed_source}
@@ -0,0 +1,40 @@
from __future__ import annotations
from app.modules.zootech.lab.models import LabRationLine
def find_rations_by_component(component_id: str) -> list[str]:
"""Recipe IDs с не удалёнными строками рациона, использующими component."""
if not component_id:
return []
rows = (
LabRationLine.query.filter_by(component_id=component_id, is_deleted=False)
.with_entities(LabRationLine.recipe_id)
.distinct()
.all()
)
return sorted({str(r[0]) for r in rows if r[0]})
def recalculate_rations(recipe_ids: list[str], user_id: str = "system") -> dict:
from app.modules.zootech.lab.commands.recalculate import recalculate_ration
ok: list[str] = []
failed: dict[str, str] = {}
results: dict[str, dict] = {}
for recipe_id in recipe_ids:
try:
results[recipe_id] = recalculate_ration(recipe_id, user_id)
ok.append(recipe_id)
except Exception as exc:
failed[recipe_id] = str(exc)
return {"ok": ok, "failed": failed, "results": results}
def on_component_nutrients_changed(component_id: str, user_id: str = "system") -> dict:
"""Найти и пересчитать все рационы с данным компонентом (для будущего UI)."""
recipe_ids = find_rations_by_component(component_id)
if not recipe_ids:
return {"recipeIds": [], "ok": [], "failed": {}}
report = recalculate_rations(recipe_ids, user_id)
return {"recipeIds": recipe_ids, **report}
@@ -0,0 +1,83 @@
"""Справочник zootech-норм из БД (lab_animal_profile norm_* + lab_profile_norm)."""
from __future__ import annotations
from app.modules.zootech.lab.commands.import_seed import norm_profile_key
from app.modules.zootech.lab.models import LabAnimalProfile
from app.modules.zootech.lab.reference_profiles import is_reference_profile
from app.modules.zootech.lab.services.profile_norms import load_norms_dict
_VALID_RATIONS = frozenset({"DAIRY", "BEEF"})
class ReferenceNormsCatalogEmptyError(LookupError):
"""Справочник norm_* в БД пуст — нужен import_seed.py --norms."""
def _normalize_ration(ration_type: str) -> str:
ration = (ration_type or "").strip().upper()
if ration not in _VALID_RATIONS:
raise ValueError(f"Неизвестная линейка: {ration_type}")
return ration
def _reference_profiles_query(ration_type: str):
ration = _normalize_ration(ration_type)
return (
LabAnimalProfile.query.filter(
LabAnimalProfile.is_deleted.is_(False),
LabAnimalProfile.ration_type == ration,
LabAnimalProfile.profile_key.like("norm_%"),
)
.order_by(LabAnimalProfile.external_no, LabAnimalProfile.profile_key)
)
def list_seed_norm_catalog(ration_type: str) -> list[dict]:
"""Краткий список строк справочника для выпадающего списка."""
rows = _reference_profiles_query(ration_type).all()
if not rows:
raise ReferenceNormsCatalogEmptyError(
"Справочник пуст — выполните: python3 scripts/import_seed.py --norms"
)
out: list[dict] = []
for profile in rows:
if profile.external_no is None:
continue
indicators = load_norms_dict(profile.id)
out.append(
{
"profileId": profile.id,
"profileKey": profile.profile_key,
"externalNo": profile.external_no,
"label": profile.label,
"rationType": profile.ration_type,
"massKg": profile.mass_kg,
"indicatorCount": len(indicators),
}
)
out.sort(key=lambda row: row["externalNo"])
return out
def get_seed_norm_entry(ration_type: str, external_no: int) -> dict | None:
"""Полная строка справочника: метаданные + indicators min/max."""
ration = _normalize_ration(ration_type)
profile = _reference_profiles_query(ration).filter_by(external_no=external_no).first()
if profile is None:
profile = LabAnimalProfile.query.filter_by(
profile_key=norm_profile_key(ration, external_no),
is_deleted=False,
).first()
if profile is None or not is_reference_profile(profile.profile_key):
return None
indicators = load_norms_dict(profile.id)
return {
"profileId": profile.id,
"externalNo": profile.external_no if profile.external_no is not None else external_no,
"profileKey": profile.profile_key,
"label": profile.label,
"rationType": profile.ration_type,
"massKg": profile.mass_kg,
"indicators": indicators,
}
@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>{{ title }}</title>
<style>
body { font-family: system-ui, sans-serif; margin: 24px; color: #111; }
h1 { font-size: 1.25rem; margin: 0 0 8px; }
.meta { color: #555; margin-bottom: 16px; font-size: 0.9rem; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th, td { border: 1px solid #ccc; padding: 6px 8px; text-align: left; }
th { background: #f5f5f5; }
.num { text-align: right; }
.indicators { display: flex; flex-wrap: wrap; gap: 8px; }
.badge { border: 1px solid #ddd; border-radius: 4px; padding: 4px 8px; font-size: 0.85rem; }
@media print { body { margin: 12px; } }
</style>
</head>
<body>
<h1>{{ title }}</h1>
<div class="meta">
Рецепт: {{ recipe_name }} · Тип: {{ ration_type }} · Голов/рейс: {{ heads }}
</div>
<table>
<thead>
<tr>
<th>Ингредиент</th>
<th class="num">кг/день</th>
{% if mode == "compound" %}<th>В комб.</th>{% else %}<th>В рац.</th>{% endif %}
</tr>
</thead>
<tbody>
{% for line in lines %}
<tr>
<td>{{ line.name }}</td>
<td class="num">{{ line.daily_kg }}</td>
<td>{{ "да" if line.included else "нет" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if indicators %}
<div class="indicators">
{% for ind in indicators %}
<span class="badge">{{ ind.label }}: {{ ind.content }} {{ ind.unit }}</span>
{% endfor %}
</div>
{% endif %}
<script>window.addEventListener("load", () => window.print());</script>
</body>
</html>