From 551ef0ec14de42c2ed7be82c1a07c6c691e66298 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 11 Nov 2026 23:51:48 +0400 Subject: [PATCH] =?UTF-8?q?fix(form2):=20=D0=BE=D1=82=D0=BA=D1=80=D1=8B?= =?UTF-8?q?=D1=82=D1=8B=D0=B9=20=D0=90=D0=A5=D0=A0=20=D0=BF=D0=B5=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=87=D0=B8=D1=82=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D0=B3=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5?= =?UTF-8?q?=20=D1=83=D0=B4=D0=B0=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=20=D0=B0=D1=80=D0=B5=D0=BD=D0=B4?= =?UTF-8?q?=D1=8B/=D0=BA=D0=BE=D0=BC=D0=BC=D1=83=D0=BD=D0=B0=D0=BB=D0=BA?= =?UTF-8?q?=D0=B8/=D0=BE=D1=85=D1=80=D0=B0=D0=BD=D1=8B=20(AURORA-1887,=20#?= =?UTF-8?q?324)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notify_ahr_about_satellite_change рассылал на АХР row_deleted по скрытой (AURORA-1887) строке сателлита с пустым result: фронт не находил строку и не получал пересчитанных заголовков — итоги статьи, раздела и сметы оставались прежними до перезагрузки. Теперь в result — заголовки ROOT/GROUP/ITEM/SUB_ITEM/SUB_SUB_ITEM по пути статьи удалённой строки (как del_budget_line для обычных листов). Поиск строки по аудиту и выборка заголовков вынесены в SheetRepository/SheetService.get_form2_ahr_satellite_delete_delta. Протокол WS и фронт не меняются. Co-Authored-By: Claude Opus 5.5 --- api/src/api/v1/websocket.py | 36 +++------ api/src/repository/sheet_repository.py | 63 +++++++++++++++ api/src/services/sheet_service.py | 12 +++ .../test_form2_ahr_satellite_integration.py | 78 +++++++++++++++++++ .../test_websocket_ahr_satellite_delete.py | 66 ++++++++++++++++ 5 files changed, 230 insertions(+), 25 deletions(-) create mode 100644 api/tests/unit/test_websocket_ahr_satellite_delete.py diff --git a/api/src/api/v1/websocket.py b/api/src/api/v1/websocket.py index c0e3122..2f42815 100644 --- a/api/src/api/v1/websocket.py +++ b/api/src/api/v1/websocket.py @@ -10,7 +10,6 @@ import time from typing import Any, Optional # -from sqlalchemy import text from sqlalchemy.exc import SQLAlchemyError from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status from sqlalchemy.ext.asyncio import AsyncSession @@ -871,30 +870,17 @@ async def notify_ahr_about_satellite_change( ): try: if source_event == "row_deleted": + # AURORA-1887: строка АХР записи скрыта, фронт её не найдёт — + # итоги обновляются только пересчитанными заголовками в result + # (useRealtimeData: updateCells(result), затем deleteRow). async with get_db_session(user_id=user_id) as db: - deleted_line_id = ( - await db.execute( - text( - """ - SELECT (event_data->>'line_id')::int - FROM v3.audit_log - WHERE form_id = :form_id - AND event = 'ROW_DELETE' - AND event_type = 'ROW' - AND event_data->>'sheet' = 'AHR' - AND event_data->>'source_sheet' = :source_sheet - AND (event_data->>'source_id')::int = :source_row_id - ORDER BY id DESC - LIMIT 1 - """ - ), - { - "form_id": form_id, - "source_sheet": source_sheet, - "source_row_id": source_row_id, - }, - ) - ).scalar_one_or_none() + deleted_line_id, ahr_headers = await SheetService( + db + ).get_form2_ahr_satellite_delete_delta( + form_id=form_id, + source_sheet=source_sheet, + source_row_id=source_row_id, + ) if deleted_line_id is None: return @@ -906,7 +892,7 @@ async def notify_ahr_about_satellite_change( "row_id": deleted_line_id, "source_sheet": source_sheet, }, - "result": [], + "result": ahr_headers, }, form_id=form_id, sheet="AHR", diff --git a/api/src/repository/sheet_repository.py b/api/src/repository/sheet_repository.py index c76ccc1..52e37f1 100644 --- a/api/src/repository/sheet_repository.py +++ b/api/src/repository/sheet_repository.py @@ -431,3 +431,66 @@ class SheetRepository: ) ).all() ] + + async def get_form2_ahr_satellite_delete_delta( + self, + form_id: int, + source_sheet: str, + source_row_id: int, + ) -> tuple[int | None, list[tuple]]: + """Строка АХР удалённой записи сателлита и пересчитанные заголовки по пути её статьи. + + Связь «запись → строка АХР» к этому моменту удалена вместе со строкой + (v3.del_budget_line, ветка сателлитов), поэтому line_id и статья берутся + из записи ROW_DELETE в аудите. INPUT-строка на АХР скрыта (AURORA-1887), + открытому листу нужны только заголовки — те же, что del_budget_line + возвращает для обычных листов. + """ + deleted = ( + await self.db.execute( + text( + """ + SELECT (event_data->>'line_id')::int, + (event_data->>'expense_item_id')::int + FROM v3.audit_log + WHERE form_id = :form_id + AND event = 'ROW_DELETE' + AND event_type = 'ROW' + AND event_data->>'sheet' = 'AHR' + AND event_data->>'source_sheet' = :source_sheet + AND (event_data->>'source_id')::int = :source_row_id + ORDER BY id DESC + LIMIT 1 + """ + ), + { + "form_id": form_id, + "source_sheet": source_sheet, + "source_row_id": source_row_id, + }, + ) + ).first() + if deleted is None: + return None, [] + + line_id, expense_item_id = deleted + if expense_item_id is None: + return line_id, [] + + rows = ( + await self.db.execute( + text( + """ + SELECT v.row_type, v.depth, v.sort_order, v.data + FROM v3.v_form_view(:form_id, 'AHR', NULL, 'Support') v + WHERE v.row_type IN ('ROOT', 'GROUP', 'ITEM', 'SUB_ITEM', 'SUB_SUB_ITEM') + AND (v.data->'header'->>'expense_item_id')::int = ANY( + v3._expense_item_path_set(ARRAY[CAST(:expense_item_id AS int)]) + ) + ORDER BY v.sort_order + """ + ), + {"form_id": form_id, "expense_item_id": expense_item_id}, + ) + ).all() + return line_id, [tuple(r) for r in rows] diff --git a/api/src/services/sheet_service.py b/api/src/services/sheet_service.py index decd1d6..2be536a 100644 --- a/api/src/services/sheet_service.py +++ b/api/src/services/sheet_service.py @@ -326,3 +326,15 @@ class SheetService: source_sheet=source_sheet, source_row_id=source_row_id, ) + + async def get_form2_ahr_satellite_delete_delta( + self, + form_id: int, + source_sheet: str, + source_row_id: int, + ) -> tuple[int | None, list[tuple]]: + return await self.sheet_repo.get_form2_ahr_satellite_delete_delta( + form_id=form_id, + source_sheet=source_sheet, + source_row_id=source_row_id, + ) diff --git a/api/tests/integration/test_form2_ahr_satellite_integration.py b/api/tests/integration/test_form2_ahr_satellite_integration.py index 9fb08cf..923c52c 100644 --- a/api/tests/integration/test_form2_ahr_satellite_integration.py +++ b/api/tests/integration/test_form2_ahr_satellite_integration.py @@ -781,3 +781,81 @@ def test_satellite_sourced_ahr_line_not_deletable_directly_after_owner_guard( ) assert response.status_code != 200, response.text assert "нельзя удалить напрямую" in response.text, response.text + + +# --- AURORA-1887, возврат 11.11 (Gitea #324): дельта АХР после удаления записи +# сателлита. Строка АХР этой записи скрыта, поэтому открытому АХР нужны +# пересчитанные заголовки по пути статьи — иначе итоги остаются прежними до +# перезагрузки страницы. + + +@pytest.mark.parametrize("sheet", sorted(SATELLITE_PARENT_EXPENSE_ITEM)) +def test_satellite_delete_delta_has_recalculated_headers( + client, admin_tokens, auth_headers, sheet +): + from src.services.sheet_service import SheetService + + expense_item_id = SATELLITE_PARENT_EXPENSE_ITEM[sheet] + form_id = _create_form2(client, admin_tokens, auth_headers, year=2071) + + kept = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=1) + deleted = _add_satellite_line(client, admin_tokens, auth_headers, form_id, sheet, vsp_id=2) + _patch_cell(client, admin_tokens, auth_headers, form_id, sheet, kept["id"], "plan.q1", 100) + _patch_cell(client, admin_tokens, auth_headers, form_id, sheet, deleted["id"], "plan.q1", 50) + + header_before = _find_ahr_header_row( + _get_ahr_rows(client, admin_tokens, auth_headers, form_id), expense_item_id + ) + assert header_before["data"]["plan"]["q1"] == 150 + + response = client.delete( + f"/api/v1/form/{form_id}/sheet/{sheet}/line/{deleted['id']}", + headers=auth_headers(admin_tokens), + ) + assert response.status_code == 200, response.text + + async def _delta(): + return await SheetService(client.db_session).get_form2_ahr_satellite_delete_delta( + form_id=form_id, + source_sheet=sheet, + source_row_id=deleted["id"], + ) + + deleted_line_id, rows = client.portal.call(_delta) + + assert deleted_line_id == deleted["line_id"] + assert all(row[0] != "INPUT" for row in rows), rows + + # Дельта — те же строки, что отдаёт GET .../sheet/AHR (тот же sort_order: + # фронт сопоставляет по row_type + depth + sort_order). + ahr_rows = _get_ahr_rows(client, admin_tokens, auth_headers, form_id) + by_key = {(r["row_type"], r["depth"], r["sort_order"]): r for r in ahr_rows} + header_after = _find_ahr_header_row(ahr_rows, expense_item_id) + assert header_after["data"]["plan"]["q1"] == 100 + + delta_by_eid = {row[3]["header"]["expense_item_id"]: row for row in rows} + assert expense_item_id in delta_by_eid, rows + assert delta_by_eid[expense_item_id][3]["plan"]["q1"] == 100 + assert "ROOT" in {row[0] for row in rows}, rows + for row_type, depth, sort_order, data in rows: + screen_row = by_key[(row_type, depth, sort_order)] + # GET отдаёт data без null-полей (AURORA-1848), дельта WS — с ними. + delta_plan = {k: v for k, v in data["plan"].items() if v is not None} + assert screen_row["data"]["plan"] == delta_plan + + +def test_satellite_delete_delta_without_audit_record_is_empty( + client, admin_tokens, auth_headers +): + from src.services.sheet_service import SheetService + + form_id = _create_form2(client, admin_tokens, auth_headers, year=2072) + + async def _delta(): + return await SheetService(client.db_session).get_form2_ahr_satellite_delete_delta( + form_id=form_id, + source_sheet="AHR_RENT", + source_row_id=987654321, + ) + + assert client.portal.call(_delta) == (None, []) diff --git a/api/tests/unit/test_websocket_ahr_satellite_delete.py b/api/tests/unit/test_websocket_ahr_satellite_delete.py new file mode 100644 index 0000000..e056e9c --- /dev/null +++ b/api/tests/unit/test_websocket_ahr_satellite_delete.py @@ -0,0 +1,66 @@ +"""AURORA-1887, возврат 11.11 (Gitea #324): после удаления записи аренды / +коммуналки / охраны открытый АХР получает row_deleted с пересчитанными +заголовками в result — фронт (useRealtimeData) сначала вливает result через +updateCells, затем убирает строку. С пустым result итоги не менялись.""" +from contextlib import asynccontextmanager +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from src.api.v1 import websocket as ws + + +HEADERS = [ + ("ROOT", 0, 1, {"header": {"expense_item_id": 1}, "plan": {"q1": 1075}}), + ("SUB_ITEM", 3, 7, {"header": {"expense_item_id": 314}, "plan": {"q1": 100}}), +] + + +@pytest.fixture +def broadcast(monkeypatch): + @asynccontextmanager + async def fake_session(user_id=None): + yield MagicMock() + + monkeypatch.setattr(ws, "get_db_session", fake_session) + mock = AsyncMock() + monkeypatch.setattr(ws.manager, "broadcast_to_form_sheet", mock) + return mock + + +def _patch_delta(monkeypatch, result): + delta = AsyncMock(return_value=result) + monkeypatch.setattr(ws.SheetService, "get_form2_ahr_satellite_delete_delta", delta) + return delta + + +@pytest.mark.asyncio +async def test_row_deleted_sends_recalculated_headers(monkeypatch, broadcast): + delta = _patch_delta(monkeypatch, (555, HEADERS)) + + await ws.notify_ahr_about_satellite_change( + form_id=10, source_sheet="AHR_RENT", source_row_id=42, source_event="row_deleted", user_id=1, + ) + + delta.assert_awaited_once() + assert delta.await_args.kwargs == {"form_id": 10, "source_sheet": "AHR_RENT", "source_row_id": 42} + broadcast.assert_awaited_once_with( + data={ + "event": "row_deleted", + "data": {"row_id": 555, "source_sheet": "AHR_RENT"}, + "result": HEADERS, + }, + form_id=10, + sheet="AHR", + ) + + +@pytest.mark.asyncio +async def test_row_deleted_without_audit_record_sends_nothing(monkeypatch, broadcast): + _patch_delta(monkeypatch, (None, [])) + + await ws.notify_ahr_about_satellite_change( + form_id=10, source_sheet="AHR_RENT", source_row_id=42, source_event="row_deleted", user_id=1, + ) + + broadcast.assert_not_awaited() -- 2.47.2