From e9977fb2211cb58f2cce33a24f7c66fc9535a2b3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:33:58 +0400 Subject: [PATCH 01/26] =?UTF-8?q?docs(#195):=20change=20counter-pairs=20?= =?UTF-8?q?=E2=80=94=20=D0=BF=D0=B0=D1=80=D1=8B=20=D1=83=D1=87=D0=B0=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D0=B8=D0=BA=D0=BE=D0=B2=20=D0=B1=D0=B5=D0=B7=20?= =?UTF-8?q?=D0=B2=D1=81=D1=82=D1=80=D0=B5=D1=87=D0=BD=D1=8B=D1=85=20=D0=B7?= =?UTF-8?q?=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9=20(AURORA-1746)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/.openspec.yaml | 2 + openspec/changes/counter-pairs/design.md | 93 +++++++++++++++++++ openspec/changes/counter-pairs/proposal.md | 67 +++++++++++++ .../specs/consolidated-osv-ui/spec.md | 50 ++++++++++ .../specs/consolidated-osv/spec.md | 75 +++++++++++++++ .../specs/consolidations-ui/spec.md | 49 ++++++++++ 6 files changed, 336 insertions(+) create mode 100644 openspec/changes/counter-pairs/.openspec.yaml create mode 100644 openspec/changes/counter-pairs/design.md create mode 100644 openspec/changes/counter-pairs/proposal.md create mode 100644 openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md create mode 100644 openspec/changes/counter-pairs/specs/consolidated-osv/spec.md create mode 100644 openspec/changes/counter-pairs/specs/consolidations-ui/spec.md diff --git a/openspec/changes/counter-pairs/.openspec.yaml b/openspec/changes/counter-pairs/.openspec.yaml new file mode 100644 index 0000000..97e3642 --- /dev/null +++ b/openspec/changes/counter-pairs/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-27 diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md new file mode 100644 index 0000000..f8b29d1 --- /dev/null +++ b/openspec/changes/counter-pairs/design.md @@ -0,0 +1,93 @@ +# Дизайн: пары участников без встречных записей (AURORA-1746, arm-core #195) + +## Контекст и зачем + +Перед шахматкой аналитику важно одно: все ли внутригрупповые связи видны с обеих сторон. Шахматка строится по строкам +свода с признаком «ГСК». Если у компании Б контрагент А лежит строкой без ИНН и без сопоставления, он «Внешний», +и пара А–Б в шахматке выглядит однобокой. + +Сейчас это подменено счётчиком всех строк без ИНН. Баннер требует «сопоставьте все», модалка [31-00] всплывает при каждом +уходе с «ОСВ», хотя переход не блокируется (AURORA-1388). Заказчик (Циликов, 23.10.2026) прочитал это как обязанность +прокликать каждую строку, а счётчик на реальных данных до нуля не доходит. + +Успех: аналитик видит только существенное — пары участников без встречных записей — и разбирает их в одном месте, видя +обе стороны. Строки без ИНН, не влияющие на пары, ему не мешают. + +Антицель: экран, который снова заставляет «прокликать всё», или модалка, которая всплывает при каждом клике. + +## Решения владельца (27.10.2026, обсуждение в чате) + +1. Проверка — пары участников периметра: у А есть записи с контрагентом Б, у Б нет записей с контрагентом А. Уровень — + компании, не счета. +2. У Б может быть настоящий пропуск в учёте, поэтому переход никогда не блокируется. +3. Модалка — вариант «короткая сводка»: только цифры, разбор — в отдельном представлении («Показать пары»). +4. Представление — отдельный режим вкладки «ОСВ» с таблицами («они любят таблицы»): список пар, строки А с Б, строки Б + без ИНН и без сопоставления. +5. Подсветка кандидата по совпадению суммы — отдельная задача. +6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ. + +## Устройство + +### Ядро + +- `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, + 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 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Три колонки на экране; для поиска пары важнее обороты и итог | Низкая: добавить колонки | +| 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: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. diff --git a/openspec/changes/counter-pairs/proposal.md b/openspec/changes/counter-pairs/proposal.md new file mode 100644 index 0000000..071f44e --- /dev/null +++ b/openspec/changes/counter-pairs/proposal.md @@ -0,0 +1,67 @@ +## Why + +AURORA-1746 и замечание заказчика (Циликов А.В., 23.10.2026, строка 10 чек-листа, AURORA-1865): «при загрузке ОСВ без +ИНН необходимо сопоставить все организации без ИНН… нужно, чтобы система несопоставленные организации считала внешними и +позволяла формировать консолидацию без прокликивания каждой». + +Расчёт уже так работает: несопоставленный контрагент — «Внешний» (§3 п. 5), переход к шахматке не блокируется (решение +заказчика 16.10.2026, AURORA-1388). Ощущение обязательности создают баннер над сводом «Для перехода к следующему шагу +сопоставьте все внешние организации без ИНН» и модалка [31-00], которая всплывает при каждом уходе с «ОСВ», пока +`unmapped_total > 0`. Счётчик до нуля на реальных данных не доходит: в него попадают зарплата, подотчётные лица и прочие +строки без организации. + +Для шахматки важны не все строки без ИНН, а только те, где прячется участник периметра. Признак этого — пара участников, +где у одного есть записи с другим, а у другого встречных записей нет. Такие пары и нужно показать аналитику перед +шахматкой, с местом для разбора (решение владельца 27.10.2026). + +## What Changes + +Репозиторий — arm-core (`api/`, `web/`). Срез 4 (сводная ОСВ), доработка. Правило — `05-domain-rules.md` §3 п. 9 +(отдельный PR в docs). + +- **Ядро.** Чистая функция «пары без встречных записей» в `domain/consolidated_osv.py`: для участников периметра А ≠ Б + пара (А, Б) — у А есть строка свода с `counterparty_id = Б`, у Б нет строки с `counterparty_id = А`. `GET …/osv/summary` + отдаёт список `counter_pairs[]`; `GET …/osv` и экспорт получают фильтр `counterparty_id`. Ничего не хранится. +- **Фронт, режим «Встречные записи».** Над сводом — переключатель «Свод | Встречные записи (N)». Во втором режиме: слева + список пар, в центре — строки А с контрагентом Б, справа — строки Б без ИНН и без сопоставления с «Указать» (та же + модалка сопоставления, что в своде). +- **Фронт, модалка перед шахматкой.** Вместо [31-00] «Остались неуказанные ИНН» — «Нет встречных записей: N пар у M + участников», кнопки «Показать пары» / «Остаться» / «Перейти». Показывается при уходе с «ОСВ», если пары есть, один раз + на версию за сессию браузера и снова — если список пар изменился. Строки без ИНН сами по себе модалку не вызывают. +- **Фронт, баннер.** Текст без «сопоставьте все»: несопоставленные учитываются как внешние; счётчик «Осталось: N» и + кнопка «Показать ЮЛ без ИНН» остаются. + +## Non-goals + +- Подсветка строки-кандидата у Б по совпадению суммы с зеркальной суммой у А (правила элиминации) — отдельная задача. +- Хранение решения аналитика «встречных записей нет, это пропуск в учёте» — в БД ничего не пишется (решение владельца + 27.10.2026); DBML не меняется. +- Отсев строк, не являющихся организациями (зарплата, подотчётники), из счётчика `unmapped_total`. +- Сверка по счетам и суммам внутри пары — это делает шахматка (§6). +- Поиск участника, ошибочно сопоставленного с другим ЮЛ: справа показываются только несопоставленные строки без ИНН. +- Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют + сопоставления, в правую таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение + владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину. + +## Capabilities + +### New Capabilities + +### Modified Capabilities + +- `consolidated-osv`: ADDED «Пары участников без встречных записей»; MODIFIED «Страница свода и сводка» (фильтр + `counterparty_id`, `counter_pairs[]` в сводке). +- `consolidated-osv-ui`: ADDED «Режим «Встречные записи»», ADDED «Баннер строк без ИНН». +- `consolidations-ui`: REMOVED «Предупреждение «Остались неуказанные ИНН»»; ADDED «Предупреждение «Нет встречных + записей»». + +## Impact + +- Таблицы DBML v5.23 — только чтение: `consolidation_member`, `osv_entry`, `legal_entity`, `consolidation_document`. +- `api/src/domain/consolidated_osv.py`, `api/src/services/consolidated_osv.py`, `api/src/schemas/consolidations.py`, + `api/src/api/endpoints/consolidations/osv.py` (+ export), тесты `api/tests/`. +- `web/src/widgets/ConsolidatedOsvContent/`, `web/src/widgets/ConsolidatedOsv/` (`OsvTabs.tsx`, новый режим), + `web/src/layouts/ConsolidationReportPage.tsx`, `web/src/utils/consolidations.ts`, `web/src/services/api/osv.ts`, + `web/src/types/api.ts`. +- Документация: `../docs/context/05-domain-rules.md` §3 п. 9, `../docs/context/10-user-journey.md` (строка 3.1), + `../docs/dev/api-contract-slice-4.md` — отдельный PR в docs. diff --git a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md new file mode 100644 index 0000000..8b9285d --- /dev/null +++ b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md @@ -0,0 +1,50 @@ +## ADDED Requirements + +### Requirement: Баннер строк без ИНН +Пока в сводке `unmapped_total > 0`, над таблицей свода SHALL показываться баннер (стиль `osv-mapping-alert` макета [30]): +заголовок «Есть организации без ИНН и без сопоставления», текст «Осталось: N. Несопоставленные учитываются как внешние; +при необходимости укажите организацию кнопкой «Указать».» и кнопка «Показать ЮЛ без ИНН» / «Показать все строки» +(фильтр `unmapped`, как прежде). Баннер MUST NOT требовать сопоставить все строки для перехода к следующему шагу: +переход не блокируется (решение заказчика 16.10.2026, AURORA-1388). При `unmapped_total = 0` баннера нет. + +#### Scenario: Есть строки без ИНН +- **WHEN** открыт свод версии РБ 2024 Q3 до сопоставления (`unmapped_total` = 1260) +- **THEN** баннер «Есть организации без ИНН и без сопоставления», «Осталось: 1260. Несопоставленные учитываются как + внешние…»; слов «Для перехода к следующему шагу сопоставьте все» на экране нет + +### Requirement: Режим «Встречные записи» +Над сводом на вкладке «ОСВ» SHALL быть переключатель режимов «Свод | Встречные записи (N)», N — число пар +`summary.counter_pairs`. Режим — параметр адреса `?view=pairs` (без параметра — «Свод»); переключение режима MUST NOT +добавлять запись в историю браузера. Шапка свода (единицы, знаки после запятой) действует в обоих режимах. + +В режиме «Встречные записи» экран SHALL показывать три колонки: +- **слева** — список пар в порядке ответа, строка пары «Б ← А» (имена `missing_name` и `holder_name`) с числами + `holder_rows` и `missing_unmapped_rows`; выбранная пара подсвечена; при входе выбрана первая пара либо пара из адреса + (`?view=pairs&holder=&missing=`); +- **в центре** — «У <А> есть записи с <Б>»: строки `GET …/osv?legal_entity_id=А&counterparty_id=Б` — счёт, контрагент, + обороты Дт/Кт, сальдо на конец Дт/Кт; +- **справа** — «У <Б> — строки без ИНН и без сопоставления»: строки `GET …/osv?legal_entity_id=Б&unmapped=true` — счёт, + контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт и «Указать» в каждой строке; над таблицей — поиск по контрагенту + (`q`, ≤ 500 символов, как фильтр свода); постранично, как таблица свода. Если + `missing_rows_total = 0` — вместо таблицы текст «У <Б> в своде нет строк: ОСВ не привязаны или по ним пробелы»; если + строки у Б есть, а без ИНН и без сопоставления нет (`missing_unmapped_rows = 0`) — текст «Строк без ИНН нет: возможно, + ИНН контрагента из файла не найден в справочнике или это пропуск в учёте». + +«Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и +ошибками; после «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается +первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»). + +#### Scenario: Пара Русского бисквита +- **WHEN** открыт свод версии РБ 2024 Q3 до сопоставления и выбран режим «Встречные записи» +- **THEN** переключатель «Встречные записи (1)»; слева пара «Русский бисквит ← Пекарня Счастливочная»; в центре строки + Счастливочной с контрагентом «Русский бисквит»; справа строки РБ без ИНН, среди них «Пекарня Счастливочная ООО» с + «Указать» + +#### Scenario: Сопоставление из режима закрывает пару +- **WHEN** в правой таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная» +- **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель — + «Встречные записи (0)» + +#### Scenario: Режим из адреса +- **WHEN** открыт адрес `…/versions/{id}/osv?view=pairs` +- **THEN** показан режим «Встречные записи» с первой парой; кнопка «Назад» браузера не проходит по переключениям режимов diff --git a/openspec/changes/counter-pairs/specs/consolidated-osv/spec.md b/openspec/changes/counter-pairs/specs/consolidated-osv/spec.md new file mode 100644 index 0000000..46eaade --- /dev/null +++ b/openspec/changes/counter-pairs/specs/consolidated-osv/spec.md @@ -0,0 +1,75 @@ +## ADDED Requirements + +### Requirement: Пары участников без встречных записей +Ядро SHALL вычислять по своду версии пары участников без встречных записей (§3 п. 9 `05-domain-rules.md`, AURORA-1746): +для участников периметра А ≠ Б (`consolidation_member` версии) пара (А, Б) входит в список, если в своде есть хотя бы одна +строка ЮЛ А с `counterparty_id = Б` (сохранённым или дорезолвленным на чтение) и нет ни одной строки ЮЛ Б с +`counterparty_id = А`. А — сторона, у которой записи есть (`holder`), Б — сторона без встречных записей (`missing`). +Пара, где записей нет ни у А, ни у Б, в список MUST NOT входить. Строки ЮЛ с контрагентом — самим собой не +учитываются. Счета, суммы и тип строки (`data_type`) на признак не влияют: учитываются все строки свода. Расчёт — +чистая функция в `domain/` от строк свода и периметра; результат MUST NOT сохраняться. Порядок списка — по имени Б, +затем по имени А. + +У каждой пары SHALL быть: `holder_id`, `holder_name`, `missing_id`, `missing_name`, `holder_rows` (число строк А с +контрагентом Б), `missing_unmapped_rows` (число строк Б с `unmapped = true`), `missing_rows_total` (число всех строк Б в +своде; 0 — у Б нет строк, например не привязано ни одной ОСВ или все её пары счетов — пробелы). + +#### Scenario: Русский бисквит до сопоставления +- **WHEN** версия ГК «Русский бисквит» 2024 Q3 с периметром {АО «Русский бисквит», ООО «Пекарня Счастливочная»}, 22 ОСВ + из `fixtures/osv/{rb,schastl}/2024-Q{1,2,3}` подтверждены; у Счастливочной контрагент «Русский бисквит» с ИНН + 3528052718 сопоставлен по ИНН; у РБ контрагент «Пекарня Счастливочная ООО» без ИНН не сопоставлен +- **THEN** список содержит одну пару: `holder` — ООО «Пекарня Счастливочная», `missing` — АО «Русский бисквит», + `holder_rows` ≥ 1, `missing_unmapped_rows` ≥ 1 + +#### Scenario: Пара закрыта ручным сопоставлением +- **WHEN** в той же версии строка РБ «Пекарня Счастливочная ООО» связана с ООО «Пекарня Счастливочная» + (`POST …/osv/entries/{entry_id}/counterparty`) +- **THEN** список пар пуст + +#### Scenario: Компании не связаны +- **WHEN** в периметре три участника; у А и Б нет записей друг с другом ни в одну сторону +- **THEN** пары (А, Б) и (Б, А) в списке нет + +#### Scenario: У стороны нет строк +- **WHEN** у А есть строки с контрагентом Б, а у Б в своде нет ни одной строки (ОСВ не привязаны) +- **THEN** пара (А, Б) в списке, `missing_rows_total = 0`, `missing_unmapped_rows = 0` + +## MODIFIED Requirements + +### Requirement: Страница свода и сводка +`GET …/osv` (`CONSOLIDATION_VIEW`, policy) SHALL отдавать `Page` строк свода в порядке: имя ЮЛ, `account_code`, +`row_number`, первый `entry_id`; `limit` ≤ 2000 (по умолчанию 500). Фильтры: `legal_entity_id`, `account_root`, +`counterparty_type`, `counterparty_id` (строка с этим `counterparty_id` после дорезолва; AURORA-1746), `unmapped=true` +(строка требует сопоставления: `counterparty_id` пуст после дорезолва **и** `counterparty_inn` пуст или из пробелов; +строка с ИНН из файла, которого нет в справочнике, сопоставления не требует — решение владельца 08.10.2026, отмена П-1; +`unmapped=false` — все остальные строки), `match_kind` (повторяемый), `q` (подстрока без регистра в `counterparty_name` +или префикс `counterparty_inn`). Те же фильтры SHALL принимать `GET …/osv/export`. `GET …/osv/summary` SHALL отдавать +`rows_total`, `unmapped_total` (строки `unmapped=true`), `auto_matched_total` (строки с `match_kind` ∈ {`NAME`, `ALIAS`} +независимо от `match_stored`), `documents_total` (привязанные ОСВ-документы), `gaps[]` (`legal_entity_id`, +`legal_entity_name`, `account_code`, `missing_quarters`) и `counter_pairs[]` — пары участников без встречных записей +(требование «Пары участников без встречных записей»). Версия в любом статусе, включая `CANCELLED`, SHALL читаться. + +#### Scenario: Только несопоставленные +- **WHEN** запрошено `GET …/osv?unmapped=true` +- **THEN** все строки ответа с `counterparty_id = null` и без `counterparty_inn`, `total` = `summary.unmapped_total` + +#### Scenario: Строка с ИНН вне справочника не требует сопоставления +- **WHEN** у строки `counterparty_inn = "7701000001"`, такого ИНН и имени в справочнике нет (`counterparty_id = null`) +- **THEN** строки нет в `GET …/osv?unmapped=true`, она не входит в `summary.unmapped_total` + +#### Scenario: ИНН сопоставленного ЮЛ +- **WHEN** строка без ИНН в файле сопоставлена с ЮЛ по алиасу (на лету или хранимо) +- **THEN** `counterparty_inn = null`, `counterparty_entity_inn` — ИНН этого ЮЛ из справочника + +#### Scenario: Фильтр по способу +- **WHEN** запрошено `GET …/osv?match_kind=NAME&match_kind=ALIAS` +- **THEN** в ответе только строки с этими `match_kind`, `total` = `summary.auto_matched_total` + +#### Scenario: Строки одной стороны пары +- **WHEN** в `summary.counter_pairs` есть пара `holder_id = А`, `missing_id = Б` и запрошено + `GET …/osv?legal_entity_id=А&counterparty_id=Б` +- **THEN** `total` = `holder_rows` пары, у всех строк ответа ЮЛ А и `counterparty_id = Б` + +#### Scenario: Кандидаты другой стороны пары +- **WHEN** для той же пары запрошено `GET …/osv?legal_entity_id=Б&unmapped=true` +- **THEN** `total` = `missing_unmapped_rows` пары diff --git a/openspec/changes/counter-pairs/specs/consolidations-ui/spec.md b/openspec/changes/counter-pairs/specs/consolidations-ui/spec.md new file mode 100644 index 0000000..4a0eb9a --- /dev/null +++ b/openspec/changes/counter-pairs/specs/consolidations-ui/spec.md @@ -0,0 +1,49 @@ +## REMOVED Requirements + +### Requirement: Предупреждение «Остались неуказанные ИНН» +**Reason**: модалка всплывала при каждом уходе с «ОСВ», пока `unmapped_total > 0`, а счётчик на реальных данных до нуля +не доходит (зарплата, подотчётные лица); заказчик воспринимал её как требование сопоставить всё (AURORA-1746, чек-лист +Циликова 23.10.2026). Для шахматки важны только пары участников без встречных записей. +**Migration**: заменено требованием «Предупреждение «Нет встречных записей»». + +## ADDED Requirements + +### Requirement: Предупреждение «Нет встречных записей» +При уходе со вкладки «ОСВ» на другую вкладку отчёта версии фронт SHALL показывать модалку «Проверьте встречные записи +участников», если в сводке есть пары `counter_pairs` и этот список пар ещё не показывался в модалке для этой версии в +текущей сессии браузера. Текст: «Нет встречных записей: N пар у M участников.» (N — число пар, M — число разных `missing_id`; +слова согласуются с числом: «1 пара у 1 участника», «2 пары у 2 участников», «5 пар у 3 участников») +и «Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте. Ещё K строк без +ИНН учтены как внешние.» (K — +`unmapped_total`; при K = 0 вторая фраза без последнего предложения). Кнопки: +- «Показать пары» — закрыть модалку без перехода и открыть на вкладке «ОСВ» режим «Встречные записи» с первой парой; +- «Остаться» — закрыть модалку без перехода; +- «Перейти» — выполнить переход. + +Показ запоминается в `sessionStorage` браузера по версии вместе со списком пар (упорядоченные пары `holder_id → missing_id`); +повторный уход с «ОСВ» в той же сессии модалку не показывает, пока список пар не изменился (например, сопоставление +закрыло одну из пар — модалка покажет оставшиеся). Недоступное хранилище — модалка показывается при каждом уходе, ошибкой это +не считается. Без пар (`counter_pairs` пуст) переход SHALL выполняться без модалки при любом `unmapped_total`. В БД ничего +не пишется (решение владельца 27.10.2026). Для версии в завершённом статусе (`VERIFIED`, `CANCELLED`, `ARCHIVED`, +`REJECTED`) модалка не показывается. Пока шагов после ОСВ нет, их вкладки неактивны и модалка не вызывается. + +#### Scenario: Пара без встречных записей +- **WHEN** в сводке одна пара (Русский бисквит ← Пекарня Счастливочная), `unmapped_total = 1260`, и аналитик выбирает + вкладку «Шахматка» +- **THEN** показана модалка «Нет встречных записей: 1 пара у 1 участника…»; «Перейти» открывает шахматку + +#### Scenario: Повтор в той же сессии +- **WHEN** аналитик вернулся на «ОСВ» и снова выбирает «Шахматку», список пар не изменился +- **THEN** шахматка открывается без модалки + +#### Scenario: Список пар изменился +- **WHEN** после показа модалки аналитик сопоставил контрагента, закрыв одну из двух пар, и выбирает «Шахматку» +- **THEN** модалка показана снова с «Нет встречных записей: 1 пара у 1 участника…» + +#### Scenario: Только строки без ИНН +- **WHEN** `counter_pairs` пуст, `unmapped_total = 214`, и аналитик выбирает «Шахматку» +- **THEN** шахматка открывается без модалки + +#### Scenario: Показать пары +- **WHEN** в модалке нажато «Показать пары» +- **THEN** вкладка «ОСВ» остаётся, открыт режим «Встречные записи» с первой парой From bdc0e907a28765d19c0a40b5539b410eeab4cb2b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:40:34 +0400 Subject: [PATCH 02/26] =?UTF-8?q?docs(#195):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?counter-pairs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-27-counter-pairs.md | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-27-counter-pairs.md 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:** коммит, если были правки по итогам проверки. From e1299fab45bfe804dec19b98c257e0653e0cab73 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:43:01 +0400 Subject: [PATCH 03/26] =?UTF-8?q?docs(#195):=20R-1=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index f8b29d1..674099d 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -91,3 +91,4 @@ | 27.10.2026 | Модалка не показывается для версий в `FINISHED_STATUSES` (находка builder check) | Замороженную версию не исправить — предупреждение было бы шумом | Низкая | Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. +| 27.10.2026 | R-1 (design #9): `needsUnmappedWarning` удаляется в Task 4 вместе с его использованием в `OsvTabs`, а не в Task 3 | Иначе между коммитами Task 3 и 4 сборка (tsc) красная | Нулевая | From 40c1bca6bcbdebd90aba8fda4f572ef3921acffc Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:44:17 +0400 Subject: [PATCH 04/26] =?UTF-8?q?feat(#195):=20=D0=B4=D0=BE=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=B0=D1=8F=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8?= =?UTF-8?q?=D1=8F=20=D0=BF=D0=B0=D1=80=20=D0=B1=D0=B5=D0=B7=20=D0=B2=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B5=D1=87=D0=BD=D1=8B=D1=85=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B5=D0=B9=20=D0=B8=20=D1=84=D0=B8=D0=BB=D1=8C?= =?UTF-8?q?=D1=82=D1=80=20counterparty=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/domain/consolidated_osv.py | 49 ++++++++++++ api/tests/test_domain_consolidated_osv.py | 93 +++++++++++++++++++++++ 2 files changed, 142 insertions(+) diff --git a/api/src/domain/consolidated_osv.py b/api/src/domain/consolidated_osv.py index abb5ae7..e87a0ad 100644 --- a/api/src/domain/consolidated_osv.py +++ b/api/src/domain/consolidated_osv.py @@ -34,6 +34,7 @@ __all__ = [ "AUTO_MATCH_KINDS", "Consolidated", "ConsolidatedRow", + "CounterPair", "CounterpartyType", "Entity", "GapItem", @@ -44,6 +45,7 @@ __all__ = [ "apply_filter", "build", "classify", + "counter_pairs", "glue_key", "summary", ] @@ -170,6 +172,7 @@ class OsvFilter: account_root: str | None = None counterparty_type: CounterpartyType | None = None unmapped: bool | None = None + counterparty_id: uuid.UUID | None = None match_kinds: frozenset[CounterpartyMatchKind] = frozenset() q: str | None = None @@ -498,6 +501,8 @@ def _matches(row: ConsolidatedRow, f: OsvFilter) -> bool: # noqa: PLR0911 - п return False if f.unmapped is not None and needs_mapping(row) != f.unmapped: return False + if f.counterparty_id is not None and row.counterparty_id != f.counterparty_id: + return False if f.match_kinds and row.match_kind not in f.match_kinds: return False return _matches_query(row, f.q) @@ -508,6 +513,50 @@ def apply_filter(rows: Iterable[ConsolidatedRow], f: OsvFilter) -> list[Consolid return [row for row in rows if _matches(row, f)] +@dataclass(frozen=True) +class CounterPair: + """Пара «держатель видит, встречной записи нет» (§3 п. 9 05-domain-rules, AURORA-1746): у `holder` есть строки с + контрагентом `missing`, а у `missing` нет ни одной строки с контрагентом `holder`.""" + + holder_id: uuid.UUID + holder_name: str + missing_id: uuid.UUID + missing_name: str + holder_rows: int # строки держателя с контрагентом `missing` + missing_unmapped_rows: int # строки `missing`, требующие сопоставления (`needs_mapping`) + missing_rows_total: int # все строки `missing` в своде + + +def counter_pairs(rows: Sequence[ConsolidatedRow], members: Mapping[uuid.UUID, Entity]) -> list[CounterPair]: + """Пары членов периметра без встречных записей (§3 п. 9 05-domain-rules, AURORA-1746). Видимая пара — + `(legal_entity_id, counterparty_id)` по строкам свода (с дорезолвом на чтении), оба в `members` и различны. + Порядок: `missing_name`, затем `holder_name` (затем id для устойчивости).""" + seen: dict[tuple[uuid.UUID, uuid.UUID], int] = defaultdict(int) + totals: dict[uuid.UUID, int] = defaultdict(int) + unmapped: dict[uuid.UUID, int] = defaultdict(int) + for row in rows: + totals[row.legal_entity_id] += 1 + if needs_mapping(row): + unmapped[row.legal_entity_id] += 1 + other = row.counterparty_id + if other is not None and other != row.legal_entity_id and other in members and row.legal_entity_id in members: + seen[(row.legal_entity_id, other)] += 1 + pairs = [ + CounterPair( + holder_id=holder, + holder_name=members[holder].name, + missing_id=missing, + missing_name=members[missing].name, + holder_rows=count, + missing_unmapped_rows=unmapped[missing], + missing_rows_total=totals[missing], + ) + for (holder, missing), count in seen.items() + if (missing, holder) not in seen + ] + return sorted(pairs, key=lambda p: (p.missing_name, p.holder_name, str(p.missing_id), str(p.holder_id))) + + def summary(c: Consolidated) -> tuple[int, int, int]: """Сводка свода: `rows_total`, `unmapped_total` (`needs_mapping`: нет ЮЛ и нет ИНН из файла), `auto_matched_total` (`match_kind` ∈ {`NAME`, `ALIAS`} независимо от `match_stored`).""" diff --git a/api/tests/test_domain_consolidated_osv.py b/api/tests/test_domain_consolidated_osv.py index a66adf1..f385cbf 100644 --- a/api/tests/test_domain_consolidated_osv.py +++ b/api/tests/test_domain_consolidated_osv.py @@ -11,6 +11,7 @@ import pytest from src.domain.consolidated_osv import ( Consolidated, ConsolidatedRow, + CounterPair, CounterpartyType, Entity, GapItem, @@ -20,6 +21,7 @@ from src.domain.consolidated_osv import ( apply_filter, build, classify, + counter_pairs, needs_mapping, summary, ) @@ -552,3 +554,94 @@ def test_needs_mapping_is_no_counterparty_and_no_inn( def test_counterparty_type_is_str_enum() -> None: assert [t.value for t in CounterpartyType] == ["GSK", "GK", "EXTERNAL"] + + +def _rb_shape(*, rb_to_sch: uuid.UUID | None = None) -> Consolidated: + sch_60 = _doc(SCH, cumulative=True) + rb_60 = _doc(RB, cumulative=True) + rows = [ + _row(sch_60, "Русский бисквит", counterparty_id=RB.id, match_kind=NAME), + _row( + rb_60, + "Пекарня Счастливочная ООО", + row_number=1, + counterparty_id=rb_to_sch, + match_kind=NAME if rb_to_sch else None, + ), + _row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id, match_kind=NAME), + ] + return _build([sch_60, rb_60], rows) + + +def test_counter_pairs_rb_shape() -> None: + result = _rb_shape() + assert 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) + ] + + +def test_counter_pairs_closed_when_both_see_each_other() -> None: + assert counter_pairs(_rb_shape(rb_to_sch=SCH.id).rows, MEMBERS) == [] + + +def test_counter_pairs_unrelated_members_absent() -> None: + sch_60 = _doc(SCH, cumulative=True) + rb_60 = _doc(RB, cumulative=True) + rows = [_row(sch_60, "РБ", counterparty_id=RB.id), _row(rb_60, "Сч", counterparty_id=SCH.id)] + result = _build([sch_60, rb_60], rows) + assert counter_pairs(result.rows, {**MEMBERS, CHKF.id: CHKF}) == [] + + +def test_counter_pairs_missing_side_without_rows() -> None: + sch_60 = _doc(SCH, cumulative=True) + result = _build([sch_60], [_row(sch_60, "РБ", counterparty_id=RB.id)]) + pairs = counter_pairs(result.rows, MEMBERS) + assert len(pairs) == 1 + assert (pairs[0].missing_rows_total, pairs[0].missing_unmapped_rows) == (0, 0) + + +def test_counter_pairs_ignore_self_and_non_members() -> None: + rb_60 = _doc(RB, cumulative=True) + rows = [ + _row(rb_60, "РБ", row_number=1, counterparty_id=RB.id), + _row(rb_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id), + ] + assert counter_pairs(_build([rb_60], rows).rows, MEMBERS) == [] + + +def test_counter_pairs_use_read_time_resolution() -> None: + sch_60 = _doc(SCH, cumulative=True) + rb_60 = _doc(RB, cumulative=True) + rows = [_row(sch_60, "РБ", counterparty_id=RB.id), _row(rb_60, "Пекарня Счастливочная ООО")] + resolver = _Resolver({(None, "Пекарня Счастливочная ООО"): Resolution(SCH.id, ALIAS)}) + assert counter_pairs(_build([sch_60, rb_60], rows, resolver=resolver).rows, MEMBERS) == [] + + +def test_counter_pairs_order() -> None: + members = {RB.id: RB, SCH.id: SCH, CHKF.id: CHKF} + sch_60 = _doc(SCH, cumulative=True) + chkf_60 = _doc(CHKF, cumulative=True) + rows = [ + _row(sch_60, "РБ", row_number=1, counterparty_id=RB.id), + _row(sch_60, "ЧКФ", row_number=2, counterparty_id=CHKF.id), + _row(chkf_60, "РБ", counterparty_id=RB.id), + ] + result = build( + year=2024, + quarter=Q3, + group_id=GROUP, + members=members, + entities=ENTITIES, + documents=[sch_60, chkf_60], + rows=rows, + resolver=_Resolver(), + ) + got = [(p.missing_id, p.holder_id) for p in counter_pairs(result.rows, members)] + assert got == [(RB.id, SCH.id), (RB.id, CHKF.id), (CHKF.id, SCH.id)] + + +def test_filter_counterparty_id() -> None: + result = _rb_shape() + kept = apply_filter(result.rows, OsvFilter(counterparty_id=RB.id)) + assert kept and all(r.counterparty_id == RB.id for r in kept) + assert len(kept) < len(result.rows) From c24bc3d4c154b87456ab738e90667eddacf75a91 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:48:59 +0400 Subject: [PATCH 05/26] =?UTF-8?q?feat(#195):=20counter=5Fpairs=20=D0=B2=20?= =?UTF-8?q?=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B5=20=D1=81=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=BD=D0=BE=D0=B9=20=D0=9E=D0=A1=D0=92=20=D0=B8=20=D1=84=D0=B8?= =?UTF-8?q?=D0=BB=D1=8C=D1=82=D1=80=20counterparty=5Fid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/src/api/endpoints/consolidations/osv.py | 3 ++- api/src/schemas/consolidations.py | 14 ++++++++++ api/src/services/consolidated_osv.py | 6 +++++ api/tests/test_api_consolidated_osv.py | 18 ++++++++++++- api/tests/test_api_counterparty_mapping.py | 29 +++++++++++++++++++++ 5 files changed, 68 insertions(+), 2 deletions(-) diff --git a/api/src/api/endpoints/consolidations/osv.py b/api/src/api/endpoints/consolidations/osv.py index 22a9034..2d89505 100644 --- a/api/src/api/endpoints/consolidations/osv.py +++ b/api/src/api/endpoints/consolidations/osv.py @@ -33,7 +33,8 @@ async def list_osv( query: Annotated[OsvQuery, Query()], ) -> Page[ConsolidatedRowOut]: """Строки свода в порядке: имя ЮЛ, счёт, номер строки, первый `entry_id`. Фильтры `legal_entity_id`, - `account_root`, `counterparty_type`, `unmapped`, `match_kind` (повторяемый), `q`; `limit` ≤ 2000 (по умолчанию 500). + `account_root`, `counterparty_type`, `unmapped`, `counterparty_id`, `match_kind` (повторяемый), `q`; + `limit` ≤ 2000 (по умолчанию 500). Суммы — строки с двумя знаками.""" return await service.page(version_id, query, query) diff --git a/api/src/schemas/consolidations.py b/api/src/schemas/consolidations.py index 0215801..ca4b727 100644 --- a/api/src/schemas/consolidations.py +++ b/api/src/schemas/consolidations.py @@ -241,6 +241,18 @@ class GapOut(BaseModel): missing_quarters: list[PeriodQuarter] +class CounterPairOut(BaseModel): + """Пара «держатель видит, встречной записи нет» (§3 п. 9, AURORA-1746) в сводке свода.""" + + 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 + + class OsvSummaryOut(BaseModel): """Сводка по своду версии (`GET …/osv/summary`).""" @@ -249,6 +261,7 @@ class OsvSummaryOut(BaseModel): auto_matched_total: int documents_total: int gaps: list[GapOut] + counter_pairs: list[CounterPairOut] class OsvFilters(BaseModel): @@ -258,6 +271,7 @@ class OsvFilters(BaseModel): account_root: str | None = None counterparty_type: CounterpartyType | None = None unmapped: bool | None = None + counterparty_id: uuid.UUID | None = None match_kind: list[CounterpartyMatchKind] | None = None q: str | None = Field(default=None, max_length=MAX_OSV_QUERY) diff --git a/api/src/services/consolidated_osv.py b/api/src/services/consolidated_osv.py index 3dd9166..b548d42 100644 --- a/api/src/services/consolidated_osv.py +++ b/api/src/services/consolidated_osv.py @@ -36,6 +36,7 @@ from src.schemas.common import Page from src.schemas.consolidations import ( CandidateOut, ConsolidatedRowOut, + CounterPairOut, GapOut, OsvFilters, OsvPageParams, @@ -65,6 +66,7 @@ def domain_filter(f: OsvFilters) -> domain.OsvFilter: account_root=f.account_root, counterparty_type=f.counterparty_type, unmapped=f.unmapped, + counterparty_id=f.counterparty_id, match_kinds=frozenset(f.match_kind or ()), q=f.q, ) @@ -232,6 +234,10 @@ class ConsolidatedOsvService: auto_matched_total=auto_matched_total, documents_total=result.consolidated.documents_total, gaps=[GapOut.model_validate(gap, from_attributes=True) for gap in result.consolidated.gaps], + counter_pairs=[ + CounterPairOut.model_validate(pair, from_attributes=True) + for pair in domain.counter_pairs(result.consolidated.rows, result.members) + ], ) async def row_for_entry(self, version_id: uuid.UUID, entry_id: uuid.UUID) -> ConsolidatedRowOut | None: diff --git a/api/tests/test_api_consolidated_osv.py b/api/tests/test_api_consolidated_osv.py index 5300f21..eff6eeb 100644 --- a/api/tests/test_api_consolidated_osv.py +++ b/api/tests/test_api_consolidated_osv.py @@ -277,7 +277,7 @@ async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactor assert summary == { "rows_total": ROWS_TOTAL, "unmapped_total": UNMAPPED_TOTAL, "auto_matched_total": AUTO_MATCHED_TOTAL, - "documents_total": DOCUMENTS_TOTAL, "gaps": [], + "documents_total": DOCUMENTS_TOTAL, "gaps": [], "counter_pairs": [], } # fmt: skip unmapped = await _page(client, world, "unmapped=true") assert unmapped["total"] == summary["unmapped_total"] @@ -287,6 +287,22 @@ async def test_summary_counts_and_page_total_agree(auth_client: AuthClientFactor assert mapped["total"] == ROWS_TOTAL - UNMAPPED_TOTAL +@pytest.mark.integration +async def test_filter_by_counterparty_id(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + world = await _world(seeded) + client = await auth_client(KA_RF) + + body = await _page(client, world, f"legal_entity_id={world.a.id}&counterparty_id={world.b.id}") + + # у «ООО А» контрагент Б — в ОСВ 60 (склейка кварталов) и в ОСВ 62 + assert body["total"] == 2 # noqa: PLR2004 + assert {(r["legal_entity_name"], r["account_code"], r["counterparty_name"]) for r in body["items"]} == { + ("ООО А", "60", "ООО Б"), + ("ООО А", "62", "ООО Б"), + } + assert (await client.get(f"{_url(world)}?counterparty_id=not-a-uuid")).status_code == 422 # noqa: PLR2004 + + @pytest.mark.integration async def test_filter_by_match_kind_repeated_param(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: world = await _world(seeded) diff --git a/api/tests/test_api_counterparty_mapping.py b/api/tests/test_api_counterparty_mapping.py index 31ad5f9..a46ba15 100644 --- a/api/tests/test_api_counterparty_mapping.py +++ b/api/tests/test_api_counterparty_mapping.py @@ -902,3 +902,32 @@ async def test_alias_delete_racing_with_move_alias_is_404_without_phantom_event( ] assert len(removals) == 1 # только удаление при переносе assert await session.get(LegalEntityAlias, alias_id) is None + + +@pytest.mark.integration +async def test_counter_pairs_before_and_after_mapping(auth_client: AuthClientFactory, seeded: AsyncSession) -> None: + """Пара «Счастливочная видит РБ, РБ её — нет»: у РБ без ИНН 60/62/66, 76 с ИНН вне справочника (AURORA-1866).""" + world = await _world(seeded) + client = await auth_client(KA_RF) + base = f"/consolidations/versions/{world.version.id}/osv" + + summary = await _summary(client, world) + + assert 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, + } + ] # fmt: skip + unmapped = await client.get(f"{base}?legal_entity_id={world.rb.id}&unmapped=true") + assert unmapped.json()["total"] == 3 # noqa: PLR2004 + held = await client.get(f"{base}?legal_entity_id={world.schastl.id}&counterparty_id={world.rb.id}") + assert held.json()["total"] == 1 + + mapped = await client.post( + _map_url(world, world.entries[("60", 1)]), json={"legal_entity_id": str(world.schastl.id)} + ) + + assert mapped.status_code == HTTP_OK, mapped.text + assert (await _summary(client, world))["counter_pairs"] == [] From 8513a8d10e3b2bda3637e0ed9dab1bad9fcf0169 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:51:45 +0400 Subject: [PATCH 06/26] =?UTF-8?q?feat(#195):=20=D1=82=D0=B8=D0=BF=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=B8=D0=BB=D0=B0=20=D0=BF?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=B7=D0=B0=20=D0=BF=D0=B0=D1=80=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=20=D0=B2=D1=81=D1=82=D1=80=D0=B5=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5=D0=B9=20(web)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/services/api/osv.test.ts | 3 + web/src/services/api/osv.ts | 1 + web/src/types/api.ts | 12 +++ web/src/utils/consolidations.test.ts | 83 ++++++++++++++++++- web/src/utils/consolidations.ts | 60 ++++++++++++++ .../ConsolidatedOsv/counterPairs.test.ts | 47 +++++++++++ .../widgets/ConsolidatedOsv/counterPairs.ts | 25 ++++++ 7 files changed, 230 insertions(+), 1 deletion(-) create mode 100644 web/src/widgets/ConsolidatedOsv/counterPairs.test.ts create mode 100644 web/src/widgets/ConsolidatedOsv/counterPairs.ts diff --git a/web/src/services/api/osv.test.ts b/web/src/services/api/osv.test.ts index 5680dd7..5d30284 100644 --- a/web/src/services/api/osv.test.ts +++ b/web/src/services/api/osv.test.ts @@ -15,4 +15,7 @@ describe('buildOsvQuery', () => { it('страница', () => { expect(buildOsvQuery({ counterparty_type: 'GSK' }, { limit: 500, offset: 1000 })).toBe('?counterparty_type=GSK&limit=500&offset=1000'); }); + it('counterparty_id', () => { + expect(buildOsvQuery({ counterparty_id: 'x' })).toBe('?counterparty_id=x'); + }); }); diff --git a/web/src/services/api/osv.ts b/web/src/services/api/osv.ts index 8344613..fba716f 100644 --- a/web/src/services/api/osv.ts +++ b/web/src/services/api/osv.ts @@ -15,6 +15,7 @@ export function buildOsvQuery(filters: OsvFilters, page?: { limit: number; offse if (filters.account_root) params.set('account_root', filters.account_root); if (filters.counterparty_type) params.set('counterparty_type', filters.counterparty_type); if (filters.unmapped) params.set('unmapped', 'true'); + if (filters.counterparty_id) params.set('counterparty_id', filters.counterparty_id); const q = filters.q?.trim(); if (q) params.set('q', q); if (page) { diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 733f262..3293854 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -357,18 +357,30 @@ export interface GapOut { account_code: string; missing_quarters: Quarter[]; } +/** Пара участников без встречных записей: у `holder` есть строки с `missing`, у `missing` — нет строк с `holder`. */ +export interface CounterPairOut { + holder_id: string; + holder_name: string; + missing_id: string; + missing_name: string; + holder_rows: number; + missing_unmapped_rows: number; + missing_rows_total: number; +} export interface OsvSummaryOut { rows_total: number; unmapped_total: number; auto_matched_total: number; documents_total: number; gaps: GapOut[]; + counter_pairs: CounterPairOut[]; } export interface OsvFilters { legal_entity_id?: string; account_root?: string; counterparty_type?: CounterpartyType; unmapped?: boolean; + counterparty_id?: string; q?: string; } diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts index 5a56c7d..1624a9e 100644 --- a/web/src/utils/consolidations.test.ts +++ b/web/src/utils/consolidations.test.ts @@ -1,19 +1,25 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import type { CounterPairOut } from '../types/api'; import type { ConsStatus, LockOut } from '../types/consolidations/common_types'; import { CONS_STATUS_LABEL, + FINISHED_STATUSES, MANUAL_TRANSITIONS, canChangeStatus, canSubmitStatusChange, canTakeInWork, consStatusDescription, + counterPairsKey, + counterPairsWarning, endSentence, isLockedBy, isValidReason, manualTransitions, needsUnmappedWarning, parseInWorkOnly, + rememberCounterPairsShown, reportTabNavigation, + shouldWarnCounterPairs, versionLabel, } from './consolidations'; @@ -143,3 +149,78 @@ describe('вкладки отчёта (AURORA-1851)', () => { }); }); }); + +const pair = (holder: string, missing: string): CounterPairOut => ({ + holder_id: holder, + holder_name: `H${holder}`, + missing_id: missing, + missing_name: `M${missing}`, + holder_rows: 1, + missing_unmapped_rows: 0, + missing_rows_total: 0, +}); + +describe('counterPairsKey', () => { + it('не зависит от порядка', () => { + expect(counterPairsKey([pair('a', 'b'), pair('c', 'd')])).toBe(counterPairsKey([pair('c', 'd'), pair('a', 'b')])); + expect(counterPairsKey([pair('c', 'd'), pair('a', 'b')])).toBe('a>b,c>d'); + }); +}); + +describe('shouldWarnCounterPairs', () => { + const pairs = [pair('a', 'b')]; + const withKey = (v: string | null) => ({ getItem: () => v }); + it('пусто или завершённый статус — false', () => { + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', [], withKey(null))).toBe(false); + for (const s of FINISHED_STATUSES) expect(shouldWarnCounterPairs('v', s, pairs, withKey(null))).toBe(false); + expect(shouldWarnCounterPairs('v', 'CANCELLED', pairs, withKey(null))).toBe(false); + }); + it('ключ в хранилище', () => { + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey(null))).toBe(true); + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey('a>b'))).toBe(false); + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, withKey('x>y'))).toBe(true); + }); + it('null и throwing storage — true', () => { + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, null)).toBe(true); + const throwing = { + getItem: () => { + throw new Error('denied'); + }, + }; + expect(shouldWarnCounterPairs('v', 'CONSOLIDATION_RF', pairs, throwing)).toBe(true); + }); +}); + +describe('rememberCounterPairsShown', () => { + it('пишет ключ', () => { + const setItem = vi.fn(); + rememberCounterPairsShown('v1', [pair('a', 'b')], { setItem }); + expect(setItem).toHaveBeenCalledWith('armka:counter-pairs:v1', 'a>b'); + }); + it('глотает исключения и null', () => { + const throwing = { + setItem: () => { + throw new Error('denied'); + }, + }; + expect(() => rememberCounterPairsShown('v1', [pair('a', 'b')], throwing)).not.toThrow(); + expect(() => rememberCounterPairsShown('v1', [pair('a', 'b')], null)).not.toThrow(); + }); +}); + +describe('counterPairsWarning', () => { + it('одна пара у одного участника', () => { + const w = counterPairsWarning([pair('a', 'b')], 1260); + expect(w.headline).toBe('Нет встречных записей: 1 пара у 1 участника.'); + expect(w.details.endsWith('Ещё 1260 строк без ИНН учтены как внешние.')).toBe(true); + }); + it('пять пар у трёх участников', () => { + const ps = [pair('a', 'b'), pair('a', 'c'), pair('a', 'd'), pair('e', 'b'), pair('f', 'c')]; + expect(counterPairsWarning(ps, 5).headline).toBe('Нет встречных записей: 5 пар у 3 участников.'); + }); + it('без строк без ИНН', () => { + expect(counterPairsWarning([pair('a', 'b')], 0).details).toBe( + 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.', + ); + }); +}); diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts index 77b3854..bf42a44 100644 --- a/web/src/utils/consolidations.ts +++ b/web/src/utils/consolidations.ts @@ -1,6 +1,8 @@ // Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные // переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API. +import type { CounterPairOut } from '../types/api'; import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types'; +import { pluralRu } from './labels'; /** Подписи статусов версии — одни для списка, фильтра «Статус», шапки карточки и модалки «Изменить статус». */ export const CONS_STATUS_LABEL: Record = { @@ -100,3 +102,61 @@ export const reportTabNavigation = (pathname: string, activeKey: string, newKey: to: pathname.replace(new RegExp(`/${activeKey}/?$`), `/${newKey}`), options: { replace: true }, }); + +/** Статусы, в которых версия закрыта для правок: модалку о парах без встречных записей не показываем. */ +export const FINISHED_STATUSES: readonly ConsStatus[] = ['VERIFIED', 'CANCELLED', 'ARCHIVED', 'REJECTED']; + +/** Префикс ключа `sessionStorage`: `armka:counter-pairs:`, значение — `counterPairsKey`. */ +export const COUNTER_PAIRS_STORAGE_PREFIX = 'armka:counter-pairs:'; + +/** Ключ списка пар — не зависит от порядка: `holder>missing`, отсортированные, через запятую. */ +export const counterPairsKey = (pairs: readonly CounterPairOut[]): string => + pairs + .map((p) => `${p.holder_id}>${p.missing_id}`) + .sort() + .join(','); + +/** + * Показывать ли модалку о парах без встречных записей (`05-domain-rules.md` §3 п. 9): есть пары, версия не закрыта и + * этот же список пар в сессии ещё не показывали. Хранилище недоступно или бросает — показываем. + */ +export const shouldWarnCounterPairs = ( + versionId: string, + status: ConsStatus | undefined, + pairs: readonly CounterPairOut[], + storage: Pick | null, +): boolean => { + if (pairs.length === 0) return false; + if (status && FINISHED_STATUSES.includes(status)) return false; + try { + return storage?.getItem(`${COUNTER_PAIRS_STORAGE_PREFIX}${versionId}`) !== counterPairsKey(pairs); + } catch { + return true; + } +}; + +/** Запоминает показанный список пар; ошибки хранилища (приватный режим) глотаем. */ +export const rememberCounterPairsShown = ( + versionId: string, + pairs: readonly CounterPairOut[], + storage: Pick | null, +): void => { + try { + storage?.setItem(`${COUNTER_PAIRS_STORAGE_PREFIX}${versionId}`, counterPairsKey(pairs)); + } catch { + // хранилище недоступно — модалка просто покажется снова + } +}; + +/** Тексты модалки «Проверьте встречные записи участников». */ +export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTotal: number): { headline: string; details: string } => { + const holders = new Set(pairs.map((p) => p.holder_id)).size; + const base = 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.'; + return { + headline: `Нет встречных записей: ${pairs.length} ${pluralRu(pairs.length, ['пара', 'пары', 'пар'])} у ${holders} ${pluralRu(holders, ['участника', 'участников', 'участников'])}.`, + details: + unmappedTotal > 0 + ? `${base} Ещё ${unmappedTotal} ${pluralRu(unmappedTotal, ['строка', 'строки', 'строк'])} без ИНН учтены как внешние.` + : base, + }; +}; diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts new file mode 100644 index 0000000..0e40837 --- /dev/null +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import type { CounterPairOut } from '../../types/api'; +import { pairLabel, pairsViewSearch, readPairsView, selectPair } from './counterPairs'; + +const pair = (holder: string, missing: string): CounterPairOut => ({ + holder_id: holder, + holder_name: `H${holder}`, + missing_id: missing, + missing_name: `M${missing}`, + holder_rows: 1, + missing_unmapped_rows: 0, + missing_rows_total: 0, +}); + +describe('readPairsView / pairsViewSearch', () => { + it('table по умолчанию', () => { + expect(readPairsView(new URLSearchParams(''))).toEqual({ view: 'table', holder: null, missing: null }); + expect(pairsViewSearch({ view: 'table', holder: 'a', missing: 'b' })).toBe(''); + }); + it('туда-обратно', () => { + const v = { view: 'pairs' as const, holder: 'a', missing: 'b' }; + const s = pairsViewSearch(v); + expect(s).toBe('?view=pairs&holder=a&missing=b'); + expect(readPairsView(new URLSearchParams(s))).toEqual(v); + expect(pairsViewSearch({ view: 'pairs', holder: null, missing: null })).toBe('?view=pairs'); + }); +}); + +describe('selectPair', () => { + const pairs = [pair('a', 'b'), pair('c', 'd')]; + it('заданная пара', () => { + expect(selectPair(pairs, 'c', 'd')).toBe(pairs[1]); + }); + it('selectPair falls back to first', () => { + expect(selectPair(pairs, 'x', 'y')).toBe(pairs[0]); + expect(selectPair(pairs, null, null)).toBe(pairs[0]); + }); + it('пусто — null', () => { + expect(selectPair([], 'a', 'b')).toBeNull(); + }); +}); + +describe('pairLabel', () => { + it('missing ← holder', () => { + expect(pairLabel(pair('a', 'b'))).toBe('Mb ← Ha'); + }); +}); diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.ts new file mode 100644 index 0000000..214190d --- /dev/null +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.ts @@ -0,0 +1,25 @@ +// Режим «Встречные записи» вкладки «ОСВ»: состояние в адресной строке и выбор пары. Без React и API. +import type { CounterPairOut } from '../../types/api'; + +export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null }; + +/** `?view=pairs[&holder=..&missing=..]`; всё остальное — обычная таблица свода. */ +export const readPairsView = (search: URLSearchParams): PairsView => + search.get('view') === 'pairs' + ? { view: 'pairs', holder: search.get('holder'), missing: search.get('missing') } + : { view: 'table', holder: null, missing: null }; + +/** Строка запроса для режима; у таблицы пусто. */ +export const pairsViewSearch = (v: PairsView): string => { + if (v.view !== 'pairs') return ''; + const params = new URLSearchParams({ view: 'pairs' }); + if (v.holder) params.set('holder', v.holder); + if (v.missing) params.set('missing', v.missing); + return `?${params.toString()}`; +}; + +/** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */ +export const selectPair = (pairs: readonly CounterPairOut[], holder: string | null, missing: string | null): CounterPairOut | null => + pairs.find((p) => p.holder_id === holder && p.missing_id === missing) ?? pairs[0] ?? null; + +export const pairLabel = (p: CounterPairOut): string => `${p.missing_name} ← ${p.holder_name}`; From c70549ffd2f6e118778ff74d95028ba58694276d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:52:41 +0400 Subject: [PATCH 07/26] =?UTF-8?q?docs(#195):=20R-2=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index 674099d..ec4dbd2 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -92,3 +92,4 @@ Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. | 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 даёт всё равно неграмматичную фразу («учтены») | Низкая | From 4942120005bdec4104705c3f68ef45a30fb36746 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:53:01 +0400 Subject: [PATCH 08/26] =?UTF-8?q?fix(#195):=20M=20=D0=B2=20=D0=BC=D0=BE?= =?UTF-8?q?=D0=B4=D0=B0=D0=BB=D0=BA=D0=B5=20=D0=BF=D0=B0=D1=80=20=E2=80=94?= =?UTF-8?q?=20=D1=87=D0=B8=D1=81=D0=BB=D0=BE=20=D1=80=D0=B0=D0=B7=D0=BD?= =?UTF-8?q?=D1=8B=D1=85=20missing=5Fid,=20=C2=AB=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=C2=BB=20=D0=B1=D0=B5=D0=B7=20=D1=81=D0=BA=D0=BB=D0=BE?= =?UTF-8?q?=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F=20(web)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/utils/consolidations.test.ts | 11 +++++++++-- web/src/utils/consolidations.ts | 9 +++------ 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts index 1624a9e..1c74617 100644 --- a/web/src/utils/consolidations.test.ts +++ b/web/src/utils/consolidations.test.ts @@ -214,10 +214,17 @@ describe('counterPairsWarning', () => { expect(w.headline).toBe('Нет встречных записей: 1 пара у 1 участника.'); expect(w.details.endsWith('Ещё 1260 строк без ИНН учтены как внешние.')).toBe(true); }); - it('пять пар у трёх участников', () => { - const ps = [pair('a', 'b'), pair('a', 'c'), pair('a', 'd'), pair('e', 'b'), pair('f', 'c')]; + it('пять пар у трёх участников (M — разные missing_id)', () => { + const ps = [pair('a', 'c'), pair('a', 'd'), pair('a', 'e'), pair('b', 'c'), pair('b', 'd')]; expect(counterPairsWarning(ps, 5).headline).toBe('Нет встречных записей: 5 пар у 3 участников.'); }); + it('M считает missing_id, а не holder_id', () => { + const ps = [pair('a', 'b'), pair('a', 'c'), pair('d', 'b'), pair('e', 'b'), pair('f', 'b')]; + expect(counterPairsWarning(ps, 5).headline).toBe('Нет встречных записей: 5 пар у 2 участников.'); + }); + it('две пары у двух участников', () => { + expect(counterPairsWarning([pair('a', 'b'), pair('a', 'c')], 1).headline).toBe('Нет встречных записей: 2 пары у 2 участников.'); + }); it('без строк без ИНН', () => { expect(counterPairsWarning([pair('a', 'b')], 0).details).toBe( 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.', diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts index bf42a44..8a23293 100644 --- a/web/src/utils/consolidations.ts +++ b/web/src/utils/consolidations.ts @@ -150,13 +150,10 @@ export const rememberCounterPairsShown = ( /** Тексты модалки «Проверьте встречные записи участников». */ export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTotal: number): { headline: string; details: string } => { - const holders = new Set(pairs.map((p) => p.holder_id)).size; + const missing = new Set(pairs.map((p) => p.missing_id)).size; const base = 'Возможно, контрагент среди строк без ИНН, его ИНН не найден в справочнике или это пропуск в учёте.'; return { - headline: `Нет встречных записей: ${pairs.length} ${pluralRu(pairs.length, ['пара', 'пары', 'пар'])} у ${holders} ${pluralRu(holders, ['участника', 'участников', 'участников'])}.`, - details: - unmappedTotal > 0 - ? `${base} Ещё ${unmappedTotal} ${pluralRu(unmappedTotal, ['строка', 'строки', 'строк'])} без ИНН учтены как внешние.` - : base, + headline: `Нет встречных записей: ${pairs.length} ${pluralRu(pairs.length, ['пара', 'пары', 'пар'])} у ${missing} ${pluralRu(missing, ['участника', 'участников', 'участников'])}.`, + details: unmappedTotal > 0 ? `${base} Ещё ${unmappedTotal} строк без ИНН учтены как внешние.` : base, }; }; From 8194f47d1a816e85da9f877af361af4b364dc0fb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:56:51 +0400 Subject: [PATCH 09/26] =?UTF-8?q?feat(#195):=20=D0=BC=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=20=C2=AB=D0=9D=D0=B5=D1=82=20=D0=B2=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B5=D1=87=D0=BD=D1=8B=D1=85=20=D0=B7=D0=B0=D0=BF?= =?UTF-8?q?=D0=B8=D1=81=D0=B5=D0=B9=C2=BB=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=20=D1=88=D0=B0=D1=85=D0=BC=D0=B0=D1=82=D0=BA=D0=BE=D0=B9=20?= =?UTF-8?q?=D0=B8=20=D0=B1=D0=B0=D0=BD=D0=BD=D0=B5=D1=80=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BA=20=D0=B1=D0=B5=D0=B7=20=D0=98=D0=9D=D0=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/layouts/ConsolidationReportPage.tsx | 9 +-- web/src/utils/consolidations.test.ts | 9 --- web/src/utils/consolidations.ts | 3 - web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 57 ++++++++++++++----- .../ConsolidatedOsvContent.tsx | 20 ++++--- 5 files changed, 60 insertions(+), 38 deletions(-) diff --git a/web/src/layouts/ConsolidationReportPage.tsx b/web/src/layouts/ConsolidationReportPage.tsx index 187a12a..ea92e89 100644 --- a/web/src/layouts/ConsolidationReportPage.tsx +++ b/web/src/layouts/ConsolidationReportPage.tsx @@ -7,6 +7,7 @@ import PageTitle from '../components/PageTitle'; import { TableTag } from '../components/TableTag/TableTag'; import { TAG_PRESETS } from '../components/TableTag/presets'; import { getVersionCard } from '../services/api/consolidations'; +import type { OsvSummaryOut } from '../types/api'; import type { ConsRussianStatus } from '../types/consolidations/common_types'; import type { VersionCard } from '../types/consolidations/version_members'; import { CONS_STATUS_LABEL, canChangeStatus, consStatusDescription, isLockedBy } from '../utils/consolidations'; @@ -20,14 +21,14 @@ import ChangeStatusModal from '../widgets/StatusChangeModal/ChangeStatusModal'; function ReportContent() { const [card, setCard] = useState(null); const [statusModalOpen, setModalOpen] = useState(false); - // unmapped_total сводки ОСВ — для [31-00] при уходе с вкладки «ОСВ» - const [unmappedTotal, setUnmappedTotal] = useState(0); + // сводка ОСВ (пары без встречных записей) — для модалки при уходе с вкладки «ОСВ» + const [osvSummary, setOsvSummary] = useState(null); const currentUser = useCurrentUser(); const { version_id, tab } = useParams<{ tab?: string; version_id: string }>(); const navigation = useNavigate(); const { pathname } = useLocation(); const TAB_COMPONENTS: Record = { - osv: , + osv: , chess: , }; @@ -108,7 +109,7 @@ function ReportContent() { ) } /> - + {ActiveTab} {card && setModalOpen(false)} />} diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts index 1c74617..2b8bd77 100644 --- a/web/src/utils/consolidations.test.ts +++ b/web/src/utils/consolidations.test.ts @@ -15,7 +15,6 @@ import { isLockedBy, isValidReason, manualTransitions, - needsUnmappedWarning, parseInWorkOnly, rememberCounterPairsShown, reportTabNavigation, @@ -133,14 +132,6 @@ describe('endSentence', () => { }); }); -describe('модалка [31-00]', () => { - it('только при несопоставленных строках', () => { - expect(needsUnmappedWarning(2)).toBe(true); - expect(needsUnmappedWarning(0)).toBe(false); - expect(needsUnmappedWarning(undefined)).toBe(false); - }); -}); - describe('вкладки отчёта (AURORA-1851)', () => { it('смена вкладки заменяет запись истории: «Назад» ведёт со страницы отчёта, а не по вкладкам', () => { expect(reportTabNavigation('/groups/g1/versions/v1/osv', 'osv', 'chess')).toEqual({ diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts index 8a23293..7124db0 100644 --- a/web/src/utils/consolidations.ts +++ b/web/src/utils/consolidations.ts @@ -91,9 +91,6 @@ export const endSentence = (text: string): string => (text.endsWith('.') ? text /** «+ Создать» в модалке «Новая консолидация»: в консолидации не меньше двух ЮЛ (иначе 422 `PERIMETER_TOO_SMALL`). */ export const MIN_PERIMETER = 2; -/** Модалка [31-00] нужна при уходе со вкладки «ОСВ», пока есть несопоставленные строки. */ -export const needsUnmappedWarning = (unmappedTotal: number | null | undefined): boolean => (unmappedTotal ?? 0) > 0; - /** * Переход на другую вкладку отчёта (`/versions/:version_id/:tab`): сегмент вкладки заменяется, запись истории — тоже, * чтобы «Назад» вёл со страницы отчёта, а не по переключениям вкладок (AURORA-1851, как переключатель документа в AURORA-1692). diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 5fa4433..9523035 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -2,7 +2,10 @@ import { WarningOutlined } from '@ant-design/icons'; import { Button, Modal, Segmented, Tooltip } from 'antd'; import { useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; -import { needsUnmappedWarning, reportTabNavigation } from '../../utils/consolidations'; +import type { CounterPairOut, OsvSummaryOut } from '../../types/api'; +import type { ConsStatus } from '../../types/consolidations/common_types'; +import { counterPairsWarning, rememberCounterPairsShown, reportTabNavigation, shouldWarnCounterPairs } from '../../utils/consolidations'; +import { pairsViewSearch } from './counterPairs'; const TAB_CONFIG = [ { key: 'osv', label: 'ОСВ' }, @@ -40,12 +43,23 @@ const TAB_CONFIG = [ ]; type Props = { - /** `unmapped_total` сводки ОСВ: больше нуля — уход с «ОСВ» через модалку [31-00]. */ - unmappedTotal?: number; + /** Сводка ОСВ: есть пары без встречных записей — переход «ОСВ» → «Шахматка» через модалку. */ + summary: OsvSummaryOut | null; + versionId: string; + status: ConsStatus | undefined; +}; + +/** `sessionStorage` может бросать (приватный режим) — тогда `null`. */ +const safeSessionStorage = (): Storage | null => { + try { + return window.sessionStorage; + } catch { + return null; + } }; /** Ряд вкладок шагов консолидации (макет [30]); вкладка — сегмент адреса `/versions/:version_id/:tab`. */ -export function OsvTabs({ unmappedTotal = 0 }: Props) { +export function OsvTabs({ summary, versionId, status }: Props) { const navigate = useNavigate(); const location = useLocation(); const { tab } = useParams<{ tab?: string }>(); @@ -61,7 +75,9 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) { const handleTabChange = (value: string | number) => { const newKey = String(value); if (newKey === activeKey) return; - if (activeKey === 'osv' && needsUnmappedWarning(unmappedTotal)) { + const pairs = summary?.counter_pairs ?? []; + if (activeKey === 'osv' && newKey === 'chess' && shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())) { + rememberCounterPairsShown(versionId, pairs, safeSessionStorage()); setPending(newKey); } else { go(newKey); @@ -97,9 +113,17 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) { style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }} options={options} /> - { + setPending(null); + navigate( + { pathname: location.pathname, search: pairsViewSearch({ view: 'pairs', holder: null, missing: null }) }, + { replace: true }, + ); + }} onStay={() => setPending(null)} onGo={() => { const next = pending; @@ -111,25 +135,33 @@ export function OsvTabs({ unmappedTotal = 0 }: Props) { ); } -/** [31-00] «Остались неуказанные ИНН»: «Остаться» — без перехода, «Перейти» — переход. */ -export function UnmappedInnModal({ +/** Модалка перед шахматкой: у части участников нет встречных записей (`05-domain-rules.md` §3 п. 9). */ +export function CounterPairsModal({ open, + pairs, unmappedTotal, + onShow, onStay, onGo, }: { open: boolean; + pairs: readonly CounterPairOut[]; unmappedTotal: number; + onShow: () => void; onStay: () => void; onGo: () => void; }) { + const { headline, details } = counterPairsWarning(pairs, unmappedTotal); return ( + Показать пары + , , @@ -138,11 +170,10 @@ export function UnmappedInnModal({ , ]}>
- {/* треугольник с «!», как в макете [31-00] */}
-
В ОСВ остались неуказанные ИНН
-
Не заполнено ИНН: {unmappedTotal}. Точно хотите перейти в другой раздел?
+
{headline}
+
{details}
diff --git a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index bafdf06..a5d2460 100644 --- a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import { ApiError } from '../../api/client'; import { exportOsv } from '../../services/api/osv'; -import type { ConsolidatedRowOut } from '../../types/api'; +import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api'; import type { VersionCard } from '../../types/consolidations/version_members'; import { exportFilename, saveBlob } from '../../utils/download'; import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal'; @@ -16,11 +16,11 @@ import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumbe type Props = { card: VersionCard | null; - /** Сообщает странице отчёта `unmapped_total` сводки — для модалки [31-00] при уходе с вкладки «ОСВ». */ - onUnmappedChange?: (total: number) => void; + /** Сообщает странице отчёта сводку ОСВ (пары без встречных записей — для модалки при уходе с «ОСВ»); при размонтировании — `null`. */ + onSummaryChange?: (summary: OsvSummaryOut | null) => void; }; -export function OsvContent({ card, onUnmappedChange }: Props) { +export function OsvContent({ card, onSummaryChange }: Props) { const [exporting, setExporting] = useState(false); const { version_id } = useParams<{ version_id: string }>(); @@ -49,10 +49,10 @@ export function OsvContent({ card, onUnmappedChange }: Props) { const onlyUnmapped = osv.filters.unmapped === true; useEffect(() => { - onUnmappedChange?.(unmapped); - }, [unmapped, onUnmappedChange]); + onSummaryChange?.(s); + }, [s, onSummaryChange]); // вне вкладки «ОСВ» предупреждать не о чем - useEffect(() => () => onUnmappedChange?.(0), [onUnmappedChange]); + useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]); return ( <> @@ -104,8 +104,10 @@ export function OsvContent({ card, onUnmappedChange }: Props) { color: '#93370d', fontSize: 13, }}> - Требуется указать соответствия - Для перехода к следующему шагу сопоставьте все внешние организации без ИНН. Осталось: {unmapped}. + Есть организации без ИНН и без сопоставления + + Осталось: {unmapped}. Несопоставленные учитываются как внешние; при необходимости укажите организацию кнопкой «Указать». + {/* макет оставляет кнопку как есть, снимать фильтр — воронкой ИНН или «Сбросить фильтры» (панель — позже) */} + ), + }, + ]; + + const mapped = useCallback(() => { + setReloadKey((k) => k + 1); + onMapped(); + }, [onMapped]); + + if (!selected) { + return
Все участники периметра видят друг друга
; + } + + let rightEmpty: string | null = null; + if (selected.missing_rows_total === 0) { + rightEmpty = `У ${selected.missing_name} в своде нет строк: ОСВ не привязаны или по ним пробелы`; + } else if (selected.missing_unmapped_rows === 0) { + rightEmpty = 'Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте'; + } + + return ( +
+
+ {pairs.map((p) => { + const active = p.holder_id === selected.holder_id && p.missing_id === selected.missing_id; + return ( + + ); + })} +
+
+
+ У {selected.holder_name} есть записи с {selected.missing_name} +
+ {center.error && } + + bordered + size='small' + rowKey={(r) => r.entry_ids.join(',')} + loading={center.loading} + columns={baseColumns} + dataSource={center.rows} + scroll={{ x: 'max-content' }} + pagination={{ + current: center.page, + pageSize: OSV_PAGE_SIZE, + total: center.total, + showSizeChanger: false, + onChange: center.setPage, + hideOnSinglePage: true, + showTotal: (t) => `Строк: ${t}`, + }} + /> +
+
+
У {selected.missing_name} — строки без ИНН и без сопоставления
+ {rightEmpty ? ( +
{rightEmpty}
+ ) : ( + <> + setQ(e.target.value)} + /> + {right.error && } + + bordered + size='small' + rowKey={(r) => r.entry_ids.join(',')} + loading={right.loading} + columns={rightColumns} + dataSource={right.rows} + scroll={{ x: 'max-content' }} + locale={{ emptyText: debouncedQ ? 'По поиску строк нет' : 'Строк нет' }} + pagination={{ + current: right.page, + pageSize: OSV_PAGE_SIZE, + total: right.total, + showSizeChanger: false, + onChange: right.setPage, + hideOnSinglePage: true, + showTotal: (t) => `Строк: ${t}`, + }} + /> + + )} +
+ {mapping && ( + setMapping(null)} + onMapped={mapped} + /> + )} +
+ ); +} diff --git a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx index 6d4fe5a..1b5b4d8 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx @@ -11,7 +11,7 @@ import type { OsvFilterDraft } from './osvFilterState'; type MoneyKey = 'opening_debit' | 'opening_credit' | 'turnover_debit' | 'turnover_credit' | 'closing_debit' | 'closing_credit'; -const money = (key: MoneyKey, title: string, format: (raw: string | null) => string) => ({ +export const money = (key: MoneyKey, title: string, format: (raw: string | null) => string) => ({ key, title, dataIndex: key, @@ -20,7 +20,7 @@ const money = (key: MoneyKey, title: string, format: (raw: string | null) => str render: (v: string | null) => format(v), }); -const group = (title: string, children: TableColumnsType): TableColumnsType[number] => ({ +export const group = (title: string, children: TableColumnsType): TableColumnsType[number] => ({ title, align: 'center', children, diff --git a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index a5d2460..9888527 100644 --- a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -1,15 +1,17 @@ import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { Alert, Button, Select, message } from 'antd'; +import { Alert, Button, Segmented, Select, message } from 'antd'; import { useEffect, useState } from 'react'; -import { useParams } from 'react-router-dom'; +import { useParams, useSearchParams } from 'react-router-dom'; import { ApiError } from '../../api/client'; import { exportOsv } from '../../services/api/osv'; import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api'; import type { VersionCard } from '../../types/consolidations/version_members'; import { exportFilename, saveBlob } from '../../utils/download'; +import { CounterPairsView } from '../ConsolidatedOsv/CounterPairsView'; import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal'; import { OsvGaps } from '../ConsolidatedOsv/OsvGaps'; import { OsvTable } from '../ConsolidatedOsv/OsvTable'; +import { pairsViewSearch, readPairsView, selectPair } from '../ConsolidatedOsv/counterPairs'; import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping'; import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv'; import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay'; @@ -45,9 +47,22 @@ export function OsvContent({ card, onSummaryChange }: Props) { }; const s = osv.summary; + const [searchParams, setSearchParams] = useSearchParams(); + const pairsView = readPairsView(searchParams); + const pairs = s?.counter_pairs ?? []; + const selectedPair = selectPair(pairs, pairsView.holder, pairsView.missing); + const inPairs = pairsView.view === 'pairs'; + const setView = (v: Parameters[0]) => setSearchParams(pairsViewSearch(v), { replace: true }); const unmapped = s?.unmapped_total ?? 0; const onlyUnmapped = osv.filters.unmapped === true; + // выбранную пару закрыли сопоставлением — в адресе первая оставшаяся + useEffect(() => { + if (!inPairs || !s) return; + if (selectedPair && (selectedPair.holder_id !== pairsView.holder || selectedPair.missing_id !== pairsView.missing)) { + setView({ view: 'pairs', holder: selectedPair.holder_id, missing: selectedPair.missing_id }); + } + }); useEffect(() => { onSummaryChange?.(s); }, [s, onSummaryChange]); @@ -88,8 +103,19 @@ export function OsvContent({ card, onSummaryChange }: Props) { Экспорт + + setView(v === 'pairs' ? { view: 'pairs', holder: null, missing: null } : { view: 'table', holder: null, missing: null }) + } + options={[ + { label: 'Свод', value: 'table' }, + { label: `Встречные записи (${pairs.length})`, value: 'pairs' }, + ]} + style={{ alignSelf: 'flex-start' }} + /> {osv.error && } - {unmapped > 0 && ( + {!inPairs && unmapped > 0 && ( // `osv-mapping-alert` макета [30] / [32-osv-no-inn]
)} - - ({ id: m.id, name: m.name }))} - onPage={osv.setPage} - numFormat={format} - onFilter={osv.patchFilters} - onMap={setMapping} - /> + {inPairs ? ( + s && ( + setView({ view: 'pairs', holder, missing })} + onMapped={osv.refresh} + /> + ) + ) : ( + <> + + ({ id: m.id, name: m.name }))} + onPage={osv.setPage} + numFormat={format} + onFilter={osv.patchFilters} + onMap={setMapping} + /> + + )} {mapping && version_id && ( Date: Tue, 27 Oct 2026 19:03:01 +0400 Subject: [PATCH 11/26] =?UTF-8?q?fix(#195):=20=D1=81=D0=B1=D1=80=D0=BE?= =?UTF-8?q?=D1=81=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B8=20=C2=AB?= =?UTF-8?q?=D0=A3=D0=BA=D0=B0=D0=B7=D0=B0=D1=82=D1=8C=C2=BB=20=D0=B8=20?= =?UTF-8?q?=D0=B2=D0=BE=D0=B7=D0=B2=D1=80=D0=B0=D1=82=20=D1=81=D0=BE=20?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=B7=D0=B0?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=86=D0=BE=D0=BC=20=D1=81=D0=BF=D0=B8?= =?UTF-8?q?=D1=81=D0=BA=D0=B0=20(T5.1=E2=80=93T5.3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx | 7 +++++++ .../ConsolidatedOsvContent/ConsolidatedOsvContent.tsx | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx index e61b328..c2bc981 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx @@ -55,6 +55,11 @@ function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey: getConsolidatedOsv(versionId, filters, OSV_PAGE_SIZE, (page - 1) * OSV_PAGE_SIZE) .then((r) => { if (requestId !== requestRef.current) return; + // страница ушла за конец (последнюю строку сопоставили) — назад на допустимую + if (r.items.length === 0 && page > 1) { + setPage(page - 1); + return; + } setRows(r.items); setTotal(r.total); setError(null); @@ -89,6 +94,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele useEffect(() => { setQ(''); setDebouncedQ(''); + setMapping(null); }, [selectedKey]); const centerFilters: OsvFilters | null = selected ? { legal_entity_id: selected.holder_id, counterparty_id: selected.missing_id } : null; @@ -128,6 +134,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele ]; const mapped = useCallback(() => { + setMapping(null); setReloadKey((k) => k + 1); onMapped(); }, [onMapped]); diff --git a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index 9888527..4550630 100644 --- a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -62,7 +62,9 @@ export function OsvContent({ card, onSummaryChange }: Props) { if (selectedPair && (selectedPair.holder_id !== pairsView.holder || selectedPair.missing_id !== pairsView.missing)) { setView({ view: 'pairs', holder: selectedPair.holder_id, missing: selectedPair.missing_id }); } - }); + // biome-ignore lint/correctness/useExhaustiveDependencies: setView пересоздаётся каждый рендер, нужны только эти значения + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inPairs, s, selectedPair, pairsView.holder, pairsView.missing]); useEffect(() => { onSummaryChange?.(s); }, [s, onSummaryChange]); From a332e8f3997fcd6ec4827a81955078ed3423217c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 19:05:18 +0400 Subject: [PATCH 12/26] =?UTF-8?q?CLAUDE.md:=20counter=5Fpairs=20=D0=B2=20c?= =?UTF-8?q?onsolidated=5Fosv?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index bd14a19..7ba1e06 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,7 +24,7 @@ `directory_import.py` — чтение xlsx/csv и план импорта §13, `aliases.py` — нормализация алиаса §3, `inn.py` — формат ИНН, `file_types.py` — тип файла по расширению и сигнатуре, `osv_mapping.py` — результат парсера → документ/строки ОСВ (§1.3, §1.5, §9.3); Срез 4 — `consolidation_periods.py` (выбор источников и приведение периодов, §2, Q1-эквивалентность ОСВ, - R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3), `checklist.py` + R-14), `consolidated_osv.py` (чистая сборка свода: склейка кварталов, пробелы, дорезолв, фильтры, §2–§3; `counter_pairs` — пары участников без встречных записей, §3 п. 9, AURORA-1746), `checklist.py` (чек-лист комплекта, ячейки `CellStatus`); Срез 3b — `statement_mapping.py` (формы ББ/ОФР: период, черновик, статус D-6), `statement_checks.py` (остатки §7 в ядре: межформенная 1370↔2400 и `subline_parents`; соотношения §7 считает `arm_statements`), `checklist_csv.py` (CSV чек-листа); `policy.py` дополнен `IN_WORK_STATUSES`/`ensure_version_editable`, §10; для шахматки From 50f28b5672bd271833695af218d1554d02739692 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 19:15:36 +0400 Subject: [PATCH 13/26] =?UTF-8?q?docs(#195):=20R-3=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index ec4dbd2..194f6d3 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -93,3 +93,4 @@ Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. | 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 проверяются финальным ревью всей ветки | Задача не содержит кода; отдельный ревьюер дублировал бы финальный | Низкая | From 397a1a7aef0eee7e4c3effe56030f4dacc23d646 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 19:22:25 +0400 Subject: [PATCH 14/26] =?UTF-8?q?fix(counter-pairs):=20=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BA=D0=B8=20=D1=84=D0=B8=D0=BD=D0=B0=D0=BB=D1=8C=D0=BD?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20(I-1,=20I?= =?UTF-8?q?-2,=20I-3,=20M-1,=20M-2,=20M-3,=20M-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Подпись пары без служебных слов, тест пары на фикстурах РБ, модалка при уходе с «ОСВ» на любую вкладку, без «Экспорт» в режиме пар, README и ARCHITECTURE, таблица решений design.md. Co-Authored-By: Claude Opus 5.5 --- README.md | 4 ++-- api/tests/test_consolidation_fixtures.py | 3 +++ openspec/changes/counter-pairs/design.md | 7 ++++--- web/ARCHITECTURE.md | 4 +++- web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx | 6 +++--- web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 2 +- .../ConsolidatedOsvContent/ConsolidatedOsvContent.tsx | 9 ++++++--- 7 files changed, 22 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4c13e12..20f9316 100644 --- a/README.md +++ b/README.md @@ -786,8 +786,8 @@ advisory-блокировкой. | `POST /consolidations/versions/{id}/status` | EDIT | ручной переход: `CANCELLED` (с комментарием) или возврат в `INITIATED` | | `PUT /consolidations/versions/{id}/members` | EDIT | заменить периметр (ЮЛ должны принадлежать ГК, ≥ 2) | | `GET /consolidations/versions/{id}/checklist` | VIEW | комплект документов: ЮЛ × правила, статус ячейки; `?format=csv` — тот же чек-лист файлом CSV | -| `GET /consolidations/versions/{id}/osv` | VIEW | страница сводной ОСВ и фильтры (`limit` ≤ 2000, по умолчанию 500) | -| `GET /consolidations/versions/{id}/osv/summary` | VIEW | `rows_total`, `unmapped_total`, `auto_matched_total`, `documents_total`, `gaps[]` | +| `GET /consolidations/versions/{id}/osv` | VIEW | страница сводной ОСВ и фильтры, в т. ч. `counterparty_id` — строки с этим контрагентом (`limit` ≤ 2000, по умолчанию 500) | +| `GET /consolidations/versions/{id}/osv/summary` | VIEW | `rows_total`, `unmapped_total`, `auto_matched_total`, `documents_total`, `gaps[]`, `counter_pairs[]` (пары «участник не видит встречных записей») | | `GET /consolidations/versions/{id}/osv/export` | VIEW | xlsx: лист «Сводная ОСВ» и лист «Пробелы» | | `GET /consolidations/versions/{id}/counterparty-candidates` | VIEW | кандидаты для сопоставления: `scope=group\|all`, `q`, `entry_id` | | `POST /consolidations/versions/{id}/osv/entries/{entry_id}/counterparty` | EDIT | ручное сопоставление контрагента строки свода | diff --git a/api/tests/test_consolidation_fixtures.py b/api/tests/test_consolidation_fixtures.py index 4132741..834456a 100644 --- a/api/tests/test_consolidation_fixtures.py +++ b/api/tests/test_consolidation_fixtures.py @@ -162,6 +162,8 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0 # Числа сверены независимым подсчётом по сырым golden-JSON (склейка Q1–Q3, пробелы по наличию файлов). assert summary["rows_total"] == len(rows) == ROWS_TOTAL assert summary["unmapped_total"] == UNMAPPED_TOTAL + # РБ записал «Юг-Финанс / ПЕКАРНЯ СЧАСТЛИВОЧНАЯ» без ИНН: Счастливочная видит РБ, а РБ — её нет (§3 п. 9) + assert [(p["holder_id"], p["missing_id"]) for p in summary["counter_pairs"]] == [(str(schastl.id), str(rb.id))] # строки свода: 60 и 62 у обеих, 76 у Счастливочной present = {(row["legal_entity_id"], row["account_root"]) for row in rows} @@ -219,6 +221,7 @@ async def test_consolidation_on_russian_biscuit_fixtures( # noqa: PLR0912, PLR0 assert body["propagated"] == PROPAGATED after = await _get(client, f"{version_url}/osv/summary") + assert after["counter_pairs"] == [] # после сопоставления пара закрыта rows_after = await _all_rows(client, version_url) # `propagated` считает записи `osv_entry` (в том числе в парах-пробелах 66/76, строк в своде не дающих), а # `unmapped_total` — строки свода: квартальные записи одного контрагента склеены (D-2). Поэтому `unmapped_total` diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index 194f6d3..c02c535 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -49,7 +49,7 @@ из шапки). Правая таблица — постранично, `OSV_PAGE_SIZE`, «Указать» → `CounterpartyMapModal` (существующая). - `ConsolidationReportPage` / `OsvTabs`: вместо `unmappedTotal` получают сводку (пары и `unmapped_total`); правило показа модалки — чистая функция в `utils/consolidations.ts` (`counterPairsKey(pairs)`, `shouldWarnCounterPairs(versionId, - pairs, storage)`), с тестами vitest. Ключ `sessionStorage` — `armka:counter-pairs:`, значение — ключ списка + status, pairs, storage)`), с тестами vitest. Ключ `sessionStorage` — `armka:counter-pairs:`, значение — ключ списка пар (`holder_id>missing_id`, отсортированные, через запятую). Чтение и запись — в `try/catch`. - `UnmappedInnModal` заменяется модалкой «Проверьте встречные записи участников»; склонение «пара/пары/пар», «участника/участников» — существующая `pluralRu` (`web/src/utils/labels.ts`). @@ -89,8 +89,9 @@ | 27.10.2026 | Тексты баннера и модалки — из спек этого change | Владелец согласовал смысл, формулировки мои | Низкая | | 27.10.2026 | Поиск `q` над правой таблицей режима (находка builder check) | У РБ ~1000 строк без ИНН; без поиска разбор пары — листание страниц | Низкая: существующий фильтр API | | 27.10.2026 | Модалка не показывается для версий в `FINISHED_STATUSES` (находка builder check) | Замороженную версию не исправить — предупреждение было бы шумом | Низкая | - -Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. | 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: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. diff --git a/web/ARCHITECTURE.md b/web/ARCHITECTURE.md index 12e7f29..b3a44ec 100644 --- a/web/ARCHITECTURE.md +++ b/web/ARCHITECTURE.md @@ -30,7 +30,9 @@ SPA поверх API arm-core (`/api/v1`). Своих данных и бизне | `ConsoleTable/LockModal.tsx` | [35] | «Взять в работу» (`POST …/lock`) и перехват с причиной (`takeover_reason`) | | `../utils/consolidations.ts` | — | правила без React: подписи и пояснения статусов версии, ручные переходы (`MANUAL_TRANSITIONS` — зеркало бэка), своя блокировка, «Версия», `in_work_only`, причина ≤ 1000 | | `ConsoleTable/consts.ts` | — | цвета бейджей статусов версии | -| `ConsolidatedOsv/OsvTabs.tsx` | [30], [31-00] | вкладки шагов отчёта и «Остались неуказанные ИНН» при уходе с «ОСВ» | +| `ConsolidatedOsv/OsvTabs.tsx` | [30] | вкладки шагов отчёта и модалка «Проверьте встречные записи участников» при уходе с «ОСВ» (список пар — `counter_pairs` сводки) | +| `ConsolidatedOsv/CounterPairsView.tsx` | [30] | режим «Встречные записи» вкладки «ОСВ»: пары, строки держателя, строки Б без ИНН с «Указать» | +| `ConsolidatedOsv/counterPairs.ts` | — | правила без React: подпись пары, параметры адреса режима (`?view=pairs&holder=&missing=`) | | `ConsolidatedOsv/useNumberDisplay.tsx` | [30] | «Единицы» и «Знаков после запятой» сводной ОСВ (`formatScaledAmount` из `utils/money.ts`) | | `../hooks/useBeforeUnload.ts` | — | предупреждение браузера о несохранённых правках в открытой модалке | | `ConsoleLegalEntities/` | [34] | панель «Организации» выбранной версии и чек-лист | diff --git a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx index c2bc981..7fe11ae 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx @@ -55,9 +55,9 @@ function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey: getConsolidatedOsv(versionId, filters, OSV_PAGE_SIZE, (page - 1) * OSV_PAGE_SIZE) .then((r) => { if (requestId !== requestRef.current) return; - // страница ушла за конец (последнюю строку сопоставили) — назад на допустимую + // страница ушла за конец (последнюю строку сопоставили) — сразу на последнюю существующую if (r.items.length === 0 && page > 1) { - setPage(page - 1); + setPage(Math.max(1, Math.ceil(r.total / OSV_PAGE_SIZE))); return; } setRows(r.items); @@ -171,7 +171,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele }}>
{pairLabel(p)}
- строк у держателя: {p.holder_rows} · без ИНН у Б: {p.missing_unmapped_rows} + записей: {p.holder_rows} · без ИНН у {p.missing_name}: {p.missing_unmapped_rows}
); diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index 9523035..a2524b6 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -76,7 +76,7 @@ export function OsvTabs({ summary, versionId, status }: Props) { const newKey = String(value); if (newKey === activeKey) return; const pairs = summary?.counter_pairs ?? []; - if (activeKey === 'osv' && newKey === 'chess' && shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())) { + if (activeKey === 'osv' && shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())) { rememberCounterPairsShown(versionId, pairs, safeSessionStorage()); setPending(newKey); } else { diff --git a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index 4550630..a7b4f90 100644 --- a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -101,9 +101,12 @@ export function OsvContent({ card, onSummaryChange }: Props) { /> - + {/* выгружает весь свод, а не пару — в режиме пар не нужна */} + {!inPairs && ( + + )} Date: Tue, 27 Oct 2026 21:52:10 +0400 Subject: [PATCH 15/26] =?UTF-8?q?docs(#195):=20=D0=BF=D0=BB=D0=B0=D0=BD=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D0=BE=D0=BB=D0=BD=D0=B0=202=20(=D0=B7=D0=B0?= =?UTF-8?q?=D0=BA=D1=80=D1=8B=D1=82=D1=8C=20=D0=BE=D1=82=D0=BB=D0=BE=D0=B6?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D0=BE=D0=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-27-counter-pairs.md | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/docs/superpowers/plans/2026-10-27-counter-pairs.md b/docs/superpowers/plans/2026-10-27-counter-pairs.md index a5bfe9b..c50247b 100644 --- a/docs/superpowers/plans/2026-10-27-counter-pairs.md +++ b/docs/superpowers/plans/2026-10-27-counter-pairs.md @@ -189,3 +189,36 @@ specs/consolidations-ui). Issue — arm-core #195, Jira — 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 · без ИНН у : 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. From 8054ff205d6ee4f6cbc81bd0705ec8ede24f5451 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 21:54:05 +0400 Subject: [PATCH 16/26] =?UTF-8?q?feat(#195):=20=D0=BF=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D1=85=D0=BE=D0=B4=20=D1=81=20=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=9E=D0=A1=D0=92,=20=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=BF=D0=B0=D1=80=20=D0=B1=D0=B5=D0=B7=20=D0=BB=D0=B8?= =?UTF-8?q?=D1=88=D0=BD=D0=B8=D1=85=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81?= =?UTF-8?q?=D0=BE=D0=B2,=20=D0=BF=D0=B0=D1=80=D0=B0=20=D0=B2=20=D0=B0?= =?UTF-8?q?=D0=B4=D1=80=D0=B5=D1=81=D0=B5=20=D0=BF=D1=80=D0=B8=20=C2=AB?= =?UTF-8?q?=D0=A1=D0=B2=D0=BE=D0=B4=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- web/src/utils/consolidations.test.ts | 23 ++++++++++ web/src/utils/consolidations.ts | 18 +++++++- web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 42 +++++++++++++---- .../ConsolidatedOsv/counterPairs.test.ts | 13 +++++- .../widgets/ConsolidatedOsv/counterPairs.ts | 21 +++++---- .../ConsolidatedOsv/useConsolidatedOsv.ts | 24 +++++----- .../ConsolidatedOsvContent.tsx | 45 ++++++++++--------- 7 files changed, 136 insertions(+), 50 deletions(-) diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts index 2b8bd77..5ff5850 100644 --- a/web/src/utils/consolidations.test.ts +++ b/web/src/utils/consolidations.test.ts @@ -11,6 +11,7 @@ import { consStatusDescription, counterPairsKey, counterPairsWarning, + decideOsvLeave, endSentence, isLockedBy, isValidReason, @@ -222,3 +223,25 @@ describe('counterPairsWarning', () => { ); }); }); + +describe('decideOsvLeave (T4.2)', () => { + const pairs = [{ holder_id: 'a', missing_id: 'b' } as CounterPairOut]; + const noStorage = { getItem: () => null }; + const base = { versionId: 'v', status: 'CONSOLIDATION_RF' as ConsStatus, storage: noStorage }; + it('сводки нет и версия открыта — запросить', () => { + expect(decideOsvLeave({ ...base, summary: null })).toBe('perform'); + }); + it('версия закрыта — переход без запроса', () => { + expect(decideOsvLeave({ ...base, status: 'VERIFIED', summary: null })).toBe('go'); + }); + it('сводка есть, пары есть и не показывали — предупредить', () => { + expect(decideOsvLeave({ ...base, summary: { counter_pairs: pairs } })).toBe('warn'); + }); + it('сводка есть, пар нет — перейти', () => { + expect(decideOsvLeave({ ...base, summary: { counter_pairs: [] } })).toBe('go'); + }); + it('список уже показывали — перейти', () => { + const shown = { getItem: () => 'a>b' }; + expect(decideOsvLeave({ ...base, storage: shown, summary: { counter_pairs: pairs } })).toBe('go'); + }); +}); diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts index 7124db0..1832061 100644 --- a/web/src/utils/consolidations.ts +++ b/web/src/utils/consolidations.ts @@ -1,6 +1,6 @@ // Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные // переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API. -import type { CounterPairOut } from '../types/api'; +import type { CounterPairOut, OsvSummaryOut } from '../types/api'; import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types'; import { pluralRu } from './labels'; @@ -154,3 +154,19 @@ export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTo details: unmappedTotal > 0 ? `${base} Ещё ${unmappedTotal} строк без ИНН учтены как внешние.` : base, }; }; + +/** Решение при уходе с вкладки «ОСВ»: `perform` — сводки ещё нет, запросить; `warn` — показать модалку; `go` — перейти. */ +export type OsvLeaveDecision = 'perform' | 'warn' | 'go'; + +export const decideOsvLeave = (args: { + summary: Pick | null; + versionId: string; + status: ConsStatus | undefined; + storage: Pick | null; +}): OsvLeaveDecision => { + const { summary, versionId, status, storage } = args; + // закрытой версии модалка не нужна — и запрашивать нечего + if (status && FINISHED_STATUSES.includes(status)) return 'go'; + if (summary === null) return 'perform'; + return shouldWarnCounterPairs(versionId, status, summary.counter_pairs, storage) ? 'warn' : 'go'; +}; diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index a2524b6..f9e421f 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -2,9 +2,10 @@ import { WarningOutlined } from '@ant-design/icons'; import { Button, Modal, Segmented, Tooltip } from 'antd'; import { useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { getOsvSummary } from '../../services/api/osv'; import type { CounterPairOut, OsvSummaryOut } from '../../types/api'; import type { ConsStatus } from '../../types/consolidations/common_types'; -import { counterPairsWarning, rememberCounterPairsShown, reportTabNavigation, shouldWarnCounterPairs } from '../../utils/consolidations'; +import { counterPairsWarning, decideOsvLeave, rememberCounterPairsShown, reportTabNavigation } from '../../utils/consolidations'; import { pairsViewSearch } from './counterPairs'; const TAB_CONFIG = [ @@ -72,12 +73,36 @@ export function OsvTabs({ summary, versionId, status }: Props) { navigate(to, options); }; - const handleTabChange = (value: string | number) => { + // сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал) + const [fetched, setFetched] = useState(null); + const [checking, setChecking] = useState(false); + const shownPairs = (summary ?? fetched)?.counter_pairs ?? []; + + const handleTabChange = async (value: string | number) => { const newKey = String(value); - if (newKey === activeKey) return; - const pairs = summary?.counter_pairs ?? []; - if (activeKey === 'osv' && shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())) { - rememberCounterPairsShown(versionId, pairs, safeSessionStorage()); + if (newKey === activeKey || checking) return; + if (activeKey !== 'osv') { + go(newKey); + return; + } + const storage = safeSessionStorage(); + let known = summary ?? fetched; + let decision = decideOsvLeave({ summary: known, versionId, status, storage }); + if (decision === 'perform') { + setChecking(true); + try { + known = await getOsvSummary(versionId); + setFetched(known); + decision = decideOsvLeave({ summary: known, versionId, status, storage }); + } catch { + // не удалось проверить — не держим пользователя на вкладке + decision = 'go'; + } finally { + setChecking(false); + } + } + if (decision === 'warn' && known) { + rememberCounterPairsShown(versionId, known.counter_pairs, storage); setPending(newKey); } else { go(newKey); @@ -110,13 +135,14 @@ export function OsvTabs({ summary, versionId, status }: Props) { { setPending(null); navigate( diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts index 0e40837..f56f96a 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { CounterPairOut } from '../../types/api'; -import { pairLabel, pairsViewSearch, readPairsView, selectPair } from './counterPairs'; +import { pairLabel, pairsViewSearch, readPairsView, selectPair, switchPairsView } from './counterPairs'; const pair = (holder: string, missing: string): CounterPairOut => ({ holder_id: holder, @@ -15,7 +15,16 @@ const pair = (holder: string, missing: string): CounterPairOut => ({ describe('readPairsView / pairsViewSearch', () => { it('table по умолчанию', () => { expect(readPairsView(new URLSearchParams(''))).toEqual({ view: 'table', holder: null, missing: null }); - expect(pairsViewSearch({ view: 'table', holder: 'a', missing: 'b' })).toBe(''); + expect(pairsViewSearch({ view: 'table', holder: null, missing: null })).toBe(''); + }); + it('пара сохраняется в режиме «Свод» (T5.5)', () => { + const v = { view: 'table' as const, holder: 'a', missing: 'b' }; + const s = pairsViewSearch(v); + expect(s).toBe('?holder=a&missing=b'); + expect(readPairsView(new URLSearchParams(s))).toEqual(v); + // переключение «Свод» → «Встречные записи» возвращает ту же пару + expect(switchPairsView({ view: 'pairs', holder: 'a', missing: 'b' }, 'table')).toEqual(v); + expect(switchPairsView(v, 'pairs')).toEqual({ view: 'pairs', holder: 'a', missing: 'b' }); }); it('туда-обратно', () => { const v = { view: 'pairs' as const, holder: 'a', missing: 'b' }; diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.ts index 214190d..363e8d1 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.ts @@ -4,18 +4,23 @@ import type { CounterPairOut } from '../../types/api'; export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null }; /** `?view=pairs[&holder=..&missing=..]`; всё остальное — обычная таблица свода. */ -export const readPairsView = (search: URLSearchParams): PairsView => - search.get('view') === 'pairs' - ? { view: 'pairs', holder: search.get('holder'), missing: search.get('missing') } - : { view: 'table', holder: null, missing: null }; +export const readPairsView = (search: URLSearchParams): PairsView => ({ + view: search.get('view') === 'pairs' ? 'pairs' : 'table', + holder: search.get('holder'), + missing: search.get('missing'), +}); -/** Строка запроса для режима; у таблицы пусто. */ +/** Переключение «Свод» / «Встречные записи»: выбранная пара не теряется. */ +export const switchPairsView = (v: PairsView, view: PairsView['view']): PairsView => ({ ...v, view }); + +/** Строка запроса для режима; пара (`holder`/`missing`) хранится и в режиме «Свод» — для возврата. */ export const pairsViewSearch = (v: PairsView): string => { - if (v.view !== 'pairs') return ''; - const params = new URLSearchParams({ view: 'pairs' }); + const params = new URLSearchParams(); + if (v.view === 'pairs') params.set('view', 'pairs'); if (v.holder) params.set('holder', v.holder); if (v.missing) params.set('missing', v.missing); - return `?${params.toString()}`; + const text = params.toString(); + return text ? `?${text}` : ''; }; /** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */ diff --git a/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts b/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts index e9a2a7b..b8739a6 100644 --- a/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts +++ b/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts @@ -2,8 +2,8 @@ import { message } from 'antd'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; import { getConsolidatedOsv, getOsvSummary } from '../../services/api/osv'; -import type { ConsolidatedRowOut, OsvFilters, OsvSummaryOut } from '../../types/api'; -import { type OsvFilterDraft, applyFilterPatch } from './osvFilterState'; +import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api'; +import { type OsvFilterDraft, type OsvFilterState, applyFilterPatch } from './osvFilterState'; export const OSV_PAGE_SIZE = 500; @@ -17,9 +17,14 @@ export function osvErrorText(e: unknown): string { return 'Не удалось получить сводную ОСВ'; } -export function useConsolidatedOsv(versionId: string) { - const [filters, setFilters] = useState({}); - const [page, setPage] = useState(1); +/** + * @param pairsMode режим «Встречные записи»: строки свода не нужны и не запрашиваются (сводка — запрашивается). + * Фильтры и страница — одно состояние: смена фильтра с возвратом на 1-ю страницу даёт один запрос, а не два. + */ +export function useConsolidatedOsv(versionId: string, pairsMode = false) { + const [query, setQuery] = useState({ filters: {}, page: 1 }); + const { filters, page } = query; + const setPage = useCallback((p: number) => setQuery((q) => ({ ...q, page: p })), []); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [summary, setSummary] = useState(null); @@ -71,18 +76,17 @@ export function useConsolidatedOsv(versionId: string) { }, [versionId, filters, page]); useEffect(() => { + if (pairsMode) return; void load(); - }, [load]); + }, [load, pairsMode]); const patchFilters = (patch: OsvFilterDraft) => { - const next = applyFilterPatch({ filters, page }, patch); - setFilters(next.filters); - setPage(next.page); + setQuery((q) => applyFilterPatch(q, patch)); }; /** После ручного сопоставления: «Осталось N» и строки меняются сразу у нескольких строк (автоприменение) — перечитать обе. */ const refresh = () => { - void load(); + if (!pairsMode) void load(); void loadSummary(); }; diff --git a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index a7b4f90..7661d81 100644 --- a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -1,5 +1,5 @@ import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { Alert, Button, Segmented, Select, message } from 'antd'; +import { Alert, Button, Segmented, Select, Spin, message } from 'antd'; import { useEffect, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import { ApiError } from '../../api/client'; @@ -11,7 +11,7 @@ import { CounterPairsView } from '../ConsolidatedOsv/CounterPairsView'; import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal'; import { OsvGaps } from '../ConsolidatedOsv/OsvGaps'; import { OsvTable } from '../ConsolidatedOsv/OsvTable'; -import { pairsViewSearch, readPairsView, selectPair } from '../ConsolidatedOsv/counterPairs'; +import { pairsViewSearch, readPairsView, selectPair, switchPairsView } from '../ConsolidatedOsv/counterPairs'; import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping'; import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv'; import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay'; @@ -28,7 +28,11 @@ export function OsvContent({ card, onSummaryChange }: Props) { // строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ) const [mapping, setMapping] = useState(null); - const osv = useConsolidatedOsv(version_id ?? ''); + const [searchParams, setSearchParams] = useSearchParams(); + const pairsView = readPairsView(searchParams); + const inPairs = pairsView.view === 'pairs'; + const osv = useConsolidatedOsv(version_id ?? '', inPairs); + const s = osv.summary; const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay(); const download = async () => { @@ -46,12 +50,8 @@ export function OsvContent({ card, onSummaryChange }: Props) { } }; - const s = osv.summary; - const [searchParams, setSearchParams] = useSearchParams(); - const pairsView = readPairsView(searchParams); const pairs = s?.counter_pairs ?? []; const selectedPair = selectPair(pairs, pairsView.holder, pairsView.missing); - const inPairs = pairsView.view === 'pairs'; const setView = (v: Parameters[0]) => setSearchParams(pairsViewSearch(v), { replace: true }); const unmapped = s?.unmapped_total ?? 0; const onlyUnmapped = osv.filters.unmapped === true; @@ -71,6 +71,19 @@ export function OsvContent({ card, onSummaryChange }: Props) { // вне вкладки «ОСВ» предупреждать не о чем useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]); + const pairsBody = s ? ( + setView({ view: 'pairs', holder, missing })} + onMapped={osv.refresh} + /> + ) : ( + !osv.error && + ); + return ( <>
- setView(v === 'pairs' ? { view: 'pairs', holder: null, missing: null } : { view: 'table', holder: null, missing: null }) - } + onChange={(v) => setView(switchPairsView(pairsView, v === 'pairs' ? 'pairs' : 'table'))} options={[ { label: 'Свод', value: 'table' }, - { label: `Встречные записи (${pairs.length})`, value: 'pairs' }, + // пока сводки нет — без числа + { label: s ? `Встречные записи (${pairs.length})` : 'Встречные записи', value: 'pairs' }, ]} style={{ alignSelf: 'flex-start' }} /> @@ -149,16 +161,7 @@ export function OsvContent({ card, onSummaryChange }: Props) { )} {inPairs ? ( - s && ( - setView({ view: 'pairs', holder, missing })} - onMapped={osv.refresh} - /> - ) + pairsBody ) : ( <> From 97071b03ad665279a8b3b7f717ddd81da38110fb Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 21:54:18 +0400 Subject: [PATCH 17/26] =?UTF-8?q?docs(#195):=20R-6=20=D0=B2=20design.md?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/design.md | 1 + 1 file changed, 1 insertion(+) diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index c02c535..826d153 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -95,3 +95,4 @@ | 27.10.2026 | R-4 (design #12): подпись под парой в списке — «записей: N · без ИНН у <Б>: M» вместо служебных «держатель»/«Б» | Аналитик не знает ролей «держатель» и «Б»; название организации понятнее | Низкая | Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. +| 27.10.2026 | R-6 (design #14): уход с «ОСВ» — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая | From e2e416282f87f2a2a1744ad77eb44d31d9fd19ec Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 21:55:47 +0400 Subject: [PATCH 18/26] =?UTF-8?q?feat(#195):=20=D1=80=D0=B0=D1=81=D0=BA?= =?UTF-8?q?=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC?= =?UTF-8?q?=D0=B0=20=C2=AB=D0=92=D1=81=D1=82=D1=80=D0=B5=D1=87=D0=BD=D1=8B?= =?UTF-8?q?=D0=B5=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=C2=BB=20=E2=80=94?= =?UTF-8?q?=20=D0=BF=D0=B0=D1=80=D1=8B=20=D1=81=D0=B2=D0=B5=D1=80=D1=85?= =?UTF-8?q?=D1=83,=20=D0=B4=D0=B2=D0=B5=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8?= =?UTF-8?q?=D1=86=D1=8B=20=D0=BD=D0=B0=20=D0=B2=D1=81=D1=8E=20=D1=88=D0=B8?= =?UTF-8?q?=D1=80=D0=B8=D0=BD=D1=83;=20=D1=87=D0=B8=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BF=D1=83?= =?UTF-8?q?=D1=81=D1=82=D1=8B=D1=85=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F?= =?UTF-8?q?=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/design.md | 1 + openspec/changes/counter-pairs/proposal.md | 4 +-- .../specs/consolidated-osv-ui/spec.md | 15 ++++---- .../ConsolidatedOsv/CounterPairsView.tsx | 34 ++++++++----------- .../ConsolidatedOsv/counterPairs.test.ts | 34 ++++++++++++++++++- .../widgets/ConsolidatedOsv/counterPairs.ts | 12 +++++++ 6 files changed, 71 insertions(+), 29 deletions(-) diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index 826d153..f1593c6 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -93,6 +93,7 @@ | 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: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. | 27.10.2026 | R-6 (design #14): уход с «ОСВ» — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая | diff --git a/openspec/changes/counter-pairs/proposal.md b/openspec/changes/counter-pairs/proposal.md index 071f44e..0a44aa6 100644 --- a/openspec/changes/counter-pairs/proposal.md +++ b/openspec/changes/counter-pairs/proposal.md @@ -22,8 +22,8 @@ AURORA-1746 и замечание заказчика (Циликов А.В., 23. - **Ядро.** Чистая функция «пары без встречных записей» в `domain/consolidated_osv.py`: для участников периметра А ≠ Б пара (А, Б) — у А есть строка свода с `counterparty_id = Б`, у Б нет строки с `counterparty_id = А`. `GET …/osv/summary` отдаёт список `counter_pairs[]`; `GET …/osv` и экспорт получают фильтр `counterparty_id`. Ничего не хранится. -- **Фронт, режим «Встречные записи».** Над сводом — переключатель «Свод | Встречные записи (N)». Во втором режиме: слева - список пар, в центре — строки А с контрагентом Б, справа — строки Б без ИНН и без сопоставления с «Указать» (та же +- **Фронт, режим «Встречные записи».** Над сводом — переключатель «Свод | Встречные записи (N)». Во втором режиме: сверху + список пар, под ним две таблицы на всю ширину: строки А с контрагентом Б и строки Б без ИНН и без сопоставления с «Указать» (та же модалка сопоставления, что в своде). - **Фронт, модалка перед шахматкой.** Вместо [31-00] «Остались неуказанные ИНН» — «Нет встречных записей: N пар у M участников», кнопки «Показать пары» / «Остаться» / «Перейти». Показывается при уходе с «ОСВ», если пары есть, один раз diff --git a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md index 8b9285d..3499a05 100644 --- a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md +++ b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md @@ -17,15 +17,16 @@ `summary.counter_pairs`. Режим — параметр адреса `?view=pairs` (без параметра — «Свод»); переключение режима MUST NOT добавлять запись в историю браузера. Шапка свода (единицы, знаки после запятой) действует в обоих режимах. -В режиме «Встречные записи» экран SHALL показывать три колонки: -- **слева** — список пар в порядке ответа, строка пары «Б ← А» (имена `missing_name` и `holder_name`) с числами +В режиме «Встречные записи» экран SHALL показывать сверху список пар, под ним на всю ширину две таблицы одна под другой: +- **сверху** — список пар одной строкой «таблеток» с переносом, в порядке ответа, строка пары «Б ← А» (имена `missing_name` и `holder_name`) с числами `holder_rows` и `missing_unmapped_rows`; выбранная пара подсвечена; при входе выбрана первая пара либо пара из адреса (`?view=pairs&holder=&missing=`); -- **в центре** — «У <А> есть записи с <Б>»: строки `GET …/osv?legal_entity_id=А&counterparty_id=Б` — счёт, контрагент, +- **первая таблица** — «У <А> есть записи с <Б>»: строки `GET …/osv?legal_entity_id=А&counterparty_id=Б` — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт; -- **справа** — «У <Б> — строки без ИНН и без сопоставления»: строки `GET …/osv?legal_entity_id=Б&unmapped=true` — счёт, +- **вторая таблица** — «У <Б> — строки без ИНН и без сопоставления»: строки `GET …/osv?legal_entity_id=Б&unmapped=true` — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт и «Указать» в каждой строке; над таблицей — поиск по контрагенту - (`q`, ≤ 500 символов, как фильтр свода); постранично, как таблица свода. Если + (`q`, ≤ 500 символов, как фильтр свода); постранично, как таблица свода. Все четыре колонки + сумм MUST быть видны без горизонтальной прокрутки при ширине окна 1440 px. Если `missing_rows_total = 0` — вместо таблицы текст «У <Б> в своде нет строк: ОСВ не привязаны или по ним пробелы»; если строки у Б есть, а без ИНН и без сопоставления нет (`missing_unmapped_rows = 0`) — текст «Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте». @@ -36,8 +37,8 @@ #### Scenario: Пара Русского бисквита - **WHEN** открыт свод версии РБ 2024 Q3 до сопоставления и выбран режим «Встречные записи» -- **THEN** переключатель «Встречные записи (1)»; слева пара «Русский бисквит ← Пекарня Счастливочная»; в центре строки - Счастливочной с контрагентом «Русский бисквит»; справа строки РБ без ИНН, среди них «Пекарня Счастливочная ООО» с +- **THEN** переключатель «Встречные записи (1)»; сверху пара «Русский бисквит ← Пекарня Счастливочная»; в первой таблице строки + Счастливочной с контрагентом «Русский бисквит»; во второй таблице строки РБ без ИНН, среди них «Пекарня Счастливочная ООО» с «Указать» #### Scenario: Сопоставление из режима закрывает пару diff --git a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx index 7fe11ae..44b0895 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx @@ -1,4 +1,5 @@ -// Режим «Встречные записи» вкладки «ОСВ»: слева пары, в центре строки держателя с Б, справа строки Б без ИНН и сопоставления. +// Режим «Встречные записи» вкладки «ОСВ»: сверху пары «таблетками», под ними на всю ширину две таблицы — +// строки держателя с Б и строки Б без ИНН и сопоставления. // Правило: 05-domain-rules.md §3 п. 9. import { Alert, Button, Input, Table } from 'antd'; import type { TableColumnsType } from 'antd'; @@ -7,7 +8,7 @@ import { getConsolidatedOsv } from '../../services/api/osv'; import type { ConsolidatedRowOut, CounterPairOut, OsvFilters } from '../../types/api'; import { CounterpartyMapModal } from './CounterpartyMapModal'; import { group, money } from './OsvTable'; -import { pairLabel } from './counterPairs'; +import { clampPage, missingSideEmptyText, pairLabel } from './counterPairs'; import { mapEntryId } from './counterpartyMapping'; import { OSV_PAGE_SIZE, osvErrorText } from './useConsolidatedOsv'; @@ -57,7 +58,7 @@ function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey: if (requestId !== requestRef.current) return; // страница ушла за конец (последнюю строку сопоставили) — сразу на последнюю существующую if (r.items.length === 0 && page > 1) { - setPage(Math.max(1, Math.ceil(r.total / OSV_PAGE_SIZE))); + setPage(clampPage(page, r.total, OSV_PAGE_SIZE)); return; } setRows(r.items); @@ -99,7 +100,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele const centerFilters: OsvFilters | null = selected ? { legal_entity_id: selected.holder_id, counterparty_id: selected.missing_id } : null; // таблицу справа не запрашиваем, если у Б нет строк или нет строк без ИНН: показываем текст - const rightHasRows = !!selected && selected.missing_rows_total > 0 && selected.missing_unmapped_rows > 0; + const rightHasRows = !!selected && missingSideEmptyText(selected) === null; const rightFilters: OsvFilters | null = selected && rightHasRows ? { legal_entity_id: selected.missing_id, unmapped: true, ...(debouncedQ ? { q: debouncedQ } : {}) } : null; @@ -107,11 +108,11 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele const right = usePagedRows(versionId, rightFilters, reloadKey); const baseColumns: TableColumnsType = [ - { key: 'account_code', title: 'Счёт', dataIndex: 'account_code', width: 100 }, + { key: 'account_code', title: 'Счёт', dataIndex: 'account_code', width: 90 }, { key: 'counterparty', title: 'Контрагент', - width: 240, + ellipsis: true, render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—', }, group('Обороты за период', [money('turnover_debit', 'Дебет', numFormat), money('turnover_credit', 'Кредит', numFormat)]), @@ -121,7 +122,6 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele ...baseColumns, { key: 'map', - fixed: 'right', title: 'ИНН', align: 'center', width: 100, @@ -143,16 +143,11 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele return
Все участники периметра видят друг друга
; } - let rightEmpty: string | null = null; - if (selected.missing_rows_total === 0) { - rightEmpty = `У ${selected.missing_name} в своде нет строк: ОСВ не привязаны или по ним пробелы`; - } else if (selected.missing_unmapped_rows === 0) { - rightEmpty = 'Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте'; - } + const rightEmpty = missingSideEmptyText(selected); return ( -
-
+
+
{pairs.map((p) => { const active = p.holder_id === selected.holder_id && p.missing_id === selected.missing_id; return ( @@ -162,6 +157,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele onClick={() => onSelect(p.holder_id, p.missing_id)} style={{ textAlign: 'left', + maxWidth: 360, cursor: 'pointer', padding: '8px 10px', borderRadius: 10, @@ -177,7 +173,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele ); })}
-
+
У {selected.holder_name} есть записи с {selected.missing_name}
@@ -189,7 +185,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele loading={center.loading} columns={baseColumns} dataSource={center.rows} - scroll={{ x: 'max-content' }} + tableLayout='fixed' pagination={{ current: center.page, pageSize: OSV_PAGE_SIZE, @@ -201,7 +197,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele }} />
-
+
У {selected.missing_name} — строки без ИНН и без сопоставления
{rightEmpty ? (
{rightEmpty}
@@ -222,7 +218,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele loading={right.loading} columns={rightColumns} dataSource={right.rows} - scroll={{ x: 'max-content' }} + tableLayout='fixed' locale={{ emptyText: debouncedQ ? 'По поиску строк нет' : 'Строк нет' }} pagination={{ current: right.page, diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts index f56f96a..ff3d26b 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { CounterPairOut } from '../../types/api'; -import { pairLabel, pairsViewSearch, readPairsView, selectPair, switchPairsView } from './counterPairs'; +import { clampPage, missingSideEmptyText, pairLabel, pairsViewSearch, readPairsView, selectPair, switchPairsView } from './counterPairs'; const pair = (holder: string, missing: string): CounterPairOut => ({ holder_id: holder, @@ -54,3 +54,35 @@ describe('pairLabel', () => { expect(pairLabel(pair('a', 'b'))).toBe('Mb ← Ha'); }); }); + +describe('missingSideEmptyText (T5.6)', () => { + const p = (total: number, unmapped: number): CounterPairOut => ({ + ...pair('a', 'b'), + missing_rows_total: total, + missing_unmapped_rows: unmapped, + }); + it('у Б нет строк в своде', () => { + expect(missingSideEmptyText(p(0, 0))).toBe('У Mb в своде нет строк: ОСВ не привязаны или по ним пробелы'); + }); + it('строки есть, без ИНН нет', () => { + expect(missingSideEmptyText(p(5, 0))).toBe( + 'Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте', + ); + }); + it('есть строки без ИНН — текста нет, показываем таблицу', () => { + expect(missingSideEmptyText(p(5, 2))).toBeNull(); + }); +}); + +describe('clampPage (T5.6)', () => { + it('не выходит за последнюю страницу', () => { + expect(clampPage(3, 500, 500)).toBe(1); + expect(clampPage(5, 1001, 500)).toBe(3); + }); + it('оставляет страницу в пределах', () => { + expect(clampPage(2, 1001, 500)).toBe(2); + }); + it('пустой результат — первая страница', () => { + expect(clampPage(4, 0, 500)).toBe(1); + }); +}); diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.ts index 363e8d1..1ff4e9d 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.ts @@ -28,3 +28,15 @@ export const selectPair = (pairs: readonly CounterPairOut[], holder: string | nu pairs.find((p) => p.holder_id === holder && p.missing_id === missing) ?? pairs[0] ?? null; export const pairLabel = (p: CounterPairOut): string => `${p.missing_name} ← ${p.holder_name}`; + +/** Текст вместо правой таблицы (строки Б без ИНН); `null` — таблицу показываем. */ +export const missingSideEmptyText = (p: CounterPairOut): string | null => { + if (p.missing_rows_total === 0) return `У ${p.missing_name} в своде нет строк: ОСВ не привязаны или по ним пробелы`; + if (p.missing_unmapped_rows === 0) + return 'Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте'; + return null; +}; + +/** Страница после ответа: не дальше последней существующей (последнюю строку сопоставили), не меньше первой. */ +export const clampPage = (page: number, total: number, pageSize: number): number => + Math.min(page, Math.max(1, Math.ceil(total / pageSize))); From 95171f4f50136db35c08ee1e783a22653c85d348 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 21:56:59 +0400 Subject: [PATCH 19/26] =?UTF-8?q?docs(#195):=20=D0=BF=D1=80=D0=B0=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Task=208=20?= =?UTF-8?q?=E2=80=94=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B8=D1=80?= =?UTF-8?q?=D0=BE=D0=B2=D0=BA=D0=B8=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB=D0=B0?= =?UTF-8?q?=D0=B4=D0=BA=D0=B8,=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/design.md | 9 ++-- openspec/changes/counter-pairs/proposal.md | 2 +- .../specs/consolidated-osv-ui/spec.md | 2 +- web/src/utils/consolidations.test.ts | 21 ++++++++ web/src/utils/consolidations.ts | 11 ++++ web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 53 +++++++++++++------ .../ConsolidatedOsv/useConsolidatedOsv.ts | 21 ++++++-- .../ConsolidatedOsvContent.tsx | 43 ++++++++++----- 8 files changed, 123 insertions(+), 39 deletions(-) diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index f1593c6..2fd8efd 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -53,7 +53,7 @@ пар (`holder_id>missing_id`, отсортированные, через запятую). Чтение и запись — в `try/catch`. - `UnmappedInnModal` заменяется модалкой «Проверьте встречные записи участников»; склонение «пара/пары/пар», «участника/участников» — существующая `pluralRu` (`web/src/utils/labels.ts`). -- Над правой таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде): +- Над второй (нижней) таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде): у РБ на фикстуре около тысячи строк без ИНН, листать страницы в поисках одной строки — та же «прокликай всё». - Модалка не показывается для версий в завершённых статусах (`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED` — `FINISHED_STATUSES`): править там нечего. Режим «Встречные записи» доступен в любом статусе, «Указать» ведёт себя как @@ -84,10 +84,10 @@ | 27.10.2026 | «После исправлений» = список пар изменился (ключ списка пар в `sessionStorage`), а не любое «Связать» | Повтор модалки нужен, когда есть что сказать нового; «Связать» вне пар модалку не меняет | Низкая: правило в одной функции фронта | | 27.10.2026 | Строки А и Б берутся существующим `GET …/osv` с фильтрами (`counterparty_id` — новый), в сводке — только пары и счётчики | Без нового эндпоинта; пагинация и экспорт работают как в своде | Низкая | | 27.10.2026 | Учитываются все строки свода, без ограничения счетами правил элиминации и без порога по суммам | Владелец согласовал уровень компаний; строка с нулями тоже говорит, что контрагент виден | Средняя: возможны «лишние» пары из-за случайных счетов — правится фильтром в функции | -| 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 | Поиск `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 даёт всё равно неграмматичную фразу («учтены») | Низкая | @@ -95,5 +95,6 @@ | 27.10.2026 | R-4 (design #12): подпись под парой в списке — «записей: N · без ИНН у <Б>: M» вместо служебных «держатель»/«Б» | Аналитик не знает ролей «держатель» и «Б»; название организации понятнее | Низкая | | 27.10.2026 | R-5 (design #13): раскладка режима «Встречные записи» — пары одной строкой «таблеток» с переносом, под ними две таблицы на всю ширину одна под другой (вместо трёх колонок) | В трёх колонках таблицам не хватало ширины: суммы обрезались, видны были только «Обороты Дт» (находка H-1) | Низкая | -Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. | 27.10.2026 | R-6 (design #14): уход с «ОСВ» — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая | + +Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. diff --git a/openspec/changes/counter-pairs/proposal.md b/openspec/changes/counter-pairs/proposal.md index 0a44aa6..33d0822 100644 --- a/openspec/changes/counter-pairs/proposal.md +++ b/openspec/changes/counter-pairs/proposal.md @@ -38,7 +38,7 @@ AURORA-1746 и замечание заказчика (Циликов А.В., 23. 27.10.2026); DBML не меняется. - Отсев строк, не являющихся организациями (зарплата, подотчётники), из счётчика `unmapped_total`. - Сверка по счетам и суммам внутри пары — это делает шахматка (§6). -- Поиск участника, ошибочно сопоставленного с другим ЮЛ: справа показываются только несопоставленные строки без ИНН. +- Поиск участника, ошибочно сопоставленного с другим ЮЛ: во второй (нижней) таблице показываются только несопоставленные строки без ИНН. - Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют сопоставления, в правую таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину. diff --git a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md index 3499a05..6d69a71 100644 --- a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md +++ b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md @@ -42,7 +42,7 @@ «Указать» #### Scenario: Сопоставление из режима закрывает пару -- **WHEN** в правой таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная» +- **WHEN** во второй таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная» - **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель — «Встречные записи (0)» diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts index 5ff5850..f8094b8 100644 --- a/web/src/utils/consolidations.test.ts +++ b/web/src/utils/consolidations.test.ts @@ -19,6 +19,7 @@ import { parseInWorkOnly, rememberCounterPairsShown, reportTabNavigation, + resolveLeaveAfterFetch, shouldWarnCounterPairs, versionLabel, } from './consolidations'; @@ -245,3 +246,23 @@ describe('decideOsvLeave (T4.2)', () => { expect(decideOsvLeave({ ...base, storage: shown, summary: { counter_pairs: pairs } })).toBe('go'); }); }); + +describe('resolveLeaveAfterFetch (T7.3)', () => { + const pairs = [{ holder_id: 'a', missing_id: 'b' } as CounterPairOut]; + const args = { versionId: 'v', status: 'CONSOLIDATION_RF' as ConsStatus, storage: { getItem: () => null } }; + it('успех с парами — warn', () => { + expect(resolveLeaveAfterFetch({ ...args, summary: { counter_pairs: pairs } })).toBe('warn'); + }); + it('успех без пар — go', () => { + expect(resolveLeaveAfterFetch({ ...args, summary: { counter_pairs: [] } })).toBe('go'); + }); + it('ошибка запроса (null) — go', () => { + expect(resolveLeaveAfterFetch({ ...args, summary: null })).toBe('go'); + }); +}); + +describe('reportTabNavigation: адрес без search (T7.4)', () => { + it('?holder=&missing= не переносятся на другую вкладку', () => { + expect(reportTabNavigation('/groups/g/versions/v/osv', 'osv', 'chess').to).toBe('/groups/g/versions/v/chess'); + }); +}); diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts index 1832061..1d5fa02 100644 --- a/web/src/utils/consolidations.ts +++ b/web/src/utils/consolidations.ts @@ -170,3 +170,14 @@ export const decideOsvLeave = (args: { if (summary === null) return 'perform'; return shouldWarnCounterPairs(versionId, status, summary.counter_pairs, storage) ? 'warn' : 'go'; }; + +/** Решение после догрузки сводки при уходе с «ОСВ»: `summary === null` — запрос не удался, не держим пользователя (`go`). */ +export const resolveLeaveAfterFetch = (args: { + summary: Pick | null; + versionId: string; + status: ConsStatus | undefined; + storage: Pick | null; +}): 'warn' | 'go' => { + if (args.summary === null) return 'go'; + return decideOsvLeave(args) === 'warn' ? 'warn' : 'go'; +}; diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index f9e421f..b9265bd 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -1,11 +1,17 @@ import { WarningOutlined } from '@ant-design/icons'; import { Button, Modal, Segmented, Tooltip } from 'antd'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { getOsvSummary } from '../../services/api/osv'; import type { CounterPairOut, OsvSummaryOut } from '../../types/api'; import type { ConsStatus } from '../../types/consolidations/common_types'; -import { counterPairsWarning, decideOsvLeave, rememberCounterPairsShown, reportTabNavigation } from '../../utils/consolidations'; +import { + counterPairsWarning, + decideOsvLeave, + rememberCounterPairsShown, + reportTabNavigation, + resolveLeaveAfterFetch, +} from '../../utils/consolidations'; import { pairsViewSearch } from './counterPairs'; const TAB_CONFIG = [ @@ -73,10 +79,22 @@ export function OsvTabs({ summary, versionId, status }: Props) { navigate(to, options); }; - // сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал) - const [fetched, setFetched] = useState(null); + // сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал); привязана к версии + const [fetched, setFetched] = useState<{ versionId: string; summary: OsvSummaryOut } | null>(null); const [checking, setChecking] = useState(false); - const shownPairs = (summary ?? fetched)?.counter_pairs ?? []; + const fetchedSummary = fetched?.versionId === versionId ? fetched.summary : null; + const known = summary ?? fetchedSummary; + const shownPairs = known?.counter_pairs ?? []; + + // актуальные версия и признак «компонент жив» — ответ запроса, пришедший после смены версии/размонтирования, игнорируется + const liveRef = useRef({ versionId, mounted: true }); + liveRef.current.versionId = versionId; + useEffect(() => { + liveRef.current.mounted = true; + return () => { + liveRef.current.mounted = false; + }; + }, []); const handleTabChange = async (value: string | number) => { const newKey = String(value); @@ -86,23 +104,26 @@ export function OsvTabs({ summary, versionId, status }: Props) { return; } const storage = safeSessionStorage(); - let known = summary ?? fetched; - let decision = decideOsvLeave({ summary: known, versionId, status, storage }); + let found: OsvSummaryOut | null = known; + let decision: 'warn' | 'go' | 'perform' = decideOsvLeave({ summary: found, versionId, status, storage }); if (decision === 'perform') { setChecking(true); + const requestedFor = versionId; + let result: OsvSummaryOut | null = null; try { - known = await getOsvSummary(versionId); - setFetched(known); - decision = decideOsvLeave({ summary: known, versionId, status, storage }); + result = await getOsvSummary(requestedFor); } catch { // не удалось проверить — не держим пользователя на вкладке - decision = 'go'; - } finally { - setChecking(false); } + if (!liveRef.current.mounted) return; + setChecking(false); + if (liveRef.current.versionId !== requestedFor) return; + if (result) setFetched({ versionId: requestedFor, summary: result }); + found = result; + decision = resolveLeaveAfterFetch({ summary: result, versionId, status, storage }); } - if (decision === 'warn' && known) { - rememberCounterPairsShown(versionId, known.counter_pairs, storage); + if (decision === 'warn' && found) { + rememberCounterPairsShown(versionId, found.counter_pairs, storage); setPending(newKey); } else { go(newKey); @@ -142,7 +163,7 @@ export function OsvTabs({ summary, versionId, status }: Props) { { setPending(null); navigate( diff --git a/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts b/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts index b8739a6..30f3af0 100644 --- a/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts +++ b/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts @@ -30,6 +30,8 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) { const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // ошибка загрузки сводки (для режима пар: без неё там не на что смотреть) + const [summaryError, setSummaryError] = useState(null); // номер последнего запроса: ответы более старых запросов игнорируются const requestRef = useRef(0); @@ -40,9 +42,15 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) { const requestId = ++summaryRef.current; try { const s = await getOsvSummary(versionId); - if (requestId === summaryRef.current) setSummary(s); + if (requestId === summaryRef.current) { + setSummary(s); + setSummaryError(null); + } } catch (e: unknown) { - if (requestId === summaryRef.current) message.error(osvErrorText(e)); + if (requestId === summaryRef.current) { + message.error(osvErrorText(e)); + setSummaryError(osvErrorText(e)); + } } }, [versionId]); @@ -76,7 +84,12 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) { }, [versionId, filters, page]); useEffect(() => { - if (pairsMode) return; + if (pairsMode) { + // ответ ещё летящего запроса строк больше не нужен + requestRef.current++; + setLoading(false); + return; + } void load(); }, [load, pairsMode]); @@ -90,5 +103,5 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) { void loadSummary(); }; - return { filters, patchFilters, page, setPage, rows, total, summary, loading, error, refresh }; + return { filters, patchFilters, page, setPage, rows, total, summary, summaryError, loadSummary, loading, error, refresh }; } diff --git a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index 7661d81..ed10985 100644 --- a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -71,18 +71,35 @@ export function OsvContent({ card, onSummaryChange }: Props) { // вне вкладки «ОСВ» предупреждать не о чем useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]); - const pairsBody = s ? ( - setView({ view: 'pairs', holder, missing })} - onMapped={osv.refresh} - /> - ) : ( - !osv.error && - ); + const renderPairsBody = () => { + if (s) { + return ( + setView({ view: 'pairs', holder, missing })} + onMapped={osv.refresh} + /> + ); + } + if (osv.summaryError) { + return ( + void osv.loadSummary()}> + Повторить + + } + /> + ); + } + return ; + }; return ( <> @@ -161,7 +178,7 @@ export function OsvContent({ card, onSummaryChange }: Props) {
)} {inPairs ? ( - pairsBody + renderPairsBody() ) : ( <> From 2bb5683cde5d556a04d18c7185f89468170b753e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 21:57:03 +0400 Subject: [PATCH 20/26] =?UTF-8?q?docs(#195):=20=D1=82=D0=B0=D0=B1=D0=BB?= =?UTF-8?q?=D0=B8=D1=86=D0=B0=20=D1=80=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B9?= =?UTF-8?q?=20=D0=B1=D0=B5=D0=B7=20=D1=80=D0=B0=D0=B7=D1=80=D1=8B=D0=B2?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/design.md | 1 - 1 file changed, 1 deletion(-) diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index 2fd8efd..9763fd0 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -94,7 +94,6 @@ | 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): уход с «ОСВ» — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая | Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. From 7c523b93d10fe47a06408d336d8bb6bcadf6cb8d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 22:24:39 +0400 Subject: [PATCH 21/26] =?UTF-8?q?fix(web):=20=D1=81=D0=B2=D0=BE=D0=B4?= =?UTF-8?q?=D0=BA=D0=B0,=20=D0=B4=D0=BE=D0=B3=D1=80=D1=83=D0=B6=D0=B5?= =?UTF-8?q?=D0=BD=D0=BD=D0=B0=D1=8F=20=D0=BF=D1=80=D0=B8=20=D1=83=D1=85?= =?UTF-8?q?=D0=BE=D0=B4=D0=B5=20=D1=81=20=D0=9E=D0=A1=D0=92,=20=D0=BD?= =?UTF-8?q?=D0=B5=20=D0=BA=D0=B5=D1=88=D0=B8=D1=80=D1=83=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20(M-9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Сводка живёт только в состоянии ожидающего перехода; при summary=null запрашивается заново. Co-Authored-By: Claude Opus 5.5 --- web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index b9265bd..5387a01 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -70,7 +70,8 @@ export function OsvTabs({ summary, versionId, status }: Props) { const navigate = useNavigate(); const location = useLocation(); const { tab } = useParams<{ tab?: string }>(); - const [pending, setPending] = useState(null); + // переход, ожидающий решения в модалке; сводка — та, по которой модалка показана (только на время модалки) + const [pending, setPending] = useState<{ key: string; summary: OsvSummaryOut | null } | null>(null); const activeKey = tab || 'osv'; @@ -79,12 +80,7 @@ export function OsvTabs({ summary, versionId, status }: Props) { navigate(to, options); }; - // сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал); привязана к версии - const [fetched, setFetched] = useState<{ versionId: string; summary: OsvSummaryOut } | null>(null); const [checking, setChecking] = useState(false); - const fetchedSummary = fetched?.versionId === versionId ? fetched.summary : null; - const known = summary ?? fetchedSummary; - const shownPairs = known?.counter_pairs ?? []; // актуальные версия и признак «компонент жив» — ответ запроса, пришедший после смены версии/размонтирования, игнорируется const liveRef = useRef({ versionId, mounted: true }); @@ -104,7 +100,7 @@ export function OsvTabs({ summary, versionId, status }: Props) { return; } const storage = safeSessionStorage(); - let found: OsvSummaryOut | null = known; + let found: OsvSummaryOut | null = summary; let decision: 'warn' | 'go' | 'perform' = decideOsvLeave({ summary: found, versionId, status, storage }); if (decision === 'perform') { setChecking(true); @@ -118,13 +114,12 @@ export function OsvTabs({ summary, versionId, status }: Props) { if (!liveRef.current.mounted) return; setChecking(false); if (liveRef.current.versionId !== requestedFor) return; - if (result) setFetched({ versionId: requestedFor, summary: result }); found = result; decision = resolveLeaveAfterFetch({ summary: result, versionId, status, storage }); } if (decision === 'warn' && found) { rememberCounterPairsShown(versionId, found.counter_pairs, storage); - setPending(newKey); + setPending({ key: newKey, summary: found }); } else { go(newKey); } @@ -162,8 +157,8 @@ export function OsvTabs({ summary, versionId, status }: Props) { /> { setPending(null); navigate( @@ -173,7 +168,7 @@ export function OsvTabs({ summary, versionId, status }: Props) { }} onStay={() => setPending(null)} onGo={() => { - const next = pending; + const next = pending?.key; setPending(null); if (next) go(next); }} From e4c030d3e944a58775be91d986cfeef22a19867c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 22:24:39 +0400 Subject: [PATCH 22/26] =?UTF-8?q?fix(web):=20=D1=80=D0=B5=D0=B6=D0=B8?= =?UTF-8?q?=D0=BC=20=D0=BF=D0=B0=D1=80=20=E2=80=94=20=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D1=80=D1=83=D1=82=D0=BA=D0=B0=20=D0=B2=D0=BD=D1=83=D1=82?= =?UTF-8?q?=D1=80=D0=B8=20=D0=BA=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8?= =?UTF-8?q?,=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=B7=D0=B0=D0=BF=D1=80=D0=BE?= =?UTF-8?q?=D1=81=20=D0=BF=D1=80=D0=B8=20=D1=81=D0=BC=D0=B5=D0=BD=D0=B5=20?= =?UTF-8?q?=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D0=B0=20(I-6,=20M-8,=20M-1?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Тело режима прокручивается внутри карточки; ключ и страница — одно состояние (currentPage); запасная горизонтальная прокрутка таблиц на узком окне. Co-Authored-By: Claude Opus 5.5 --- .../ConsolidatedOsv/CounterPairsView.tsx | 17 +++++++------- .../ConsolidatedOsv/counterPairs.test.ts | 23 ++++++++++++++++++- .../widgets/ConsolidatedOsv/counterPairs.ts | 5 +++- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx index 44b0895..6831517 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx @@ -8,7 +8,7 @@ import { getConsolidatedOsv } from '../../services/api/osv'; import type { ConsolidatedRowOut, CounterPairOut, OsvFilters } from '../../types/api'; import { CounterpartyMapModal } from './CounterpartyMapModal'; import { group, money } from './OsvTable'; -import { clampPage, missingSideEmptyText, pairLabel } from './counterPairs'; +import { clampPage, currentPage, missingSideEmptyText, pairLabel } from './counterPairs'; import { mapEntryId } from './counterpartyMapping'; import { OSV_PAGE_SIZE, osvErrorText } from './useConsolidatedOsv'; @@ -33,15 +33,14 @@ const PANEL_TITLE: React.CSSProperties = { fontWeight: 700, fontSize: 13, color: function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey: number) { const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); - const [page, setPage] = useState(1); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const requestRef = useRef(0); const key = JSON.stringify(filters); - - // смена фильтра — снова первая страница - // biome-ignore lint/correctness/useExhaustiveDependencies: key — сериализованный filters - useEffect(() => setPage(1), [key]); + // ключ и страница — одно состояние: смена фильтра даёт первую страницу и один запрос, а не два + const [pageState, setPageState] = useState({ key, page: 1 }); + const page = currentPage(pageState, key); + const setPage = useCallback((p: number) => setPageState({ key, page: p }), [key]); // biome-ignore lint/correctness/useExhaustiveDependencies: key — сериализованный filters; reloadKey — перечитать после сопоставления useEffect(() => { @@ -99,7 +98,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele }, [selectedKey]); const centerFilters: OsvFilters | null = selected ? { legal_entity_id: selected.holder_id, counterparty_id: selected.missing_id } : null; - // таблицу справа не запрашиваем, если у Б нет строк или нет строк без ИНН: показываем текст + // нижнюю таблицу не запрашиваем, если у Б нет строк или нет строк без ИНН: показываем текст const rightHasRows = !!selected && missingSideEmptyText(selected) === null; const rightFilters: OsvFilters | null = selected && rightHasRows ? { legal_entity_id: selected.missing_id, unmapped: true, ...(debouncedQ ? { q: debouncedQ } : {}) } : null; @@ -146,7 +145,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele const rightEmpty = missingSideEmptyText(selected); return ( -
+
{pairs.map((p) => { const active = p.holder_id === selected.holder_id && p.missing_id === selected.missing_id; @@ -186,6 +185,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele columns={baseColumns} dataSource={center.rows} tableLayout='fixed' + scroll={{ x: 750 }} pagination={{ current: center.page, pageSize: OSV_PAGE_SIZE, @@ -219,6 +219,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele columns={rightColumns} dataSource={right.rows} tableLayout='fixed' + scroll={{ x: 850 }} locale={{ emptyText: debouncedQ ? 'По поиску строк нет' : 'Строк нет' }} pagination={{ current: right.page, diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts index ff3d26b..7b0e3d8 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts @@ -1,6 +1,15 @@ import { describe, expect, it } from 'vitest'; import type { CounterPairOut } from '../../types/api'; -import { clampPage, missingSideEmptyText, pairLabel, pairsViewSearch, readPairsView, selectPair, switchPairsView } from './counterPairs'; +import { + clampPage, + currentPage, + missingSideEmptyText, + pairLabel, + pairsViewSearch, + readPairsView, + selectPair, + switchPairsView, +} from './counterPairs'; const pair = (holder: string, missing: string): CounterPairOut => ({ holder_id: holder, @@ -86,3 +95,15 @@ describe('clampPage (T5.6)', () => { expect(clampPage(4, 0, 500)).toBe(1); }); }); + +describe('currentPage (M-8)', () => { + it('ключ тот же — сохранённая страница', () => { + expect(currentPage({ key: 'a', page: 3 }, 'a')).toBe(3); + }); + it('ключ сменился — первая страница без отдельного обновления состояния', () => { + expect(currentPage({ key: 'a', page: 3 }, 'b')).toBe(1); + }); + it('вернулись к прежнему ключу после смены страницы под новым — страница нового', () => { + expect(currentPage({ key: 'b', page: 2 }, 'b')).toBe(2); + }); +}); diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.ts index 1ff4e9d..1c9c90b 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.ts @@ -29,7 +29,7 @@ export const selectPair = (pairs: readonly CounterPairOut[], holder: string | nu export const pairLabel = (p: CounterPairOut): string => `${p.missing_name} ← ${p.holder_name}`; -/** Текст вместо правой таблицы (строки Б без ИНН); `null` — таблицу показываем. */ +/** Текст вместо нижней таблицы (строки Б без ИНН); `null` — таблицу показываем. */ export const missingSideEmptyText = (p: CounterPairOut): string | null => { if (p.missing_rows_total === 0) return `У ${p.missing_name} в своде нет строк: ОСВ не привязаны или по ним пробелы`; if (p.missing_unmapped_rows === 0) @@ -40,3 +40,6 @@ export const missingSideEmptyText = (p: CounterPairOut): string | null => { /** Страница после ответа: не дальше последней существующей (последнюю строку сопоставили), не меньше первой. */ export const clampPage = (page: number, total: number, pageSize: number): number => Math.min(page, Math.max(1, Math.ceil(total / pageSize))); + +/** Страница, привязанная к ключу фильтра: другой ключ — первая страница сразу, без отдельного обновления состояния (один запрос). */ +export const currentPage = (state: { key: string; page: number }, key: string): number => (state.key === key ? state.page : 1); From fb13389f75ae013a375f06309476f022c35cd45c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 22:24:39 +0400 Subject: [PATCH 23/26] =?UTF-8?q?docs(openspec):=20=D1=80=D0=B0=D1=81?= =?UTF-8?q?=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B0=20=D0=BF=D0=B0=D1=80=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA=D0=B8=20=D0=B2=20=D1=81=D0=BF?= =?UTF-8?q?=D0=B5=D0=BA=D0=B5=20counter-pairs=20(M-10,=20M-11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/design.md | 6 +++--- openspec/changes/counter-pairs/proposal.md | 2 +- .../specs/consolidated-osv-ui/spec.md | 16 +++++++++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index 9763fd0..cb701cd 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -46,7 +46,7 @@ - `useConsolidatedOsv` / `ConsolidatedOsvContent`: режим из `?view=pairs` (`useSearchParams`, `replace`). - Новый виджет `widgets/ConsolidatedOsv/CounterPairs*.tsx`: список пар + две таблицы (antd `Table`), колонки — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт; суммы — через существующий `useNumberDisplay` (единицы и знаки - из шапки). Правая таблица — постранично, `OSV_PAGE_SIZE`, «Указать» → `CounterpartyMapModal` (существующая). + из шапки). Вторая (нижняя) таблица — постранично, `OSV_PAGE_SIZE`, «Указать» → `CounterpartyMapModal` (существующая). - `ConsolidationReportPage` / `OsvTabs`: вместо `unmappedTotal` получают сводку (пары и `unmapped_total`); правило показа модалки — чистая функция в `utils/consolidations.ts` (`counterPairsKey(pairs)`, `shouldWarnCounterPairs(versionId, status, pairs, storage)`), с тестами vitest. Ключ `sessionStorage` — `armka:counter-pairs:`, значение — ключ списка @@ -68,7 +68,7 @@ ## Что оставлено исполнителю -Раскладка трёх колонок и ширины, тексты пустых состояний в пределах спеки, имена компонентов, вынос общих колонок сумм +Раскладка (пары сверху, две таблицы на всю ширину) и ширины, тексты пустых состояний в пределах спеки, имена компонентов, вынос общих колонок сумм между сводом и новым режимом. ## Риски @@ -94,6 +94,6 @@ | 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): уход с «ОСВ» — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая | +| 27.10.2026 | R-6 (design #14): уход с «ОСВ» (продолжение: пока сводки режима нет — Spin и подпись «Встречные записи» без «(N)»; ошибка запроса сводки — Alert с «Повторить»; сводка, догруженная при уходе, не кешируется и живёт только на время модалки) — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая | Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. diff --git a/openspec/changes/counter-pairs/proposal.md b/openspec/changes/counter-pairs/proposal.md index 33d0822..e276775 100644 --- a/openspec/changes/counter-pairs/proposal.md +++ b/openspec/changes/counter-pairs/proposal.md @@ -40,7 +40,7 @@ AURORA-1746 и замечание заказчика (Циликов А.В., 23. - Сверка по счетам и суммам внутри пары — это делает шахматка (§6). - Поиск участника, ошибочно сопоставленного с другим ЮЛ: во второй (нижней) таблице показываются только несопоставленные строки без ИНН. - Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют - сопоставления, в правую таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение + сопоставления, во вторую (нижнюю) таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину. ## Capabilities diff --git a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md index 6d69a71..69cfd5d 100644 --- a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md +++ b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md @@ -26,11 +26,17 @@ - **вторая таблица** — «У <Б> — строки без ИНН и без сопоставления»: строки `GET …/osv?legal_entity_id=Б&unmapped=true` — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт и «Указать» в каждой строке; над таблицей — поиск по контрагенту (`q`, ≤ 500 символов, как фильтр свода); постранично, как таблица свода. Все четыре колонки - сумм MUST быть видны без горизонтальной прокрутки при ширине окна 1440 px. Если + сумм MUST быть видны без горизонтальной прокрутки при ширине окна 1440 px (на узком окне допустима прокрутка таблицы). Если `missing_rows_total = 0` — вместо таблицы текст «У <Б> в своде нет строк: ОСВ не привязаны или по ним пробелы»; если строки у Б есть, а без ИНН и без сопоставления нет (`missing_unmapped_rows = 0`) — текст «Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте». +Обе таблицы и список пар MUST оставаться внутри белой карточки экрана: тело режима прокручивается по вертикали внутри +карточки, а не выходит за её границу. + +Пока сводки нет, вместо тела режима показывается индикатор загрузки, а подпись переключателя — «Встречные записи» без «(N)». +Если запрос сводки не удался, показывается ошибка с кнопкой «Повторить», повторяющей запрос. + «Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и ошибками; после «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»). @@ -46,6 +52,14 @@ - **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель — «Встречные записи (0)» +#### Scenario: Сводка ещё не получена +- **WHEN** открыт режим «Встречные записи», запрос сводки в пути +- **THEN** показан индикатор загрузки, подпись переключателя — «Встречные записи» без числа + +#### Scenario: Ошибка запроса сводки +- **WHEN** запрос сводки в режиме «Встречные записи» завершился ошибкой +- **THEN** показан Alert с текстом ошибки и кнопкой «Повторить»; нажатие повторяет запрос + #### Scenario: Режим из адреса - **WHEN** открыт адрес `…/versions/{id}/osv?view=pairs` - **THEN** показан режим «Встречные записи» с первой парой; кнопка «Назад» браузера не проходит по переключениям режимов From d230fb13ee0e462cbe707fb5a9c8cd021e7d9191 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 22:26:05 +0400 Subject: [PATCH 24/26] =?UTF-8?q?fix(web):=20=D1=81=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BA=D0=BB=D1=8E=D1=87=D0=B0=20=D1=84=D0=B8=D0=BB?= =?UTF-8?q?=D1=8C=D1=82=D1=80=D0=B0=20=D0=BF=D0=B0=D1=80=20=D1=81=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D1=81=D1=8B=D0=B2=D0=B0=D0=B5=D1=82=20=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=83=20=D0=B2=20=D1=81=D0=BE?= =?UTF-8?q?=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8=D0=B8=20(M-13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Возврат A→B→A даёт первую страницу, а не прежнюю. Co-Authored-By: Claude Opus 5.5 --- .../ConsolidatedOsv/CounterPairsView.tsx | 9 ++++++--- .../ConsolidatedOsv/counterPairs.test.ts | 18 ++++++++++-------- .../widgets/ConsolidatedOsv/counterPairs.ts | 5 +++-- 3 files changed, 19 insertions(+), 13 deletions(-) diff --git a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx index 6831517..f6d9743 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx @@ -8,7 +8,7 @@ import { getConsolidatedOsv } from '../../services/api/osv'; import type { ConsolidatedRowOut, CounterPairOut, OsvFilters } from '../../types/api'; import { CounterpartyMapModal } from './CounterpartyMapModal'; import { group, money } from './OsvTable'; -import { clampPage, currentPage, missingSideEmptyText, pairLabel } from './counterPairs'; +import { clampPage, missingSideEmptyText, pairLabel, syncPageState } from './counterPairs'; import { mapEntryId } from './counterpartyMapping'; import { OSV_PAGE_SIZE, osvErrorText } from './useConsolidatedOsv'; @@ -38,8 +38,11 @@ function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey: const requestRef = useRef(0); const key = JSON.stringify(filters); // ключ и страница — одно состояние: смена фильтра даёт первую страницу и один запрос, а не два - const [pageState, setPageState] = useState({ key, page: 1 }); - const page = currentPage(pageState, key); + const [rawPageState, setPageState] = useState({ key, page: 1 }); + const pageState = syncPageState(rawPageState, key); + // ключ сменился — состояние сразу пересоздаётся (возврат к прежнему ключу не вернёт старую страницу) + if (pageState !== rawPageState) setPageState(pageState); + const page = pageState.page; const setPage = useCallback((p: number) => setPageState({ key, page: p }), [key]); // biome-ignore lint/correctness/useExhaustiveDependencies: key — сериализованный filters; reloadKey — перечитать после сопоставления diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts index 7b0e3d8..375609e 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts @@ -2,13 +2,13 @@ import { describe, expect, it } from 'vitest'; import type { CounterPairOut } from '../../types/api'; import { clampPage, - currentPage, missingSideEmptyText, pairLabel, pairsViewSearch, readPairsView, selectPair, switchPairsView, + syncPageState, } from './counterPairs'; const pair = (holder: string, missing: string): CounterPairOut => ({ @@ -96,14 +96,16 @@ describe('clampPage (T5.6)', () => { }); }); -describe('currentPage (M-8)', () => { - it('ключ тот же — сохранённая страница', () => { - expect(currentPage({ key: 'a', page: 3 }, 'a')).toBe(3); +describe('syncPageState (M-8, M-13)', () => { + it('ключ тот же — состояние то же', () => { + const s = { key: 'a', page: 3 }; + expect(syncPageState(s, 'a')).toBe(s); }); - it('ключ сменился — первая страница без отдельного обновления состояния', () => { - expect(currentPage({ key: 'a', page: 3 }, 'b')).toBe(1); + it('ключ сменился — {новый ключ, 1}', () => { + expect(syncPageState({ key: 'a', page: 3 }, 'b')).toEqual({ key: 'b', page: 1 }); }); - it('вернулись к прежнему ключу после смены страницы под новым — страница нового', () => { - expect(currentPage({ key: 'b', page: 2 }, 'b')).toBe(2); + it('A (стр. 3) → B → A: снова первая страница, а не прежняя', () => { + const onB = syncPageState({ key: 'a', page: 3 }, 'b'); + expect(syncPageState(onB, 'a')).toEqual({ key: 'a', page: 1 }); }); }); diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.ts index 1c9c90b..d85852a 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.ts @@ -41,5 +41,6 @@ export const missingSideEmptyText = (p: CounterPairOut): string | null => { export const clampPage = (page: number, total: number, pageSize: number): number => Math.min(page, Math.max(1, Math.ceil(total / pageSize))); -/** Страница, привязанная к ключу фильтра: другой ключ — первая страница сразу, без отдельного обновления состояния (один запрос). */ -export const currentPage = (state: { key: string; page: number }, key: string): number => (state.key === key ? state.page : 1); +/** Состояние страницы под ключом фильтра: другой ключ — `{ключ, 1}`; прежнее состояние не хранится (A→B→A даёт первую страницу). */ +export const syncPageState = (state: { key: string; page: number }, key: string): { key: string; page: number } => + state.key === key ? state : { key, page: 1 }; From 93ee8b2352cb3d7bbf2d969ab9034c39481e671c Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 22:58:50 +0400 Subject: [PATCH 25/26] =?UTF-8?q?feat(#195):=20=D0=BC=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=D0=BB=D0=BA=D0=B0=20=D1=81=D0=BE=D0=BF=D0=BE=D1=81=D1=82=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B6=D0=B8=D0=BC=D0=B5=20=D0=BF=D0=B0=D1=80=20=D1=81=D1=80?= =?UTF-8?q?=D0=B0=D0=B7=D1=83=20=D1=81=D1=83=D0=B6=D0=B5=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B4=D0=BE=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=BF=D0=B0=D1=80=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решение владельца после показа на стенде: «Указать» во «Встречных записях» открывает список из одного участника пары; «Показать все организации» — обычный список. Чистая pairCandidateRows + vitest, спека и design.md. Co-Authored-By: Claude Opus 5.5 --- .../plans/2026-10-27-counter-pairs.md | 9 +++ openspec/changes/counter-pairs/design.md | 1 + .../specs/consolidated-osv-ui/spec.md | 11 +++- .../ConsolidatedOsv/CounterPairsView.tsx | 1 + .../ConsolidatedOsv/CounterpartyMapModal.tsx | 57 ++++++++++++------- .../counterpartyMapping.test.ts | 16 ++++++ .../ConsolidatedOsv/counterpartyMapping.ts | 11 ++++ 7 files changed, 85 insertions(+), 21 deletions(-) diff --git a/docs/superpowers/plans/2026-10-27-counter-pairs.md b/docs/superpowers/plans/2026-10-27-counter-pairs.md index c50247b..c778b10 100644 --- a/docs/superpowers/plans/2026-10-27-counter-pairs.md +++ b/docs/superpowers/plans/2026-10-27-counter-pairs.md @@ -222,3 +222,12 @@ specs/consolidations-ui). Issue — arm-core #195, Jira — AURORA-1746. Вет - [ ] 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 + спека, исполнял основной сеанс) + +- [x] `pairCandidateRows(candidates, target)` в `counterpartyMapping.ts` + vitest (участник пары под «Участник пары»; нет — пусто). +- [x] `CounterpartyMapModal` — проп `pairTarget?: PairTarget`: запрос `scope=group&q=<имя А>`, поиск и «Искать во всём справочнике» скрыты, флажок «Показать все организации»; `CounterPairsView` передаёт участника выбранной пары. +- [x] Спека `consolidated-osv-ui` (сценарий «Модалка сопоставления сужена до участника пары»), design.md (решение владельца 7). +- [x] tsc, biome, prettier, `npm test`, `npm run lint`; стенд — модалка с одной строкой «ПЕКАРНЯ СЧАСТЛИВОЧНАЯ, ООО». diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index cb701cd..c964efe 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -25,6 +25,7 @@ без ИНН и без сопоставления. 5. Подсветка кандидата по совпадению суммы — отдельная задача. 6. В БД ничего не хранить. Модалка — один раз за сессию или после исправлений в сводной ОСВ. +7. (после показа на стенде) «Указать» в режиме пар открывает модалку сопоставления, сразу суженную до участника пары, с флажком «Показать все организации» — вариант 1 из двух показанных; кнопку «Связать с <А>» прямо в строке не делаем (легко промахнуться). ## Устройство diff --git a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md index 69cfd5d..87f3c28 100644 --- a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md +++ b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md @@ -38,7 +38,11 @@ Если запрос сводки не удался, показывается ошибка с кнопкой «Повторить», повторяющей запрос. «Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и -ошибками; после «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается +ошибками, но список кандидатов сразу сужен до участника пары А: одна строка под заголовком «Участник пары» с «Связать» +(кандидаты запрашиваются с `scope=group` и `q` = имя А), поле поиска и «Искать во всём справочнике» скрыты; флажок +«Показать все организации» возвращает обычный список модалки (решение владельца 27.10.2026). Если А среди кандидатов не +нашёлся — текст «Участник пары не найден среди кандидатов — включите «Показать все организации»». В обычном своде +«Указать» не меняется. После «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»). #### Scenario: Пара Русского бисквита @@ -52,6 +56,11 @@ - **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель — «Встречные записи (0)» +#### Scenario: Модалка сопоставления сужена до участника пары +- **WHEN** в паре «Русский бисквит ← Пекарня Счастливочная» во второй таблице нажато «Указать» +- **THEN** в модалке одна строка — ООО «Пекарня Счастливочная» под заголовком «Участник пары» с «Связать»; после + «Показать все организации» — поиск и обычный список кандидатов + #### Scenario: Сводка ещё не получена - **WHEN** открыт режим «Встречные записи», запрос сводки в пути - **THEN** показан индикатор загрузки, подпись переключателя — «Встречные записи» без числа diff --git a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx index f6d9743..1f5c721 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx @@ -244,6 +244,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele row={mapping} onClose={() => setMapping(null)} onMapped={mapped} + pairTarget={selected ? { id: selected.holder_id, name: selected.holder_name } : undefined} /> )}
diff --git a/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx b/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx index 5c7b22e..351f64d 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx @@ -9,6 +9,7 @@ import type { CandidateOut, CandidateScope, ConsolidatedRowOut, RefOut } from '. import { ALL_SCOPE_MIN_QUERY, type CandidateRow, + type PairTarget, aliasOwners, buildCandidatesQuery, candidateRows, @@ -16,6 +17,7 @@ import { mapEntryId, mapErrorKind, mapResultText, + pairCandidateRows, } from './counterpartyMapping'; const SEARCH_DELAY_MS = 300; @@ -55,12 +57,16 @@ type Props = { onClose: () => void; /** Сопоставление записано: перечитать строки и сводку. */ onMapped: () => void; + /** Режим «Встречные записи»: список сразу сужен до участника пары, «Показать все организации» — обычный список. */ + pairTarget?: PairTarget; }; /** Модалка «Выбор организации для связи с …» [32-osv-map-counterparty]: кандидаты, поиск и «Связать». */ -export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Props) { +export function CounterpartyMapModal({ versionId, row, onClose, onMapped, pairTarget }: Props) { const [q, setQ] = useState(''); const [scope, setScope] = useState('group'); + const [showAll, setShowAll] = useState(false); + const pairOnly = !!pairTarget && !showAll; const [candidates, setCandidates] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); @@ -70,7 +76,8 @@ export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Prop const requestRef = useRef(0); const entryId = mapEntryId(row); const sourceName = row.counterparty_name?.trim() || '—'; - const query = buildCandidatesQuery(entryId, scope, q); + // участник пары ищется по имени в группе: в первые 100 кандидатов без поиска он может не попасть + const query = pairOnly && pairTarget ? buildCandidatesQuery(entryId, 'group', pairTarget.name) : buildCandidatesQuery(entryId, scope, q); useEffect(() => { const requestId = ++requestRef.current; @@ -211,10 +218,11 @@ export function CounterpartyMapModal({ versionId, row, onClose, onMapped }: Prop }, ]; - const tooShort = scope === 'all' && query === null; - const emptyText = tooShort - ? `Для поиска по всему справочнику введите не меньше ${ALL_SCOPE_MIN_QUERY} символов` - : 'Организации не найдены'; + const tooShort = !pairOnly && scope === 'all' && query === null; + let emptyText = 'Организации не найдены'; + if (pairOnly) emptyText = 'Участник пары не найден среди кандидатов — включите «Показать все организации»'; + else if (tooShort) emptyText = `Для поиска по всему справочнику введите не меньше ${ALL_SCOPE_MIN_QUERY} символов`; + const rows = pairOnly && pairTarget ? pairCandidateRows(candidates, pairTarget) : candidateRows(candidates); return ( {contextHolder}
- } - placeholder='Поиск' - maxLength={500} - value={q} - onChange={(e) => setQ(e.target.value)} - /> - setScope(e.target.checked ? 'all' : 'group')}> - Искать во всём справочнике (не только в группе) - + {!pairOnly && ( + <> + } + placeholder='Поиск' + maxLength={500} + value={q} + onChange={(e) => setQ(e.target.value)} + /> + setScope(e.target.checked ? 'all' : 'group')}> + Искать во всём справочнике (не только в группе) + + + )} + {pairTarget && ( + setShowAll(e.target.checked)}> + Показать все организации + + )} {error &&
{error}
} size='small' rowKey='key' loading={loading} columns={columns} - dataSource={candidateRows(candidates)} + dataSource={rows} pagination={false} scroll={{ y: 420 }} locale={{ emptyText }} diff --git a/web/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts b/web/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts index cf9f8dc..54b9583 100644 --- a/web/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts +++ b/web/src/widgets/ConsolidatedOsv/counterpartyMapping.test.ts @@ -12,6 +12,7 @@ import { mapErrorKind, mapResultText, needsMapping, + pairCandidateRows, } from './counterpartyMapping'; const row = (patch: Partial = {}): ConsolidatedRowOut => ({ @@ -88,6 +89,21 @@ describe('needsMapping / mapEntryId', () => { }); }); +describe('pairCandidateRows', () => { + const cand = (id: string, recommended = false) => ({ id, recommended }) as unknown as CandidateOut; + + it('оставляет только участника пары под заголовком «Участник пары»', () => { + const rows = pairCandidateRows([cand('a', true), cand('b'), cand('c')], { id: 'b', name: 'Б' }); + expect(rows.map((r) => r.key)).toEqual(['section:pair', 'b']); + expect(rows[0]).toMatchObject({ kind: 'section', label: 'Участник пары' }); + expect(rows[1]).toMatchObject({ kind: 'candidate', recommended: true }); + }); + + it('участника нет среди кандидатов — пустой список', () => { + expect(pairCandidateRows([cand('a')], { id: 'b', name: 'Б' })).toEqual([]); + }); +}); + describe('buildCandidatesQuery', () => { it('scope=group: q необязателен, entry_id всегда', () => { expect(buildCandidatesQuery('e1', 'group', ' ')).toBe('?scope=group&entry_id=e1'); diff --git a/web/src/widgets/ConsolidatedOsv/counterpartyMapping.ts b/web/src/widgets/ConsolidatedOsv/counterpartyMapping.ts index 469eefa..3ff25a7 100644 --- a/web/src/widgets/ConsolidatedOsv/counterpartyMapping.ts +++ b/web/src/widgets/ConsolidatedOsv/counterpartyMapping.ts @@ -47,6 +47,17 @@ export function candidateRows(candidates: CandidateOut[]): CandidateRow[] { return [...section, ...recommended, ...rest]; } +/** Участник пары «Встречных записей», с которым, скорее всего, надо связать строку (AURORA-1746). */ +export type PairTarget = { id: string; name: string }; + +/** Режим пар: список кандидатов сужен до участника пары, под заголовком «Участник пары»; не нашёлся — пусто. */ +export function pairCandidateRows(candidates: CandidateOut[], target: PairTarget): CandidateRow[] { + const found: CandidateRow[] = candidates + .filter((c) => c.id === target.id) + .map((c) => ({ kind: 'candidate', key: c.id, candidate: c, recommended: true })); + return found.length > 0 ? [{ kind: 'section', key: 'section:pair', label: 'Участник пары' }, ...found] : []; +} + export type MapErrorKind = 'ALIAS_TAKEN' | 'LOCK_REQUIRED' | 'LOCKED_BY_OTHER' | 'VERSION_NOT_EDITABLE' | 'OTHER'; const KNOWN_CONFLICTS: readonly MapErrorKind[] = ['ALIAS_TAKEN', 'LOCK_REQUIRED', 'LOCKED_BY_OTHER', 'VERSION_NOT_EDITABLE']; From f647e687fefd6abf78fb9ec9ad6fbe013fcf0b0e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 23:01:00 +0400 Subject: [PATCH 26/26] =?UTF-8?q?fix(#195):=20=D1=84=D0=BB=D0=B0=D0=B6?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BC=D0=BE=D0=B4=D0=B0=D0=BB=D0=BA=D0=B8=20?= =?UTF-8?q?=D1=81=D0=BE=D0=BF=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=E2=80=94=20=D0=BE=D0=B4=D0=BD=D0=BE=D0=B9?= =?UTF-8?q?=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=BE=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ConsolidatedOsv/CounterpartyMapModal.tsx | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx b/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx index 351f64d..ae8879f 100644 --- a/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx +++ b/web/src/widgets/ConsolidatedOsv/CounterpartyMapModal.tsx @@ -237,27 +237,30 @@ export function CounterpartyMapModal({ versionId, row, onClose, onMapped, pairTa {contextHolder}
{!pairOnly && ( - <> - } - placeholder='Поиск' - maxLength={500} - value={q} - onChange={(e) => setQ(e.target.value)} - /> + } + placeholder='Поиск' + maxLength={500} + value={q} + onChange={(e) => setQ(e.target.value)} + /> + )} + {/* флажки — одной строкой */} +
+ {pairTarget && ( + setShowAll(e.target.checked)}> + Показать все организации + + )} + {!pairOnly && ( setScope(e.target.checked ? 'all' : 'group')}> Искать во всём справочнике (не только в группе) - - )} - {pairTarget && ( - setShowAll(e.target.checked)}> - Показать все организации - - )} + )} +
{error &&
{error}
} size='small'