docs(#195): change counter-pairs — пары участников без встречных записей (AURORA-1746)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 18:33:58 +04:00
parent 69cc52025a
commit e9977fb221
6 changed files with 336 additions and 0 deletions

View File

@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-27

View File

@ -0,0 +1,93 @@
# Дизайн: пары участников без встречных записей (AURORA-1746, arm-core #195)
## Контекст и зачем
Перед шахматкой аналитику важно одно: все ли внутригрупповые связи видны с обеих сторон. Шахматка строится по строкам
свода с признаком «ГСК». Если у компании Б контрагент А лежит строкой без ИНН и без сопоставления, он «Внешний»,
и пара А–Б в шахматке выглядит однобокой.
Сейчас это подменено счётчиком всех строк без ИНН. Баннер требует «сопоставьте все», модалка [31-00] всплывает при каждом
уходе с «ОСВ», хотя переход не блокируется (AURORA-1388). Заказчик (Циликов, 23.10.2026) прочитал это как обязанность
прокликать каждую строку, а счётчик на реальных данных до нуля не доходит.
Успех: аналитик видит только существенное — пары участников без встречных записей — и разбирает их в одном месте, видя
обе стороны. Строки без ИНН, не влияющие на пары, ему не мешают.
Антицель: экран, который снова заставляет «прокликать всё», или модалка, которая всплывает при каждом клике.
## Решения владельца (27.10.2026, обсуждение в чате)
1. Проверка — пары участников периметра: у А есть записи с контрагентом Б, у Б нет записей с контрагентом А. Уровень —
компании, не счета.
2. У Б может быть настоящий пропуск в учёте, поэтому переход никогда не блокируется.
3. Модалка — вариант «короткая сводка»: только цифры, разбор — в отдельном представлении («Показать пары»).
4. Представление — отдельный режим вкладки «ОСВ» с таблицами («они любят таблицы»): список пар, строки А с Б, строки Б
без ИНН и без сопоставления.
5. Подсветка кандидата по совпадению суммы — отдельная задача.
6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ.
## Устройство
### Ядро
- `domain/consolidated_osv.py`: `counter_pairs(rows, members) -> list[CounterPair]` — чистая функция по строкам
`Consolidated.rows` (после дорезолва) и периметру. Множество «кто кого видит» — пары `(row.legal_entity_id,
row.counterparty_id)`, где оба в периметре и различны. Пара (А, Б) в списке ⇔ (А, Б) есть, (Б, А) нет. Счётчики:
`holder_rows` — строки А с контрагентом Б; `missing_unmapped_rows` — строки Б с `needs_mapping`; `missing_rows_total` —
все строки Б. Сортировка — имя Б, имя А (затем id для стабильности).
- `OsvFilter.counterparty_id` + `_matches`; `OsvFilters.counterparty_id` в схеме; экспорт принимает тот же фильтр
автоматически (общая модель фильтров).
- `OsvSummaryOut.counter_pairs: list[CounterPairOut]`; `ConsolidatedOsvService.summary` считает их из того же `_build`,
второго прохода по БД нет.
- Аудит не пишется (чтение).
### Фронт
- `useConsolidatedOsv` / `ConsolidatedOsvContent`: режим из `?view=pairs` (`useSearchParams`, `replace`).
- Новый виджет `widgets/ConsolidatedOsv/CounterPairs*.tsx`: список пар + две таблицы (antd `Table`), колонки — счёт,
контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт; суммы — через существующий `useNumberDisplay` (единицы и знаки
из шапки). Правая таблица — постранично, `OSV_PAGE_SIZE`, «Указать» → `CounterpartyMapModal` (существующая).
- `ConsolidationReportPage` / `OsvTabs`: вместо `unmappedTotal` получают сводку (пары и `unmapped_total`); правило показа
модалки — чистая функция в `utils/consolidations.ts` (`counterPairsKey(pairs)`, `shouldWarnCounterPairs(versionId,
pairs, storage)`), с тестами vitest. Ключ `sessionStorage` — `armka:counter-pairs:<version_id>`, значение — ключ списка
пар (`holder_id>missing_id`, отсортированные, через запятую). Чтение и запись — в `try/catch`.
- `UnmappedInnModal` заменяется модалкой «Проверьте встречные записи участников»; склонение «пара/пары/пар»,
«участника/участников» — существующая `pluralRu` (`web/src/utils/labels.ts`).
- Над правой таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде):
у РБ на фикстуре около тысячи строк без ИНН, листать страницы в поисках одной строки — та же «прокликай всё».
- Модалка не показывается для версий в завершённых статусах (`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED` —
`FINISHED_STATUSES`): править там нечего. Режим «Встречные записи» доступен в любом статусе, «Указать» ведёт себя как
в своде (ошибки 409 — от сервера).
- Баннер — новый текст (спека `consolidated-osv-ui`).
### Документы (отдельный PR в docs)
- `05-domain-rules.md` §3 п. 9 «Пары участников без встречных записей» — определение из спеки `consolidated-osv`.
- `10-user-journey.md` строка 3.1 — новый текст баннера, режим «Встречные записи», модалка вместо [31-00].
- `dev/api-contract-slice-4.md` — `counter_pairs[]` в сводке, фильтр `counterparty_id`.
## Что оставлено исполнителю
Раскладка трёх колонок и ширины, тексты пустых состояний в пределах спеки, имена компонентов, вынос общих колонок сумм
между сводом и новым режимом.
## Риски
- Объём: `GET …/osv/summary` теперь считает пары — линейный проход по уже собранным строкам, без новых запросов к БД.
- Дорезолв на чтение влияет на пары: появление алиаса может закрыть пару без «Связать». Это корректно — признак считается
от того же свода, что видит аналитик.
## Decisions made without discussion
| Дата | Решение | Обоснование | Цена ошибки |
|---|---|---|---|
| 27.10.2026 | «После исправлений» = список пар изменился (ключ списка пар в `sessionStorage`), а не любое «Связать» | Повтор модалки нужен, когда есть что сказать нового; «Связать» вне пар модалку не меняет | Низкая: правило в одной функции фронта |
| 27.10.2026 | Строки А и Б берутся существующим `GET …/osv` с фильтрами (`counterparty_id` — новый), в сводке — только пары и счётчики | Без нового эндпоинта; пагинация и экспорт работают как в своде | Низкая |
| 27.10.2026 | Учитываются все строки свода, без ограничения счетами правил элиминации и без порога по суммам | Владелец согласовал уровень компаний; строка с нулями тоже говорит, что контрагент виден | Средняя: возможны «лишние» пары из-за случайных счетов — правится фильтром в функции |
| 27.10.2026 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Три колонки на экране; для поиска пары важнее обороты и итог | Низкая: добавить колонки |
| 27.10.2026 | Режим — параметр адреса `?view=pairs` (с `holder`/`missing`), переключение без записи в историю | Как вкладки отчёта (AURORA-1851); ссылку на пару можно переслать | Низкая |
| 27.10.2026 | Тексты баннера и модалки — из спек этого change | Владелец согласовал смысл, формулировки мои | Низкая |
| 27.10.2026 | Поиск `q` над правой таблицей режима (находка builder check) | У РБ ~1000 строк без ИНН; без поиска разбор пары — листание страниц | Низкая: существующий фильтр API |
| 27.10.2026 | Модалка не показывается для версий в `FINISHED_STATUSES` (находка builder check) | Замороженную версию не исправить — предупреждение было бы шумом | Низкая |
Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867.

View File

@ -0,0 +1,67 @@
## Why
AURORA-1746 и замечание заказчика (Циликов А.В., 23.10.2026, строка 10 чек-листа, AURORA-1865): «при загрузке ОСВ без
ИНН необходимо сопоставить все организации без ИНН… нужно, чтобы система несопоставленные организации считала внешними и
позволяла формировать консолидацию без прокликивания каждой».
Расчёт уже так работает: несопоставленный контрагент — «Внешний» (§3 п. 5), переход к шахматке не блокируется (решение
заказчика 16.10.2026, AURORA-1388). Ощущение обязательности создают баннер над сводом «Для перехода к следующему шагу
сопоставьте все внешние организации без ИНН» и модалка [31-00], которая всплывает при каждом уходе с «ОСВ», пока
`unmapped_total > 0`. Счётчик до нуля на реальных данных не доходит: в него попадают зарплата, подотчётные лица и прочие
строки без организации.
Для шахматки важны не все строки без ИНН, а только те, где прячется участник периметра. Признак этого — пара участников,
где у одного есть записи с другим, а у другого встречных записей нет. Такие пары и нужно показать аналитику перед
шахматкой, с местом для разбора (решение владельца 27.10.2026).
## What Changes
Репозиторий — arm-core (`api/`, `web/`). Срез 4 (сводная ОСВ), доработка. Правило — `05-domain-rules.md` §3 п. 9
(отдельный PR в docs).
- **Ядро.** Чистая функция «пары без встречных записей» в `domain/consolidated_osv.py`: для участников периметра А ≠ Б
пара (А, Б) — у А есть строка свода с `counterparty_id = Б`, у Б нет строки с `counterparty_id = А`. `GET …/osv/summary`
отдаёт список `counter_pairs[]`; `GET …/osv` и экспорт получают фильтр `counterparty_id`. Ничего не хранится.
- **Фронт, режим «Встречные записи».** Над сводом — переключатель «Свод | Встречные записи (N)». Во втором режиме: слева
список пар, в центре — строки А с контрагентом Б, справа — строки Б без ИНН и без сопоставления с «Указать» (та же
модалка сопоставления, что в своде).
- **Фронт, модалка перед шахматкой.** Вместо [31-00] «Остались неуказанные ИНН» — «Нет встречных записей: N пар у M
участников», кнопки «Показать пары» / «Остаться» / «Перейти». Показывается при уходе с «ОСВ», если пары есть, один раз
на версию за сессию браузера и снова — если список пар изменился. Строки без ИНН сами по себе модалку не вызывают.
- **Фронт, баннер.** Текст без «сопоставьте все»: несопоставленные учитываются как внешние; счётчик «Осталось: N» и
кнопка «Показать ЮЛ без ИНН» остаются.
## Non-goals
- Подсветка строки-кандидата у Б по совпадению суммы с зеркальной суммой у А (правила элиминации) — отдельная задача.
- Хранение решения аналитика «встречных записей нет, это пропуск в учёте» — в БД ничего не пишется (решение владельца
27.10.2026); DBML не меняется.
- Отсев строк, не являющихся организациями (зарплата, подотчётники), из счётчика `unmapped_total`.
- Сверка по счетам и суммам внутри пары — это делает шахматка (§6).
- Поиск участника, ошибочно сопоставленного с другим ЮЛ: справа показываются только несопоставленные строки без ИНН.
- Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют
сопоставления, в правую таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение
владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину.
## Capabilities
### New Capabilities
### Modified Capabilities
- `consolidated-osv`: ADDED «Пары участников без встречных записей»; MODIFIED «Страница свода и сводка» (фильтр
`counterparty_id`, `counter_pairs[]` в сводке).
- `consolidated-osv-ui`: ADDED «Режим «Встречные записи»», ADDED «Баннер строк без ИНН».
- `consolidations-ui`: REMOVED «Предупреждение «Остались неуказанные ИНН»»; ADDED «Предупреждение «Нет встречных
записей»».
## Impact
- Таблицы DBML v5.23 — только чтение: `consolidation_member`, `osv_entry`, `legal_entity`, `consolidation_document`.
- `api/src/domain/consolidated_osv.py`, `api/src/services/consolidated_osv.py`, `api/src/schemas/consolidations.py`,
`api/src/api/endpoints/consolidations/osv.py` (+ export), тесты `api/tests/`.
- `web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedOsv/` (`OsvTabs.tsx`, новый режим),
`web/src/layouts/ConsolidationReportPage.tsx`, `web/src/utils/consolidations.ts`, `web/src/services/api/osv.ts`,
`web/src/types/api.ts`.
- Документация: `../docs/context/05-domain-rules.md` §3 п. 9, `../docs/context/10-user-journey.md` (строка 3.1),
`../docs/dev/api-contract-slice-4.md` — отдельный PR в docs.

View File

@ -0,0 +1,50 @@
## ADDED Requirements
### Requirement: Баннер строк без ИНН
Пока в сводке `unmapped_total > 0`, над таблицей свода SHALL показываться баннер (стиль `osv-mapping-alert` макета [30]):
заголовок «Есть организации без ИНН и без сопоставления», текст «Осталось: N. Несопоставленные учитываются как внешние;
при необходимости укажите организацию кнопкой «Указать».» и кнопка «Показать ЮЛ без ИНН» / «Показать все строки»
(фильтр `unmapped`, как прежде). Баннер MUST NOT требовать сопоставить все строки для перехода к следующему шагу:
переход не блокируется (решение заказчика 16.10.2026, AURORA-1388). При `unmapped_total = 0` баннера нет.
#### Scenario: Есть строки без ИНН
- **WHEN** открыт свод версии РБ 2024 Q3 до сопоставления (`unmapped_total` = 1260)
- **THEN** баннер «Есть организации без ИНН и без сопоставления», «Осталось: 1260. Несопоставленные учитываются как
внешние…»; слов «Для перехода к следующему шагу сопоставьте все» на экране нет
### Requirement: Режим «Встречные записи»
Над сводом на вкладке «ОСВ» SHALL быть переключатель режимов «Свод | Встречные записи (N)», N — число пар
`summary.counter_pairs`. Режим — параметр адреса `?view=pairs` (без параметра — «Свод»); переключение режима MUST NOT
добавлять запись в историю браузера. Шапка свода (единицы, знаки после запятой) действует в обоих режимах.
В режиме «Встречные записи» экран SHALL показывать три колонки:
- **слева** — список пар в порядке ответа, строка пары «Б ← А» (имена `missing_name` и `holder_name`) с числами
`holder_rows` и `missing_unmapped_rows`; выбранная пара подсвечена; при входе выбрана первая пара либо пара из адреса
(`?view=pairs&holder=<id>&missing=<id>`);
- **в центре** — «У <А> есть записи с <Б>»: строки `GET …/osv?legal_entity_id=А&counterparty_id=Б` — счёт, контрагент,
обороты Дт/Кт, сальдо на конец Дт/Кт;
- **справа** — «У <Б> — строки без ИНН и без сопоставления»: строки `GET …/osv?legal_entity_id=Б&unmapped=true` — счёт,
контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт и «Указать» в каждой строке; над таблицей — поиск по контрагенту
(`q`, ≤ 500 символов, как фильтр свода); постранично, как таблица свода. Если
`missing_rows_total = 0` — вместо таблицы текст «У <Б> в своде нет строк: ОСВ не привязаны или по ним пробелы»; если
строки у Б есть, а без ИНН и без сопоставления нет (`missing_unmapped_rows = 0`) — текст «Строк без ИНН нет: возможно,
ИНН контрагента из файла не найден в справочнике или это пропуск в учёте».
«Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и
ошибками; после «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается
первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»).
#### Scenario: Пара Русского бисквита
- **WHEN** открыт свод версии РБ 2024 Q3 до сопоставления и выбран режим «Встречные записи»
- **THEN** переключатель «Встречные записи (1)»; слева пара «Русский бисквит ← Пекарня Счастливочная»; в центре строки
Счастливочной с контрагентом «Русский бисквит»; справа строки РБ без ИНН, среди них «Пекарня Счастливочная ООО» с
«Указать»
#### Scenario: Сопоставление из режима закрывает пару
- **WHEN** в правой таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная»
- **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель —
«Встречные записи (0)»
#### Scenario: Режим из адреса
- **WHEN** открыт адрес `…/versions/{id}/osv?view=pairs`
- **THEN** показан режим «Встречные записи» с первой парой; кнопка «Назад» браузера не проходит по переключениям режимов

View File

@ -0,0 +1,75 @@
## ADDED Requirements
### Requirement: Пары участников без встречных записей
Ядро SHALL вычислять по своду версии пары участников без встречных записей (§3 п. 9 `05-domain-rules.md`, AURORA-1746):
для участников периметра А ≠ Б (`consolidation_member` версии) пара (А, Б) входит в список, если в своде есть хотя бы одна
строка ЮЛ А с `counterparty_id = Б` (сохранённым или дорезолвленным на чтение) и нет ни одной строки ЮЛ Б с
`counterparty_id = А`. А — сторона, у которой записи есть (`holder`), Б — сторона без встречных записей (`missing`).
Пара, где записей нет ни у А, ни у Б, в список MUST NOT входить. Строки ЮЛ с контрагентом — самим собой не
учитываются. Счета, суммы и тип строки (`data_type`) на признак не влияют: учитываются все строки свода. Расчёт —
чистая функция в `domain/` от строк свода и периметра; результат MUST NOT сохраняться. Порядок списка — по имени Б,
затем по имени А.
У каждой пары SHALL быть: `holder_id`, `holder_name`, `missing_id`, `missing_name`, `holder_rows` (число строк А с
контрагентом Б), `missing_unmapped_rows` (число строк Б с `unmapped = true`), `missing_rows_total` (число всех строк Б в
своде; 0 — у Б нет строк, например не привязано ни одной ОСВ или все её пары счетов — пробелы).
#### Scenario: Русский бисквит до сопоставления
- **WHEN** версия ГК «Русский бисквит» 2024 Q3 с периметром {АО «Русский бисквит», ООО «Пекарня Счастливочная»}, 22 ОСВ
из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}` подтверждены; у Счастливочной контрагент «Русский бисквит» с ИНН
3528052718 сопоставлен по ИНН; у РБ контрагент «Пекарня Счастливочная ООО» без ИНН не сопоставлен
- **THEN** список содержит одну пару: `holder` — ООО «Пекарня Счастливочная», `missing` — АО «Русский бисквит»,
`holder_rows` ≥ 1, `missing_unmapped_rows` ≥ 1
#### Scenario: Пара закрыта ручным сопоставлением
- **WHEN** в той же версии строка РБ «Пекарня Счастливочная ООО» связана с ООО «Пекарня Счастливочная»
(`POST …/osv/entries/{entry_id}/counterparty`)
- **THEN** список пар пуст
#### Scenario: Компании не связаны
- **WHEN** в периметре три участника; у А и Б нет записей друг с другом ни в одну сторону
- **THEN** пары (А, Б) и (Б, А) в списке нет
#### Scenario: У стороны нет строк
- **WHEN** у А есть строки с контрагентом Б, а у Б в своде нет ни одной строки (ОСВ не привязаны)
- **THEN** пара (А, Б) в списке, `missing_rows_total = 0`, `missing_unmapped_rows = 0`
## MODIFIED Requirements
### Requirement: Страница свода и сводка
`GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`,
`row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`,
`counterparty_type`, `counterparty_id` (строка с этим `counterparty_id` после дорезолва; AURORA-1746), `unmapped=true`
(строка требует сопоставления: `counterparty_id` пуст после дорезолва **и** `counterparty_inn` пуст или из пробелов;
строка с ИНН из файла, которого нет в справочнике, сопоставления не требует — решение владельца 08.10.2026, отмена П-1;
`unmapped=false` — все остальные строки), `match_kind` (повторяемый), `q` (подстрока без регистра в `counterparty_name`
или префикс `counterparty_inn`). Те же фильтры SHALL принимать `GET …/osv/export`. `GET …/osv/summary` SHALL отдавать
`rows_total`, `unmapped_total` (строки `unmapped=true`), `auto_matched_total` (строки с `match_kind` ∈ {`NAME`, `ALIAS`}
независимо от `match_stored`), `documents_total` (привязанные ОСВ-документы), `gaps[]` (`legal_entity_id`,
`legal_entity_name`, `account_code`, `missing_quarters`) и `counter_pairs[]` — пары участников без встречных записей
(требование «Пары участников без встречных записей»). Версия в любом статусе, включая `CANCELLED`, SHALL читаться.
#### Scenario: Только несопоставленные
- **WHEN** запрошено `GET …/osv?unmapped=true`
- **THEN** все строки ответа с `counterparty_id = null` и без `counterparty_inn`, `total` = `summary.unmapped_total`
#### Scenario: Строка с ИНН вне справочника не требует сопоставления
- **WHEN** у строки `counterparty_inn = "7701000001"`, такого ИНН и имени в справочнике нет (`counterparty_id = null`)
- **THEN** строки нет в `GET …/osv?unmapped=true`, она не входит в `summary.unmapped_total`
#### Scenario: ИНН сопоставленного ЮЛ
- **WHEN** строка без ИНН в файле сопоставлена с ЮЛ по алиасу (на лету или хранимо)
- **THEN** `counterparty_inn = null`, `counterparty_entity_inn` — ИНН этого ЮЛ из справочника
#### Scenario: Фильтр по способу
- **WHEN** запрошено `GET …/osv?match_kind=NAME&match_kind=ALIAS`
- **THEN** в ответе только строки с этими `match_kind`, `total` = `summary.auto_matched_total`
#### Scenario: Строки одной стороны пары
- **WHEN** в `summary.counter_pairs` есть пара `holder_id = А`, `missing_id = Б` и запрошено
`GET …/osv?legal_entity_id=А&counterparty_id=Б`
- **THEN** `total` = `holder_rows` пары, у всех строк ответа ЮЛ А и `counterparty_id = Б`
#### Scenario: Кандидаты другой стороны пары
- **WHEN** для той же пары запрошено `GET …/osv?legal_entity_id=Б&unmapped=true`
- **THEN** `total` = `missing_unmapped_rows` пары

View File

@ -0,0 +1,49 @@
## REMOVED Requirements
### Requirement: Предупреждение «Остались неуказанные ИНН»
**Reason**: модалка всплывала при каждом уходе с «ОСВ», пока `unmapped_total > 0`, а счётчик на реальных данных до нуля
не доходит (зарплата, подотчётные лица); заказчик воспринимал её как требование сопоставить всё (AURORA-1746, чек-лист
Циликова 23.10.2026). Для шахматки важны только пары участников без встречных записей.
**Migration**: заменено требованием «Предупреждение «Нет встречных записей»».
## ADDED Requirements
### Requirement: Предупреждение «Нет встречных записей»
При уходе со вкладки «ОСВ» на другую вкладку отчёта версии фронт SHALL показывать модалку «Проверьте встречные записи
участников», если в сводке есть пары `counter_pairs` и этот список пар ещё не показывался в модалке для этой версии в
текущей сессии браузера. Текст: «Нет встречных записей: N пар у M участников.» (N — число пар, M — число разных `missing_id`;
слова согласуются с числом: «1 пара у 1 участника», «2 пары у 2 участников», «5 пар у 3 участников»)
и «Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте. Ещё K строк без
ИНН учтены как внешние.» (K —
`unmapped_total`; при K = 0 вторая фраза без последнего предложения). Кнопки:
- «Показать пары» — закрыть модалку без перехода и открыть на вкладке «ОСВ» режим «Встречные записи» с первой парой;
- «Остаться» — закрыть модалку без перехода;
- «Перейти» — выполнить переход.
Показ запоминается в `sessionStorage` браузера по версии вместе со списком пар (упорядоченные пары `holder_id → missing_id`);
повторный уход с «ОСВ» в той же сессии модалку не показывает, пока список пар не изменился (например, сопоставление
закрыло одну из пар — модалка покажет оставшиеся). Недоступное хранилище — модалка показывается при каждом уходе, ошибкой это
не считается. Без пар (`counter_pairs` пуст) переход SHALL выполняться без модалки при любом `unmapped_total`. В БД ничего
не пишется (решение владельца 27.10.2026). Для версии в завершённом статусе (`VERIFIED`, `CANCELLED`, `ARCHIVED`,
`REJECTED`) модалка не показывается. Пока шагов после ОСВ нет, их вкладки неактивны и модалка не вызывается.
#### Scenario: Пара без встречных записей
- **WHEN** в сводке одна пара (Русский бисквит ← Пекарня Счастливочная), `unmapped_total = 1260`, и аналитик выбирает
вкладку «Шахматка»
- **THEN** показана модалка «Нет встречных записей: 1 пара у 1 участника…»; «Перейти» открывает шахматку
#### Scenario: Повтор в той же сессии
- **WHEN** аналитик вернулся на «ОСВ» и снова выбирает «Шахматку», список пар не изменился
- **THEN** шахматка открывается без модалки
#### Scenario: Список пар изменился
- **WHEN** после показа модалки аналитик сопоставил контрагента, закрыв одну из двух пар, и выбирает «Шахматку»
- **THEN** модалка показана снова с «Нет встречных записей: 1 пара у 1 участника…»
#### Scenario: Только строки без ИНН
- **WHEN** `counter_pairs` пуст, `unmapped_total = 214`, и аналитик выбирает «Шахматку»
- **THEN** шахматка открывается без модалки
#### Scenario: Показать пары
- **WHEN** в модалке нажато «Показать пары»
- **THEN** вкладка «ОСВ» остаётся, открыт режим «Встречные записи» с первой парой