Files
2026-07-17 12:57:18 +03:00

839 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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("/<string:recipe_id>/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("/<string:recipe_id>")
@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("/<string:recipe_id>")
@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("/<string:recipe_id>")
@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("/<string:recipe_id>/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": "Порядок рейсов обновлён"})