From 361e7094e3bae33fc4480b363b86bbd2e08e7a3d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 16:17:45 +0400 Subject: [PATCH] =?UTF-8?q?feat(documents):=20=D0=BF=D0=BE=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=20409=20=D0=BF=D0=BE=D0=B4=D1=82=D0=B2=D0=B5?= =?UTF-8?q?=D1=80=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D1=84=D0=BE=D1=80?= =?UTF-8?q?=D0=BC=D1=8B=20=E2=80=94=20LINES=5FUNRESOLVED,=20DUPLICATE=5FFO?= =?UTF-8?q?RM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit form_confirmable заменён на form_blocker: PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED. Ручное и автоподтверждение используют одну функцию. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- README.md | 6 +- api/src/repositories/files.py | 22 +++- api/src/repositories/statements.py | 25 ++++- api/src/services/auto_confirm.py | 21 ++-- api/src/services/documents.py | 34 ++----- api/src/services/statements.py | 74 +++++++++++--- api/tests/test_api_statements.py | 149 +++++++++++++++++++++++++++ api/tests/test_auto_confirm.py | 38 ++++++- api/tests/test_form_confirmable.py | 158 ++++++++++++++++++++++++++--- 9 files changed, 462 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 1547791..06bdcc7 100644 --- a/README.md +++ b/README.md @@ -483,7 +483,7 @@ curl -s "localhost:8000/api/v1/files/" -H "Authorization: Bearer $TOKEN - `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит) либо система при автоподтверждении чистого документа (`confirmed_by = null`, аудит `DOCUMENT_CONFIRM` без пользователя; OQ-157); - `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл). -Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_confirmable`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`. +Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_blocker`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`. `counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; `period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде. @@ -533,7 +533,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл | `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 | | `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` | | `GET /documents/{document_id}/entries` | `DOCUMENT_VIEW` | строки ОСВ по `row_number` (`limit` ≤ 2000, по умолчанию 500); дерево — по `parent_entry_id` | -| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для автоподтверждённого документа — 409 `ALREADY_CONFIRMED` (D-6); для формы ББ/ОФР — ещё `PERIOD_REQUIRED` и `CHECKS_NOT_PASSED` | +| `POST /documents/{document_id}/confirm` | `DOCUMENT_EDIT` | `RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED`, аудит `DOCUMENT_CONFIRM`; для автоподтверждённого документа — 409 `ALREADY_CONFIRMED` (D-6); для формы ББ/ОФР — ещё (в порядке проверки) `PERIOD_REQUIRED`, `LINES_UNRESOLVED`, `DUPLICATE_FORM`, `CHECKS_NOT_PASSED` | | `GET /documents/{document_id}/statement` | `DOCUMENT_VIEW` | форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР») | | `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` | | `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) | @@ -924,6 +924,8 @@ email без учёта регистра проверяет только сер | `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | | `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | | `PERIOD_REQUIRED` | 409 | подтверждение формы: период не определён — поправьте дату отчёта | +| `LINES_UNRESOLVED` | 409 | подтверждение формы: есть строки `unreadable`/`missing` с непустой ячейкой; `detail.lines[] {line_id, row_index, line_code_text}` по `row_index` — назначьте код или очистите строку | +| `DUPLICATE_FORM` | 409 | подтверждение формы: в файле уже подтверждена форма того же типа, года и квартала; `detail.document_id` — подтверждённая форма | | `CHECKS_NOT_PASSED` | 409 | подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; `detail.failures[] {column_index, relation, expected, actual}` | | `TOO_MANY_IDS` | 422 | `GET /users/brief`: больше 200 id | | `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID | diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index abed777..e51cd35 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -20,7 +20,7 @@ from src.models.access import AppUser from src.models.audit_ex import Comment from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.directories import LegalEntity -from src.models.enums import FileStatus, S3BucketRole +from src.models.enums import DocumentStatus, FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry from src.repositories import statements as statements_repo @@ -194,6 +194,26 @@ async def lock_file_documents(session: AsyncSession, file_id: uuid.UUID) -> list return list((await session.execute(stmt)).scalars().all()) +async def confirmed_twin(session: AsyncSession, document: Document) -> uuid.UUID | None: + """Подтверждённая форма того же файла, типа, года и квартала (другой `file_ordinal`) — для `DUPLICATE_FORM`.""" + if document.period_year is None or document.period_quarter is None: + return None + stmt = ( + select(Document.id) + .where( + Document.stored_file_id == document.stored_file_id, + Document.document_type == document.document_type, + Document.period_year == document.period_year, + Document.period_quarter == document.period_quarter, + Document.status == DocumentStatus.CONFIRMED, + Document.id != document.id, + ) + .order_by(Document.file_ordinal) + .limit(1) + ) + return (await session.execute(stmt)).scalar_one_or_none() + + async def supersedes_others(session: AsyncSession, file_id: uuid.UUID) -> bool: """На файл ссылаются `superseded_by_file_id` других файлов (файл заменил другие версии).""" stmt = select(select(StoredFile.id).where(StoredFile.superseded_by_file_id == file_id).exists()) diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index 1ad0218..782d40e 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from decimal import Decimal from typing import Any -from sqlalchemy import delete, func, insert, select +from sqlalchemy import delete, func, insert, or_, select from sqlalchemy.ext.asyncio import AsyncSession from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code @@ -203,6 +203,29 @@ async def has_source_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool: return bool((await session.execute(stmt)).scalar_one()) +async def unresolved_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[uuid.UUID, int | None, str | None]]: + """Строки `unreadable`/`missing` с непустой ячейкой (`amount` или `string_value`) по `row_index`: + `(line_id, row_index, line_code_text)`. Строки без значений подтверждение не блокируют.""" + has_value = ( + select(FinancialStatementValue.id) + .where( + FinancialStatementValue.line_id == FinancialStatementLine.id, + or_(FinancialStatementValue.amount.is_not(None), FinancialStatementValue.string_value.is_not(None)), + ) + .exists() + ) + stmt = ( + select(FinancialStatementLine.id, FinancialStatementLine.row_index, FinancialStatementLine.line_code_text) + .where( + FinancialStatementLine.meta_id == meta_id, + FinancialStatementLine.line_status.in_((StatementLineStatus.unreadable, StatementLineStatus.missing)), + has_value, + ) + .order_by(FinancialStatementLine.row_index.asc().nulls_last(), FinancialStatementLine.id) + ) + return [(row[0], row[1], row[2]) for row in (await session.execute(stmt)).all()] + + async def form_aggregate( session: AsyncSession, meta_id: uuid.UUID, diff --git a/api/src/services/auto_confirm.py b/api/src/services/auto_confirm.py index f5bb462..84f943c 100644 --- a/api/src/services/auto_confirm.py +++ b/api/src/services/auto_confirm.py @@ -17,7 +17,7 @@ from src.models.files import Document, StoredFile from src.repositories import statements as statements_repo from src.services import consolidation_documents from src.services.audit import AuditService -from src.services.statements import FORM_TYPES, form_confirmable +from src.services.statements import FORM_TYPES, form_blocker AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний" OBJECT_TYPE = "document" @@ -34,24 +34,25 @@ def transition_comment(document: Document) -> str: async def auto_confirm( session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime ) -> list[Document]: - """Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие - не трогает. Возвращает подтверждённые документы по возрастанию `id`.""" + """Подтверждает документы `RECOGNIZED` файла (форма — только при `form_blocker is None`, D-3); `NEEDS_CHECK` + и прочие не трогает. Возвращает подтверждённые документы по возрастанию `id`.""" audit = AuditService(session) confirmed: list[Document] = [] for document in sorted(documents, key=lambda item: item.id): if document.status is not DocumentStatus.RECOGNIZED: continue - if not await form_confirmable(session, document): - continue + meta = None + if document.document_type in FORM_TYPES: + meta = await statements_repo.get_meta(session, document.id, refresh=True) + if meta is not None and await form_blocker(session, document, meta) is not None: + continue document.status = DocumentStatus.CONFIRMED document.confirmed_by = None document.confirmed_at = now document.updated_at = now - if document.document_type in FORM_TYPES: - meta = await statements_repo.get_meta(session, document.id) - if meta is not None: - meta.reviewed_by = None - meta.reviewed_at = now + if meta is not None: + meta.reviewed_by = None + meta.reviewed_at = now await session.flush() await consolidation_documents.on_document_confirmed( session, diff --git a/api/src/services/documents.py b/api/src/services/documents.py index 215d279..dfa82b9 100644 --- a/api/src/services/documents.py +++ b/api/src/services/documents.py @@ -244,45 +244,33 @@ class DocumentService: return await files_repo.list_entries(self._session, document.id, limit=page.limit, offset=page.offset) async def _check_statement(self, document: Document) -> FinancialStatementMeta | None: - """Условия подтверждения формы ББ/ОФР (D-9): период определён, жёсткие проверки §7 пройдены или акцептованы. + """Условия подтверждения формы ББ/ОФР (D-9): `form_blocker` — период, нераспознанные строки со значениями, + дубль формы, жёсткие проверки §7 (пройдены или акцептованы). Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт). Для не-формы и документа без шапки — `None`, условий нет. """ - from src.services.statements import FORM_TYPES, statement_hard_failures # noqa: PLC0415 - цикл импорта + from src.services.statements import FORM_TYPES, form_blocker # noqa: PLC0415 - цикл импорта if document.document_type not in FORM_TYPES: return None meta = await statements_repo.get_meta(self._session, document.id, refresh=True) if meta is None: return None - if document.period_year is None or document.period_quarter is None: - raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта") - failures = await statement_hard_failures(self._session, document, meta) - if failures: - raise ConflictError( - CHECKS_NOT_PASSED, - "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", - failures=[ - { - "column_index": failure.column_index, - "relation": failure.relation, - "expected": str(failure.expected), - "actual": str(failure.actual), - } - for failure in failures - ], - ) + blocker = await form_blocker(self._session, document, meta) + if blocker is not None: + raise ConflictError(blocker.code, blocker.message, **blocker.extra) return meta async def confirm(self, document_id: uuid.UUID) -> DocumentOut: """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. 409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус — - `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён) и - `CHECKS_NOT_PASSED` (неакцептованные нарушения проверок §7 в `failures`; D-9). Порядок блокировок и - перечитывание — см. docstring модуля. В той же транзакции документ привязывается к подходящим версиям - «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят в `DATA_CORRECTION` + `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён), + `LINES_UNRESOLVED`, `DUPLICATE_FORM` и `CHECKS_NOT_PASSED` (неакцептованные нарушения §7 в + `failures`; D-9). Порядок блокировок и перечитывание — см. docstring модуля. В той же транзакции документ + привязывается к подходящим версиям «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` + переходят в `DATA_CORRECTION` (`on_document_confirmed`, spec documents-api «Подтверждение документа»). """ visible = await self._visible(document_id) diff --git a/api/src/services/statements.py b/api/src/services/statements.py index c1064fd..d22f2ea 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -6,6 +6,7 @@ import re import uuid +from dataclasses import dataclass from typing import Any from sqlalchemy.ext.asyncio import AsyncSession @@ -43,7 +44,13 @@ from src.repositories import files as files_repo from src.repositories import statements as repo from src.repositories.counterparties import CounterpartyIndex from src.schemas import statements as schemas -from src.services.documents import CONFIRMABLE_STATUSES, DOCUMENT_NOT_CONFIRMABLE, FILE_SUPERSEDED +from src.services.documents import ( + CHECKS_NOT_PASSED, + CONFIRMABLE_STATUSES, + DOCUMENT_NOT_CONFIRMABLE, + FILE_SUPERSEDED, + PERIOD_REQUIRED, +) DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" @@ -53,6 +60,8 @@ UNKNOWN_CELL = "UNKNOWN_CELL" DUPLICATE_CELL = "DUPLICATE_CELL" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" INVALID_INN = "INVALID_INN" +LINES_UNRESOLVED = "LINES_UNRESOLVED" +DUPLICATE_FORM = "DUPLICATE_FORM" OBJECT_TYPE = "document" INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII) FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) @@ -80,18 +89,59 @@ async def statement_hard_failures( return tuple(report.failed) -async def form_confirmable(session: AsyncSession, document: Document) -> bool: - """Условия подтверждения формы ББ/ОФР без исключений (§7, D-9 Среза 3b; автоподтверждение — design D-3, OQ-157): - период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b) - — `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409.""" - if document.document_type not in FORM_TYPES: - return True - meta = await repo.get_meta(session, document.id, refresh=True) - if meta is None: - return True +@dataclass(frozen=True) +class FormBlocker: + """Причина отказа в подтверждении формы: код 409, сообщение и поля `detail`.""" + + code: str + message: str + extra: dict[str, Any] + + +async def form_blocker(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> FormBlocker | None: + """Первое препятствие подтверждению формы ББ/ОФР или `None` (общий путь ручного и автоподтверждения). + + Порядок: `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (строки `unreadable`/`missing` с непустой ячейкой) → + `DUPLICATE_FORM` (уже подтверждена форма того же файла, типа и периода) → `CHECKS_NOT_PASSED` (жёсткие §7 + без акцепта). + """ if document.period_year is None or document.period_quarter is None: - return False - return not await statement_hard_failures(session, document, meta) + return FormBlocker(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта", {}) + lines = await repo.unresolved_lines(session, meta.id) + if lines: + return FormBlocker( + LINES_UNRESOLVED, + "в форме есть нераспознанные строки со значениями: назначьте код или очистите строку", + { + "lines": [ + {"line_id": str(line_id), "row_index": row_index, "line_code_text": code_text} + for line_id, row_index, code_text in lines + ] + }, + ) + twin = await files_repo.confirmed_twin(session, document) + if twin is not None: + return FormBlocker( + DUPLICATE_FORM, "форма этого типа и периода в файле уже подтверждена", {"document_id": str(twin)} + ) + failures = await statement_hard_failures(session, document, meta) + if failures: + return FormBlocker( + CHECKS_NOT_PASSED, + "не пройдены логические проверки формы: исправьте значения или акцептуйте с комментарием", + { + "failures": [ + { + "column_index": failure.column_index, + "relation": failure.relation, + "expected": str(failure.expected), + "actual": str(failure.actual), + } + for failure in failures + ] + }, + ) + return None class StatementService: diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 0a8cc1c..2d3efde 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -908,6 +908,155 @@ async def test_confirm_requires_checks( assert meta.reviewed_by is not None and meta.reviewed_at is not None +async def _meta_of(session: AsyncSession, document: Document) -> FinancialStatementMeta: + return ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + + +async def _add_unresolved( # noqa: PLR0913 - фабрика тестовой строки + session: AsyncSession, + document: Document, + *, + status: StatementLineStatus, + row_index: int, + code_text: str | None = None, + amount: Decimal | None = None, + string_value: str | None = None, +) -> FinancialStatementLine: + meta = await _meta_of(session, document) + line = FinancialStatementLine(meta_id=meta.id, line_status=status, row_index=row_index, line_code_text=code_text) + session.add(line) + await session.flush() + column = ( + await session.execute( + select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1) + ) + ).scalar_one() + session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=amount, string_value=string_value)) + await session.flush() + return line + + +async def _add_confirmed_twin(session: AsyncSession, document: Document) -> Document: + twin = Document( + stored_file_id=document.stored_file_id, + legal_entity_id=document.legal_entity_id, + document_type=document.document_type, + file_ordinal=document.file_ordinal + 1, + period_year=document.period_year, + period_quarter=document.period_quarter, + status=DocumentStatus.CONFIRMED, + created_at=document.created_at, + updated_at=document.updated_at, + ) + session.add(twin) + await session.flush() + return twin + + +async def test_confirm_unresolved_line_with_amount( + 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) # жёсткое нарушение не мешает: LINES_UNRESOLVED раньше + line = await _add_unresolved(seeded, document, status=StatementLineStatus.missing, row_index=40, amount=Decimal(7)) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT + detail = blocked.json()["detail"] + assert detail["code"] == "LINES_UNRESOLVED" + assert detail["lines"] == [{"line_id": str(line.id), "row_index": 40, "line_code_text": None}] + + +async def test_confirm_unresolved_line_with_text_only( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _add_unresolved( + seeded, + document, + status=StatementLineStatus.unreadable, + row_index=41, + code_text="ИЧ,250", + string_value="211(1 |", + ) + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "LINES_UNRESOLVED" + + +async def test_unresolved_line_without_values_does_not_block( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _add_unresolved(seeded, document, status=StatementLineStatus.unreadable, row_index=41) + confirmed = await client.post(f"/documents/{document.id}/confirm") + assert confirmed.status_code == HTTPStatus.OK and confirmed.json()["status"] == "CONFIRMED" + + +async def test_confirm_duplicate_form(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + """Review Focus 1: вторая форма того же типа и периода в файле после подтверждения первой — `DUPLICATE_FORM`.""" + first = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + first_confirmed = await client.post(f"/documents/{first.id}/confirm") + assert first_confirmed.status_code == HTTPStatus.OK + second = Document( + stored_file_id=first.stored_file_id, + legal_entity_id=first.legal_entity_id, + document_type=first.document_type, + file_ordinal=first.file_ordinal + 1, + period_year=first.period_year, + period_quarter=first.period_quarter, + status=DocumentStatus.NEEDS_CHECK, + created_at=first.created_at, + updated_at=first.updated_at, + ) + seeded.add(second) + await seeded.flush() + first_meta = await _meta_of(seeded, first) + seeded.add( + FinancialStatementMeta( + document_id=second.id, + recognition_job_id=first_meta.recognition_job_id, + statement_type=first_meta.statement_type, + ) + ) + await seeded.flush() + blocked = await client.post(f"/documents/{second.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT + assert blocked.json()["detail"]["code"] == "DUPLICATE_FORM" + assert blocked.json()["detail"]["document_id"] == str(first.id) + + +async def test_conflict_order(seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory) -> None: + """PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + await _break_balance(client, document.id) + await _add_confirmed_twin(seeded, document) + line = await _add_unresolved(seeded, document, status=StatementLineStatus.missing, row_index=40, amount=Decimal(7)) + quarter = document.period_quarter + document.period_quarter = None + await seeded.flush() + + async def code() -> str: + return (await client.post(f"/documents/{document.id}/confirm")).json()["detail"]["code"] + + seen = [await code()] + document.period_quarter = quarter + await seeded.flush() + seen.append(await code()) + await seeded.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id == line.id)) + await seeded.flush() + seen.append(await code()) + await seeded.execute(delete(Document).where(Document.status == DocumentStatus.CONFIRMED)) + await seeded.flush() + seen.append(await code()) + assert seen == ["PERIOD_REQUIRED", "LINES_UNRESOLVED", "DUPLICATE_FORM", "CHECKS_NOT_PASSED"] + + async def test_confirm_after_non_quarter_date_edit( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: diff --git a/api/tests/test_auto_confirm.py b/api/tests/test_auto_confirm.py index 4c731ea..05ebed8 100644 --- a/api/tests/test_auto_confirm.py +++ b/api/tests/test_auto_confirm.py @@ -2,6 +2,7 @@ OQ-157; design D-1–D-3).""" import uuid +from decimal import Decimal from pathlib import Path import pytest @@ -22,9 +23,15 @@ from src.models.enums import ( FileStatus, PeriodQuarter, RecognitionStatus, + StatementLineStatus, ) from src.models.files import Document, StoredFile -from src.models.statements import FinancialStatementMeta +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) from src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment from tests.conftest import AuthClientFactory from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file @@ -210,6 +217,35 @@ async def test_recognized_form_without_period_stays(seeded: AsyncSession, fixtur assert income.status is DocumentStatus.RECOGNIZED +@pytest.mark.fixtures +async def test_auto_confirm_skips_blocked_form(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" / "1_Форма2_бух_09.json" + import json # noqa: PLC0415 - нужен только здесь + + data = json.loads(path.read_text("utf-8")) + inn = next(d["header"]["inn"] for d in data["documents"] if d.get("header", {}).get("inn")) + owner = await make_legal_entity(seeded, name="ООО Владелец", inn=inn) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + meta = ( + await seeded.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == income.id)) + ).scalar_one() + line = FinancialStatementLine(meta_id=meta.id, line_status=StatementLineStatus.missing, row_index=900) + seeded.add(line) + await seeded.flush() + column = ( + await seeded.execute( + select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1) + ) + ).scalar_one() + seeded.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=Decimal(5))) + await seeded.flush() + file = await seeded.get(StoredFile, income.stored_file_id) + assert file is not None + + assert await auto_confirm(seeded, file=file, documents=[income], now=utc_now()) == [] + assert income.status is DocumentStatus.RECOGNIZED + + async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`.""" office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() diff --git a/api/tests/test_form_confirmable.py b/api/tests/test_form_confirmable.py index 67cd9f5..d5df08e 100644 --- a/api/tests/test_form_confirmable.py +++ b/api/tests/test_form_confirmable.py @@ -1,17 +1,23 @@ -"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b).""" +"""Условия подтверждения формы ББ/ОФР: ручной и автоматический путь (OQ-157, design D-3; D-9 Среза 3b; A11).""" import json +from decimal import Decimal from pathlib import Path import pytest -from sqlalchemy import select +from sqlalchemy import delete, select from sqlalchemy.ext.asyncio import AsyncSession from src.models.directories import LegalEntity -from src.models.enums import DocumentType +from src.models.enums import DocumentStatus, DocumentType, StatementLineStatus from src.models.files import Document -from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue -from src.services.statements import form_confirmable, statement_hard_failures +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) +from src.services.statements import form_blocker, statement_hard_failures from tests.factories import make_legal_entity from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot @@ -35,15 +41,78 @@ async def _income(session: AsyncSession, fixtures_dir: Path) -> Document: return income +async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + return ( + await session.execute(select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id)) + ).scalar_one() + + +async def _unresolved_line( # noqa: PLR0913 - фабрика тестовой строки + session: AsyncSession, + meta: FinancialStatementMeta, + *, + status: StatementLineStatus, + row_index: int, + code_text: str | None = None, + amount: Decimal | None = None, + string_value: str | None = None, +) -> FinancialStatementLine: + """Строка без кода (ORM-фабрика) с одной ячейкой в первой колонке формы.""" + line = FinancialStatementLine(meta_id=meta.id, line_status=status, row_index=row_index, line_code_text=code_text) + session.add(line) + await session.flush() + column = ( + await session.execute( + select(FinancialStatementColumn).where(FinancialStatementColumn.meta_id == meta.id).limit(1) + ) + ).scalar_one() + session.add(FinancialStatementValue(line_id=line.id, column_id=column.id, amount=amount, string_value=string_value)) + await session.flush() + return line + + +async def _confirmed_twin(session: AsyncSession, document: Document) -> Document: + """Вторая форма того же файла, типа и периода со статусом `CONFIRMED` (другой `file_ordinal`).""" + twin = Document( + stored_file_id=document.stored_file_id, + legal_entity_id=document.legal_entity_id, + document_type=document.document_type, + file_ordinal=document.file_ordinal + 1, + period_year=document.period_year, + period_quarter=document.period_quarter, + status=DocumentStatus.CONFIRMED, + created_at=document.created_at, + updated_at=document.updated_at, + ) + session.add(twin) + await session.flush() + return twin + + +async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> None: + values = ( + await session.execute( + select(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta.id, FinancialStatementLine.line_code == "2200") + ) + ).scalars() + for value in values: + value.amount = (value.amount or 0) + 1000 + await session.flush() + + async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: - assert await form_confirmable(seeded, await _income(seeded, fixtures_dir)) is True + income = await _income(seeded, fixtures_dir) + assert await form_blocker(seeded, income, await _meta(seeded, income)) is None async def test_form_without_period_is_not_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: income = await _income(seeded, fixtures_dir) income.period_quarter = None await seeded.flush() - assert await form_confirmable(seeded, income) is False + blocker = await form_blocker(seeded, income, await _meta(seeded, income)) + assert blocker is not None and blocker.code == "PERIOD_REQUIRED" async def test_form_with_hard_failure_is_not_confirmable_until_accepted( @@ -67,17 +136,76 @@ async def test_form_with_hard_failure_is_not_confirmable_until_accepted( await seeded.flush() assert await statement_hard_failures(seeded, income, meta) != () - assert await form_confirmable(seeded, income) is False + blocker = await form_blocker(seeded, income, meta) + assert blocker is not None and blocker.code == "CHECKS_NOT_PASSED" and blocker.extra["failures"] meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор await seeded.flush() - assert await form_confirmable(seeded, income) is True + assert await form_blocker(seeded, income, meta) is None -async def test_disclosure_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: - owner = await make_legal_entity(seeded, name="ООО Синтетика") - documents = await map_golden_snapshot( - seeded, entity=owner, path=fixtures_dir / SYNTH / "st06_header_variants.json", uploaded_by=None +async def test_unresolved_line_with_amount_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + meta = await _meta(seeded, income) + line = await _unresolved_line(seeded, meta, status=StatementLineStatus.missing, row_index=40, amount=Decimal(5)) + blocker = await form_blocker(seeded, income, meta) + assert blocker is not None and blocker.code == "LINES_UNRESOLVED" + assert blocker.extra == {"lines": [{"line_id": str(line.id), "row_index": 40, "line_code_text": None}]} + + +async def test_unresolved_line_with_text_only_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + meta = await _meta(seeded, income) + await _unresolved_line( + seeded, meta, status=StatementLineStatus.unreadable, row_index=41, code_text="ИЧ,250", string_value="211(1 |" ) - disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) - assert await form_confirmable(seeded, disclosure) is True + blocker = await form_blocker(seeded, income, meta) + assert blocker is not None and blocker.code == "LINES_UNRESOLVED" + assert blocker.extra["lines"][0]["line_code_text"] == "ИЧ,250" + + +async def test_unresolved_line_without_values_does_not_block(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + meta = await _meta(seeded, income) + await _unresolved_line(seeded, meta, status=StatementLineStatus.unreadable, row_index=41) + assert await form_blocker(seeded, income, meta) is None + + +async def test_duplicate_form_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Review Focus 1: подтверждённая форма того же файла, типа и периода -> `DUPLICATE_FORM` с её id.""" + income = await _income(seeded, fixtures_dir) + twin = await _confirmed_twin(seeded, income) + blocker = await form_blocker(seeded, income, await _meta(seeded, income)) + assert blocker is not None and blocker.code == "DUPLICATE_FORM" and blocker.extra == {"document_id": str(twin.id)} + + +async def test_other_period_twin_does_not_block(seeded: AsyncSession, fixtures_dir: Path) -> None: + income = await _income(seeded, fixtures_dir) + twin = await _confirmed_twin(seeded, income) + twin.period_year = (income.period_year or 0) - 1 + await seeded.flush() + assert await form_blocker(seeded, income, await _meta(seeded, income)) is None + + +async def test_conflict_order(seeded: AsyncSession, fixtures_dir: Path) -> None: + """PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED.""" + income = await _income(seeded, fixtures_dir) + meta = await _meta(seeded, income) + await _break_2200(seeded, meta) + await _confirmed_twin(seeded, income) + await _unresolved_line(seeded, meta, status=StatementLineStatus.missing, row_index=40, amount=Decimal(5)) + quarter = income.period_quarter + income.period_quarter = None + await seeded.flush() + codes = [] + codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] + income.period_quarter = quarter + await seeded.flush() + codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] + await seeded.execute(delete(FinancialStatementValue).where(FinancialStatementValue.amount == Decimal(5))) + await seeded.flush() + codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] + await seeded.execute(delete(Document).where(Document.status == DocumentStatus.CONFIRMED)) + await seeded.flush() + codes.append((await form_blocker(seeded, income, meta)).code) # type: ignore[union-attr] + assert codes == ["PERIOD_REQUIRED", "LINES_UNRESOLVED", "DUPLICATE_FORM", "CHECKS_NOT_PASSED"]