# Дизайн: пары участников без встречных записей (AURORA-1746, arm-core #195) ## Контекст и зачем Перед шахматкой аналитику важно одно: все ли внутригрупповые связи видны с обеих сторон. Шахматка строится по строкам свода с признаком «ГСК». Если у компании Б контрагент А лежит строкой без ИНН и без сопоставления, он «Внешний», и пара А–Б в шахматке выглядит однобокой. Сейчас это подменено счётчиком всех строк без ИНН. Баннер требует «сопоставьте все», модалка [31-00] всплывает при каждом уходе с «ОСВ», хотя переход не блокируется (AURORA-1388). Заказчик (Циликов, 23.10.2026) прочитал это как обязанность прокликать каждую строку, а счётчик на реальных данных до нуля не доходит. Успех: аналитик видит только существенное — пары участников без встречных записей — и разбирает их в одном месте, видя обе стороны. Строки без ИНН, не влияющие на пары, ему не мешают. Антицель: экран, который снова заставляет «прокликать всё», или модалка, которая всплывает при каждом клике. ## Решения владельца (27.10.2026, обсуждение в чате) 1. Проверка — пары участников периметра: у А есть записи с контрагентом Б, у Б нет записей с контрагентом А. Уровень — компании, не счета. 2. У Б может быть настоящий пропуск в учёте, поэтому переход никогда не блокируется. 3. Модалка — вариант «короткая сводка»: только цифры, разбор — в отдельном представлении («Показать пары»). 4. Представление — отдельный режим вкладки «ОСВ» с таблицами («они любят таблицы»): список пар, строки А с Б, строки Б без ИНН и без сопоставления. 5. Подсветка кандидата по совпадению суммы — отдельная задача. 6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ. 7. (после показа на стенде) «Указать» в режиме пар открывает модалку сопоставления, сразу суженную до участника пары, с флажком «Показать все организации» — вариант 1 из двух показанных; кнопку «Связать с <А>» прямо в строке не делаем (легко промахнуться). ## Устройство ### Ядро - `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, status, pairs, storage)`), с тестами vitest. Ключ `sessionStorage` — `armka:counter-pairs:`, значение — ключ списка пар (`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 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Колонки должны уместиться на всю ширину при 1440; для поиска пары важнее обороты и итог | Низкая: добавить колонки | | 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 | R-1 (design #9): `needsUnmappedWarning` удаляется в Task 4 вместе с его использованием в `OsvTabs`, а не в Task 3 | Иначе между коммитами Task 3 и 4 сборка (tsc) красная | Нулевая | | 27.10.2026 | R-2 (design #10): в тексте модалки «Ещё K строк без ИНН…» слово «строк» не склоняется, M — число разных `missing_id` (по спеке) | Спека дословна; склонение K=1 даёт всё равно неграмматичную фразу («учтены») | Низкая | | 27.10.2026 | R-3 (design #11): отдельное ревью Task 6 не проводится — её diff в arm-core (одна фраза в CLAUDE.md) и docs-PR #176 проверяются финальным ревью всей ветки | Задача не содержит кода; отдельный ревьюер дублировал бы финальный | Низкая | | 27.10.2026 | R-4 (design #12): подпись под парой в списке — «записей: N · без ИНН у <Б>: M» вместо служебных «держатель»/«Б» | Аналитик не знает ролей «держатель» и «Б»; название организации понятнее | Низкая | | 27.10.2026 | R-5 (design #13): раскладка режима «Встречные записи» — пары одной строкой «таблеток» с переносом, под ними две таблицы на всю ширину одна под другой (вместо трёх колонок) | В трёх колонках таблицам не хватало ширины: суммы обрезались, видны были только «Обороты Дт» (находка H-1) | Низкая | | 27.10.2026 | R-6 (design #14): уход с «ОСВ» (продолжение: пока сводки режима нет — Spin и подпись «Встречные записи» без «(N)»; ошибка запроса сводки — Alert с «Повторить»; сводка, догруженная при уходе, не кешируется и живёт только на время модалки) — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая | Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867.