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

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