diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 2001dca..ad303df 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -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() diff --git a/api/src/services/statements.py b/api/src/services/statements.py index ef4defb..37c59c3 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -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 diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 80e9f20..6a5c1de 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -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: