arm-core/api/src/services/result_mapping.py
Andrey Epifancev 7f184d895f feat(#190): факт технического отказа (TIMEOUT/NO_ACK/SERVICE_ERROR/MAPPING, §14.5)
Задача 10: quality_failure_fact на 6 точках отказа — вахтёр сроков
(expire_deadlines, TIMEOUT, батчем на все просроченные задания), dlq
(_process_one, NO_ACK), невалидный запрос и результат сервиса со
status=error (_process_one/_apply_result, SERVICE_ERROR), неповторяемая
и исчерпанная ошибки маппинга (_fail/_register_failure, MAPPING).
Измерения ЮЛ/ГК/is_scan — отдельным запросом по file_id (батчем у
вахтёра), отсутствие строки ЮЛ не прерывает обработку, только лог.

Восстановление после обрыва сессии: имплементер задачи 10 (agent
a7ad86adedbac1d8a) закончил работу, но коммит и отчёт не состоялись —
диф остался в рабочем дереве незакоммиченным. Коммит и прогон
проверок — контроллером (ruff/mypy/pytest -m "not integration" чисты;
один magic-value в новом тесте поправлен тут же), не ревью находка.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-11-06 13:00:12 +04:00

1439 lines
79 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Цикл маппинга результата распознавания в `document` + `osv_entry` (spec osv-result-mapping; design D-1, D-2, D-3,
D-7, D-9; рулинги R-3, R-4, R-5; docs/context/05-domain-rules.md §1.3, §1.5, §3 п. 4, §9.2, §9.3).
Inbox файл результата не читает (OQ-67, OQ-69): признак «результат ещё не смапплен» — `stored_file.status =
PROCESSING` у файла, чьё ТЕКУЩЕЕ задание (`current_recognition_job_id`) уже `done`/`needs_check`. Один проход
`process_once` обрабатывает одного кандидата в три шага:
1. короткая транзакция: `pick_candidate` (`FOR UPDATE OF recognition_job SKIP LOCKED`) и проверка ключа результата
(бакет хранилища, папка `results/{file_id}/{job_id}/`, OQ-70);
2. БЕЗ транзакции: чтение объекта из S3 и валидация результата по MAJOR (`1.x` или `2.0`) — блокировки не
держатся на время сети;
3. транзакция маппинга: задание и файл перечитываются под блокировкой (порядок «задание → файл», как у inbox,
вахтёра и публикатора, — без взаимоблокировок); файл уже не `PROCESSING` или задание уже не текущее (результат
старого задания, Review Focus 1) — ничего не меняется. Иначе `map_file`: очистка документов и строк файла,
документы и строки (листы ОСВ, затем формы ББ/ОФР Среза 3b и расшифровки), `group_schema` (только листы ОСВ),
предупреждения, `PROCESSED`, автозамена прежних версий (`supersede_previous`, D-10, аудит `FILE_REPLACE`) →
автоподтверждение чистых документов (`services/auto_confirm.py`, OQ-157) → переход версий по `NEEDS_CHECK` (D-6).
Ошибки (D-2): неповторяемые (`MappingFatal`: ключ вне папки, объекта нет, JSON не проходит схему, нет документов)
— сразу файл `ERROR` и `metrics.mapping_error`; прочие — откат транзакции маппинга, в отдельной транзакции
`metrics.mapping_attempts + 1`, на `MAPPING_MAX_ATTEMPTS` — `ERROR`; следующая попытка — после паузы цикла (R-15).
`mapping_error` виден пользователю: в нём категория (схема — число ошибок и путь первой; БД/S3 — тип исключения и число
попыток), без текста исключений pydantic/SQLAlchemy; полный текст — только в логе (M-1).
Статус файла — через `services/file_status.py` (только текущее задание). `audit_event` не пишется: обмен и маппинг
не аудируются (ASSUMPTION(OQ-55)); `FILE_REPLACE` автозамены — системное событие (`user_id = NULL`).
"""
import asyncio
import dataclasses
import uuid
from collections.abc import Awaitable, Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from typing import Any
from loguru import logger
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, StatementDocument
from src.contract.result_v2 import DocumentWarning, OsvDocumentV2, RecognitionResultV2, StatementDocumentV2
from src.contract.versions import parse_result
from src.core.config import Settings
from src.core.storage import ObjectNotFoundError, ObjectStorage
from src.domain.osv_mapping import (
EntryDraft,
build_entries,
checks_clean,
counterparty_check,
document_fields,
document_status,
opening_matches,
)
from src.domain.quality_facts import build_intake_payload, build_outcome_payload
from src.domain.statement_issues import IssueDraft, mapping_warning
from src.domain.statement_mapping import StatementDraft, build_statement, statement_status, values_ok
from src.domain.statement_status import duplicate_period_forms, has_error_warning, osv_status_v2
from src.models.accounting import ChartOfAccount
from src.models.consolidation import Consolidation, ConsolidationVersion
from src.models.directories import CompanyGroup, LegalEntity
from src.models.enums import (
AuditAction,
AuditSection,
DocumentCheckResult,
DocumentIssueKind,
DocumentStatus,
DocumentType,
FileStatus,
IssueSeverity,
IssueSource,
OsvDataType,
OsvGroupType,
PeriodQuarter,
QualityFailureKind,
QualityOutcome,
RecognitionStatus,
StatementLineStatus,
)
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry
from src.models.statements import DocumentIssue
from src.repositories import statements as statements_repo
from src.repositories.counterparties import CounterpartyIndex
from src.repositories.quality_facts import insert_failure_fact, insert_intake_fact, insert_outcome_fact
from src.services import consolidation_documents
from src.services.audit import AuditService
from src.services.file_status import set_file_status_for_job
from src.services.statement_intake_v2 import (
FILE_WARNINGS_KEY,
FormV2,
OtherV2,
file_warnings,
insert_forms_v2,
map_statement_docs_v2,
)
from src.services.statement_recompute import checks_on_intake_1x
from src.services.statements_engine import StatementsEngine, default_engine
__all__ = [
"ENTRY_BATCH_SIZE",
"MAPPING_ATTEMPTS_KEY",
"MAPPING_ERROR_KEY",
"MAPPING_WARNINGS_KEY",
"NO_DOCUMENTS",
"Candidate",
"MappingFatal",
"map_file",
"pick_candidate",
"process_once",
"run_mapping_loop",
"supersede_previous",
"validate_result_key",
]
MAPPING_ATTEMPTS_KEY = "mapping_attempts" # счётчик неудачных попыток в `recognition_job.metrics` (D-2)
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})
RESOLVED_DATA_TYPES = frozenset({OsvDataType.BU, OsvDataType.SUM}) # резолв контрагента — §3 п. 4, R-3
ROOT_LEVEL = 0
# §14.1 (design quality-metrics §4.1): факт приёма пишется только для этих типов; DISCLOSURE/OTHER — без факта
INTAKE_FACT_DOCUMENT_TYPES = frozenset({DocumentType.BALANCE, DocumentType.INCOME, DocumentType.OSV})
STORED_FILE_OBJECT_TYPE = "stored_file" # `audit_event.object_type` события FILE_REPLACE
SUPERSEDE_LOCK_PREFIX = "supersede" # пространство ключей advisory-блокировки автозамены в пределах ЮЛ
_FORBIDDEN_KEY_SEGMENTS = frozenset({"", ".", ".."})
# §14.5 (design quality-metrics §4.3): единый короткий error_code факта отказа MAPPING — что именно не так (схема,
# S3, объект не найден, БД) остаётся в `metrics.mapping_error`/логе (M-1), у `quality_failure_fact` нет текстовой
# колонки для детали; свой код, не пересекается с ERROR_TIMEOUT/ERROR_NO_ACK/ERROR_INVALID_REQUEST обмена
MAPPING_FAILURE_ERROR_CODE = "MAPPING_FAILED"
Clock = Callable[[], datetime]
SleepFn = Callable[[float], Awaitable[None]]
class MappingFatal(Exception):
"""Ошибка без повтора (ключ, объект, схема, нет документов): файл сразу `ERROR` (D-2)."""
@dataclass(frozen=True)
class Candidate:
"""Задание с результатом, ожидающим маппинга, и ключ файла результата."""
job_id: uuid.UUID
file_id: uuid.UUID
bucket: str
key: str
def _utcnow() -> datetime:
return datetime.now(UTC)
def _short(text: str) -> str:
return text if len(text) <= MAPPING_ERROR_MAX_LENGTH else text[: MAPPING_ERROR_MAX_LENGTH - 1] + "…"
def _metrics(job: RecognitionJob) -> dict[str, Any]:
"""Копия `metrics` задания (не объект — `{}`): JSONB меняется только присваиванием нового dict."""
return dict(job.metrics) if isinstance(job.metrics, dict) else {}
def _attempts(metrics: Mapping[str, Any]) -> int:
value = metrics.get(MAPPING_ATTEMPTS_KEY)
return value if isinstance(value, int) and not isinstance(value, bool) else 0
# --- шаг 1: кандидат и ключ -----------------------------------------------------------------------------------------
async def pick_candidate(session: AsyncSession, *, max_attempts: int) -> Candidate | None:
"""Следующее задание для маппинга (D-1): `done`/`needs_check`, текущее для файла в `PROCESSING`, попыток меньше
`max_attempts`, со ссылкой на файл результата; старейшее по `finished_at`. Строка задания блокируется, занятые
другим проходом пропускаются (несколько инстансов ядра)."""
job = RecognitionJob
attempts = func.coalesce(job.metrics[MAPPING_ATTEMPTS_KEY].astext.cast(Integer), 0)
stmt = (
select(job.id, job.file_id, S3Object.bucket, S3Object.key)
.join(StoredFile, (StoredFile.id == job.file_id) & (StoredFile.current_recognition_job_id == job.id))
.join(S3Object, S3Object.id == job.result_s3_object_id)
.where(
job.status.in_(MAPPABLE_JOB_STATUSES),
StoredFile.status == FileStatus.PROCESSING,
attempts < max_attempts,
)
.order_by(job.finished_at.asc().nulls_last(), job.id)
.limit(1)
.with_for_update(of=job, skip_locked=True)
)
row = (await session.execute(stmt)).one_or_none()
if row is None:
return None
return Candidate(job_id=row.id, file_id=row.file_id, bucket=row.bucket, key=row.key)
def validate_result_key(candidate: Candidate, storage_bucket: str) -> None:
"""OQ-70: результат читается только из бакета хранилища и только из папки своего задания
`results/{file_id}/{job_id}/<имя>`; сегменты `.`/`..`/пустые запрещены. Иначе — `MappingFatal`."""
if candidate.bucket != storage_bucket:
raise MappingFatal(
f"файл результата в чужом бакете: {candidate.bucket}/{candidate.key} (ожидался бакет {storage_bucket})"
)
prefix = f"{RESULTS_PREFIX}/{candidate.file_id}/{candidate.job_id}/"
rest = candidate.key[len(prefix) :] if candidate.key.startswith(prefix) else None
if rest is None or any(segment in _FORBIDDEN_KEY_SEGMENTS for segment in rest.split("/")):
raise MappingFatal(f"ключ файла результата вне папки задания: {candidate.key} (ожидалось {prefix}<имя>)")
# --- шаг 2: чтение результата ---------------------------------------------------------------------------------------
def _schema_error_summary(exc: ValidationError) -> str:
"""M-1: для `metrics.mapping_error` (видно пользователю) — число ошибок, путь и тип первой; без `input_value` и
ссылок pydantic (полный текст — в логе через `__cause__` у `MappingFatal`)."""
errors = exc.errors(include_url=False, include_input=False, include_context=False)
first = errors[0] if errors else None
where = ".".join(map(str, first["loc"])) if first and first["loc"] else "<корень>"
kind = first["type"] if first else "?"
return f"результат не соответствует схеме recognition-result: ошибок {exc.error_count()}, первая: {where} {kind}"
def _osv_documents(result: RecognitionResult | RecognitionResultV2) -> list[OsvDocument]:
return [document for document in result.documents if isinstance(document, OsvDocument)]
async def _load_result(storage: ObjectStorage, candidate: Candidate) -> RecognitionResult | RecognitionResultV2:
"""Объект результата из S3 и его валидация по контракту; неповторяемые ошибки — `MappingFatal`.
Модель выбирается по MAJOR `schema_version` (`contract.versions.parse_result`): 1 — `RecognitionResult` (маппер
`1.x`), 2 — `RecognitionResultV2` (маппер `2.0`); иной MAJOR — итог проверки схемы (P3-46). Нечитаемый JSON и
не-JSON байты — тоже неповторяемая ошибка схемы (T5.2), без текста исключения в `mapping_error`."""
try:
content = await storage.get_bytes(candidate.key)
except ObjectNotFoundError as exc:
raise MappingFatal(f"файл результата не найден в S3: {candidate.bucket}/{candidate.key}") from exc
try:
result = parse_result(content)
except ValidationError as exc:
raise MappingFatal(_schema_error_summary(exc)) from exc
except ValueError as exc: # JSONDecodeError, UnicodeDecodeError: pydantic ValidationError перехвачен выше
raise MappingFatal("результат не соответствует схеме recognition-result: файл не является JSON") from exc
if result.job_id != candidate.job_id or result.file_id != candidate.file_id:
raise MappingFatal(
f"файл результата относится к другому заданию: job_id={result.job_id}, file_id={result.file_id}"
)
if not result.documents:
raise MappingFatal(NO_DOCUMENTS)
return result
# --- шаг 3: маппинг файла -------------------------------------------------------------------------------------------
def _with_counterparty(entry: EntryDraft, index: CounterpartyIndex) -> EntryDraft:
"""R-3: `counterparty_id` — только строкам `COUNTERPARTY` с `data_type ∈ {BU, SUM}` (§3 п. 4, без policy)."""
if entry.group_type is not OsvGroupType.COUNTERPARTY or entry.data_type not in RESOLVED_DATA_TYPES:
return entry
resolution = index.resolve(entry.counterparty_inn, entry.counterparty_name)
if resolution is None:
return entry
return dataclasses.replace(entry, counterparty_id=resolution.entity_id, counterparty_match_kind=resolution.kind)
def _entry_values(
entry: EntryDraft, *, document_id: uuid.UUID, job_id: uuid.UUID, owner_id: uuid.UUID
) -> dict[str, Any]:
"""Строка `osv_entry` (DBML v5.16) из черновика; суммы — `Decimal` из контракта без изменений."""
opening_debit, opening_credit, turnover_debit, turnover_credit, closing_debit, closing_credit = entry.sums
return {
"id": entry.id,
"recognition_job_id": job_id,
"document_id": document_id,
"owner_legal_entity_id": owner_id,
"parent_entry_id": entry.parent_id,
"group_type": entry.group_type,
"group_name": entry.group_name,
"row_number": entry.row_number,
"level": entry.level,
"row_value": entry.row_value,
"data_type": entry.data_type,
"account_code": entry.account_code,
"account_id": entry.account_id,
"account_root": entry.account_root,
"counterparty_name": entry.counterparty_name,
"counterparty_inn": entry.counterparty_inn,
"counterparty_id": entry.counterparty_id,
"counterparty_match_kind": entry.counterparty_match_kind,
"opening_debit": opening_debit,
"opening_credit": opening_credit,
"turnover_debit": turnover_debit,
"turnover_credit": turnover_credit,
"closing_debit": closing_debit,
"closing_credit": closing_credit,
"is_modified": False,
}
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))
async def _q1_root_opening(
session: AsyncSession, *, legal_entity_id: uuid.UUID, account_code: str, year: int
) -> tuple[Decimal | None, Decimal | None] | None:
"""D-9: сальдо на начало корня документа ОСВ Q1 того же ЮЛ, счёта и года из файла не `SUPERSEDED` (при нескольких —
последний созданный); корень — строка уровня 0 без родителя с минимальным `row_number`. Нет Q1/корня — `None`."""
document_id = (
await session.execute(
select(Document.id)
.join(StoredFile, StoredFile.id == Document.stored_file_id)
.where(
Document.legal_entity_id == legal_entity_id,
Document.document_type == DocumentType.OSV,
Document.osv_account_code == account_code,
Document.period_year == year,
Document.period_quarter == PeriodQuarter.Q1,
StoredFile.status != FileStatus.SUPERSEDED,
)
.order_by(Document.created_at.desc().nulls_last(), Document.id)
.limit(1)
)
).scalar_one_or_none()
if document_id is None:
return None
root = (
await session.execute(
select(OsvEntry.opening_debit, OsvEntry.opening_credit)
.where(
OsvEntry.document_id == document_id,
OsvEntry.level == ROOT_LEVEL,
OsvEntry.parent_entry_id.is_(None),
)
.order_by(OsvEntry.row_number)
.limit(1)
)
).one_or_none()
return (root.opening_debit, root.opening_credit) if root is not None else None
def _osv_warning_issue(warning: DocumentWarning) -> IssueDraft:
"""Предупреждение листа ОСВ `2.0` (`OSV_*`, `info`) → `document_issue` вида `warning`, источник `service`.
У ОСВ нет строк и колонок формы: `line_refs` не сохраняются, `column_index` и `field` — как пришли."""
return IssueDraft(
kind=DocumentIssueKind.warning,
code=warning.code,
severity=IssueSeverity(warning.severity),
message=warning.message,
source=IssueSource.service,
column_index=warning.column_index,
field=warning.field,
)
def _osv_status(
sheet: OsvDocument,
*,
job_done: bool,
file_error: bool,
counterparty: DocumentCheckResult,
mapping_warnings: Sequence[str],
) -> tuple[DocumentStatus, list[IssueDraft]]:
"""Статус документа ОСВ и записи `document_issue` сервиса: `2.0` — по Р-28 (`osv_status_v2`, статус листа и
предупреждения листа), `1.x` — по §9.3 (`document_status`, статус задания); у `1.x` записей сервиса нет."""
if isinstance(sheet, OsvDocumentV2):
status = osv_status_v2(
osv_done=sheet.status == "done",
checks_ok=checks_clean(sheet.checks),
file_error_warning=file_error,
counterparty=counterparty,
sheet_mapping_warnings=bool(mapping_warnings),
)
return status, [_osv_warning_issue(warning) for warning in sheet.warnings]
return document_status(
job_done=job_done, checks=sheet.checks, counterparty=counterparty, warnings=mapping_warnings
), []
def _group_schema(sheet: OsvDocument) -> dict[str, Any]:
return {
"locator": sheet.locator,
"groups": [{"type": group.type, "name": group.name, "has_inn": group.has_inn} for group in sheet.group_schema],
}
def _split_duplicates(sheets: Sequence[OsvDocument]) -> tuple[list[OsvDocument], dict[str, list[str]], list[str]]:
"""D-7: первый лист каждого счёта — документ; повтор счёта в файле — только предупреждение, которое относится и к
первому листу (он получает `NEEDS_CHECK`). Возвращает листы-документы, предупреждения по `locator` первого листа и
все предупреждения о повторах."""
first_locator: dict[str, str] = {}
kept: list[OsvDocument] = []
by_locator: dict[str, list[str]] = {}
warnings: list[str] = []
for sheet in sheets:
code = document_fields(sheet).osv_account_code
if code in first_locator:
warning = f"sheet={sheet.locator}: счёт {code} уже есть в листе {first_locator[code]}"
warnings.append(warning)
by_locator.setdefault(first_locator[code], []).append(warning)
continue
first_locator[code] = sheet.locator
kept.append(sheet)
return kept, by_locator, warnings
def _map_statement_docs( # noqa: PLR0913 - все входы именованные
statement_docs: Sequence[StatementDocument],
*,
ordinals: Mapping[int, int],
file: StoredFile,
owner: LegalEntity,
owner_names: frozenset[str],
now: datetime,
) -> tuple[list[tuple[Document, StatementDraft]], list[Document], list[str], dict[uuid.UUID, str]]:
"""Документы форм ББ/ОФР и расшифровок (spec «Документ формы ББ/ОФР», «Повтор формы в файле и прочие документы»;
D-2, D-3; P3-4, P3-6). Возвращает формы (документ + черновик; статус уточняется после вставки значений),
документы `DISCLOSURE`/`OTHER` (по одному на тип, `locator` через запятую, `RECOGNIZED`, `file_ordinal` — индекс
первого такого документа в `documents[]`), предупреждения маппинга и текст «другая форма» по `id` документов форм
одного типа и периода (Р-10, Р-18: хранятся все, каждый `NEEDS_CHECK`). `ordinals` — `id(документ результата)` →
индекс в `result.documents`."""
forms: list[tuple[Document, StatementDraft]] = []
other_locators: dict[DocumentType, list[str]] = {}
other_ordinals: dict[DocumentType, int] = {}
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)
other_ordinals.setdefault(doc_type, ordinals[id(doc)])
continue
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,
file_ordinal=ordinals[id(doc)],
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))
duplicated = duplicate_period_forms(
(document.id, document.document_type, document.period_year, document.period_quarter) for document, _ in forms
)
repeats: dict[uuid.UUID, str] = {}
for document, _ in forms:
if document.id in duplicated:
text = f"в файле есть другая форма {document.document_type.value} за тот же период"
repeats[document.id] = text
warnings.append(f"{document.locator}: {text}")
others = [
Document(
id=uuid.uuid4(),
stored_file_id=file.id,
legal_entity_id=file.legal_entity_id,
document_type=doc_type,
file_ordinal=other_ordinals[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, repeats
async def _insert_statements( # noqa: PLR0913 - все входы именованные
session: AsyncSession,
*,
job: RecognitionJob,
forms: Sequence[tuple[Document, StatementDraft]],
repeats: Mapping[uuid.UUID, str],
job_done: bool,
now: datetime,
engine: StatementsEngine | None = None,
) -> None:
"""Шапка, колонки, строки и значения каждой формы; предупреждения черновика и повтор формы — `MAPPING_WARNING`
документа; статус документа по D-6 (акцепта при маппинге нет)."""
if not forms:
return
engine = engine or default_engine()
for document, draft in forms:
meta = await statements_repo.insert_statement(session, document_id=document.id, job_id=job.id, draft=draft)
warnings = [*draft.warnings, *([repeats[document.id]] if document.id in repeats else [])]
await statements_repo.add_issues(session, document.id, [mapping_warning(text) for text in warnings], now=now)
checks_passed = await checks_on_intake_1x(session, document=document, meta=meta, engine=engine, now=now)
document.status = statement_status(
job_done=job_done,
checks_passed=checks_passed,
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=warnings,
)
@dataclass
class _FormsPlan:
"""Документы форм/раскрытий результата до вставки: общие поля и черновики по MAJOR."""
documents: list[Document]
warnings: list[str]
repeats: dict[uuid.UUID, str]
forms_v1: list[tuple[Document, StatementDraft]]
forms_v2: list[FormV2]
others_v2: list[OtherV2]
def _plan_forms( # noqa: PLR0913 - все входы именованные
result: RecognitionResult | RecognitionResultV2,
*,
job: RecognitionJob,
ordinals: Mapping[int, int],
file: StoredFile,
owner: LegalEntity,
owner_names: frozenset[str],
now: datetime,
) -> _FormsPlan:
"""Выбор маппера форм по типу результата: `2.0` (и `job.statements_version` сервиса) или `1.x`."""
if isinstance(result, RecognitionResultV2):
job.statements_version = result.service.statements_version
forms_v2, others_v2, warnings, repeats = map_statement_docs_v2(
[doc for doc in result.documents if isinstance(doc, StatementDocumentV2)],
ordinals=ordinals,
file=file,
owner=owner,
owner_names=owner_names,
now=now,
)
documents = [*(other.document for other in others_v2), *(form.document for form in forms_v2)]
return _FormsPlan(documents, warnings, repeats, [], forms_v2, others_v2)
forms_v1, other_documents, warnings, repeats = _map_statement_docs(
[doc for doc in result.documents if isinstance(doc, StatementDocument)],
ordinals=ordinals,
file=file,
owner=owner,
owner_names=owner_names,
now=now,
)
documents = [*other_documents, *(document for document, _ in forms_v1)]
return _FormsPlan(documents, warnings, repeats, forms_v1, [], [])
async def _insert_forms( # noqa: PLR0913 - все входы именованные
session: AsyncSession,
plan: _FormsPlan,
*,
job: RecognitionJob,
result: RecognitionResult | RecognitionResultV2,
job_done: bool,
now: datetime,
engine: StatementsEngine | None,
) -> None:
"""Вставка форм: `2.0` — как пришли (`insert_forms_v2`), `1.x` — `_insert_statements`."""
if isinstance(result, RecognitionResultV2):
await insert_forms_v2(
session,
job=job,
result=result,
forms=plan.forms_v2,
others=plan.others_v2,
repeats=plan.repeats,
engine=engine or default_engine(),
now=now,
)
return
await _insert_statements(
session, job=job, forms=plan.forms_v1, repeats=plan.repeats, job_done=job_done, now=now, engine=engine
)
async def map_file( # noqa: PLR0913 - все входы маппинга файла именованные
session: AsyncSession,
*,
job: RecognitionJob,
file: StoredFile,
result: RecognitionResult | RecognitionResultV2,
chart: Mapping[str, uuid.UUID],
index: CounterpartyIndex,
now: datetime,
engine: StatementsEngine | None = None,
) -> list[str]:
"""Маппинг результата в транзакции вызывающего (D-3, D-6…D-10); возвращает предупреждения маппинга файла.
Маппер выбирается по типу результата: `RecognitionResultV2` — формы по контракту `2.0`
(`services/statement_intake_v2`, `job.statements_version` = `service.statements_version`), `RecognitionResult` —
`1.x` (`_map_statement_docs`).
Очистка прежних документов/строк файла → документ на каждый лист ОСВ (повтор счёта — предупреждение) и на каждую
форму ББ/ОФР (`_map_statement_docs`) → строки ОСВ пакетами по `ENTRY_BATCH_SIZE` → вставка форм и их статус →
`job.group_schema`, `metrics.mapping_warnings` → файл `PROCESSED` (только из
`PROCESSING` и только для текущего задания) → автозамена прежних версий (`supersede_previous`; её предупреждение
о выпавших документах дописывается в `metrics.mapping_warnings`, OQ-154). Без commit.
"""
if not result.documents:
raise MappingFatal(NO_DOCUMENTS)
sheets = _osv_documents(result)
owner = await session.get(LegalEntity, file.legal_entity_id)
if owner is None:
raise MappingFatal(f"ЮЛ-владелец файла не найден: {file.legal_entity_id}")
owner_names = index.owner_names(owner)
job_done = job.status is RecognitionStatus.done
ordinals = {id(doc): index for index, doc in enumerate(result.documents)} # v5.19: номер документа в файле
await _clear_file(session, file.id)
kept, duplicate_warnings, warnings = _split_duplicates(sheets)
documents: list[Document] = []
sheet_issues: list[tuple[Document, list[IssueDraft]]] = []
file_error = isinstance(result, RecognitionResultV2) and has_error_warning(file_warnings(result))
entries: list[dict[str, Any]] = []
for sheet in kept:
fields = document_fields(sheet)
mapped = build_entries(sheet, chart)
sheet_warnings: list[str] = [] # предупреждения листа сверх повторов счёта (те уже в `warnings`)
if mapped.orphans:
sheet_warnings.append(
f"sheet={sheet.locator}: строки без родителя в результате: {', '.join(map(str, mapped.orphans))}"
)
is_cumulative = fields.is_cumulative
if (
fields.period_quarter in CUMULATIVE_CHECK_QUARTERS
and is_cumulative is False
and fields.period_year is not None
and mapped.root is not None
):
q1_opening = await _q1_root_opening(
session,
legal_entity_id=file.legal_entity_id,
account_code=fields.osv_account_code,
year=fields.period_year,
)
# ASSUMPTION(OQ-02): квартальная ОСВ с сальдо на начало, равным Q1, — нарастающая
if q1_opening is not None and opening_matches(mapped.root, q1_opening):
is_cumulative = True
sheet_warnings.append(
f"sheet={sheet.locator}: период {sheet.header.period_text}: нарастающая по данным Q1"
)
check = counterparty_check(
sheet.header.organization_inn, sheet.header.organization_name, owner.inn, owner_names
)
mapping_warnings = [*duplicate_warnings.get(sheet.locator, ()), *sheet_warnings]
status, service_drafts = _osv_status(
sheet,
job_done=job_done,
file_error=file_error,
counterparty=check,
mapping_warnings=mapping_warnings,
)
document = Document(
id=uuid.uuid4(),
stored_file_id=file.id,
legal_entity_id=file.legal_entity_id,
document_type=DocumentType.OSV,
file_ordinal=ordinals[id(sheet)],
osv_account_code=fields.osv_account_code,
period_year=fields.period_year,
period_quarter=fields.period_quarter,
is_cumulative=is_cumulative,
locator=fields.locator,
status=status,
period_check=DocumentCheckResult.UNKNOWN, # до версии консолидации (Срез 4)
counterparty_check=check,
created_at=now,
updated_at=now,
)
documents.append(document)
sheet_issues.append((document, [*service_drafts, *(mapping_warning(text) for text in mapping_warnings)]))
warnings.extend(sheet_warnings)
entries.extend(
_entry_values(
_with_counterparty(entry, index),
document_id=document.id,
job_id=job.id,
owner_id=file.legal_entity_id,
)
for entry in mapped.entries
)
plan = _plan_forms(result, job=job, ordinals=ordinals, file=file, owner=owner, owner_names=owner_names, now=now)
documents.extend(plan.documents)
warnings.extend(plan.warnings)
session.add_all(documents)
await session.flush()
for document, drafts in sheet_issues: # P3-5: предупреждения листа ОСВ (сервис 2.0 и маппинг) — в `document_issue`
await statements_repo.add_issues(session, document.id, drafts, now=now)
# T7.2: родитель раньше потомка на любой границе пачки (self-FK `parent_entry_id`) — не полагаемся на порядок строк
# результата; сортировка устойчивая, внутри уровня порядок строк сохраняется
entries.sort(key=lambda values: values["level"])
for start in range(0, len(entries), ENTRY_BATCH_SIZE):
await session.execute(insert(OsvEntry).values(entries[start : start + ENTRY_BATCH_SIZE]))
await _insert_forms(session, plan, job=job, result=result, job_done=job_done, now=now, engine=engine)
job.group_schema = [_group_schema(sheet) for sheet in sheets]
job.metrics = _mapped_metrics(job, result, warnings)
await session.flush()
if not await set_file_status_for_job(
session, job, FileStatus.PROCESSED, now=now, only_from={FileStatus.PROCESSING}
):
raise RuntimeError(f"файл {file.id} не переведён в PROCESSED: не PROCESSING или задание {job.id} не текущее")
await session.refresh(file, ["status", "updated_at"])
return await _supersede_and_warn(session, job=job, file=file, documents=documents, warnings=warnings, now=now)
def _mapped_metrics(
job: RecognitionJob, result: RecognitionResult | RecognitionResultV2, warnings: list[str]
) -> dict[str, Any]:
"""`metrics` задания после маппинга: предупреждения маппинга и, для результата `2.0`, предупреждения файла
результата отдельным ключом — единый источник «ошибки файла» для пересчёта после правки (P3-53, R-11)."""
metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings}
if isinstance(result, RecognitionResultV2):
metrics[FILE_WARNINGS_KEY] = file_warnings(result)
return metrics
async def _supersede_and_warn( # noqa: PLR0913 - входы автозамены именованные
session: AsyncSession,
*,
job: RecognitionJob,
file: StoredFile,
documents: Sequence[Document],
warnings: list[str],
now: datetime,
) -> list[str]:
"""Автозамена нового файла; её предупреждение о выпавших документах (OQ-154) дописывается в
`metrics.mapping_warnings` задания. Возвращает полный список предупреждений маппинга.
Предупреждение — про заменённые файлы, а не про документы нового: их статусы уже посчитаны и от него не меняются.
"""
replace_warnings: list[str] = []
await supersede_previous(
session,
new_file=file,
new_documents=documents,
uploader_id=file.uploaded_by,
now=now,
warnings=replace_warnings,
)
if not replace_warnings:
return warnings
# новый список, а не `extend`: предыдущая версия `metrics` ссылается на тот же список, и SQLAlchemy не увидел бы
# изменения JSONB (старое и новое значение сравнялись бы)
warnings = [*warnings, *replace_warnings]
job.metrics = {**_metrics(job), MAPPING_WARNINGS_KEY: warnings}
await session.flush()
return warnings
# --- автозамена прежних версий --------------------------------------------------------------------------------------
DocumentKey = tuple[DocumentType, str | None, int, PeriodQuarter]
def _document_key(document: Document) -> DocumentKey | None:
"""Ключ совпадения документов для автозамены (D-10); документ без года или квартала в замене не участвует."""
if document.period_year is None or document.period_quarter is None:
return None
return (document.document_type, document.osv_account_code, document.period_year, document.period_quarter)
def _replaceable(document: Document, keys: set[DocumentKey]) -> bool:
"""Документ участвует в автозамене: ключ среди ключей нового файла и шапка не другой организации (AURORA-1644,
§9.5 п. 3: пара с `MISMATCH` у любой стороны не заменяется; `UNKNOWN` — как прежде)."""
return _document_key(document) in keys and document.counterparty_check is not DocumentCheckResult.MISMATCH
def _describe_document(document: Document) -> str:
period = f"{document.period_year} {document.period_quarter.value if document.period_quarter else ''}".strip()
return f"{document.document_type.value} {document.osv_account_code or ''} {period} ({document.status.value})"
def _describe_dropped(document: Document) -> str:
"""Выпавший из замены документ для предупреждения (OQ-154): тип, счёт, период и статус (в т. ч. `CONFIRMED`)."""
period = (
f"{document.period_year} {document.period_quarter.value}"
if document.period_year is not None and document.period_quarter is not None
else "период не определён"
)
parts = [document.document_type.value, document.osv_account_code, period]
return f"{' '.join(part for part in parts if part)} ({document.status.value})"
async def _version_labels(session: AsyncSession, version_ids: Sequence[uuid.UUID]) -> list[str]:
"""Человекочитаемые подписи версий консолидации («ГК» год квартал, версия N) в порядке `version_ids`."""
if not version_ids:
return []
rows = await session.execute(
select(
ConsolidationVersion.id,
ConsolidationVersion.version_no,
Consolidation.year,
Consolidation.quarter,
CompanyGroup.name,
)
.join(Consolidation, Consolidation.id == ConsolidationVersion.consolidation_id)
.join(CompanyGroup, CompanyGroup.id == Consolidation.company_group_id)
.where(ConsolidationVersion.id.in_(version_ids))
)
labels = {
version_id: f"«{group_name}» {year} {quarter.value}, версия {version_no}"
for version_id, version_no, year, quarter, group_name in rows
}
return [labels.get(version_id, str(version_id)) for version_id in version_ids]
def _replace_warning(old: StoredFile, dropped: Sequence[Document], version_labels: Sequence[str]) -> str:
"""Предупреждение нового файла о документах заменённого файла `old`, которых в новом файле нет (OQ-154)."""
text = (
f"автозамена: файл «{old.original_name}» (версия {old.version_no}) заменён этим файлом целиком, "
f"но в новом файле нет документов прежнего: {', '.join(sorted(map(_describe_dropped, dropped)))}"
)
if version_labels:
text += f"; привязки сняты с версий консолидации: {', '.join(version_labels)}"
return text
def _skip_warning(old: StoredFile, documents: Sequence[Document]) -> str:
"""Предупреждение нового файла: файл `old` с документами того же ключа оставлен, потому что у нового или прежнего
документа организация в шапке не совпадает с карточкой (AURORA-1644)."""
return (
f"автозамена не выполнена: файл «{old.original_name}» (версия {old.version_no}) оставлен рядом с этим — "
f"у нового или прежнего документа организация в шапке не совпадает с организацией карточки: "
f"{', '.join(sorted(map(_describe_document, documents)))}"
)
async def _skip_warnings(
session: AsyncSession,
candidates: Sequence[Document],
all_keys: set[DocumentKey],
replaced_file_ids: set[uuid.UUID],
) -> list[str]:
"""Предупреждения о файлах с документами того же ключа, которые автозамена оставила из-за `MISMATCH`
(AURORA-1644): по одному на файл, в порядке `id`."""
skipped: dict[uuid.UUID, list[Document]] = {}
for document in candidates:
if document.stored_file_id not in replaced_file_ids and _document_key(document) in all_keys:
skipped.setdefault(document.stored_file_id, []).append(document)
if not skipped:
return []
kept = await session.execute(select(StoredFile).where(StoredFile.id.in_(sorted(skipped))).order_by(StoredFile.id))
return [_skip_warning(old, skipped[old.id]) for old in kept.scalars()]
async def supersede_previous( # noqa: PLR0912, PLR0913, PLR0915 - автозамена одной транзакцией: входы и шаги именованные
session: AsyncSession,
*,
new_file: StoredFile,
new_documents: Sequence[Document],
uploader_id: uuid.UUID | None,
now: datetime,
warnings: list[str] | None = None,
) -> list[StoredFile]:
"""D-10, spec primary-files «Автозамена прежних версий»: файлы того же ЮЛ (не `SUPERSEDED`, не сам новый) с
документом тех же `document_type`, `osv_account_code`, `period_year`, `period_quarter` → `SUPERSEDED`,
`superseded_by_file_id = new_file.id`; у нового файла `version_no` = максимум заменённых + 1; файл без
консолидации (`consolidation_id` пуст) наследует её у первого по `id` заменённого файла, где она задана (v5.18,
§9.5 п. 3). На каждый заменённый файл — одно системное событие `FILE_REPLACE` (`user_id = NULL`, инициатор
загрузки — в `description`, `confirmed=true`, если среди заменённых документов есть `CONFIRMED`; решение
владельца 05.10.2026). Документы и строки заменённых файлов не трогаются (история). Возвращает заменённые файлы;
в транзакции вызывающего, без commit.
Привязки документов заменённых файлов к версиям консолидации «в работе» снимаются
(`consolidation_documents.on_documents_superseded`); id версий — в `after_value.unbound_version_ids` события
заменённого файла (spec audit-trail), статус версий не меняется.
OQ-154: замена остаётся на уровне файла, но если у заменённого файла есть документы, ключа которых нет среди
`new_documents` (например, подтверждённый ББ из книги «ББ + ОФР», заменённой отдельным ОФР), в `warnings` (если
передан) добавляется по одному предупреждению на заменённый файл: имя файла, выпавшие документы (тип, счёт,
период, статус) и версии консолидации, с которых сняты привязки этих документов. Поведение замены не меняется.
AURORA-1644: пара «новый — прежний» с `counterparty_check = MISMATCH` у любого из двух в замене не участвует
(`_replaceable`) — документы другой организации, загруженные в карточку, не вытесняют друг друга. Файл, оставленный
только из-за этого, — предупреждение в `warnings` (`_skip_warning`); `UNKNOWN` заменяется как прежде.
§14.3 (design quality-metrics §4.2, AURORA-1759): на каждый документ BALANCE/INCOME/OSV заменённого файла —
факт исхода `SUPERSEDED` (не подтверждался) или `SUPERSEDED_AFTER_CONFIRM` (был `CONFIRMED` на момент замены, в
т. ч. вручную — здесь, в отличие от удаления файла (задача 8), нет 409-проверки, исключающей такие документы).
На КАЖДЫЙ документ заменённого файла, а не только на выпавшие (OQ-154 выше): замена — файл целиком (design №33).
Блокировки: advisory-блокировка ЮЛ сериализует автозамены одного ЮЛ (два новых файла одного документа, смапленные
параллельно, иначе не увидели бы друг друга и оба остались бы действующими); заменяемые файлы — `FOR UPDATE` в
порядке `id` (сериализация с подтверждением документа, которое перечитывает файл под `FOR UPDATE`); затем
advisory ГК и версии консолидации по `id` (D-9, R-2).
"""
all_keys = {key for key in map(_document_key, new_documents) if key is not None}
if not all_keys:
return []
keys = { # ключи, которыми новый файл может заменять: без документов другой организации (AURORA-1644)
key
for document in new_documents
if (key := _document_key(document)) is not None
and document.counterparty_check is not DocumentCheckResult.MISMATCH
}
lock_key = func.hashtextextended(f"{SUPERSEDE_LOCK_PREFIX}:{new_file.legal_entity_id}", 0)
await session.execute(select(func.pg_advisory_xact_lock(lock_key)))
def matching(stmt: Select[tuple[Document]]) -> Select[tuple[Document]]:
return stmt.join(StoredFile, StoredFile.id == Document.stored_file_id).where(
StoredFile.legal_entity_id == new_file.legal_entity_id,
StoredFile.id != new_file.id,
StoredFile.status != FileStatus.SUPERSEDED,
Document.document_type.in_(sorted({key[0] for key in all_keys})),
Document.period_year.in_(sorted({key[2] for key in all_keys})),
)
candidates = (await session.execute(matching(select(Document)))).scalars().all()
file_ids = sorted({document.stored_file_id for document in candidates if _replaceable(document, keys)})
if warnings is not None:
warnings.extend(await _skip_warnings(session, candidates, all_keys, set(file_ids)))
if not file_ids:
return []
locked = (
(
await session.execute(
select(StoredFile)
.where(StoredFile.id.in_(file_ids), StoredFile.status != FileStatus.SUPERSEDED)
.order_by(StoredFile.id)
.with_for_update()
.execution_options(populate_existing=True)
)
)
.scalars()
.all()
)
if not locked:
return []
ids = [old.id for old in locked]
# документы перечитываются ПОСЛЕ блокировки файлов: подтверждение, зафиксированное до неё, попадёт в `confirmed`
fresh = await session.execute(
matching(select(Document)).where(Document.stored_file_id.in_(ids)).execution_options(populate_existing=True)
)
replaced_documents: dict[uuid.UUID, list[Document]] = {}
for document in fresh.scalars():
if _replaceable(document, keys):
replaced_documents.setdefault(document.stored_file_id, []).append(document)
await session.execute(
update(StoredFile)
.where(StoredFile.id.in_(ids), StoredFile.status != FileStatus.SUPERSEDED)
.values(status=FileStatus.SUPERSEDED, superseded_by_file_id=new_file.id, updated_at=now)
.execution_options(synchronize_session=False)
)
new_file.version_no = max(old.version_no for old in locked) + 1
if new_file.consolidation_id is None: # v5.18, §9.5 п. 3: файл без контекста наследует консоль заменённого
new_file.consolidation_id = next(
(old.consolidation_id for old in locked if old.consolidation_id is not None), None
)
new_file.updated_at = now
await session.flush()
# привязки к версиям «в работе» снимаются у ВСЕХ документов заменённого файла (spec primary-files), а не только у
# совпавших по ключу: подхват берёт документы лишь незаменённых файлов (design №33)
file_documents = await session.execute(
select(Document).where(Document.stored_file_id.in_(ids)).execution_options(populate_existing=True)
)
documents_by_file: dict[uuid.UUID, list[Document]] = {}
for document in file_documents.scalars():
documents_by_file.setdefault(document.stored_file_id, []).append(document)
# §14.3 (design quality-metrics §4.2, AURORA-1759): факт исхода на КАЖДЫЙ документ заменённого файла — и выпавший,
# и совпавший по ключу («rest»): замена — файл целиком (design №33, тот же принцип, что у снятия привязок выше).
# Счётчик нарушений — одним запросом на все документы всех заменяемых файлов (без N+1 на документ/файл).
outcome_issue_counts = await statements_repo.issue_counts_by_document(
session, [document.id for group in documents_by_file.values() for document in group]
)
unbound: dict[uuid.UUID, list[uuid.UUID]] = {}
dropped_by_file: dict[uuid.UUID, list[Document]] = {} # документы без ключа среди новых (OQ-154)
dropped_versions: dict[uuid.UUID, list[uuid.UUID]] = {} # версии, где сняты привязки именно выпавших документов
for old in locked: # по возрастанию id; advisory ГК и версии берутся первым вызовом (порядок D-9)
old_documents = documents_by_file.get(old.id, [])
for document in old_documents:
if document.document_type not in INTAKE_FACT_DOCUMENT_TYPES: # тот же гейт, что у §14.1 (задачи 5-8)
continue
outcome = (
QualityOutcome.SUPERSEDED_AFTER_CONFIRM
if document.status is DocumentStatus.CONFIRMED
else QualityOutcome.SUPERSEDED
)
counts = outcome_issue_counts.get(document.id)
issue_count_at_outcome = (counts.error + counts.info) if counts is not None else 0
await insert_outcome_fact(
session,
document_id=document.id,
outcome=outcome,
outcome_at=now,
primary_cause=None, # SUPERSEDED*/DELETED* вне матрицы причин (§14.3 правило 1)
report_date=document.reporting_date,
confirmed_by=document.confirmed_by, # может быть не NULL: здесь нет 409-проверки задачи 8
issue_count_at_outcome=issue_count_at_outcome,
payload=build_outcome_payload(document, fixes=()),
)
dropped = [document for document in old_documents if _document_key(document) not in keys]
dropped_by_file[old.id] = dropped
# выпавшие снимаются отдельным первым вызовом: он возвращает версии именно их привязок (после блокировки
# версий, без окна между чтением и удалением); второй вызов добирает остальные документы файла
dropped_versions[old.id] = (
await consolidation_documents.on_documents_superseded(session, documents=dropped, now=now)
if dropped
else []
)
rest_versions = await consolidation_documents.on_documents_superseded(session, documents=old_documents, now=now)
unbound[old.id] = sorted({*dropped_versions[old.id], *rest_versions})
audit = AuditService(session)
uploader = f"user:{uploader_id}" if uploader_id is not None else "неизвестен"
for old in locked:
await session.refresh(old, ["status", "superseded_by_file_id", "updated_at"])
documents = sorted(replaced_documents.get(old.id, []), key=_describe_document)
confirmed = any(document.status is DocumentStatus.CONFIRMED for document in documents)
await audit.record(
action=AuditAction.FILE_REPLACE,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type=STORED_FILE_OBJECT_TYPE,
object_id=old.id,
user_id=None,
after_value={
"file_id": new_file.id,
"version_no": new_file.version_no,
"unbound_version_ids": unbound[old.id],
},
description=(
f"заменён файлом {new_file.original_name} (загрузил {uploader}); "
f"документы: {', '.join(map(_describe_document, documents))}; "
f"confirmed={'true' if confirmed else 'false'}"
),
)
if warnings is not None and dropped_by_file[old.id]:
labels = await _version_labels(session, dropped_versions[old.id])
warnings.append(_replace_warning(old, dropped_by_file[old.id], labels))
logger.info(
"Автозамена: прежние версии файла заменены. file_id={file_id}, version_no={version_no}, replaced={replaced}",
file_id=new_file.id,
version_no=new_file.version_no,
replaced=[str(old_id) for old_id in ids],
)
return list(locked)
# --- проход и цикл --------------------------------------------------------------------------------------------------
async def _lock_job(session: AsyncSession, job_id: uuid.UUID) -> RecognitionJob | None:
stmt = (
select(RecognitionJob)
.where(RecognitionJob.id == job_id)
.with_for_update()
.execution_options(populate_existing=True)
)
return (await session.execute(stmt)).scalar_one_or_none()
async def _lock_file(session: AsyncSession, file_id: uuid.UUID) -> StoredFile | None:
stmt = (
select(StoredFile).where(StoredFile.id == file_id).with_for_update().execution_options(populate_existing=True)
)
return (await session.execute(stmt)).scalar_one_or_none()
# --- факт отказа (quality_failure_fact, §14.5, design quality-metrics §4.3, AURORA-1759) -----------------------------
@dataclass(frozen=True)
class _FailureDimensions:
"""ЮЛ/ГК и признак скана файла на момент отказа маппинга — измерения `quality_failure_fact` (design §4: факт без
FK, имена и id ЮЛ/ГК — копия на момент отказа)."""
legal_entity_id: uuid.UUID
legal_entity_name: str
group_id: uuid.UUID | None
group_name: str | None
is_scan: bool | None
async def _failure_dimensions(session: AsyncSession, file_id: uuid.UUID) -> _FailureDimensions | None:
"""ЮЛ/ГК и признак скана файла по `file_id` (`session.get`, тот же приём, что у `_write_intake_facts`): файл или
ЮЛ не найдены (не должно быть при штатной работе) — `None`, факт отказа не пишется, остальная обработка не
прерывается."""
file = await session.get(StoredFile, file_id)
if file is None:
return None
owner = await session.get(LegalEntity, file.legal_entity_id)
if owner is None:
return None
group_name: str | None = None
if owner.company_group_id is not None:
group = await session.get(CompanyGroup, owner.company_group_id)
group_name = group.name if group is not None else None
return _FailureDimensions(
legal_entity_id=owner.id,
legal_entity_name=owner.name,
group_id=owner.company_group_id,
group_name=group_name,
is_scan=file.is_scan,
)
async def _write_mapping_failure_fact(session: AsyncSession, job: RecognitionJob, *, failed_at: datetime) -> None:
"""Факт отказа `MAPPING` (§14.5) — `job` уже дошёл до `done`/`needs_check` (`service_name`/`attempt` сервиса
известны, в отличие от отказов обмена задачи 10 sites 1-4), а маппинг результата не удался."""
dims = await _failure_dimensions(session, job.file_id)
if dims is None:
logger.warning(
"Факт отказа MAPPING не записан: ЮЛ файла не найдено. job_id={job_id}, file_id={file_id}",
job_id=job.id,
file_id=job.file_id,
)
return
await insert_failure_fact(
session,
job_id=job.id,
file_id=job.file_id,
attempt=job.attempt,
legal_entity_id=dims.legal_entity_id,
legal_entity_name=dims.legal_entity_name,
group_id=dims.group_id,
group_name=dims.group_name,
pipeline=job.pipeline,
is_scan=dims.is_scan,
service_name=job.service_name,
failure_kind=QualityFailureKind.MAPPING,
error_code=MAPPING_FAILURE_ERROR_CODE,
failed_at=failed_at,
)
async def _fail(
session: AsyncSession, candidate: Candidate, message: str, now: datetime, *, cause: BaseException | None = None
) -> None:
"""Неповторяемая ошибка: `metrics.mapping_error` и файл текущего задания → `ERROR` (транзакция вызывающего).
`cause` — исходное исключение (например, `ValidationError`): его полный текст пишется только в лог (M-1).
§14.5: факт отказа `MAPPING`."""
job = await _lock_job(session, candidate.job_id)
if job is None:
return
job.metrics = {**_metrics(job), MAPPING_ERROR_KEY: _short(message)}
await session.flush()
changed = await set_file_status_for_job(session, job, FileStatus.ERROR, now=now)
await _write_mapping_failure_fact(session, job, failed_at=now)
logger.opt(exception=cause).error(
"Маппинг результата невозможен: файл переведён в ERROR. error_category=mapping_fatal, job_id={job_id}, "
"file_id={file_id}, file_changed={changed}, error={error}",
job_id=candidate.job_id,
file_id=candidate.file_id,
changed=changed,
error=message,
)
async def _register_failure(
session_factory: async_sessionmaker[AsyncSession],
candidate: Candidate,
exc: Exception,
*,
max_attempts: int,
now: datetime,
) -> bool:
"""D-2: после отката — `metrics.mapping_attempts + 1` отдельной транзакцией; на `max_attempts` — файл `ERROR` и
факт отказа `MAPPING` (§14.5). Возвращает `True`, если попытки исчерпаны (файл переведён в `ERROR` и покинул
очередь)."""
async with session_factory() as session, session.begin():
job = await _lock_job(session, candidate.job_id)
if job is None:
return False
metrics = _metrics(job)
attempts = _attempts(metrics) + 1
metrics[MAPPING_ATTEMPTS_KEY] = attempts
exhausted = attempts >= max_attempts
if exhausted:
# M-1: только категория и тип исключения — текст SQLAlchemy/asyncpg ([SQL: …], параметры) остаётся в логе
metrics[MAPPING_ERROR_KEY] = _short(
f"маппинг не удался: ошибка БД/S3 ({type(exc).__name__}), попыток {attempts}"
)
job.metrics = metrics
await session.flush()
if exhausted:
await set_file_status_for_job(session, job, FileStatus.ERROR, now=now)
await _write_mapping_failure_fact(session, job, failed_at=now)
logger.opt(exception=exc).error(
"Ошибка маппинга результата, транзакция откачена. error_category=mapping_error, job_id={job_id}, "
"file_id={file_id}, attempts={attempts}, file_error={exhausted}",
job_id=candidate.job_id,
file_id=candidate.file_id,
attempts=attempts,
exhausted=exhausted,
)
return exhausted
# --- факт приёма (quality_intake_fact, §14.1, design quality-metrics §4.1, AURORA-1759) ------------------------------
async def _s3_key(session: AsyncSession, object_id: uuid.UUID | None) -> str | None:
"""Ключ `s3_object` по id для факта качества (хранится строкой, без FK — design §4); нет id или записи — `None`."""
if object_id is None:
return None
return (await session.execute(select(S3Object.key).where(S3Object.id == object_id))).scalar_one_or_none()
async def _statement_intake_fields(
session: AsyncSession, document: Document, issues: Sequence[DocumentIssue]
) -> dict[str, Any]:
"""Счётчики и `payload` факта приёма формы ББ/ОФР: строки и значения — `financial_statement_*` формы документа,
нарушения/предупреждения — уже прочитанные `document_issue`. `warning_count` — предупреждения СЕРВИСА
(`document_issue.source = service`; у `1.x` таких записей нет, формирует только маппер `2.0`).
Формы без шапки (`meta is None`) из текущего `map_file` не возникают — каждая форма получает `financial_statement_
meta` в той же транзакции (`_insert_statements`/`insert_forms_v2`); на случай, если это всё же не так, снимок без
построчных данных дешевле, чем прерванная транзакция маппинга."""
meta = await statements_repo.get_meta(session, document.id)
if meta is None:
return {"payload": build_intake_payload(document, issues=issues)}
_, pairs = await statements_repo.load_form(session, meta.id)
lines = [line for line, _ in pairs]
values = [value for _, line_values in pairs for value in line_values]
return {
"line_count": len(lines),
"value_count": len(values),
"value_needs_review_count": sum(1 for value in values if value.needs_review),
"line_unreadable_count": sum(1 for line in lines if line.line_status is StatementLineStatus.unreadable),
"line_missing_count": sum(1 for line in lines if line.line_status is StatementLineStatus.missing),
"issue_relation_count": sum(1 for issue in issues if issue.kind is DocumentIssueKind.relation),
"issue_soft_count": sum(1 for issue in issues if issue.kind is DocumentIssueKind.soft),
"warning_count": sum(
1 for issue in issues if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service
),
"payload": build_intake_payload(
document, lines=lines, values=values, issues=issues, header_source=meta.header_source
),
}
async def _osv_intake_fields(
session: AsyncSession, document: Document, issues: Sequence[DocumentIssue]
) -> dict[str, Any]:
"""`payload` факта приёма ОСВ: без построчного снимка (строк/значений §7 правок у ОСВ в ядре нет) — только число
строк `osv_entry` и коды предупреждений сервиса `OSV_*` (`document_issue` вида `warning`, `source = service`,
см. `_osv_warning_issue`; предупреждения маппинга ядра — `MAPPING_WARNING`, `source = core` — в этот список не
входят, design §4.1 «коды предупреждений OSV_*»). Счётчики строк/значений формы для ОСВ не считаются (`None`)."""
row_count = (
await session.execute(select(func.count()).select_from(OsvEntry).where(OsvEntry.document_id == document.id))
).scalar_one()
osv_warnings = [
issue.code
for issue in issues
if issue.kind is DocumentIssueKind.warning and issue.source is IssueSource.service
]
return {"payload": build_intake_payload(document, osv_warnings=osv_warnings, osv_row_count=row_count)}
async def _write_intake_facts( # noqa: PLR0913 - все входы факта приёма именованные
session: AsyncSession,
*,
job: RecognitionJob,
file: StoredFile,
owner: LegalEntity,
documents: Sequence[Document],
now: datetime,
) -> None:
"""Факт приёма «как распознано» (§14.1, design §4.1): один `quality_intake_fact` на каждый документ
BALANCE/INCOME/OSV файла (`DISCLOSURE`/`OTHER` — без факта); пишется в транзакции маппинга, после `map_file`
(документы, строки форм и `document_issue` уже вставлены), до `auto_confirm`. Чисто добавочный шаг: не меняет
`documents`, статусы и прочий результат маппинга."""
group_name: str | None = None
if owner.company_group_id is not None:
group = await session.get(CompanyGroup, owner.company_group_id)
group_name = group.name if group is not None else None
raw_s3_key = await _s3_key(session, job.raw_s3_object_id)
result_s3_key = await _s3_key(session, job.result_s3_object_id)
upload_s3_key = await _s3_key(session, file.s3_object_id)
for document in documents:
if document.document_type not in INTAKE_FACT_DOCUMENT_TYPES:
continue
issues = await statements_repo.issues(session, document.id)
fields = (
await _osv_intake_fields(session, document, issues)
if document.document_type is DocumentType.OSV
else await _statement_intake_fields(session, document, issues)
)
await insert_intake_fact(
session,
document_id=document.id,
file_id=file.id,
job_id=job.id,
attempt=job.attempt,
legal_entity_id=file.legal_entity_id,
legal_entity_name=owner.name,
group_id=owner.company_group_id,
group_name=group_name,
document_type=document.document_type,
pipeline=job.pipeline,
is_scan=file.is_scan,
service_name=job.service_name,
service_version=job.service_version,
statements_version=job.statements_version,
result_schema_version=job.result_schema_version,
report_date=document.reporting_date,
intake_at=now,
intake_status=document.status,
raw_s3_key=raw_s3_key,
result_s3_key=result_s3_key,
upload_s3_key=upload_s3_key,
**fields,
)
async def _map_candidate(
session_factory: async_sessionmaker[AsyncSession],
candidate: Candidate,
result: RecognitionResult | RecognitionResultV2,
clock: Clock,
) -> None:
"""Шаг 3: транзакция маппинга. Задание и файл перечитываются под блокировкой (порядок «задание → файл»); файл уже
не ждёт результата этого задания (его опередили, задание не текущее — Review Focus 1) — ничего не меняется."""
async with session_factory() as session, session.begin():
job = await _lock_job(session, candidate.job_id)
file = await _lock_file(session, candidate.file_id)
if (
job is None
or file is None
or job.status not in MAPPABLE_JOB_STATUSES
or file.status is not FileStatus.PROCESSING
or file.current_recognition_job_id != job.id
):
logger.info(
"Маппинг пропущен: файл уже не ждёт результата этого задания. job_id={job_id}, file_id={file_id}",
job_id=candidate.job_id,
file_id=candidate.file_id,
)
return
rows = await session.execute(select(ChartOfAccount.code, ChartOfAccount.id))
chart = {code: account_id for code, account_id in rows}
index = await CounterpartyIndex.load(session)
now = clock()
warnings = await map_file(session, job=job, file=file, result=result, chart=chart, index=index, now=now)
# После `map_file`, а значит после автозамены (advisory ЮЛ → старые файлы → advisory ГК → версии): версии
# блокируются последними, порядок «задание → файл → документы → версии» (D-9) не нарушается. Сначала
# автоподтверждение чистых документов (привязка и переход версии, OQ-157, design D-1), затем переход по
# `NEEDS_CHECK` (D-6) — он уже видит версию после автоподтверждения и второго перехода не делает.
documents = list((await session.execute(select(Document).where(Document.stored_file_id == file.id))).scalars())
# §14.1: факт приёма — ПОСЛЕ map_file (документы и формы уже вставлены), ДО auto_confirm (снимок «как
# распознано», не «как подтверждено»); owner уже есть в БД — map_file выше не дал бы MappingFatal иначе
owner = await session.get(LegalEntity, file.legal_entity_id)
if owner is not None:
await _write_intake_facts(session, job=job, file=file, owner=owner, documents=documents, now=now)
# отложенный импорт: цикл documents → result_mapping → auto_confirm → statements → documents
from src.services.auto_confirm import auto_confirm # noqa: PLC0415
await auto_confirm(session, file=file, documents=documents, now=now)
await consolidation_documents.on_documents_mapped(session, file=file, documents=documents, now=now)
logger.info(
"Результат распознавания смапплен: файл PROCESSED. job_id={job_id}, file_id={file_id}, warnings={warnings}",
job_id=candidate.job_id,
file_id=candidate.file_id,
warnings=len(warnings),
)
async def process_once(
session_factory: async_sessionmaker[AsyncSession],
storage: ObjectStorage,
settings: Settings,
*,
now: Clock | None = None,
) -> bool:
"""Один проход цикла маппинга (модель шагов — в docstring модуля). Результат — выполнена ли полезная работа, после
которой цикл может не спать: `True` — кандидат смапплен, отклонён неповторяемой ошибкой или исчерпал попытки
(файл `ERROR`), либо пропущен, потому что его опередили; `False` — кандидатов нет или повторяемая неудача
(R-15: пауза `MAPPING_POLL_SECONDS` перед следующей попыткой, иначе кратковременный сбой S3/БД сжёг бы все попытки
за миллисекунды)."""
clock = now or _utcnow
async with session_factory() as session, session.begin():
candidate = await pick_candidate(session, max_attempts=settings.MAPPING_MAX_ATTEMPTS)
if candidate is None:
return False
try:
validate_result_key(candidate, storage.bucket)
except MappingFatal as exc:
await _fail(session, candidate, str(exc), clock())
return True
try:
result = await _load_result(storage, candidate) # шаг 2 — вне транзакции: блокировки не ждут S3
await _map_candidate(session_factory, candidate, result, clock)
except MappingFatal as exc:
async with session_factory() as session, session.begin():
await _fail(session, candidate, str(exc), clock(), cause=exc.__cause__)
except Exception as exc: # сбой S3 или БД: транзакция маппинга откачена, повтор после паузы цикла (R-15)
return await _register_failure(
session_factory, candidate, exc, max_attempts=settings.MAPPING_MAX_ATTEMPTS, now=clock()
)
return True
async def run_mapping_loop(
session_factory: async_sessionmaker[AsyncSession],
storage: ObjectStorage,
settings: Settings,
*,
sleep: SleepFn = asyncio.sleep,
) -> None:
"""Бесконечный цикл маппинга: после полезной работы (`process_once` → `True`) — следующий кандидат сразу; без
кандидатов или после повторяемой неудачи — пауза `MAPPING_POLL_SECONDS` (R-15); ошибка прохода логируется, цикл
продолжается."""
while True:
processed = False
try:
processed = await process_once(session_factory, storage, settings)
except Exception as exc:
logger.exception(
"Ошибка прохода цикла маппинга. error_category=mapping_loop_error, error_type={error_type}, "
"error={error}",
error_type=type(exc).__name__,
error=repr(exc),
)
await sleep(0 if processed else settings.MAPPING_POLL_SECONDS)