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