docs(#195): план counter-pairs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 18:40:34 +04:00
parent e9977fb221
commit bdc0e907a2

View File

@ -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_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:
```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 <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`):
```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`):
```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:** коммит, если были правки по итогам проверки.