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:
parent
38a6a1b0c2
commit
6c00a8705f
@ -182,9 +182,13 @@ async def lock_cells(
|
||||
}
|
||||
|
||||
|
||||
async def get_meta(session: AsyncSession, document_id: uuid.UUID) -> FinancialStatementMeta | None:
|
||||
"""Шапка формы документа (1:1) или `None`."""
|
||||
async def get_meta(
|
||||
session: AsyncSession, document_id: uuid.UUID, *, refresh: bool = False
|
||||
) -> FinancialStatementMeta | None:
|
||||
"""Шапка формы документа (1:1) или `None`; `refresh` — перечитать атрибуты, даже если объект уже в сессии."""
|
||||
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()
|
||||
|
||||
|
||||
|
||||
@ -76,7 +76,9 @@ class StatementService:
|
||||
raise ConflictError(DOCUMENT_READONLY, "подтверждённая форма только для чтения: загрузите новый файл")
|
||||
if document.status not in CONFIRMABLE_STATUSES:
|
||||
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:
|
||||
raise ConflictError(DOCUMENT_NOT_STATEMENT, "документ — не форма ББ/ОФР")
|
||||
return document, meta
|
||||
|
||||
@ -13,6 +13,7 @@ from sqlalchemy import select, update
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.directories import LegalEntity, RegionalOffice
|
||||
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_INCOME = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json"
|
||||
MEZH_BALANCE_LINES = 37
|
||||
EVENTS_AFTER_BREAK_AND_ACCEPT = 2
|
||||
|
||||
|
||||
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()
|
||||
.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(
|
||||
f"/documents/{document.id}/statement",
|
||||
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:
|
||||
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-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()
|
||||
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(
|
||||
@ -329,6 +351,61 @@ async def test_patch_inn_validates_and_rechecks(
|
||||
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(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> 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"
|
||||
|
||||
|
||||
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(
|
||||
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
|
||||
) -> None:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user