From e9977fb2211cb58f2cce33a24f7c66fc9535a2b3 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 18:33:58 +0400 Subject: [PATCH] =?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** вкладка «ОСВ» остаётся, открыт режим «Встречные записи» с первой парой