fix(statements): шапка формы перечитывается под блокировкой; тесты аудита и сброса акцепта (I-1, T6.1)

I-1: `_editable` брал шапку без перечитывания, и после ожидания блокировки видел устаревший акцепт и значения
шапки. `get_meta(..., refresh=True)` читает с populate_existing после lock_file и lock_document.

T6.1: тесты — содержимое аудита правки значения и акцепта, исходное значение даты отчёта, дата не на конце квартала,
сброс акцепта правкой только значения, неизменность формы после INVALID_INN, свежесть шапки под блокировкой.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 12:23:03 +04:00
parent 38a6a1b0c2
commit 6c00a8705f
3 changed files with 134 additions and 6 deletions

View File

@ -182,9 +182,13 @@ async def lock_cells(
} }
async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None: async def get_meta(
"""Шапка формы документа (1:1) или `None`.""" session: AsyncSession, document_id: uuid.UUID, *, refresh: bool = False
) -> FinancialStatementMeta | None:
"""Шапка формы документа (1:1) или `None`; `refresh` — перечитать атрибуты, даже если объект уже в сессии."""
stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id) stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document_id)
if refresh:
stmt = stmt.execution_options(populate_existing=True)
return (await session.execute(stmt)).scalar_one_or_none() return (await session.execute(stmt)).scalar_one_or_none()

View File

@ -76,7 +76,9 @@ class StatementService:
raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите новый файл") raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите новый файл")
if document.status not in CONFIRMABLE_STATUSES: if document.status not in CONFIRMABLE_STATUSES:
raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится") raise ConflictError(DOCUMENT_NOT_CONFIRMABLE, f"форма в статусе {document.status.value} не правится")
meta = await repo.get_meta(self._session, document.id) # читаем под блокировкой: `_visible_statement` загрузил шапку до неё, а параллельный запрос мог её изменить
# (например, поставить акцепт) — без `refresh` в сессии остался бы устаревший экземпляр
meta = await repo.get_meta(self._session, document.id, refresh=True)
if meta is None: if meta is None:
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР") raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
return document, meta return document, meta

View File

@ -13,6 +13,7 @@ from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.statement_checks import CROSS_FORM_RELATION from src.domain.statement_checks import CROSS_FORM_RELATION
from src.models.access import AppUser
from src.models.audit_ex import AuditEvent from src.models.audit_ex import AuditEvent
from src.models.directories import LegalEntity, RegionalOffice from src.models.directories import LegalEntity, RegionalOffice
from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus from src.models.enums import AuditAction, DocumentStatus, DocumentType, FileStatus, RecognitionStatus
@ -35,6 +36,7 @@ ROSTOV = "Ростовский региональный филиал"
MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json" MEZH_BALANCE = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма1_бух_09.json"
MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" MEZH_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
MEZH_BALANCE_LINES = 37 MEZH_BALANCE_LINES = 37
EVENTS_AFTER_BREAK_AND_ACCEPT = 2
async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity: async def rostov_entity(session: AsyncSession, path: Path, name: str = "ООО Межениновская") -> LegalEntity:
@ -297,7 +299,11 @@ async def test_patch_value_and_revert(seeded: AsyncSession, fixtures_dir: Path,
.scalars() .scalars()
.one() .one()
) )
assert event.before_value and event.after_value key = f"value:{edited['id']}"
assert set(event.before_value) == {key} == set(event.after_value)
assert Decimal(event.before_value[key]) == Decimal(original) and Decimal(event.after_value[key]) == Decimal(new)
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
assert edited["modified_by"] == str(user_id)
response = await client.patch( response = await client.patch(
f"/documents/{document.id}/statement", f"/documents/{document.id}/statement",
json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]}, json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": original}]},
@ -311,10 +317,26 @@ async def test_patch_report_date_recomputes_period(
) -> None: ) -> None:
document = await form(seeded, fixtures_dir) document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF) client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-06-30"}}) url = f"/documents/{document.id}/statement"
original = (await client.get(url)).json()["header"]["report_date"]["original_value"]
assert original is not None
response = await client.patch(url, json={"header": {"report_date": "2025-06-30"}})
body = response.json() body = response.json()
assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True assert body["document"]["period_quarter"] == "Q2" and body["header"]["report_date"]["modified"] is True
assert body["header"]["report_date"]["original_value"] is not None # исходник не тронут assert body["header"]["report_date"]["value"] == "2025-06-30"
assert body["header"]["report_date"]["original_value"] == original # исходник не тронут
async def test_patch_report_date_not_quarter_end(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Review Focus 5 (PATCH): дата не на конце квартала — квартал пуст, статус `NEEDS_CHECK`."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"report_date": "2025-10-31"}})
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()["document"]
assert body["period_quarter"] is None and body["status"] == "NEEDS_CHECK"
async def test_patch_inn_validates_and_rechecks( async def test_patch_inn_validates_and_rechecks(
@ -329,6 +351,61 @@ async def test_patch_inn_validates_and_rechecks(
assert other.json()["document"]["status"] == "NEEDS_CHECK" assert other.json()["document"]["status"] == "NEEDS_CHECK"
async def test_patch_invalid_inn_changes_nothing(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""После `INVALID_INN` значения из того же тела не применяются, аудита нет."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
line, cell = await _first_cell(client, document.id)
before = (await client.get(url)).json()
body = {
"header": {"inn": "12345"},
"values": [{"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}],
}
response = await client.patch(url, json=body)
assert response.status_code == HTTPStatus.UNPROCESSABLE_ENTITY
assert response.json()["detail"]["code"] == "INVALID_INN"
assert (await client.get(url)).json() == before
events = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().all()
assert events == []
async def test_patch_rereads_meta_under_lock(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Шапка перечитывается под блокировкой: акцепт, поставленный параллельным запросом после загрузки шапки в
сессию, виден правке и сбрасывается ею (иначе акцепт остался бы поверх изменённых данных)."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
line, cell = await _first_cell(client, document.id)
# карта идентичности слабая: удерживаем шапку, как это делает запрос между `_visible_statement` и блокировкой
held = (
await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id))
).scalar_one()
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
await seeded.execute(
update(FinancialStatementMeta)
.where(FinancialStatementMeta.document_id == document.id)
.values(
checks_accepted_by=user_id,
checks_accepted_at=datetime.datetime.now(datetime.UTC),
checks_accepted_comment="x",
)
.execution_options(synchronize_session=False) # объект в сессии остаётся устаревшим, как у ждущего запроса
)
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}
response = await client.patch(url, json={"values": [item]})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["checks"]["accepted"] is None
await seeded.refresh(held)
assert held.checks_accepted_at is None and held.checks_accepted_comment is None
event = (await seeded.execute(select(AuditEvent).where(AuditEvent.object_id == document.id))).scalars().one()
assert event.before_value["checks_accepted"]["by"] == str(user_id) and event.after_value["checks_accepted"] is None
async def test_patch_duplicate_cell_rejected( async def test_patch_duplicate_cell_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None: ) -> None:
@ -434,6 +511,51 @@ async def test_accept_and_reset(seeded: AsyncSession, fixtures_dir: Path, auth_c
assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK" assert after["checks"]["accepted"] is None and after["document"]["status"] == "NEEDS_CHECK"
async def test_accept_audit(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None:
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
await _break_balance(client, document.id)
response = await client.post(
f"/documents/{document.id}/statement/checks/accept", json={"comment": " округление "}
)
assert response.status_code == HTTPStatus.OK, response.text
user_id = (await seeded.execute(select(AppUser.id).where(AppUser.email == KA_RF))).scalar_one()
events = (
(
await seeded.execute(
select(AuditEvent).where(
AuditEvent.object_id == document.id, AuditEvent.action == AuditAction.RECOGNIZED_DATA_EDIT
)
)
)
.scalars()
.all()
)
assert len(events) == EVENTS_AFTER_BREAK_AND_ACCEPT # правка значения в `_break_balance` и акцепт
(accept,) = [event for event in events if "checks_accepted" in event.after_value]
assert accept.before_value == {"checks_accepted": None}
assert set(accept.after_value) == {"checks_accepted"}
assert set(accept.after_value["checks_accepted"]) == {"by", "at", "comment"}
assert accept.after_value["checks_accepted"]["by"] == str(user_id)
assert accept.after_value["checks_accepted"]["comment"] == "округление" # без пробелов по краям
async def test_value_patch_resets_accept(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None:
"""Сбрасывает акцепт и правка одного только значения (не только шапки)."""
document = await form(seeded, fixtures_dir)
client = await auth_client(KA_RF)
url = f"/documents/{document.id}/statement"
await _break_balance(client, document.id)
assert (await client.post(f"{url}/checks/accept", json={"comment": "ок"})).status_code == HTTPStatus.OK
line, cell = await _first_cell(client, document.id)
item = {"line_id": line["id"], "column_id": cell["column_id"], "amount": str(Decimal(cell["amount"]) + 1)}
response = await client.patch(url, json={"values": [item]})
assert response.status_code == HTTPStatus.OK, response.text
assert response.json()["checks"]["accepted"] is None
async def test_accept_blank_comment_rejected( async def test_accept_blank_comment_rejected(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None: ) -> None: