212 lines
6.7 KiB
Python
212 lines
6.7 KiB
Python
"""Группы кормов для авторациона — канонический тип компонента + legacy-маппинг."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from app.modules.zootech.wesp_bridge_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
|