feat(documents): порядок 409 подтверждения формы — LINES_UNRESOLVED, DUPLICATE_FORM

form_confirmable заменён на form_blocker: PERIOD_REQUIRED -> LINES_UNRESOLVED -> DUPLICATE_FORM -> CHECKS_NOT_PASSED.
Ручное и автоподтверждение используют одну функцию.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 16:17:45 +04:00
parent b57431a234
commit 361e7094e3
9 changed files with 462 additions and 65 deletions

View File

@ -483,7 +483,7 @@ curl -s "localhost:8000/api/v1/files/<file_id>" -H "Authorization: Bearer $TOKEN
- `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит) либо система при автоподтверждении чистого документа (`confirmed_by = null`, аудит `DOCUMENT_CONFIRM` без пользователя; OQ-157); - `CONFIRMED` — аналитик нажал «Подтвердить» (`POST /documents/{id}/confirm`, `confirmed_by/at`, аудит) либо система при автоподтверждении чистого документа (`confirmed_by = null`, аудит `DOCUMENT_CONFIRM` без пользователя; OQ-157);
- `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл). - `ERROR` — зарезервирован: в Срезе 2c ядро документ в `ERROR` не создаёт (результат без документов — «в результате нет документов» — переводит в `ERROR` файл).
Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_confirmable`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`. Чистый документ (`RECOGNIZED` при маппинге) подтверждается системой сразу в маппинге. `RECOGNIZED` остаётся у форм, ставших чистыми после правки/акцепта (автоподтверждения после правки нет), у форм, не прошедших условия подтверждения (`form_blocker`), и у документов, созданных до автоподтверждения; у документов с замечаниями статус `NEEDS_CHECK`.
`counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла; `counterparty_check` (`MATCH` / `MISMATCH` / `UNKNOWN`) сверяет шапку (ИНН, иначе имя или алиас) с ЮЛ — владельцем файла;
`period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде. `period_check` — `UNKNOWN`: подходящесть документа версии консолидации — функция (документ, версия), а не свойство документа (Срез 4, OQ-120). Допущения помечены `ASSUMPTION(OQ-nn)` в коде.
@ -533,7 +533,7 @@ S3 (storage); без S3 — предупреждение в логе, цикл
| `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 | | `DELETE /files/{file_id}` | `DOCUMENT_EDIT` | удалить файл (см. «Удаление и замена»), 204 |
| `GET /documents/{document_id}` | `DOCUMENT_VIEW` | документ: статус, проверки, `locator`, `group_schema` листа, `warnings`, `file_id` | | `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` | | `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 (см. «Формы ББ/ОФР») | | `GET /documents/{document_id}/statement` | `DOCUMENT_VIEW` | форма ББ/ОФР: шапка, колонки, строки со значениями, редакция, проверки §7 (см. «Формы ББ/ОФР») |
| `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` | | `PATCH /documents/{document_id}/statement` | `DOCUMENT_EDIT` | правка значений и шапки (`inn`, `report_date`, `units`) до подтверждения; аудит `RECOGNIZED_DATA_EDIT` |
| `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) | | `POST /documents/{document_id}/statement/checks/accept` | `DOCUMENT_EDIT` | акцепт непройденных жёстких проверок с комментарием (`{comment}`) |
@ -924,6 +924,8 @@ email без учёта регистра проверяет только сер
| `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды | | `DUPLICATE_CELL` | 422 | `PATCH …/statement`: одна ячейка указана в запросе дважды |
| `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) | | `NOTHING_TO_ACCEPT` | 409 | акцепт: непройденных жёстких проверок нет (в том числе уже акцептованы) |
| `PERIOD_REQUIRED` | 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}` | | `CHECKS_NOT_PASSED` | 409 | подтверждение формы: жёсткие проверки §7 не пройдены и не акцептованы; `detail.failures[] {column_index, relation, expected, actual}` |
| `TOO_MANY_IDS` | 422 | `GET /users/brief`: больше 200 id | | `TOO_MANY_IDS` | 422 | `GET /users/brief`: больше 200 id |
| `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID | | `INVALID_IDS` | 422 | `GET /users/brief`: элемент `ids` — не UUID |

View File

@ -20,7 +20,7 @@ from src.models.access import AppUser
from src.models.audit_ex import Comment from src.models.audit_ex import Comment
from src.models.consolidation import ConsolidationDocument, ConsolidationVersion from src.models.consolidation import ConsolidationDocument, ConsolidationVersion
from src.models.directories import LegalEntity 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.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry from src.models.osv import OsvEntry
from src.repositories import statements as statements_repo 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()) 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: async def supersedes_others(session: AsyncSession, file_id: uuid.UUID) -> bool:
"""На файл ссылаются `superseded_by_file_id` других файлов (файл заменил другие версии).""" """На файл ссылаются `superseded_by_file_id` других файлов (файл заменил другие версии)."""
stmt = select(select(StoredFile.id).where(StoredFile.superseded_by_file_id == file_id).exists()) stmt = select(select(StoredFile.id).where(StoredFile.superseded_by_file_id == file_id).exists())

View File

@ -8,7 +8,7 @@ from dataclasses import dataclass
from decimal import Decimal from decimal import Decimal
from typing import Any 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 sqlalchemy.ext.asyncio import AsyncSession
from src.domain.statement_aggregate import AggLine, CodeAggregate, aggregate_by_code 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()) 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( async def form_aggregate(
session: AsyncSession, session: AsyncSession,
meta_id: uuid.UUID, meta_id: uuid.UUID,

View File

@ -17,7 +17,7 @@ from src.models.files import Document, StoredFile
from src.repositories import statements as statements_repo from src.repositories import statements as statements_repo
from src.services import consolidation_documents from src.services import consolidation_documents
from src.services.audit import AuditService 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 = "автоподтверждение: без замечаний" AUTO_CONFIRM_DESCRIPTION = "автоподтверждение: без замечаний"
OBJECT_TYPE = "document" OBJECT_TYPE = "document"
@ -34,24 +34,25 @@ def transition_comment(document: Document) -> str:
async def auto_confirm( async def auto_confirm(
session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime session: AsyncSession, *, file: StoredFile, documents: Sequence[Document], now: datetime.datetime
) -> list[Document]: ) -> list[Document]:
"""Подтверждает документы `RECOGNIZED` файла (форма — только при `form_confirmable`, D-3); `NEEDS_CHECK` и прочие """Подтверждает документы `RECOGNIZED` файла (форма — только при `form_blocker is None`, D-3); `NEEDS_CHECK`
не трогает. Возвращает подтверждённые документы по возрастанию `id`.""" и прочие не трогает. Возвращает подтверждённые документы по возрастанию `id`."""
audit = AuditService(session) audit = AuditService(session)
confirmed: list[Document] = [] confirmed: list[Document] = []
for document in sorted(documents, key=lambda item: item.id): for document in sorted(documents, key=lambda item: item.id):
if document.status is not DocumentStatus.RECOGNIZED: if document.status is not DocumentStatus.RECOGNIZED:
continue continue
if not await form_confirmable(session, document): meta = None
continue 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.status = DocumentStatus.CONFIRMED
document.confirmed_by = None document.confirmed_by = None
document.confirmed_at = now document.confirmed_at = now
document.updated_at = now document.updated_at = now
if document.document_type in FORM_TYPES: if meta is not None:
meta = await statements_repo.get_meta(session, document.id) meta.reviewed_by = None
if meta is not None: meta.reviewed_at = now
meta.reviewed_by = None
meta.reviewed_at = now
await session.flush() await session.flush()
await consolidation_documents.on_document_confirmed( await consolidation_documents.on_document_confirmed(
session, session,

View File

@ -244,45 +244,33 @@ class DocumentService:
return await files_repo.list_entries(self._session, document.id, limit=page.limit, offset=page.offset) 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: async def _check_statement(self, document: Document) -> FinancialStatementMeta | None:
"""Условия подтверждения формы ББ/ОФР (D-9): период определён, жёсткие проверки §7 пройдены или акцептованы. """Условия подтверждения формы ББ/ОФР (D-9): `form_blocker` — период, нераспознанные строки со значениями,
дубль формы, жёсткие проверки §7 (пройдены или акцептованы).
Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт). Шапка читается под блокировками документа с `refresh` (иначе в сессии мог остаться устаревший акцепт).
Для не-формы и документа без шапки — `None`, условий нет. Для не-формы и документа без шапки — `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: if document.document_type not in FORM_TYPES:
return None return None
meta = await statements_repo.get_meta(self._session, document.id, refresh=True) meta = await statements_repo.get_meta(self._session, document.id, refresh=True)
if meta is None: if meta is None:
return None return None
if document.period_year is None or document.period_quarter is None: blocker = await form_blocker(self._session, document, meta)
raise ConflictError(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта") if blocker is not None:
failures = await statement_hard_failures(self._session, document, meta) raise ConflictError(blocker.code, blocker.message, **blocker.extra)
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
],
)
return meta return meta
async def confirm(self, document_id: uuid.UUID) -> DocumentOut: async def confirm(self, document_id: uuid.UUID) -> DocumentOut:
"""`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`. """`RECOGNIZED`/`NEEDS_CHECK` → `CONFIRMED` (`confirmed_by/at`), аудит `DOCUMENT_CONFIRM`.
409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус — 409: файл `SUPERSEDED` — `FILE_SUPERSEDED`; уже `CONFIRMED` — `ALREADY_CONFIRMED`; иной статус —
`DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён) и `DOCUMENT_NOT_CONFIRMABLE`; для формы ББ/ОФР далее — `PERIOD_REQUIRED` (период не определён),
`CHECKS_NOT_PASSED` (неакцептованные нарушения проверок §7 в `failures`; D-9). Порядок блокировок и `LINES_UNRESOLVED`, `DUPLICATE_FORM` и `CHECKS_NOT_PASSED` (неакцептованные нарушения §7 в
перечитывание — см. docstring модуля. В той же транзакции документ привязывается к подходящим версиям `failures`; D-9). Порядок блокировок и перечитывание — см. docstring модуля. В той же транзакции документ
«в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING` переходят в `DATA_CORRECTION` привязывается к подходящим версиям «в работе», версии из `INITIATED`/`DOCUMENTS_PROCESSING`
переходят в `DATA_CORRECTION`
(`on_document_confirmed`, spec documents-api «Подтверждение документа»). (`on_document_confirmed`, spec documents-api «Подтверждение документа»).
""" """
visible = await self._visible(document_id) visible = await self._visible(document_id)

View File

@ -6,6 +6,7 @@
import re import re
import uuid import uuid
from dataclasses import dataclass
from typing import Any from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession 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 import statements as repo
from src.repositories.counterparties import CounterpartyIndex from src.repositories.counterparties import CounterpartyIndex
from src.schemas import statements as schemas 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_FOUND = "документ не найден" # одинаково для чужого и несуществующего
DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT"
@ -53,6 +60,8 @@ UNKNOWN_CELL = "UNKNOWN_CELL"
DUPLICATE_CELL = "DUPLICATE_CELL" DUPLICATE_CELL = "DUPLICATE_CELL"
NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT" NOTHING_TO_ACCEPT = "NOTHING_TO_ACCEPT"
INVALID_INN = "INVALID_INN" INVALID_INN = "INVALID_INN"
LINES_UNRESOLVED = "LINES_UNRESOLVED"
DUPLICATE_FORM = "DUPLICATE_FORM"
OBJECT_TYPE = "document" OBJECT_TYPE = "document"
INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII) INN_RE = re.compile(r"\d{10}|\d{12}", re.ASCII)
FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME}) FORM_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME})
@ -80,18 +89,59 @@ async def statement_hard_failures(
return tuple(report.failed) return tuple(report.failed)
async def form_confirmable(session: AsyncSession, document: Document) -> bool: @dataclass(frozen=True)
"""Условия подтверждения формы ББ/ОФР без исключений (§7, D-9 Среза 3b; автоподтверждение — design D-3, OQ-157): class FormBlocker:
период определён и жёстких нарушений §7 нет или они акцептованы. Не-форма и форма без шапки (созданная до Среза 3b) """Причина отказа в подтверждении формы: код 409, сообщение и поля `detail`."""
— `True`: условий у них нет. Ручной путь (`DocumentService._check_statement`) проверяет то же, но отвечает 409."""
if document.document_type not in FORM_TYPES: code: str
return True message: str
meta = await repo.get_meta(session, document.id, refresh=True) extra: dict[str, Any]
if meta is None:
return True
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: if document.period_year is None or document.period_quarter is None:
return False return FormBlocker(PERIOD_REQUIRED, "у формы не определён период: поправьте дату отчёта", {})
return not await statement_hard_failures(session, document, meta) 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: class StatementService:

View File

@ -908,6 +908,155 @@ async def test_confirm_requires_checks(
assert meta.reviewed_by is not None and meta.reviewed_at is not None 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( async def test_confirm_after_non_quarter_date_edit(
seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory
) -> None: ) -> None:

View File

@ -2,6 +2,7 @@
OQ-157; design D-1–D-3).""" OQ-157; design D-1–D-3)."""
import uuid import uuid
from decimal import Decimal
from pathlib import Path from pathlib import Path
import pytest import pytest
@ -22,9 +23,15 @@ from src.models.enums import (
FileStatus, FileStatus,
PeriodQuarter, PeriodQuarter,
RecognitionStatus, RecognitionStatus,
StatementLineStatus,
) )
from src.models.files import Document, StoredFile 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 src.services.auto_confirm import AUTO_CONFIRM_DESCRIPTION, auto_confirm, transition_comment
from tests.conftest import AuthClientFactory from tests.conftest import AuthClientFactory
from tests.factories import make_company_group, make_consolidation, make_document, make_legal_entity, make_stored_file 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 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: async def test_manual_confirm_of_auto_confirmed_is_409(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`.""" """Review Focus 4: фронт с устаревшим статусом жмёт «Подтвердить» — 409 `ALREADY_CONFIRMED`."""
office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() office = (await seeded.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one()

View File

@ -1,17 +1,23 @@
"""Условия подтверждения формы ББ/ОФР — общие для ручного и автоматического пути (OQ-157, design D-3; D-9 Среза 3b).""" """Условия подтверждения формы ББ/ОФР: ручной и автоматический путь (OQ-157, design D-3; D-9 Среза 3b; A11)."""
import json import json
from decimal import Decimal
from pathlib import Path from pathlib import Path
import pytest import pytest
from sqlalchemy import select from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.models.directories import LegalEntity 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.files import Document
from src.models.statements import FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue from src.models.statements import (
from src.services.statements import form_confirmable, statement_hard_failures FinancialStatementColumn,
FinancialStatementLine,
FinancialStatementMeta,
FinancialStatementValue,
)
from src.services.statements import form_blocker, statement_hard_failures
from tests.factories import make_legal_entity from tests.factories import make_legal_entity
from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot 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 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: 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: async def test_form_without_period_is_not_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None:
income = await _income(seeded, fixtures_dir) income = await _income(seeded, fixtures_dir)
income.period_quarter = None income.period_quarter = None
await seeded.flush() 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( 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() await seeded.flush()
assert await statement_hard_failures(seeded, income, meta) != () 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 # акцепт: любые непустые дата и автор meta.checks_accepted_at = income.created_at # акцепт: любые непустые дата и автор
await seeded.flush() 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: async def test_unresolved_line_with_amount_blocks(seeded: AsyncSession, fixtures_dir: Path) -> None:
owner = await make_legal_entity(seeded, name="ООО Синтетика") income = await _income(seeded, fixtures_dir)
documents = await map_golden_snapshot( meta = await _meta(seeded, income)
seeded, entity=owner, path=fixtures_dir / SYNTH / "st06_header_variants.json", uploaded_by=None 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) blocker = await form_blocker(seeded, income, meta)
assert await form_confirmable(seeded, disclosure) is True 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"]