Интегрирован wesp в сайт
CI / quality (push) Canceled after 0s

This commit is contained in:
влад
2026-07-17 12:57:18 +03:00
parent 5dfa06ddbe
commit 355c0ef9f1
883 changed files with 194576 additions and 177 deletions
@@ -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}