From 0b7d6917359f645d8075d74f4b7c776c72a59291 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 18 Oct 2026 12:16:21 +0400 Subject: [PATCH] =?UTF-8?q?openspec(#116):=20=D0=BF=D0=BB=D0=B0=D0=BD=203?= =?UTF-8?q?=20=E2=80=94=20=D0=B2=D1=8B=D0=B7=D0=BE=D0=B2=D1=8B=20=D0=BF?= =?UTF-8?q?=D0=B0=D0=BA=D0=B5=D1=82=D0=B0=20=D0=BF=D1=80=D0=B8=D0=B2=D0=B5?= =?UTF-8?q?=D0=B4=D0=B5=D0=BD=D1=8B=20=D0=BA=20=D0=BF=D1=83=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=87=D0=BD=D0=BE=D0=BC=D1=83=20API=20=D0=BF=D0=BB=D0=B0?= =?UTF-8?q?=D0=BD=D0=B0=201=20(O-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W --- .../plans/2026-10-18-plan-3-arm-core.md | 167 +++++++++++------- 1 file changed, 102 insertions(+), 65 deletions(-) diff --git a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md index fa2d46f..18dc82a 100644 --- a/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md +++ b/openspec/changes/unified-statement-normalization/plans/2026-10-18-plan-3-arm-core.md @@ -37,7 +37,7 @@ mypy; `tea` (Gitea), OpenSpec, codegraph. | План | Что | Связь с этим планом | |---|---|---| | 0 | тест идентичности копий контракта по MAJOR (arm-core, tabular-parser, scan-gateway) | слит в arm-core **до** задачи A5 | -| 1 | пакет `arm_statements` (новый репозиторий) | его раздел «Публичный API пакета» сверяется с интерфейсом-заглушкой ниже до старта части Б | +| 1 | пакет `arm_statements` (новый репозиторий) | ядро вызывает только имена раздела «Публичный API пакета» плана 1 (сверено, раздел «Что ядро вызывает» ниже) | | 2 | `sync-statements`: копия пакета в `api/arm_statements/`, `statements-check` в `make check-full` | PR плана 2 в arm-core слит **до** части Б | | docs PR #117 | контракт v2.0, DBML v5.19, правила | слит **до** слияния PR части А (dbml-check, тест идентичности против `docs` main) | | 6 | arm-web: экран по новым полям, затем `sync-web` в `arm-core/web` | выкладывается вместе с ядром: `FileOut.job.warnings` меняет тип | @@ -56,7 +56,7 @@ mypy; `tea` (Gitea), OpenSpec, codegraph. | Worktree arm-core | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-a/arm-core` | `/Users/andrey.epifancev/projects/awp-ca/.wt/normalization-core-b/arm-core` | | Change OpenSpec arm-core | `openspec/changes/unified-normalization-core-a/` | `openspec/changes/unified-normalization-core-b/` | | Задачи | A0–A13 (14) | B0–B6 (7) | -| Старт | сразу (A5 — после плана 0) | после слияния PR части А и PR плана 2 в arm-core, после сверки имён пакета (O-8) | +| Старт | сразу (A5 — после плана 0) | после слияния PR части А и PR плана 2 в arm-core | Зеркальный workspace — как в плане 0 (`context/00-workspace-layout.md`, «Worktree вместо веток»): в каталоге части лежат worktree `arm-core/` (ветка фичи), **отсоединённый** worktree arm-ka-docs `docs/` (до слияния #117 — на @@ -186,45 +186,59 @@ cd $WS/.wt/normalization-core-a/arm-core && codegraph init && make venv --- -## Интерфейс-заглушка пакета (часть Б) +## Что ядро вызывает у пакета (часть Б) -> **Сверить с разделом «Публичный API пакета» плана 1** до старта части Б (O-8). Ниже — имена и типы, которые ожидает -> ядро; если в плане 1 они иные, меняется только адаптер `statements_engine.py` и эта таблица, остальной код ядра -> работает с протоколом. +Источник истины по именам и типам — раздел «Публичный API пакета» плана 1 (`2026-10-18-plan-1-arm-statements.md`); +здесь они не копируются. Ядро импортирует имена верхнего уровня `arm_statements` только в +`api/src/services/statements_engine.py` и `api/src/domain/stored_form.py` (сборка входа пересчёта); остальной код +работает с протоколом ниже. ```python -# api/src/services/statements_engine.py — единственная точка импорта arm_statements -class StatementsEngine(Protocol): - @property - def version(self) -> str | None: ... # содержимое api/arm_statements/VERSION; нет файла — None - def recompute(self, form: "StoredForm") -> "StoredFormResult": ... - def document_status(self, status_input: "StatusInput") -> Literal["done", "needs_check"]: ... - def code_order(self, edition: str, document_type: str, form_variant: str | None) -> tuple[str, ...]: ... - def editions(self) -> tuple[str, ...]: ... # порядок редакций перечня: ("66n", "fsbu4") - def relative_period_date(self, period_kind: str, effective_date: datetime.date | None) -> datetime.date | None: ... - def tolerance(self) -> Decimal: ... # допуск ±1 словаря §7 пакета — для межформенной проверки ядра +# api/src/services/statements_engine.py — единственная точка вызова функций arm_statements +from arm_statements import (DocStatus, DocumentType, Edition, FormVariant, PeriodKind, RecomputeResult, Rules, + StatusInput, StoredForm, default_rules, document_status, recompute, resolve_relative, + statements_version) -def default_engine() -> StatementsEngine: ... # ArmStatementsEngine поверх копии пакета, кэш на процесс +class StatementsEngine(Protocol): # тонкая обёртка: подмена в модульных тестах (FakeEngine), кэш правил на процесс + @property + def version(self) -> str | None: ... # statements_version(); нет файла VERSION — None + def recompute(self, form: StoredForm) -> RecomputeResult: ... # recompute(form, rules) + def document_status(self, status_input: StatusInput) -> DocStatus: ... # document_status(status_input) + def code_order(self, edition: Edition, document_type: DocumentType, form_variant: FormVariant) -> tuple[str, ...]: ... + # rules.code_orders.sequence(edition, document_type, form_variant) + def editions(self) -> tuple[Edition, ...]: ... # rules.code_orders.editions — порядок редакций перечня + def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ... + def tolerance(self) -> Decimal: ... # rules.checks.tolerance — допуск ±1, для межформенной проверки ядра + +def default_engine() -> StatementsEngine: ... # ArmStatementsEngine(default_rules()), кэш на процесс ``` -Ожидаемые имена пакета (`arm_statements`, импорт верхнего уровня): - -| Имя | Вид | Поля / сигнатура | +| Вызов ядра | Имя плана 1 | Где используется | |---|---|---| -| `StoredForm` | frozen dataclass | `document_type: str`, `effective_date: date \| None`, `has_organization_or_inn: bool`, `period_known: bool`, `needs_review_fields: tuple[str, ...]`, `accepted: bool`, `warnings: tuple[StoredWarning, ...]`, `columns: tuple[StoredColumn, ...]`, `lines: tuple[StoredLine, ...]` | -| `StoredWarning` | frozen dataclass | `code: str`, `severity: Literal["error", "info"]` | -| `StoredColumn` | frozen dataclass | `key: str`, `column_index: int`, `period_date: date \| None`, `period_kind: str` | -| `StoredLine` | frozen dataclass | `key: str`, `row_index: int \| None`, `line_status: Literal["ok", "unreadable", "missing", "detail"]`, `line_code: str \| None`, `line_code_confidence: Decimal \| None`, `line_name: str \| None`, `code_fixed: bool`, `parent_key: str \| None`, `values: tuple[StoredValue, ...]` | -| `StoredValue` | frozen dataclass | `column_key: str`, `amount: Decimal \| None`, `text: str \| None`, `confidence: Decimal \| None`, `is_recognized: bool \| None`, `value_fixed: bool` | -| `recompute_stored_form` | функция | `(form: StoredForm) -> StoredFormResult` | -| `StoredFormResult` | frozen dataclass | `form_variant: Literal["full", "simplified"] \| None`, `edition: str`, `checks: StatementChecks`, `line_needs_review: Mapping[str, bool]` (ключ строки), `value_needs_review: Mapping[tuple[str, str], bool]` (ключ строки, ключ колонки), `detail_parents: Mapping[str, str \| None]`, `status: Literal["done", "needs_check"]` | -| `StatementChecks` | frozen dataclass | `relations_failed: tuple[FailedRelation, ...]`, `soft_relations_failed: tuple[FailedRelation, ...]` | -| `FailedRelation` | frozen dataclass | `column_index: int`, `relation: str`, `codes: tuple[str, ...]`, `expected: Decimal \| None`, `actual: Decimal \| None` | -| `StatusInput` | frozen dataclass | `document_type: str`, `layout_found: bool`, `has_organization_or_inn: bool`, `period_known: bool`, `lines: tuple[tuple[str, bool], ...]` (`line_status`, `needs_review`), `values: tuple[tuple[str, bool], ...]` (`line_status` строки, `needs_review`), `relations_failed: int`, `accepted: bool`, `warning_severities: tuple[str, ...]` | -| `document_status` | функция | `(status_input: StatusInput) -> Literal["done", "needs_check"]` | -| `load_code_orders` | функция | `() -> CodeOrders`; `CodeOrders.order(edition, document_type, variant) -> tuple[str, ...]`, `CodeOrders.editions -> tuple[str, ...]` | -| `relative_period_date` | функция | `(period_kind: str, effective_date: date \| None) -> date \| None` (`relative:current`, `relative:prev_year_end`, `relative:prev_prev_year_end`, `relative:prev_period`) | -| `load_check_rules` | функция | `() -> CheckRules` с полем `tolerance: Decimal` | +| `engine.recompute(form)` | `recompute(form: StoredForm, rules) -> RecomputeResult` | B2 (после `PATCH`), B3 (приём `1.x`), B4 (только `edition`) | +| `engine.document_status(...)` | `document_status(StatusInput) -> DocStatus` | B4 (страховка статуса результата `2.0`) | +| `engine.code_order(...)` | `default_rules().code_orders.sequence(edition, form, variant)` | B4, B6 | +| `engine.editions()` | `default_rules().code_orders.editions` | B6 | +| `engine.resolve_relative(...)` | `resolve_relative(kind, effective)` | B2 (правка `header.report_date`) | +| `engine.tolerance()` | `default_rules().checks.tolerance` | B6 | +| `engine.version` | `statements_version()` | B2 (`STATEMENTS_VERSION_MISMATCH`) | +| `StoredForm`, `StoredColumn`, `StoredLine`, `StoredValue`, `StoredWarning` | одноимённые dataclass-ы пакета | B1 (`build_stored_form`) | +| `StatusInput`, `StatusLine`, `StatusValue` | одноимённые dataclass-ы пакета | B1 (`status_input_from_v2`) | +| `FailedRelation`, `StatementChecks` | из `RecomputeResult.checks` | B2 (`relations_to_issues`) | + +Соглашения на границе ядра и пакета (все — в `stored_form.py` / адаптере, остальной код ядра их не видит): + +- Перечисления пакета — `StrEnum` (подкласс `str`, значения — строки контракта и БД): строка БД превращается в + `LineStatus(...)`, `DocumentType(...)`, `PeriodKind(...)`, `Severity(...)` при сборке `StoredForm`; `period_kind = NULL` + в БД → `PeriodKind.NONE`. Обратно значения пишутся в БД как `str(...)` / `.value`. +- Уверенности пакета — `float | None` (0–100), в БД — `Numeric(5,2)`: при сборке `float(confidence)`, `None` остаётся `None`. + `StoredValue.is_recognized` — `bool`: `NULL` в БД читается как `False`. +- `StoredColumn.column_index: int | None` (колонка без номера столбца допустима); ключи строки и колонки — `str(uuid)`. +- `RecomputeResult.edition: Edition | None` — `None` при нераспознанной редакции (нет даты и ничья): проверки по редакции + (`CODE_NOT_IN_EDITION`) тогда не пишутся. +- `FailedRelation` несёт `column_index: int | None` и `column_key: str`; при пересчёте из БД `column_key` — это + `StoredColumn.key`, то есть `str(column_id)`: колонка находится по ключу без обратного сопоставления по индексу. + `expected` и `actual` — всегда `Decimal`. Ключи хоста — `str(uuid)` строки и колонки (`key`, `column_key`, `parent_key`). @@ -267,7 +281,15 @@ def default_engine() -> StatementsEngine: ... # ArmStatementsEngine повер - **O-6.** Действие аудита при смене `crm_id` ГСК/ГСЗ (P3-18): сейчас правки ГСК/ГСЗ пишут `MEMBERSHIP_CHANGE`. - **O-7.** Конверт с неизвестным MAJOR (`3.0`) inbox, как и сейчас, отбрасывает с предупреждением в лог (задание закроется по сроку); сценарий спеки «файл получает `ERROR` с `mapping_error`» выполняется для файла результата. Оставить? -- **O-8.** Имена публичного API пакета — по заглушке выше; свести с планом 1 до старта части Б. +- **O-8.** Сверка с планом 1 выполнена (ядро вызывает имена его раздела «Публичный API пакета»). Пробелы API пакета для + ядра, план 1 не правился: (а) типы возврата `Rules.code_orders.sequence` (`tuple[str, ...]`?), `.editions` + (`tuple[Edition, ...]`?) и `Rules.checks.tolerance` (`Decimal`?) в плане 1 не заданы, а параметр `variant` у `sequence` + — `FormVariant` или `FormVariant | None` (для `DISCLOSURE`) не сказан; ядро предполагает перечисленные в «Что ядро + вызывает»; (б) `Edition` формы без строк, то есть в B4 при приёме `2.0`, публично определяется только через + `recompute(StoredForm)` (ядро строит `StoredForm` из документа результата ради одного поля `edition`) — нужна + функция вроде `choose_edition(effective, codes)`; (в) поведение `resolve_relative` для `ABSOLUTE` / `NONE` не задано + (ядро вызывает её только для `relative:*`); (г) `StatusInput` собирает хост — функции «документ результата -> + `StatusInput`» в пакете нет, `status_input_from_v2` (B1) её заменяет. - **O-9.** Выкладка: PR части А в `main` без снимка контура до части Б; встроенный фронт `arm-core/web` получает новые типы только через `sync-web` после плана 6 — выкладка ядра и фронта одним снимком. - **O-10.** Метрики `fields_*` ядро сейчас не считает по строкам формы (копирует из конверта, `recognition_exchange.FIELD_COUNTERS`): @@ -1003,8 +1025,7 @@ def test_has_error_warning(): ## Часть Б > Старт — после слияния PR части А и PR плана 2 (копия `api/arm_statements/`, `mypy src scripts arm_statements`, -> `statements-check` в `check-full`) в arm-core и после сверки интерфейса-заглушки с планом 1 (O-8). Worktree части Б -> создаётся от `origin/main` с обоими слияниями. +> `statements-check` в `check-full`) в arm-core. Worktree части Б создаётся от `origin/main` с обоими слияниями. ### Task B0: Change OpenSpec части Б в arm-core @@ -1024,32 +1045,44 @@ def test_has_error_warning(): - Test: `api/tests/test_domain_stored_form.py`, `api/tests/test_statements_engine.py` **Interfaces:** -- Consumes: копия `arm_statements` (имена — таблица «Интерфейс-заглушка пакета»). +- Consumes: копия `arm_statements` (имена — раздел «Публичный API пакета» плана 1; вызовы ядра — раздел «Что ядро вызывает»). - Produces: - - `StatementsEngine` (протокол) и `default_engine()` — как в разделе «Интерфейс-заглушка»; `ArmStatementsEngine.version` - читает `api/arm_statements/VERSION` (`strip()`), нет файла — `None`; + - `StatementsEngine` (протокол) и `default_engine()` — как в разделе «Что ядро вызывает»; `ArmStatementsEngine.version` + возвращает `statements_version()` (содержимое `api/arm_statements/VERSION`; нет файла — `None`), `recompute` / + `code_order` / `editions` / `tolerance` берут `default_rules()` пакета; - `build_stored_form(document: Document, meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn], lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], warnings: Sequence[DocumentIssue]) - -> StoredForm`: `effective_date = document.reporting_date`; `has_organization_or_inn` — непустые + -> StoredForm` (типы пакета, перечисления — `StrEnum`: `DocumentType`, `LineStatus`, `PeriodKind`, `Severity`; + уверенности — `float(...)`, `NULL` значения `is_recognized` — `False`; `period_kind = NULL` — `PeriodKind.NONE`): + `effective_date = document.reporting_date`; `has_organization_or_inn` — непустые `meta.organization_name` или `meta.inn`; `period_known = document.reporting_date is not None`; `needs_review_fields = tuple(meta.needs_review_fields or ())`; `accepted = meta.checks_accepted_at is not None`; `warnings` — записи `kind = warning` (`code`, `severity`); `code_fixed = bool(line.line_code_modified) or line.is_confirmed`; `value_fixed = value.is_modified or value.is_confirmed`; `text = value.source_text`; - `parent_key` — ключ строки с `row_index == line.parent_row_index` (у `detail`), иначе `None`; - - `status_input_from_v2(doc: StatementDocumentV2) -> StatusInput` — вход страховки статуса из результата: `layout_found = - bool(doc.lines)`, `has_organization_or_inn` — по `header.organization_name`/`header.inn`, `period_known` — - `header.report_date` или хотя бы одна колонка с `period_date`, строки и значения с `needs_review`, `relations_failed = - len(checks.relations_failed)` (нет `checks` — 0), `accepted = False`, уровни `doc.warnings`; - - `FakeEngine` для модульных тестов: возвращает заданный `StoredFormResult`, запоминает вход. + `parent_key` — ключ строки с `row_index == line.parent_row_index` (у `detail`), иначе `None`; `key` строки и колонки — + `str(uuid)`; + - `status_input_from_v2(doc: StatementDocumentV2) -> StatusInput` — вход страховки статуса из результата (поля + `StatusInput` плана 1): `document_type = DocumentType(doc.document_type)`, `layout_found = bool(doc.lines)`, + `has_organization_or_inn` — по `header.organization_name`/`header.inn`, `period_known` — `header.report_date` или + хотя бы одна колонка с `period_date`, `needs_review_fields = tuple(header.needs_review_fields)`, + `lines = tuple(StatusLine(LineStatus(l.line_status), l.needs_review))`, `values = tuple(StatusValue(LineStatus(l.line_status), + v.needs_review))` по значениям каждой строки (`line_status` — статус строки значения), `hard_failures = + len(checks.relations_failed)` (нет `checks` — 0), `accepted = False`, `warning_severities = tuple(Severity(w.severity) + for w in doc.warnings)`; + - `FakeEngine` для модульных тестов: возвращает заданный `RecomputeResult`, запоминает вход `StoredForm`. - [ ] **Step 1: Write the failing tests:** `test_build_stored_form_maps_flags` (шаблонная строка → `row_index is None`; правленая ячейка → `value_fixed`; подтверждённая ячейка → `value_fixed`; строка с назначенным кодом → `code_fixed`; `detail` с `parent_row_index = 3` → `parent_key` строки `row_index 3`; `warnings` — только `kind = warning`); `test_status_input_from_v2` (на `contracts/examples/result-statement-minimal.json`: `layout_found is True`, - `relations_failed == 0`, строка `unreadable` во входе); `test_engine_scenario_recompute_after_edit` (реальный пакет, + `hard_failures == 0`, `needs_review_fields` из шапки, строка `unreadable` во входе в `lines` и `values`); `test_engine_scenario_recompute_after_edit` (реальный пакет, сценарий спеки «Пересчёт после правки по строкам БД»: ячейка 1230 `value_fixed`, код 1250 `code_fixed` у бывшей `unreadable`, шаблонная 1260 с суммой, `detail` с уверенностью 60 → у 1230 и у значения `detail` `needs_review` `False`, - у строки 1250 `False`, соотношение `1200 = …` посчитано с 1250 и 1260); `test_engine_version_reads_file`. + у строки 1250 `False`, соотношение `1200 = …` посчитано с 1250 и 1260; ожидаемый `RecomputeResult.checks` и + `value_needs_review` — с ключами `(str(line_id), str(column_id))`, `edition` — `Edition.FSBU4` или `RAS_66N` по дате); + `test_stored_form_types` (уверенность `Decimal("67.50")` в БД → `67.5` типа `float`; `is_recognized = NULL` → `False`; + `period_kind = NULL` → `PeriodKind.NONE`; `line_status` — `LineStatus`); `test_engine_version_reads_file` + (`engine.version == statements_version()`; `statements_version(tmp_path / "VERSION")` без файла — `None`). - [ ] **Step 2:** `cd api && .venv/bin/pytest tests/test_domain_stored_form.py tests/test_statements_engine.py -v` — FAIL. - [ ] **Step 3: Implement**; `ArmStatementsEngine` кэшируется на процесс (`functools.cache` у `default_engine`). - [ ] **Step 4:** запуск и `make check` (в т. ч. `mypy src scripts arm_statements`) — PASS. @@ -1071,17 +1104,21 @@ def test_has_error_warning(): - `async def recompute_form(session, *, document, meta, engine: StatementsEngine, job: RecognitionJob | None) -> None`: строит вход, вызывает `engine.recompute`, пишет `needs_review` строк и значений, `meta.form_variant`, `parent_row_index` строк `detail` (ключ → `row_index`), заменяет записи `relation`/`soft` (любого источника) и записи - `core` по P3-8, пишет `STATEMENTS_VERSION_MISMATCH` (`info`, `core`), если `job.statements_version` не `None` и не равен - `engine.version`; статус (кроме `CONFIRMED`) — `form_status_v2(service_done = result.status == "done", + `core` по P3-8 (`RecomputeResult` пакета: `form_variant: FormVariant | None`, `edition: Edition | None`, + `checks`, `line_needs_review`, `value_needs_review`, `detail_parents`, `status: DocStatus`), пишет `STATEMENTS_VERSION_MISMATCH` (`info`, `core`), если `job.statements_version` не `None` и не равен + `engine.version`; статус (кроме `CONFIRMED`) — `form_status_v2(service_done = result.status == DocStatus.DONE, file_error_warning = has_error_warning(job.metrics["warnings"]), counterparty, period_known = year и quarter, names_ok = нет NAME_MISMATCH, duplicate_in_file)`; - - `relations_to_issues(checks, *, columns, aggregates, source: IssueSource) -> list[IssueDraft]` — `relation` → - `RELATION_FAILED`/`error`, `soft` → `RELATION_SOFT`/`info`; `column_id` по `column_index`; `line_ids` — все строки `ok` - кодов `codes`; `message` — `f"не сошлось {relation}: {expected} ≠ {actual}"`; + - `relations_to_issues(checks: StatementChecks, *, columns, aggregates, source: IssueSource) -> list[IssueDraft]` — + `relations_failed` → `RELATION_FAILED`/`error`, `soft_relations_failed` → `RELATION_SOFT`/`info` (`FailedRelation` + плана 1); `column_id` — по `FailedRelation.column_key` (при пересчёте из БД это `str(column_id)`; при приёме `1.x` + колонки уже вставлены и ключ строится так же) — по `column_index` только для проверок сервиса `2.0` + (`column_key = str(column_index)`, B4); `line_ids` — все строки `ok` кодов `codes`; `expected`/`actual` — `Decimal`; + `message` — `f"не сошлось {relation}: {expected} ≠ {actual}"`; - `StatementService.recompute` вызывает `recompute_form` (тело A12 удаляется, `nearest_ok_parents` A13 удаляется); - `statement_hard_failures` и `checks.failed`/`soft_failed` ответа — из записей `relation`/`soft` (с `codes`, `line_ids`); `NOTHING_TO_ACCEPT` — нет записей `relation`; правка `header.report_date` (в т. ч. `null`) пересчитывает - `period_date` колонок с `period_kind` `relative:*` через `engine.relative_period_date` до пересчёта. + `period_date` колонок с `period_kind` `relative:*` через `engine.resolve_relative(PeriodKind(kind), effective_date)` до пересчёта. - [ ] **Step 1: Write the failing tests:** - `test_1x_form_after_patch_recomputed` (сценарий «1.x-документ после PATCH») — форма `1.2` с несошедшимся `1200 = 1210 + @@ -1093,7 +1130,7 @@ def test_has_error_warning(): - `test_unconfirm_uncovered_cell` (сценарий «Снятие подтверждения») — ячейка, не покрытая соотношением → `needs_review is True` после пересчёта; - `test_statements_version_mismatch` — `job.statements_version = "abc1234"`, `engine.version = "def5678"` → запись - `STATEMENTS_VERSION_MISMATCH` после `PATCH`; `job.statements_version = None` — нет; + `STATEMENTS_VERSION_MISMATCH` после `PATCH` (`FakeEngine.version` / реальный `statements_version()`); `job.statements_version = None` — нет; - `test_relative_column_follows_report_date` — колонка `relative:prev_year_end`, `PATCH header.report_date = 2025-09-30` → `period_date == 2024-12-31`; после ручной даты (A12) — не меняется; `report_date = null` → `period_date is None` у относительных; @@ -1111,7 +1148,7 @@ def test_has_error_warning(): - Test: `api/tests/test_mapping_statements.py`, `api/tests/test_api_statements.py` **Interfaces:** -- Consumes: `engine.recompute`, `build_stored_form`, `relations_to_issues` (B1, B2). +- Consumes: `engine.recompute` (`RecomputeResult.form_variant`, `.checks`; `.status` здесь не используется), `build_stored_form`, `relations_to_issues` (B1, B2). - Produces: `async def checks_on_intake_1x(session, *, document, meta, engine) -> bool` — один вызов `engine.recompute` по вставленной форме; пишет `meta.form_variant` и записи `relation`/`soft` (`source = core`); возвращает «жёстких нарушений нет» — условие D-6 «проверки сошлись»; `needs_review` значений и строк **не** перезаписывает (у `1.x` @@ -1137,7 +1174,7 @@ def test_has_error_warning(): **Interfaces:** - Consumes: модели `result_v2` (A5), `IssueDraft`/`add_issues` (A9), `form_status_v2`, `duplicate_period_forms`, - `name_mismatches`, `has_error_warning` (A10), `engine.document_status`, `engine.recompute` (только `edition`), + `name_mismatches`, `has_error_warning` (A10), `engine.document_status`, `engine.recompute` (только `RecomputeResult.edition`, O-8б), `engine.code_order`, `status_input_from_v2` (B1). - Produces: - `build_statement_v2(doc: StatementDocumentV2) -> StatementDraftV2` — строки как пришли (`row_index`, `source_ref = @@ -1151,10 +1188,10 @@ def test_has_error_warning(): `inn_confidence`, `needs_review_fields`, `form_variant`; P3-9; - `service_issues(doc, *, line_ids_by_ref, column_ids_by_index, ok_line_ids_by_code) -> list[IssueDraft]` — `doc.warnings` (`warning`, `service`; `line_refs` → `line_ids`; `column_index` → `column_id`, если колонка есть, иначе - `column_index`) и `doc.checks` (`relation`/`soft`, `service`); - - `core_info_issues(*, lines, edition, form_variant, catalog, engine) -> list[IssueDraft]` — `CODE_NOT_IN_DICTIONARY`, - `CODE_NOT_IN_EDITION` (P3-7), `NAME_MISMATCH` (строка); - - статус формы: `service_done = doc.status == "done" and engine.document_status(status_input_from_v2(doc)) == "done"`; + `column_index`) и `doc.checks` (`relation`/`soft`, `service`; `column_id` — по `column_index` колонки: у сервиса `FailedRelation.column_key = str(column_index)`); + - `core_info_issues(*, lines, edition: Edition | None, form_variant: FormVariant | None, catalog, engine) -> list[IssueDraft]` — `CODE_NOT_IN_DICTIONARY`, + `CODE_NOT_IN_EDITION` (P3-7), `NAME_MISMATCH` (строка); `edition is None` или `form_variant is None` — проверки по редакции не пишутся; + - статус формы: `service_done = doc.status == DocStatus.DONE and engine.document_status(status_input_from_v2(doc)) == DocStatus.DONE`; `doc.status == "done"` при несогласии — запись `RESULT_STATUS_INCONSISTENT` (`info`, `core`, у документа); `file_error_warning = has_error_warning(result.metrics.warnings)` (P3-15); `RECOGNIZED` → автоподтверждение (как сейчас); - `job.statements_version = result.service.statements_version`; `DISCLOSURE` — один документ на файл (P3-6), @@ -1225,13 +1262,13 @@ def test_has_error_warning(): - Delete: `api/rules/statement_checks.yaml` - Modify: `api/src/domain/statement_checks.py` (остаются `cross_form_check` и `subline_parents`; `RelationRule`, `CheckRules`, `load_check_rules`, `run_checks`, `FailedCheck.codes` удаляются), `api/src/services/statements.py` - (`checks_report`: допуск — `engine.tolerance()`), `api/src/domain/statement_lines.py` (порядок и состав редакций — - `engine.editions()`), `CLAUDE.md` (раздел «Формы ББ/ОФР»: §7 — копия `arm_statements`, проверки хранятся в + (`checks_report`: допуск — `engine.tolerance()` = `default_rules().checks.tolerance`), `api/src/domain/statement_lines.py` (порядок и состав редакций — + `engine.editions()` = `default_rules().code_orders.editions` (`Edition`)), `CLAUDE.md` (раздел «Формы ББ/ОФР»: §7 — копия `arm_statements`, проверки хранятся в `document_issue`), `README.md` - Test: `api/tests/test_domain_statement_checks.py`, `api/tests/test_domain_statement_lines.py` **Interfaces:** -- Consumes: `engine.tolerance()`, `engine.editions()`, `engine.code_order()` (B1). +- Consumes: `engine.tolerance()`, `engine.editions()`, `engine.code_order(edition, document_type, form_variant)` (B1; `Rules.code_orders.sequence` плана 1). - Produces: `cross_form_check(balance_1370, income_2400, tolerance: Decimal) -> FailedCheck | None` без изменений сигнатуры; `test_name_dictionary_codes_in_code_orders` — каждый код `statement_lines.yaml` редакции есть в перечне этой редакции копии пакета (любой вариант формы).