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