diff --git a/docs/superpowers/plans/2026-10-27-counter-pairs.md b/docs/superpowers/plans/2026-10-27-counter-pairs.md new file mode 100644 index 0000000..a5bfe9b --- /dev/null +++ b/docs/superpowers/plans/2026-10-27-counter-pairs.md @@ -0,0 +1,191 @@ +# Пары участников без встречных записей — план реализации + +> **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_rows_total = 0`: `У в своде нет строк: ОСВ не привязаны или по ним пробелы`; `missing_unmapped_rows = 0` при строках: `Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте`. +- Модалка не показывается для статусов `VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED`; ключ `sessionStorage` — `armka:counter-pairs:`, значение — ключ списка пар. +- Режим — `?view=pairs[&holder=&missing=]`, переключение режима и выбор пары — `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: + ```python + @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 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=` у `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=&unmapped=true` → `total == 3`; `GET …/osv?legal_entity_id=&counterparty_id=` → `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`): + ```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 | null): boolean; + export const rememberCounterPairsShown = (versionId: string, pairs: readonly CounterPairOut[], storage: Pick | 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`): + ```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:` = ключ; бросающий `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/-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 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:** коммит, если были правки по итогам проверки.