Интегрирован 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,249 @@
from __future__ import annotations
import uuid
from typing import Any
from app import db
from app.lab.calc.feed_groups import classify_feed_group
from app.lab.calc.gfe_policies import DeriveContext
from app.lab.calc.ingredient_catalog import DERIVED_HEADERS
from app.lab.calc.ingredient_derive import derive_ingredient_nutrients
from app.lab.models import LabComponentNutrientValue
from app.lab.nutrient_keys import augment_with_indicator_keys, canonicalize_for_storage
from app.lab.nutrient_schema import (
dry_matter_g_per_kg,
mapping_to_calc_dict,
read_from_mapping,
)
from app.models.component 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.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.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)
+52
View File
@@ -0,0 +1,52 @@
"""Сериализация параметров методики норм на профиле."""
from __future__ import annotations
import json
from typing import Any
from app.lab.calc.norms_resolver import NormsParams
from app.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 {}
+226
View File
@@ -0,0 +1,226 @@
from __future__ import annotations
import json
from typing import Any
from app import db
from app.lab.calc.norms_resolver import NormsResolveRequest, normalize_norms_method, resolve_norms
from app.lab.services.norms_params import load_norms_params
from app.lab.calc.norms import merge_norms_from_profile
from app.lab.calc.nutrients import parse_num
from app.lab.models import LabAnimalProfile, LabProfileNorm
from app.models.base 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.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.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,155 @@
"""Импорт и загрузка справочников RACION из БД."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
from app import db
from app.lab.calc.racion.tables import clear_tables_cache
from app.lab.models.racion_normy import (
LabRacionNormyInfo,
LabRacionNormyMoskwa,
LabRacionNormyMoskwaMeta,
LabRacionNormyPiter,
LabRacionNormyPiterMeta,
)
from app.models.base import default_uuid
_SEED_DIR = Path(__file__).resolve().parents[3] / "data" / "seed" / "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 import db
from app.lab.calc.nutrients import parse_num
from app.lab.indicators import label_to_indicator_key
from app.lab.models import (
LabRationCalcIndicator,
LabRationCalcTotal,
LabRationCompoundLine,
LabRecipeRation,
)
from app.models.base 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.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.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.lab.commands.import_seed import norm_profile_key
from app.lab.models import LabAnimalProfile
from app.lab.reference_profiles import is_reference_profile
from app.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,
}