321 lines
11 KiB
Python
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",
|
|
)
|