530 lines
21 KiB
Python
530 lines
21 KiB
Python
"""
|
||
Обновление рецепта (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}
|