2026-10-08 18:11:56 +04:00

258 lines
26 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

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

# 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`, `address` — как пришли; `statement_type` — `balance`/`income`. Колонки — по `columns[]`,
`is_current = true` у колонки с самой поздней `period_date`. Строки — по `lines[]` с исходниками кода и названия; повтор
кода — первая строка и предупреждение. Значение SHALL создаваться на каждую пару «строка × колонка»: из результата —
`amount`, `string_value`, `confidence`, `is_recognized`; отсутствующая ячейка — все поля `NULL`; `is_modified = false`,
`original_amount = NULL`. `checks` результата MUST NOT использоваться для статуса. `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` в одном файле MUST NOT создаваться: предупреждение маппинга, первый документ —
`NEEDS_CHECK`. Документы `DISCLOSURE` и `OTHER` результата SHALL давать по одному `document` каждого типа на файл без
данных (`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` с шапкой, строк нет (колонок в снимке тоже нет — создаются те, что есть в результате)
### Requirement: Статус документа формы
Документ `BALANCE`/`INCOME` SHALL быть `RECOGNIZED`, только если одновременно: задание `done`; нет непройденных
неакцептованных жёстких проверок §7 (`statement-checks`); `counterparty_check ≠ MISMATCH`; `period_year` и
`period_quarter` определены; у каждого значения `is_modified = true` либо (`is_recognized` не `false` и `confidence`
равна 100 или `NULL`); нет предупреждений маппинга по документу; есть хотя бы одна строка. Иначе `NEEDS_CHECK`. Статус
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 = «автоподтверждение документа <document_type> <osv_account_code>»` (счёт — только у
ОСВ). Если `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` и привязан к версии