plan(#116): план 3 — ядро на дополненном API пакета, поля API для фронта, решения Р-34 и Р-35

Пакет: engine.edition (choose_edition) и engine.contract_document_status
(status_input_from_contract) вместо сборки StoredForm ради редакции и status_input_from_v2;
FailedRelation.expected/actual — Decimal | None; O-8 закрыт.
API для плана 6: LineOut.line_code_confidence, StatementHeaderOut.okei, header.source.confidence —
строка Decimal, StatementOut.edition_lines, DocumentBrief.issue_counts, line_id/line_code в detail
ошибок элемента lines[]; P3-19…P3-24; спека arm-core и гайд §5.6.1 дополнены.
Р-34: подтверждение кода не меняет уверенность — буква спеки arm-core, §9.5 п. 9 и гайда приведена;
O-2 закрыт. Р-35: очистка отказывает только при верифицированной версии — P3-12, тесты A4, спека
«Очистка форм» п. 2 и сценарий; O-5 закрыт.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 13:00:56 +04:00
parent c385b7ec42
commit 8a7711da5a
4 changed files with 174 additions and 93 deletions

View File

@ -892,9 +892,10 @@ replay: dlq → queued (`attempt + 1`). `max_attempts`: OCR 1, парсер 3; `
Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail`
отметкой; `ok` → `ok` сменой кода. Обратного перехода `detail` → `unreadable` / `missing` нет: ошибочная отметка
исправляется назначением кода, исходное состояние — перезагрузкой файла. `line_status` в PATCH принимает только
`"detail"`: `"ok"` без кода — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только `line_code`). Назначенный или
подтверждённый код — `line_code_confidence = 100`, `line_code_modified`, `needs_review` строки = `false`;
`line_code_original_value` не меняется (код из источника; у строк без кода — `null`); после правки родитель **всех**
`"detail"`: `"ok"` без кода — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только `line_code`). Назначенный
код — `line_code_confidence = 100`, `line_code_modified`, `needs_review` строки = `false`; подтверждённый код —
флаг `is_confirmed`, `needs_review` строки = `false`, `line_code_confidence` и `line_code_modified` не меняются,
снятие подтверждения возвращает `needs_review` по прежнему правилу (Р-34); `line_code_original_value` не меняется (код из источника; у строк без кода — `null`); после правки родитель **всех**
строк `detail` пересчитывается как ближайшая строка `ok` выше по `row_index` (нет такой — `null`). Код — 4–5 цифр (422 `INVALID_LINE_CODE`), может
совпадать с существующим (Р-4); незнакомый словарю — `info`. **Строка, созданная правкой** — `row_index IS NULL`
(шаблонная): если строка с назначаемым кодом — такая и у неё есть суммы, — 409 `LINE_CODE_TAKEN`; без сумм — она

View File

@ -426,14 +426,17 @@ UI прячет элементы по `GET /me → permissions` (объедин
- `header.needs_review_fields[]` — поля шапки, которые держат документ в «Требует проверки» (сейчас бывает только
`report_date`; подсветить поле). Сомнения в ИНН и организации — не здесь, а в `issues` с `field` (`INN_UNCERTAIN`,
`ORGANIZATION_UNCERTAIN`, уровень `info`). `header.source` — `{<поле>: {text, confidence}}`: как поле распознано
(сырой текст и уверенность) — в подсказку у поля; `header.organization_name_modified` — наименование правилось.
(сырой текст и уверенность; `confidence` — строка Decimal `"61.54"` или `null`, как прочие уверенности) — в
подсказку у поля; `header.organization_name_modified` — наименование правилось; `header.okei` — код ОКЕИ (только
чтение).
- `columns[].period_kind` — `absolute` | `relative:current` | `relative:prev_year_end` | `relative:prev_prev_year_end` |
`relative:prev_period` | `none`; `columns[].label_confidence`. Колонка с `period_kind = none` и `period_date = null` —
нужна дата от аналитика (ввод даты у колонки).
- `lines[]` — **все строки источника**, а не только строки с кодом: `row_index` (порядок в файле; `null` — и только
тогда — у строк, созданных правкой, и у достроенных шаблонных; `origin = template` ⇔ `row_index = null`), `source_ref`, `line_status` (`ok` | `unreadable` | `missing` | `detail`), `line_code` (`null` у
статусов кроме `ok`), `line_code_text` (сырой текст ячейки кода — показывать серым у `unreadable` / `missing`),
`line_name` (наименование **из файла**), `reference_name` (справочное имя по коду или `null` — его показывать основным,
`line_code_confidence` (строка Decimal или `null`; подтверждение кода её не меняет), `line_name` (наименование **из
файла**), `reference_name` (справочное имя по коду или `null` — его показывать основным,
файловое рядом, если отличается), `parent_row_index` (родитель расшифровки `detail`), `needs_review` (подсветить строку),
`is_confirmed`, `line_code_modified`. **Один код может стоять у нескольких строк** (повтор кода законен): не строить
словарь «код → строка», ключ строки — `id` (у достроенных шаблонных — `line_code`, их у кода с реальной строкой `ok` нет).
@ -449,6 +452,8 @@ UI прячет элементы по `GET /me → permissions` (объедин
Нарушения §7 приходят и здесь (`kind = relation` / `soft`, `code = RELATION_FAILED` / `RELATION_SOFT`) — у любой формы,
в том числе загруженной до перехода.
- `checks.failed[]` / `soft_failed[]` — у нарушения ещё `codes[]` и `line_ids[]` (все строки кодов соотношения).
- `edition_lines[] {code, name}` — справочник строк формы для подсказки кода при назначении: коды редакции формы по
одному (имя — первое из словаря), у формы без даты (редакции нет) — коды всех редакций по одному.
- Порядок `lines` — по возрастанию кода, как сейчас (одинаковые коды — по `row_index`); `unreadable` / `missing` — после
ближайшей предыдущей по `row_index` строки с кодом (без такой — в начале), `detail` — сразу после родителя. Разделы и
размещение итогов «как на бумаге» строит экран, как и раньше (`formLayout`).
@ -462,12 +467,14 @@ UI прячет элементы по `GET /me → permissions` (объедин
| дата колонки | `columns: [{column_id, period_date}]` (дата обязательна) | дата колонки, `is_current`, период документа, `period_kind = absolute` (правка даты отчёта её больше не пересчитывает); снимает `COLUMN_WITHOUT_DATE` / `COLUMN_DATE_UNCERTAIN`; та же дата у колонки с таким предупреждением — подтверждение (акцепт не сбрасывает) | 422 `UNKNOWN_COLUMN`; `period_date: null` — 422 массивом |
| назначить / сменить код | `lines: [{line_id, line_code}]` | `unreadable` / `missing` / `detail` / `ok` → `ok` с этим кодом, `line_code_confidence = 100` | 422 `INVALID_LINE_CODE` (не 4–5 цифр), 409 `LINE_CODE_TAKEN` (код занят строкой, созданной правкой, с суммами — сначала очистить её) |
| отметить расшифровкой | `lines: [{line_id, line_status: "detail"}]` | строка выходит из сумм и не блокирует подтверждение; обратно — только назначением кода | 422 `LINE_CODE_REQUIRED` (`line_status: "ok"` — в `ok` переводит только `line_code`) |
| подтвердить код / снять подтверждение | `lines: [{line_id, confirmed: true \| false}]` | `is_confirmed`, `needs_review` строки = `false` (при `false` — пересчитывается) | — |
| подтвердить код / снять подтверждение | `lines: [{line_id, confirmed: true \| false}]` | `is_confirmed`, `needs_review` строки = `false` (при `false` — пересчитывается); `line_code_confidence` и `line_code_modified` не меняются — в подсказке остаётся исходная уверенность | — |
| наименование организации | `header: {organization_name}` (непустая строка) | `organization_name_modified`, `counterparty_check`; снимает `NO_ORGANIZATION_NO_INN` | 422 массивом |
| подтвердить дату отчёта | `header: {report_date}` тем же значением, если `report_date` в `needs_review_fields` | поле уходит из `needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN` | — |
Общие ошибки `lines`: 422 `UNKNOWN_LINE` (строка не этого документа), 422 `INVALID_LINE_PATCH` (в элементе нет действия или
их несколько). Правка суммы по `line_code` (достроенная строка) для кода, у которого уже есть строка `ok`, — 422
их несколько). У ошибок элемента `lines[]` (`LINE_CODE_TAKEN`, `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`,
`INVALID_LINE_PATCH`, `UNKNOWN_LINE`) в `detail` есть `line_id` и `line_code` первого неверного элемента — показывать
ошибку у поля этой строки. Правка суммы по `line_code` (достроенная строка) для кода, у которого уже есть строка `ok`, — 422
`UNKNOWN_CELL`: передайте `line_id`. Правка `header.report_date` пересчитывает даты колонок с `period_kind = relative:*`.
Удалять строки нельзя — для этого «Сделать расшифровкой». Акцепт проверок сбрасывает правка данных (сумма, код, статус
строки, дата колонки, поле шапки), подтверждения (`confirmed`) его не сбрасывают. Ответ — `StatementOut` после пересчёта.
@ -487,7 +494,9 @@ line_code_text}` — строки `unreadable` / `missing`, у которых в
**Карточка файла и документа (несовместимое изменение).** `FileOut.job.warnings` — список объектов `{code, severity:
"error" | "info", message, locator}` вместо строк (у заданий до выкладки ядро отдаёт строки, обёрнутые в `{code:
"LEGACY_WARNING", severity: "info", message, locator: null}`); показывать `message` и `locator`, `error` отличать от
`info`. `FileOut.job.mapping_warnings` — по-прежнему строки. `DocumentOut.issues[]` — те же записи, что `issues` формы,
`info`. `FileOut.job.mapping_warnings` — по-прежнему строки. `FileOut.documents[].issue_counts` и
`DocumentOut.issue_counts` — `{error, info}`: сколько записей `issues` у документа (для значка в панели файлов без
запроса документа). `DocumentOut.issues[]` — те же записи, что `issues` формы,
у любого документа, в том числе ОСВ (предупреждения листа `OSV_*`); `DocumentOut.warnings` (строки маппинга) остаётся на
окно перехода.

View File

@ -99,9 +99,10 @@ cd $WS/.wt/normalization-core-a/arm-core && codegraph init && make venv
- Порядок блокировок: задание → файл → документы → advisory ГК → версии (по `id`); правка формы — «файл → документ», шапка
перечитывается под блокировкой с `refresh=True`.
- Ошибки — `DomainError`: 404 `NOT_FOUND`, 409 `ConflictError`, 422 `DomainValidationError`, тело `{"detail": {"code",
"message", …}}`; 422 валидации тела pydantic — массив. Коды этого плана: 409 `LINE_CODE_TAKEN`, `LINES_UNRESOLVED`
(`lines[] {line_id, row_index, line_code_text}`), `DUPLICATE_FORM` (`document_id`), `CRM_ID_EXISTS`; 422
`LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`, `UNKNOWN_COLUMN`, `UNKNOWN_CELL`. Каталог
"message", …}}`; 422 валидации тела pydantic — массив. Коды этого плана: 409 `LINE_CODE_TAKEN` (`line_id`,
`line_code`), `LINES_UNRESOLVED` (`lines[] {line_id, row_index, line_code_text}`), `DUPLICATE_FORM` (`document_id`),
`CRM_ID_EXISTS`; 422 `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE` (все четыре — с
`line_id`, `line_code` элемента `lines[]`, P3-24), `UNKNOWN_COLUMN`, `UNKNOWN_CELL`. Каталог
кодов в `arm-core/README.md` («Ошибки API») дополняется в той задаче, что вводит код.
- Один `PATCH …/statement` — один пересчёт и одна запись `audit_event` `RECOGNIZED_DATA_EDIT` (раздел `RECOGNITION`,
`object_type = "document"`); запрос без изменений — 200 без аудита. Аудит — только через `services/audit.py`.
@ -196,36 +197,45 @@ cd $WS/.wt/normalization-core-a/arm-core && codegraph init && make venv
```python
# 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)
StoredForm, choose_edition, default_rules, document_status, recompute, resolve_relative,
status_input_from_contract, statements_version)
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 contract_document_status(self, document: Mapping[str, Any]) -> DocStatus: ...
# document_status(status_input_from_contract(document)) — statementDocument результата 2.0 как dict
def edition(self, document_type: DocumentType, codes: Sequence[tuple[str, bool]],
effective_date: datetime.date | None, form_variant: FormVariant | None) -> Edition | None: ...
# choose_edition(document_type, codes, effective_date, rules, variant=form_variant)
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 — порядок редакций перечня
# rules.code_orders.sequence(edition, document_type, form_variant); DISCLOSURE → ()
def editions(self) -> tuple[Edition, ...]: ... # rules.code_orders.editions — (RAS_66N, FSBU4)
def resolve_relative(self, kind: PeriodKind, effective_date: datetime.date | None) -> datetime.date | None: ...
def tolerance(self) -> Decimal: ... # rules.checks.tolerance — допуск ±1, для межформенной проверки ядра
# ABSOLUTE / NONE → None
def tolerance(self) -> Decimal: ... # rules.checks.tolerance — Decimal("1"), для межформенной проверки ядра
def default_engine() -> StatementsEngine: ... # ArmStatementsEngine(default_rules()), кэш на процесс
```
| Вызов ядра | Имя плана 1 | Где используется |
|---|---|---|
| `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.recompute(form)` | `recompute(form: StoredForm, rules) -> RecomputeResult` | B2 (после `PATCH`), B3 (приём `1.x`) |
| `engine.contract_document_status(doc)` | `document_status(status_input_from_contract(doc))` | B4 (страховка статуса результата `2.0`) |
| `engine.edition(...)` | `choose_edition(document_type, codes, effective, rules, *, variant)` | B4 (редакция формы `2.0` для `CODE_NOT_IN_EDITION`) |
| `engine.code_order(...)` | `default_rules().code_orders.sequence(edition, form, variant) -> tuple[str, ...]` | B4, B6 |
| `engine.editions()` | `default_rules().code_orders.editions -> tuple[Edition, ...]` | B6 |
| `engine.resolve_relative(...)` | `resolve_relative(kind, effective) -> date \| None` | B2 (правка `header.report_date`) |
| `engine.tolerance()` | `default_rules().checks.tolerance -> Decimal` | 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`) |
`to_contract` / `warning_to_contract` ядру не нужны: ядро читает контракт, а не пишет его. `StatusInput` ядро не собирает
— вход статуса из документа результата строит пакет (`status_input_from_contract`), поэтому страховка статуса считает
ровно то, что посчитал сервис (план 1, P1-27).
Соглашения на границе ядра и пакета (все — в `stored_form.py` / адаптере, остальной код ядра их не видит):
- Перечисления пакета — `StrEnum` (подкласс `str`, значения — строки контракта и БД): строка БД превращается в
@ -234,11 +244,14 @@ def default_engine() -> StatementsEngine: ... # ArmStatementsEngine(default_rul
- Уверенности пакета — `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`) тогда не пишутся.
- `RecomputeResult.edition` и `engine.edition(...)` — `Edition | None`; `None` при нераспознанной редакции (нет даты и
ничья): проверки по редакции (`CODE_NOT_IN_EDITION`) тогда не пишутся. Пары `codes` для `engine.edition` — строки `ok`
результата по `row_index`: `(line_code, line_code_confidence == 100)` (у результата `2.0` кодов, правленых аналитиком,
нет); пересчёт после правки берёт ту же функцию внутри `recompute` — редакция приёма и пересчёта совпадает.
- `FailedRelation` несёт `column_index: int | None` и `column_key: str`; при пересчёте из БД `column_key` — это
`StoredColumn.key`, то есть `str(column_id)`: колонка находится по ключу без обратного сопоставления по индексу.
`expected` и `actual` — всегда `Decimal`.
`expected` и `actual` — `Decimal | None` (`None` — цель или все члены пусты, план 1 P1-21); `document_issue.expected` /
`actual` — `numeric(18,2)` без `NOT NULL`, `IssueDraft.expected` / `actual` — `Decimal | None`.
Ключи хоста — `str(uuid)` строки и колонки (`key`, `column_key`, `parent_key`).
@ -259,37 +272,40 @@ def default_engine() -> StatementsEngine: ... # ArmStatementsEngine(default_rul
| P3-9 | Результат `2.0`: значение с `column_index` вне колонок формы и повтор `column_index` колонки — первое остаётся, `MAPPING_WARNING` | FK значения на колонку; уникальность `(meta_id, column_index)` | нет |
| P3-10 | Части `PATCH` применяются в порядке `lines` → `values` → `columns` → `header`; вся адресация (422) проверяется до первой записи; `UNKNOWN_CELL` по `line_code` — по кодам строк `ok` с учётом назначений `lines` этого запроса; один `line_id` дважды в `lines` — 422 `INVALID_LINE_PATCH` | атомарность и предсказуемость | нет |
| P3-11 | `reference_name` — первое имя кода в редакции ответа (`edition` по дате); нет редакции или кода — `null` | спека: «первое имя кода в словаре»; `edition` ответа — по дате | нет (Р-29) |
| P3-12 | Очистка отказывает, если документ формы привязан к версии **любого** статуса не «в работе» (как `DELETE /files`), а не только `VERIFIED` и далее | безопаснее; владелец решает по отчёту | O-5 |
| P3-12 | Очистка отказывает, только если документ формы упомянут в `ex_snapshot_item` или привязан к версии в статусе «Верифицирована» или дальше: `VERIFIED` или `ARCHIVED` с `ex_snapshot.source_version_id` = версия; привязки к версиям в работе, на согласовании, `REJECTED`, `CANCELLED` (и архивированным отменённым) снимаются, статус версий не меняется | решение владельца 18.10.2026 (Р-35): верифицированная версия read-only и заморожена в ЕХ, прочие — нет; на стенде версий дальше «в работе» нет | архивированная версия без снимка ЕХ (из `CANCELLED`) очистке не мешает — если бы мешала, владелец увидел бы лишний отказ |
| P3-13 | Коды выхода `purge_statements.py`: `0` — отчёт или выполнено, `1` — отказ (п. 2 спеки), `2` — ошибка аргументов | как у `check_dbml_vs_orm.py` | нет |
| P3-14 | До части Б §7 считает ядро на лету (`statement_checks.yaml`) по агрегату; `checks.failed[].codes` берутся из правила, `line_ids` — по агрегату | часть А должна быть самосогласованной | временный код удаляется в B6 |
| P3-15 | Для условия `RECOGNIZED` `2.0` предупреждения файла берутся из `metrics.warnings` файла результата; в `recognition_job.metrics.warnings` — из конверта (inbox) | маппер читает файл результата; по контракту наборы одинаковы | нет |
| P3-16 | Подтверждение кода (`lines[] {confirmed: true}`) ставит `is_confirmed = true` и `needs_review = false`, `line_code_confidence` и `line_code_modified` **не меняет**; назначение кода — `confidence = 100`, `line_code_modified = true` | иначе исходная уверенность теряется, а снятие подтверждения не может вернуть `needs_review` (гайд §5.6.1: «при `false` — пересчитывается»); аналогия с ячейкой (`is_modified` не ставится) | O-2: в спеке `arm-core` строка «Назначенный или подтверждённый код: `line_code_confidence = 100`, `line_code_modified = true`» |
| P3-16 | Подтверждение кода (`lines[] {confirmed: true}`) ставит `is_confirmed = true` и `needs_review = false`, `line_code_confidence` и `line_code_modified` **не меняет**; назначение кода — `confidence = 100`, `line_code_modified = true` | иначе исходная уверенность теряется, а снятие подтверждения не может вернуть `needs_review` (гайд §5.6.1: «при `false` — пересчитывается»); аналогия с ячейкой (`is_modified` не ставится); подтверждено владельцем 18.10.2026 (Р-34), спека `arm-core`, §9.5 п. 9 и гайд §5.6.1 приведены к этому | нет |
| P3-17 | Порядок строк ответа: строки `ok` (с шаблонными) — по коду, затем `row_index` (`NULL` последними), затем `id`; за каждой строкой `ok` источника — её «вложения» по `row_index`: строки `detail` с этим родителем и строки `unreadable` / `missing` / `detail` без родителя, у которых она — ближайшая предыдущая по `row_index` строка `ok` источника; вложения без такой строки — в начале | спека «Ответ `GET …/statement`» | нет |
| P3-18 | Изменение `crm_id` ГСК/ГСЗ пишет одно событие `GROUP_CHANGE` с `before/after` всех изменённых полей вместо `MEMBERSHIP_CHANGE` | спека: «изменение — аудит `GROUP_CHANGE`» | O-6 |
| P3-19 | Ядро зовёт у пакета `choose_edition` (`engine.edition`) и `status_input_from_contract` (`engine.contract_document_status`) вместо сборки `StoredForm` ради редакции и своего `status_input_from_v2`; `to_contract` ядру не нужен | одно правило с сервисом и с `recompute` (план 1, P1-25, P1-27); пары `(код, уверенный)` — из строк `ok` результата по `row_index` | при ошибке сборки пар редакция приёма разойдётся с редакцией после правки — тест B1 сравнивает их |
| P3-20 | `header.source.<поле>.confidence` — строка Decimal с двумя знаками (`"61.54"`), как прочие уверенности API; в JSONB `header_source` остаётся число как пришло | фронт читает все уверенности одним способом (план 6, A8) | округление до сотых в подсказке; исходное число — в БД |
| P3-21 | `StatementHeaderOut.okei` — из `financial_statement_meta.okei` (колонка есть с v5.18), только чтение; в `PATCH header` не входит | спека `header` контракта несёт `okei`, экран показывает `issues` с `field = "okei"` у поля | нет |
| P3-22 | Справочник строк для подсказки кода — поле `StatementOut.edition_lines [{code, name}]`, а не отдельный эндпоинт: данные уже загружены (`LineCatalog`, A6) при сборке ответа, нет нового маршрута, права, кэша и запроса на фронте; около 80 пар на форму (≈ 4 КБ) против строк формы с значениями; у формы без даты — коды всех редакций, по одному | наименьший объём реализации в ядре и во фронте; закрывает пробел «у формы без даты подсказки нет» (план 6, O-3) | ответ `GET` тяжелее на ≈ 4 КБ; если понадобится справочник вне формы — отдельный эндпоинт поверх того же `LineCatalog.edition_lines` |
| P3-23 | `DocumentBrief.issue_counts {error, info}` — все записи `document_issue` документа (и `relation` / `soft`, в том числе акцептованные) по `severity`; один групповой запрос на файл | панели файлов нужен признак «есть что смотреть» без запроса на каждый документ (план 6, O-4) | акцептованное нарушение продолжает помечать документ в панели — подробности на экране документа |
| P3-24 | Ошибки элемента `lines[]` (409 `LINE_CODE_TAKEN`, 422 `LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`) несут в `detail` `line_id` и `line_code` первого неверного элемента; `LINE_CODE_REQUIRED` добавлен к четырём кодам запроса фронта | экран ставит ошибку у поля нужной строки, не угадывая (план 6, O-2); одно правило для всех ошибок элемента | при нескольких неверных элементах фронт видит только первый — как и прежде |
## Открытые вопросы (`O-n`)
- **O-1.** Р-10 для результатов `1.x` (P3-4): хранить вторую форму того же типа и при `1.x` или пропускать, как раньше?
План хранит.
- **O-2.** Подтверждение кода строки (P3-16) расходится с буквой спеки `arm-core` («подтверждённый код: `line_code_confidence
= 100`, `line_code_modified = true`») и согласуется с гайдом и DBML («без изменения»). Нужна правка спеки одной строкой
или подтверждение буквы (тогда снятие подтверждения не возвращает `needs_review`).
- **O-2.** Закрыт решением владельца 18.10.2026 (Р-34): подтверждение кода уверенность не меняет (P3-16); буква спеки
`arm-core`, `05-domain-rules.md` §9.5 п. 9 и гайда §5.6.1 приведена к этому правилу.
- **O-3.** `CODE_NOT_IN_DICTIONARY` / `CODE_NOT_IN_EDITION` у пятизначных подстрок: каждая подстрока Excel даёт `info`.
Спека это требует (сценарий «Нестандартный код Excel»); план пишет по записи на код. Ограничить четырёхзначными?
- **O-4.** Пересчёт проверок ядра `info` после правки (P3-8) спека не описывает.
- **O-5.** Отказ очистки при привязке к `CANCELLED` / `REJECTED` / маршруту согласования (P3-12) — или только `VERIFIED` и далее?
- **O-5.** Закрыт решением владельца 18.10.2026 (Р-35): отказ только при верифицированной версии (P3-12); спека `arm-core`
«Очистка форм перед выкладкой» п. 2 приведена к этому правилу, сценарий «Форма в версии на согласовании или
отклонённой» добавлен.
- **O-6.** Действие аудита при смене `crm_id` ГСК/ГСЗ (P3-18): сейчас правки ГСК/ГСЗ пишут `MEMBERSHIP_CHANGE`.
- **O-7.** Конверт с неизвестным MAJOR (`3.0`) inbox, как и сейчас, отбрасывает с предупреждением в лог (задание закроется
по сроку); сценарий спеки «файл получает `ERROR` с `mapping_error`» выполняется для файла результата. Оставить?
- **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-8.** Закрыт 18.10.2026 сведением с планом 1 (его P1-21…P1-31): (а) типы `Rules.code_orders.sequence(...) ->
tuple[str, ...]` (параметр `variant: FormVariant`, у `DISCLOSURE` — `()`), `.editions -> tuple[Edition, ...]`,
`Rules.checks.tolerance -> Decimal` записаны в API пакета; (б) редакция без пересчёта — `choose_edition` (`engine.edition`,
B4); (в) `resolve_relative(ABSOLUTE | NONE, …)` → `None`; (г) вход статуса из документа результата —
`status_input_from_contract` пакета (`engine.contract_document_status`), `status_input_from_v2` не пишется.
- **O-9.** Выкладка: PR части А в `main` без снимка контура до части Б; встроенный фронт `arm-core/web` получает новые типы
только через `sync-web` после плана 6 — выкладка ядра и фронта одним снимком.
- **O-10.** Метрики `fields_*` ядро сейчас не считает по строкам формы (копирует из конверта, `recognition_exchange.FIELD_COUNTERS`):
@ -488,10 +504,10 @@ assert await count(conn, "financial_statement_line") == lines_before # данн
**Interfaces:**
- Consumes: `files_repo.delete_file_rows`, `files_repo.expire_s3_objects`, `statements_repo.delete_file_statements`
(уже чистит `document_issue`, A1), `consolidation_documents.bound_versions` / `unbind_documents`, `AuditService.record`,
`IN_WORK_STATUSES`.
`ex_snapshot` (`source_version_id`).
- Produces:
- `@dataclass PurgePlan`: `form_only_files: list[uuid.UUID]`, `mixed_files: list[uuid.UUID]`, `form_documents: int`,
`confirmed_documents: int`, `in_work_bindings: int`, `blocking: list[BlockingVersion]` (`version_id`, `status`,
`confirmed_documents: int`, `unverified_bindings: int`, `blocking: list[BlockingVersion]` (`version_id`, `status`,
`document_id`), `superseded_with_osv: list[uuid.UUID]` (файлы, заменённые удаляемым файлом, в которых есть ОСВ);
- `async def plan_purge(session: AsyncSession, *, include_mixed: bool) -> PurgePlan` — только чтение;
- `async def apply_purge(session: AsyncSession, plan: PurgePlan, *, now: datetime) -> None`;
@ -502,10 +518,14 @@ assert await count(conn, "financial_statement_line") == lines_before # данн
- [ ] **Step 1: Write the failing tests** (БД теста, фабрики `tests/factories.py`):
- `test_dry_run_reports_and_changes_nothing`: файл только с ББ+ОФР (ББ `CONFIRMED`, привязан к версии `INITIATED`),
смешанный файл (ОСВ + ББ), файл только с ОСВ → `run([])` → код 0; в отчёте строки `файлов только с формами: 1`,
`смешанных файлов: 1`, `документов форм: 3`, `подтверждённых документов: 1`, `привязок к версиям «в работе»: 1`; число
`смешанных файлов: 1`, `документов форм: 3`, `подтверждённых документов: 1`, `привязок к неверифицированным версиям: 1`; число
строк всех таблиц не изменилось.
- `test_apply_refuses_verified_binding`: ББ привязан к версии `VERIFIED` → `run(["--apply"])` → код 1, в выводе id версии
и `VERIFIED`; база не изменена. То же для документа в `ex_snapshot_item.document_id`.
и `VERIFIED`; база не изменена. То же для документа в `ex_snapshot_item.document_id` и для версии `ARCHIVED`, у которой
есть `ex_snapshot` с её `source_version_id`.
- `test_apply_ignores_unverified_versions` (сценарий «Форма в версии на согласовании или отклонённой», P3-12): ББ
привязан к версии `VERIFICATION_RF`, ОФР — к `REJECTED`, ещё одна форма — к `CANCELLED` и к `ARCHIVED` без
`ex_snapshot` → `run(["--apply"])` → код 0; привязки сняты, формы удалены, статусы всех версий прежние.
- `test_apply_deletes_form_only_file`: файл с ББ, ОФР, `DISCLOSURE`, комментарием к ББ, заданием; файл `old` с
`superseded_by_file_id` = удаляемый → после `--apply`: файла, документов, `financial_statement_*`, комментариев, заданий
нет; `old.superseded_by_file_id is None`, `old.status == SUPERSEDED`; `s3_object.expires_at` выставлен; привязка снята,
@ -701,9 +721,12 @@ def test_template_line_counts_as_ok():
**Files:**
- Create: `api/src/domain/statement_layout.py`
- Modify: `api/src/schemas/statements.py`, `api/src/schemas/documents.py`, `api/src/services/statements.py`
(`build_out`), `api/src/services/documents.py` (`document_out`), `api/src/repositories/statements.py` (`issues`)
- Test: `api/tests/test_domain_statement_layout.py`, `api/tests/test_api_statements.py`, `api/tests/test_api_documents.py`
- Modify: `api/src/schemas/statements.py`, `api/src/schemas/documents.py`, `api/src/schemas/files.py` (`DocumentBrief`),
`api/src/services/statements.py` (`build_out`), `api/src/services/documents.py` (`document_out`, `file_out` — `issue_counts` рядом с `counts` комментариев),
`api/src/services/primary_files.py` (сборка `FileOut.documents`), `api/src/repositories/statements.py` (`issues`,
`issue_counts_by_document`), `api/src/domain/statement_lines.py` (`LineCatalog.edition_lines`)
- Test: `api/tests/test_domain_statement_layout.py`, `api/tests/test_api_statements.py`, `api/tests/test_api_documents.py`,
`api/tests/test_api_files_read.py`, `api/tests/test_domain_statement_lines.py`
**Interfaces:**
- Consumes: `CodeAggregate` (A7), `LineCatalog.reference_name` (A6).
@ -713,12 +736,25 @@ def test_template_line_counts_as_ok():
- `repo.issues(session, document_id) -> list[DocumentIssue]` (по `created_at, id`);
- `IssueOut {id, kind, code, severity, message, line_ids: list[uuid.UUID], column_id, column_index, field, source,
relation, codes: list[str], expected, actual}` — общий для `StatementOut.issues` и `DocumentOut.issues`;
- `StatementDocumentOut.form_variant`; `StatementHeaderOut.needs_review_fields: list[str]`, `source: dict[str, dict[str,
Any]]` (= `header_source` или `{}`), `organization_name_modified: bool`; `ColumnOut.period_kind`,
`label_confidence`; `LineOut` + `row_index`, `source_ref`, `line_status`, `line_code_text`, `reference_name`,
`parent_row_index`, `needs_review`, `is_confirmed`, `line_code_modified`; `ValueOut` + `text` (`source_text`),
`needs_review`, `is_confirmed`; `FailedCheckOut` + `codes`, `line_ids`; `StatementOut.issues: list[IssueOut]`;
- `DocumentOut.issues: list[IssueOut]` (у любого документа); `DocumentOut.warnings` остаётся.
- `StatementDocumentOut.form_variant`; `StatementHeaderOut.needs_review_fields: list[str]`, `source: dict[str,
HeaderSourceOut]` (= `header_source` или `{}`; `HeaderSourceOut {text: str | None, confidence: Decimal | None}` —
число из JSONB приводится `Decimal(str(x)).quantize(Decimal("0.01"))` и отдаётся строкой, как прочие уверенности
API, P3-20), `organization_name_modified: bool`, `okei: str | None` (`financial_statement_meta.okei`, P3-21);
`ColumnOut.period_kind`, `label_confidence`; `LineOut` + `row_index`, `source_ref`, `line_status`, `line_code_text`,
`line_code_confidence: Decimal | None` (строка), `reference_name`, `parent_row_index`, `needs_review`,
`is_confirmed`, `line_code_modified`; `ValueOut` + `text` (`source_text`), `needs_review`, `is_confirmed`;
`FailedCheckOut` + `codes`, `line_ids`; `StatementOut.issues: list[IssueOut]`;
- `StatementOut.edition_lines: list[EditionLineOut]`, `EditionLineOut {code: str, name: str}` — справочник строк для
подсказки кода (P3-22): строки словаря имён формы `document.document_type` в редакции ответа (`edition`), по одной
на код, имя — первое (как `reference_name`), в порядке словаря; `edition is None` — строки всех редакций словаря по
порядку редакций, по одной на код (имя — из первой редакции, где код есть); `LineCatalog.edition_lines(form: str,
edition: str | None) -> list[tuple[str, str]]`;
- `DocumentOut.issues: list[IssueOut]` (у любого документа); `DocumentOut.warnings` остаётся;
- `DocumentBrief.issue_counts: IssueCountsOut {error: int, info: int}` (а значит и `DocumentOut.issue_counts`) — число
записей `document_issue` документа по `severity`, всех видов (`warning`, `relation`, `soft`), P3-23;
`repo.issue_counts_by_document(session, document_ids: Sequence[uuid.UUID]) -> dict[uuid.UUID, IssueCounts]` — один
запрос `GROUP BY document_id, severity` на список документов (как `comments_count_by_document`); документа нет в
ответе — `{error: 0, info: 0}`.
- [ ] **Step 1: Write the failing tests:**
@ -738,7 +774,16 @@ def test_detail_without_parent_follows_previous_ok():
`header.organization_name_modified is False`; `columns[].period_kind ∈ {"absolute", "none"}`; `issues == []`);
`test_reference_name_first_name` (`fsbu4`, строка `1520` → `reference_name == "Кредиторская задолженность"`);
`test_issues_in_statement_and_document` (вставленная запись `document_issue` `warning` `COLUMN_SKIPPED` с `column_index =
5`, `column_id = null` → она в `StatementOut.issues` и `DocumentOut.issues` с этими полями).
5`, `column_id = null` → она в `StatementOut.issues` и `DocumentOut.issues` с этими полями);
`test_line_code_confidence_and_okei` (строка с `line_code_confidence = 67.50` → `"67.50"`; `meta.okei = "384"` →
`header.okei == "384"`); `test_header_source_confidence_is_decimal_string` (`header_source = {"inn": {"text": "x",
"confidence": 61.538464}, "units": {"text": "тыс. руб.", "confidence": 100}}` → `header.source.inn.confidence ==
"61.54"`, `header.source.units.confidence == "100.00"`; `confidence: null` → `null`);
`test_edition_lines` (форма `fsbu4` ББ → `edition_lines` — коды редакции `fsbu4` ББ словаря имён по одному, `1520` с
именем «Кредиторская задолженность», `1120` нет; форма без даты (`edition is None`) → коды обеих редакций, `1120`
есть, повторов кода нет); `test_issue_counts_in_file_and_document` (у ББ записи `warning` `error`, `relation` `error`,
`soft` `info`, у ОФР нет → в `GET /files/{id}` `documents[]`: ББ `issue_counts == {"error": 2, "info": 1}`, ОФР `{"error":
0, "info": 0}`; `GET /documents/{id}` — те же числа; число SQL-запросов на `GET /files` не растёт с числом документов).
- [ ] **Step 2:** запуск трёх файлов — FAIL.
- [ ] **Step 3: Implement**; порядок `lines` ответа — `order_lines` (достроенные шаблонные — как строки `ok` без
`row_index`).
@ -970,7 +1015,10 @@ def test_has_error_warning():
dict) -> bool` (возвращает «были правки данных» — для сброса акцепта): проверки до записи — `INVALID_LINE_PATCH` (не
ровно одно действие; один `line_id` дважды), `UNKNOWN_LINE`, `LINE_CODE_REQUIRED` (`line_status == "ok"`),
`INVALID_LINE_CODE` (не `^\d{4,5}$`), затем `LINE_CODE_TAKEN` (строка `row_index IS NULL`, `line_status = ok`, с тем
же кодом и хотя бы одной непустой `amount`; 409, ничего не изменено); затем запись: удаление пустых шаблонных строк
же кодом и хотя бы одной непустой `amount`; 409, ничего не изменено); у всех пяти ошибок `detail` несёт `line_id` и
`line_code` первого неверного элемента `lines[]` по порядку запроса (`line_id` — как в элементе, у `UNKNOWN_LINE` —
чужой id; `line_code` — код элемента или `null`; у `LINE_CODE_TAKEN` — строка, которой назначается код, и этот код;
P3-24); затем запись: удаление пустых шаблонных строк
назначаемых кодов (значения → строка → `id` из `line_ids` записей документа), назначение (`line_status = ok`,
`line_code`, `line_code_confidence = 100`, `line_code_modified = true`, `needs_review = false`), отметка `detail`
(`line_code = null`), подтверждение (P3-16), пересчёт `parent_row_index` всех строк `detail`;
@ -989,16 +1037,20 @@ def test_has_error_warning():
(`row_index 2`) с суммой → обе `ok` с 1250, обе в БД, 409 нет — Review Focus 3;
- `test_code_taken_by_template_with_amount` — шаблонная `1250` (`row_index = null`) с суммой → `lines: [{unreadable,
line_code: "1250"}, {other, line_status: "detail"}]`, `values: [...]`, `header: {units: "руб."}` → 409
`LINE_CODE_TAKEN`; ни одна строка, ячейка и поле шапки не изменились, аудита нет — Review Focus 4;
`LINE_CODE_TAKEN` с `detail.line_id` строки `unreadable` и `detail.line_code == "1250"`; ни одна строка, ячейка и поле
шапки не изменились, аудита нет — Review Focus 4;
- `test_code_taken_by_empty_template` — шаблонная `1250` без сумм и запись `document_issue` с `line_ids = [template,
other]` → шаблонной строки и её значений нет, `line_ids == [other]`, строка источника `ok` 1250 — Review Focus 4;
- `test_mark_detail` — `missing` с суммой → `line_status == "detail"`, в агрегате её нет, подтверждение не даёт
`LINES_UNRESOLVED`;
- `test_status_ok_without_code` — `line_status: "ok"` → 422 `LINE_CODE_REQUIRED`;
- `test_status_ok_without_code` — `line_status: "ok"` → 422 `LINE_CODE_REQUIRED`, `detail.line_id == id`,
`detail.line_code is None`;
- `test_invalid_line_patch` — `{line_id}`; `{line_id, line_code, confirmed}`; один `line_id` дважды → 422
`INVALID_LINE_PATCH`;
- `test_unknown_line` — строка другой формы → 422 `UNKNOWN_LINE`;
- `test_invalid_line_code` — `"123"`, `"123456"`, `"12a4"` → 422 `INVALID_LINE_CODE`; `"11101"` → 200;
`INVALID_LINE_PATCH`, `detail.line_id` — id элемента (у повтора — повторённый id), `detail.line_code` — код
элемента или `None`;
- `test_unknown_line` — строка другой формы → 422 `UNKNOWN_LINE`, `detail.line_id` — этот id;
- `test_invalid_line_code` — `"123"`, `"123456"`, `"12a4"` → 422 `INVALID_LINE_CODE`, `detail == {code, message,
line_id, line_code: "123"…}`; `"11101"` → 200;
- `test_confirm_code` — строка `ok` 1120, уверенность 50, `needs_review = true`, акцепт стоит → `confirmed: true` →
`is_confirmed is True`, `needs_review is False`, акцепт сохранён; `confirmed: false` → `is_confirmed is False`
(P3-16);
@ -1049,7 +1101,8 @@ def test_has_error_warning():
- Produces:
- `StatementsEngine` (протокол) и `default_engine()` — как в разделе «Что ядро вызывает»; `ArmStatementsEngine.version`
возвращает `statements_version()` (содержимое `api/arm_statements/VERSION`; нет файла — `None`), `recompute` /
`code_order` / `editions` / `tolerance` берут `default_rules()` пакета;
`edition` / `code_order` / `editions` / `tolerance` берут `default_rules()` пакета, `contract_document_status` —
`document_status(status_input_from_contract(document))`;
- `build_stored_form(document: Document, meta: FinancialStatementMeta, columns: Sequence[FinancialStatementColumn],
lines: Sequence[tuple[FinancialStatementLine, Sequence[FinancialStatementValue]]], warnings: Sequence[DocumentIssue])
-> StoredForm` (типы пакета, перечисления — `StrEnum`: `DocumentType`, `LineStatus`, `PeriodKind`, `Severity`;
@ -1061,21 +1114,21 @@ def test_has_error_warning():
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`; `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`.
- `edition_codes(doc: StatementDocumentV2) -> list[tuple[str, bool]]` — пары `(line_code, line_code_confidence == 100)`
строк `ok` результата по `row_index` (вход `engine.edition`); своего `StatusInput` ядро не собирает — вход статуса
из документа результата строит пакет (`status_input_from_contract`, план 1 P1-27);
- `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`,
`hard_failures == 0`, `needs_review_fields` из шапки, строка `unreadable` во входе в `lines` и `values`); `test_engine_scenario_recompute_after_edit` (реальный пакет,
`test_contract_document_status_matches_examples` (реальный пакет: у каждого `statementDocument` из
`contracts/examples/result-statement-minimal.json` и `result-statement-excel.json` —
`engine.contract_document_status(doc) == DocStatus(doc["status"])`; у минимального — `NEEDS_CHECK` из-за строки
`unreadable`); `test_edition_codes_and_engine_edition` (минимальный пример: пары в порядке `row_index`, строка
`unreadable` не входит; `engine.edition(DocumentType.BALANCE, pairs, date(2025, 9, 30), FormVariant.FULL)` — `Edition`;
тот же ответ, что `recompute` формы, собранной из этого результата); `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; ожидаемый `RecomputeResult.checks` и
@ -1113,8 +1166,9 @@ def test_has_error_warning():
`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}"`;
(`column_key = str(column_index)`, B4); `line_ids` — все строки `ok` кодов `codes`; `expected`/`actual` — `Decimal |
None` (`None` — цель или все члены пусты, план 1 P1-21; в запись — `NULL`); `message` — `f"не сошлось {relation}:
{expected} ≠ {actual}"`, `None` пишется словом «пусто»;
- `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`) пересчитывает
@ -1174,8 +1228,8 @@ 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` (только `RecomputeResult.edition`, O-8б),
`engine.code_order`, `status_input_from_v2` (B1).
`name_mismatches`, `has_error_warning` (A10), `engine.contract_document_status`, `engine.edition`, `engine.code_order`,
`edition_codes` (B1).
- Produces:
- `build_statement_v2(doc: StatementDocumentV2) -> StatementDraftV2` — строки как пришли (`row_index`, `source_ref =
ref`, `line_status`, `line_code`, `line_code_original_value = line_code`, `line_code_text`, `line_code_confidence`,
@ -1191,7 +1245,10 @@ def test_has_error_warning():
`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`;
`edition = engine.edition(DocumentType(doc.document_type), edition_codes(doc), document.reporting_date,
FormVariant(doc.form_variant) if doc.form_variant else None)` — без сборки `StoredForm` и пересчёта;
- статус формы: `service_done = doc.status == DocStatus.DONE and engine.contract_document_status(doc.model_dump(mode="json")) == DocStatus.DONE`;
`expected`/`actual` нарушений результата — `Decimal | None` (контракт: `null` — цели нет);
`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),
@ -1299,7 +1356,7 @@ def test_has_error_warning():
| Признак строки, созданной правкой | A7, A13 | — |
| Шапка формы — сырой текст и уверенность | схема A1; `1.x` A9; `header.source` A8 | `2.0` B4 |
| Агрегат формы по коду | A7 | — |
| Условие `RECOGNIZED` при приёме 2.0 | чистые функции A10; словарь A6 | B4 (страховка — `engine.document_status`) |
| Условие `RECOGNIZED` при приёме 2.0 | чистые функции A10; словарь A6 | B4 (страховка — `engine.contract_document_status`) |
| Предупреждения и нарушения документа — `document_issue` | таблица A1; снятие правкой A12; `line_ids` A13; `issues` A8 | `relation`/`soft`, `core` B2–B4 |
| Формы результата 1.x — §7 при приёме | поля и `MAPPING_WARNING` A9; `LEGACY_WARNING` A5 | B3 |
| Пересчёт после правки | временный пересчёт A12 | B2 |

View File

@ -269,15 +269,19 @@ SHALL **один раз при приёме** посчитать §7 точко
отметкой; `ok` → `ok` сменой кода; `ok` с неуверенным кодом — подтверждением. Обратного перехода `detail` →
`unreadable` / `missing` нет: ошибочная отметка исправляется назначением кода, исходное состояние — перезагрузкой файла.
`line_status` принимает только `"detail"`; `"ok"` — 422 `LINE_CODE_REQUIRED` (в `ok` строку переводит только
`line_code`). Назначенный или подтверждённый код: `line_code_confidence = 100`, `line_code_modified = true`,
`needs_review` строки = `false`; `line_code_original_value` не меняется; после любого изменения статусов или кодов
`line_code`). Назначенный код: `line_code_confidence = 100`, `line_code_modified = true`, `needs_review` строки =
`false`. Подтверждённый код (`confirmed: true`): `is_confirmed = true`, `needs_review` строки = `false`,
`line_code_confidence` остаётся как пришло, `line_code_modified` не ставится; снятие подтверждения (`confirmed: false`)
возвращает `needs_review` по прежнему правилу (Р-34). `line_code_original_value` не меняется; после любого изменения статусов или кодов
`parent_row_index` **всех** строк `detail` документа пересчитывается как ближайшая строка `ok` выше по `row_index`
(требование «Пересчёт после правки»). Код — 4–5 цифр (иначе 422 `INVALID_LINE_CODE`), может совпадать с
существующим; незнакомый словарю — `info`. Строка с тем же кодом, **созданная правкой** (`row_index IS NULL`), с суммами —
409 `LINE_CODE_TAKEN` (сначала очистить её суммы или выбрать другой код); без сумм — удаляется (её `id` убирается из
`document_issue.line_ids`), её место занимает назначенная строка. Строки источника с тем же кодом не трогаются. В одном
элементе больше одного действия или ни одного — 422 `INVALID_LINE_PATCH`; строка не этого документа — 422
`UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Правка суммы по `line_code` (для достроенной строки)
`UNKNOWN_LINE`; колонка не этого документа — 422 `UNKNOWN_COLUMN`. Ошибки элемента `lines[]` (`LINE_CODE_TAKEN`,
`LINE_CODE_REQUIRED`, `INVALID_LINE_CODE`, `INVALID_LINE_PATCH`, `UNKNOWN_LINE`) SHALL нести в `detail` `line_id` и
`line_code` первого неверного элемента (`line_code` — код элемента или `null`). Правка суммы по `line_code` (для достроенной строки)
при наличии строки `ok` с этим кодом — 422 `UNKNOWN_CELL` (передайте `line_id`). Удаления строк источника нет. Строки,
созданные правкой: `row_index = null`, `source_ref = null`, `line_status = ok`, `line_code_original_value = null`. Аудит
`RECOGNIZED_DATA_EDIT` с `before/after` по коду, статусу, родителю, подтверждениям и наименованию организации.
@ -369,13 +373,16 @@ SHALL **один раз при приёме** посчитать §7 точко
- **THEN** справочное «Гудвил» (≥ 5 символов) входит в него — совпало, `NAME_MISMATCH` нет
### Requirement: Ответ `GET …/statement` по v2.0
`StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields`, `source` (`header_source`) и
`organization_name_modified`; у колонок `period_kind`, `label_confidence`; у строк `id`, `origin` (`template` ⇔
`StatementOut` SHALL отдавать: у документа `form_variant`; у шапки `needs_review_fields`, `source` (`header_source`;
`confidence` — строкой Decimal, как прочие уверенности API), `organization_name_modified` и `okei`; у колонок `period_kind`, `label_confidence`; у строк `id`, `origin` (`template` ⇔
`row_index = null`), `row_index`, `source_ref`, `line_status`, `line_code`, `line_code_text`, `line_code_confidence`,
`line_name` (из файла), `reference_name` (справочное имя по коду — первое имя кода в словаре — или `null`),
`parent_row_index`, `parent_code`, `needs_review`, `is_confirmed`, `line_code_modified`; у значений `text`
(`source_text`), `needs_review`, `is_confirmed` и прежние поля; `issues[]` — записи `document_issue` с `line_ids`,
`column_id`, `column_index`, `field`, `codes`; `checks` — нарушения из `document_issue` с `codes` и `line_ids`. Порядок
`column_id`, `column_index`, `field`, `codes`; `checks` — нарушения из `document_issue` с `codes` и `line_ids`;
`edition_lines[] {code, name}` — справочник строк для подсказки кода: коды словаря имён формы в редакции ответа по одному
(имя — первое), у формы без редакции — коды всех редакций по одному. `DocumentBrief` (`FileOut.documents[]`) и
`DocumentOut` SHALL отдавать `issue_counts {error, info}` — число записей `document_issue` документа по уровню. Порядок
`lines` — по возрастанию кода, как сейчас (`schemas/statements.py`), одинаковые коды — по `row_index`; строки
`unreadable` / `missing` — после ближайшей предыдущей по `row_index` строки, размещённой по коду, без такой — в начале;
`detail` — после родителя. Разделы и размещение итогов на экране («как на бумаге», Р-14) строит экран.
@ -441,11 +448,14 @@ organization_name`, `document.file_ordinal` — по `(created_at, id)` внут
режимами `--dry-run` (по умолчанию: только отчёт) и `--apply`, одной транзакцией:
1. **Отбор:** документы `BALANCE` / `INCOME` (у файлов любого статуса, включая `SUPERSEDED`).
2. **Отказ без изменений**, если хотя бы один отобранный документ упомянут в `ex_snapshot_item` или привязан
(`consolidation_document`) к версии консолидации не «в работе» (`VERIFIED` и далее): отчёт называет версии, решение —
за владельцем.
(`consolidation_document`) к версии консолидации в статусе «Верифицирована» или дальше — `VERIFIED` или `ARCHIVED`
верифицированной версии (есть `ex_snapshot` с `source_version_id` версии): такая версия read-only и заморожена в ЕХ.
Отчёт называет версии, решение — за владельцем. Версии в работе, на согласовании, отклонённые (`REJECTED`) и
отменённые (`CANCELLED`, в том числе архивированные) очистке не мешают: привязки к ним снимаются, как к версиям
«в работе» (п. 3, 4), статус версий не меняется (Р-35).
3. **Файлы только с формами** — у файла нет документов `OSV`: удаляется файл целиком, как `DELETE /files/{id}` (§9.5 п. 5),
но без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`: привязки документов к версиям «в работе» снимаются (статус версий не
меняется), удаляются документы всех типов файла (`BALANCE`, `INCOME`, `DISCLOSURE`, `OTHER`) с
но без запрета `FILE_HAS_CONFIRMED_DOCUMENTS`: привязки документов к неверифицированным версиям (п. 2) снимаются
(статус версий не меняется), удаляются документы всех типов файла (`BALANCE`, `INCOME`, `DISCLOSURE`, `OTHER`) с
`financial_statement_*` и комментариями документов, задания `recognition_job` файла и `stored_file`; объектам S3
ставится `expires_at = now()`; ссылка `superseded_by_file_id` других файлов на удаляемый обнуляется, а заменённые им
файлы остаются `SUPERSEDED` (повторная загрузка заменит их снова; отчёт перечисляет такие файлы, если в них есть ОСВ).
@ -457,18 +467,22 @@ organization_name`, `document.file_ordinal` — по `(created_at, id)` внут
карточка файла в `before_value`); на документ смешанного файла — то же событие по файлу с перечнем удалённых
документов.
6. **Не трогаются:** ОСВ (`osv_entry`), консолидации и их версии, корректировки, гудвилл, справочники, пользователи,
журнал аудита. Результаты КФО версий «в работе», посчитанные с удалёнными формами, пересчитываются, как после удаления
файла.
журнал аудита. Результаты КФО неверифицированных версий, посчитанные с удалёнными формами, пересчитываются, как после
удаления файла.
#### Scenario: Отчёт перед очисткой
- **WHEN** владелец запускает скрипт без ключей
- **THEN** скрипт ничего не меняет и печатает число файлов только с формами, смешанных файлов, документов форм,
подтверждённых документов и привязок к версиям «в работе»
подтверждённых документов и привязок к неверифицированным версиям
#### Scenario: Форма в верифицированной версии
- **WHEN** документ ББ привязан к версии `VERIFIED`
- **THEN** `--apply` отказывает с перечнем версий, база не изменена
#### Scenario: Форма в версии на согласовании или отклонённой
- **WHEN** документ ББ привязан к версии `VERIFICATION_RF`, а ОФР — к версии `REJECTED`; верифицированных версий нет
- **THEN** `--apply` выполняется: привязки сняты, статусы версий не изменились
#### Scenario: Очистка и повторная загрузка
- **WHEN** очистка выполнена, ядро v5.19 выложено, аналитик загружает тот же PDF с ББ и ОФР
- **THEN** дедуп не мешает (файла больше нет), формы создаются по мапперу 1.x или 2.0 — смотря что шлёт сервис