751 lines
26 KiB
Python
751 lines
26 KiB
Python
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": "Рецепт удален из периода"})
|
|
|