Files
site/WESP_REL/app/lab/calc/feed_groups.py
T
2026-07-17 12:57:18 +03:00

212 lines
6.7 KiB
Python

"""Группы кормов для авторациона — канонический тип компонента + 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