@@ -0,0 +1,344 @@
|
||||
"""Стартовые nutrients для компонентов без EAV (демо-шаблоны, не лабораторный анализ).
|
||||
|
||||
При старте и пересчёте рациона заполняет только пустые `lab_component_nutrient_value`.
|
||||
Маркер в data/ — журнал последнего прогона, не блокирует новые компоненты.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from app import db
|
||||
from config import Config
|
||||
from app.lab.nutrient_schema import read_from_mapping
|
||||
from app.lab.services.component_nutrients import nutrients_full_dict, nutrients_is_empty, save_component_nutrients
|
||||
from app.models.component 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(Config.DATA_DIR) / _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}
|
||||
Reference in New Issue
Block a user