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 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,
},
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,
)
).scalar_one_or_none()
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",

View File

@ -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]

View File

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

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 "нельзя удалить напрямую" 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()