839 lines
31 KiB
Python
839 lines
31 KiB
Python
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": "Порядок рейсов обновлён"})
|
||
|