feat(statements): маппинг форм ББ/ОФР в map_file — meta, колонки, строки, значения, статус (D-1…D-3, D-6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 11:40:59 +04:00
parent 29f516db3c
commit 1d821b4d3e
5 changed files with 489 additions and 29 deletions

View File

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

View File

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

View File

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

View File

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

View File

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