Andrey Epifancev 7dcffbd10d Резолв контрагента: алиас приоритетнее ЮЛ-тёзки по нормализованному имени (#57)
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
2026-10-08 00:21:15 +04:00

19 KiB
Raw Blame History

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 нет