From 1d821b4d3e48b3d53d8ac0bc5631f6f002d5f270 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 11:40:59 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20=D0=BC=D0=B0=D0=BF=D0=BF?= =?UTF-8?q?=D0=B8=D0=BD=D0=B3=20=D1=84=D0=BE=D1=80=D0=BC=20=D0=91=D0=91/?= =?UTF-8?q?=D0=9E=D0=A4=D0=A0=20=D0=B2=20map=5Ffile=20=E2=80=94=20meta,=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=BB=D0=BE=D0=BD=D0=BA=D0=B8,=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=D0=B8,=20=D0=B7=D0=BD=D0=B0=D1=87=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F,=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=20(D?= =?UTF-8?q?-1=E2=80=A6D-3,=20D-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/statements.py | 140 ++++++++++++++++++ api/src/services/result_mapping.py | 129 +++++++++++++++-- api/tests/test_mapping_golden.py | 9 +- api/tests/test_mapping_statements.py | 204 +++++++++++++++++++++++++++ api/tests/test_result_mapping.py | 36 ++--- 5 files changed, 489 insertions(+), 29 deletions(-) create mode 100644 api/src/repositories/statements.py create mode 100644 api/tests/test_mapping_statements.py diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py new file mode 100644 index 0000000..d3c95f3 --- /dev/null +++ b/api/src/repositories/statements.py @@ -0,0 +1,140 @@ +"""Таблицы `financial_statement_*` (Срез 3b): вставка формы при маппинге, очистка файла, чтение сумм и флагов значений. +Только SQL, без правил: правила — `domain/statement_mapping.py`, `domain/statement_checks.py`.""" + +import uuid +from decimal import Decimal +from typing import Any + +from sqlalchemy import delete, insert, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.statement_mapping import StatementDraft +from src.models.files import Document +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) + +__all__ = ["BATCH", "delete_file_statements", "insert_statement", "statement_amounts", "value_flags"] + +BATCH = 1000 # значений на один INSERT: 8 колонок × 1000 < 32767 параметров asyncpg + + +async def insert_statement( + session: AsyncSession, *, document_id: uuid.UUID, job_id: uuid.UUID, draft: StatementDraft +) -> FinancialStatementMeta: + """Шапка, колонки, строки и значения формы из черновика (без commit); возвращает шапку.""" + meta = FinancialStatementMeta( + id=uuid.uuid4(), + document_id=document_id, + recognition_job_id=job_id, + statement_type=draft.statement_type, + **draft.header, + ) + session.add(meta) + await session.flush() + column_ids: dict[int, uuid.UUID] = {} + columns: list[dict[str, Any]] = [] + for column_index, label, period_date, is_current in draft.columns: + column_ids[column_index] = uuid.uuid4() + columns.append( + { + "id": column_ids[column_index], + "meta_id": meta.id, + "column_index": column_index, + "label": label, + "period_date": period_date, + "is_current": is_current, + } + ) + if columns: + await session.execute(insert(FinancialStatementColumn).values(columns)) + line_ids: dict[str, uuid.UUID] = {} + lines: list[dict[str, Any]] = [] + for line in draft.lines: + line_ids[line.line_code] = uuid.uuid4() + lines.append( + { + "id": line_ids[line.line_code], + "meta_id": meta.id, + "line_code": line.line_code, + "line_code_original_value": line.line_code, + "line_code_confidence": line.line_code_confidence, + "line_code_modified": False, + "line_name": line.line_name, + "line_name_original_value": line.line_name, + "line_name_confidence": line.line_name_confidence, + "line_name_modified": False, + } + ) + for start in range(0, len(lines), BATCH): + await session.execute(insert(FinancialStatementLine).values(lines[start : start + BATCH])) + values = [ + { + "id": uuid.uuid4(), + "line_id": line_ids[value.line_code], + "column_id": column_ids[value.column_index], + "amount": value.amount, + "string_value": value.string_value, + "confidence": value.confidence, + "is_recognized": value.is_recognized, + "is_modified": False, + } + for value in draft.values + ] + for start in range(0, len(values), BATCH): + await session.execute(insert(FinancialStatementValue).values(values[start : start + BATCH])) + return meta + + +async def delete_file_statements(session: AsyncSession, file_id: uuid.UUID) -> None: + """Значения, строки, колонки и шапки форм всех документов файла (в порядке FK).""" + documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() + metas = select(FinancialStatementMeta.id).where(FinancialStatementMeta.document_id.in_(documents)).scalar_subquery() + lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id.in_(metas)).scalar_subquery() + await session.execute(delete(FinancialStatementValue).where(FinancialStatementValue.line_id.in_(lines))) + await session.execute(delete(FinancialStatementLine).where(FinancialStatementLine.meta_id.in_(metas))) + await session.execute(delete(FinancialStatementColumn).where(FinancialStatementColumn.meta_id.in_(metas))) + await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents))) + + +async def statement_amounts( + session: AsyncSession, meta_id: uuid.UUID +) -> tuple[list[int], list[tuple[str, dict[int, Decimal | None]]]]: + """Индексы колонок и `(код строки, {колонка: сумма})` по возрастанию кода — вход `run_checks`.""" + columns = ( + await session.execute( + select(FinancialStatementColumn.id, FinancialStatementColumn.column_index) + .where(FinancialStatementColumn.meta_id == meta_id) + .order_by(FinancialStatementColumn.column_index) + ) + ).all() + index_by_id = {column_id: index for column_id, index in columns} + rows = ( + await session.execute( + select(FinancialStatementLine.line_code, FinancialStatementValue.column_id, FinancialStatementValue.amount) + .join(FinancialStatementValue, FinancialStatementValue.line_id == FinancialStatementLine.id) + .where(FinancialStatementLine.meta_id == meta_id) + .order_by(FinancialStatementLine.line_code) + ) + ).all() + by_code: dict[str, dict[int, Decimal | None]] = {} + for code, column_id, amount in rows: + by_code.setdefault(code or "", {})[index_by_id[column_id]] = amount + return [index for _, index in columns], list(by_code.items()) + + +async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[bool, bool | None, Decimal | None]]: + """`(is_modified, is_recognized, confidence)` всех значений формы — вход `values_ok`.""" + rows = await session.execute( + select( + FinancialStatementValue.is_modified, + FinancialStatementValue.is_recognized, + FinancialStatementValue.confidence, + ) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta_id) + ) + return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows] diff --git a/api/src/services/result_mapping.py b/api/src/services/result_mapping.py index 80ca04c..972c554 100644 --- a/api/src/services/result_mapping.py +++ b/api/src/services/result_mapping.py @@ -11,10 +11,10 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn 3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox, вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла, - документы и строки, `group_schema`, предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, - D-10, аудит `FILE_REPLACE`). + документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 3b и расшифровки), `group_schema` (только листы ОСВ), + предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`). -Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ) +Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов) — сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции `metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15). `mapping_error` виден пользователю: в нём категория (схема — число ошибок и путь первой; БД/S3 — тип исключения и число @@ -37,7 +37,7 @@ from pydantic import ValidationError from sqlalchemy import Integer, Select, delete, func, insert, select, update from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from src.contract.result import OsvDocument, RecognitionResult +from src.contract.result import OsvDocument, RecognitionResult, StatementDocument from src.core.config import Settings from src.core.storage import ObjectNotFoundError, ObjectStorage from src.domain.osv_mapping import ( @@ -48,6 +48,8 @@ from src.domain.osv_mapping import ( document_status, opening_matches, ) +from src.domain.statement_checks import load_check_rules, run_checks +from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok from src.models.accounting import ChartOfAccount from src.models.directories import LegalEntity from src.models.enums import ( @@ -64,6 +66,7 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry +from src.repositories import statements as statements_repo from src.repositories.counterparties import CounterpartyIndex from src.services import consolidation_documents from src.services.audit import AuditService @@ -74,6 +77,7 @@ __all__ = [ "MAPPING_ATTEMPTS_KEY", "MAPPING_ERROR_KEY", "MAPPING_WARNINGS_KEY", + "NO_DOCUMENTS", "Candidate", "MappingFatal", "map_file", @@ -89,6 +93,7 @@ MAPPING_ERROR_KEY = "mapping_error" # причина `ERROR` файла по м MAPPING_WARNINGS_KEY = "mapping_warnings" # предупреждения маппинга файла, список строк (D-7, R-6) ENTRY_BATCH_SIZE = 1000 # строк `osv_entry` на один INSERT: 23 колонки × 1000 < 32767 параметров asyncpg MAPPING_ERROR_MAX_LENGTH = 2000 +NO_DOCUMENTS = "в результате нет документов" RESULTS_PREFIX = "results" MAPPABLE_JOB_STATUSES = (RecognitionStatus.done, RecognitionStatus.needs_check) CUMULATIVE_CHECK_QUARTERS = frozenset({PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4}) @@ -206,8 +211,8 @@ async def _load_result(storage: ObjectStorage, candidate: Candidate) -> Recognit raise MappingFatal( f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}" ) - if not _osv_documents(result): - raise MappingFatal("в результате нет документов ОСВ") + if not result.documents: + raise MappingFatal(NO_DOCUMENTS) return result @@ -261,6 +266,7 @@ def _entry_values( async def _clear_file(session: AsyncSession, file_id: uuid.UUID) -> None: """D-3: строки и документы файла от прерванной попытки (файл в `PROCESSING` правок аналитика не содержит).""" documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery() + await statements_repo.delete_file_statements(session, file_id) await session.execute(delete(OsvEntry).where(OsvEntry.document_id.in_(documents))) await session.execute(delete(Document).where(Document.stored_file_id == file_id)) @@ -330,6 +336,98 @@ def _split_duplicates(sheets: Sequence[OsvDocument]) -> tuple[list[OsvDocument], return kept, by_locator, warnings +def _map_statement_docs( # noqa: PLR0913 - все входы именованные + statement_docs: Sequence[StatementDocument], + *, + file: StoredFile, + owner: LegalEntity, + owner_names: frozenset[str], + now: datetime, +) -> tuple[list[tuple[Document, StatementDraft]], list[Document], list[str], set[DocumentType]]: + """Документы форм ББ/ОФР и расшифровок (spec «Документ формы ББ/ОФР», «Повтор формы в файле и прочие документы»; + D-2, D-3). Возвращает формы (документ + черновик; статус уточняется после вставки значений), документы + `DISCLOSURE`/`OTHER` (по одному на тип, `locator` через запятую, `RECOGNIZED`), предупреждения и типы форм, + у которых был повтор в файле (R-1: первый документ такого типа — `NEEDS_CHECK`).""" + forms: list[tuple[Document, StatementDraft]] = [] + seen_forms: set[DocumentType] = set() + duplicated: set[DocumentType] = set() + other_locators: dict[DocumentType, list[str]] = {} + warnings: list[str] = [] + for doc in statement_docs: + doc_type = DocumentType(doc.document_type) + if doc_type in (DocumentType.DISCLOSURE, DocumentType.OTHER): + other_locators.setdefault(doc_type, []).append(doc.locator) + continue + if doc_type in seen_forms: + warnings.append(f"{doc.locator}: вторая форма {doc_type.value} в файле пропущена (OQ-77)") + duplicated.add(doc_type) + continue + seen_forms.add(doc_type) + draft = build_statement(doc) + warnings.extend(draft.warnings) + check = counterparty_check(doc.header.inn, doc.header.organization_name, owner.inn, owner_names) + document = Document( + id=uuid.uuid4(), + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=doc_type, + osv_account_code=None, + period_year=draft.period_year, + period_quarter=draft.period_quarter, + is_cumulative=None, + reporting_date=draft.period_date, + locator=doc.locator, + status=DocumentStatus.NEEDS_CHECK, # уточняется после вставки значений (нужны проверки по суммам) + period_check=DocumentCheckResult.UNKNOWN, + counterparty_check=check, + created_at=now, + updated_at=now, + ) + forms.append((document, draft)) + others = [ + Document( + id=uuid.uuid4(), + stored_file_id=file.id, + legal_entity_id=file.legal_entity_id, + document_type=doc_type, + locator=",".join(locators), + status=DocumentStatus.RECOGNIZED, + period_check=DocumentCheckResult.UNKNOWN, + created_at=now, + updated_at=now, + ) + for doc_type, locators in other_locators.items() + ] + return forms, others, warnings, duplicated + + +async def _insert_statements( + session: AsyncSession, + *, + job: RecognitionJob, + forms: Sequence[tuple[Document, StatementDraft]], + duplicated: set[DocumentType], + job_done: bool, +) -> None: + """Шапка, колонки, строки и значения каждой формы; статус документа по D-6 (акцепта при маппинге нет).""" + if not forms: + return + rules = load_check_rules() + for document, draft in forms: + meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=draft) + column_indexes, amounts = await statements_repo.statement_amounts(session, meta.id) + report = run_checks(document.document_type.value, amounts, column_indexes, rules) + document.status = statement_status( + job_done=job_done, + checks_passed=not report.failed, + counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, + period_known=document.period_year is not None and document.period_quarter is not None, + values_ok=values_ok(await statements_repo.value_flags(session, meta.id)), + has_lines=bool(draft.lines), + warnings=[*draft.warnings, *(["повтор формы"] if document.document_type in duplicated else [])], + ) + + async def map_file( # noqa: PLR0913 - все входы маппинга файла именованные session: AsyncSession, *, @@ -342,13 +440,15 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай ) -> list[str]: """Маппинг результата в транзакции вызывающего (D-3, D-6…D-10); возвращает предупреждения маппинга файла. - Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) → строки - пакетами по `ENTRY_BATCH_SIZE` → `job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из + Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) и на каждую + форму ББ/ОФР (`_map_statement_docs`) → строки ОСВ пакетами по `ENTRY_BATCH_SIZE` → вставка форм и их статус → + `job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из `PROCESSING` и только для текущего задания) → автозамена прежних версий (`supersede_previous`). Без commit. """ + if not result.documents: + raise MappingFatal(NO_DOCUMENTS) sheets = _osv_documents(result) - if not sheets: - raise MappingFatal("в результате нет документов ОСВ") + statement_docs = [doc for doc in result.documents if isinstance(doc, StatementDocument)] owner = await session.get(LegalEntity, file.legal_entity_id) if owner is None: raise MappingFatal(f"ЮЛ-владелец файла не найден: {file.legal_entity_id}") @@ -422,6 +522,13 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай for entry in mapped.entries ) + forms, other_documents, form_warnings, duplicated = _map_statement_docs( + statement_docs, file=file, owner=owner, owner_names=owner_names, now=now + ) + warnings.extend(form_warnings) + documents.extend(other_documents) + documents.extend(document for document, _ in forms) + session.add_all(documents) await session.flush() # T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк @@ -430,6 +537,8 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай for start in range(0, len(entries), ENTRY_BATCH_SIZE): await session.execute(insert(OsvEntry).values(entries[start : start + ENTRY_BATCH_SIZE])) + await _insert_statements(session, job=job, forms=forms, duplicated=duplicated, job_done=job_done) + job.group_schema = [_group_schema(sheet) for sheet in sheets] job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings} await session.flush() diff --git a/api/tests/test_mapping_golden.py b/api/tests/test_mapping_golden.py index 2c62240..1c833ad 100644 --- a/api/tests/test_mapping_golden.py +++ b/api/tests/test_mapping_golden.py @@ -31,7 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.contract.result import RecognitionResult from src.domain.osv_mapping import normalize_account_code from src.models.accounting import ChartOfAccount -from src.models.enums import FileStatus, RecognitionStatus +from src.models.enums import DocumentType, FileStatus, RecognitionStatus from src.models.files import Document, StoredFile from src.models.osv import OsvEntry from src.repositories.counterparties import CounterpartyIndex @@ -148,7 +148,12 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P documents = { document.locator: document for document in ( - await session.execute(select(Document).where(Document.stored_file_id == file_id)) + await session.execute( + # формы ББ/ОФР смешанных снимков (s12) маппятся с Среза 3b — здесь сверяются только листы ОСВ + select(Document).where( + Document.stored_file_id == file_id, Document.document_type == DocumentType.OSV + ) + ) ).scalars() } sheets = [sheet for sheet in raw["documents"] if sheet["document_type"] == "OSV"] diff --git a/api/tests/test_mapping_statements.py b/api/tests/test_mapping_statements.py new file mode 100644 index 0000000..54e5224 --- /dev/null +++ b/api/tests/test_mapping_statements.py @@ -0,0 +1,204 @@ +"""Маппинг форм ББ/ОФР (spec osv-result-mapping «Цикл маппинга…», «Документ формы ББ/ОФР», «Повтор формы в файле и +прочие документы», «Статус документа формы»; design D-1…D-3, D-6) на golden-снимках fixtures.""" + +import json +import uuid +from pathlib import Path + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from src.models.directories import LegalEntity +from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, FileStatus, PeriodQuarter +from src.models.files import Document, StoredFile +from src.models.statements import ( + FinancialStatementColumn, + FinancialStatementLine, + FinancialStatementMeta, + FinancialStatementValue, +) +from tests.factories import make_legal_entity +from tests.fixtures_helpers import GOLDEN_STATEMENTS, OCR_EXPECTED, map_golden_snapshot, ocr_result + +pytestmark = [pytest.mark.integration, pytest.mark.fixtures] + +MEZH = GOLDEN_STATEMENTS / "mezheninovskaya" / "2025-Q3" +NPZ = GOLDEN_STATEMENTS / "npz" / "2025-Q3" +SYNTH = GOLDEN_STATEMENTS / "synthetic" +MEZH_BALANCE_LINES = 37 +NPZ_BALANCE_LINES = 132 +NPZ_INCOME_LINES = 43 +DISCLOSURE_LOCATORS_COMMAS = 4 # пять `DISCLOSURE` снимка st06 → пять locator через запятую +MIN_EXCEL_SNAPSHOTS = 25 +MIN_MAPPED_SNAPSHOTS = 27 + + +async def _meta(session: AsyncSession, document: Document) -> FinancialStatementMeta: + stmt = select(FinancialStatementMeta).where(FinancialStatementMeta.document_id == document.id) + return (await session.execute(stmt)).scalar_one() + + +async def _count(session: AsyncSession, model: type, meta_id: uuid.UUID) -> int: + if model is FinancialStatementValue: + stmt = ( + select(func.count()) + .select_from(FinancialStatementValue) + .join(FinancialStatementLine, FinancialStatementLine.id == FinancialStatementValue.line_id) + .where(FinancialStatementLine.meta_id == meta_id) + ) + else: + stmt = select(func.count()).select_from(model).where(model.meta_id == meta_id) # type: ignore[attr-defined] + return (await session.execute(stmt)).scalar_one() + + +async def _owner(session: AsyncSession, path: Path) -> LegalEntity: + """Владелец файла с ИНН шапки формы (MATCH), чтобы чистые формы могли быть RECOGNIZED.""" + data = json.loads(path.read_text("utf-8")) + inn = next((d["header"].get("inn") for d in data["documents"] if d.get("header", {}).get("inn")), None) + return await make_legal_entity(session, name="ООО Владелец", inn=inn) + + +async def test_file_with_forms_only(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / MEZH / "1_Форма1_бух_09.json" + owner = await _owner(seeded, path) + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert [d.document_type for d in documents] == [DocumentType.BALANCE] + balance = documents[0] + assert (balance.period_year, balance.period_quarter) == (2025, PeriodQuarter.Q3) + assert balance.reporting_date is not None and balance.reporting_date.isoformat() == "2025-09-30" + assert balance.osv_account_code is None and balance.is_cumulative is None + meta = await _meta(seeded, balance) + lines = await _count(seeded, FinancialStatementLine, meta.id) + columns = await _count(seeded, FinancialStatementColumn, meta.id) + assert lines == MEZH_BALANCE_LINES + assert await _count(seeded, FinancialStatementValue, meta.id) == lines * columns + file = await seeded.get(StoredFile, balance.stored_file_id) + assert file is not None and file.status is FileStatus.PROCESSED + + +async def test_clean_income_recognized(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + owner = await _owner(seeded, path) + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert income.document_type is DocumentType.INCOME + assert income.counterparty_check is DocumentCheckResult.MATCH + assert income.status is DocumentStatus.RECOGNIZED + + +async def test_book_with_two_forms(seeded: AsyncSession, fixtures_dir: Path) -> None: + path = next((fixtures_dir / NPZ).glob("*.json")) + owner = await _owner(seeded, path) + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + by_type = {d.document_type: d for d in documents} + assert set(by_type) == {DocumentType.BALANCE, DocumentType.INCOME} + balance_meta = await _meta(seeded, by_type[DocumentType.BALANCE]) + income_meta = await _meta(seeded, by_type[DocumentType.INCOME]) + assert await _count(seeded, FinancialStatementLine, balance_meta.id) == NPZ_BALANCE_LINES + assert await _count(seeded, FinancialStatementLine, income_meta.id) == NPZ_INCOME_LINES + for document in documents: + assert (document.period_year, document.period_quarter) == (2025, PeriodQuarter.Q3) + + +async def test_disclosures_merged(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Синтетика") + path = fixtures_dir / SYNTH / "st06_header_variants.json" + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + types = [d.document_type for d in documents] + assert types.count(DocumentType.DISCLOSURE) == 1 and DocumentType.INCOME in types + disclosure = next(d for d in documents if d.document_type is DocumentType.DISCLOSURE) + assert disclosure.status is DocumentStatus.RECOGNIZED + assert disclosure.locator.count(",") == DISCLOSURE_LOCATORS_COMMAS + + +async def test_form_without_codes(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Без кодов") + path = fixtures_dir / SYNTH / "st03_no_codes.json" + (balance,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert balance.status is DocumentStatus.NEEDS_CHECK + meta = await _meta(seeded, balance) + assert await _count(seeded, FinancialStatementLine, meta.id) == 0 + # в снимке st03_no_codes колонок нет (`columns = []`): только шапка (расхождение со spec «и колонками») + assert await _count(seeded, FinancialStatementColumn, meta.id) == 0 + + +async def test_mixed_book_osv_and_balance(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Смешанная") + path = fixtures_dir / SYNTH / "st04_mixed_book.json" + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert {d.document_type for d in documents} == {DocumentType.OSV, DocumentType.BALANCE} + + +async def test_ocr_form_without_report_date(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Дорстроймонтаж") + documents = await map_golden_snapshot( + seeded, + entity=owner, + path=fixtures_dir / OCR_EXPECTED / "aep_6.json", + uploaded_by=None, + result_builder=ocr_result, + ) + balance = next(d for d in documents if d.document_type is DocumentType.BALANCE) + assert (balance.period_year, balance.period_quarter) == (2025, PeriodQuarter.Q2) + meta = await _meta(seeded, balance) + assert meta.report_date is None and meta.inn is None + current = ( + await seeded.execute( + select(FinancialStatementColumn.period_date).where( + FinancialStatementColumn.meta_id == meta.id, FinancialStatementColumn.is_current.is_(True) + ) + ) + ).scalar_one() + assert current is not None and current.isoformat() == "2025-06-30" + assert balance.status is DocumentStatus.NEEDS_CHECK + + +async def test_ocr_noisy_form_mismatch(seeded: AsyncSession, fixtures_dir: Path) -> None: + owner = await make_legal_entity(seeded, name="ООО Другое", inn="7700000000") + documents = await map_golden_snapshot( + seeded, + entity=owner, + path=fixtures_dir / OCR_EXPECTED / "aep_3.json", + uploaded_by=None, + result_builder=ocr_result, + ) + balance = next(d for d in documents if d.document_type is DocumentType.BALANCE) + assert balance.counterparty_check is DocumentCheckResult.MISMATCH + assert balance.status is DocumentStatus.NEEDS_CHECK + + +async def test_all_statement_snapshots_map(seeded: AsyncSession, fixtures_dir: Path) -> None: + """Все golden форм (Excel и OCR, кроме ответов-ошибок) маппятся без исключений; у каждой формы значения на все + ячейки.""" + owner = await make_legal_entity(seeded, name="ООО Все формы") + excel = sorted((fixtures_dir / GOLDEN_STATEMENTS).rglob("*.json")) + ocr = sorted((fixtures_dir / OCR_EXPECTED).glob("*.json")) + assert len(excel) >= MIN_EXCEL_SNAPSHOTS + mapped = 0 + for path in excel: + await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + mapped += 1 + for path in ocr: + if json.loads(path.read_text("utf-8"))["status"] == "error": + continue + await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None, result_builder=ocr_result) + mapped += 1 + assert mapped >= MIN_MAPPED_SNAPSHOTS + metas = (await seeded.execute(select(FinancialStatementMeta.id))).scalars().all() + for meta_id in metas: + lines = await _count(seeded, FinancialStatementLine, meta_id) + columns = await _count(seeded, FinancialStatementColumn, meta_id) + assert await _count(seeded, FinancialStatementValue, meta_id) == lines * columns + + +async def test_duplicate_form_in_file_skipped(seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path) -> None: + """R-1, spec «Повтор формы в файле»: вторая форма того же типа не создаётся, первая — `NEEDS_CHECK` даже чистая.""" + source = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + owner = await _owner(seeded, source) + data = json.loads(source.read_text("utf-8")) + data["documents"].append(data["documents"][0] | {"locator": "sheet:повтор"}) + path = tmp_path / "duplicate.json" + path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + documents = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert [d.document_type for d in documents] == [DocumentType.INCOME] + assert documents[0].status is DocumentStatus.NEEDS_CHECK # без повтора тот же снимок — RECOGNIZED diff --git a/api/tests/test_result_mapping.py b/api/tests/test_result_mapping.py index dfa06b3..52fc7bc 100644 --- a/api/tests/test_result_mapping.py +++ b/api/tests/test_result_mapping.py @@ -25,6 +25,7 @@ from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.contract import ack_msg_id, result_msg_id +from src.contract.result import RecognitionResult from src.core.config import Settings, get_settings from src.models.access import AppUser from src.models.accounting import ChartOfAccount @@ -47,12 +48,14 @@ from src.models.enums import ( ) from src.models.files import Document, RecognitionJob, StoredFile from src.models.osv import OsvEntry +from src.repositories.counterparties import CounterpartyIndex from src.services import result_mapping as rm from src.services.recognition_exchange import expire_deadlines, handle_message from src.services.result_mapping import ( MAPPING_ATTEMPTS_KEY, MAPPING_ERROR_KEY, MAPPING_WARNINGS_KEY, + NO_DOCUMENTS, Candidate, MappingFatal, process_once, @@ -335,32 +338,31 @@ async def test_missing_result_object_is_error(committed_sessionmaker: async_sess assert "не найден" in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] -@pytest.mark.parametrize( - ("documents", "status", "message"), - [ - ([], "needs_check", "схем"), # контракт требует documents ≥ 1 — результат не проходит схему - ( - [{"document_type": "BALANCE", "locator": "pages:1", "header": {}, "columns": [], "lines": []}], - "needs_check", - "нет документов ОСВ", - ), - ], -) -async def test_result_without_osv_documents_is_error( - committed_sessionmaker: async_sessionmaker[AsyncSession], documents: list[Any], status: str, message: str -): - """Review Focus 2: результат без документов ОСВ → файл `ERROR` «нет документов ОСВ», не исключение цикла.""" +async def test_result_with_empty_documents_is_error(committed_sessionmaker: async_sessionmaker[AsyncSession]): + """Результат без документов не проходит схему контракта (documents ≥ 1) → файл `ERROR`, не исключение цикла.""" maker, storage = committed_sessionmaker, FakeStorage() - result = _minimal() | {"status": status, "documents": documents} + result = _minimal() | {"status": "needs_check", "documents": []} file_id, job_id, _ = await _prepare(maker, storage, result, job_status=RecognitionStatus.needs_check) assert await process_once(maker, storage, _settings()) is True assert (await _file(maker, file_id)).status is FileStatus.ERROR - assert message in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] + assert "схем" in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY] assert await _documents(maker, file_id) == [] +async def test_map_file_without_documents_is_fatal(seeded: AsyncSession): + """`map_file` на результате без документов (схема такого не пропускает — защита) → `NO_DOCUMENTS`.""" + entity = await make_legal_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.PROCESSING, job_status=RecognitionStatus.done + ) + result = RecognitionResult.model_construct(documents=[]) + index = await CounterpartyIndex.load(seeded) + with pytest.raises(MappingFatal, match=NO_DOCUMENTS): + await rm.map_file(seeded, job=job, file=file, result=result, chart={}, index=index, now=datetime.now(UTC)) + + async def test_mapping_failure_retries_then_error( committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch ):