"""Пересчёт формы ББ/ОФР после правки общим кодом `arm_statements` (часть Б, B2; spec arm-core «Пересчёт после правки»). Единая точка: вход пересчёта собирается из БД (`build_stored_form`), считает пакет (`engine.recompute`), результат пишется обратно — `needs_review` строк и значений, `form_variant`, `parent_row_index` расшифровок, записи `document_issue` видов `relation` / `soft` и проверок ядра (P3-8), статус документа. §7 хранится только здесь и в приёме результата (B3/B4); экран и подтверждение читают его из `document_issue`. """ from collections.abc import Mapping, Sequence from datetime import datetime from sqlalchemy.ext.asyncio import AsyncSession from src.common.clock import utc_now from src.domain.statement_aggregate import CodeAggregate from src.domain.statement_issues import ( CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, RECOMPUTED_CORE_CODES, RELATION_FAILED, RELATION_SOFT, STATEMENTS_VERSION_MISMATCH, IssueDraft, code_issues, core_info_issues, name_issues, relation_message, version_issue, ) from src.domain.statement_lines import load_line_catalog from src.domain.statement_status import duplicate_period_forms, form_status_v2 from src.domain.stored_form import build_stored_form from src.models.enums import ( DocumentCheckResult, DocumentIssueKind, DocumentStatus, IssueSeverity, IssueSource, StatementFormVariant, StatementLineStatus, ) from src.models.files import Document, RecognitionJob from src.models.statements import FinancialStatementColumn, FinancialStatementMeta from src.repositories import statements as repo from src.services.statement_intake_v2 import file_error_in_metrics from src.services.statements_engine import FailedRelation, StatementChecks, StatementsEngine __all__ = [ "CODE_NOT_IN_DICTIONARY", "CODE_NOT_IN_EDITION", "NAME_MISMATCH", "RECOMPUTED_CORE_CODES", "RELATION_FAILED", "RELATION_SOFT", "STATEMENTS_VERSION_MISMATCH", "checks_on_intake_1x", "code_issues", "core_info_issues", "name_issues", "recompute_form", "relations_to_issues", "version_issue", ] # коды и проверки ядра `info` живут в `domain/statement_issues` (P3-39); здесь они реэкспортируются (`__all__`) def _find_column( columns: Sequence[FinancialStatementColumn], failure: FailedRelation ) -> FinancialStatementColumn | None: """Колонка нарушения: по ключу `str(column_id)` (пересчёт из БД), иначе по `str(column_index)` (проверки `2.0`).""" for column in columns: if str(column.id) == failure.column_key: return column for column in columns: if str(column.column_index) == failure.column_key: return column return None def relations_to_issues( checks: StatementChecks, *, columns: Sequence[FinancialStatementColumn], aggregates: Mapping[str, CodeAggregate], source: IssueSource, ) -> list[IssueDraft]: """Нарушения пакета → черновики `document_issue`: `relations_failed` — `relation` / `RELATION_FAILED` / `error`, `soft_relations_failed` — `soft` / `RELATION_SOFT` / `info`. `line_ids` — все строки `ok` кодов `codes`; `expected` / `actual` могут быть `None` (цель или все члены пусты, P1-21) — в сообщении «пусто», в записи `NULL`.""" drafts: list[IssueDraft] = [] groups = ( (checks.relations_failed, DocumentIssueKind.relation, RELATION_FAILED, IssueSeverity.error), (checks.soft_relations_failed, DocumentIssueKind.soft, RELATION_SOFT, IssueSeverity.info), ) for failures, kind, code, severity in groups: for failure in failures: column = _find_column(columns, failure) line_ids = dict.fromkeys( line_id for form_code in failure.codes if form_code in aggregates for line_id in aggregates[form_code].line_ids ) drafts.append( IssueDraft( kind=kind, code=code, severity=severity, message=relation_message(failure.relation, failure.expected, failure.actual), source=source, line_ids=tuple(line_ids), column_id=column.id if column is not None else None, column_index=failure.column_index, relation=failure.relation, codes=tuple(failure.codes), expected=failure.expected, actual=failure.actual, ) ) return drafts async def recompute_form( session: AsyncSession, *, document: Document, meta: FinancialStatementMeta, engine: StatementsEngine, job: RecognitionJob | None, ) -> None: """Пересчёт формы по сохранённым данным (один вызов `engine.recompute`) и запись результата; статус `CONFIRMED` не меняется. Без commit.""" columns, loaded = await repo.load_form(session, meta.id) stored = build_stored_form(document, meta, columns, loaded, await repo.issues(session, document.id)) result = engine.recompute(stored) lines = [line for line, _ in loaded] row_of = {str(line.id): line.row_index for line in lines} for line, values in loaded: key = str(line.id) line.needs_review = bool(result.line_needs_review.get(key, False)) if line.line_status is StatementLineStatus.detail: parent_key = result.detail_parents.get(key) line.parent_row_index = row_of.get(parent_key) if parent_key is not None else None for value in values: value.needs_review = bool(result.value_needs_review.get((key, str(value.column_id)), False)) meta.form_variant = StatementFormVariant(result.form_variant.value) if result.form_variant is not None else None form = document.document_type.value catalog = load_line_catalog() edition = result.edition.value if result.edition is not None else None order = ( engine.code_order(result.edition, stored.document_type, result.form_variant) if result.edition is not None and result.form_variant is not None else None ) info = core_info_issues(lines, form=form, edition=edition, order=order, catalog=catalog) mismatched = info.mismatched drafts = list(info.drafts) mismatch = version_issue(job.statements_version if job is not None else None, engine.version) if mismatch is not None: drafts.append(mismatch) _, aggregates = await repo.form_aggregate(session, meta.id) drafts += relations_to_issues(result.checks, columns=columns, aggregates=aggregates, source=IssueSource.core) await repo.replace_issues( session, document.id, drafts, kinds=(DocumentIssueKind.relation, DocumentIssueKind.soft), core_codes=RECOMPUTED_CORE_CODES, now=utc_now(), ) if document.status is not DocumentStatus.CONFIRMED: forms = await repo.file_form_periods(session, document.stored_file_id) document.status = form_status_v2( service_done=result.status.value == "done", file_error_warning=file_error_in_metrics(job.metrics if job is not None else None), counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, period_known=document.period_year is not None and document.period_quarter is not None, names_ok=not mismatched, duplicate_in_file=document.id in duplicate_period_forms(forms), ) document.updated_at = utc_now() async def checks_on_intake_1x( session: AsyncSession, *, document: Document, meta: FinancialStatementMeta, engine: StatementsEngine, now: datetime, ) -> bool: """§7 и вариант формы при приёме результата `1.x` (Р-30): один вызов `engine.recompute` по вставленной форме. Пишет `meta.form_variant` и записи `relation` / `soft` (`source = core`); `needs_review` значений и строк не трогает (у `1.x` они по правилу приёма, A9), проверки ядра уровня `info` не пишет (P3-7). Возвращает «жёстких нарушений нет» — условие D-6 «проверки сошлись». Без commit.""" columns, loaded = await repo.load_form(session, meta.id) stored = build_stored_form(document, meta, columns, loaded, await repo.issues(session, document.id)) result = engine.recompute(stored) meta.form_variant = StatementFormVariant(result.form_variant.value) if result.form_variant is not None else None _, aggregates = await repo.form_aggregate(session, meta.id) drafts = relations_to_issues(result.checks, columns=columns, aggregates=aggregates, source=IssueSource.core) await repo.add_issues(session, document.id, drafts, now=now) return not result.checks.relations_failed