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

61 KiB
Raw Blame History

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