From 065153e470b6ef1e902531ac9e26fe82553a1e57 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 20:26:40 +0400 Subject: [PATCH] =?UTF-8?q?feat(statements):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=81=D1=87=D1=91=D1=82=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D1=82=D0=BE=D1=87=D0=BA?= =?UTF-8?q?=D0=BE=D0=B9=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0=20arm=5Fstatement?= =?UTF-8?q?s,=20=C2=A77=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA=D0=BE=20=D0=B8?= =?UTF-8?q?=D0=B7=20document=5Fissue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit recompute_form (services/statement_recompute.py) собирает вход из БД, зовёт engine.recompute и пишет needs_review строк и значений, form_variant, parent_row_index расшифровок, записи relation/soft и core по P3-8, статус через form_status_v2. GET/confirm/accept читают нарушения §7 из document_issue. Правка header.report_date пересчитывает relative:* колонки. Временный код A12/A13 удалён. Co-Authored-By: Claude Sonnet 5.5 --- api/src/api/dependencies/files.py | 11 +- api/src/domain/statement_layout.py | 15 +- api/src/repositories/statements.py | 84 +++--- api/src/schemas/statements.py | 30 +- api/src/services/statement_line_edits.py | 14 - api/src/services/statement_recompute.py | 275 ++++++++++++++++++ api/src/services/statements.py | 110 +++---- api/src/services/statements_engine.py | 11 +- api/tests/test_api_statements.py | 224 +++++++++++++- api/tests/test_domain_statement_layout.py | 13 +- api/tests/test_form_confirmable.py | 15 +- api/tests/test_statement_recompute.py | 183 ++++++++++++ .../unified-normalization-core-b/design.md | 8 + 13 files changed, 825 insertions(+), 168 deletions(-) create mode 100644 api/src/services/statement_recompute.py create mode 100644 api/tests/test_statement_recompute.py diff --git a/api/src/api/dependencies/files.py b/api/src/api/dependencies/files.py index c18f316..63afe17 100644 --- a/api/src/api/dependencies/files.py +++ b/api/src/api/dependencies/files.py @@ -11,6 +11,7 @@ from src.domain.policy import VisibilityScope from src.services.documents import DocumentService from src.services.primary_files import PrimaryFilesService from src.services.statements import StatementService +from src.services.statements_engine import StatementsEngine, default_engine def get_primary_files_service( @@ -43,13 +44,19 @@ def get_document_service( DocumentServiceDep = Annotated[DocumentService, Depends(get_document_service)] +def get_statements_engine() -> StatementsEngine: + """Общий код нормализации `arm_statements` (правила — один раз на процесс); тесты подменяют зависимость.""" + return default_engine() + + def get_statement_service( session: SessionDep, audit: Annotated[RequestAudit, Depends(get_request_audit)], scope: Annotated[VisibilityScope, Depends(get_scope)], + engine: Annotated[StatementsEngine, Depends(get_statements_engine)], ) -> StatementService: - """Сервис формы ББ/ОФР: чтение, проверки §7 (видимость — через ЮЛ-владельца).""" - return StatementService(session, audit, scope) + """Сервис формы ББ/ОФР: чтение, пересчёт после правки, проверки §7 (видимость — через ЮЛ-владельца).""" + return StatementService(session, audit, scope, engine) StatementServiceDep = Annotated[StatementService, Depends(get_statement_service)] diff --git a/api/src/domain/statement_layout.py b/api/src/domain/statement_layout.py index 3df26e5..dc7870c 100644 --- a/api/src/domain/statement_layout.py +++ b/api/src/domain/statement_layout.py @@ -10,7 +10,7 @@ import bisect from collections.abc import Hashable, Sequence from dataclasses import dataclass -__all__ = ["LayoutLine", "nearest_ok_parents", "order_lines"] +__all__ = ["LayoutLine", "order_lines"] OK = "ok" DETAIL = "detail" @@ -51,16 +51,3 @@ def order_lines(lines: Sequence[LayoutLine]) -> list[Hashable]: result.append(line.key) result.extend(item.key for item in attached.get(line.key, [])) return result - - -def nearest_ok_parents(lines: Sequence[LayoutLine]) -> dict[Hashable, int | None]: - """Родитель строк `detail`: `row_index` ближайшей строки `ok` выше по `row_index` (строки без `row_index` — - шаблонные — родителями не бывают); нет такой — `None`. Временная: в части Б заменяется `detail_parents` пакета.""" - ok_rows = sorted(line.row_index for line in lines if line.line_status == OK and line.row_index is not None) - parents: dict[Hashable, int | None] = {} - for line in lines: - if line.line_status != DETAIL: - continue - position = bisect.bisect_left(ok_rows, line.row_index) - 1 if line.row_index is not None else -1 - parents[line.key] = ok_rows[position] if position >= 0 else None - return parents diff --git a/api/src/repositories/statements.py b/api/src/repositories/statements.py index d72c52f..7d39e37 100644 --- a/api/src/repositories/statements.py +++ b/api/src/repositories/statements.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from decimal import Decimal from typing import Any -from sqlalchemy import delete, exists, func, insert, or_, select, update +from sqlalchemy import delete, func, insert, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.sql.expression import ScalarSelect @@ -22,6 +22,7 @@ from src.models.enums import ( FileStatus, IssueSeverity, IssueSource, + PeriodQuarter, StatementLineStatus, ) from src.models.files import Document, StoredFile @@ -47,15 +48,14 @@ __all__ = [ "form_aggregate", "form_lines", "get_meta", - "has_review_values", - "has_source_lines", "insert_statement", "issue_counts_by_document", "issues", "load_form", "lock_cells", + "file_form_periods", "pair_amounts", - "settle_edited_values", + "replace_issues", "value_flags", ] @@ -224,15 +224,40 @@ async def _delete_statements(session: AsyncSession, documents: ScalarSelect[uuid await session.execute(delete(FinancialStatementMeta).where(FinancialStatementMeta.document_id.in_(documents))) -async def has_source_lines(session: AsyncSession, meta_id: uuid.UUID) -> bool: - """В форме есть строка источника (`row_index IS NOT NULL`) — единое определение «есть строки» для статуса D-6; - шаблонные строки (созданные правкой) не в счёт.""" - stmt = select( - select(FinancialStatementLine.id) - .where(FinancialStatementLine.meta_id == meta_id, FinancialStatementLine.row_index.is_not(None)) - .exists() +async def replace_issues( # noqa: PLR0913 - фильтр замены и новые записи независимы + session: AsyncSession, + document_id: uuid.UUID, + drafts: Sequence[IssueDraft], + *, + kinds: Collection[DocumentIssueKind], + core_codes: Collection[str], + now: datetime.datetime, +) -> None: + """Пересчёт после правки: удаляет записи документа видов `kinds` (любого источника) и записи источника `core` с + кодами `core_codes`, затем вставляет `drafts`. Прочие записи (`warning` источника `service`, `MAPPING_WARNING` и + т. п.) сохраняются.""" + await session.execute( + delete(DocumentIssue).where( + DocumentIssue.document_id == document_id, + or_( + DocumentIssue.kind.in_(kinds), + (DocumentIssue.source == IssueSource.core) & DocumentIssue.code.in_(core_codes), + ), + ) ) - return bool((await session.execute(stmt)).scalar_one()) + await add_issues(session, document_id, drafts, now=now) + + +async def file_form_periods( + session: AsyncSession, file_id: uuid.UUID +) -> list[tuple[uuid.UUID, DocumentType, int | None, PeriodQuarter | None]]: + """Формы ББ/ОФР файла (документы с шапкой): `(id, тип, год, квартал)` — вход `duplicate_period_forms` (Р-18).""" + stmt = ( + select(Document.id, Document.document_type, Document.period_year, Document.period_quarter) + .join(FinancialStatementMeta, FinancialStatementMeta.document_id == Document.id) + .where(Document.stored_file_id == file_id) + ) + return [(row[0], row[1], row[2], row[3]) for row in (await session.execute(stmt)).all()] async def unresolved_lines(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[uuid.UUID, int | None, str | None]]: @@ -317,41 +342,6 @@ async def value_flags(session: AsyncSession, meta_id: uuid.UUID) -> list[tuple[b return [(bool(modified), recognized, confidence) for modified, recognized, confidence in rows] -async def has_review_values(session: AsyncSession, meta_id: uuid.UUID) -> bool: - """Есть ли в форме значение с `needs_review` у строки не `detail` (вход условия `RECOGNIZED`; значения строк - `detail` не требуют проверки, Р-23).""" - stmt = select( - exists().where( - FinancialStatementValue.line_id == FinancialStatementLine.id, - FinancialStatementLine.meta_id == meta_id, - FinancialStatementLine.line_status != StatementLineStatus.detail, - FinancialStatementValue.needs_review.is_(True), - ) - ) - return bool((await session.execute(stmt)).scalar()) - - -async def settle_edited_values(session: AsyncSession, meta_id: uuid.UUID) -> None: - """Правило пересчёта: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; значения строк `detail` — - тоже `false` (Р-23).""" - lines = select(FinancialStatementLine.id).where(FinancialStatementLine.meta_id == meta_id) - detail = lines.where(FinancialStatementLine.line_status == StatementLineStatus.detail) - await session.execute( - update(FinancialStatementValue) - .where( - FinancialStatementValue.line_id.in_(lines), - FinancialStatementValue.needs_review.is_(True), - or_( - FinancialStatementValue.is_modified.is_(True), - FinancialStatementValue.is_confirmed.is_(True), - FinancialStatementValue.line_id.in_(detail), - ), - ) - .values(needs_review=False) - .execution_options(synchronize_session="fetch") - ) - - async def lock_cells( session: AsyncSession, meta_id: uuid.UUID, cells: Sequence[tuple[uuid.UUID, uuid.UUID]] ) -> dict[tuple[uuid.UUID, uuid.UUID], FinancialStatementValue]: diff --git a/api/src/schemas/statements.py b/api/src/schemas/statements.py index b0cb8a6..a53b520 100644 --- a/api/src/schemas/statements.py +++ b/api/src/schemas/statements.py @@ -14,11 +14,12 @@ from typing import Annotated, Literal from pydantic import BaseModel, ConfigDict, Field, StringConstraints, model_validator from src.domain.statement_aggregate import CodeAggregate -from src.domain.statement_checks import ChecksReport, FailedCheck +from src.domain.statement_checks import FailedCheck from src.domain.statement_layout import LayoutLine, order_lines from src.domain.statement_lines import LineCatalog, TemplateLine from src.models.enums import ( DocumentCheckResult, + DocumentIssueKind, DocumentStatus, DocumentType, PeriodQuarter, @@ -133,8 +134,8 @@ class FailedCheckOut(BaseModel): column_index: int relation: str - expected: Decimal - actual: Decimal + expected: Decimal | None # None — цель или все члены пусты (P1-21) + actual: Decimal | None codes: list[str] = [] line_ids: list[uuid.UUID] = [] @@ -196,6 +197,23 @@ def _failed(check: FailedCheck, aggregates: Mapping[str, CodeAggregate]) -> Fail ) +def _failed_issue(issue: DocumentIssue, column_index: Mapping[uuid.UUID, int]) -> FailedCheckOut: + """Нарушение §7 из записи `document_issue` (`relation` / `soft`): `codes` и `line_ids` — из записи.""" + index = issue.column_index + if issue.column_id is not None and issue.column_id in column_index: + index = column_index[issue.column_id] + codes = issue.codes if isinstance(issue.codes, list) else [] + line_ids = issue.line_ids if isinstance(issue.line_ids, list) else [] + return FailedCheckOut( + column_index=index if index is not None else -1, + relation=issue.relation or "", + expected=issue.expected, + actual=issue.actual, + codes=[str(item) for item in codes], + line_ids=[uuid.UUID(str(item)) for item in line_ids], + ) + + def _value_out(value: FinancialStatementValue, column_index: dict[uuid.UUID, int]) -> ValueOut: return ValueOut( id=value.id, @@ -345,7 +363,6 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], - report: ChecksReport, cross: FailedCheck | None, *, edition: str | None, @@ -358,6 +375,7 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч """Ответ экрана проверки: документ, шапка, колонки, строки со значениями и проверки; строки достроены по справочнику редакции (D-3).""" form = document.document_type.value + by_column = {column.id: column.column_index for column in columns} return StatementOut( document=StatementDocumentOut( id=document.id, @@ -408,8 +426,8 @@ def statement_out( # noqa: PLR0913, PLR0917 - сборка ответа из ч columns=[ColumnOut.model_validate(column) for column in columns], lines=_lines_out(columns, lines, template, parents, lambda code: catalog.reference_name(edition, form, code)), checks=ChecksOut( - failed=[_failed(check, aggregates) for check in report.failed], - soft_failed=[_failed(check, aggregates) for check in report.soft_failed], + failed=[_failed_issue(i, by_column) for i in issues if i.kind is DocumentIssueKind.relation], + soft_failed=[_failed_issue(i, by_column) for i in issues if i.kind is DocumentIssueKind.soft], cross_form=_failed(cross, aggregates) if cross is not None else None, accepted=_accepted(meta), ), diff --git a/api/src/services/statement_line_edits.py b/api/src/services/statement_line_edits.py index 7ef893f..5ba24ca 100644 --- a/api/src/services/statement_line_edits.py +++ b/api/src/services/statement_line_edits.py @@ -12,7 +12,6 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from src.common.exceptions import ConflictError, DomainError, DomainValidationError -from src.domain.statement_layout import LayoutLine, nearest_ok_parents from src.domain.statement_mapping import FULL_CONFIDENCE from src.models.enums import StatementLineStatus from src.models.files import Document @@ -102,18 +101,6 @@ def _apply_item(line: FinancialStatementLine, item: LinePatch) -> bool: return False -def _recompute_parents(lines: Sequence[FinancialStatementLine]) -> None: - """Родитель всех строк `detail` — ближайшая строка `ok` выше по `row_index` (spec «Пересчёт после правки»).""" - layout = [ - LayoutLine(line.id, line.row_index, line.line_status.value, line.line_code, line.parent_row_index) - for line in lines - ] - parents = nearest_ok_parents(layout) - for line in lines: - if line.line_status is StatementLineStatus.detail: - line.parent_row_index = parents.get(line.id) - - async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13: документ, шапка, элементы, пользователь, аудит session: AsyncSession, *, @@ -152,7 +139,6 @@ async def apply_line_edits( # noqa: PLR0913 - контракт задачи A13 lines = [line for line in lines if line.id != template.id] deleted.append(template.id) # T13.2: удаление пустой шаблонной строки — в аудит как `line: -> null` data_edited = any([_apply_item(by_id[item.line_id], item) for item in items]) - _recompute_parents(lines) await session.flush() for line in lines: now = _snapshot(line) diff --git a/api/src/services/statement_recompute.py b/api/src/services/statement_recompute.py new file mode 100644 index 0000000..e8752bd --- /dev/null +++ b/api/src/services/statement_recompute.py @@ -0,0 +1,275 @@ +"""Пересчёт формы ББ/ОФР после правки общим кодом `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`. +""" + +import re +import uuid +from collections.abc import Hashable, Mapping, Sequence +from decimal import Decimal + +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 IssueDraft +from src.domain.statement_lines import load_line_catalog +from src.domain.statement_status import ( + NameCheckLine, + duplicate_period_forms, + form_status_v2, + has_error_warning, + name_mismatches, +) +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, FinancialStatementLine, FinancialStatementMeta +from src.repositories import statements as repo +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", + "code_issues", + "name_issues", + "recompute_form", + "relations_to_issues", + "version_issue", +] + +RELATION_FAILED = "RELATION_FAILED" +RELATION_SOFT = "RELATION_SOFT" +CODE_NOT_IN_DICTIONARY = "CODE_NOT_IN_DICTIONARY" +CODE_NOT_IN_EDITION = "CODE_NOT_IN_EDITION" +NAME_MISMATCH = "NAME_MISMATCH" +STATEMENTS_VERSION_MISMATCH = "STATEMENTS_VERSION_MISMATCH" +# записи `core`, которые пересчёт заменяет целиком (P3-8); прочие (`MAPPING_WARNING`, `RESULT_STATUS_INCONSISTENT`, +# `LEGACY_WARNING`) — факт приёма, их пересчёт сохраняет +RECOMPUTED_CORE_CODES = (CODE_NOT_IN_DICTIONARY, CODE_NOT_IN_EDITION, NAME_MISMATCH, STATEMENTS_VERSION_MISMATCH) +_CHECKED_CODE = re.compile(r"\d{4}", re.ASCII) # R-1 (Р-40): проверяются только четырёхзначные коды +_EMPTY = "пусто" + + +def _amount_text(value: Decimal | None) -> str: + return _EMPTY if value is None else str(value) + + +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=( + f"не сошлось {failure.relation}: " + f"{_amount_text(failure.expected)} ≠ {_amount_text(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 + + +def _core_info(code: str, message: str, line_ids: tuple[uuid.UUID, ...] = ()) -> IssueDraft: + return IssueDraft( + kind=DocumentIssueKind.warning, + code=code, + severity=IssueSeverity.info, + message=message, + source=IssueSource.core, + line_ids=line_ids, + ) + + +def code_issues( + lines: Sequence[FinancialStatementLine], + *, + in_dictionary: Mapping[str, bool], + in_edition: Mapping[str, bool] | None, +) -> list[IssueDraft]: + """`CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` — по записи на код документа (`line_ids` — все строки `ok` с + этим кодом), только для кодов из ровно четырёх цифр (R-1, Р-40). `in_dictionary` — код есть в словаре имён редакции, + `in_edition` — код из перечня редакции и варианта (`None` — редакция или вариант не определены, не проверяется).""" + by_code: dict[str, list[FinancialStatementLine]] = {} + for line in lines: + if line.line_status is StatementLineStatus.ok and line.line_code and _CHECKED_CODE.fullmatch(line.line_code): + by_code.setdefault(line.line_code, []).append(line) + drafts: list[IssueDraft] = [] + for code in sorted(by_code): + ids = tuple( + line.id for line in sorted(by_code[code], key=lambda item: (item.row_index is None, item.row_index or 0)) + ) + if not in_dictionary.get(code, True): + drafts.append(_core_info(CODE_NOT_IN_DICTIONARY, f"кода {code} нет в словаре наименований редакции", ids)) + if in_edition is not None and not in_edition.get(code, True): + drafts.append(_core_info(CODE_NOT_IN_EDITION, f"код {code} не входит в перечень кодов редакции формы", ids)) + return drafts + + +def name_issues(lines: Sequence[FinancialStatementLine], mismatched: Sequence[Hashable]) -> list[IssueDraft]: + """`NAME_MISMATCH` — по записи на строку из `name_mismatches`.""" + by_id = {line.id: line for line in lines} + drafts: list[IssueDraft] = [] + for key in mismatched: + line = by_id[key] # type: ignore[index] + drafts.append( + _core_info( + NAME_MISMATCH, + f"наименование «{line.line_name or ''}» не совпало со справочным для кода {line.line_code}", + (line.id,), + ) + ) + return drafts + + +def version_issue(job_version: str | None, engine_version: str | None) -> IssueDraft | None: + """`STATEMENTS_VERSION_MISMATCH`: версия копии пакета у задания известна и не равна версии копии ядра.""" + if job_version is None or job_version == engine_version: + return None + return _core_info( + STATEMENTS_VERSION_MISMATCH, + f"форма распознана общим кодом версии {job_version}, в ядре версия {engine_version or 'не определена'}", + ) + + +def _name_lines(lines: Sequence[FinancialStatementLine]) -> list[NameCheckLine]: + return [ + NameCheckLine( + key=line.id, + line_status=line.line_status.value, + line_code=line.line_code, + line_code_confidence=line.line_code_confidence, + line_name=line.line_name, + code_fixed=bool(line.line_code_modified) or bool(line.is_confirmed), + ) + for line in lines + ] + + +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 + in_dictionary = { + line.line_code: catalog.reference_name(edition, form, line.line_code) is not None + for line in lines + if line.line_code is not None + } + in_edition: dict[str, bool] | None = None + if result.edition is not None and result.form_variant is not None: + order = set(engine.code_order(result.edition, stored.document_type, result.form_variant)) + in_edition = {line.line_code: line.line_code in order for line in lines if line.line_code is not None} + mismatched = name_mismatches(_name_lines(lines), catalog, form) + drafts = [ + *(code_issues(lines, in_dictionary=in_dictionary, in_edition=in_edition) if edition is not None else []), + *name_issues(lines, mismatched), + ] + 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=has_error_warning((job.metrics or {}).get("warnings") 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() diff --git a/api/src/services/statements.py b/api/src/services/statements.py index 8a2523d..c6fcd91 100644 --- a/api/src/services/statements.py +++ b/api/src/services/statements.py @@ -1,7 +1,7 @@ """Форма ББ/ОФР для экрана «Проверка распознавания» (spec documents-api; design D-5, D-7, D-8, D-14). Видимость — через ЮЛ-владельца (`files_repo.get_document`): чужой документ — тот же 404, что несуществующий. -Проверки §7 считаются на лету и не хранятся. +Нарушения §7 хранятся в `document_issue` и пересчитываются после каждой правки общим кодом `arm_statements`. """ import re @@ -18,32 +18,24 @@ from src.common.exceptions import ConflictError, DomainValidationError, NotFound from src.domain.osv_mapping import counterparty_check from src.domain.policy import VisibilityScope from src.domain.statement_aggregate import CodeAggregate, checks_input -from src.domain.statement_checks import ( - ChecksReport, - FailedCheck, - cross_form_check, - load_check_rules, - run_checks, - subline_parents, -) +from src.domain.statement_checks import FailedCheck, cross_form_check, subline_parents from src.domain.statement_issues import COLUMN_CLEARS, HEADER_CLEARS from src.domain.statement_lines import TemplateLine, load_line_catalog -from src.domain.statement_mapping import edition_for, quarter_of, statement_period, statement_status, value_clean +from src.domain.statement_mapping import edition_for, quarter_of, statement_period from src.models.directories import LegalEntity from src.models.enums import ( AuditAction, AuditSection, - DocumentCheckResult, DocumentIssueKind, DocumentStatus, DocumentType, FileStatus, IssueSource, - RecognitionStatus, StatementLineStatus, ) from src.models.files import Document, RecognitionJob from src.models.statements import ( + DocumentIssue, FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, @@ -61,6 +53,8 @@ from src.services.documents import ( PERIOD_REQUIRED, ) from src.services.statement_line_edits import apply_line_edits +from src.services.statement_recompute import recompute_form +from src.services.statements_engine import PeriodKind, StatementsEngine, default_engine DOCUMENT_NOT_FOUND = "документ не найден" # одинаково для чужого и несуществующего DOCUMENT_NOT_STATEMENT = "DOCUMENT_NOT_STATEMENT" @@ -92,14 +86,12 @@ async def form_aggregate( async def statement_hard_failures( session: AsyncSession, document: Document, meta: FinancialStatementMeta -) -> tuple[FailedCheck, ...]: - """Нарушенные жёсткие проверки §7 по текущим суммам формы; при акцепте — пустой кортеж (D-8 Среза 3b).""" +) -> tuple[DocumentIssue, ...]: + """Нарушенные жёсткие проверки §7 — записи `document_issue` вида `relation`; при акцепте — пустой кортеж (D-8 + Среза 3b). Форма без записей (до первого пересчёта) нарушений не показывает.""" if meta.checks_accepted_at is not None: return () - columns, aggregates = await form_aggregate(session, document, meta) - column_indexes, amounts = checks_input(columns, aggregates) - report = run_checks(document.document_type.value, amounts, column_indexes, load_check_rules()) - return tuple(report.failed) + return tuple(issue for issue in await repo.issues(session, document.id) if issue.kind is DocumentIssueKind.relation) @dataclass(frozen=True) @@ -147,8 +139,8 @@ async def form_blocker(session: AsyncSession, document: Document, meta: Financia { "column_index": failure.column_index, "relation": failure.relation, - "expected": str(failure.expected), - "actual": str(failure.actual), + "expected": None if failure.expected is None else str(failure.expected), + "actual": None if failure.actual is None else str(failure.actual), } for failure in failures ] @@ -171,19 +163,14 @@ class _Edit: self.data_edited = self.data_edited or data -def _value_needs_review(value: FinancialStatementValue, line: FinancialStatementLine) -> bool: - """Правило `needs_review` значения при приёме `1.x` и без пакета: правка/подтверждение и строка `detail` снимают - сомнение, иначе — по уверенности распознавания (`value_clean`).""" - if value.is_modified or value.is_confirmed or line.line_status is StatementLineStatus.detail: - return False - return not value_clean(value.confidence, value.is_recognized) - - class StatementService: - def __init__(self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope) -> None: + def __init__( + self, session: AsyncSession, audit: RequestAudit, scope: VisibilityScope, engine: StatementsEngine | None = None + ) -> None: self._session = session self._audit = audit self._scope = scope + self._engine = engine if engine is not None else default_engine() async def _visible_statement(self, document_id: uuid.UUID) -> tuple[Document, FinancialStatementMeta]: """Документ формы в пределах видимости: 404 — нет или чужой; 409 `DOCUMENT_NOT_STATEMENT` — не форма.""" @@ -216,23 +203,15 @@ class StatementService: return document, meta async def checks_report( - self, document: Document, meta: FinancialStatementMeta - ) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]: - """Проверки §7 по текущим суммам формы, мягкая межформенная проверка (D-5) и родители подстрок - (`parent_code` ответа; AURORA-1527). Всё считается по агрегату формы по коду (`form_aggregate`): родители — по - тем же кодам и в том же порядке, что и проверка «сумма подстрок = родитель» (ответ и проверка не расходятся).""" - columns, aggregates = await form_aggregate(self._session, document, meta) - return await self._report(document, columns, aggregates) - - async def _report( self, document: Document, columns: list[FinancialStatementColumn], aggregates: dict[str, CodeAggregate] - ) -> tuple[ChecksReport, FailedCheck | None, dict[str, str]]: - rules = load_check_rules() - column_indexes, amounts = checks_input(columns, aggregates) - report = run_checks(document.document_type.value, amounts, column_indexes, rules) + ) -> tuple[FailedCheck | None, dict[str, str]]: + """Мягкая межформенная проверка (D-5) и родители подстрок (`parent_code` ответа; AURORA-1527). Нарушения §7 + сюда не входят: ответ читает их из `document_issue`. Родители — по агрегату формы по коду, в том же порядке, + что и проверка «сумма подстрок = родитель» пакета.""" + _, amounts = checks_input(columns, aggregates) parents = subline_parents([code for code, _ in amounts]) balance_1370, income_2400 = await repo.pair_amounts(self._session, document) - return report, cross_form_check(balance_1370, income_2400, rules.tolerance), parents + return cross_form_check(balance_1370, income_2400, self._engine.tolerance()), parents async def get(self, document_id: uuid.UUID) -> schemas.StatementOut: document, meta = await self._visible_statement(document_id) @@ -243,7 +222,7 @@ class StatementService: ответами правки/акцепта). В БД ничего не пишет.""" columns, lines = await repo.load_form(self._session, meta.id) _, aggregates = await form_aggregate(self._session, document, meta) - report, cross, parents = await self._report(document, columns, aggregates) + cross, parents = await self.checks_report(document, columns, aggregates) edition = edition_for(document.reporting_date) catalog = load_line_catalog() return schemas.statement_out( @@ -251,7 +230,6 @@ class StatementService: meta, columns, lines, - report, cross, edition=edition, template=catalog.template_lines(edition, document.document_type.value), @@ -261,32 +239,17 @@ class StatementService: catalog=catalog, ) - async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[FailedCheck, ...]: + async def hard_failures(self, document: Document, meta: FinancialStatementMeta) -> tuple[DocumentIssue, ...]: """Нарушенные жёсткие проверки §7; при акцепте — пустой кортеж (используется и подтверждением формы).""" return await statement_hard_failures(self._session, document, meta) async def recompute(self, document: Document, meta: FinancialStatementMeta) -> None: - """Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6); `CONFIRMED` - не трогает. До части Б `values_ok` — нет `needs_review` у значений строк не `detail`; сигнатура остаётся и в Б. - - `warnings=()`: предупреждения приёма (`MAPPING_WARNING`) хранятся как `document_issue` уровня `info` и - определяют статус только при приёме (D-6); после правки аналитика статус строится по данным формы, - проверкам, контрагенту и периоду, а эти записи остаются видимыми в `issues` и статус не удерживают. - """ + """Единая точка пересчёта после правки или акцепта (spec arm-core «Пересчёт после правки»; D-6): общий код + `arm_statements`, запись результата — `recompute_form`. `CONFIRMED` не трогает.""" if document.status is DocumentStatus.CONFIRMED: return - await repo.settle_edited_values(self._session, meta.id) job = await self._session.get(RecognitionJob, meta.recognition_job_id) - document.status = statement_status( - job_done=job is not None and job.status is RecognitionStatus.done, - checks_passed=not await self.hard_failures(document, meta), - counterparty=document.counterparty_check or DocumentCheckResult.UNKNOWN, - period_known=document.period_year is not None and document.period_quarter is not None, - values_ok=not await repo.has_review_values(self._session, meta.id), - has_lines=await repo.has_source_lines(self._session, meta.id), - warnings=(), - ) - document.updated_at = utc_now() + await recompute_form(self._session, document=document, meta=meta, engine=self._engine, job=job) async def _apply_cells( self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch, edit: _Edit @@ -344,7 +307,6 @@ class StatementService: value.amount = amount value.is_modified = value.amount != value.original_amount value.modified_by, value.modified_at = self._audit.user_id, now - value.needs_review = _value_needs_review(value, line) def _apply_confirmation( self, @@ -361,7 +323,6 @@ class StatementService: edit.record(f"value:{value.id}:confirmed", value.is_confirmed, confirmed, data=False) value.is_confirmed = confirmed value.confirmed_by, value.confirmed_at = (self._audit.user_id, now) if confirmed else (None, None) - value.needs_review = _value_needs_review(value, line) async def _resolve_cells( self, document: Document, meta: FinancialStatementMeta, body: schemas.StatementPatch @@ -491,6 +452,21 @@ class StatementService: meta.units, meta.units_modified = header.units, True return date_changed + async def _resolve_relative_columns(self, meta: FinancialStatementMeta, edit: _Edit) -> bool: + """Правка `header.report_date` (в том числе `null`): `period_date` колонок с `period_kind` `relative:*` + пересчитывается от новой даты отчёта до пересчёта формы; ручные (`absolute`) даты не трогаются. `True` — + даты колонок изменились (нужен пересчёт `is_current` и периода).""" + changed = False + for column in (await repo.load_form(self._session, meta.id))[0]: + if column.period_kind is None or not str(column.period_kind).startswith("relative:"): + continue + resolved = self._engine.resolve_relative(PeriodKind(str(column.period_kind)), meta.report_date) + if resolved != column.period_date: + edit.record(f"column:{column.id}", column.period_date, resolved, data=False) + column.period_date = resolved + changed = True + return changed + async def _refresh_period(self, document: Document, meta: FinancialStatementMeta, *, columns_changed: bool) -> None: """Период документа — по §9.5 п. 2 (дата отчёта, иначе самая поздняя дата колонки); `is_current` колонок — колонка с самой поздней датой (меньший `column_index` при равенстве), как при приёме.""" @@ -530,6 +506,8 @@ class StatementService: date_changed = False if header is not None: date_changed = await self._apply_header(document, meta, header, edit) + if date_changed: + columns_changed = await self._resolve_relative_columns(meta, edit) or columns_changed if not edit.after: return await self.build_out(document, meta) if columns_changed or date_changed: diff --git a/api/src/services/statements_engine.py b/api/src/services/statements_engine.py index 74b8365..1c0e035 100644 --- a/api/src/services/statements_engine.py +++ b/api/src/services/statements_engine.py @@ -14,10 +14,12 @@ from arm_statements import ( DocStatus, DocumentType, Edition, + FailedRelation, FormVariant, PeriodKind, RecomputeResult, Rules, + StatementChecks, StoredForm, choose_edition, default_rules, @@ -28,7 +30,14 @@ from arm_statements import ( status_input_from_contract, ) -__all__ = ["ArmStatementsEngine", "StatementsEngine", "default_engine"] +__all__ = [ + "ArmStatementsEngine", + "FailedRelation", + "PeriodKind", + "StatementChecks", + "StatementsEngine", + "default_engine", +] class StatementsEngine(Protocol): diff --git a/api/tests/test_api_statements.py b/api/tests/test_api_statements.py index 1033560..5872c4f 100644 --- a/api/tests/test_api_statements.py +++ b/api/tests/test_api_statements.py @@ -10,9 +10,11 @@ from pathlib import Path from typing import Any import pytest +from fastapi import FastAPI from sqlalchemy import delete, select, update from sqlalchemy.ext.asyncio import AsyncSession +from src.api.dependencies.files import get_statements_engine from src.domain.statement_checks import CROSS_FORM_RELATION from src.models.access import AppUser from src.models.audit_ex import AuditEvent @@ -29,7 +31,7 @@ from src.models.enums import ( RecognitionStatus, StatementLineStatus, ) -from src.models.files import Document, StoredFile +from src.models.files import Document, RecognitionJob, StoredFile from src.models.statements import ( DocumentIssue, FinancialStatementColumn, @@ -37,6 +39,9 @@ from src.models.statements import ( FinancialStatementMeta, FinancialStatementValue, ) +from src.services.statement_recompute import recompute_form +from src.services.statements import form_blocker +from src.services.statements_engine import default_engine from tests.conftest import AuthClientFactory from tests.factories import ( make_company_group, @@ -45,6 +50,7 @@ from tests.factories import ( make_legal_entity, make_stored_file, ) +from tests.fake_engine import FakeEngine from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot pytestmark = [pytest.mark.integration, pytest.mark.fixtures] @@ -259,6 +265,7 @@ async def test_get_statement_parent_code_matches_subline_check( document = await form(seeded, fixtures_dir, UNAGRANDE_BALANCE) await set_amount(seeded, document, "1110", Decimal("999999"), current=True) await set_amount(seeded, document, "11101", Decimal("1"), current=True) # детализация есть — проверка работает + await recompute_now(seeded, document) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() kids = sorted(line["line_code"] for line in body["lines"] if line["parent_code"] == "1110") @@ -303,6 +310,15 @@ async def _meta(session: AsyncSession, document: Document) -> FinancialStatement ).scalar_one() +async def recompute_now(session: AsyncSession, document: Document) -> None: + """Пересчёт формы общим кодом, как после первой правки: §7 хранится в `document_issue`, а не считается на лету. + До приёма B3 у загруженных маппером форм записей нарушений нет — тест, меняющий суммы в БД напрямую, зовёт это.""" + meta = await _meta(session, document) + job = await session.get(RecognitionJob, meta.recognition_job_id) + await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job) + await session.flush() + + async def drop_line(session: AsyncSession, document: Document, line_code: str) -> dict[int, Decimal | None]: """Удалить строку формы в БД теста (как будто источник её потерял); вернуть её суммы по `column_index`.""" meta = await _meta(session, document) @@ -373,6 +389,7 @@ async def test_get_completes_lost_line_without_writing( """Строка, потерянная источником (1700), достроена из справочника; статус и проверки — как без достройки.""" document = await form(seeded, fixtures_dir) await drop_line(seeded, document, "1700") + await recompute_now(seeded, document) lines_in_db = await db_line_count(seeded, document) client = await auth_client(KA_RF) body = (await client.get(f"/documents/{document.id}/statement")).json() @@ -391,8 +408,10 @@ async def test_get_completes_lost_line_without_writing( ) assert [v["column_index"] for v in line["values"]] == sorted(c["column_index"] for c in body["columns"]) relations = {(f["relation"], f["column_index"]) for f in body["checks"]["failed"]} - # цель 1700 не проверяется; «1600 = 1700» (цель 1600 есть, 1700 = 0) нарушено — как и без достройки - assert not any(relation.startswith("1700 =") for relation, _ in relations) + # пакет (P1-21): посчитанное соотношение с пустой целью — нарушение с `expected = null`; «1600 = 1700» (цель 1600 + # есть, 1700 пуста) нарушено — как и без достройки + lost_target = [f for f in body["checks"]["failed"] if f["relation"].startswith("1700 =")] + assert lost_target and all(f["expected"] is None for f in lost_target) assert {index for relation, index in relations if relation == "1600 = 1700"} == {0, 1, 2} assert body["document"]["status"] == document.status.value # статус пересчитывается только правкой assert await db_line_count(seeded, document) == lines_in_db @@ -456,6 +475,8 @@ async def test_cross_form_violation_is_reported_on_both_forms( await set_amount(seeded, balance, "1370", Decimal("500"), current=True) await set_amount(seeded, balance, "1370", Decimal("300"), period_date=datetime.date(2024, 12, 31)) await set_amount(seeded, income, "2400", Decimal("150"), current=True) + await recompute_now(seeded, balance) + await recompute_now(seeded, income) client = await auth_client(KA_RF) for document in (balance, income): body = (await client.get(f"/documents/{document.id}/statement")).json() @@ -1573,7 +1594,10 @@ async def test_template_line_checks_follow_filled_amounts( ) assert response.status_code == HTTPStatus.OK, response.text after_clear = response.json()["checks"]["failed"] - assert not any(f["relation"].startswith("1700 =") for f in after_clear) # очищенная 1700 снова не цель + # пакет (P1-21): пустая цель при непустых членах — нарушение с `expected = null` (прежнее «очищенная не цель» + # не действует): 1700 пуста во всех колонках — нарушение в каждой + emptied = [f for f in after_clear if f["relation"] == relation] + assert sorted((f["column_index"], f["expected"]) for f in emptied) == [(0, None), (1, None), (2, None)] assert {f["column_index"] for f in after_clear if f["relation"] == "1600 = 1700"} == {0, 1, 2} @@ -1849,6 +1873,7 @@ async def test_failed_check_has_codes_and_line_ids( ) -> None: document = await form(seeded, fixtures_dir) await set_amount(seeded, document, "1600", Decimal("1"), current=True) + await recompute_now(seeded, document) body = await _statement(await auth_client(KA_RF), document) failed = body["checks"]["failed"] assert failed, body["checks"] @@ -2046,12 +2071,36 @@ async def test_unconfirm_cell(seeded: AsyncSession, fixtures_dir: Path, auth_cli ) assert response.status_code == HTTPStatus.OK, response.text edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] - assert edited["is_confirmed"] is False and edited["needs_review"] is True + # ячейка покрыта сошедшимся соотношением — пакет считает её чистой и после снятия подтверждения + assert edited["is_confirmed"] is False and edited["needs_review"] is False value = await _db_value(seeded, line["id"], cell["column_id"]) assert value.confirmed_by is None and value.confirmed_at is None assert len(await _edit_events(seeded, document)) == 1 +async def test_unconfirm_uncovered_cell( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Сценарий «Снятие подтверждения»: ячейка вне соотношений после снятия подтверждения — `needs_review`.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + db_line = await seeded.get(FinancialStatementLine, uuid.UUID(line["id"])) + assert db_line is not None + db_line.line_code = "1999" # кода нет ни в одном соотношении + value = await _db_value(seeded, line["id"], cell["column_id"]) + value.confidence, value.is_confirmed, value.needs_review = Decimal(80), True, False + value.confirmed_by, value.confirmed_at = await _user_id(seeded), NOW + await seeded.flush() + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line["id"], "column_id": cell["column_id"], "confirmed": False}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + edited = next(item for item in response.json()["lines"] if item["id"] == line["id"])["values"][0] + assert edited["is_confirmed"] is False and edited["needs_review"] is True + + async def test_cell_amount_and_confirmed_is_422( seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory ) -> None: @@ -2308,3 +2357,168 @@ async def test_segment_warning_stays_after_value_edit( ).status_code == HTTPStatus.OK confirm = await client.post(f"/documents/{document.id}/confirm") assert confirm.status_code == HTTPStatus.OK, confirm.text + + +# --- B2: пересчёт общим кодом, §7 из document_issue -------------------------------------------------------------- + + +async def _issues(session: AsyncSession, document: Document) -> list[DocumentIssue]: + stmt = ( + select(DocumentIssue).where(DocumentIssue.document_id == document.id).execution_options(populate_existing=True) + ) + return list((await session.execute(stmt)).scalars()) + + +async def _cell(client: Any, document: Document, code: str, column_index: int) -> tuple[str, str, str | None]: + """`(line_id, column_id, amount)` ячейки строки `ok` с кодом `code` в колонке `column_index`.""" + body = await _statement(client, document) + line = next(item for item in body["lines"] if item["line_code"] == code and item["id"] is not None) + value = next(v for v in line["values"] if v["column_index"] == column_index) + return line["id"], value["column_id"], value["amount"] + + +async def test_1x_form_after_patch_recomputed( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Сценарий «1.x-документ после PATCH»: запись `relation` по 1200 заменена, статус пересчитан, версии нет.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line_id, column_id, original = await _cell(client, document, "1230", 0) + assert original is not None + await set_amount(seeded, document, "1230", Decimal(original) + 1000, current=True) + await recompute_now(seeded, document) # до B3 запись приёма вставляется пересчётом + broken = [i for i in await _issues(seeded, document) if (i.relation or "").startswith("1200 =")] + assert broken and all(i.kind is DocumentIssueKind.relation and i.source is IssueSource.core for i in broken) + response = await client.patch( + f"/documents/{document.id}/statement", + json={"values": [{"line_id": line_id, "column_id": column_id, "amount": original}]}, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert not [i for i in await _issues(seeded, document) if (i.relation or "").startswith("1200 =")] + assert not any(f["relation"].startswith("1200 =") for f in response.json()["checks"]["failed"]) + assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document) + + +async def test_migrated_form_shows_violation_after_first_patch( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """Сценарий «Миграция на непустой базе»: форма без записей §7 нарушений не показывает и не блокирует + подтверждение; после первой правки появляется `RELATION_FAILED`.""" + document = await form(seeded, fixtures_dir) + await set_amount(seeded, document, "1600", Decimal("1"), current=True) # 1600 ≠ 1700, записей нет + client = await auth_client(KA_RF) + assert (await _statement(client, document))["checks"]["failed"] == [] + assert await form_blocker(seeded, document, await _meta(seeded, document)) is None # CHECKS_NOT_PASSED нет + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "млн руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + found = [i for i in await _issues(seeded, document) if i.code == "RELATION_FAILED" and i.codes == ["1600", "1700"]] + assert found and all(i.line_ids for i in found) + assert response.json()["checks"]["failed"] + + +async def test_statements_version_mismatch( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + meta = await _meta(seeded, document) + job = await seeded.get(RecognitionJob, meta.recognition_job_id) + assert job is not None + client = await auth_client(KA_RF) + job.statements_version = "abc1234" + await seeded.flush() + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + mismatch = [i for i in await _issues(seeded, document) if i.code == "STATEMENTS_VERSION_MISMATCH"] + assert len(mismatch) == 1 + assert mismatch[0].severity is IssueSeverity.info and mismatch[0].source is IssueSource.core + job.statements_version = None # у задания 1.x версии нет — запись не пишется (и прежняя заменяется) + await seeded.flush() + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "тыс. руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document) + job.statements_version = default_engine().version # версия совпала — тоже без записи + await seeded.flush() + response = await client.patch(f"/documents/{document.id}/statement", json={"header": {"units": "млн руб."}}) + assert response.status_code == HTTPStatus.OK, response.text + assert "STATEMENTS_VERSION_MISMATCH" not in await _issue_codes(seeded, document) + + +async def test_relative_column_follows_report_date( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + document = await form(seeded, fixtures_dir) + columns = await _db_columns(seeded, document) + relative, manual = columns[1], columns[2] + relative.period_kind = manual.period_kind = "relative:prev_year_end" + await seeded.flush() + client = await auth_client(KA_RF) + url = f"/documents/{document.id}/statement" + assert (await client.patch(url, json={"header": {"report_date": "2026-03-31"}})).status_code == HTTPStatus.OK + assert (await _db_columns(seeded, document))[1].period_date == datetime.date(2025, 12, 31) + # ручная дата делает колонку absolute — дальше дата отчёта её не двигает (A12) + response = await client.patch(url, json={"columns": [{"column_id": str(manual.id), "period_date": "2024-06-30"}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert (await client.patch(url, json={"header": {"report_date": "2025-09-30"}})).status_code == HTTPStatus.OK + fresh = await _db_columns(seeded, document) + assert fresh[1].period_date == datetime.date(2024, 12, 31) and fresh[2].period_date == datetime.date(2024, 6, 30) + assert (await client.patch(url, json={"header": {"report_date": None}})).status_code == HTTPStatus.OK + fresh = await _db_columns(seeded, document) + assert fresh[1].period_date is None and fresh[2].period_date == datetime.date(2024, 6, 30) + + +async def test_checks_from_issues_with_codes_and_line_ids( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """`checks.failed`, `NOTHING_TO_ACCEPT` и `CHECKS_NOT_PASSED` читают запись `document_issue`, а не сумм формы.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + nothing = await client.post(f"/documents/{document.id}/statement/checks/accept", json={"comment": "x"}) + assert nothing.status_code == HTTPStatus.CONFLICT and nothing.json()["detail"]["code"] == "NOTHING_TO_ACCEPT" + line_1600, column_id, _ = await _cell(client, document, "1600", 0) + seeded.add( + DocumentIssue( + document_id=document.id, + kind=DocumentIssueKind.relation, + code="RELATION_FAILED", + severity=IssueSeverity.error, + message="не сошлось 1600 = 1700: пусто ≠ 5", + line_ids=[line_1600], + column_id=uuid.UUID(column_id), + codes=["1600", "1700"], + source=IssueSource.core, + relation="1600 = 1700", + expected=None, + actual=Decimal(5), + created_at=NOW, + ) + ) + await seeded.flush() + (failed,) = (await _statement(client, document))["checks"]["failed"] + assert failed["codes"] == ["1600", "1700"] and failed["line_ids"] == [line_1600] + assert failed["relation"] == "1600 = 1700" and failed["expected"] is None and failed["actual"] == "5.00" + blocked = await client.post(f"/documents/{document.id}/confirm") + assert blocked.status_code == HTTPStatus.CONFLICT and blocked.json()["detail"]["code"] == "CHECKS_NOT_PASSED" + assert blocked.json()["detail"]["failures"][0]["expected"] is None + + +async def test_one_patch_one_recompute( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory, app: FastAPI +) -> None: + """`PATCH` из трёх частей: один вызов пересчёта пакета и одно событие аудита.""" + fake = FakeEngine() + app.dependency_overrides[get_statements_engine] = lambda: fake + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + line, cell = await _first_cell(client, document.id) + line_id, column_id, amount = line["id"], cell["column_id"], cell["amount"] + response = await client.patch( + f"/documents/{document.id}/statement", + json={ + "lines": [{"line_id": line_id, "confirmed": True}], + "values": [{"line_id": line_id, "column_id": column_id, "amount": str(Decimal(amount) + 1)}], + "header": {"units": "руб."}, + }, + ) + assert response.status_code == HTTPStatus.OK, response.text + assert len(fake.forms) == 1 + assert len(await _edit_events(seeded, document)) == 1 diff --git a/api/tests/test_domain_statement_layout.py b/api/tests/test_domain_statement_layout.py index 6e824d9..0cac669 100644 --- a/api/tests/test_domain_statement_layout.py +++ b/api/tests/test_domain_statement_layout.py @@ -1,6 +1,6 @@ """Порядок строк ответа `GET …/statement` (P3-17): строки `ok` по коду, вложения источника — за своей строкой.""" -from src.domain.statement_layout import LayoutLine, nearest_ok_parents, order_lines +from src.domain.statement_layout import LayoutLine, order_lines def L( # noqa: N802 - короткая фабрика строки для таблиц тестов @@ -44,14 +44,3 @@ def test_attachments_keep_row_order() -> None: def test_empty() -> None: assert order_lines([]) == [] - - -def test_nearest_ok_parents() -> None: - lines = [ - L("a", 0, "ok", "1100", None), - L("b", 1, "detail", None, None), - L("t", None, "ok", "1250", None), - L("c", 3, "detail", None, None), - ] - assert nearest_ok_parents(lines) == {"b": 0, "c": 0} - assert nearest_ok_parents([L("d", 1, "detail", None, None), L("u", 2, "unreadable", None, None)]) == {"d": None} diff --git a/api/tests/test_form_confirmable.py b/api/tests/test_form_confirmable.py index d5df08e..a37de6a 100644 --- a/api/tests/test_form_confirmable.py +++ b/api/tests/test_form_confirmable.py @@ -10,14 +10,16 @@ from sqlalchemy.ext.asyncio import AsyncSession from src.models.directories import LegalEntity from src.models.enums import DocumentStatus, DocumentType, StatementLineStatus -from src.models.files import Document +from src.models.files import Document, RecognitionJob from src.models.statements import ( FinancialStatementColumn, FinancialStatementLine, FinancialStatementMeta, FinancialStatementValue, ) +from src.services.statement_recompute import recompute_form from src.services.statements import form_blocker, statement_hard_failures +from src.services.statements_engine import default_engine from tests.factories import make_legal_entity from tests.fixtures_helpers import GOLDEN_STATEMENTS, map_golden_snapshot @@ -89,6 +91,13 @@ async def _confirmed_twin(session: AsyncSession, document: Document) -> Document return twin +async def _recompute(session: AsyncSession, document: Document, meta: FinancialStatementMeta) -> None: + """§7 хранится в `document_issue`: после прямой правки сумм в БД теста нарушения появляются пересчётом.""" + job = await session.get(RecognitionJob, meta.recognition_job_id) + await recompute_form(session, document=document, meta=meta, engine=default_engine(), job=job) + await session.flush() + + async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> None: values = ( await session.execute( @@ -100,6 +109,9 @@ async def _break_2200(session: AsyncSession, meta: FinancialStatementMeta) -> No for value in values: value.amount = (value.amount or 0) + 1000 await session.flush() + document = await session.get(Document, meta.document_id) + assert document is not None + await _recompute(session, document, meta) async def test_clean_form_is_confirmable(seeded: AsyncSession, fixtures_dir: Path) -> None: @@ -134,6 +146,7 @@ async def test_form_with_hard_failure_is_not_confirmable_until_accepted( for value in values: value.amount = (value.amount or 0) + 1000 await seeded.flush() + await _recompute(seeded, income, meta) assert await statement_hard_failures(seeded, income, meta) != () blocker = await form_blocker(seeded, income, meta) diff --git a/api/tests/test_statement_recompute.py b/api/tests/test_statement_recompute.py new file mode 100644 index 0000000..a58a1a4 --- /dev/null +++ b/api/tests/test_statement_recompute.py @@ -0,0 +1,183 @@ +"""Пересчёт формы после правки общим кодом (часть Б, B2): чистые преобразования и запись результата в БД +(spec arm-core «Пересчёт после правки», P3-8, R-1).""" + +import uuid +from decimal import Decimal +from http import HTTPStatus +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from src.domain.statement_aggregate import CodeAggregate +from src.models.enums import DocumentIssueKind, IssueSeverity, IssueSource, StatementLineStatus +from src.models.statements import FinancialStatementColumn, FinancialStatementLine +from src.services.statement_recompute import code_issues, name_issues, relations_to_issues, version_issue +from src.services.statements_engine import FailedRelation, StatementChecks +from tests.conftest import AuthClientFactory +from tests.test_api_statements import ( + KA_RF, + _db_value, + _issues, + _statement, + form, + recompute_now, +) + + +def _failure(key: str, relation: str, codes: tuple[str, ...], expected: Decimal | None, actual: Decimal | None) -> Any: + return FailedRelation( + column_index=1, column_key=key, relation=relation, codes=codes, expected=expected, actual=actual + ) + + +def _aggregate(code: str, *line_ids: uuid.UUID) -> CodeAggregate: + return CodeAggregate(code=code, amounts={}, line_ids=line_ids, reference_name=None, has_source_line=True) + + +def test_relations_to_issues_kinds_columns_and_empty_amounts() -> None: + column = FinancialStatementColumn(id=uuid.uuid4(), column_index=1) + line_a, line_b, line_c = uuid.uuid4(), uuid.uuid4(), uuid.uuid4() + checks = StatementChecks( + relations_failed=( + _failure(str(column.id), "1600 = 1700", ("1600", "1700"), None, Decimal(5)), + _failure("1", "1100 = 1110", ("1100", "1110", "9999"), Decimal(2), Decimal(3)), # ключ — индекс (`2.0`) + ), + soft_relations_failed=(_failure("77", "1110 = 11101", ("1110", "11101"), Decimal(1), None),), + ) + aggregates = { + "1600": _aggregate("1600", line_a), + "1700": _aggregate("1700", line_b, line_c), + "1100": _aggregate("1100", line_a), + } + hard_empty, hard_by_index, soft = relations_to_issues( + checks, columns=[column], aggregates=aggregates, source=IssueSource.core + ) + assert (hard_empty.kind, hard_empty.code, hard_empty.severity) == ( + DocumentIssueKind.relation, + "RELATION_FAILED", + IssueSeverity.error, + ) + assert hard_empty.column_id == column.id and hard_empty.line_ids == (line_a, line_b, line_c) + assert hard_empty.expected is None and hard_empty.actual == Decimal(5) + assert hard_empty.message == "не сошлось 1600 = 1700: пусто ≠ 5" + assert hard_by_index.column_id == column.id # ключ `1` — индекс колонки + assert hard_by_index.line_ids == (line_a,) # кода нет в форме — вклада нет + assert (soft.kind, soft.code, soft.severity) == (DocumentIssueKind.soft, "RELATION_SOFT", IssueSeverity.info) + assert soft.column_id is None and soft.column_index == 1 and soft.message.endswith("1 ≠ пусто") + + +def _line( + code: str | None, *, row: int, status: StatementLineStatus = StatementLineStatus.ok +) -> FinancialStatementLine: + return FinancialStatementLine( + id=uuid.uuid4(), + meta_id=uuid.uuid4(), + line_code=code, + row_index=row, + line_status=StatementLineStatus.ok, + line_name=code, + ) + + +def test_code_issues_only_four_digit_codes_one_record_per_code() -> None: + """R-1 (Р-40): пятизначные коды не проверяются; одна запись на код, `line_ids` — все строки `ok` кода.""" + first, second, five, detail = _line("1999", row=1), _line("1999", row=2), _line("19999", row=3), _line(None, row=4) + drafts = code_issues( + [second, first, five, detail], + in_dictionary={"1999": False, "19999": False}, + in_edition={"1999": False, "19999": False}, + ) + assert [(d.code, d.severity, d.source) for d in drafts] == [ + ("CODE_NOT_IN_DICTIONARY", IssueSeverity.info, IssueSource.core), + ("CODE_NOT_IN_EDITION", IssueSeverity.info, IssueSource.core), + ] + assert all(d.line_ids == (first.id, second.id) for d in drafts) + assert code_issues([first], in_dictionary={"1999": False}, in_edition=None)[0].code == "CODE_NOT_IN_DICTIONARY" + + +def test_name_and_version_issues() -> None: + line = _line("1120", row=1) + (draft,) = name_issues([line], [line.id]) + assert draft.code == "NAME_MISMATCH" and draft.line_ids == (line.id,) and draft.source is IssueSource.core + assert version_issue(None, "def5678") is None and version_issue("def5678", "def5678") is None + mismatch = version_issue("abc1234", "def5678") + assert mismatch is not None and mismatch.code == "STATEMENTS_VERSION_MISMATCH" + assert mismatch.severity is IssueSeverity.info and mismatch.source is IssueSource.core + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_code_change_recomputes_core_issues( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """P3-8 (T13.4): после смены кода `CODE_NOT_IN_*` пересчитываются — только четырёхзначные (R-1).""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + body = await _statement(client, document) + ok_lines = [line for line in body["lines"] if line["id"] is not None and line["line_status"] == "ok"] + first, second = ok_lines[0], ok_lines[1] + url = f"/documents/{document.id}/statement" + response = await client.patch( + url, + json={ + "lines": [{"line_id": first["id"], "line_code": "1999"}, {"line_id": second["id"], "line_code": "19999"}] + }, + ) + assert response.status_code == HTTPStatus.OK, response.text + issues = await _issues(seeded, document) + by_code = {i.code: i for i in issues if i.code.startswith("CODE_NOT_IN_")} + assert set(by_code) == {"CODE_NOT_IN_DICTIONARY", "CODE_NOT_IN_EDITION"} + assert all(i.line_ids == [first["id"]] for i in by_code.values()) # пятизначный 19999 не проверяется + response = await client.patch(url, json={"lines": [{"line_id": first["id"], "line_code": first["line_code"]}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert not [i for i in await _issues(seeded, document) if i.code.startswith("CODE_NOT_IN_")] + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_name_mismatch_follows_code_confirmation( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """P3-8: `NAME_MISMATCH` пересчитывается; подтверждённому (`code_fixed`) коду не ставится.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + body = await _statement(client, document) + target = next(line for line in body["lines"] if line["line_code"] == "1120" and line["id"] is not None) + line = await seeded.get(FinancialStatementLine, uuid.UUID(target["id"])) + assert line is not None + line.line_code_confidence, line.line_name = Decimal(50), "Нематериальные активы" + await seeded.flush() + await recompute_now(seeded, document) + mismatch = [i for i in await _issues(seeded, document) if i.code == "NAME_MISMATCH"] + assert [i.line_ids for i in mismatch] == [[target["id"]]] + response = await client.patch( + f"/documents/{document.id}/statement", json={"lines": [{"line_id": target["id"], "confirmed": True}]} + ) + assert response.status_code == HTTPStatus.OK, response.text + assert not [i for i in await _issues(seeded, document) if i.code == "NAME_MISMATCH"] + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_values_of_line_leaving_detail_are_recomputed( + seeded: AsyncSession, fixtures_dir: Path, auth_client: AuthClientFactory +) -> None: + """T13.3: `needs_review` значений строки, ушедшей в `detail`, не залипает на `false` — после назначения кода его + даёт пересчёт пакета.""" + document = await form(seeded, fixtures_dir) + client = await auth_client(KA_RF) + body = await _statement(client, document) + target = next(line for line in body["lines"] if line["id"] is not None and line["values"][0]["amount"] is not None) + column_id = target["values"][0]["column_id"] + value = await _db_value(seeded, target["id"], column_id) + value.confidence = Decimal(80) + await seeded.flush() + url = f"/documents/{document.id}/statement" + response = await client.patch(url, json={"lines": [{"line_id": target["id"], "line_status": "detail"}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert (await _db_value(seeded, target["id"], column_id)).needs_review is False + response = await client.patch(url, json={"lines": [{"line_id": target["id"], "line_code": "1999"}]}) + assert response.status_code == HTTPStatus.OK, response.text + assert (await _db_value(seeded, target["id"], column_id)).needs_review is True # код вне соотношений — не покрыта diff --git a/openspec/changes/unified-normalization-core-b/design.md b/openspec/changes/unified-normalization-core-b/design.md index 01ff4b4..75eb2ba 100644 --- a/openspec/changes/unified-normalization-core-b/design.md +++ b/openspec/changes/unified-normalization-core-b/design.md @@ -36,3 +36,11 @@ Goals и Non-goals — см. `proposal.md`. | 2026-10-18 | **P3-25 (B1).** Тип формы во входе пересчёта — `document.document_type` (`BALANCE` / `INCOME` / `DISCLOSURE`), а не `meta.statement_type` (только `balance` / `income`); план писал «тип формы» без источника | `DISCLOSURE` не представим в `statement_type`; тип документа — единственный источник для всех трёх типов | форма `DISCLOSURE` собралась бы с ошибкой `KeyError` / потерей типа; правка — одна строка в `build_stored_form` | | 2026-10-18 | **P3-26 (B1, O-3 часть А).** Копия `statement-code-orders.yaml` в `api/contracts/` не заводится: перечень кодов читает пакет из своей байтовой копии `api/arm_statements/rules/statement_code_orders.yaml` (`loader._ORDERS`), ядро файла не открывает; идентичность копии пакета с `docs/contracts/statement-code-orders.yaml` проверяет `sync-statements --check`; тест идентичности контракта сверяет только `*.json`. Сейчас копия пакета побайтно равна файлу docs | второй экземпляр файла в ядре никто бы не читал и он мог бы разойтись с пакетом | нет; если sync-statements когда-либо перестанет сверять YAML — добавить сверку в `contract_identity.py` | | 2026-10-18 | **P3-27 (B1).** `edition_codes` берёт строки `ok` результата `2.0`, сортирует по `row_index` и помечает код уверенным при `line_code_confidence == 100` (ровно), `None` — неуверенный | как в разделе «Что ядро вызывает» и в `recompute` пакета (код правленый/подтверждённый там — `code_fixed`) | редакция приёма разойдётся с редакцией пересчёта; сверку делает `test_edition_codes_and_engine_edition` | +| 2026-10-18 | **P3-28 (B2).** `StatementService` получает `StatementsEngine` через зависимость `get_statements_engine` (тесты подменяют её `FakeEngine`); `FailedRelation`, `StatementChecks`, `PeriodKind` реэкспортируются из `services/statements_engine.py`, чтобы `import arm_statements` оставался только в двух файлах | единственная точка импорта пакета (common.md); подмена без патчей | нет | +| 2026-10-18 | **P3-29 (B2).** Статус после правки — `form_status_v2`: `service_done = (result.status == DONE)`, `file_error_warning` — `metrics.warnings` задания, `names_ok` — нет `NAME_MISMATCH` пересчёта, `duplicate_in_file` — `duplicate_period_forms` по формам файла (`file_form_periods`); `statement_status` D-6 с пути правки убран | один источник статуса с приёмом `2.0` (Р-16, Р-18, Р-19) | форма с повтором периода в файле после правки остаётся `NEEDS_CHECK` — как при приёме | +| 2026-10-18 | **P3-30 (B2).** Пустая цель при непустых членах — нарушение с `expected = NULL` (P1-21); тесты части А «очищенная 1700 не цель» и «цель потерянной строки не проверяется» переписаны под это; потерявшая строку 1700 форма блокирует подтверждение до правки или акцепта | следствие P1-21 пакета; спека («посчитанное соотношение») | форма, потерявшая итоговую строку, теперь требует действия аналитика — если владелец против, правится правило пакета (план 1), не ядро | +| 2026-10-18 | **P3-31 (B2).** `NOTHING_TO_ACCEPT` и `CHECKS_NOT_PASSED` читают записи `relation`; акцептованная форма не считается нарушенной (`statement_hard_failures` пуст при `checks_accepted_at`), поэтому повторный акцепт — 409 `NOTHING_TO_ACCEPT`, как в части А | сохранение поведения А; буква брифа («нет записей `relation`») допускает оба прочтения | повторный акцепт с новым комментарием станет возможен, если снять условие | +| 2026-10-18 | **P3-32 (B2).** «Эффективная дата» для `relative:*` колонок при правке `header.report_date` — новое значение `meta.report_date` (в том числе `NULL` ⇒ `period_date = NULL`), не `document.reporting_date`; изменение дат колонок идёт в аудит ключом `column:` без признака правки данных | период документа сам выводится из даты отчёта и дат колонок (§9.5 п. 2), поэтому циклическая зависимость недопустима | колонка при пустой дате отчёта теряет дату; аналитик ставит её вручную | +| 2026-10-18 | **P3-33 (B2).** Запись `relation` / `soft` несёт и `column_id` (колонка найдена по `column_key`: `str(column_id)`, затем `str(column_index)`), и `column_index` нарушения; ответ берёт индекс из колонки, иначе из записи, иначе `-1` | ключ колонки B4 для `2.0` — индекс | нет | +| 2026-10-18 | **P3-34 (B2).** `NAME_MISMATCH` пересчитывается для всех кодов (R-1 ограничивает только `CODE_NOT_IN_*`); `CODE_NOT_IN_*` не пишутся, если пакет не определил редакцию (а `CODE_NOT_IN_EDITION` — и вариант); словарь имён берётся по редакции пакета, не по дате | R-1 и спека (редакция документа — `edition` пакета) | пятизначная подстрока с уверенностью < 100 даёт `NAME_MISMATCH` (кода нет в словаре) — если поток окажется шумным, ограничить `name_mismatches` четырёхзначными | +| 2026-10-18 | **P3-35 (B2).** Временный код A12/A13 удалён: тело `StatementService.recompute`, `settle_edited_values`, `has_review_values`, `has_source_lines`, `_value_needs_review`, `nearest_ok_parents` и `_recompute_parents`; `parent_row_index` `detail` пишет только пересчёт пакета | задание B2; T13.3 (флаги detail больше не затираются навсегда) | нет |