Files
site/apps/api/app/modules/zootech/daily_plan/replacements.py
T
2026-07-17 12:57:18 +03:00

321 lines
11 KiB
Python

"""Временная замена компонентов в плане на день."""
from __future__ import annotations
from typing import Any, Dict, List, Optional, Set, Tuple
from sqlalchemy import select
from app import db
from app.models import Component, DailyIngredientReplacement, Ingredient, Recipe
from app.services.daily_plan.skips import (
_parse_plan_date,
_serialize_skip_dates,
_skip_active_filters,
_soft_delete_skip_row,
_soft_unskip_part,
_upsert_part_skip,
resolve_skip_range,
)
def _component_payload(component: Component, *, similar: bool = False) -> Dict[str, Any]:
return {
"id": component.id,
"name": component.name,
"type": component.type or "",
"dryMatter": float(component.dry_matter or 0),
"protein": float(component.protein or 0),
"energy": float(component.energy or 0),
"similar": similar,
}
def _similarity_score(source: Component, candidate: Component) -> float:
if source.id == candidate.id:
return -1.0
score = 0.0
src_type = (source.type or "").strip().lower()
cand_type = (candidate.type or "").strip().lower()
if src_type and cand_type and src_type == cand_type:
score += 100.0
dm_diff = abs(float(source.dry_matter or 0) - float(candidate.dry_matter or 0))
score += max(0.0, 50.0 - dm_diff * 2.0)
prot_diff = abs(float(source.protein or 0) - float(candidate.protein or 0))
score += max(0.0, 20.0 - prot_diff)
return score
def find_component_alternatives(
component_id: str,
*,
query: str = "",
limit: int = 20,
) -> Dict[str, Any]:
"""Похожие компоненты и поиск по всем активным."""
source = db.session.execute(
select(Component).where(
Component.id == component_id,
Component.is_deleted.is_(False),
)
).scalar_one_or_none()
if source is None:
raise LookupError("Компонент не найден")
q = (query or "").strip().lower()
try:
limit_val = max(1, min(int(limit), 100))
except (TypeError, ValueError):
limit_val = 20
candidates = db.session.execute(
select(Component).where(
Component.is_active.is_(True),
Component.is_deleted.is_(False),
Component.id != component_id,
)
).scalars().all()
scored: List[Tuple[float, Component]] = []
for candidate in candidates:
if q and q not in (candidate.name or "").lower():
continue
score = _similarity_score(source, candidate)
if score < 0:
continue
scored.append((score, candidate))
scored.sort(key=lambda item: (-item[0], (item[1].name or "").lower()))
similar_cutoff = 80.0 if (source.type or "").strip() else 40.0
similar: List[Dict[str, Any]] = []
all_items: List[Dict[str, Any]] = []
for score, candidate in scored:
is_similar = score >= similar_cutoff
payload = _component_payload(candidate, similar=is_similar)
all_items.append(payload)
if is_similar and len(similar) < 8:
similar.append(payload)
if len(all_items) >= limit_val:
break
return {
"sourceComponent": _component_payload(source),
"similar": similar,
"results": all_items,
}
def get_ingredient_replacement_map(
plan_date: Optional[str] = None,
) -> Dict[str, Dict[str, str]]:
"""recipe_id -> ingredient_id -> replacement_component_id."""
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(
DailyIngredientReplacement.recipe_id,
DailyIngredientReplacement.ingredient_id,
DailyIngredientReplacement.replacement_component_id,
).where(*_skip_active_filters(DailyIngredientReplacement, d))
).all()
out: Dict[str, Dict[str, str]] = {}
for recipe_id, ingredient_id, replacement_id in rows:
out.setdefault(recipe_id, {})[ingredient_id] = replacement_id
return out
def get_replaced_recipe_ids(plan_date: Optional[str] = None) -> Set[str]:
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(DailyIngredientReplacement.recipe_id).where(
*_skip_active_filters(DailyIngredientReplacement, d)
)
).scalars().all()
return set(rows)
def list_ingredient_replacements(plan_date: Optional[str] = None) -> List[Dict[str, Any]]:
d = _parse_plan_date(plan_date)
rows = db.session.execute(
select(
DailyIngredientReplacement,
Recipe.name,
Ingredient.name,
Component.name,
)
.join(Recipe, Recipe.id == DailyIngredientReplacement.recipe_id)
.join(Ingredient, Ingredient.id == DailyIngredientReplacement.ingredient_id)
.join(Component, Component.id == DailyIngredientReplacement.replacement_component_id)
.where(
*_skip_active_filters(DailyIngredientReplacement, d),
Recipe.is_deleted.is_(False),
Ingredient.is_deleted.is_(False),
Component.is_deleted.is_(False),
)
.order_by(Recipe.name.asc(), Ingredient.order.asc())
).all()
return [
{
"id": row.id,
"recipeId": row.recipe_id,
"recipeName": recipe_name,
"ingredientId": row.ingredient_id,
"ingredientName": ing_name or "—",
"replacementComponentId": row.replacement_component_id,
"replacementName": replacement_name or "—",
**_serialize_skip_dates(row, fallback=d),
}
for row, recipe_name, ing_name, replacement_name in rows
]
def replace_ingredient(
recipe_id: str,
ingredient_id: str,
replacement_component_id: str,
plan_date: Optional[str] = None,
*,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> DailyIngredientReplacement:
start = _parse_plan_date(plan_date)
_, valid_until = resolve_skip_range(start, duration=duration, until_date=until_date)
recipe = db.session.execute(
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
).scalar_one_or_none()
if recipe is None:
raise LookupError("Рецепт не найден")
ingredient = db.session.execute(
select(Ingredient).where(
Ingredient.id == ingredient_id,
Ingredient.recipe_id == recipe_id,
Ingredient.is_deleted.is_(False),
)
).scalar_one_or_none()
if ingredient is None:
raise LookupError("Компонент рейса не найден")
replacement = db.session.execute(
select(Component).where(
Component.id == replacement_component_id,
Component.is_deleted.is_(False),
Component.is_active.is_(True),
)
).scalar_one_or_none()
if replacement is None:
raise LookupError("Компонент-замена не найден")
if ingredient.component_id and ingredient.component_id == replacement_component_id:
raise LookupError("Выберите другой компонент")
return _upsert_part_skip(
DailyIngredientReplacement,
lookup_filters=(
DailyIngredientReplacement.recipe_id == recipe_id,
DailyIngredientReplacement.ingredient_id == ingredient_id,
),
create_fields={
"recipe_id": recipe_id,
"ingredient_id": ingredient_id,
"replacement_component_id": replacement_component_id,
"plan_date": start,
"valid_until": valid_until if valid_until != start else None,
},
user=user,
)
def collect_component_replacement_targets(
component_id: str,
plan_date: Optional[str] = None,
*,
dispenser_id: Optional[str] = None,
) -> List[Tuple[str, str]]:
"""Уникальные пары (recipe_id, ingredient_id) в плане для мастер-компонента."""
from app.services.daily_plan.builder import build_daily_plan
iso_date = _parse_plan_date(plan_date).isoformat()
if not dispenser_id:
return []
try:
plan = build_daily_plan(dispenser_id=dispenser_id, plan_date=iso_date)
except LookupError:
return []
seen: Set[Tuple[str, str]] = set()
targets: List[Tuple[str, str]] = []
for period in plan.get("periods") or []:
for trip in period.get("trips") or []:
recipe_id = str(trip.get("recipeId") or "")
if not recipe_id:
continue
for ing in trip.get("ingredients") or []:
if ing.get("skippedToday"):
continue
orig = str(ing.get("originalComponentId") or ing.get("componentId") or "")
if orig != component_id:
continue
ingredient_id = str(ing.get("id") or "")
if not ingredient_id:
continue
key = (recipe_id, ingredient_id)
if key in seen:
continue
seen.add(key)
targets.append(key)
return targets
def replace_component_in_plan(
component_id: str,
replacement_component_id: str,
plan_date: Optional[str] = None,
*,
dispenser_id: Optional[str] = None,
duration: Optional[str] = None,
until_date: Optional[str] = None,
user: str = "system",
) -> List[DailyIngredientReplacement]:
"""Заменить компонент во всех рейсах плана, где он используется."""
if component_id == replacement_component_id:
raise LookupError("Выберите другой компонент")
targets = collect_component_replacement_targets(
component_id,
plan_date,
dispenser_id=dispenser_id,
)
if not targets:
raise LookupError("Компонент не найден в плане на эту дату")
rows: List[DailyIngredientReplacement] = []
for recipe_id, ingredient_id in targets:
rows.append(
replace_ingredient(
recipe_id,
ingredient_id,
replacement_component_id,
plan_date,
duration=duration,
until_date=until_date,
user=user,
)
)
return rows
def undo_ingredient_replacement(
recipe_id: str,
ingredient_id: str,
plan_date: Optional[str] = None,
*,
user: str = "system",
) -> bool:
d = _parse_plan_date(plan_date)
return _soft_unskip_part(
DailyIngredientReplacement,
lookup_filters=(
DailyIngredientReplacement.recipe_id == recipe_id,
DailyIngredientReplacement.ingredient_id == ingredient_id,
*_skip_active_filters(DailyIngredientReplacement, d),
),
user=user,
table_name="daily_ingredient_replacement",
)