Andrey Epifancev 47e6372cbe chore(openspec): архив unified-statement-normalization (#116)
Спеки применены: 5 новых capability (arm-core, arm-web, scan-gateway,
statement-normalization, tabular-recognition-service) с Purpose, изменения
quality-checks и recognition-contract. Ссылки в 05-domain-rules, DBML v5.19,
entities, dev/checks.md и dev/qa-statements-v2.md ведут в архив.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 09:25:43 +04:00

536 lines
61 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# arm-core Specification
## Purpose
Требования к ядру arm-core со стороны единой нормализации ББ/ОФР: хранение результата формы контракта 2.0 (DBML v5.19), приём результатов 1.x и 2.0 в окне перехода, пересчёт формы после правки общим кодом `arm_statements`, правка строк, кодов и дат колонок, блокировки подтверждения и очистка форм перед выкладкой.
## Requirements
### Requirement: Хранение результата формы v2.0 (DBML v5.19)
Маппер результата `2.0` SHALL писать документ формы как пришёл, без повторной дедупликации колонок, кодов и значений:
каждая строка результата — `financial_statement_line` (`row_index`, `source_ref` = `ref`, `line_status`, `line_code`,
`line_code_original_value` = `line_code` результата, `line_code_text`, `line_code_confidence`, `line_name`,
`line_name_confidence`, `parent_row_index` — `row_index` строки с `ref = parent_ref`, `needs_review`, `is_confirmed =
false`); каждая пара «строка × колонка» — одна `financial_statement_value` (`amount`, `string_value`, `source_text` =
`values[].text`, `confidence`, `is_recognized`, `needs_review`; значения, которого нет в результате, — пустая запись с
`needs_review = false`); колонки — `financial_statement_column` с `period_kind`, `label_confidence`; шапка —
`financial_statement_meta` с `form_variant`, `needs_review_fields` и `header_source` (раздел «Шапка формы»);
предупреждения и нарушения документа — `document_issue` (источник `service`); версия общего кода —
`recognition_job.statements_version`. Уникальность `(meta_id, line_code)` снята; `(meta_id, source_ref)` уникален там,
где `source_ref` не `null`. Каждый `BALANCE` / `INCOME` результата SHALL становиться отдельным `document`; вторая
форма того же типа в файле хранится (Р-10); ОСВ по счёту, `DISCLOSURE` и `OTHER` — по-прежнему один документ на тип (и
счёт) на файл. **Каждый** создаваемый `document` (ОСВ, формы, `DISCLOSURE`, `OTHER`) при приёме результата любого
`MAJOR` (мапперы `1.x` и `2.0`) SHALL получать `file_ordinal` = индекс документа в `documents[]` результата (с 0);
документ, который не создаётся (второй лист ОСВ с тем же счётом, второй `DISCLOSURE`), номер не занимает, но и не
сдвигает остальные — номера уникальны в файле и могут идти с пропусками.
#### Scenario: Книга из двух ОСВ
- **WHEN** результат содержит две ОСВ по счетам 60 и 62 (`documents[0]`, `documents[1]`), принятый маппером `1.x` или `2.0`
- **THEN** два документа с `file_ordinal` 0 и 1; уникальность `(stored_file_id, file_ordinal)` не нарушена, файл не `ERROR`
#### Scenario: PDF с ББ и ОФР по 1.x
- **WHEN** результат `1.2` содержит `BALANCE` и `INCOME`
- **THEN** документы с `file_ordinal` 0 и 1
#### Scenario: Повтор кода сохраняется
- **WHEN** в результате две строки `ok` с кодом 2410 (пустая и с `(649)`)
- **THEN** в БД две строки 2410 с разными `row_index` и `source_ref`, обе со своими значениями
#### Scenario: Строка без кода
- **WHEN** в результате строка `unreadable` с `line_code_text = "ИЧ,250"` и суммой
- **THEN** строка хранится с `line_code = null`, `line_code_original_value = null`, `line_status = unreadable`,
`line_code_text = "ИЧ,250"` и `row_index` из результата
#### Scenario: Два баланса одного периода в одном файле
- **WHEN** результат содержит два `BALANCE` с одной парой `period_year`, `period_quarter` (лист-продолжение другого
типа или двойная печать)
- **THEN** создаются два документа с разными `file_ordinal`; ни один не автоподтверждается (Р-18)
#### Scenario: Два баланса разных периодов в одном файле
- **WHEN** результат содержит `BALANCE` на 30.09.2025 и `BALANCE` на 31.12.2024
- **THEN** два документа с разными `file_ordinal`; каждый получает статус по своим условиям, правило Р-18 их не касается
### Requirement: Признак строки, созданной правкой
Строка формы SHALL считаться созданной правкой аналитика (шаблонной, `origin = template`) тогда и только тогда, когда
`row_index IS NULL`. Маппер `1.x` и маппер `2.0` SHALL всегда писать `row_index` (у `1.x` — по порядку строк результата с
0), поэтому у строки из источника он не `null`. `line_code_original_value` SHALL хранить код строки из результата как
пришёл (`null` у `unreadable` / `missing` / `detail` и у строк, созданных правкой) и правками кода MUST NOT меняться.
Признак `line_code_original_value IS NULL` для `origin` MUST NOT использоваться.
#### Scenario: Строка источника без кода — не шаблонная
- **WHEN** у строки `unreadable` из результата `line_code_original_value = null` и `row_index = 7`
- **THEN** `origin = source`; правила шаблонных строк (удаление без сумм, 409) к ней не применяются
#### Scenario: Назначение одного кода двум строкам источника
- **WHEN** аналитик назначил код 1250 строке `unreadable` (`row_index = 7`, без сумм), а затем тот же код — строке
`missing` (`row_index = 2`) с суммой
- **THEN** обе строки `ok` с кодом 1250, ни одна не удалена, 409 нет (повтор кода законен, Р-4)
### Requirement: Шапка формы — сырой текст и уверенность
Маппер `2.0` SHALL писать в `financial_statement_meta.header_source` объект `{"<поле>": {"text": <поле>_text,
"confidence": <поле>_confidence}}` для всех полей шапки результата (`organization_name`, `inn`, `report_date`, `units`,
`okud`, `okpo`, `okopf`, `okfs`, `okei`, `address`); разобранные значения — в одноимённые колонки; `*_original_value` —
как прежде: `inn_original_value = inn`, `report_date_original_value = report_date_text`, `units_original_value =
units_text`; `organization_name_original_value = organization_name`, `inn_confidence = inn_confidence`.
`header_source` и `*_original_value` правками MUST NOT меняться. Маппер `1.x` заполняет `header_source` из полей шапки
1.x. `StatementOut.header.source` SHALL отдавать `header_source`.
#### Scenario: Подсказка у поля шапки
- **WHEN** в результате `organization_name_text = "ООО «Ромашкa»"`, `organization_name_confidence = 67`
- **THEN** `header_source.organization_name = {"text": "ООО «Ромашкa»", "confidence": 67}`; экран показывает их в подсказке
у поля
### Requirement: Агрегат формы по коду
Все чтения формы «по коду» SHALL идти через одну чистую функцию «агрегат формы по коду» без хранения (DECISION-5):
для кода и колонки — сумма `amount` строк `ok` с этим кодом (`null`, если ни одной непустой), список `line_id`,
справочное имя. На неё переводятся межформенная проверка `1370 / 2400`, суммы формы для консолидации и выгрузок,
`parent_code` подстрок, раскладка строк на экране, правка ячеек по `line_code`, метрики `fields_*`. Строки `detail`,
`unreadable`, `missing` в агрегат, КФО и снимок ЕХ MUST NOT попадать.
#### Scenario: Межформенная проверка по агрегату
- **WHEN** в ББ две строки 1370 (сумма 500 и 300 на отчётную дату)
- **THEN** межформенная проверка берёт 1370 = 800
### Requirement: Условие `RECOGNIZED` при приёме 2.0
При приёме `2.0` статус документа формы SHALL быть `RECOGNIZED` ⇔ `document.status = done` и он согласован с
содержимым документа ∧ в `metrics.warnings` файла нет предупреждений уровня `error` ∧ `counterparty_check ≠ MISMATCH` ∧
период — конец квартала (`period_year` и `period_quarter` определены) ∧ у каждой строки `ok` с неуверенным кодом
(`line_code_confidence < 100`) распознанное наименование совпало со справочным по коду ∧ в файле нет второй формы того
же типа и периода (та же пара `period_year`, `period_quarter`; формы без квартала не считаются); иначе `NEEDS_CHECK`.
**Согласованность:** ядро SHALL вызывать общую функцию статуса пакета (`statement-normalization`, «Шаг 8 — статус
документа и конверта») по входу, собранному из результата: `layout_found` — `lines[]` непуст, `has_organization_or_inn`
и `period_known` — по шапке и колонкам результата, строки и значения с их `needs_review` (значения `detail` функция не
учитывает), число `checks.relations_failed`, `accepted = false`, уровни `warnings[]` документа. `done` согласован, если
функция тоже даёт `done`; иначе ядро считает документ `needs_check` и пишет `document_issue`
`RESULT_STATUS_INCONSISTENT` (`info`, источник `core`). Это вызов той же функции, а не пересчёт §7 и `needs_review`. Статус конверта и `needs_check` соседнего документа в условие
MUST NOT входить (Р-19). Проверки §7, `needs_review` и предупреждения сервиса при загрузке MUST NOT пересчитываться.
**Совпадение наименований:** обе строки приводятся к нижнему регистру, `ё` → `е`, всё, кроме букв и цифр, — пробел,
пробелы схлопываются, крайние пробелы обрезаются (`strip`); совпало, если строки равны, или более короткая (не короче 5
символов) входит в более длинную, или отношение сходства `difflib.SequenceMatcher` ≥ 0,8; сравнение — с **любым**
справочным именем кода (у кода может быть несколько имён — синонимы и законные повторы, Р-20) в любой редакции словаря
ядра. Если хотя бы одно из сравниваемых имён начинается с «итого по разделу», сравниваются **номера раздела**: номер —
начальная часть первого слова после фразы из букв `i`, `v`, `x` (латиница; кириллические «і», «х» приводятся к ним),
остальное в слове отбрасывается («v1» → V, «v» → V); совпало ⇔ номера равны; номер не выделен хотя бы у одного имени —
совпадение только при равенстве строк целиком. Кода нет в словаре имён или наименование пусто — не совпало.
Несовпадение — `document_issue` `NAME_MISMATCH` (`info`, источник `core`, строка). `RECOGNIZED` SHALL, как и прежде,
автоподтверждаться (OQ-157).
#### Scenario: Ячейка соседнего документа
- **WHEN** ББ `done`, ОФР того же файла `needs_check`, предупреждений файла уровня `error` нет, ИНН совпал, период —
30.09.2025
- **THEN** ББ `RECOGNIZED` и автоподтверждается, ОФР — `NEEDS_CHECK`
#### Scenario: Потерянный json
- **WHEN** все документы `done`, но в `metrics.warnings` файла `JSON_FAILED` (или `TABLE_PARSE_ERROR`) уровня `error`
- **THEN** все документы формы файла `NEEDS_CHECK`
#### Scenario: Подмена неуверенного кода на соседа
- **WHEN** строка с кодом 1120 (уверенность 50) и наименованием «Нематериальные активы»
- **THEN** наименование не совпало со справочным «Результаты исследований и разработок», документ `NEEDS_CHECK`,
`NAME_MISMATCH` у строки
#### Scenario: Законный повтор с другим именем
- **WHEN** строка 1220 (уверенность 50) с наименованием «НДС, начисленный по отгрузке», а в словаре имён `fsbu4` у 1220
есть это имя вторым
- **THEN** наименование совпало, `NAME_MISMATCH` нет
#### Scenario: Подмена итога раздела
- **WHEN** строка с кодом 1200 (уверенность 50) и наименованием «Итого по разделу I»
- **THEN** номер раздела I не равен II у справочного «Итого по разделу II», `NAME_MISMATCH`
#### Scenario: Хвост OCR после номера раздела (Счастливочная, РБ)
- **WHEN** строка 1500 (уверенность 56, разлепленная) с наименованием «Итого по разделу V1», или строка с наименованием
«Итого по разделу V /»
- **THEN** номер раздела V равен номеру справочного «Итого по разделу V», наименование совпало, `NAME_MISMATCH` нет
#### Scenario: Сервис прислал done при неразобранной строке без значений
- **WHEN** документ пришёл `done`, но в нём строка `unreadable` без значений
- **THEN** функция статуса даёт `needs_check` (не все строки `ok` / `detail`), документ `NEEDS_CHECK`,
`RESULT_STATUS_INCONSISTENT`; автоподтверждения нет
#### Scenario: Сервис ошибся со статусом
- **WHEN** документ пришёл `done`, но в нём строка `unreadable` с суммой
- **THEN** документ `NEEDS_CHECK`, `RESULT_STATUS_INCONSISTENT` у документа; автоподтверждения нет
#### Scenario: Период не конец квартала
- **WHEN** документ `done` с датой отчёта 15.08.2025
- **THEN** квартал не определён, документ `NEEDS_CHECK` (как в D-6) и не автоподтверждается; ручное подтверждение —
409 `PERIOD_REQUIRED`, пока аналитик не поправит дату отчёта
### Requirement: Предупреждения и нарушения документа — `document_issue`
Ядро SHALL хранить предупреждения и нарушения документа в `document_issue`: вид (`warning` | `relation` | `soft`), код,
уровень (`error` | `info`), сообщение, список `line_id`, `column_id` (колонка формы) или `column_index` (столбец
предупреждения, которого нет среди колонок формы), поле шапки, источник (`service` | `core`); для нарушений — `codes`
(коды цели и членов), текст соотношения, `expected`, `actual`. Нарушение из `checks.relations_failed` — `kind =
relation`, `code = RELATION_FAILED`, `severity = error`; из `soft_relations_failed` — `kind = soft`, `code =
RELATION_SOFT`, `severity = info`; `line_ids` — все строки `ok` кодов `codes` в документе. При загрузке пишутся
предупреждения и `checks` результата (источник `service`) и проверки ядра уровня `info` (источник `core`):
`CODE_NOT_IN_DICTIONARY` (кода нет в словаре имён редакции документа), `CODE_NOT_IN_EDITION` (код не из перечня
редакции документа и его варианта — по копии перечня `arm_statements`), `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT`.
**Редакция документа** для этих проверок — `edition` точки входа пакета для пересчёта (`statement-normalization`):
редакция с лучшей подпоследовательностью кодов строк `ok` документа, при равенстве — по эффективной дате; так бланк 66н,
сданный в 2025 году, проверяется по `66n`, а не по дате. Поле `edition` ответа `GET …/statement` и выбор шаблонных
строк по-прежнему — по дате периода (§7 «Полный состав формы»). `document_issue` ведётся у **любого** документа файла: у
ОСВ — предупреждения `osvDocument.warnings` результата `2.0` (вид `warning`, источник `service`, `OSV_*`, `info`). При перезагрузке файла записи документа
удаляются вместе с ним. При пересчёте после правки записи `relation` и `soft` заменяются целиком независимо от источника;
записи `warning` источника `service` сохраняются, кроме снимаемых правкой: правка `header.report_date` (в том числе
подтверждение тем же значением) снимает `PERIOD_UNKNOWN`, `REPORT_DATE_UNPARSED`, `REPORT_DATE_UNCERTAIN`; правка
`header.inn` или `header.organization_name` — `NO_ORGANIZATION_NO_INN`; правка `columns[].period_date` —
`COLUMN_WITHOUT_DATE` и `COLUMN_DATE_UNCERTAIN` этой колонки; прочие `error` сервиса снимаются только перезагрузкой
файла. При удалении шаблонной строки её `id` SHALL убираться из `line_ids` всех записей документа. `GET …/statement` и
`POST …/confirm` SHALL читать нарушения §7 только из `document_issue`.
#### Scenario: Дата колонке
- **WHEN** у колонки предупреждение `COLUMN_WITHOUT_DATE`, аналитик ставит ей `period_date` через PATCH
- **THEN** предупреждение удаляется, статус пересчитывается
#### Scenario: Неснимаемое предупреждение
- **WHEN** у документа `SEGMENT_COLUMNS_MISMATCH`, аналитик правит значения
- **THEN** предупреждение остаётся; документ можно подтвердить вручную (оно подтверждение не блокирует)
#### Scenario: Бланк 66н в 2025 году без ложного info
- **WHEN** форма Межениновской за 3 кв. 2025 на бланке 66н с кодом 1120 (в перечне `fsbu4` его нет)
- **THEN** редакция документа для проверок ядра — `66n` (подпоследовательность длиннее), `CODE_NOT_IN_EDITION` у 1120 нет
#### Scenario: Пропущенная колонка
- **WHEN** в результате предупреждение `COLUMN_SKIPPED` с `column_index = 5`, колонки 5 среди `columns[]` нет
- **THEN** запись `document_issue` с `column_index = 5` и `column_id = null`; экран показывает её у документа
### Requirement: Формы результата 1.x — §7 при приёме
Для формы, принятой по результату `1.x` (окно перехода после выкладки ядра; `checks` контракта 2.0 у неё нет), ядро
SHALL **один раз при приёме** посчитать §7 точкой входа пакета для пересчёта (`statement-normalization`; вариант формы —
по структуре кодов, как у 2.0) по суммам результата и записать нарушения в `document_issue` (источник `core`, виды
`relation` / `soft`, как в требовании о `document_issue`) и `form_variant` в `financial_statement_meta`. Условие D-6
«проверки сошлись» для `1.x` SHALL браться из этого расчёта; `needs_review` значения при приёме `1.x` = **НЕ**
`values_ok` (`values_ok` — `is_recognized` не `false` и `confidence` равна 100 или пуста), `needs_review` строки —
`false`. Предупреждения маппера `1.x` по документу (повтор кода, значение вне колонок) — `document_issue`
`MAPPING_WARNING` (`info`, источник `core`); строки `metrics.warnings` результата `1.x` — в
`recognition_job.metrics.warnings` структурами `{code: "LEGACY_WARNING", severity: "info", message}`. После правки такая
форма пересчитывается общим кодом, как форма 2.0; `MAPPING_WARNING` и `LEGACY_WARNING` статус не держат. Формы,
существовавшие в БД до миграции v5.19, §7 не пересчитываются (требования «Миграция DBML v5.19» и «Очистка форм перед
выкладкой»).
#### Scenario: Полярность needs_review при приёме 1.x
- **WHEN** в результате `1.2` у значения `confidence = 100`, `is_recognized = true`, у другого — `confidence = 80`
- **THEN** у первого `needs_review = false`, у второго — `true`
#### Scenario: Форма 1.x с нарушением
- **WHEN** результат `1.2` содержит ББ с несошедшимся `1600 = 1700`
- **THEN** у формы запись `document_issue` `relation` `RELATION_FAILED` с `codes = ["1600", "1700"]` (источник `core`),
документ `NEEDS_CHECK`; `POST …/confirm` без акцепта — 409 `CHECKS_NOT_PASSED`
#### Scenario: 1.x-документ после PATCH
- **WHEN** форма принята по результату `1.2`, аналитик правит сумму 1230 так, что `1200 = 1210 + … + 1260` сходится
- **THEN** ядро пересчитывает §7 и статус общим кодом, запись `relation` по 1200 заменена (её больше нет), статус
пересчитан; `STATEMENTS_VERSION_MISMATCH` не пишется (у задания 1.x версии нет)
### Requirement: Пересчёт после правки
После каждого PATCH формы ядро SHALL пересчитывать `checks`, `needs_review`, `form_variant`, родителей строк `detail`
и статус точкой входа пакета для пересчёта по сохранённым данным (`statement-normalization`; конвенция знака — по
`source_text`), с правилами: `is_modified` или `is_confirmed` значения ⇒ `needs_review = false`; `line_code_modified` или
`is_confirmed` строки ⇒ `needs_review` строки = `false`; значения строк `detail` — `needs_review = false` (Р-23), у
строки, выведенной из `detail` назначением кода, `needs_review` её значений считается по общему правилу; акцепт ⇒
жёсткие нарушения не учитываются; правленые ячейки в голосование знака не входят. Результат пишется: `needs_review`
строк и значений, `form_variant`, `parent_row_index` всех строк `detail` (ближайшая строка `ok` выше по `row_index`;
нет такой — `null`, строка остаётся `detail`), записи `document_issue` видов `relation` / `soft`, статус. Отличие `statements_version` задания от версии копии ядра — `document_issue`
`STATEMENTS_VERSION_MISMATCH` (`info`); у задания без версии (`1.x`) запись не пишется. Один PATCH — один пересчёт и одна
запись аудита `RECOGNIZED_DATA_EDIT`. Акцепт SHALL сбрасываться правкой данных — суммы, кода или статуса строки, даты
колонки, поля шапки; подтверждение или снятие подтверждения (`confirmed`) и подтверждение даты отчёта или даты колонки
тем же значением акцепт MUST NOT сбрасывать (Р-25, S-47).
#### Scenario: Подтверждение ячейки без правки
- **WHEN** аналитик подтверждает ячейку с `needs_review = true` без изменения суммы
- **THEN** `is_confirmed = true`, `confirmed_by/at`, `is_modified` не ставится, `needs_review = false`, аудит; акцепт
проверок, если был, сохраняется
#### Scenario: Снятие подтверждения
- **WHEN** аналитик снимает подтверждение с ячейки (`confirmed: false`), которая не покрыта соотношением
- **THEN** `is_confirmed = false`, `needs_review = true` после пересчёта, аудит
#### Scenario: Родитель уходит в расшифровки
- **WHEN** у строки 1150 две расшифровки (`detail`, `parent_row_index` = 1150), аналитик отмечает саму 1150 расшифровкой
- **THEN** после пересчёта родитель всех трёх строк `detail` — ближайшая строка `ok` выше 1150; строк над ними нет — `null`
(на экране — в начале раздела)
### Requirement: PATCH формы — строки, колонки, подтверждение, наименование организации
`PATCH /documents/{id}/statement` SHALL принимать помимо `header {inn, report_date, units}` и `values` (сумма):
- `header.organization_name` — непустая строка до 1000 символов: `organization_name_modified = true`,
`organization_name_original_value` не меняется, пересчитывается `counterparty_check`, аудит `before/after`;
- `header.report_date`, равный текущему, у поля из `needs_review_fields` — подтверждение даты: поле уходит из
`needs_review_fields`, снимается `REPORT_DATE_UNCERTAIN`, аудит; иначе запрос без изменений — 200 без эффектов, как
прежде;
- `values[] {line_id, column_id, confirmed: true | false}` — подтвердить ячейку без изменения значения или снять
подтверждение;
- `columns[] {column_id, period_date}` — дата колонке (обязательная дата; `null` — 422 ошибка валидации; пересчитывает
`is_current` и период документа по правилу §9.5 п. 2); ручная дата ставит колонке `period_kind = absolute`, чтобы
следующая правка даты отчёта её не перезаписала; дата, равная текущей, у колонки с `COLUMN_DATE_UNCERTAIN` или
`COLUMN_WITHOUT_DATE` — подтверждение: предупреждение снимается, `period_kind = absolute`, аудит (иначе запрос без
изменений — 200 без эффектов); правка `header.report_date` (в том числе `null`) пересчитывает `period_date` колонок с
`period_kind = relative:*` общим кодом (`null` даёт `null`);
- `lines[] {line_id, line_code}` — назначить или сменить код; `lines[] {line_id, line_status: "detail"}` — отметить
расшифровкой; `lines[] {line_id, confirmed: true | false}` — подтвердить код или снять подтверждение.
Переходы: `unreadable` / `missing` / `detail` → `ok` назначением кода; `unreadable` / `missing` / `ok` → `detail`
отметкой; `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`. Подтверждённый код (`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`. Ошибки элемента `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` по коду, статусу, родителю, подтверждениям и наименованию организации.
Ограничения состояния — как прежде (`DOCUMENT_READONLY`, `FILE_SUPERSEDED`, `DOCUMENT_NOT_STATEMENT`).
#### Scenario: Назначение кода строке-мусору (AURORA-1462)
- **WHEN** аналитик назначает строке `unreadable` «ИЧ,250» (`row_index = 2`) код 1250
- **THEN** строка `ok`, `line_code = 1250`, `line_code_confidence = 100`, `line_code_modified = true`,
`line_code_original_value` остаётся `null`, `row_index` остаётся 2 (`origin = source`); §7 и статус пересчитаны
#### Scenario: Отметка расшифровкой
- **WHEN** аналитик отмечает строку `missing` с суммой как расшифровку
- **THEN** `line_status = detail`, строка выходит из сумм и не блокирует подтверждение
#### Scenario: Код занят шаблонной строкой с суммами
- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой аналитика (`row_index = null`) и
имеет сумму
- **THEN** 409 `LINE_CODE_TAKEN`, ничего не изменено
#### Scenario: Код занят пустой шаблонной строкой
- **WHEN** аналитик назначает строке `unreadable` код 1250, а строка 1250 создана правкой и сумм не имеет
- **THEN** шаблонная строка удалена, её `id` убран из `document_issue.line_ids`; строка источника стала `ok` с кодом 1250
#### Scenario: Правка наименования организации
- **WHEN** у формы `NO_ORGANIZATION_NO_INN`, аналитик вводит `header.organization_name`
- **THEN** наименование сохранено, `organization_name_modified = true`, исходное — в `organization_name_original_value`,
`counterparty_check` пересчитан, предупреждение снято, аудит
#### Scenario: Подтверждение неуверенной даты отчёта
- **WHEN** `report_date` в `needs_review_fields`, аналитик отправляет PATCH с тем же `header.report_date`
- **THEN** `needs_review_fields` пуст, `REPORT_DATE_UNCERTAIN` снят, статус пересчитан, аудит
#### Scenario: Ручная дата относительной колонки
- **WHEN** колонке `relative:prev_year_end` аналитик ставит `period_date` 31.12.2024, затем правит дату отчёта
- **THEN** после первой правки `period_kind = absolute`; правка даты отчёта её дату не меняет
#### Scenario: Подтверждение неуверенной даты колонки
- **WHEN** у колонки `COLUMN_DATE_UNCERTAIN`, аналитик отправляет `columns: [{column_id, period_date}]` с той же датой
- **THEN** предупреждение снято, `period_kind = absolute`, статус пересчитан, аудит
#### Scenario: Статус ok без кода
- **WHEN** приходит `lines: [{line_id, line_status: "ok"}]`
- **THEN** 422 `LINE_CODE_REQUIRED`
### Requirement: Подтверждение формы — порядок 409
`POST /documents/{id}/confirm` для формы ББ/ОФР SHALL проверять в порядке: `FILE_SUPERSEDED` → `ALREADY_CONFIRMED` →
`DOCUMENT_NOT_CONFIRMABLE` → `PERIOD_REQUIRED` → `LINES_UNRESOLVED` (есть строки `unreadable` / `missing`, у которых хотя
бы в одной ячейке `amount` или `string_value` не `null`; `detail.lines[] {line_id, row_index, line_code_text}`; строки без
значений не мешают) → `DUPLICATE_FORM` (в файле уже подтверждена другая форма того же типа, `period_year` и
`period_quarter`; `detail.document_id`) → `CHECKS_NOT_PASSED` (записи `document_issue` вида `relation` без акцепта).
Между файлами повтор формы решает автозамена §9.5 п. 3.
#### Scenario: Неразобранная строка с суммой
- **WHEN** у формы строка `missing` с суммой и одновременно жёсткое нарушение
- **THEN** 409 `LINES_UNRESOLVED` с этой строкой в `detail.lines`
#### Scenario: Неразобранная строка только с текстом
- **WHEN** у формы строка `unreadable` без сумм, но с мусором `211(1 |` в ячейке значения (`string_value`)
- **THEN** 409 `LINES_UNRESOLVED` — потерянная сумма тоже требует разбора
#### Scenario: Вторая форма того же периода
- **WHEN** в файле два ББ на 30.09.2025, первый подтверждён, аналитик подтверждает второй
- **THEN** 409 `DUPLICATE_FORM` со ссылкой на первый
### Requirement: Словари ядра и достройка шаблонных строк
Копия словаря §7 ядра (`api/rules/statement_checks.yaml`) SHALL удаляться: проверки ядро считает копией пакета
`arm_statements`. `api/rules/statement_lines.yaml` SHALL оставаться только словарём имён (код → наименования по
редакциям): у кода может быть несколько имён (синонимы и законные повторы кода в бланке, Р-20), загрузчик повтор кода в
редакции MUST NOT запрещать; сверка наименования — с любым именем кода; шаблонная строка берёт первое имя. Порядок и
состав редакций — из копии перечня кодов пакета; `sync-statements --check` сверяет, что все коды словаря имён есть в
перечне. В PR перехода ядра на v2.0 словарь имён SHALL быть дополнен: в редакции `fsbu4` — вторые имена законных
повторов 1220 «НДС, начисленный по отгрузке», 1520 «Обязательство по аренде», 2410 «Налог при упрощенной системе
налогообложения» (Р-20) и коды печатной формы 1С 1105 «Гудвил», 1130 «Нематериальные поисковые активы», 1140
«Материальные поисковые активы», 1530 «Доходы будущих периодов», 2460 «Прочее» (имена — по `fixtures/ocr` и шаблонам
банка); без этого законные повторы РБ, Счастливочной и aep_6 и любой неуверенный 1105 дают `NAME_MISMATCH`. Шаблонные строки достраиваются по словарю имён (одна на код); код, у которого есть хотя бы одна строка `ok` из
файла, шаблоном MUST NOT достраиваться; строки `unreadable` / `missing` не заменяются шаблонной строкой молча.
#### Scenario: Код есть только у строки unreadable
- **WHEN** у формы нет строки `ok` с кодом 1250, но есть строка `unreadable` «ИЧ,250»
- **THEN** шаблонная строка 1250 достраивается пустой, строка `unreadable` показывается отдельно со статусом
#### Scenario: Два имени у кода
- **WHEN** в редакции `fsbu4` словаря имён код 1520 записан дважды — «Кредиторская задолженность» и «Обязательство по
аренде»
- **THEN** словарь загружается; шаблонная строка 1520 одна, с именем «Кредиторская задолженность»
#### Scenario: Гудвил с заголовком раздела в имени
- **WHEN** строка 1105 (уверенность 67) с наименованием «АКТИВ I. Внеоборотные активы Гудвил»
- **THEN** справочное «Гудвил» (≥ 5 символов) входит в него — совпало, `NAME_MISMATCH` нет
### Requirement: Ответ `GET …/statement` по v2.0
`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`;
`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) строит экран.
#### Scenario: Документ, принятый по 1.x
- **WHEN** форма загружена до перехода сервиса (результат 1.2)
- **THEN** у строк `line_status = ok`, `row_index` по порядку, `source_ref = null`, `origin = source`, у значений `text =
string_value`, `issues` содержат нарушения §7, посчитанные ядром, и `MAPPING_WARNING`; экран работает
### Requirement: Миграция DBML v5.19
Одна миграция Alembic SHALL приводить схему к DBML v5.19 и **только** добавлять колонки, таблицу, enum'ы и индексы и
заполнять значения по умолчанию (Р-24); §7 по существующим формам миграция MUST NOT считать, импорта копии
`arm_statements` в ревизии нет. Перед выкладкой база очищается от форм ББ/ОФР (требование «Очистка форм перед
выкладкой»), но миграция SHALL проходить и на непустой базе (стенды разработчиков, база, очищенная не полностью):
`row_index` — `NULL` у строк с `line_code_original_value IS NULL` (созданных правкой), у остальных — по возрастанию
`line_code` внутри формы с 0; `line_status = ok`, `needs_review` строк — `false`, `needs_review` значений — **НЕ**
`values_ok` (`values_ok` — правлено, или `is_recognized` не `false` и `confidence` равна 100 или пуста), `source_text =
string_value`, `is_confirmed = false`, `period_kind` — `absolute` при `period_date`, иначе `none`, `form_variant =
null`, `header_source` — из `*_original_value` и `inn_confidence`, `organization_name_original_value =
organization_name`, `document.file_ordinal` — по `(created_at, id)` внутри файла с 0 (у всех документов),
`related_group.crm_id` — `null`; `document_issue` — пуст. **Корректность нарушений у форм, оставшихся с до-миграционных
времён, не гарантируется:** у них нет записей `document_issue`, поэтому до первой правки экран не показывает нарушений
§7, а `POST …/confirm` не отвечает `CHECKS_NOT_PASSED`; первый PATCH пересчитывает форму общим кодом. Это цена Р-24; на
контуре её снимает очистка. Сверка `check_dbml_vs_orm.py` — против `data-model-v5.19.dbml`.
Откат (`downgrade`) SHALL быть гарантирован на пустой БД и на данных, которые схема v5.18 может представить без потери
первичных данных и ручного ввода. `downgrade` SHALL останавливаться с ошибкой, называющей причину и число записей, и
ничего не менять, если есть: две строки с одним кодом в форме, строка с `line_code = null`, две формы одного типа в файле,
непустой `related_group.crm_id`, `organization_name_modified = true` (исходное наименование хранится только в новой
колонке). Остальные новые колонки и таблица `document_issue` — производные и сигнальные данные v5.19 (`needs_review`,
`source_text`, `header_source`, `is_confirmed` с `confirmed_by/at`, `period_kind`, `label_confidence`, `form_variant`,
`needs_review_fields`, `file_ordinal`, `statements_version`, `document_issue`): `downgrade` удаляет их и пишет в журнал
миграции, сколько записей с непустыми значениями удалено (подтверждения аналитика остаются в `audit_event`). Это не
«молчаливое удаление»: потеря названа в журнале и в этом требовании.
#### Scenario: Цикл миграций
- **WHEN** `alembic upgrade head` → `downgrade base` → `upgrade head` на чистой БД
- **THEN** все три шага проходят, сверка ORM с DBML v5.19 — без расхождений
#### Scenario: Миграция на непустой базе
- **WHEN** на стенде есть форма ББ, загруженная до v5.19, с несошедшимся `1600 = 1700`, и выполнена миграция
- **THEN** миграция проходит: у строк `row_index` по коду, `line_status = ok`, у документа `file_ordinal`, записей
`document_issue` нет — нарушение до первой правки не показано (корректность не гарантируется); после любого PATCH форма
пересчитана, и запись `RELATION_FAILED` появляется
#### Scenario: Строка правки до миграции
- **WHEN** до миграции у формы есть строка 1250, созданная правкой (`line_code_original_value = null`), с суммой
- **THEN** после миграции у неё `row_index = null`, `origin = template`; строки источника пронумерованы с 0
#### Scenario: Откат на данных v2.0
- **WHEN** в БД есть форма с двумя строками 2410, выполняется `downgrade` ниже v5.19
- **THEN** откат останавливается с ошибкой о повторе кода; данные не изменены
#### Scenario: Откат после подтверждений
- **WHEN** в БД нет несовместимых данных, но у ячеек есть `is_confirmed = true` и записи `document_issue`
- **THEN** `downgrade` проходит, удаляет колонки и таблицу и пишет в журнал число удалённых подтверждений и записей
### Requirement: Очистка форм перед выкладкой
Перед выкладкой ядра с миграцией v5.19 на контур (шаг 3 порядка выкладки) база SHALL очищаться от форм ББ/ОФР (Р-24):
формы пересоздаются повторной загрузкой файлов уже по новым правилам. Очистку выполняет владелец или платформенная
команда по его заявке, на контуре — после резервной копии БД и до `alembic upgrade` v5.19 (на стендах разработчиков — по
желанию). Инструмент — скрипт ядра `scripts/purge_statements.py` (пишется в PR шага 3; описание здесь, кода нет) с
режимами `--dry-run` (по умолчанию: только отчёт) и `--apply`, одной транзакцией:
1. **Отбор:** документы `BALANCE` / `INCOME` (у файлов любого статуса, включая `SUPERSEDED`).
2. **Отказ без изменений**, если хотя бы один отобранный документ упомянут в `ex_snapshot_item` или привязан
(`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`: привязки документов к неверифицированным версиям (п. 2) снимаются
(статус версий не меняется), удаляются документы всех типов файла (`BALANCE`, `INCOME`, `DISCLOSURE`, `OTHER`) с
`financial_statement_*` и комментариями документов, задания `recognition_job` файла и `stored_file`; объектам S3
ставится `expires_at = now()`; ссылка `superseded_by_file_id` других файлов на удаляемый обнуляется, а заменённые им
файлы остаются `SUPERSEDED` (повторная загрузка заменит их снова; отчёт перечисляет такие файлы, если в них есть ОСВ).
4. **Смешанные файлы** — у файла есть и ОСВ, и формы: удаляются только документы `BALANCE` / `INCOME` с их
`financial_statement_*`, комментариями и привязками; файл, ОСВ, `DISCLOSURE` / `OTHER` и задания остаются. Повторно
загрузить такой файл нельзя (дедуп по `sha256`), поэтому отчёт `--dry-run` перечисляет смешанные файлы отдельно, и
владелец решает до `--apply`, удалять ли их целиком (ключ `--include-mixed`).
5. **Аудит:** на каждый удалённый файл — системное `FILE_DELETE` (`user_id = NULL`, описание «очистка форм перед v5.19»,
карточка файла в `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 — смотря что шлёт сервис
### Requirement: Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0
`recognition_job.metrics.warnings` SHALL хранить предупреждения уровня файла структурами `{code, severity, message,
locator?}` (`fileWarning` контракта): у `2.0` — как пришли, у `1.x` — строки, обёрнутые в `LEGACY_WARNING` `info`.
`JobBrief.warnings` карточки файла (`FileOut.job`) SHALL отдавать список этих структур, а не строк; строки заданий,
сохранённых до выкладки ядра v5.19, при чтении конвертируются в `{code: "LEGACY_WARNING", severity: "info", message:
<строка>, locator: null}` (данные в БД не переписываются). `JobBrief.mapping_warnings` (предупреждения маппинга ядра)
остаётся списком строк. Это несовместимое изменение типа поля для фронта — фиксируется в гайде фронта.
Предупреждения листа ОСВ результата `2.0` (`osvDocument.warnings`, коды `OSV_*`, `info`) SHALL храниться в
`document_issue` документа ОСВ (вид `warning`, источник `service`) и отдаваться в `DocumentOut.issues[]` (те же поля,
что `issues` формы) вместе с предупреждениями маппинга ядра этого листа; `DocumentOut.warnings` (строки маппинга файла)
сохраняется на окно перехода. `osvDocument.status` SHALL использоваться: при приёме `2.0` документ ОСВ `RECOGNIZED` ⇔
`osvDocument.status = done` ∧ `checks` листа без нарушений ∧ в `metrics.warnings` файла нет предупреждений уровня
`error` ∧ `counterparty_check ≠ MISMATCH` ∧ нет предупреждений маппинга ядра по этому листу (повтор счёта в файле, строки
без родителя, нарастающая по данным Q1); статус конверта в условие не входит (как Р-19 для форм). При приёме `1.x` — как
прежде (§9.3).
#### Scenario: Карточка файла задания до выкладки
- **WHEN** у задания, сохранённого до v5.19, `metrics.warnings = ["sheet=ОСВ 60 строки без родителя"]`
- **THEN** `JobBrief.warnings = [{code: "LEGACY_WARNING", severity: "info", message: "sheet=ОСВ 60 строки без родителя",
locator: null}]`
#### Scenario: Потерянный json в карточке файла
- **WHEN** результат `2.0` scan-gateway несёт `metrics.warnings` с `JSON_FAILED`
- **THEN** `JobBrief.warnings` содержит `{code: "JSON_FAILED", severity: "error", message, locator}`; предупреждение не
теряется
#### Scenario: ОСВ в файле с ОФР, требующим проверки
- **WHEN** книга из ОСВ (`status = done`, контрольные суммы сошлись) и ОФР (`needs_check`), конверт `needs_check`,
предупреждений файла `error` нет
- **THEN** ОСВ `RECOGNIZED` и автоподтверждается; её предупреждения `OSV_*` — в `DocumentOut.issues`
### Requirement: `crm_id` ГСК/ГСЗ
`related_group.crm_id` (`varchar`, nullable, уникален) SHALL сохраняться в `POST …/related-groups` и
`PATCH /directories/related-groups/{id}` и отдаваться в `RelatedGroupOut` / `RelatedGroupDetail`; повтор — 409
`CRM_ID_EXISTS`; изменение — аудит `GROUP_CHANGE` с `before/after` (arm-core#112). Импорт ЕЦП `crm_id` групп не
заполняет (OQ-91).
#### Scenario: Занятый crm_id
- **WHEN** создаётся ГСЗ с `crm_id`, который уже есть у другой ГСК или ГСЗ
- **THEN** 409 `CRM_ID_EXISTS`, ничего не создано