""" Обновление рецепта (PUT): логика перенесена из legacy/proga_monolith.update_recipe. Sync: listeners + явные enqueue для recipe/ingredient/unloading_group (в т.ч. deleted_* из UI). """ from __future__ import annotations import logging from datetime import datetime from typing import Any, Dict, List, Optional from sqlalchemy import select from app import db from app.models import Component, Ingredient, Recipe, UnloadingGroup from app.services.recipe_calculator import calculate_recipe from app.services.sync_manager import enqueue_sync_queue_task logger = logging.getLogger(__name__) class RecipeUpdateError(Exception): def __init__(self, message: str, status_code: int = 400): self.message = message self.status_code = status_code super().__init__(message) def _now() -> datetime: return datetime.now() def _ing_weight_per_head(ing: dict) -> float: for key in ("weightPerHead", "weight_per_head"): if key in ing and ing[key] is not None and ing[key] != "": try: return float(ing[key]) except (TypeError, ValueError): continue return 0.0 def _resolve_component(ing: dict) -> Optional[Component]: component_id = ing.get("component_id") component = None if component_id: component = db.session.get(Component, str(component_id)) if not component: name = ing.get("name") if name: component = db.session.execute( select(Component).where(Component.name == name, Component.is_deleted.is_(False)) ).scalar_one_or_none() return component def _validate_strict_header(data: dict) -> None: """Как в монолите: name, heads_count, mixing_time обязательны.""" for key in ("name", "heads_count", "mixing_time"): if key not in data: raise RecipeUpdateError(f'Отсутствует обязательное поле "{key}"', 400) def _enqueue_recipe_children_sync(recipe_id: str) -> None: """ После сохранения рецепта — update в sync для всех активных ингредиентов и групп. ORM-listeners ставят задачи только на изменённые строки; reorder ингредиентов и частичные правки групп выгрузки иначе не доходят до peer (баготест #5–#6). Через pending-очередь session.info (как after_insert/after_update), а не прямой enqueue_sync_queue_task — иначе при autoflush дубли в sync_queue (SQLite). """ from app.models import _SYNC_ENQUEUE_INFO_KEY pending = db.session.info.setdefault(_SYNC_ENQUEUE_INFO_KEY, []) with db.session.no_autoflush: active_ingredients = db.session.execute( select(Ingredient).where( Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False), ) ).scalars().all() active_groups = db.session.execute( select(UnloadingGroup).where( UnloadingGroup.recipe_id == recipe_id, UnloadingGroup.is_deleted.is_(False), ) ).scalars().all() for ing in active_ingredients: pending.append(("ingredient", str(ing.id), "update", 3, None)) for grp in active_groups: pending.append(("unloading_group", str(grp.id), "update", 3, None)) def update_recipe_from_payload(recipe_id: str, data: Optional[dict]) -> Dict[str, Any]: """ Выполняет обновление рецепта в текущей сессии (без commit). Возвращает dict для jsonify: message, id, stats (как в легаси). """ if not data: raise RecipeUpdateError("Данные не предоставлены", 400) _validate_strict_header(data) logger.info( "[RECIPES-DB] update_recipe_from_payload start id=%s name_in_payload=%s", recipe_id, (data.get("name") or "")[:80], ) recipe = db.session.get(Recipe, recipe_id) if recipe is None: raise RecipeUpdateError("Рецепт не найден", 404) if getattr(recipe, "is_deleted", False): raise RecipeUpdateError("Рецепт удалён", 404) try: recipe.name = data["name"] recipe.heads_per_trip = int(data["heads_count"]) recipe.mixing_time = int(data["mixing_time"]) except (TypeError, ValueError) as e: raise RecipeUpdateError(f"Некорректные числовые поля рецепта: {e}", 400) from e recipe.trip_percent = float(data.get("trip_percent", 100) or 100) if "dry_matter_locked" in data: recipe.dry_matter_locked = bool(data.get("dry_matter_locked")) if "unloading_link_broken" in data: recipe.unloading_link_broken = bool(data.get("unloading_link_broken")) if "target_component_id" in data: recipe.target_component_id = data.get("target_component_id") recipe.updated_by = "system" recipe.updated_at = _now() recipe.version = (recipe.version or 1) + 1 existing_rows = db.session.execute( select(Ingredient).where( Ingredient.recipe_id == recipe_id, Ingredient.is_deleted.is_(False), ) ).scalars().all() existing_ingredients: Dict[int, Ingredient] = {ing.order: ing for ing in existing_rows} existing_ingredients_by_id: Dict[str, Ingredient] = {str(ing.id): ing for ing in existing_rows} existing_grows = db.session.execute( select(UnloadingGroup).where( UnloadingGroup.recipe_id == recipe_id, UnloadingGroup.is_deleted.is_(False), ) ).scalars().all() existing_groups: Dict[int, UnloadingGroup] = {g.order: g for g in existing_grows} existing_groups_by_id: Dict[str, UnloadingGroup] = {str(g.id): g for g in existing_grows} def _delete_ingredient_row(cur: Ingredient) -> None: ing_id = str(cur.id) cur.soft_delete(deleted_by_user="system") enqueue_sync_queue_task("ingredient", ing_id, "delete", priority=1) existing_ingredients_by_id.pop(ing_id, None) for ord_k, row in list(existing_ingredients.items()): if str(row.id) == ing_id: existing_ingredients.pop(ord_k, None) def _delete_group_row(cur: UnloadingGroup) -> None: gid = str(cur.id) cur.soft_delete(deleted_by_user="system") enqueue_sync_queue_task("unloading_group", gid, "delete", priority=1) existing_groups_by_id.pop(gid, None) for ord_k, row in list(existing_groups.items()): if str(row.id) == gid: existing_groups.pop(ord_k, None) for raw_id in data.get("deleted_ingredient_ids") or []: ing_id = str(raw_id or "").strip() if not ing_id: continue cur = existing_ingredients_by_id.get(ing_id) or db.session.get(Ingredient, ing_id) if cur is None or str(cur.recipe_id) != str(recipe_id) or cur.is_deleted: continue _delete_ingredient_row(cur) for raw_id in data.get("deleted_unloading_group_ids") or []: gid = str(raw_id or "").strip() if not gid: continue cur = existing_groups_by_id.get(gid) or db.session.get(UnloadingGroup, gid) if cur is None or str(cur.recipe_id) != str(recipe_id) or cur.is_deleted: continue _delete_group_row(cur) recipe_is_locked = bool(getattr(recipe, "dry_matter_locked", False)) calc_by_order: Dict[int, Dict[str, Any]] = {} group_calc_by_order: Dict[int, Dict[str, Any]] = {} ingredients_in: List[dict] = [x for x in (data.get("ingredients") or []) if isinstance(x, dict)] groups_in: List[dict] = [x for x in (data.get("unloading_groups") or []) if isinstance(x, dict)] if recipe_is_locked: try: heads_count_calc = int(data.get("heads_count") or recipe.heads_per_trip or 0) trip_percent_calc = float( data.get("trip_percent", recipe.trip_percent or 100) or 100 ) dm_component_ids = [ ing.get("component_id") for ing in ingredients_in if ing.get("component_id") ] dm_components = [] if dm_component_ids: dm_components = ( db.session.execute( select(Component).where( Component.id.in_(dm_component_ids), Component.is_deleted.is_(False), ) ) .scalars() .all() ) dm_by_component_id = {c.id: float(c.dry_matter or 0) for c in dm_components} calc_ingredients_payload: List[dict] = [] ing_orders: List[int] = [] for idx, ing in enumerate(ingredients_in, 1): order_value = int(ing.get("order", idx)) ing_orders.append(order_value) dm_percent = ing.get("dry_matter") if dm_percent is None: dm_percent = 0 try: dm_percent = float(dm_percent) except (TypeError, ValueError): dm_percent = 0.0 if dm_percent <= 0: cid = ing.get("component_id") try: dm_percent = float( dm_by_component_id.get(str(cid), 0) if cid else 0 ) except (TypeError, ValueError): dm_percent = 0.0 dm_per_head = ( ing.get("dry_matter_per_head") if ing.get("dry_matter_per_head") is not None else ing.get("dryMatterPerHead") ) try: dm_per_head = float(dm_per_head) if dm_per_head is not None else 0.0 except (TypeError, ValueError): dm_per_head = 0.0 if dm_per_head <= 0 and recipe_is_locked and order_value in existing_ingredients: old_ing = existing_ingredients[order_value] if ( old_ing.dry_matter_per_head is not None and old_ing.dry_matter_per_head > 0 ): dm_per_head = float(old_ing.dry_matter_per_head) else: old_wph = float(old_ing.weight_per_head or 0) old_dm = float(old_ing.dry_matter or 0) if old_dm > 0 and old_wph > 0: dm_per_head = old_wph * (old_dm / 100.0) else: raise RecipeUpdateError( f"Не удалось восстановить СВ/гол для ингредиента (order={order_value}). " "Убедитесь, что отправлено поле dry_matter_per_head или ингредиент существует в БД.", 400, ) if dm_per_head <= 0 and dm_percent > 0 and not recipe_is_locked: try: wph = float( ing.get("weight_per_head") or ing.get("weightPerHead") or 0 ) except (TypeError, ValueError): wph = 0.0 if wph > 0: dm_per_head = wph * (dm_percent / 100.0) if dm_percent <= 0: raise RecipeUpdateError( f"СВ,% (dry_matter) должно быть > 0 для расчета (order={order_value})", 400, ) calc_ingredients_payload.append( { "component_id": ing.get("component_id"), "dryMatterPerHead": dm_per_head, "dryMatter": dm_percent, } ) calc_groups_payload: List[dict] = [] group_orders: List[int] = [] for idx, group in enumerate(groups_in, 1): order_value = int(group.get("order", idx)) group_orders.append(order_value) calc_groups_payload.append( { "distributionType": group.get("distribution_type", "percent"), "value": float(group.get("value") or 0), } ) result_calc = calculate_recipe( ingredients=calc_ingredients_payload, heads_count=heads_count_calc, trip_percent=trip_percent_calc, unloading_groups=calc_groups_payload, component_dry_matter_map=None, calculate_from_dry_matter=True, ) for order_value, calc in zip( ing_orders, result_calc.get("ingredients", []) ): calc_by_order[order_value] = calc for order_value, calcg in zip( group_orders, result_calc.get("unloadingGroups", []) ): group_calc_by_order[order_value] = calcg except RecipeUpdateError: raise except Exception as e: logger.error( "[RECIPE-UPDATE] Ошибка серверного расчета при замке СВ: %s", e, exc_info=True, ) raise RecipeUpdateError( f"Ошибка расчета при замке СВ: {str(e)}", 400 ) from e seen_ingredient_ids: set[str] = set() seen_orders: set[int] = set() for idx, ing in enumerate(ingredients_in, 1): order_value = int(ing.get("order", idx)) component = _resolve_component(ing) if not component: raise RecipeUpdateError( f'Компонент не найден (id="{ing.get("component_id", "")}", ' f'name="{ing.get("name", "")}"). Проверьте, что компонент существует в базе данных.', 400, ) ing_id = str(ing.get("id") or "").strip() cur = None if ing_id and ing_id in existing_ingredients_by_id: cur = existing_ingredients_by_id[ing_id] elif order_value in existing_ingredients: cur = existing_ingredients[order_value] if cur is not None: cur.name = component.name if recipe_is_locked and order_value in calc_by_order: cur.weight_per_head = float( calc_by_order[order_value].get("weightPerHead", 0) or 0 ) cur.amount = float(calc_by_order[order_value].get("tripWeight", 0) or 0) dm_per_head_to_save = float( calc_by_order[order_value].get("dryMatterPerHead", 0) or 0 ) cur.dry_matter_per_head = ( dm_per_head_to_save if dm_per_head_to_save > 0 else None ) else: cur.weight_per_head = _ing_weight_per_head(ing) cur.amount = float(ing.get("amount", 0) or 0) cur_dm = float(ing.get("dry_matter", component.dry_matter) or 0) cur_wph = float(cur.weight_per_head or 0) cur.dry_matter_per_head = ( cur_wph * (cur_dm / 100.0) if (cur_dm > 0 and cur_wph > 0) else None ) cur.dry_matter = float(ing.get("dry_matter", component.dry_matter) or 0) cur.component_id = component.id cur.order = order_value cur.updated_by = "system" cur.updated_at = _now() cur.version = (cur.version or 1) + 1 existing_ingredients[order_value] = cur existing_ingredients_by_id[str(cur.id)] = cur seen_ingredient_ids.add(str(cur.id)) else: new_wph = _ing_weight_per_head(ing) new_amount = float(ing.get("amount", 0) or 0) new_dm_per_head = None if recipe_is_locked and order_value in calc_by_order: new_wph = float(calc_by_order[order_value].get("weightPerHead", 0) or 0) new_amount = float(calc_by_order[order_value].get("tripWeight", 0) or 0) new_dm_per_head = float( calc_by_order[order_value].get("dryMatterPerHead", 0) or 0 ) or None elif recipe_is_locked: raw = ing.get("dry_matter_per_head") or ing.get("dryMatterPerHead") new_dm_per_head = float(raw) if raw not in (None, "") else None else: cur_dm = float(ing.get("dry_matter", component.dry_matter) or 0) new_dm_per_head = ( new_wph * (cur_dm / 100.0) if (cur_dm > 0 and new_wph > 0) else None ) new_ingredient = Ingredient( name=component.name, weight_per_head=new_wph, amount=new_amount, dry_matter=float(ing.get("dry_matter", component.dry_matter) or 0), dry_matter_per_head=new_dm_per_head, component_id=component.id, order=order_value, recipe_id=recipe_id, created_by="system", updated_by="system", ) db.session.add(new_ingredient) db.session.flush() existing_ingredients[order_value] = new_ingredient existing_ingredients_by_id[str(new_ingredient.id)] = new_ingredient seen_ingredient_ids.add(str(new_ingredient.id)) seen_orders.add(order_value) for cur in list(existing_ingredients_by_id.values()): if str(cur.id) not in seen_ingredient_ids: _delete_ingredient_row(cur) seen_group_ids: set[str] = set() seen_group_orders: set[int] = set() for idx, group in enumerate(groups_in, 1): order_value = int(group.get("order", idx)) try: gname = group["name"] gdist = group["distribution_type"] gval = float(group["value"]) except (KeyError, TypeError, ValueError) as e: raise RecipeUpdateError( f"Некорректная группа выгрузки (order={order_value}): {e}", 400 ) from e group_id = str(group.get("id") or "").strip() cur = None if group_id and group_id in existing_groups_by_id: cur = existing_groups_by_id[group_id] elif order_value in existing_groups: cur = existing_groups[order_value] if cur is not None: cur.name = gname cur.distribution_type = gdist cur.value = gval if recipe_is_locked and order_value in group_calc_by_order: cur.weight = float( group_calc_by_order[order_value].get("calculatedWeight", 0) or 0 ) else: w = group.get("weight") cur.weight = float(w) if w not in (None, "") else None cur.order = order_value cur.updated_by = "system" cur.updated_at = _now() cur.version = (cur.version or 1) + 1 existing_groups[order_value] = cur existing_groups_by_id[str(cur.id)] = cur seen_group_ids.add(str(cur.id)) else: weight_val: Optional[float] if recipe_is_locked and order_value in group_calc_by_order: weight_val = float( group_calc_by_order[order_value].get("calculatedWeight", 0) or 0 ) else: w = group.get("weight") weight_val = float(w) if w not in (None, "") else None new_group = UnloadingGroup( name=gname, distribution_type=gdist, value=gval, weight=weight_val, order=order_value, recipe_id=recipe_id, created_by="system", updated_by="system", ) db.session.add(new_group) db.session.flush() existing_groups[order_value] = new_group existing_groups_by_id[str(new_group.id)] = new_group seen_group_ids.add(str(new_group.id)) seen_group_orders.add(order_value) for cur in list(existing_groups_by_id.values()): if str(cur.id) not in seen_group_ids: _delete_group_row(cur) _enqueue_recipe_children_sync(recipe_id) enqueue_sync_queue_task("recipe", recipe_id, "update", priority=3) logger.info( "[RECIPES-DB] update_recipe_from_payload session_ready id=%s name=%s version=%s " "ingredients_payload=%s groups_payload=%s", recipe_id, (recipe.name or "")[:80], recipe.version, len(ingredients_in), len(groups_in), ) stats = { "changed_components": 0, "affected_recipes": 0, "recalculated_recipes": 0, "updated_ingredients": 0, "updated_unloading_groups": 0, "skipped_recipes": 0, } return {"message": "Сохранено", "id": recipe_id, "stats": stats}