@@ -0,0 +1,529 @@
|
||||
"""
|
||||
Обновление рецепта (PUT): логика перенесена из legacy/proga_monolith.update_recipe.
|
||||
|
||||
Sync: listeners + явные enqueue для recipe/ingredient/unloading_group (в т.ч. deleted_* из UI).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app import db
|
||||
from app.models import Component, Ingredient, Recipe, UnloadingGroup
|
||||
from app.services.recipe_calculator import calculate_recipe
|
||||
from app.services.sync_manager import enqueue_sync_queue_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecipeUpdateError(Exception):
|
||||
def __init__(self, message: str, status_code: int = 400):
|
||||
self.message = message
|
||||
self.status_code = status_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _ing_weight_per_head(ing: dict) -> float:
|
||||
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 _resolve_component(ing: dict) -> Optional[Component]:
|
||||
component_id = ing.get("component_id")
|
||||
component = None
|
||||
if component_id:
|
||||
component = db.session.get(Component, str(component_id))
|
||||
if not component:
|
||||
name = ing.get("name")
|
||||
if name:
|
||||
component = db.session.execute(
|
||||
select(Component).where(Component.name == name, Component.is_deleted.is_(False))
|
||||
).scalar_one_or_none()
|
||||
return component
|
||||
|
||||
|
||||
def _validate_strict_header(data: dict) -> None:
|
||||
"""Как в монолите: name, heads_count, mixing_time обязательны."""
|
||||
for key in ("name", "heads_count", "mixing_time"):
|
||||
if key not in data:
|
||||
raise RecipeUpdateError(f'Отсутствует обязательное поле "{key}"', 400)
|
||||
|
||||
|
||||
def _enqueue_recipe_children_sync(recipe_id: str) -> None:
|
||||
"""
|
||||
После сохранения рецепта — update в sync для всех активных ингредиентов и групп.
|
||||
|
||||
ORM-listeners ставят задачи только на изменённые строки; reorder ингредиентов и
|
||||
частичные правки групп выгрузки иначе не доходят до peer (баготест #5–#6).
|
||||
|
||||
Через pending-очередь session.info (как after_insert/after_update), а не прямой
|
||||
enqueue_sync_queue_task — иначе при autoflush дубли в sync_queue (SQLite).
|
||||
"""
|
||||
from app.models import _SYNC_ENQUEUE_INFO_KEY
|
||||
|
||||
pending = db.session.info.setdefault(_SYNC_ENQUEUE_INFO_KEY, [])
|
||||
with db.session.no_autoflush:
|
||||
active_ingredients = db.session.execute(
|
||||
select(Ingredient).where(
|
||||
Ingredient.recipe_id == recipe_id,
|
||||
Ingredient.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
active_groups = db.session.execute(
|
||||
select(UnloadingGroup).where(
|
||||
UnloadingGroup.recipe_id == recipe_id,
|
||||
UnloadingGroup.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
for ing in active_ingredients:
|
||||
pending.append(("ingredient", str(ing.id), "update", 3, None))
|
||||
for grp in active_groups:
|
||||
pending.append(("unloading_group", str(grp.id), "update", 3, None))
|
||||
|
||||
|
||||
def update_recipe_from_payload(recipe_id: str, data: Optional[dict]) -> Dict[str, Any]:
|
||||
"""
|
||||
Выполняет обновление рецепта в текущей сессии (без commit).
|
||||
Возвращает dict для jsonify: message, id, stats (как в легаси).
|
||||
"""
|
||||
if not data:
|
||||
raise RecipeUpdateError("Данные не предоставлены", 400)
|
||||
|
||||
_validate_strict_header(data)
|
||||
|
||||
logger.info(
|
||||
"[RECIPES-DB] update_recipe_from_payload start id=%s name_in_payload=%s",
|
||||
recipe_id,
|
||||
(data.get("name") or "")[:80],
|
||||
)
|
||||
|
||||
recipe = db.session.get(Recipe, recipe_id)
|
||||
if recipe is None:
|
||||
raise RecipeUpdateError("Рецепт не найден", 404)
|
||||
if getattr(recipe, "is_deleted", False):
|
||||
raise RecipeUpdateError("Рецепт удалён", 404)
|
||||
|
||||
try:
|
||||
recipe.name = data["name"]
|
||||
recipe.heads_per_trip = int(data["heads_count"])
|
||||
recipe.mixing_time = int(data["mixing_time"])
|
||||
except (TypeError, ValueError) as e:
|
||||
raise RecipeUpdateError(f"Некорректные числовые поля рецепта: {e}", 400) from e
|
||||
|
||||
recipe.trip_percent = float(data.get("trip_percent", 100) or 100)
|
||||
if "dry_matter_locked" in data:
|
||||
recipe.dry_matter_locked = bool(data.get("dry_matter_locked"))
|
||||
if "unloading_link_broken" in data:
|
||||
recipe.unloading_link_broken = bool(data.get("unloading_link_broken"))
|
||||
if "target_component_id" in data:
|
||||
recipe.target_component_id = data.get("target_component_id")
|
||||
|
||||
recipe.updated_by = "system"
|
||||
recipe.updated_at = _now()
|
||||
recipe.version = (recipe.version or 1) + 1
|
||||
|
||||
existing_rows = db.session.execute(
|
||||
select(Ingredient).where(
|
||||
Ingredient.recipe_id == recipe_id,
|
||||
Ingredient.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
existing_ingredients: Dict[int, Ingredient] = {ing.order: ing for ing in existing_rows}
|
||||
existing_ingredients_by_id: Dict[str, Ingredient] = {str(ing.id): ing for ing in existing_rows}
|
||||
|
||||
existing_grows = db.session.execute(
|
||||
select(UnloadingGroup).where(
|
||||
UnloadingGroup.recipe_id == recipe_id,
|
||||
UnloadingGroup.is_deleted.is_(False),
|
||||
)
|
||||
).scalars().all()
|
||||
existing_groups: Dict[int, UnloadingGroup] = {g.order: g for g in existing_grows}
|
||||
existing_groups_by_id: Dict[str, UnloadingGroup] = {str(g.id): g for g in existing_grows}
|
||||
|
||||
def _delete_ingredient_row(cur: Ingredient) -> None:
|
||||
ing_id = str(cur.id)
|
||||
cur.soft_delete(deleted_by_user="system")
|
||||
enqueue_sync_queue_task("ingredient", ing_id, "delete", priority=1)
|
||||
existing_ingredients_by_id.pop(ing_id, None)
|
||||
for ord_k, row in list(existing_ingredients.items()):
|
||||
if str(row.id) == ing_id:
|
||||
existing_ingredients.pop(ord_k, None)
|
||||
|
||||
def _delete_group_row(cur: UnloadingGroup) -> None:
|
||||
gid = str(cur.id)
|
||||
cur.soft_delete(deleted_by_user="system")
|
||||
enqueue_sync_queue_task("unloading_group", gid, "delete", priority=1)
|
||||
existing_groups_by_id.pop(gid, None)
|
||||
for ord_k, row in list(existing_groups.items()):
|
||||
if str(row.id) == gid:
|
||||
existing_groups.pop(ord_k, None)
|
||||
|
||||
for raw_id in data.get("deleted_ingredient_ids") or []:
|
||||
ing_id = str(raw_id or "").strip()
|
||||
if not ing_id:
|
||||
continue
|
||||
cur = existing_ingredients_by_id.get(ing_id) or db.session.get(Ingredient, ing_id)
|
||||
if cur is None or str(cur.recipe_id) != str(recipe_id) or cur.is_deleted:
|
||||
continue
|
||||
_delete_ingredient_row(cur)
|
||||
|
||||
for raw_id in data.get("deleted_unloading_group_ids") or []:
|
||||
gid = str(raw_id or "").strip()
|
||||
if not gid:
|
||||
continue
|
||||
cur = existing_groups_by_id.get(gid) or db.session.get(UnloadingGroup, gid)
|
||||
if cur is None or str(cur.recipe_id) != str(recipe_id) or cur.is_deleted:
|
||||
continue
|
||||
_delete_group_row(cur)
|
||||
|
||||
recipe_is_locked = bool(getattr(recipe, "dry_matter_locked", False))
|
||||
calc_by_order: Dict[int, Dict[str, Any]] = {}
|
||||
group_calc_by_order: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
ingredients_in: List[dict] = [x for x in (data.get("ingredients") or []) if isinstance(x, dict)]
|
||||
groups_in: List[dict] = [x for x in (data.get("unloading_groups") or []) if isinstance(x, dict)]
|
||||
|
||||
if recipe_is_locked:
|
||||
try:
|
||||
heads_count_calc = int(data.get("heads_count") or recipe.heads_per_trip or 0)
|
||||
trip_percent_calc = float(
|
||||
data.get("trip_percent", recipe.trip_percent or 100) or 100
|
||||
)
|
||||
|
||||
dm_component_ids = [
|
||||
ing.get("component_id")
|
||||
for ing in ingredients_in
|
||||
if ing.get("component_id")
|
||||
]
|
||||
dm_components = []
|
||||
if dm_component_ids:
|
||||
dm_components = (
|
||||
db.session.execute(
|
||||
select(Component).where(
|
||||
Component.id.in_(dm_component_ids),
|
||||
Component.is_deleted.is_(False),
|
||||
)
|
||||
)
|
||||
.scalars()
|
||||
.all()
|
||||
)
|
||||
dm_by_component_id = {c.id: float(c.dry_matter or 0) for c in dm_components}
|
||||
|
||||
calc_ingredients_payload: List[dict] = []
|
||||
ing_orders: List[int] = []
|
||||
for idx, ing in enumerate(ingredients_in, 1):
|
||||
order_value = int(ing.get("order", idx))
|
||||
ing_orders.append(order_value)
|
||||
|
||||
dm_percent = ing.get("dry_matter")
|
||||
if dm_percent is None:
|
||||
dm_percent = 0
|
||||
try:
|
||||
dm_percent = float(dm_percent)
|
||||
except (TypeError, ValueError):
|
||||
dm_percent = 0.0
|
||||
|
||||
if dm_percent <= 0:
|
||||
cid = ing.get("component_id")
|
||||
try:
|
||||
dm_percent = float(
|
||||
dm_by_component_id.get(str(cid), 0) if cid else 0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
dm_percent = 0.0
|
||||
|
||||
dm_per_head = (
|
||||
ing.get("dry_matter_per_head")
|
||||
if ing.get("dry_matter_per_head") is not None
|
||||
else ing.get("dryMatterPerHead")
|
||||
)
|
||||
try:
|
||||
dm_per_head = float(dm_per_head) if dm_per_head is not None else 0.0
|
||||
except (TypeError, ValueError):
|
||||
dm_per_head = 0.0
|
||||
|
||||
if dm_per_head <= 0 and recipe_is_locked and order_value in existing_ingredients:
|
||||
old_ing = existing_ingredients[order_value]
|
||||
if (
|
||||
old_ing.dry_matter_per_head is not None
|
||||
and old_ing.dry_matter_per_head > 0
|
||||
):
|
||||
dm_per_head = float(old_ing.dry_matter_per_head)
|
||||
else:
|
||||
old_wph = float(old_ing.weight_per_head or 0)
|
||||
old_dm = float(old_ing.dry_matter or 0)
|
||||
if old_dm > 0 and old_wph > 0:
|
||||
dm_per_head = old_wph * (old_dm / 100.0)
|
||||
else:
|
||||
raise RecipeUpdateError(
|
||||
f"Не удалось восстановить СВ/гол для ингредиента (order={order_value}). "
|
||||
"Убедитесь, что отправлено поле dry_matter_per_head или ингредиент существует в БД.",
|
||||
400,
|
||||
)
|
||||
|
||||
if dm_per_head <= 0 and dm_percent > 0 and not recipe_is_locked:
|
||||
try:
|
||||
wph = float(
|
||||
ing.get("weight_per_head")
|
||||
or ing.get("weightPerHead")
|
||||
or 0
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
wph = 0.0
|
||||
if wph > 0:
|
||||
dm_per_head = wph * (dm_percent / 100.0)
|
||||
|
||||
if dm_percent <= 0:
|
||||
raise RecipeUpdateError(
|
||||
f"СВ,% (dry_matter) должно быть > 0 для расчета (order={order_value})",
|
||||
400,
|
||||
)
|
||||
|
||||
calc_ingredients_payload.append(
|
||||
{
|
||||
"component_id": ing.get("component_id"),
|
||||
"dryMatterPerHead": dm_per_head,
|
||||
"dryMatter": dm_percent,
|
||||
}
|
||||
)
|
||||
|
||||
calc_groups_payload: List[dict] = []
|
||||
group_orders: List[int] = []
|
||||
for idx, group in enumerate(groups_in, 1):
|
||||
order_value = int(group.get("order", idx))
|
||||
group_orders.append(order_value)
|
||||
calc_groups_payload.append(
|
||||
{
|
||||
"distributionType": group.get("distribution_type", "percent"),
|
||||
"value": float(group.get("value") or 0),
|
||||
}
|
||||
)
|
||||
|
||||
result_calc = calculate_recipe(
|
||||
ingredients=calc_ingredients_payload,
|
||||
heads_count=heads_count_calc,
|
||||
trip_percent=trip_percent_calc,
|
||||
unloading_groups=calc_groups_payload,
|
||||
component_dry_matter_map=None,
|
||||
calculate_from_dry_matter=True,
|
||||
)
|
||||
|
||||
for order_value, calc in zip(
|
||||
ing_orders, result_calc.get("ingredients", [])
|
||||
):
|
||||
calc_by_order[order_value] = calc
|
||||
for order_value, calcg in zip(
|
||||
group_orders, result_calc.get("unloadingGroups", [])
|
||||
):
|
||||
group_calc_by_order[order_value] = calcg
|
||||
except RecipeUpdateError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"[RECIPE-UPDATE] Ошибка серверного расчета при замке СВ: %s",
|
||||
e,
|
||||
exc_info=True,
|
||||
)
|
||||
raise RecipeUpdateError(
|
||||
f"Ошибка расчета при замке СВ: {str(e)}", 400
|
||||
) from e
|
||||
|
||||
seen_ingredient_ids: set[str] = set()
|
||||
seen_orders: set[int] = set()
|
||||
for idx, ing in enumerate(ingredients_in, 1):
|
||||
order_value = int(ing.get("order", idx))
|
||||
component = _resolve_component(ing)
|
||||
if not component:
|
||||
raise RecipeUpdateError(
|
||||
f'Компонент не найден (id="{ing.get("component_id", "")}", '
|
||||
f'name="{ing.get("name", "")}"). Проверьте, что компонент существует в базе данных.',
|
||||
400,
|
||||
)
|
||||
|
||||
ing_id = str(ing.get("id") or "").strip()
|
||||
cur = None
|
||||
if ing_id and ing_id in existing_ingredients_by_id:
|
||||
cur = existing_ingredients_by_id[ing_id]
|
||||
elif order_value in existing_ingredients:
|
||||
cur = existing_ingredients[order_value]
|
||||
|
||||
if cur is not None:
|
||||
cur.name = component.name
|
||||
if recipe_is_locked and order_value in calc_by_order:
|
||||
cur.weight_per_head = float(
|
||||
calc_by_order[order_value].get("weightPerHead", 0) or 0
|
||||
)
|
||||
cur.amount = float(calc_by_order[order_value].get("tripWeight", 0) or 0)
|
||||
dm_per_head_to_save = float(
|
||||
calc_by_order[order_value].get("dryMatterPerHead", 0) or 0
|
||||
)
|
||||
cur.dry_matter_per_head = (
|
||||
dm_per_head_to_save if dm_per_head_to_save > 0 else None
|
||||
)
|
||||
else:
|
||||
cur.weight_per_head = _ing_weight_per_head(ing)
|
||||
cur.amount = float(ing.get("amount", 0) or 0)
|
||||
cur_dm = float(ing.get("dry_matter", component.dry_matter) or 0)
|
||||
cur_wph = float(cur.weight_per_head or 0)
|
||||
cur.dry_matter_per_head = (
|
||||
cur_wph * (cur_dm / 100.0) if (cur_dm > 0 and cur_wph > 0) else None
|
||||
)
|
||||
cur.dry_matter = float(ing.get("dry_matter", component.dry_matter) or 0)
|
||||
cur.component_id = component.id
|
||||
cur.order = order_value
|
||||
cur.updated_by = "system"
|
||||
cur.updated_at = _now()
|
||||
cur.version = (cur.version or 1) + 1
|
||||
existing_ingredients[order_value] = cur
|
||||
existing_ingredients_by_id[str(cur.id)] = cur
|
||||
seen_ingredient_ids.add(str(cur.id))
|
||||
else:
|
||||
new_wph = _ing_weight_per_head(ing)
|
||||
new_amount = float(ing.get("amount", 0) or 0)
|
||||
new_dm_per_head = None
|
||||
|
||||
if recipe_is_locked and order_value in calc_by_order:
|
||||
new_wph = float(calc_by_order[order_value].get("weightPerHead", 0) or 0)
|
||||
new_amount = float(calc_by_order[order_value].get("tripWeight", 0) or 0)
|
||||
new_dm_per_head = float(
|
||||
calc_by_order[order_value].get("dryMatterPerHead", 0) or 0
|
||||
) or None
|
||||
elif recipe_is_locked:
|
||||
raw = ing.get("dry_matter_per_head") or ing.get("dryMatterPerHead")
|
||||
new_dm_per_head = float(raw) if raw not in (None, "") else None
|
||||
else:
|
||||
cur_dm = float(ing.get("dry_matter", component.dry_matter) or 0)
|
||||
new_dm_per_head = (
|
||||
new_wph * (cur_dm / 100.0) if (cur_dm > 0 and new_wph > 0) else None
|
||||
)
|
||||
|
||||
new_ingredient = Ingredient(
|
||||
name=component.name,
|
||||
weight_per_head=new_wph,
|
||||
amount=new_amount,
|
||||
dry_matter=float(ing.get("dry_matter", component.dry_matter) or 0),
|
||||
dry_matter_per_head=new_dm_per_head,
|
||||
component_id=component.id,
|
||||
order=order_value,
|
||||
recipe_id=recipe_id,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(new_ingredient)
|
||||
db.session.flush()
|
||||
existing_ingredients[order_value] = new_ingredient
|
||||
existing_ingredients_by_id[str(new_ingredient.id)] = new_ingredient
|
||||
seen_ingredient_ids.add(str(new_ingredient.id))
|
||||
|
||||
seen_orders.add(order_value)
|
||||
|
||||
for cur in list(existing_ingredients_by_id.values()):
|
||||
if str(cur.id) not in seen_ingredient_ids:
|
||||
_delete_ingredient_row(cur)
|
||||
|
||||
seen_group_ids: set[str] = set()
|
||||
seen_group_orders: set[int] = set()
|
||||
for idx, group in enumerate(groups_in, 1):
|
||||
order_value = int(group.get("order", idx))
|
||||
try:
|
||||
gname = group["name"]
|
||||
gdist = group["distribution_type"]
|
||||
gval = float(group["value"])
|
||||
except (KeyError, TypeError, ValueError) as e:
|
||||
raise RecipeUpdateError(
|
||||
f"Некорректная группа выгрузки (order={order_value}): {e}", 400
|
||||
) from e
|
||||
|
||||
group_id = str(group.get("id") or "").strip()
|
||||
cur = None
|
||||
if group_id and group_id in existing_groups_by_id:
|
||||
cur = existing_groups_by_id[group_id]
|
||||
elif order_value in existing_groups:
|
||||
cur = existing_groups[order_value]
|
||||
|
||||
if cur is not None:
|
||||
cur.name = gname
|
||||
cur.distribution_type = gdist
|
||||
cur.value = gval
|
||||
if recipe_is_locked and order_value in group_calc_by_order:
|
||||
cur.weight = float(
|
||||
group_calc_by_order[order_value].get("calculatedWeight", 0) or 0
|
||||
)
|
||||
else:
|
||||
w = group.get("weight")
|
||||
cur.weight = float(w) if w not in (None, "") else None
|
||||
cur.order = order_value
|
||||
cur.updated_by = "system"
|
||||
cur.updated_at = _now()
|
||||
cur.version = (cur.version or 1) + 1
|
||||
existing_groups[order_value] = cur
|
||||
existing_groups_by_id[str(cur.id)] = cur
|
||||
seen_group_ids.add(str(cur.id))
|
||||
else:
|
||||
weight_val: Optional[float]
|
||||
if recipe_is_locked and order_value in group_calc_by_order:
|
||||
weight_val = float(
|
||||
group_calc_by_order[order_value].get("calculatedWeight", 0) or 0
|
||||
)
|
||||
else:
|
||||
w = group.get("weight")
|
||||
weight_val = float(w) if w not in (None, "") else None
|
||||
|
||||
new_group = UnloadingGroup(
|
||||
name=gname,
|
||||
distribution_type=gdist,
|
||||
value=gval,
|
||||
weight=weight_val,
|
||||
order=order_value,
|
||||
recipe_id=recipe_id,
|
||||
created_by="system",
|
||||
updated_by="system",
|
||||
)
|
||||
db.session.add(new_group)
|
||||
db.session.flush()
|
||||
existing_groups[order_value] = new_group
|
||||
existing_groups_by_id[str(new_group.id)] = new_group
|
||||
seen_group_ids.add(str(new_group.id))
|
||||
|
||||
seen_group_orders.add(order_value)
|
||||
|
||||
for cur in list(existing_groups_by_id.values()):
|
||||
if str(cur.id) not in seen_group_ids:
|
||||
_delete_group_row(cur)
|
||||
|
||||
_enqueue_recipe_children_sync(recipe_id)
|
||||
enqueue_sync_queue_task("recipe", recipe_id, "update", priority=3)
|
||||
|
||||
logger.info(
|
||||
"[RECIPES-DB] update_recipe_from_payload session_ready id=%s name=%s version=%s "
|
||||
"ingredients_payload=%s groups_payload=%s",
|
||||
recipe_id,
|
||||
(recipe.name or "")[:80],
|
||||
recipe.version,
|
||||
len(ingredients_in),
|
||||
len(groups_in),
|
||||
)
|
||||
|
||||
stats = {
|
||||
"changed_components": 0,
|
||||
"affected_recipes": 0,
|
||||
"recalculated_recipes": 0,
|
||||
"updated_ingredients": 0,
|
||||
"updated_unloading_groups": 0,
|
||||
"skipped_recipes": 0,
|
||||
}
|
||||
|
||||
return {"message": "Сохранено", "id": recipe_id, "stats": stats}
|
||||
Reference in New Issue
Block a user