Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
+838
View File
@@ -0,0 +1,838 @@
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": "Порядок рейсов обновлён"})