arm-core/api/src/services/statement_recompute.py
Andrey Epifancev e6292a24df fix(statements): fix-волна части Б — is_recognized NULL, единый источник ошибки файла, тесты правки форм 2.0
I-2: NULL у is_recognized не сомнение (P3-54); I-3: metrics.file_warnings при приёме 2.0 — единый
источник для пересчёта (P3-53, R-11); I-4/T4.4/T6.1: тесты правки, акцепта и веток приёма 2.0;
T4.2: now в checks_on_intake_1x; M-4: row_by_ref по принятым строкам; документация (M-5, M-6, M-7).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:38:24 +04:00

208 lines
9.3 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.

"""Пересчёт формы ББ/ОФР после правки общим кодом `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