@@ -0,0 +1,750 @@
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
from sqlalchemy import exists, func, select, update
|
||||
|
||||
from app import db
|
||||
from app.models import FeedDispenser, FeedingPeriod, PeriodRecipe, Recipe
|
||||
from app.routes.auth_decorators import require_auth, require_auth_or_paired_terminal
|
||||
from app.services.period_recipes_query import recipes_for_period_ordered
|
||||
from app.services.daily_plan.adjustments import get_adjusted_recipe_ids
|
||||
from app.services.daily_plan.replacements import get_replaced_recipe_ids
|
||||
from app.services.daily_plan.builder import recipe_total_weights_by_id
|
||||
from app.services.daily_plan.skips import (
|
||||
filter_recipes_for_list_view,
|
||||
get_recipe_ids_with_any_skip,
|
||||
get_skipped_ingredient_ids,
|
||||
get_skipped_unloading_group_ids,
|
||||
is_kiosk_recipe_list_request,
|
||||
is_zootech_recipe_list_view,
|
||||
)
|
||||
from app.services.sync_manager import enqueue_sync_queue_task
|
||||
|
||||
bp = Blueprint("equipment", __name__, url_prefix="/api/feed_dispensers")
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _error(message: str, status_code: int = 400):
|
||||
return jsonify({"error": True, "message": message}), status_code
|
||||
|
||||
|
||||
@bp.get("/ping")
|
||||
def equipment_ping():
|
||||
"""Временный health-check эндпоинт для модуля оборудования."""
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
@bp.get("")
|
||||
@require_auth_or_paired_terminal
|
||||
def list_feed_dispensers():
|
||||
"""Список кормораздатчиков с пагинацией."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", 100))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return _error("Некорректные параметры пагинации", 400)
|
||||
|
||||
rows = db.session.execute(
|
||||
select(FeedDispenser)
|
||||
.where(FeedDispenser.is_deleted.is_(False))
|
||||
.order_by(FeedDispenser.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(limit)
|
||||
).scalars().all()
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
**_serialize_dispenser(d),
|
||||
"periods": _periods_payload_for_dispenser(d.id),
|
||||
"hasSkipToday": _dispenser_has_skip_today(d),
|
||||
}
|
||||
for d in rows
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/names")
|
||||
@require_auth_or_paired_terminal
|
||||
def feed_dispenser_names():
|
||||
"""Legacy-compatible endpoint for dispenser names list."""
|
||||
rows = db.session.execute(
|
||||
select(FeedDispenser)
|
||||
.where(FeedDispenser.is_deleted.is_(False))
|
||||
.order_by(FeedDispenser.name.asc())
|
||||
).scalars().all()
|
||||
names = [r.name for r in rows if r.name]
|
||||
# Preserve order while deduplicating
|
||||
seen = set()
|
||||
unique_names = []
|
||||
for n in names:
|
||||
if n in seen:
|
||||
continue
|
||||
seen.add(n)
|
||||
unique_names.append(n)
|
||||
return jsonify(unique_names)
|
||||
|
||||
|
||||
def _serialize_recipe_short(
|
||||
recipe: Recipe,
|
||||
*,
|
||||
total_weight: float | None = None,
|
||||
skipped_today: bool = False,
|
||||
skipped_ingredient_today: bool = False,
|
||||
skipped_group_today: bool = False,
|
||||
adjusted_ingredient_today: bool = False,
|
||||
replaced_ingredient_today: bool = False,
|
||||
):
|
||||
payload = {
|
||||
"id": recipe.id,
|
||||
"name": recipe.name,
|
||||
"heads_count": recipe.heads_per_trip,
|
||||
"mixing_time": recipe.mixing_time,
|
||||
"trip_percent": recipe.trip_percent,
|
||||
}
|
||||
if total_weight is not None:
|
||||
payload["total_weight"] = total_weight
|
||||
if is_zootech_recipe_list_view():
|
||||
payload["skippedToday"] = skipped_today
|
||||
payload["skippedIngredientToday"] = skipped_ingredient_today
|
||||
payload["skippedGroupToday"] = skipped_group_today
|
||||
payload["adjustedIngredientToday"] = adjusted_ingredient_today
|
||||
payload["replacedIngredientToday"] = replaced_ingredient_today
|
||||
return payload
|
||||
|
||||
|
||||
def _serialize_dispenser(dispenser: FeedDispenser):
|
||||
return {
|
||||
"id": dispenser.id,
|
||||
"name": dispenser.name,
|
||||
"farm": dispenser.farm,
|
||||
"operator": dispenser.operator,
|
||||
"type": dispenser.type,
|
||||
"is_active": dispenser.is_active,
|
||||
"version": dispenser.version,
|
||||
}
|
||||
|
||||
|
||||
def _dispenser_has_skip_today(dispenser: FeedDispenser) -> bool:
|
||||
skip_ids = get_recipe_ids_with_any_skip()
|
||||
if not skip_ids:
|
||||
return False
|
||||
if dispenser.type == "mill":
|
||||
recipes = db.session.execute(
|
||||
select(Recipe.id).where(
|
||||
Recipe.is_deleted.is_(False),
|
||||
~exists(
|
||||
select(1).where(
|
||||
PeriodRecipe.recipe_id == Recipe.id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
),
|
||||
)
|
||||
).scalars().all()
|
||||
return any(rid in skip_ids for rid in recipes)
|
||||
periods = db.session.execute(
|
||||
select(FeedingPeriod.id).where(
|
||||
FeedingPeriod.dispenser_id == dispenser.id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedingPeriod.is_active.is_(True),
|
||||
)
|
||||
).scalars().all()
|
||||
for period_id in periods:
|
||||
recipes = recipes_for_period_ordered(period_id)
|
||||
if any(r.id in skip_ids for r in recipes):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _periods_payload_for_dispenser(dispenser_id: str) -> list[dict]:
|
||||
"""Активные периоды с рейсами (как в GET .../feed_dispensers/<id>). Для UI списка кормораздатчиков."""
|
||||
periods = db.session.execute(
|
||||
select(FeedingPeriod)
|
||||
.where(
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedingPeriod.is_active.is_(True),
|
||||
)
|
||||
.order_by(FeedingPeriod.created_at.asc())
|
||||
).unique().scalars().all()
|
||||
payload = []
|
||||
zootech = is_zootech_recipe_list_view()
|
||||
skip_ings = get_skipped_ingredient_ids()
|
||||
skip_grps = get_skipped_unloading_group_ids()
|
||||
skip_ids = get_recipe_ids_with_any_skip()
|
||||
adj_ids = get_adjusted_recipe_ids()
|
||||
repl_ids = get_replaced_recipe_ids()
|
||||
for p in periods:
|
||||
recipes = recipes_for_period_ordered(p.id)
|
||||
visible, skipped_today = filter_recipes_for_list_view(recipes)
|
||||
period_has_skip = any(r.id in skip_ids for r in recipes)
|
||||
if zootech:
|
||||
recipe_rows = [
|
||||
_serialize_recipe_short(
|
||||
r,
|
||||
skipped_today=r.id in skipped_today,
|
||||
skipped_ingredient_today=bool(skip_ings.get(r.id)),
|
||||
skipped_group_today=bool(skip_grps.get(r.id)),
|
||||
adjusted_ingredient_today=r.id in adj_ids,
|
||||
replaced_ingredient_today=r.id in repl_ids,
|
||||
)
|
||||
for r in visible
|
||||
]
|
||||
else:
|
||||
recipe_rows = [_serialize_recipe_short(r) for r in visible]
|
||||
payload.append(
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"is_active": p.is_active,
|
||||
"is_deleted": False,
|
||||
"hasSkipToday": period_has_skip,
|
||||
"recipes": recipe_rows,
|
||||
}
|
||||
)
|
||||
return payload
|
||||
|
||||
|
||||
@bp.get("/<string:dispenser_id>/periods")
|
||||
@require_auth_or_paired_terminal
|
||||
def list_dispenser_periods(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
|
||||
raw = _periods_payload_for_dispenser(dispenser_id)
|
||||
# Совместимость: в ответе /periods поле is_deleted не требовалось
|
||||
payload = [{k: v for k, v in item.items() if k != "is_deleted"} for item in raw]
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.get("/<string:dispenser_id>/recipes")
|
||||
@require_auth_or_paired_terminal
|
||||
def list_dispenser_recipes(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
|
||||
recipes = []
|
||||
if dispenser.type == "mill":
|
||||
# Как legacy get_dispenser_recipes: только рецепты без привязки к периодам (кормоцех),
|
||||
# а не все строки recipe — иначе рейсы из периодов «висят» в списке кормоцеха.
|
||||
recipes = db.session.execute(
|
||||
select(Recipe)
|
||||
.where(
|
||||
Recipe.is_deleted.is_(False),
|
||||
~exists(
|
||||
select(1).where(
|
||||
PeriodRecipe.recipe_id == Recipe.id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
),
|
||||
)
|
||||
.order_by(Recipe.updated_at.desc())
|
||||
).scalars().all()
|
||||
logger.info(
|
||||
"[RECIPES-DB] list_dispenser_recipes mill dispenser_id=%s orphan_recipes=%s",
|
||||
dispenser_id,
|
||||
len(recipes),
|
||||
)
|
||||
else:
|
||||
periods = db.session.execute(
|
||||
select(FeedingPeriod)
|
||||
.where(
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
FeedingPeriod.is_active.is_(True),
|
||||
)
|
||||
.order_by(FeedingPeriod.created_at.asc())
|
||||
).unique().scalars().all()
|
||||
seen = set()
|
||||
for p in periods:
|
||||
for r in recipes_for_period_ordered(p.id):
|
||||
if r.id in seen:
|
||||
continue
|
||||
seen.add(r.id)
|
||||
recipes.append(r)
|
||||
|
||||
visible, skipped_today = filter_recipes_for_list_view(recipes)
|
||||
skip_ings = get_skipped_ingredient_ids()
|
||||
skip_grps = get_skipped_unloading_group_ids()
|
||||
adj_ids = get_adjusted_recipe_ids()
|
||||
repl_ids = get_replaced_recipe_ids()
|
||||
plan_weights: dict[str, float] = {}
|
||||
if is_kiosk_recipe_list_request():
|
||||
plan_weights = recipe_total_weights_by_id(dispenser_id=dispenser_id)
|
||||
|
||||
def _row(recipe: Recipe) -> dict:
|
||||
return _serialize_recipe_short(
|
||||
recipe,
|
||||
total_weight=plan_weights.get(recipe.id),
|
||||
skipped_today=recipe.id in skipped_today,
|
||||
skipped_ingredient_today=bool(skip_ings.get(recipe.id)),
|
||||
skipped_group_today=bool(skip_grps.get(recipe.id)),
|
||||
adjusted_ingredient_today=recipe.id in adj_ids,
|
||||
replaced_ingredient_today=recipe.id in repl_ids,
|
||||
)
|
||||
|
||||
return jsonify([_row(r) for r in visible])
|
||||
|
||||
|
||||
@bp.post("")
|
||||
@require_auth
|
||||
def create_feed_dispenser():
|
||||
data = request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
farm = (data.get("farm") or "").strip()
|
||||
operator = (data.get("operator") or "").strip()
|
||||
if not name or not farm or not operator:
|
||||
return _error("Поля name, farm и operator обязательны", 400)
|
||||
|
||||
device_type = (data.get("type") or "dispenser").strip()
|
||||
if device_type not in {"dispenser", "mill"}:
|
||||
device_type = "dispenser"
|
||||
|
||||
dispenser = FeedDispenser(
|
||||
name=name,
|
||||
farm=farm,
|
||||
operator=operator,
|
||||
type=device_type,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(dispenser)
|
||||
db.session.commit()
|
||||
return (
|
||||
jsonify(
|
||||
{
|
||||
"message": "Кормораздатчик успешно создан",
|
||||
"id": dispenser.id,
|
||||
}
|
||||
),
|
||||
201,
|
||||
)
|
||||
|
||||
|
||||
@bp.get("/<string:dispenser_id>")
|
||||
@require_auth_or_paired_terminal
|
||||
def get_feed_dispenser(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
payload = _serialize_dispenser(dispenser)
|
||||
payload["periods"] = _periods_payload_for_dispenser(dispenser_id)
|
||||
return jsonify(payload)
|
||||
|
||||
|
||||
@bp.put("/<string:dispenser_id>")
|
||||
@require_auth
|
||||
def update_feed_dispenser(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
if "name" in data:
|
||||
dispenser.name = (data.get("name") or dispenser.name).strip()
|
||||
if "farm" in data:
|
||||
dispenser.farm = (data.get("farm") or dispenser.farm).strip()
|
||||
if "operator" in data:
|
||||
dispenser.operator = (data.get("operator") or dispenser.operator).strip()
|
||||
if "type" in data:
|
||||
candidate = (data.get("type") or "").strip()
|
||||
if candidate in {"dispenser", "mill"}:
|
||||
dispenser.type = candidate
|
||||
dispenser.updated_by = "system"
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Кормораздатчик успешно обновлен"})
|
||||
|
||||
|
||||
@bp.delete("/<string:dispenser_id>")
|
||||
@require_auth
|
||||
def delete_feed_dispenser(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
dispenser.soft_delete(deleted_by="system", reason="Удаление через API", request=None)
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Кормораздатчик успешно удален"})
|
||||
|
||||
|
||||
@bp.post("/<string:dispenser_id>/periods")
|
||||
@require_auth
|
||||
def create_feeding_period(dispenser_id: str):
|
||||
dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not dispenser:
|
||||
return _error("Кормораздатчик не найден", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name:
|
||||
return _error("Название периода обязательно", 400)
|
||||
|
||||
period = FeedingPeriod(
|
||||
name=name,
|
||||
dispenser_id=dispenser_id,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(period)
|
||||
db.session.commit()
|
||||
return jsonify({"id": period.id}), 201
|
||||
|
||||
|
||||
@bp.put("/<string:dispenser_id>/periods/<string:period_id>")
|
||||
@require_auth
|
||||
def update_feeding_period(dispenser_id: str, period_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id,
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
|
||||
data = request.get_json() or {}
|
||||
if "name" in data:
|
||||
new_name = (data.get("name") or "").strip()
|
||||
if not new_name:
|
||||
return _error("Название периода не может быть пустым", 400)
|
||||
period.name = new_name
|
||||
period.updated_by = "system"
|
||||
db.session.commit()
|
||||
return jsonify({"id": period.id})
|
||||
|
||||
|
||||
@bp.delete("/<string:dispenser_id>/periods/<string:period_id>")
|
||||
@require_auth
|
||||
def delete_feeding_period(dispenser_id: str, period_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id,
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
period.soft_delete(deleted_by="system", reason="Удаление через API", request=None)
|
||||
db.session.commit()
|
||||
return jsonify({"message": "Период кормления успешно удален"})
|
||||
|
||||
|
||||
@bp.post("/<string:dispenser_id>/periods/<string:period_id>/recipes/<string:recipe_id>")
|
||||
@require_auth
|
||||
def add_recipe_to_period(dispenser_id: str, period_id: str, recipe_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id,
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if not recipe:
|
||||
return _error("Рецепт не найден", 404)
|
||||
|
||||
logger.info(
|
||||
"[RECIPES-DB] add_recipe_to_period dispenser_id=%s period_id=%s recipe_id=%s path=%s remote=%s",
|
||||
dispenser_id,
|
||||
period_id,
|
||||
recipe_id,
|
||||
request.path,
|
||||
request.remote_addr,
|
||||
)
|
||||
|
||||
existing = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing:
|
||||
logger.info(
|
||||
"[RECIPES-DB] add_recipe_to_period already_linked period_id=%s recipe_id=%s",
|
||||
period_id,
|
||||
recipe_id,
|
||||
)
|
||||
return jsonify({"message": "Рецепт добавлен в период"})
|
||||
|
||||
max_order = db.session.scalar(
|
||||
select(func.max(PeriodRecipe.order)).where(
|
||||
PeriodRecipe.period_id == period_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
) or 0
|
||||
link = PeriodRecipe(
|
||||
period_id=period_id,
|
||||
recipe_id=recipe_id,
|
||||
order=max_order + 1,
|
||||
created_at=datetime.now(),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(link)
|
||||
period.updated_by = "system"
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"[RECIPES-DB] add_recipe_to_period committed period_id=%s recipe_id=%s order=%s",
|
||||
period_id,
|
||||
recipe_id,
|
||||
link.order,
|
||||
)
|
||||
return jsonify({"message": "Рецепт добавлен в период"})
|
||||
|
||||
|
||||
@bp.post("/<string:to_dispenser_id>/periods/<string:to_period_id>/recipes/<string:recipe_id>/transfer")
|
||||
@require_auth
|
||||
def transfer_recipe_between_periods(
|
||||
to_dispenser_id: str, to_period_id: str, recipe_id: str
|
||||
):
|
||||
"""Атомарно снять рейс с исходного периода и вставить в целевой на позицию to_index (0-based)."""
|
||||
data = request.get_json() or {}
|
||||
from_dispenser_id = data.get("from_dispenser_id")
|
||||
from_period_id = data.get("from_period_id")
|
||||
if not from_dispenser_id or not from_period_id:
|
||||
return _error("Укажите from_dispenser_id и from_period_id", 400)
|
||||
try:
|
||||
to_index = int(data.get("to_index"))
|
||||
except (TypeError, ValueError):
|
||||
return _error("to_index должен быть числом", 400)
|
||||
|
||||
if from_period_id == to_period_id and from_dispenser_id == to_dispenser_id:
|
||||
return _error("Для смены порядка внутри периода используйте перестановку в списке", 400)
|
||||
|
||||
to_dispenser = db.session.execute(
|
||||
select(FeedDispenser).where(
|
||||
FeedDispenser.id == to_dispenser_id, FeedDispenser.is_deleted.is_(False)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not to_dispenser:
|
||||
return _error("Целевое оборудование не найдено", 404)
|
||||
if getattr(to_dispenser, "type", None) == "mill":
|
||||
return _error("Перенос в кормоцех через периоды не поддерживается", 400)
|
||||
|
||||
to_period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == to_period_id,
|
||||
FeedingPeriod.dispenser_id == to_dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not to_period:
|
||||
return _error("Целевой период не найден", 404)
|
||||
|
||||
from_period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == from_period_id,
|
||||
FeedingPeriod.dispenser_id == from_dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not from_period:
|
||||
return _error("Исходный период не найден", 404)
|
||||
|
||||
recipe = db.session.execute(
|
||||
select(Recipe).where(Recipe.id == recipe_id, Recipe.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
if not recipe:
|
||||
return _error("Рецепт не найден", 404)
|
||||
|
||||
source_link = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == from_period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if not source_link:
|
||||
return _error("Рейс не привязан к исходному периоду", 404)
|
||||
|
||||
existing_target = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == to_period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if existing_target:
|
||||
return _error("Этот рейс уже привязан к целевому периоду — перенос не выполнен.", 409)
|
||||
|
||||
source_link.soft_delete(deleted_by_user="system")
|
||||
from_period.updated_by = "system"
|
||||
db.session.flush()
|
||||
|
||||
others = (
|
||||
db.session.execute(
|
||||
select(PeriodRecipe)
|
||||
.where(
|
||||
PeriodRecipe.period_id == to_period_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(PeriodRecipe.order.asc(), PeriodRecipe.created_at.asc())
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
n = len(others)
|
||||
if to_index < 0:
|
||||
to_index = 0
|
||||
elif to_index > n:
|
||||
to_index = n
|
||||
|
||||
ordered_ids = [pr.recipe_id for pr in others[:to_index]] + [recipe_id] + [
|
||||
pr.recipe_id for pr in others[to_index:]
|
||||
]
|
||||
|
||||
ghost = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == to_period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if ghost is not None:
|
||||
if not ghost.is_deleted:
|
||||
logger.error(
|
||||
"transfer_recipe_between_periods: unexpected active ghost "
|
||||
"period_id=%s recipe_id=%s",
|
||||
to_period_id,
|
||||
recipe_id,
|
||||
)
|
||||
db.session.rollback()
|
||||
return _error(
|
||||
"Этот рейс уже привязан к целевому периоду — перенос не выполнен.",
|
||||
409,
|
||||
)
|
||||
ghost.is_deleted = False
|
||||
ghost.deleted_at = None
|
||||
ghost.deleted_by = None
|
||||
ghost.updated_by = "system"
|
||||
else:
|
||||
db.session.add(
|
||||
PeriodRecipe(
|
||||
period_id=to_period_id,
|
||||
recipe_id=recipe_id,
|
||||
order=0,
|
||||
created_at=datetime.now(),
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
)
|
||||
db.session.flush()
|
||||
|
||||
now = datetime.now()
|
||||
with db.session.no_autoflush:
|
||||
for idx, rid in enumerate(ordered_ids, start=1):
|
||||
db.session.execute(
|
||||
update(PeriodRecipe)
|
||||
.where(
|
||||
PeriodRecipe.period_id == to_period_id,
|
||||
PeriodRecipe.recipe_id == rid,
|
||||
)
|
||||
.values(order=idx, updated_by="system", updated_at=now)
|
||||
)
|
||||
|
||||
to_period.updated_by = "system"
|
||||
from_period.updated_by = "system"
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes",
|
||||
f"{from_period_id}:{recipe_id}",
|
||||
"delete",
|
||||
priority=1,
|
||||
)
|
||||
for rid in ordered_ids:
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes",
|
||||
f"{to_period_id}:{rid}",
|
||||
"update",
|
||||
priority=4,
|
||||
)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
logger.info(
|
||||
"[RECIPES-DB] transfer_recipe_between_periods committed recipe_id=%s from=%s/%s to=%s/%s index=%s",
|
||||
recipe_id,
|
||||
from_dispenser_id,
|
||||
from_period_id,
|
||||
to_dispenser_id,
|
||||
to_period_id,
|
||||
to_index,
|
||||
)
|
||||
return jsonify({"success": True, "message": "Рейс перенесён"})
|
||||
|
||||
|
||||
@bp.delete("/<string:dispenser_id>/periods/<string:period_id>/recipes/<string:recipe_id>")
|
||||
@require_auth
|
||||
def remove_recipe_from_period(dispenser_id: str, period_id: str, recipe_id: str):
|
||||
period = db.session.execute(
|
||||
select(FeedingPeriod).where(
|
||||
FeedingPeriod.id == period_id,
|
||||
FeedingPeriod.dispenser_id == dispenser_id,
|
||||
FeedingPeriod.is_deleted.is_(False),
|
||||
)
|
||||
).unique().scalar_one_or_none()
|
||||
if not period:
|
||||
return _error("Период не найден", 404)
|
||||
|
||||
link = db.session.execute(
|
||||
select(PeriodRecipe).where(
|
||||
PeriodRecipe.period_id == period_id,
|
||||
PeriodRecipe.recipe_id == recipe_id,
|
||||
PeriodRecipe.is_deleted.is_(False),
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if link:
|
||||
logger.info(
|
||||
"[RECIPES-DB] remove_recipe_from_period dispenser_id=%s period_id=%s recipe_id=%s path=%s",
|
||||
dispenser_id,
|
||||
period_id,
|
||||
recipe_id,
|
||||
request.path,
|
||||
)
|
||||
link.soft_delete(deleted_by="system", reason="Удаление через API", request=None)
|
||||
period.updated_by = "system"
|
||||
enqueue_sync_queue_task(
|
||||
"period_recipes",
|
||||
f"{period_id}:{recipe_id}",
|
||||
"delete",
|
||||
priority=1,
|
||||
)
|
||||
db.session.commit()
|
||||
logger.info(
|
||||
"[RECIPES-DB] remove_recipe_from_period committed period_id=%s recipe_id=%s",
|
||||
period_id,
|
||||
recipe_id,
|
||||
)
|
||||
return jsonify({"message": "Рецепт удален из периода"})
|
||||
|
||||
Reference in New Issue
Block a user