# 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`, ничего не создано