"""Временная замена компонентов в плане на день.""" 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", )