Решение владельца после показа на стенде: «Указать» во «Встречных записях» открывает список из одного участника пары; «Показать все организации» — обычный список. Чистая pairCandidateRows + vitest, спека и design.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
32 KiB
Пары участников без встречных записей — план реализации
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
- Пара с
missing_rows_total = 0(у Б нет ОСВ) — правая часть показывает текст, а не пустую таблицу и не ошибку → тест в Task 1 (test_counter_pairs_missing_side_without_rows) и ветка рендера в Task 5. - Сопоставление закрыло выбранную пару — экран выбирает первую оставшуюся, а не висит на исчезнувших
holder/missingиз адреса →selectPairв Task 3 (selectPair falls back to first). sessionStorageбросает исключение (приватный режим) — модалка показывается, страница не падает → тесты Task 3 (throwing storage).- Дорезолв на чтение закрывает пару без «Связать» — функция работает по строкам после дорезолва →
test_counter_pairs_use_read_time_resolutionв Task 1. - Строка Б с ИНН вне справочника — не в правой таблице, пара остаётся (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— зелёно; из корня worktreemake 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); querycounterparty_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, PRtea pulls createсо ссылкой на arm-core #195 и AURORA-1746. -
Step 2: правка
CLAUDE.mdarm-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; стенд — модалка с одной строкой «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО».