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