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);
- `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 |

View File

@ -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())

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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:

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
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:

View File

@ -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()

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
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"]