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:
parent
29f516db3c
commit
1d821b4d3e
140
api/src/repositories/statements.py
Normal file
140
api/src/repositories/statements.py
Normal 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]
|
||||||
@ -11,10 +11,10 @@ PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recogn
|
|||||||
3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox,
|
3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox,
|
||||||
вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат
|
вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат
|
||||||
старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла,
|
старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла,
|
||||||
документы и строки, `group_schema`, предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`,
|
документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 3b и расшифровки), `group_schema` (только листы ОСВ),
|
||||||
D-10, аудит `FILE_REPLACE`).
|
предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`).
|
||||||
|
|
||||||
Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов ОСВ)
|
Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов)
|
||||||
— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции
|
— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции
|
||||||
`metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15).
|
`metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15).
|
||||||
`mapping_error` виден пользователю: в нём категория (схема — число ошибок и путь первой; БД/S3 — тип исключения и число
|
`mapping_error` виден пользователю: в нём категория (схема — число ошибок и путь первой; БД/S3 — тип исключения и число
|
||||||
@ -37,7 +37,7 @@ from pydantic import ValidationError
|
|||||||
from sqlalchemy import Integer, Select, delete, func, insert, select, update
|
from sqlalchemy import Integer, Select, delete, func, insert, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
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.config import Settings
|
||||||
from src.core.storage import ObjectNotFoundError, ObjectStorage
|
from src.core.storage import ObjectNotFoundError, ObjectStorage
|
||||||
from src.domain.osv_mapping import (
|
from src.domain.osv_mapping import (
|
||||||
@ -48,6 +48,8 @@ from src.domain.osv_mapping import (
|
|||||||
document_status,
|
document_status,
|
||||||
opening_matches,
|
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.accounting import ChartOfAccount
|
||||||
from src.models.directories import LegalEntity
|
from src.models.directories import LegalEntity
|
||||||
from src.models.enums import (
|
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.files import Document, RecognitionJob, S3Object, StoredFile
|
||||||
from src.models.osv import OsvEntry
|
from src.models.osv import OsvEntry
|
||||||
|
from src.repositories import statements as statements_repo
|
||||||
from src.repositories.counterparties import CounterpartyIndex
|
from src.repositories.counterparties import CounterpartyIndex
|
||||||
from src.services import consolidation_documents
|
from src.services import consolidation_documents
|
||||||
from src.services.audit import AuditService
|
from src.services.audit import AuditService
|
||||||
@ -74,6 +77,7 @@ __all__ = [
|
|||||||
"MAPPING_ATTEMPTS_KEY",
|
"MAPPING_ATTEMPTS_KEY",
|
||||||
"MAPPING_ERROR_KEY",
|
"MAPPING_ERROR_KEY",
|
||||||
"MAPPING_WARNINGS_KEY",
|
"MAPPING_WARNINGS_KEY",
|
||||||
|
"NO_DOCUMENTS",
|
||||||
"Candidate",
|
"Candidate",
|
||||||
"MappingFatal",
|
"MappingFatal",
|
||||||
"map_file",
|
"map_file",
|
||||||
@ -89,6 +93,7 @@ MAPPING_ERROR_KEY = "mapping_error" # причина `ERROR` файла по м
|
|||||||
MAPPING_WARNINGS_KEY = "mapping_warnings" # предупреждения маппинга файла, список строк (D-7, R-6)
|
MAPPING_WARNINGS_KEY = "mapping_warnings" # предупреждения маппинга файла, список строк (D-7, R-6)
|
||||||
ENTRY_BATCH_SIZE = 1000 # строк `osv_entry` на один INSERT: 23 колонки × 1000 < 32767 параметров asyncpg
|
ENTRY_BATCH_SIZE = 1000 # строк `osv_entry` на один INSERT: 23 колонки × 1000 < 32767 параметров asyncpg
|
||||||
MAPPING_ERROR_MAX_LENGTH = 2000
|
MAPPING_ERROR_MAX_LENGTH = 2000
|
||||||
|
NO_DOCUMENTS = "в результате нет документов"
|
||||||
RESULTS_PREFIX = "results"
|
RESULTS_PREFIX = "results"
|
||||||
MAPPABLE_JOB_STATUSES = (RecognitionStatus.done, RecognitionStatus.needs_check)
|
MAPPABLE_JOB_STATUSES = (RecognitionStatus.done, RecognitionStatus.needs_check)
|
||||||
CUMULATIVE_CHECK_QUARTERS = frozenset({PeriodQuarter.Q2, PeriodQuarter.Q3, PeriodQuarter.Q4})
|
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(
|
raise MappingFatal(
|
||||||
f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}"
|
f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}"
|
||||||
)
|
)
|
||||||
if not _osv_documents(result):
|
if not result.documents:
|
||||||
raise MappingFatal("в результате нет документов ОСВ")
|
raise MappingFatal(NO_DOCUMENTS)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@ -261,6 +266,7 @@ def _entry_values(
|
|||||||
async def _clear_file(session: AsyncSession, file_id: uuid.UUID) -> None:
|
async def _clear_file(session: AsyncSession, file_id: uuid.UUID) -> None:
|
||||||
"""D-3: строки и документы файла от прерванной попытки (файл в `PROCESSING` правок аналитика не содержит)."""
|
"""D-3: строки и документы файла от прерванной попытки (файл в `PROCESSING` правок аналитика не содержит)."""
|
||||||
documents = select(Document.id).where(Document.stored_file_id == file_id).scalar_subquery()
|
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(OsvEntry).where(OsvEntry.document_id.in_(documents)))
|
||||||
await session.execute(delete(Document).where(Document.stored_file_id == file_id))
|
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
|
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 - все входы маппинга файла именованные
|
async def map_file( # noqa: PLR0913 - все входы маппинга файла именованные
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
*,
|
*,
|
||||||
@ -342,13 +440,15 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
|
|||||||
) -> list[str]:
|
) -> list[str]:
|
||||||
"""Маппинг результата в транзакции вызывающего (D-3, D-6…D-10); возвращает предупреждения маппинга файла.
|
"""Маппинг результата в транзакции вызывающего (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.
|
`PROCESSING` и только для текущего задания) → автозамена прежних версий (`supersede_previous`). Без commit.
|
||||||
"""
|
"""
|
||||||
|
if not result.documents:
|
||||||
|
raise MappingFatal(NO_DOCUMENTS)
|
||||||
sheets = _osv_documents(result)
|
sheets = _osv_documents(result)
|
||||||
if not sheets:
|
statement_docs = [doc for doc in result.documents if isinstance(doc, StatementDocument)]
|
||||||
raise MappingFatal("в результате нет документов ОСВ")
|
|
||||||
owner = await session.get(LegalEntity, file.legal_entity_id)
|
owner = await session.get(LegalEntity, file.legal_entity_id)
|
||||||
if owner is None:
|
if owner is None:
|
||||||
raise MappingFatal(f"ЮЛ-владелец файла не найден: {file.legal_entity_id}")
|
raise MappingFatal(f"ЮЛ-владелец файла не найден: {file.legal_entity_id}")
|
||||||
@ -422,6 +522,13 @@ async def map_file( # noqa: PLR0913 - все входы маппинга фай
|
|||||||
for entry in mapped.entries
|
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)
|
session.add_all(documents)
|
||||||
await session.flush()
|
await session.flush()
|
||||||
# T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк
|
# 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):
|
for start in range(0, len(entries), ENTRY_BATCH_SIZE):
|
||||||
await session.execute(insert(OsvEntry).values(entries[start : start + 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.group_schema = [_group_schema(sheet) for sheet in sheets]
|
||||||
job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings}
|
job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings}
|
||||||
await session.flush()
|
await session.flush()
|
||||||
|
|||||||
@ -31,7 +31,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from src.contract.result import RecognitionResult
|
from src.contract.result import RecognitionResult
|
||||||
from src.domain.osv_mapping import normalize_account_code
|
from src.domain.osv_mapping import normalize_account_code
|
||||||
from src.models.accounting import ChartOfAccount
|
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.files import Document, StoredFile
|
||||||
from src.models.osv import OsvEntry
|
from src.models.osv import OsvEntry
|
||||||
from src.repositories.counterparties import CounterpartyIndex
|
from src.repositories.counterparties import CounterpartyIndex
|
||||||
@ -148,7 +148,12 @@ async def test_all_golden_snapshots_mapped(seeded: AsyncSession, fixtures_dir: P
|
|||||||
documents = {
|
documents = {
|
||||||
document.locator: document
|
document.locator: document
|
||||||
for document in (
|
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()
|
).scalars()
|
||||||
}
|
}
|
||||||
sheets = [sheet for sheet in raw["documents"] if sheet["document_type"] == "OSV"]
|
sheets = [sheet for sheet in raw["documents"] if sheet["document_type"] == "OSV"]
|
||||||
|
|||||||
204
api/tests/test_mapping_statements.py
Normal file
204
api/tests/test_mapping_statements.py
Normal 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
|
||||||
@ -25,6 +25,7 @@ from sqlalchemy.exc import IntegrityError
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from src.contract import ack_msg_id, result_msg_id
|
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.core.config import Settings, get_settings
|
||||||
from src.models.access import AppUser
|
from src.models.access import AppUser
|
||||||
from src.models.accounting import ChartOfAccount
|
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.files import Document, RecognitionJob, StoredFile
|
||||||
from src.models.osv import OsvEntry
|
from src.models.osv import OsvEntry
|
||||||
|
from src.repositories.counterparties import CounterpartyIndex
|
||||||
from src.services import result_mapping as rm
|
from src.services import result_mapping as rm
|
||||||
from src.services.recognition_exchange import expire_deadlines, handle_message
|
from src.services.recognition_exchange import expire_deadlines, handle_message
|
||||||
from src.services.result_mapping import (
|
from src.services.result_mapping import (
|
||||||
MAPPING_ATTEMPTS_KEY,
|
MAPPING_ATTEMPTS_KEY,
|
||||||
MAPPING_ERROR_KEY,
|
MAPPING_ERROR_KEY,
|
||||||
MAPPING_WARNINGS_KEY,
|
MAPPING_WARNINGS_KEY,
|
||||||
|
NO_DOCUMENTS,
|
||||||
Candidate,
|
Candidate,
|
||||||
MappingFatal,
|
MappingFatal,
|
||||||
process_once,
|
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]
|
assert "не найден" in (await _job(maker, job_id)).metrics[MAPPING_ERROR_KEY]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
async def test_result_with_empty_documents_is_error(committed_sessionmaker: async_sessionmaker[AsyncSession]):
|
||||||
("documents", "status", "message"),
|
"""Результат без документов не проходит схему контракта (documents ≥ 1) → файл `ERROR`, не исключение цикла."""
|
||||||
[
|
|
||||||
([], "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` «нет документов ОСВ», не исключение цикла."""
|
|
||||||
maker, storage = committed_sessionmaker, FakeStorage()
|
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)
|
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 process_once(maker, storage, _settings()) is True
|
||||||
|
|
||||||
assert (await _file(maker, file_id)).status is FileStatus.ERROR
|
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) == []
|
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(
|
async def test_mapping_failure_retries_then_error(
|
||||||
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
|
committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch
|
||||||
):
|
):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user