"""Стартовые 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}