arm-core/docs/superpowers/plans/2026-10-27-counter-pairs.md
Andrey Epifancev 93ee8b2352 feat(#195): модалка сопоставления в режиме пар сразу сужена до участника пары
Решение владельца после показа на стенде: «Указать» во «Встречных записях»
открывает список из одного участника пары; «Показать все организации» —
обычный список. Чистая pairCandidateRows + vitest, спека и design.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 22:58:50 +04:00

32 KiB
Raw Blame History

Пары участников без встречных записей — план реализации

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: вместо требования «сопоставьте все строки без ИНН» показать аналитику пары участников периметра без встречных записей: сводка ядра, режим «Встречные записи» на вкладке «ОСВ», модалка перед шахматкой, новый баннер.

Architecture: чистая функция counter_pairs в api/src/domain/consolidated_osv.py считает пары по уже собранным строкам свода; GET …/osv/summary отдаёт их, GET …/osv получает фильтр counterparty_id. Фронт берёт строки обеих сторон пары существующим GET …/osv с фильтрами; правило показа модалки — чистые функции в web/src/utils/ с sessionStorage. В БД ничего не пишется.

Tech Stack: Python 3.11, FastAPI, pydantic v2, pytest; React 18 + TS strict, antd 6, react-router 7, vitest.

Spec: openspec/changes/counter-pairs/ (proposal.md, design.md, specs/consolidated-osv, specs/consolidated-osv-ui, specs/consolidations-ui). Issue — arm-core #195, Jira — AURORA-1746. Ветка feat/195-counter-pairs, worktree /Users/andrey.epifancev/projects/awp-ca/.wt/counter-pairs/arm-core. Все git-команды — git -C <абсолютный путь worktree>.

Global Constraints

  • Ничего не хранится: ни новых таблиц, ни колонок, ни миграций; DBML v5.23 не меняется.
  • Пара (А, Б): А, Б ∈ периметр (members версии), А ≠ Б; у А есть строка свода с counterparty_id = Б, у Б нет строки с counterparty_id = А. Учитываются все строки свода (любой счёт, любые суммы).
  • Порядок пар: имя Б (missing_name), затем имя А (holder_name), затем missing_id, holder_id.
  • Поля пары (API и TS): holder_id, holder_name, missing_id, missing_name, holder_rows, missing_unmapped_rows, missing_rows_total.
  • Тексты (дословно):
    • баннер, заголовок: Есть организации без ИНН и без сопоставления; текст: Осталось: N. Несопоставленные учитываются как внешние; при необходимости укажите организацию кнопкой «Указать».
    • модалка, заголовок: Проверьте встречные записи участников; строка 1: Нет встречных записей: N пар у M участников. (склонение через pluralRu: ['пара','пары','пар'], ['участника','участников','участников']); строка 2: Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте. Ещё K строк без ИНН учтены как внешние. — при K = 0 без последнего предложения; кнопки Показать пары, Остаться, Перейти.
    • переключатель: Свод | Встречные записи (N); строка пары: <missing_name> ← <holder_name>.
    • центр: У <holder_name> есть записи с <missing_name>; справа: У <missing_name> — строки без ИНН и без сопоставления.
    • пусто: Все участники периметра видят друг друга; missing_rows_total = 0: У <missing_name> в своде нет строк: ОСВ не привязаны или по ним пробелы; missing_unmapped_rows = 0 при строках: Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте.
  • Модалка не показывается для статусов VERIFIED, CANCELLED, ARCHIVED, REJECTED; ключ sessionStorage — armka:counter-pairs:<version_id>, значение — ключ списка пар.
  • Режим — ?view=pairs[&holder=<id>&missing=<id>], переключение режима и выбор пары — replace: true.
  • Комментарии, коммиты, тексты — по-русски; номер § правила (05-domain-rules.md §3 п. 9) — в docstring.

Review Focus

  1. Пара с missing_rows_total = 0 (у Б нет ОСВ) — правая часть показывает текст, а не пустую таблицу и не ошибку → тест в Task 1 (test_counter_pairs_missing_side_without_rows) и ветка рендера в Task 5.
  2. Сопоставление закрыло выбранную пару — экран выбирает первую оставшуюся, а не висит на исчезнувших holder/missing из адреса → selectPair в Task 3 (selectPair falls back to first).
  3. sessionStorage бросает исключение (приватный режим) — модалка показывается, страница не падает → тесты Task 3 (throwing storage).
  4. Дорезолв на чтение закрывает пару без «Связать» — функция работает по строкам после дорезолва → test_counter_pairs_use_read_time_resolution в Task 1.
  5. Строка Б с ИНН вне справочника — не в правой таблице, пара остаётся (AURORA-1866) → интеграционный тест Task 2 считает missing_unmapped_rows = 3 при 5 строках РБ.

Task 1: Доменная функция пар и фильтр counterparty_id (api, модель: sonnet)

Files:

  • Modify: api/src/domain/consolidated_osv.py (новый CounterPair, counter_pairs; поле OsvFilter.counterparty_id, проверка в _matches)
  • Test: api/tests/test_domain_consolidated_osv.py

Interfaces:

  • Produces:

    @dataclass(frozen=True)
    class CounterPair:
        holder_id: uuid.UUID
        holder_name: str
        missing_id: uuid.UUID
        missing_name: str
        holder_rows: int
        missing_unmapped_rows: int
        missing_rows_total: int
    
    def counter_pairs(rows: Sequence[ConsolidatedRow], members: Mapping[uuid.UUID, Entity]) -> list[CounterPair]
    # OsvFilter.counterparty_id: uuid.UUID | None = None
    

    Имена — из members[id].name. missing_unmapped_rows — через существующую needs_mapping.

  • Step 1: Тесты (фабрики _doc, _row, _build, сущности RB, SCH, CHKF, MEMBERS уже в файле):

    • test_counter_pairs_rb_shape: sch_60 со строкой «Русский бисквит» counterparty_id=RB.id; rb_60 со строками «Пекарня Счастливочная ООО» (без id) и «ЧКФ» (CHKF.id) → counter_pairs(result.rows, MEMBERS) == [CounterPair(SCH.id, SCH.name, RB.id, RB.name, holder_rows=1, missing_unmapped_rows=1, missing_rows_total=2)].
    • test_counter_pairs_closed_when_both_see_each_other: та же картина, но строка РБ сопоставлена counterparty_id=SCH.id → [].
    • test_counter_pairs_unrelated_members_absent: строки только РБ↔Счастливочная в обе стороны, members = {**MEMBERS, CHKF.id: CHKF} → [] (ЧКФ ни с кем не связан).
    • test_counter_pairs_missing_side_without_rows: только sch_60 со строкой на РБ → одна пара, missing_rows_total == 0, missing_unmapped_rows == 0.
    • test_counter_pairs_ignore_self_and_non_members: строка РБ с counterparty_id=RB.id и строка РБ с CHKF.id при MEMBERS → [].
    • test_counter_pairs_use_read_time_resolution: строка РБ «Пекарня Счастливочная ООО» без id, _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)}) (Resolution(entity_id, kind) из src/domain/osv_mapping.py), у Счастливочной строка на РБ → [].
    • test_counter_pairs_order: members из трёх (RB, SCH, CHKF как участник); ЧКФ и Счастливочная видят РБ, РБ не видит никого; плюс Счастливочная видит ЧКФ, ЧКФ не видит Счастливочную → порядок по missing_name, затем holder_name: [(missing=РБ, holder=Счастливочная), (missing=РБ, holder=ЧКФ), (missing=ЧКФ, holder=Счастливочная)] («АО Русский бисквит» < «ООО ЧКФ»; «ООО Пекарня…» < «ООО ЧКФ»).
    • test_filter_counterparty_id: apply_filter(rows, OsvFilter(counterparty_id=RB.id)) оставляет только строки с counterparty_id == RB.id.
  • Step 2: cd api && .venv/bin/pytest tests/test_domain_consolidated_osv.py -q — новые тесты падают (ImportError/AttributeError).

  • Step 3: реализовать CounterPair, counter_pairs (множество видимых пар (legal_entity_id, counterparty_id) по строкам, оба в members, различны; счётчики одним проходом), OsvFilter.counterparty_id и строку в _matches. Docstring — «§3 п. 9 05-domain-rules (AURORA-1746)»; добавить в __all__, если он есть.

  • Step 4: pytest tests/test_domain_consolidated_osv.py -q — зелёно; из корня worktree make lint — чисто.

  • Step 5: git -C <worktree> commit -m "feat(#195): доменная функция пар без встречных записей и фильтр counterparty_id" (+ строка Co-Authored-By).

Task 2: Сводка и фильтр в API (api, модель: sonnet)

Files:

  • Modify: api/src/schemas/consolidations.py (CounterPairOut, OsvSummaryOut.counter_pairs, OsvFilters.counterparty_id)
  • Modify: api/src/services/consolidated_osv.py (domain_filter, summary)
  • Modify: api/src/api/endpoints/consolidations/osv.py — только docstring маршрута (перечень фильтров), если он их перечисляет
  • Test: api/tests/test_api_consolidated_osv.py, api/tests/test_api_counterparty_mapping.py

Interfaces:

  • Consumes: domain.counter_pairs, domain.CounterPair, OsvFilter.counterparty_id (Task 1).

  • Produces: JSON summary.counter_pairs: [{holder_id, holder_name, missing_id, missing_name, holder_rows, missing_unmapped_rows, missing_rows_total}] (id — строки UUID); query counterparty_id=<uuid> у GET …/osv и GET …/osv/export.

  • Step 1: Тесты.

    • test_api_consolidated_osv.py::test_summary_counts_and_page_total_agree — в ожидаемый словарь добавить "counter_pairs": [] (в мире теста А и Б видят друг друга).
    • test_api_consolidated_osv.py::test_filter_by_counterparty_id: _page(client, world, f"legal_entity_id={world.a.id}&counterparty_id={world.b.id}") → total == 1, строка ("ООО А", "62", "ООО Б"); невалидный UUID → 422.
    • test_api_counterparty_mapping.py::test_counter_pairs_before_and_after_mapping: world = await _world(seeded); _summary → counter_pairs == [{"holder_id": str(world.schastl.id), "holder_name": "ООО «Пекарня Счастливочная»", "missing_id": str(world.rb.id), "missing_name": "АО Русский бисквит", "holder_rows": 1, "missing_unmapped_rows": 3, "missing_rows_total": 5}] (у РБ строки 60×2, 62, 66, 76; без ИНН — 60/62/66; 76 с ИНН вне справочника — AURORA-1866); GET …/osv?legal_entity_id=<rb>&unmapped=true → total == 3; GET …/osv?legal_entity_id=<schastl>&counterparty_id=<rb> → total == 1; затем POST _map_url(world, world.entries[("60", 1)]) с legal_entity_id = schastl → 200, _summary(...)["counter_pairs"] == [].
  • Step 2: из корня worktree make test-integration (или cd api && .venv/bin/pytest -m integration tests/test_api_consolidated_osv.py tests/test_api_counterparty_mapping.py -q при поднятом compose, make up) — новые тесты падают.

  • Step 3: CounterPairOut(BaseModel) с полями пары; OsvSummaryOut.counter_pairs: list[CounterPairOut]; OsvFilters.counterparty_id: uuid.UUID | None = None; domain_filter передаёт поле; summary() — counter_pairs=[CounterPairOut.model_validate(p, from_attributes=True) for p in domain.counter_pairs(result.consolidated.rows, result.members)].

  • Step 4: те же тесты зелёные; make check — зелёно.

  • Step 5: коммит feat(#195): counter_pairs в сводке сводной ОСВ и фильтр counterparty_id.

Task 3: Типы, клиент API и чистые правила фронта (web, модель: sonnet)

Files:

  • Modify: web/src/types/api.ts (CounterPairOut, OsvSummaryOut.counter_pairs, OsvFilters.counterparty_id?: string)
  • Modify: web/src/services/api/osv.ts (buildOsvQuery — counterparty_id), тест web/src/services/api/osv.test.ts
  • Modify: web/src/utils/consolidations.ts (+ utils/consolidations.test.ts): удалить needsUnmappedWarning и его тест
  • Create: web/src/widgets/ConsolidatedOsv/counterPairs.ts + counterPairs.test.ts

Interfaces:

  • Produces (utils/consolidations.ts):
    export const FINISHED_STATUSES: readonly ConsStatus[]; // ['VERIFIED','CANCELLED','ARCHIVED','REJECTED']
    export const COUNTER_PAIRS_STORAGE_PREFIX = 'armka:counter-pairs:';
    export const counterPairsKey = (pairs: readonly CounterPairOut[]): string; // `${holder_id}>${missing_id}` отсортированные, через ','
    export const shouldWarnCounterPairs = (versionId: string, status: ConsStatus | undefined, pairs: readonly CounterPairOut[], storage: Pick<Storage, 'getItem'> | null): boolean;
    export const rememberCounterPairsShown = (versionId: string, pairs: readonly CounterPairOut[], storage: Pick<Storage, 'setItem'> | null): void;
    export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTotal: number): { headline: string; details: string };
    
    shouldWarnCounterPairs: false при пустых парах или статусе из FINISHED_STATUSES; иначе true, если хранилище null, бросает или хранит другой ключ. rememberCounterPairsShown глотает исключения.
  • Produces (widgets/ConsolidatedOsv/counterPairs.ts):
    export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null };
    export const readPairsView = (search: URLSearchParams): PairsView;
    export const pairsViewSearch = (v: PairsView): string; // '' для table; '?view=pairs[&holder=..&missing=..]'
    export const selectPair = (pairs: readonly CounterPairOut[], holder: string | null, missing: string | null): CounterPairOut | null; // заданная или первая
    export const pairLabel = (p: CounterPairOut): string; // `${missing_name} ← ${holder_name}`
    
  • Step 1: Тесты vitest.
    • counterPairsKey не зависит от порядка входа.
    • shouldWarnCounterPairs: пусто → false; CANCELLED → false; хранилище без ключа → true; с тем же ключом → false; с другим ключом → true; null → true; getItem бросает → true (throwing storage).
    • rememberCounterPairsShown пишет armka:counter-pairs:<id> = ключ; бросающий setItem не пробрасывает.
    • counterPairsWarning(одна пара, 1260) → headline === 'Нет встречных записей: 1 пара у 1 участника.', details оканчивается Ещё 1260 строк без ИНН учтены как внешние.; пять пар у трёх участников → 'Нет встречных записей: 5 пар у 3 участников.'; unmappedTotal = 0 → details === 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.'.
    • readPairsView/pairsViewSearch — туда-обратно; selectPair — заданная пара; selectPair falls back to first, если заданной нет; пустой список → null.
    • buildOsvQuery({ counterparty_id: 'x' }) → ?counterparty_id=x.
  • Step 2: cd web && npm test — падают.
  • Step 3: реализовать; needsUnmappedWarning удалить (использование в OsvTabs убирает Task 4 — до тех пор tsc может падать, поэтому Task 3 и Task 4 коммитятся по очереди, но npm test зелёный после Task 3).
  • Step 4: npm test — зелёно.
  • Step 5: коммит feat(#195): типы и правила показа пар без встречных записей (web).

Task 4: Модалка перед шахматкой и баннер (web, модель: sonnet)

Files:

  • Modify: web/src/widgets/ConsolidatedOsv/OsvTabs.tsx (удалить UnmappedInnModal, добавить CounterPairsModal)
  • Modify: web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx (баннер; проп onSummaryChange вместо onUnmappedChange)
  • Modify: web/src/layouts/ConsolidationReportPage.tsx

Interfaces:

  • Consumes: shouldWarnCounterPairs, rememberCounterPairsShown, counterPairsWarning, pairsViewSearch (Task 3); OsvSummaryOut.counter_pairs.

  • Produces: OsvContent проп onSummaryChange?: (summary: OsvSummaryOut | null) => void (при размонтировании — null); OsvTabs пропсы { summary: OsvSummaryOut | null; versionId: string; status: ConsStatus | undefined }.

  • Step 1: ConsolidationReportPage: состояние osvSummary вместо unmappedTotal, передать в OsvContent и OsvTabs (versionId, card?.status).

  • Step 2: OsvTabs.handleTabChange: с osv и shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage()) → открыть модалку и сразу rememberCounterPairsShown; иначе go. safeSessionStorage() — try { return window.sessionStorage } catch { return null }. Модалка — тексты из Global Constraints через counterPairsWarning; «Показать пары» → navigate({ pathname: location.pathname, search: pairsViewSearch({ view: 'pairs', holder: null, missing: null }) }, { replace: true }), «Остаться» — закрыть, «Перейти» — go(pending).

  • Step 3: баннер — новые заголовок и текст (Global Constraints), кнопка фильтра без изменений.

  • Step 4: make web-check (из корня worktree) и cd web && npm test && npm run lint — зелёно.

  • Step 5: ручная проверка на стенде (../../docs/dev/qa-guide.md §4, версия РБ 2024 Q3 до сопоставления): баннер с новым текстом; «Шахматка» → модалка «Нет встречных записей: 1 пара у 1 участника.»; повторный переход — без модалки; «Показать пары» оставляет «ОСВ» с ?view=pairs. Скриншот модалки — в отчёт задачи.

  • Step 6: коммит feat(#195): модалка «Нет встречных записей» перед шахматкой и баннер строк без ИНН.

Task 5: Режим «Встречные записи» (web, модель: sonnet)

Files:

  • Create: web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx
  • Modify: web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx (переключатель режимов)
  • Modify: web/src/widgets/ConsolidatedOsv/OsvTable.tsx — экспортировать money и group для повторного использования колонок (без изменения поведения)

Interfaces:

  • Consumes: readPairsView, pairsViewSearch, selectPair, pairLabel (Task 3); getConsolidatedOsv(versionId, filters, limit, offset); CounterpartyMapModal({ versionId, row, onClose, onMapped }); OSV_PAGE_SIZE; format из useNumberDisplay.

  • Produces: CounterPairsView({ versionId, pairs, holder, missing, numFormat, onSelect, onMapped }), где onSelect: (holder: string, missing: string) => void, onMapped: () => void.

  • Step 1: в OsvContent — useSearchParams; Segmented с опциями Свод / Встречные записи (N) (N = summary?.counter_pairs.length ?? 0), смена — setSearchParams(..., { replace: true }) через pairsViewSearch; в режиме pairs вместо OsvGaps + OsvTable рендерить CounterPairsView; шапка (единицы, знаки, экспорт) остаётся.

  • Step 2: CounterPairsView: слева список пар (pairLabel, числа holder_rows / missing_unmapped_rows, подсветка выбранной); выбранная — selectPair. Центр — getConsolidatedOsv(versionId, { legal_entity_id: holder_id, counterparty_id: missing_id }, OSV_PAGE_SIZE, 0); справа — { legal_entity_id: missing_id, unmapped: true, q } постранично, над таблицей Input.Search (≤ 500 символов), колонка «Указать» открывает CounterpartyMapModal. Колонки обеих таблиц: «Счёт», «Контрагент», группы «Обороты за период» (Дт/Кт) и «Сальдо на конец периода» (Дт/Кт) через money/group. Пустые состояния — тексты из Global Constraints по missing_rows_total / missing_unmapped_rows / пустому списку пар. Ответы устаревших запросов игнорировать (счётчик запросов, как в useConsolidatedOsv).

  • Step 3: после onMapped — osv.refresh() (сводка) и перечитать обе таблицы; если выбранная пара исчезла — selectPair вернёт первую, адрес обновить replace.

  • Step 4: make web-check, npm test, npm run lint — зелёно.

  • Step 5: ручной сценарий на стенде (РБ 2024 Q3 до сопоставления): «Встречные записи (1)», пара «АО Русский бисквит ← ООО «Пекарня Счастливочная»», в центре строка Счастливочной на РБ, справа поиск «Пекарня» находит «Пекарня Счастливочная ООО»; «Указать» → «Связать» → «Все участники периметра видят друг друга», «Встречные записи (0)». Скриншоты — в отчёт задачи; сверить стиль с макетом [30] (../../docs/mockup/INDEX.md).

  • Step 6: коммит feat(#195): режим «Встречные записи» на вкладке «ОСВ».

Task 6: Документация и финальная проверка (docs + arm-core, модель: sonnet)

Files:

  • docs (отдельный репозиторий, свой worktree /Users/andrey.epifancev/projects/awp-ca/.wt/counter-pairs/docs от origin/main, issue в RSHB/arm-ka-docs через tea issues create --login home --repo RSHB/arm-ka-docs, ветка feat/<issue>-counter-pairs):

    • context/05-domain-rules.md §3 — новый п. 9 «Пары участников без встречных записей» (определение и поля из спеки consolidated-osv, решение владельца 27.10.2026, AURORA-1746; строки Б с ИНН вне справочника — AURORA-1866);
    • context/10-user-journey.md строка 3.1 — баннер, режим «Встречные записи», модалка вместо [31-00];
    • dev/api-contract-slice-4.md — counter_pairs[] в OsvSummaryOut, фильтр counterparty_id, строка [31-00] → модалка «Нет встречных записей».
  • arm-core: CLAUDE.md раздел «Консолидации» — одна фраза про counter_pairs в consolidated_osv.

  • Step 1: правки docs, make -C <docs worktree> check (если цель есть), коммит, push, PR tea pulls create со ссылкой на arm-core #195 и AURORA-1746.

  • Step 2: правка CLAUDE.md arm-core, коммит.

  • Step 3: из корня arm-core worktree make check-full — вывод сохранить для описания PR; openspec validate counter-pairs --strict.

  • Step 4: коммит, если были правки по итогам проверки.


Волна 2 (решение владельца 27.10.2026: всё отложенное закрыть в этой ветке)

Правила те же: sonnet — исполнители и ревью задач, opus — финальное ревью; маркеры T/H/O/R/C-I-M; без push в arm-core. Новые npm-пакеты не добавляем (web/CLAUDE.md, contour-transfer-guide §3.2): компонентные тесты заменяются выносом логики в чистые функции + vitest.

Task 7: логика перехода и режима пар (web, модель: sonnet)

Files: web/src/widgets/ConsolidatedOsv/OsvTabs.tsx, web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx, web/src/widgets/ConsolidatedOsv/counterPairs.ts (+ тест), web/src/utils/consolidations.ts (+ тест), хук useConsolidatedOsv, web/src/layouts/ConsolidationReportPage.tsx при необходимости.

  • T4.1: один вызов safeSessionStorage() в OsvTabs.
  • T4.2: при уходе с «ОСВ», если summary === null — запросить getOsvSummary(versionId) и решить по ответу (чистая функция решения: perform/warn/go); при ошибке запроса — переход без модалки; переход без проверки при null недопустим.
  • T5.4: useConsolidatedOsv не запрашивает строки, пока активен режим пар (сводку — запрашивает); убрать двойной fetch при смене фильтра/пары (setPage(1) не должен порождать запрос со старой страницей).
  • T5.5: holder/missing в адресе не стираются в режиме «Свод»; возврат на «Встречные записи» восстанавливает пару; readPairsView/pairsViewSearch и тесты поправить.
  • M-4: в режиме пар при summary = null — Spin; переключатель «Встречные записи» без «(N)» пока сводки нет.
  • T4.3 (часть): логику решений OsvTabs вынести в чистые функции и покрыть vitest.
  • make web-check, npm test, npm run lint; коммит feat(#195): ....

Task 8: раскладка режима «Встречные записи» и чистая логика CounterPairsView (web + спека, модель: sonnet)

Files: web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx (+ вынесенные чистые функции и тест), openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md, openspec/changes/counter-pairs/design.md.

  • H-1: вместо трёх колонок — список пар вверху одной строкой «таблеток» с переносом (подпись записей: N · без ИНН у <missing_name>: M сохранить), под ним две таблицы на всю ширину одна под другой: «У А есть записи с Б», затем «У Б — строки без ИНН и без сопоставления» с поиском. Все четыре колонки сумм видны без горизонтальной прокрутки при ширине окна 1440.
  • Спека consolidated-osv-ui («слева/в центре/справа») — под новую раскладку; решение — в design.md как R-5 (design #13).
  • T5.6: вынести в чистые функции и покрыть vitest: какой текст пустого состояния показать (пусто / missing_rows_total = 0 / missing_unmapped_rows = 0), выбор пары после сопоставления, ограничение страницы.
  • make web-check, npm test, npm run lint; коммит feat(#195): ....

Task 9: проверка на стенде и окружение (контроллер/исполнитель, модель: sonnet)

  • O-5: стенд с кодом ветки; проверить три пустых состояния и раскладку на 1440; сверить с макетом [30]. Перед правкой данных — SELECT исходных значений в файл ledger; после — восстановление и сверка SELECT-ом (включая counterparty_match_kind у 25 строк РБ, смена с ALIAS на MANUAL/PROPAGATED от O-3 — вернуть прежние значения, если есть бэкап, иначе описать в отчёте).
  • O-1: проверить make up из worktree при существующем .wt/counter-pairs/docs.
  • M-7: правка Purpose в openspec/specs/consolidations-ui/spec.md — пункт чеклиста закрытия (openspec archive), не follow-up.

Волна 3 (решение владельца 27.10.2026 после показа на стенде)

Task 10: модалка сопоставления в режиме пар сужена до участника пары (web + спека, исполнял основной сеанс)

  • pairCandidateRows(candidates, target) в counterpartyMapping.ts + vitest (участник пары под «Участник пары»; нет — пусто).
  • CounterpartyMapModal — проп pairTarget?: PairTarget: запрос scope=group&q=<имя А>, поиск и «Искать во всём справочнике» скрыты, флажок «Показать все организации»; CounterPairsView передаёт участника выбранной пары.
  • Спека consolidated-osv-ui (сценарий «Модалка сопоставления сужена до участника пары»), design.md (решение владельца 7).
  • tsc, biome, prettier, npm test, npm run lint; стенд — модалка с одной строкой «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО».