import logging import threading import time from datetime import datetime from flask import Blueprint, current_app, jsonify, request from sqlalchemy import exists, select, update from sqlalchemy.exc import OperationalError from app import db from app.models import ( Component, FeedDispenser, FeedingPeriod, Ingredient, PeriodRecipe, Recipe, UnloadingGroup, ) from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal, require_paired_terminal from app.services.daily_plan.builder import trip_overlay_for_recipe from app.services.daily_plan.ingredient_weights import resolve_plan_ingredient_weights from app.services.daily_plan.adjustments import get_component_adjustment_map from app.services.daily_plan.replacements import get_ingredient_replacement_map from app.services.daily_plan.skips import ( get_skipped_ingredient_ids, get_skipped_unloading_group_ids, is_kiosk_recipe_list_request, ) from app.services.recipe_calculator import calculate_recipe from app.services.recipe_update_service import RecipeUpdateError, update_recipe_from_payload from app.services.sync_manager import _is_sqlite_lock_message, db_commit_with_retry, enqueue_sync_queue_task from app.services.sync_content_hash import compute_content_hash_hex, stable_payload_for_hash from config import Config logger = logging.getLogger(__name__) bp = Blueprint("recipes", __name__, url_prefix="/api/recipes") @bp.get("/ping") def recipes_ping(): """Health-check для модуля рецептов.""" return jsonify({"status": "ok"}), 200 def _add_no_cache_headers(response): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" return response def _error(message: str, status_code: int = 400): return jsonify({"error": True, "message": message}), status_code def _ingredient_weight_per_head(ing: dict) -> float: """Как в recipes.html: приходит weight_per_head или weightPerHead.""" 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 _ingredient_dry_matter_per_head(ing: dict): if not ing: return None for key in ("dryMatterPerHead", "dry_matter_per_head"): if key in ing and ing[key] is not None and ing[key] != "": try: return float(ing[key]) except (TypeError, ValueError): return None return None def _group_distribution_type(group: dict) -> str: return group.get("distributionType") or group.get("distribution_type") or "percent" def _unloading_groups_from_payload(data: dict) -> list: raw = data.get("unloadingGroups") or data.get("unloading_groups") or [] return [g for g in raw if isinstance(g, dict)] def _serialize_recipe( recipe: Recipe, *, plan_date: str | None = None, exclude_skipped: bool = False ): from datetime import date as date_cls apply_overlay = plan_date is not None resolved_date = None if apply_overlay: try: resolved_date = date_cls.fromisoformat(str(plan_date).strip()[:10]).isoformat() except ValueError: apply_overlay = False skipped_ingredient_ids: set[str] = set() skipped_group_ids: set[str] = set() replacement_map: dict[str, str] = {} component_adjustment_map: dict[str, dict] = {} if apply_overlay and resolved_date: skipped_ing_by_recipe = get_skipped_ingredient_ids(resolved_date) skipped_grp_by_recipe = get_skipped_unloading_group_ids(resolved_date) skipped_ingredient_ids = skipped_ing_by_recipe.get(recipe.id, set()) skipped_group_ids = skipped_grp_by_recipe.get(recipe.id, set()) replacement_map = get_ingredient_replacement_map(resolved_date).get(recipe.id, {}) component_adjustment_map = get_component_adjustment_map(resolved_date) kiosk_view = is_kiosk_recipe_list_request() hide_skipped = kiosk_view or exclude_skipped trip_overlay = None if apply_overlay and resolved_date: trip_overlay = trip_overlay_for_recipe(recipe, resolved_date) trip_ing_by_id = { row["id"]: row for row in (trip_overlay or {}).get("ingredients") or [] } trip_grp_by_id = { row["id"]: row for row in (trip_overlay or {}).get("unloadingGroups") or [] } ingredients = db.session.execute( select(Ingredient) .where(Ingredient.recipe_id == recipe.id, Ingredient.is_deleted.is_(False)) .order_by(Ingredient.order.asc()) ).scalars().all() comp_ids = [i.component_id for i in ingredients if i.component_id] comp_ids.extend(replacement_map.values()) components_by_id: dict[str, Component] = {} comp_name_by_id: dict[str, str] = {} if comp_ids: comps = db.session.execute( select(Component).where( Component.id.in_(set(comp_ids)), Component.is_deleted.is_(False), ) ).scalars().all() for c in comps: components_by_id[c.id] = c comp_name_by_id[c.id] = c.name def _ingredient_row_name(ing: Ingredient) -> str: raw = (ing.name or "").strip() if raw: base = raw elif ing.component_id and ing.component_id in comp_name_by_id: base = comp_name_by_id[ing.component_id] else: base = "—" repl_id = replacement_map.get(ing.id) if repl_id and repl_id in comp_name_by_id: return f"{base} → {comp_name_by_id[repl_id]}" return base def _ingredient_payload(i: Ingredient) -> dict: repl_id = replacement_map.get(i.id) if apply_overlay else None heads = int(recipe.heads_per_trip or 0) component_adj = ( component_adjustment_map.get(str(i.component_id)) if apply_overlay and i.component_id else None ) trip_row = trip_ing_by_id.get(i.id) if apply_overlay else None if apply_overlay: weights = resolve_plan_ingredient_weights( i, recipe, heads=heads, components_by_id=components_by_id, replacement_component_id=repl_id, component_adjustment=component_adj, ) wph = weights["weightPerHead"] dm_pct = weights["dryMatterPct"] dm_ph = weights["dryMatterPerHead"] if trip_row is not None: amount = float(trip_row.get("totalKg") or 0) elif i.id in skipped_ingredient_ids: amount = 0.0 else: amount = weights["totalKg"] if wph > 0 else i.amount else: wph = float(i.weight_per_head or 0) dm_pct = float(i.dry_matter or 0) if not dm_pct and i.component_id and i.component_id in components_by_id: dm_pct = float(components_by_id[i.component_id].dry_matter or 0) dm_ph = float(i.dry_matter_per_head or 0) if not dm_ph and wph > 0 and dm_pct > 0: dm_ph = wph * (dm_pct / 100.0) amount = i.amount weights = {} payload = { "id": i.id, "name": ( trip_row.get("name") if trip_row and trip_row.get("name") else (_ingredient_row_name(i) if apply_overlay else ( (i.name or "").strip() or (comp_name_by_id.get(i.component_id) if i.component_id else "") or "—" )) ), "weightPerHead": wph, "weight_per_head": wph, "amount": amount, "dry_matter": dm_pct, "dry_matter_per_head": dm_ph, "order": i.order, "component_id": (repl_id or i.component_id) if apply_overlay else i.component_id, "original_component_id": i.component_id, "version": i.version, "created_at": i.created_at.isoformat() if i.created_at else None, "updated_at": i.updated_at.isoformat() if i.updated_at else None, "created_by": i.created_by, "updated_by": i.updated_by, } if apply_overlay and not kiosk_view: payload["replacedToday"] = bool(repl_id) payload["adjustedToday"] = bool(weights.get("adjustedToday")) if repl_id: payload["recalculationMode"] = weights.get("recalculationMode") if repl_id or weights.get("adjustedToday"): payload["originalWeightPerHead"] = weights.get("originalWeightPerHead") payload["originalDryMatterPerHead"] = weights.get("originalDryMatterPerHead") payload["originalDryMatterPct"] = weights.get("originalDryMatterPct") if i.id in skipped_ingredient_ids: payload["skippedToday"] = True return payload groups = db.session.execute( select(UnloadingGroup) .where(UnloadingGroup.recipe_id == recipe.id, UnloadingGroup.is_deleted.is_(False)) .order_by(UnloadingGroup.order.asc()) ).scalars().all() def _group_plan_weight(group: UnloadingGroup) -> float | None: if not apply_overlay: return None trip_group = trip_grp_by_id.get(group.id) if trip_group is None: return None return float(trip_group.get("weightKg") or 0) return { "id": recipe.id, "name": recipe.name, "headsPerTrip": recipe.heads_per_trip, "mixingTime": recipe.mixing_time, "tripPercent": recipe.trip_percent, # Синонимы в snake_case — форма /recipes и копирование рейса ожидают эти ключи "heads_count": recipe.heads_per_trip, "mixing_time": recipe.mixing_time, "trip_percent": recipe.trip_percent, "dryMatterLocked": recipe.dry_matter_locked, "dry_matter_locked": recipe.dry_matter_locked, "unloading_link_broken": recipe.unloading_link_broken, "unloadingLinkBroken": recipe.unloading_link_broken, "target_component_id": recipe.target_component_id, "version": recipe.version, "created_at": recipe.created_at.isoformat() if recipe.created_at else None, "updated_at": recipe.updated_at.isoformat() if recipe.updated_at else None, "created_by": recipe.created_by, "updated_by": recipe.updated_by, "ingredients": [ _ingredient_payload(i) for i in ingredients if not hide_skipped or i.id not in skipped_ingredient_ids ], "unloadingGroups": [ { "id": g.id, "name": g.name, "distributionType": g.distribution_type, "value": g.value, "weight": (_pw if (_pw := _group_plan_weight(g)) is not None else g.weight), "order": g.order, "version": g.version, "created_at": g.created_at.isoformat() if g.created_at else None, "updated_at": g.updated_at.isoformat() if g.updated_at else None, "created_by": g.created_by, "updated_by": g.updated_by, **( {"skippedToday": True} if apply_overlay and not kiosk_view and g.id in skipped_group_ids else {} ), } for g in groups if not hide_skipped or g.id not in skipped_group_ids ], # snake_case — copy/paste рейса и форма редактора "unloading_groups": [ { "id": g.id, "name": g.name, "distribution_type": g.distribution_type, "distributionType": g.distribution_type, "value": g.value, "weight": (_pw if (_pw := _group_plan_weight(g)) is not None else g.weight), "order": g.order, **( {"skippedToday": True} if apply_overlay and not kiosk_view and g.id in skipped_group_ids else {} ), } for g in groups if not hide_skipped or g.id not in skipped_group_ids ], **( { "total_weight": trip_overlay["totalWeightKg"], "totalWeightKg": trip_overlay["totalWeightKg"], "unloadingTotalKg": trip_overlay.get("unloadingTotalKg"), } if trip_overlay else {} ), } @bp.get("") @require_paired_terminal def list_recipes(): """Список рецептов (совместим с легаси /api/recipes).""" try: limit = int(request.args.get("limit", 500)) offset = int(request.args.get("offset", 0)) except ValueError: return _error("Некорректные параметры пагинации", 400) recipes = db.session.execute( select(Recipe) .where(Recipe.is_deleted.is_(False)) .offset(offset) .limit(limit) ).scalars().all() response = jsonify([_serialize_recipe(r) for r in recipes]) return _add_no_cache_headers(response) @bp.get("//open-context") @require_auth def recipe_open_context(recipe_id: str): """Контекст для перехода к рейсу из центра уведомлений.""" recipe = db.session.get(Recipe, recipe_id) if recipe is None or getattr(recipe, "is_deleted", False): return _error("Рецепт не найден", 404) pr = db.session.execute( select(PeriodRecipe) .where( PeriodRecipe.recipe_id == recipe_id, PeriodRecipe.is_deleted.is_(False), ) .limit(1) ).scalar_one_or_none() if pr is not None: period = db.session.get(FeedingPeriod, pr.period_id) if period is not None and not getattr(period, "is_deleted", False): return jsonify( { "recipeId": recipe_id, "dispenserId": period.dispenser_id, "periodId": pr.period_id, "isMill": False, } ) mill = db.session.execute( select(FeedDispenser) .where( FeedDispenser.type == "mill", FeedDispenser.is_deleted.is_(False), ) .limit(1) ).scalar_one_or_none() if mill is not None: orphan = not db.session.execute( select( exists().where( PeriodRecipe.recipe_id == recipe_id, PeriodRecipe.is_deleted.is_(False), ) ) ).scalar() if orphan: return jsonify( { "recipeId": recipe_id, "dispenserId": mill.id, "periodId": None, "isMill": True, } ) return _error("Не удалось определить расположение рецепта", 404) @bp.get("/") @require_auth_or_paired_terminal def get_recipe(recipe_id: str): """Получение одного рецепта по ID.""" recipe = db.session.get(Recipe, recipe_id) if recipe is None: return _error("Рецепт не найден", 404) if getattr(recipe, "is_deleted", False): return _error("Рецепт удалён", 404) plan_date = request.args.get("date") return jsonify(_serialize_recipe(recipe, plan_date=plan_date)) @bp.post("") @require_auth def create_recipe(): """Создание рецепта без привязки к периоду (для кормоцеха).""" data = request.get_json() or {} name = (data.get("name") or "").strip() if not name: return _error("Название рецепта обязательно", 400) recipe = Recipe( name=name, heads_per_trip=int(data.get("headsPerTrip", data.get("heads_count", 1))), mixing_time=int(data.get("mixingTime", data.get("mixing_time", 0))), trip_percent=float(data.get("tripPercent", data.get("trip_percent", 100))), dry_matter_locked=bool(data.get("dry_matter_locked", data.get("dryMatterLocked", False))), unloading_link_broken=bool(data.get("unloading_link_broken", False)), target_component_id=data.get("target_component_id"), created_by="system", updated_by="system", ) db.session.add(recipe) db.session.flush() # Ингредиенты for idx, ing in enumerate(data.get("ingredients", []) or [], start=1): component = None comp_id = ing.get("component_id") if comp_id: component = db.session.get(Component, comp_id) if not component and ing.get("name"): component = db.session.execute( select(Component).where(Component.name == ing["name"]) ).scalar_one_or_none() if not component: return ( jsonify( { "error": True, "message": ( f'Компонент не найден (id="{comp_id or ""}", ' f'name="{ing.get("name", "")}")' ), } ), 400, ) dmph = _ingredient_dry_matter_per_head(ing) ingredient = Ingredient( name=component.name, weight_per_head=_ingredient_weight_per_head(ing), amount=float(ing.get("amount", 0) or 0), dry_matter=float(ing.get("dry_matter", component.dry_matter) or 0), dry_matter_per_head=dmph, component_id=component.id, order=idx, recipe_id=recipe.id, created_by="system", updated_by="system", ) db.session.add(ingredient) db.session.flush() for idx, group in enumerate(_unloading_groups_from_payload(data), start=1): unloading_group = UnloadingGroup( name=group.get("name", ""), distribution_type=_group_distribution_type(group), value=float(group.get("value", 0) or 0), weight=float(group["weight"]) if group.get("weight") not in (None, "") else None, order=int(group.get("order", idx)), recipe_id=recipe.id, created_by="system", updated_by="system", ) db.session.add(unloading_group) db.session.flush() db.session.commit() logger.info( "[RECIPES-DB] create_recipe committed id=%s name=%s path=%s remote=%s", recipe.id, recipe.name, request.path, request.remote_addr, ) return jsonify({"message": "Рецепт добавлен", "id": recipe.id}), 201 @bp.put("/") @require_auth def update_recipe(recipe_id: str): """Обновление рецепта (логика как legacy/proga_monolith.update_recipe).""" data = request.get_json() try: result = update_recipe_from_payload(recipe_id, data) db.session.commit() logger.info( "[RECIPES-DB] update_recipe committed id=%s path=%s remote=%s", recipe_id, request.path, request.remote_addr, ) except RecipeUpdateError as e: db.session.rollback() return _error(e.message, e.status_code) except Exception as e: db.session.rollback() logger.exception("Ошибка при обновлении рецепта %s", recipe_id) return _error(str(e), 500) return jsonify( { "success": True, "message": result["message"], "id": result["id"], "stats": result["stats"], } ) @bp.delete("/") @require_auth def delete_recipe(recipe_id: str): """Мягкое удаление рецепта (soft-delete, каскад делает событие).""" recipe = db.session.get(Recipe, recipe_id) if recipe is None: return _error("Рецепт не найден", 404) if getattr(recipe, "is_deleted", False): return jsonify({"success": True, "message": "Рецепт уже удалён"}) rid = recipe.id if hasattr(recipe, "soft_delete"): recipe.soft_delete(deleted_by_user="api") else: db.session.delete(recipe) enqueue_sync_queue_task("recipe", rid, "delete", priority=1) db.session.commit() logger.info( "[RECIPES-DB] delete_recipe committed id=%s path=%s remote=%s", rid, request.path, request.remote_addr, ) return jsonify({"success": True, "message": "Рецепт удалён"}) @bp.post("/calculate") @require_auth def calculate_recipe_endpoint(): """Как legacy/proga_monolith.calculate_recipe_endpoint: те же ключи тела и нормализация.""" data = request.get_json() if not data: return _error("Данные не предоставлены", 400) try: heads_count = int( data.get("headsCount") or data.get("heads_count") or data.get("headsPerTrip") or 0 ) trip_percent = float( data.get("tripPercent") or data.get("trip_percent") or 100 ) except (TypeError, ValueError): return _error("Некорректные числовые параметры", 400) ingredients = data.get("ingredients", []) or [] unloading_groups = data.get("unloadingGroups") or data.get("unloading_groups") or [] calculate_from_dry_matter = bool( data.get("calculateFromDryMatter") if data.get("calculateFromDryMatter") is not None else data.get("calculate_from_dry_matter", False) ) normalized_ingredients = [] for ing in ingredients: if not isinstance(ing, dict): continue ing = dict(ing) if "component_id" not in ing and "componentId" in ing: ing["component_id"] = ing.get("componentId") if "dryMatterPerHead" not in ing and "dry_matter_per_head" in ing: ing["dryMatterPerHead"] = ing.get("dry_matter_per_head") if "dryMatter" not in ing and "dry_matter" in ing: ing["dryMatter"] = ing.get("dry_matter") if "weightPerHead" not in ing and "weight_per_head" in ing: ing["weightPerHead"] = ing.get("weight_per_head") normalized_ingredients.append(ing) ingredients = normalized_ingredients normalized_groups = [] for g in unloading_groups: if not isinstance(g, dict): continue g = dict(g) if "distributionType" not in g and "distribution_type" in g: g["distributionType"] = g.get("distribution_type") normalized_groups.append(g) unloading_groups = normalized_groups component_ids = [i.get("component_id") for i in ingredients if i.get("component_id")] component_dry_matter_map = {} if component_ids: comps = db.session.execute( select(Component).where(Component.id.in_(component_ids)) ).scalars().all() component_dry_matter_map = {c.id: c.dry_matter for c in comps} result = calculate_recipe( ingredients=ingredients, heads_count=heads_count, trip_percent=trip_percent, unloading_groups=unloading_groups, component_dry_matter_map=component_dry_matter_map or None, calculate_from_dry_matter=calculate_from_dry_matter, ) def to_float2(x): try: return round(float(x), 2) except (TypeError, ValueError): return 0.0 def truncate2(x): try: v = float(x) return float(int(v * 100)) / 100.0 except (TypeError, ValueError): return 0.0 if result.get("ingredients"): for ing in result["ingredients"]: for key in ("weightPerHead", "tripWeight", "totalWeight", "dryMatterPerHead"): if key in ing and ing[key] is not None: raw_val = ing[key] v = truncate2(raw_val) if key == "weightPerHead" else to_float2(raw_val) ing[key] = f"{v:.2f}" if key == "weightPerHead" else v if result.get("totals"): for key in ( "totalWeight", "totalTripWeight", "totalDryMatterPerHead", "totalWeightPerHead", ): if key in result["totals"] and result["totals"][key] is not None: result["totals"][key] = to_float2(result["totals"][key]) return jsonify(result) def _period_recipe_order_content_hash(period_id: str, recipe_id: str, order: int) -> str: return compute_content_hash_hex( stable_payload_for_hash( { "period_id": period_id, "recipe_id": recipe_id, "order": order, } ) ) def _resolve_period_recipe_move(rows, recipe_id: str, req_from_index: int, req_to_index: int): """rows: list of (recipe_id, order). Returns (from_index, to_index) or (None, None) if not found.""" if not rows: return None, None from_index = req_from_index if from_index < 0 or from_index >= len(rows): from_index = next((i for i, row in enumerate(rows) if row[0] == recipe_id), -1) elif rows[from_index][0] != recipe_id: from_index = next((i for i, row in enumerate(rows) if row[0] == recipe_id), -1) if from_index < 0: return None, None to_index = max(0, min(int(req_to_index), len(rows) - 1)) return from_index, to_index def _period_recipe_order_updates(rows, from_index: int, to_index: int): """Return [(recipe_id, new_order), ...] only for rows whose order changes.""" recipe_ids = [row[0] for row in rows] current_order = {row[0]: row[1] for row in rows} moved = recipe_ids.pop(from_index) recipe_ids.insert(to_index, moved) updates = [] for idx, rid in enumerate(recipe_ids, start=1): if current_order.get(rid) != idx: updates.append((rid, idx)) return updates def _load_period_recipe_order_rows(period_id: str): return db.session.execute( select(PeriodRecipe.recipe_id, PeriodRecipe.order) .where(PeriodRecipe.period_id == period_id, PeriodRecipe.is_deleted.is_(False)) .order_by(PeriodRecipe.order.asc(), PeriodRecipe.created_at.asc()) ).all() def _commit_period_recipe_reorder(period_id: str, order_updates): """Отдельное короткое соединение: не конкурирует с сессией sync pull.""" now = datetime.now() attempts = int(getattr(Config, "RECIPE_REORDER_COMMIT_ATTEMPTS", 12)) base_delay = float(getattr(Config, "RECIPE_REORDER_COMMIT_BASE_DELAY", 0.12)) for attempt in range(attempts): try: with db.engine.begin() as conn: for recipe_id, new_order in order_updates: conn.execute( update(PeriodRecipe) .where( PeriodRecipe.period_id == period_id, PeriodRecipe.recipe_id == recipe_id, ) .values( order=new_order, content_hash=_period_recipe_order_content_hash(period_id, recipe_id, new_order), updated_by="system", updated_at=now, ) ) db.session.rollback() return except OperationalError as exc: db.session.rollback() if _is_sqlite_lock_message(exc) and attempt < attempts - 1: logger.warning( "move_recipe_in_period: SQLite locked, retry %s/%s period=%s", attempt + 1, attempts, period_id, ) time.sleep(base_delay * (2 ** min(attempt, 5))) continue raise def _enqueue_period_recipe_reorder_sync(period_id: str, order_updates) -> None: db.session.rollback() for recipe_id, _ in order_updates: enqueue_sync_queue_task( "period_recipes", f"{period_id}:{recipe_id}", "update", priority=4, ) db_commit_with_retry() def _defer_period_recipe_reorder_sync(app, period_id: str, order_updates) -> None: """Sync queue — после ответа клиенту, чтобы не держать lock вместе с pull.""" def _worker() -> None: with app.app_context(): try: _enqueue_period_recipe_reorder_sync(period_id, order_updates) except Exception: logger.exception( "move_recipe_in_period: deferred sync enqueue failed period=%s", period_id, ) threading.Thread( target=_worker, name=f"recipe-reorder-sync-{period_id[:8]}", daemon=True, ).start() @bp.put("//move") @require_auth def move_recipe_in_period(recipe_id: str): """Legacy-compatible reorder endpoint for recipes list in a period.""" data = request.get_json() or {} period_id = data.get("period_id") if not period_id: return jsonify({"error": True, "message": "period_id обязателен"}), 400 try: req_from_index = int(data.get("from_index")) req_to_index = int(data.get("to_index")) except (TypeError, ValueError): return jsonify({"error": True, "message": "from_index/to_index должны быть числами"}), 400 try: db.session.rollback() rows = _load_period_recipe_order_rows(period_id) if not rows: return jsonify({"error": True, "message": "Рецепты периода не найдены"}), 404 from_index, to_index = _resolve_period_recipe_move(rows, recipe_id, req_from_index, req_to_index) if from_index is None: return jsonify({"error": True, "message": "Рецепт не найден в периоде"}), 404 if from_index == to_index: return jsonify({"success": True, "message": "Порядок рейсов без изменений"}), 200 order_updates = _period_recipe_order_updates(rows, from_index, to_index) if not order_updates: return jsonify({"success": True, "message": "Порядок рейсов без изменений"}), 200 _commit_period_recipe_reorder(period_id, order_updates) except OperationalError as exc: db.session.rollback() if _is_sqlite_lock_message(exc): logger.warning( "move_recipe_in_period: SQLite locked period=%s recipe=%s", period_id, recipe_id, ) return jsonify({"error": True, "message": "База занята, повторите через секунду"}), 503 raise _defer_period_recipe_reorder_sync(current_app._get_current_object(), period_id, order_updates) return jsonify({"success": True, "message": "Порядок рейсов обновлён"})