# osv-result-mapping Specification ## Purpose Маппинг файла результата распознавания (`recognition-result.schema.json` v1.x, `osvDocument`) в `document` и `osv_entry` отдельным фоновым циклом (§1.3, §1.5, §3 п. 4, §9.3); резолв счетов и контрагентов; проверки шапки; статус документа. ## Requirements ### Requirement: Цикл маппинга отделён от inbox Фоновая задача ядра SHALL с периодом `MAPPING_POLL_SECONDS` (5) выбирать одно задание со `status ∈ {done, needs_check}`, чей файл имеет `status = PROCESSING` и `current_recognition_job_id` = это задание (`FOR UPDATE SKIP LOCKED`), проверять, что `result_s3_object` относится к бакету хранилища и его ключ начинается с `results/{file_id}/{job_id}/` (OQ-70), читать объект из S3 **вне** транзакции, валидировать `RecognitionResult` v1.x и в одной транзакции создать документы и строки (листы ОСВ и формы ББ/ОФР), записать `recognition_job.group_schema`, перевести файл в `PROCESSED` и выполнить автозамену. Результат без документов SHALL давать неповторяемую ошибку маппинга «в результате нет документов»; результат с формами, но без ОСВ, ошибкой MUST NOT быть. Inbox MUST NOT читать файл результата и MUST NOT создавать документы. #### Scenario: Результат смапплен - **WHEN** в S3 лежит результат парсера для `rb/2024-FY/ОСВ_66 за 2024.xls`, задание `done`, файл `PROCESSING` - **THEN** не позднее чем через `MAPPING_POLL_SECONDS` + время маппинга файл — `PROCESSED`, создан один `document` (`OSV`, `osv_account_code = "66"`, `period_year = 2024`, `period_quarter = Q4`, `is_cumulative = true`, `locator = sheet:Лист_1`), `osv_entry` — все строки результата кроме `TOTAL`, `group_schema` задания содержит `[{"locator": "sheet:Лист_1", "groups": [...]}]` #### Scenario: Ключ результата вне папки задания - **WHEN** `result_s3_object.key = results/<другой file_id>/.../result.json` - **THEN** файл — `ERROR` с ошибкой маппинга, документы не созданы, повтор не выполняется #### Scenario: Сбой маппинга с повтором - **WHEN** вставка строк падает по ошибке БД - **THEN** транзакция откачена, `metrics.mapping_attempts = 1`, файл остаётся `PROCESSING`; на третьей неудаче — `ERROR` с `metrics.mapping_error` #### Scenario: Повторный маппинг идемпотентен - **WHEN** маппинг прерван после частичной вставки (имитация) и выполнен снова - **THEN** у файла ровно один набор документов и строк, дублей по `(document_id, row_number)` нет #### Scenario: Файл только с формами - **WHEN** смаплен golden-снимок `mezheninovskaya/2025-Q3/1_Форма1_бух_09` (одна форма `BALANCE`, ОСВ нет) - **THEN** файл `PROCESSED`, создан документ `BALANCE`, ошибки «нет документов ОСВ» нет #### Scenario: Пустой результат - **WHEN** результат без документов (`documents = []`): контракт v1.x такого результата не допускает — его останавливает проверка схемы, а прямой вызов `map_file` даёт `MappingFatal` - **THEN** файл `ERROR`, причина в `metrics.mapping_error` (при проверке схемы — ошибка схемы, в `map_file` — «в результате нет документов»), повтор не выполняется ### Requirement: Документ по листу ОСВ На каждый `osvDocument` ядро SHALL создать `document` с `document_type = OSV`, `osv_account_code` = нормализованный код из шапки (`60.1 → 60.01`, `58.03`, буквенные сегменты как есть), `period_year`, `period_quarter`, `is_cumulative` по шапке, `locator`, `legal_entity_id` владельца файла. Второй лист того же файла с тем же счётом MUST NOT создавать документ: в `metrics.mapping_warnings` пишется предупреждение, первый документ получает `NEEDS_CHECK`. #### Scenario: Два листа одного счёта - **WHEN** результат содержит два `osvDocument` со счётом `60` - **THEN** один `document`, статус `NEEDS_CHECK`, предупреждение называет оба `locator` ### Requirement: Строки ОСВ Ядро SHALL сохранить все строки `osvDocument.rows` кроме `group_type = TOTAL` (OQ-01) как `osv_entry`: `row_number`, `level`, `row_value`, `group_type`, `group_name`, `data_type` (включая `NU`), шесть сумм без изменений, `counterparty_name`/`counterparty_inn` как в результате, `parent_entry_id` по `parent_row_number`, `account_code` (как в результате), `account_root` (первый сегмент, `None` если он не цифровой — `ASSUMPTION(OQ-103)`), `account_id` (точное совпадение нормализованного кода с `chart_of_account.code`, иначе NULL), `owner_legal_entity_id`, `recognition_job_id`, `is_modified = false`. #### Scenario: Дерево и счета - **WHEN** смапплен результат `rb/2024-FY/ОСВ_66 за 2024.xls` - **THEN** строк `osv_entry` = строк результата − 1 (TOTAL); у «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» `parent_entry_id` указывает на строку `66.03`, `account_code = "66.03"`, `account_root = "66"`, `account_id` = строка плана счетов `66.03` #### Scenario: Все golden-снимки парсера - **WHEN** в БД маппятся все `../fixtures/golden/expected_osv_parse/**` (82 результата, маркер `fixtures`) - **THEN** для каждого: число строк = `rows` без TOTAL, у каждой строки уровня > 0 есть родитель, суммы равны снимку, `account_id` заполнен у всех строк-счетов, код которых есть в плане счетов seed ### Requirement: Резолв контрагента Для строк `group_type = COUNTERPARTY` с `data_type ∈ {BU, SUM}` ядро SHALL искать `counterparty_id` по §3 п. 4: по `counterparty_inn` в `legal_entity.inn`; иначе по точному `counterparty_name` в `legal_entity.name`; иначе по `normalize_alias(counterparty_name)` среди нормализованных имён ЮЛ и `legal_entity_alias.normalized_alias`; больше одного кандидата на шаге → `NULL`. Резолв SHALL выполняться по всему справочнику без фильтра видимости. Вместе с `counterparty_id` SHALL записываться `counterparty_match_kind`: `INN`, `NAME` или `ALIAS` по шагу, на котором нашёлся единственный кандидат (нормализованное имя ЮЛ и алиас — оба `ALIAS`); при `NULL` — NULL. #### Scenario: По ИНН, по алиасу, неоднозначно - **WHEN** в справочнике есть ЮЛ с ИНН `3528052718`, ЮЛ с алиасом `русский бисквит`, и два ЮЛ с одинаковым нормализованным именем `альфа` - **THEN** строка с ИНН `3528052718` → это ЮЛ, `counterparty_match_kind = INN`; строка «Русский бисквит» без ИНН → ЮЛ по алиасу, `ALIAS`; строка «ООО Альфа» → `counterparty_id = NULL`, `counterparty_match_kind = NULL` #### Scenario: Алиас приоритетнее ЮЛ-тёзки - **WHEN** в справочнике есть ЮЛ «ООО Гамма Сервис» и другое ЮЛ с алиасом `гамма сервис`, строка — «Гамма Сервис» без ИНН - **THEN** `counterparty_id` — владелец алиаса, `counterparty_match_kind = ALIAS`; тот же результат при дорезолве свода на чтение ### Requirement: Проверки шапки и статус документа `counterparty_check` SHALL быть `MATCH`, если ИНН шапки равен ИНН владельца, или (без ИНН) нормализованное имя шапки совпадает с нормализованным именем или алиасом владельца; `MISMATCH` иначе; `UNKNOWN` при пустой шапке. `period_check` SHALL быть `UNKNOWN` до появления версии консолидации (Срез 4). Для `period_quarter ∈ {Q2, Q3, Q4}` с `is_cumulative = false` ядро SHALL сверить сальдо на начало корневой строки с документом `Q1` того же года, ЮЛ и счёта (файл не `SUPERSEDED`) и при совпадении (±0.01) установить `is_cumulative = true` с предупреждением (`ASSUMPTION(OQ-02)`). Статус документа при маппинге SHALL быть `RECOGNIZED`, если задание `done`, `checks` без нарушений, `counterparty_check ≠ MISMATCH` и нет предупреждений маппинга; иначе `NEEDS_CHECK` (OQ-05). Документ `RECOGNIZED` в той же транзакции SHALL подтверждаться автоматически (требование «Автоподтверждение чистого документа»). #### Scenario: Шапка другой организации - **WHEN** файл загружен для ЮЛ «Пекарня Счастливочная», а в шапке `АО "Русский бисквит"` - **THEN** `counterparty_check = MISMATCH`, `status = NEEDS_CHECK` #### Scenario: Нарастающая по данным - **WHEN** у ЮЛ есть документ ОСВ 60 за 2024 Q1 (`opening_*` корня = X) и маппится ОСВ 60 «за 3 квартал 2024» с `opening_*` корня = X - **THEN** `is_cumulative = true`, предупреждение «нарастающая по данным Q1» #### Scenario: Чистый результат - **WHEN** задание `done`, шапка совпадает с владельцем, нарушений нет - **THEN** после маппинга `status = CONFIRMED` (автоподтверждение), `counterparty_check = MATCH`, `period_check = UNKNOWN` ### Requirement: Документ NEEDS_CHECK переводит версию в корректировку После фиксации документов файла и автоподтверждения чистых документов цикл маппинга SHALL для каждой версии в `DOCUMENTS_PROCESSING`, в периметр которой входит ЮЛ файла, проверить документы файла: если среди них есть документ `NEEDS_CHECK`, подходящий версии по периоду (правила привязки `documents-api`), версия SHALL перейти в `DATA_CORRECTION` с `changed_by = stored_file.uploaded_by` в той же транзакции маппинга (версии блокируются `FOR UPDATE` по `id` после файла). Если `uploaded_by` NULL, переход SHALL пропускаться с записью в лог. Привязка `consolidation_document` для документа `NEEDS_CHECK` при маппинге MUST NOT создаваться (привязку при маппинге создаёт только автоподтверждение). #### Scenario: Предупреждение маппинга двигает статус - **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл ЮЛ периметра с ОСВ 62 за 2024 Q3 в `NEEDS_CHECK` - **THEN** версия в `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; `consolidation_document` нет #### Scenario: Документ RECOGNIZED - **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл с единственным чистым документом за 2024 Q3 - **THEN** документ `CONFIRMED` и привязан; переход версии в `DATA_CORRECTION` записан автоподтверждением (комментарий «автоподтверждение документа …»), этот хук второго перехода не делает #### Scenario: Документ не подходит версии - **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл с единственным документом `NEEDS_CHECK` за 2025 Q1 - **THEN** статус версии не изменился ### Requirement: Документ формы ББ/ОФР Каждый `statementDocument` с `document_type ∈ {BALANCE, INCOME}` SHALL давать `document` (`osv_account_code = NULL`, `is_cumulative = NULL`, `locator` из результата, `period_check = UNKNOWN`) и `financial_statement_meta` 1:1. Дата периода — `header.report_date`, при её отсутствии — самая поздняя `columns[].period_date`; `period_year` — год даты, `period_quarter` — квартал, если дата — последний день марта, июня, сентября или декабря, иначе `NULL` с предупреждением; `reporting_date` — эта дата. Шапка: `inn`, `inn_original_value` = `header.inn`, `inn_confidence`, `inn_verified = (inn_confidence = 100)`; `organization_name`; `report_date` = `header.report_date`, `report_date_original_value` = `header.report_date_text`; `units` = `units_original_value` = `header.units`; `okud`, `okpo`, `okopf`, `okfs`, `okei`, `address` — как пришли (шапка 1.x — по полям 1.x); `statement_type` — `balance`/`income`. Колонки — по `columns[]`, `is_current = true` у колонки с самой поздней `period_date`. Строки — по `lines[]` с исходниками кода и названия; повтор кода сохраняется как отдельная строка (Р-4), порядок строк — `row_index` (с 0). Значение SHALL создаваться на каждую пару «строка × колонка»: из результата — `amount`, `string_value`, `confidence`, `is_recognized`; отсутствующая ячейка — пустая запись с `needs_review = false`; `is_modified = false`, `original_amount = NULL`. Сохранение результата — требование «Хранение результата формы v2.0 (DBML v5.19)». Нарушения `checks` результата `2.0` — в `document_issue`; для `1.x` §7 считает ядро при приёме (требование «Формы результата 1.x — §7 при приёме»). `counterparty_check` — тем же правилом, что у листа ОСВ (ИНН шапки, иначе имя или алиас владельца). #### Scenario: Форма из Excel - **WHEN** смаплен golden-снимок `mezheninovskaya/2025-Q3/1_Форма1_бух_09` - **THEN** документ `BALANCE`, `period_year = 2025`, `period_quarter = Q3`, `reporting_date = 2025-09-30`; у `financial_statement_meta` 37 строк и по значению на каждую пару строка × колонка #### Scenario: Книга с двумя формами - **WHEN** смаплен golden-снимок книги НПЗ `npz/2025-Q3/…НОВОРОССИЙСКИЙ ПРОКАТНЫЙ ЗАВОД` - **THEN** у файла документы `BALANCE` (132 строки) и `INCOME` (43 строки), оба за 2025 Q3 #### Scenario: Форма из OCR без даты отчёта - **WHEN** смаплен эталон нормализатора `ocr/expected_normalize/aep_6` (дата отчёта `null`, колонки баланса 2025-06-30, 2024-12-31, 2023-12-31) - **THEN** документ `BALANCE` за 2025 Q2, `is_current` — у колонки 2025-06-30, `report_date` в шапке `NULL`, `inn` `NULL` #### Scenario: Дата не конец квартала - **WHEN** дата отчёта формы — 2025-10-31 - **THEN** `period_year = 2025`, `period_quarter = NULL`, предупреждение маппинга, документ `NEEDS_CHECK` ### Requirement: Повтор формы в файле и прочие документы Каждый `BALANCE` / `INCOME` результата SHALL давать отдельный `document` с собственным `file_ordinal`; вторая форма того же типа в файле хранится (Р-10). Формы одного типа и одной пары `period_year`, `period_quarter` получают предупреждение маппинга «в файле есть другая форма <тип> за тот же период», документ — `NEEDS_CHECK`, автоподтверждения нет (Р-18). Документы `DISCLOSURE` и `OTHER` результата SHALL давать по одному `document` каждого типа на файл (`file_ordinal` — индекс первого такого документа в `documents[]`) без данных (`locator` — все `locator` этого типа через запятую, статус при маппинге `RECOGNIZED`, затем автоподтверждение → `CONFIRMED`). Форма `BALANCE`/`INCOME` без строк SHALL создаваться с шапкой и колонками (если колонки есть в результате). #### Scenario: Расшифровки - **WHEN** смаплен синтетический снимок `st06_header_variants` (форма `INCOME` и пять `DISCLOSURE`) - **THEN** у файла два документа: `INCOME` и один `DISCLOSURE` с пятью `locator` через запятую; `DISCLOSURE` — `CONFIRMED` #### Scenario: Форма без колонки кодов - **WHEN** смаплен синтетический снимок `st03_no_codes` (`BALANCE`, строк 0) - **THEN** документ `BALANCE` `NEEDS_CHECK` с шапкой, строк нет (колонок в снимке тоже нет — создаются те, что есть в результате) #### Scenario: Две формы одного типа и периода - **WHEN** результат содержит два `BALANCE` с одной парой `period_year`, `period_quarter` - **THEN** создаются два документа с разными `file_ordinal`, оба `NEEDS_CHECK` с предупреждением маппинга; ни один не автоподтверждается ### Requirement: Статус документа формы Документ `BALANCE`/`INCOME`, принятый по результату `1.x`, SHALL быть `RECOGNIZED`, только если одновременно: задание `done`; нет записей `document_issue` вида `relation` без акцепта (§7, посчитанный ядром при приёме, `statement-checks`); `counterparty_check ≠ MISMATCH`; `period_year` и `period_quarter` определены; ни у одного значения нет `needs_review = true` (при приёме `1.x` `needs_review` = **НЕ** `values_ok`); нет предупреждений маппинга по документу; в файле нет второй формы того же типа и периода (Р-18); есть хотя бы одна строка. Иначе `NEEDS_CHECK`. Для результата `2.0` условие — требование «Условие `RECOGNIZED` при приёме 2.0». Статус SHALL пересчитываться при маппинге, после правки формы и после акцепта, кроме документа `CONFIRMED`. Документ `RECOGNIZED` после маппинга SHALL подтверждаться автоматически (требование «Автоподтверждение чистого документа»); `RECOGNIZED`, полученный пересчётом после правки или акцепта, — только вручную. Документ формы `NEEDS_CHECK` SHALL участвовать в переходе версии в `DATA_CORRECTION` наравне с ОСВ (требование «Документ NEEDS_CHECK переводит версию в корректировку»). #### Scenario: Чистая форма Excel - **WHEN** смаплен `mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, ИНН шапки = ИНН владельца файла) - **THEN** документ `INCOME` `CONFIRMED` (автоподтверждение) #### Scenario: Форма OCR - **WHEN** смаплен эталон `ocr/expected_normalize/aep_3` (уверенность значений < 100, ИНН шапки ≠ ИНН владельца) - **THEN** документ `BALANCE` `NEEDS_CHECK`, `counterparty_check = MISMATCH` #### Scenario: Форма двигает версию - **WHEN** версия ГК 2025 Q3 в `DOCUMENTS_PROCESSING`, в периметре ЮЛ файла; смаплена форма `BALANCE` за 2025 Q3 в `NEEDS_CHECK` - **THEN** версия в `DATA_CORRECTION`, привязки `consolidation_document` нет #### Scenario: Чистая после правки — вручную - **WHEN** форма `NEEDS_CHECK` исправлена аналитиком (`PATCH …/statement`) и пересчёт дал `RECOGNIZED` - **THEN** документ остаётся `RECOGNIZED` до `POST …/confirm` ### Requirement: Автоподтверждение чистого документа В транзакции маппинга после автозамены прежних версий файла и до перехода версий по `NEEDS_CHECK` цикл маппинга SHALL перевести каждый документ файла, получивший при маппинге статус `RECOGNIZED`, в `CONFIRMED` (ТЗ 03_9 п. 4, 10_10; решение владельца 08.10.2026, OQ-157): `confirmed_by = NULL`, `confirmed_at = now`; у формы `BALANCE`/`INCOME` — `financial_statement_meta.reviewed_by = NULL`, `reviewed_at = now`; `audit_event(DOCUMENT_CONFIRM, PRIMARY_DOCUMENTS, object_type = document)` с `user_id = NULL`, `before_value.status = RECOGNIZED`, `after_value.status = CONFIRMED` и `description = «автоподтверждение: без замечаний»`. Форма `BALANCE`/`INCOME` с шапкой SHALL подтверждаться автоматически, только если выполнены условия ручного подтверждения (`period_year` и `period_quarter` определены, нет непройденных неакцептованных жёстких проверок §7); иначе она MUST оставаться `RECOGNIZED` без ошибки. Документы `NEEDS_CHECK` MUST NOT подтверждаться автоматически. Автоподтверждённый документ SHALL привязываться к версиям так же, как при ручном подтверждении (`documents-api` «Подтверждение документа»: все подходящие версии «в работе», замена привязки того же ключа R-1), и каждая версия, получившая привязку, из `INITIATED` или `DOCUMENTS_PROCESSING` SHALL переходить в `DATA_CORRECTION` с `changed_by = stored_file.uploaded_by` и `comment = «автоподтверждение документа »` (счёт — только у ОСВ). Если `uploaded_by` NULL, привязка SHALL создаваться, а переход — пропускаться с записью в лог. Пересчёт статуса формы после правки или акцепта MUST NOT подтверждать документ автоматически. #### Scenario: Чистая ОСВ сразу «Загружен» - **WHEN** смаплен результат `fixtures/osv/rb/2025-Q1/ОСВ сч 60 за 1 кв 2025 год.xlsx` для ЮЛ АО «Русский бисквит» (задание `done`, шапка совпадает с владельцем, нарушений и предупреждений нет) - **THEN** документ `OSV` 60 — `CONFIRMED`, `confirmed_by` NULL, `confirmed_at` заполнен; есть `audit_event(DOCUMENT_CONFIRM)` с `user_id` NULL и описанием «автоподтверждение: без замечаний» #### Scenario: Привязка и переход версии - **WHEN** версия ГК «Русский Бисквит» 2025 Q1 в `INITIATED`, РБ в периметре; смаплен чистый результат ОСВ 60 РБ за 2025 Q1, файл загрузил `ka_go` - **THEN** `consolidation_document(version, document)` создан; версия в `DATA_CORRECTION`; запись истории `INITIATED → DATA_CORRECTION` с `changed_by` = `ka_go` и комментарием «автоподтверждение документа OSV 60» #### Scenario: Документ с замечанием ждёт аналитика - **WHEN** файл ОСВ загружен для ЮЛ «Пекарня Счастливочная», а в шапке `АО "Русский бисквит"` - **THEN** документ `NEEDS_CHECK`, `confirmed_at` пуст, события `DOCUMENT_CONFIRM` нет, привязки нет #### Scenario: Чистая форма Excel - **WHEN** смаплен `statements-xlsx/mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, период определён) - **THEN** документ `INCOME` `CONFIRMED`, `reviewed_by` NULL, `reviewed_at` заполнен #### Scenario: Форма OCR не подтверждается сама - **WHEN** смаплен эталон `ocr/expected_normalize/БО 1 кв 2025` (уверенность значений < 100, дата отчёта не разобрана) - **THEN** документы `BALANCE` и `INCOME` — `NEEDS_CHECK`, не `CONFIRMED` #### Scenario: Файл без загрузившего - **WHEN** версия в `INITIATED`, ЮЛ в периметре; смаплен чистый результат файла с `uploaded_by` NULL - **THEN** документ `CONFIRMED` и привязан к версии; версия осталась `INITIATED`; в логе — строка о пропуске перехода #### Scenario: Автозамена чистой версией файла - **WHEN** документ ОСВ 60 РБ за 2025 Q1 из файла A привязан к версии 2025 Q1; загружен и смаплен файл B того же ключа (чистый), A заменён автозаменой - **THEN** привязка документа A снята, документ B `CONFIRMED` и привязан к версии ### 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`. Единственные случаи, когда элемент результата не записывается, — нарушение уникальности ключей хранения, и каждый из них MUST фиксироваться `document_issue` `MAPPING_WARNING` (`info`, источник `core`): значение с `column_index` вне колонок формы; повтор `column_index` колонки — остаётся первая колонка, остальные пропускаются; повтор `row_index` или `ref` у строк — остаётся первая строка, остальные пропускаются; `detail` с `parent_ref`, которого нет, хранится с `parent_row_index = null`. Уникальность `(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: Форма 2.0 хранится как пришла - **WHEN** результат `2.0` содержит две строки с кодом 1230 и строку без кода (`ref` с `parent_ref`) - **THEN** создано три строки с их `row_index`, `source_ref` и `parent_row_index`; повтор кода не сливается #### 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 их не касается #### Scenario: Повтор column_index колонки - **WHEN** в `columns[]` результата два элемента с `column_index = 1` - **THEN** сохраняется первая колонка, вторая пропускается, у документа `MAPPING_WARNING` #### Scenario: Повтор ref строки - **WHEN** в результате две строки с одним `ref` - **THEN** сохраняется первая, вторая пропускается с `MAPPING_WARNING`; приём не падает ### 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: Условие `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: Формы результата 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`; собственного пересчёта §7 ядро MUST NOT выполнять. Условие 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: Проверки ядра при приёме результата 2.0 При приёме результата `2.0` ядро SHALL писать в `document_issue` формы предупреждения и `checks` результата (источник `service`) и проверки ядра (источник `core`): `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` (редакция — `edition` точки входа пакета, а не по дате; одна запись на код документа, `line_ids` — все строки `ok` кода; **только для кодов из ровно четырёх цифр** — пятизначные подстроки Excel не проверяются; запись не пишется, если пакет не определил редакцию), `NAME_MISMATCH`, `RESULT_STATUS_INCONSISTENT` (согласованность `done` — вызовом `engine.contract_document_status` пакета) и `STATEMENTS_VERSION_MISMATCH` (при отличии `statements_version` задания от версии копии ядра; у `1.x` не пишется). Проверки §7, `needs_review` и предупреждения сервиса MUST NOT пересчитываться при загрузке. Статус документа формы — по требованию «Условие `RECOGNIZED` при приёме 2.0». #### Scenario: Пятизначный код не даёт info - **WHEN** в ББ строка с кодом `12301` (подстрока Excel), которого нет в словаре - **THEN** `CODE_NOT_IN_DICTIONARY` и `CODE_NOT_IN_EDITION` для него не пишутся #### Scenario: Четырёхзначный код вне перечня - **WHEN** в ББ строка 1120 на бланке `fsbu4`, где такого кода нет - **THEN** запись `CODE_NOT_IN_EDITION` (`info`, `core`) с `line_ids` строк `ok` этого кода #### Scenario: Версия копии отличается - **WHEN** результат `2.0` принят с `statements_version`, отличной от версии копии ядра - **THEN** запись `STATEMENTS_VERSION_MISMATCH` (`info`, `core`) уже при приёме ### Requirement: Ведомость ОСВ по контракту 2.0 Для листа ОСВ результата `2.0` ядро SHALL создавать документ ОСВ по `osvDocument`, предупреждения листа (`osvDocument.warnings`, `OSV_*`) писать в `document_issue` (вид `warning`, источник `service`), а статус документа определять по требованию «Предупреждения файла и ОСВ в API, статус ОСВ при приёме 2.0». Статус ОСВ MUST NOT зависеть от статуса соседнего документа книги и от статуса конверта. #### Scenario: ОСВ в книге с ОФР, требующим проверки - **WHEN** книга из ОСВ (`status = done`, контрольные суммы сошлись) и ОФР (`needs_check`) - **THEN** документ ОСВ `RECOGNIZED`, документ ОФР `NEEDS_CHECK`