AURORA-1887: открытый АХР пересчитывает итоги после удаления записи аренды/коммуналки/охраны #325

Merged
aep merged 1 commits from feat/324-aurora-1887-ahr-delete-refresh into test 2026-11-12 00:01:19 +03:00
5 changed files with 230 additions and 25 deletions

View File

@ -10,7 +10,6 @@ import time
from typing import Any, Optional from typing import Any, Optional
# #
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect, status
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@ -871,30 +870,17 @@ async def notify_ahr_about_satellite_change(
): ):
try: try:
if source_event == "row_deleted": if source_event == "row_deleted":
# AURORA-1887: строка АХР записи скрыта, фронт её не найдёт —
# итоги обновляются только пересчитанными заголовками в result
# (useRealtimeData: updateCells(result), затем deleteRow).
async with get_db_session(user_id=user_id) as db: async with get_db_session(user_id=user_id) as db:
deleted_line_id = ( deleted_line_id, ahr_headers = await SheetService(
await db.execute( db
text( ).get_form2_ahr_satellite_delete_delta(
""" form_id=form_id,
SELECT (event_data->>'line_id')::int source_sheet=source_sheet,
FROM v3.audit_log source_row_id=source_row_id,
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()
if deleted_line_id is None: if deleted_line_id is None:
return return
@ -906,7 +892,7 @@ async def notify_ahr_about_satellite_change(
"row_id": deleted_line_id, "row_id": deleted_line_id,
"source_sheet": source_sheet, "source_sheet": source_sheet,
}, },
"result": [], "result": ahr_headers,
}, },
form_id=form_id, form_id=form_id,
sheet="AHR", sheet="AHR",

View File

@ -431,3 +431,66 @@ class SheetRepository:
) )
).all() ).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]

View File

@ -326,3 +326,15 @@ class SheetService:
source_sheet=source_sheet, source_sheet=source_sheet,
source_row_id=source_row_id, 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,
)

View File

@ -781,3 +781,81 @@ def test_satellite_sourced_ahr_line_not_deletable_directly_after_owner_guard(
) )
assert response.status_code != 200, response.text assert response.status_code != 200, response.text
assert "нельзя удалить напрямую" in response.text, 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, [])

View File

@ -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()