CounterpartyIndex.resolve: порядок шагов ИНН -> точное имя -> алиас -> нормализованное имя (OQ-128, §3 п. 4). Алиас уникален (ALIAS_TAKEN), поэтому владелец алиаса берётся с match_kind = ALIAS, даже если есть ЮЛ-тёзки по нормализованному имени; NULL остаётся при нескольких совпадениях только по имени. Правило общее для маппинга (Срез 2c) и дорезолва свода на чтение (§3 п. 7). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
194 lines
19 KiB
Markdown
194 lines
19 KiB
Markdown
# 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).
|
||
|
||
#### 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 = RECOGNIZED`, `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` при маппинге MUST NOT создаваться.
|
||
|
||
#### Scenario: Предупреждение маппинга двигает статус
|
||
- **WHEN** версия 2024 Q3 в `DOCUMENTS_PROCESSING`; смапплен файл ЮЛ периметра с ОСВ 62 за 2024 Q3 в `NEEDS_CHECK`
|
||
- **THEN** версия в `DATA_CORRECTION`, запись истории с `changed_by` = загрузивший; `consolidation_document` нет
|
||
|
||
#### Scenario: Документ RECOGNIZED
|
||
- **WHEN** смапплен файл с единственным документом `RECOGNIZED`
|
||
- **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`). Форма `BALANCE`/`INCOME` без строк
|
||
SHALL создаваться с шапкой и колонками (если колонки есть в результате).
|
||
|
||
#### Scenario: Расшифровки
|
||
- **WHEN** смаплен синтетический снимок `st06_header_variants` (форма `INCOME` и пять `DISCLOSURE`)
|
||
- **THEN** у файла два документа: `INCOME` и один `DISCLOSURE` с пятью `locator` через запятую
|
||
|
||
#### 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`. Документ формы
|
||
`NEEDS_CHECK` SHALL участвовать в переходе версии в `DATA_CORRECTION` наравне с ОСВ (требование «Документ NEEDS_CHECK
|
||
переводит версию в корректировку»).
|
||
|
||
#### Scenario: Чистая форма Excel
|
||
- **WHEN** смаплен `mezheninovskaya/2025-Q3/1_Форма2_бух_09` (ОФР, `done`, соотношения сходятся, ИНН шапки = ИНН владельца файла)
|
||
- **THEN** документ `INCOME` `RECOGNIZED`
|
||
|
||
#### 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` нет
|