"""Группы кормов для авторациона — канонический тип компонента + legacy-маппинг.""" from __future__ import annotations from typing import Any from app.models import Component FEED_GROUPS: tuple[dict[str, Any], ...] = ( { "id": "rough", "label": "База — грубые", "shortLabel": "База", "required": True, "minPick": 1, "hint": "Без каркаса партия не взлетит. Я не бизнесмен — я специалист.", "step": 1, }, { "id": "succulent", "label": "Влага — сочные", "shortLabel": "Влага", "required": False, "minPick": 0, "hint": "Сочное сырьё. Можно не мешать — но чистота пострадает.", "step": 2, }, { "id": "concentrate", "label": "Энергия — концентраты", "shortLabel": "Энергия", "required": False, "minPick": 0, "hint": "Концентрат дозируй как реагент — точно. Держись подальше от моей территории.", "step": 3, }, { "id": "other", "label": "Добавки", "shortLabel": "Добавки", "required": False, "minPick": 0, "hint": "Минералы и премикс. Необязательно. Но Хайзенберг бы не пропустил.", "step": 4, }, ) # Канонические значения component.type (выбор в /components) FEED_COMPONENT_TYPES: tuple[dict[str, str], ...] = ( { "value": "Грубые корма", "feedGroup": "rough", "description": "Сено, солома.", }, { "value": "Сочные корма", "feedGroup": "succulent", "description": "Силос, корнеплоды.", }, { "value": "Концентрированные", "feedGroup": "concentrate", "description": "Зерно, комбикорм, жмых, шрот", }, { "value": "Добавки", "feedGroup": "other", "description": "Премикс, минералы, витамины, КЖП", }, ) _GROUP_BY_ID = {g["id"]: g for g in FEED_GROUPS} _LEGACY_TYPE_TO_GROUP: dict[str, str] = { "зерновые": "concentrate", "энергетические": "concentrate", "белковые": "concentrate", "минеральные": "other", "витаминные": "other", } # Старый тип «Объемные корма» — уточните до Грубые/Сочные; эвристика по имени _ROUGH_NAME_KEYS = ("солом", "сено", "hay", "straw") _SUCCULENT_NAME_KEYS = ("силос", "сенаж", "сочн", "корнеплод", "свекл", "морков", "тыкв", "зелен") _CONCENTRATE_NAME_KEYS = ("зерн", "концентр", "комбикорм", "комбик", "жмых", "шрот", "дробин", "пивн") def _norm(text: str | None) -> str: return (text or "").strip().lower() def _canonical_type_map() -> dict[str, str]: return {_norm(t["value"]): t["feedGroup"] for t in FEED_COMPONENT_TYPES} def _canonical_values() -> list[str]: return [t["value"] for t in FEED_COMPONENT_TYPES] def list_component_feed_types() -> list[dict[str, str]]: return [ { "value": t["value"], "feedGroup": t["feedGroup"], "description": t["description"], "groupLabel": _GROUP_BY_ID[t["feedGroup"]]["label"], } for t in FEED_COMPONENT_TYPES ] def is_canonical_feed_type(component_type: str | None) -> bool: return _norm(component_type) in _canonical_type_map() def feed_group_for_type(component_type: str | None) -> str | None: """Группа авторациона по component.type (канон или legacy).""" ctype = _norm(component_type) if not ctype: return None canonical = _canonical_type_map().get(ctype) if canonical: return canonical if ctype in ("объемные корма", "объёмные корма"): return None return _LEGACY_TYPE_TO_GROUP.get(ctype) def classify_feed_group(comp: Component) -> str: """Группа для авторациона: сначала component.type, иначе эвристика по имени (legacy).""" by_type = feed_group_for_type(comp.type) if by_type: return by_type name = _norm(comp.name) def has_any(keys: tuple[str, ...]) -> bool: return any(k in name for k in keys) if has_any(_ROUGH_NAME_KEYS): return "rough" if has_any(_SUCCULENT_NAME_KEYS): return "succulent" if has_any(_CONCENTRATE_NAME_KEYS): return "concentrate" if _norm(comp.type) in ("объемные корма", "объёмные корма"): return "succulent" return "other" def list_feed_groups_api() -> list[dict[str, Any]]: return [ { "id": g["id"], "label": g["label"], "shortLabel": g["shortLabel"], "required": g["required"], "minPick": g["minPick"], "hint": g["hint"], "step": g["step"], } for g in FEED_GROUPS ] def parse_group_selections(raw: Any) -> dict[str, list[str]]: if not isinstance(raw, dict): return {} out: dict[str, list[str]] = {} for gid in _GROUP_BY_ID: vals = raw.get(gid) or [] if isinstance(vals, list): out[gid] = [str(x) for x in vals if x] return out def flatten_group_selections(selections: dict[str, list[str]]) -> list[str]: seen: list[str] = [] for gid in _GROUP_BY_ID: for cid in selections.get(gid) or []: if cid not in seen: seen.append(cid) return seen def validate_group_selections(selections: dict[str, list[str]]) -> list[str]: errors: list[str] = [] for g in FEED_GROUPS: gid = g["id"] picked = selections.get(gid) or [] if g["required"] and len(picked) < int(g["minPick"]): errors.append(f"{gid}:need_{g['minPick']}") total = len(flatten_group_selections(selections)) if total < 3: errors.append("pool:need_3") return errors def triplet_meets_group_rules( triplet_ids: set[str], selections: dict[str, list[str]], ) -> bool: for g in FEED_GROUPS: if not g["required"]: continue pool = set(selections.get(g["id"]) or []) if not pool: continue if not triplet_ids & pool: return False return True