Compare commits

..

No commits in common. "d230fb13ee0e462cbe707fb5a9c8cd021e7d9191" and "397a1a7aef0eee7e4c3effe56030f4dacc23d646" have entirely different histories.

12 changed files with 99 additions and 384 deletions

View File

@ -189,36 +189,3 @@ specs/consolidations-ui). Issue — arm-core #195, Jira — AURORA-1746. Вет
- [ ] **Step 2:** правка `CLAUDE.md` arm-core, коммит. - [ ] **Step 2:** правка `CLAUDE.md` arm-core, коммит.
- [ ] **Step 3:** из корня arm-core worktree `make check-full` — вывод сохранить для описания PR; `openspec validate counter-pairs --strict`. - [ ] **Step 3:** из корня arm-core worktree `make check-full` — вывод сохранить для описания PR; `openspec validate counter-pairs --strict`.
- [ ] **Step 4:** коммит, если были правки по итогам проверки. - [ ] **Step 4:** коммит, если были правки по итогам проверки.
---
## Волна 2 (решение владельца 27.10.2026: всё отложенное закрыть в этой ветке)
Правила те же: sonnet — исполнители и ревью задач, opus — финальное ревью; маркеры T/H/O/R/C-I-M; без push в arm-core. Новые npm-пакеты не добавляем (web/CLAUDE.md, contour-transfer-guide §3.2): компонентные тесты заменяются выносом логики в чистые функции + vitest.
### Task 7: логика перехода и режима пар (web, модель: sonnet)
**Files:** `web/src/widgets/ConsolidatedOsv/OsvTabs.tsx`, `web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx`, `web/src/widgets/ConsolidatedOsv/counterPairs.ts` (+ тест), `web/src/utils/consolidations.ts` (+ тест), хук `useConsolidatedOsv`, `web/src/layouts/ConsolidationReportPage.tsx` при необходимости.
- [ ] T4.1: один вызов `safeSessionStorage()` в `OsvTabs`.
- [ ] T4.2: при уходе с «ОСВ», если `summary === null` — запросить `getOsvSummary(versionId)` и решить по ответу (чистая функция решения: `perform`/`warn`/`go`); при ошибке запроса — переход без модалки; переход без проверки при `null` недопустим.
- [ ] T5.4: `useConsolidatedOsv` не запрашивает строки, пока активен режим пар (сводку — запрашивает); убрать двойной fetch при смене фильтра/пары (`setPage(1)` не должен порождать запрос со старой страницей).
- [ ] T5.5: `holder`/`missing` в адресе не стираются в режиме «Свод»; возврат на «Встречные записи» восстанавливает пару; `readPairsView`/`pairsViewSearch` и тесты поправить.
- [ ] M-4: в режиме пар при `summary = null` — `Spin`; переключатель «Встречные записи» без «(N)» пока сводки нет.
- [ ] T4.3 (часть): логику решений `OsvTabs` вынести в чистые функции и покрыть vitest.
- [ ] `make web-check`, `npm test`, `npm run lint`; коммит `feat(#195): ...`.
### Task 8: раскладка режима «Встречные записи» и чистая логика CounterPairsView (web + спека, модель: sonnet)
**Files:** `web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx` (+ вынесенные чистые функции и тест), `openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md`, `openspec/changes/counter-pairs/design.md`.
- [ ] H-1: вместо трёх колонок — список пар вверху одной строкой «таблеток» с переносом (подпись `записей: N · без ИНН у <missing_name>: M` сохранить), под ним две таблицы на всю ширину одна под другой: «У А есть записи с Б», затем «У Б — строки без ИНН и без сопоставления» с поиском. Все четыре колонки сумм видны без горизонтальной прокрутки при ширине окна 1440.
- [ ] Спека consolidated-osv-ui («слева/в центре/справа») — под новую раскладку; решение — в design.md как `R-5 (design #13)`.
- [ ] T5.6: вынести в чистые функции и покрыть vitest: какой текст пустого состояния показать (пусто / `missing_rows_total = 0` / `missing_unmapped_rows = 0`), выбор пары после сопоставления, ограничение страницы.
- [ ] `make web-check`, `npm test`, `npm run lint`; коммит `feat(#195): ...`.
### Task 9: проверка на стенде и окружение (контроллер/исполнитель, модель: sonnet)
- [ ] O-5: стенд с кодом ветки; проверить три пустых состояния и раскладку на 1440; сверить с макетом [30]. Перед правкой данных — SELECT исходных значений в файл ledger; после — восстановление и сверка SELECT-ом (включая `counterparty_match_kind` у 25 строк РБ, смена с ALIAS на MANUAL/PROPAGATED от O-3 — вернуть прежние значения, если есть бэкап, иначе описать в отчёте).
- [ ] O-1: проверить `make up` из worktree при существующем `.wt/counter-pairs/docs`.
- [ ] M-7: правка Purpose в `openspec/specs/consolidations-ui/spec.md` — пункт чеклиста закрытия (`openspec archive`), не follow-up.

View File

@ -46,14 +46,14 @@
- `useConsolidatedOsv` / `ConsolidatedOsvContent`: режим из `?view=pairs` (`useSearchParams`, `replace`). - `useConsolidatedOsv` / `ConsolidatedOsvContent`: режим из `?view=pairs` (`useSearchParams`, `replace`).
- Новый виджет `widgets/ConsolidatedOsv/CounterPairs*.tsx`: список пар + две таблицы (antd `Table`), колонки — счёт, - Новый виджет `widgets/ConsolidatedOsv/CounterPairs*.tsx`: список пар + две таблицы (antd `Table`), колонки — счёт,
контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт; суммы — через существующий `useNumberDisplay` (единицы и знаки контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт; суммы — через существующий `useNumberDisplay` (единицы и знаки
из шапки). Вторая (нижняя) таблица — постранично, `OSV_PAGE_SIZE`, «Указать» → `CounterpartyMapModal` (существующая). из шапки). Правая таблица — постранично, `OSV_PAGE_SIZE`, «Указать» → `CounterpartyMapModal` (существующая).
- `ConsolidationReportPage` / `OsvTabs`: вместо `unmappedTotal` получают сводку (пары и `unmapped_total`); правило показа - `ConsolidationReportPage` / `OsvTabs`: вместо `unmappedTotal` получают сводку (пары и `unmapped_total`); правило показа
модалки — чистая функция в `utils/consolidations.ts` (`counterPairsKey(pairs)`, `shouldWarnCounterPairs(versionId, модалки — чистая функция в `utils/consolidations.ts` (`counterPairsKey(pairs)`, `shouldWarnCounterPairs(versionId,
status, pairs, storage)`), с тестами vitest. Ключ `sessionStorage` — `armka:counter-pairs:<version_id>`, значение — ключ списка status, pairs, storage)`), с тестами vitest. Ключ `sessionStorage` — `armka:counter-pairs:<version_id>`, значение — ключ списка
пар (`holder_id>missing_id`, отсортированные, через запятую). Чтение и запись — в `try/catch`. пар (`holder_id>missing_id`, отсортированные, через запятую). Чтение и запись — в `try/catch`.
- `UnmappedInnModal` заменяется модалкой «Проверьте встречные записи участников»; склонение «пара/пары/пар», - `UnmappedInnModal` заменяется модалкой «Проверьте встречные записи участников»; склонение «пара/пары/пар»,
«участника/участников» — существующая `pluralRu` (`web/src/utils/labels.ts`). «участника/участников» — существующая `pluralRu` (`web/src/utils/labels.ts`).
- Над второй (нижней) таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде): - Над правой таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде):
у РБ на фикстуре около тысячи строк без ИНН, листать страницы в поисках одной строки — та же «прокликай всё». у РБ на фикстуре около тысячи строк без ИНН, листать страницы в поисках одной строки — та же «прокликай всё».
- Модалка не показывается для версий в завершённых статусах (`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED` — - Модалка не показывается для версий в завершённых статусах (`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED` —
`FINISHED_STATUSES`): править там нечего. Режим «Встречные записи» доступен в любом статусе, «Указать» ведёт себя как `FINISHED_STATUSES`): править там нечего. Режим «Встречные записи» доступен в любом статусе, «Указать» ведёт себя как
@ -68,7 +68,7 @@
## Что оставлено исполнителю ## Что оставлено исполнителю
Раскладка (пары сверху, две таблицы на всю ширину) и ширины, тексты пустых состояний в пределах спеки, имена компонентов, вынос общих колонок сумм Раскладка трёх колонок и ширины, тексты пустых состояний в пределах спеки, имена компонентов, вынос общих колонок сумм
между сводом и новым режимом. между сводом и новым режимом.
## Риски ## Риски
@ -84,16 +84,14 @@
| 27.10.2026 | «После исправлений» = список пар изменился (ключ списка пар в `sessionStorage`), а не любое «Связать» | Повтор модалки нужен, когда есть что сказать нового; «Связать» вне пар модалку не меняет | Низкая: правило в одной функции фронта | | 27.10.2026 | «После исправлений» = список пар изменился (ключ списка пар в `sessionStorage`), а не любое «Связать» | Повтор модалки нужен, когда есть что сказать нового; «Связать» вне пар модалку не меняет | Низкая: правило в одной функции фронта |
| 27.10.2026 | Строки А и Б берутся существующим `GET …/osv` с фильтрами (`counterparty_id` — новый), в сводке — только пары и счётчики | Без нового эндпоинта; пагинация и экспорт работают как в своде | Низкая | | 27.10.2026 | Строки А и Б берутся существующим `GET …/osv` с фильтрами (`counterparty_id` — новый), в сводке — только пары и счётчики | Без нового эндпоинта; пагинация и экспорт работают как в своде | Низкая |
| 27.10.2026 | Учитываются все строки свода, без ограничения счетами правил элиминации и без порога по суммам | Владелец согласовал уровень компаний; строка с нулями тоже говорит, что контрагент виден | Средняя: возможны «лишние» пары из-за случайных счетов — правится фильтром в функции | | 27.10.2026 | Учитываются все строки свода, без ограничения счетами правил элиминации и без порога по суммам | Владелец согласовал уровень компаний; строка с нулями тоже говорит, что контрагент виден | Средняя: возможны «лишние» пары из-за случайных счетов — правится фильтром в функции |
| 27.10.2026 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Колонки должны уместиться на всю ширину при 1440; для поиска пары важнее обороты и итог | Низкая: добавить колонки | | 27.10.2026 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Три колонки на экране; для поиска пары важнее обороты и итог | Низкая: добавить колонки |
| 27.10.2026 | Режим — параметр адреса `?view=pairs` (с `holder`/`missing`), переключение без записи в историю | Как вкладки отчёта (AURORA-1851); ссылку на пару можно переслать | Низкая | | 27.10.2026 | Режим — параметр адреса `?view=pairs` (с `holder`/`missing`), переключение без записи в историю | Как вкладки отчёта (AURORA-1851); ссылку на пару можно переслать | Низкая |
| 27.10.2026 | Тексты баннера и модалки — из спек этого change | Владелец согласовал смысл, формулировки мои | Низкая | | 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 | Модалка не показывается для версий в `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-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-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-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-4 (design #12): подпись под парой в списке — «записей: N · без ИНН у <Б>: M» вместо служебных «держатель»/«Б» | Аналитик не знает ролей «держатель» и «Б»; название организации понятнее | Низкая |
| 27.10.2026 | R-5 (design #13): раскладка режима «Встречные записи» — пары одной строкой «таблеток» с переносом, под ними две таблицы на всю ширину одна под другой (вместо трёх колонок) | В трёх колонках таблицам не хватало ширины: суммы обрезались, видны были только «Обороты Дт» (находка H-1) | Низкая |
| 27.10.2026 | R-6 (design #14): уход с «ОСВ» (продолжение: пока сводки режима нет — Spin и подпись «Встречные записи» без «(N)»; ошибка запроса сводки — Alert с «Повторить»; сводка, догруженная при уходе, не кешируется и живёт только на время модалки) — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая |
Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867.

View File

@ -22,8 +22,8 @@ AURORA-1746 и замечание заказчика (Циликов А.В., 23.
- **Ядро.** Чистая функция «пары без встречных записей» в `domain/consolidated_osv.py`: для участников периметра А ≠ Б - **Ядро.** Чистая функция «пары без встречных записей» в `domain/consolidated_osv.py`: для участников периметра А ≠ Б
пара (А, Б) — у А есть строка свода с `counterparty_id = Б`, у Б нет строки с `counterparty_id = А`. `GET …/osv/summary` пара (А, Б) — у А есть строка свода с `counterparty_id = Б`, у Б нет строки с `counterparty_id = А`. `GET …/osv/summary`
отдаёт список `counter_pairs[]`; `GET …/osv` и экспорт получают фильтр `counterparty_id`. Ничего не хранится. отдаёт список `counter_pairs[]`; `GET …/osv` и экспорт получают фильтр `counterparty_id`. Ничего не хранится.
- **Фронт, режим «Встречные записи».** Над сводом — переключатель «Свод | Встречные записи (N)». Во втором режиме: сверху - **Фронт, режим «Встречные записи».** Над сводом — переключатель «Свод | Встречные записи (N)». Во втором режиме: слева
список пар, под ним две таблицы на всю ширину: строки А с контрагентом Б и строки Б без ИНН и без сопоставления с «Указать» (та же список пар, в центре — строки А с контрагентом Б, справа — строки Б без ИНН и без сопоставления с «Указать» (та же
модалка сопоставления, что в своде). модалка сопоставления, что в своде).
- **Фронт, модалка перед шахматкой.** Вместо [31-00] «Остались неуказанные ИНН» — «Нет встречных записей: N пар у M - **Фронт, модалка перед шахматкой.** Вместо [31-00] «Остались неуказанные ИНН» — «Нет встречных записей: N пар у M
участников», кнопки «Показать пары» / «Остаться» / «Перейти». Показывается при уходе с «ОСВ», если пары есть, один раз участников», кнопки «Показать пары» / «Остаться» / «Перейти». Показывается при уходе с «ОСВ», если пары есть, один раз
@ -38,9 +38,9 @@ AURORA-1746 и замечание заказчика (Циликов А.В., 23.
27.10.2026); DBML не меняется. 27.10.2026); DBML не меняется.
- Отсев строк, не являющихся организациями (зарплата, подотчётники), из счётчика `unmapped_total`. - Отсев строк, не являющихся организациями (зарплата, подотчётники), из счётчика `unmapped_total`.
- Сверка по счетам и суммам внутри пары — это делает шахматка (§6). - Сверка по счетам и суммам внутри пары — это делает шахматка (§6).
- Поиск участника, ошибочно сопоставленного с другим ЮЛ: во второй (нижней) таблице показываются только несопоставленные строки без ИНН. - Поиск участника, ошибочно сопоставленного с другим ЮЛ: справа показываются только несопоставленные строки без ИНН.
- Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют - Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют
сопоставления, во вторую (нижнюю) таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение сопоставления, в правую таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение
владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину. владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину.
## Capabilities ## Capabilities

View File

@ -17,49 +17,34 @@
`summary.counter_pairs`. Режим — параметр адреса `?view=pairs` (без параметра — «Свод»); переключение режима MUST NOT `summary.counter_pairs`. Режим — параметр адреса `?view=pairs` (без параметра — «Свод»); переключение режима MUST NOT
добавлять запись в историю браузера. Шапка свода (единицы, знаки после запятой) действует в обоих режимах. добавлять запись в историю браузера. Шапка свода (единицы, знаки после запятой) действует в обоих режимах.
В режиме «Встречные записи» экран SHALL показывать сверху список пар, под ним на всю ширину две таблицы одна под другой: В режиме «Встречные записи» экран SHALL показывать три колонки:
- **сверху** — список пар одной строкой «таблеток» с переносом, в порядке ответа, строка пары «Б ← А» (имена `missing_name` и `holder_name`) с числами - **слева** — список пар в порядке ответа, строка пары «Б ← А» (имена `missing_name` и `holder_name`) с числами
`holder_rows` и `missing_unmapped_rows`; выбранная пара подсвечена; при входе выбрана первая пара либо пара из адреса `holder_rows` и `missing_unmapped_rows`; выбранная пара подсвечена; при входе выбрана первая пара либо пара из адреса
(`?view=pairs&holder=<id>&missing=<id>`); (`?view=pairs&holder=<id>&missing=<id>`);
- **первая таблица** — «У <А> есть записи с <Б>»: строки `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_rows_total = 0` — вместо таблицы текст «У <Б> в своде нет строк: ОСВ не привязаны или по ним пробелы»; если
строки у Б есть, а без ИНН и без сопоставления нет (`missing_unmapped_rows = 0`) — текст «Строк без ИНН нет: возможно, строки у Б есть, а без ИНН и без сопоставления нет (`missing_unmapped_rows = 0`) — текст «Строк без ИНН нет: возможно,
ИНН контрагента из файла не найден в справочнике или это пропуск в учёте». ИНН контрагента из файла не найден в справочнике или это пропуск в учёте».
Обе таблицы и список пар MUST оставаться внутри белой карточки экрана: тело режима прокручивается по вертикали внутри
карточки, а не выходит за её границу.
Пока сводки нет, вместо тела режима показывается индикатор загрузки, а подпись переключателя — «Встречные записи» без «(N)».
Если запрос сводки не удался, показывается ошибка с кнопкой «Повторить», повторяющей запрос.
«Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и «Указать» SHALL открывать модалку сопоставления из требования «Ручное сопоставление контрагента» с теми же ответами и
ошибками; после «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается ошибками; после «Связать» экран SHALL перечитывать сводку и обе таблицы. Если выбранная пара исчезла из списка, выбирается
первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»). первая оставшаяся; пустой список — текст «Все участники периметра видят друг друга» (переключатель — «Встречные записи (0)»).
#### Scenario: Пара Русского бисквита #### Scenario: Пара Русского бисквита
- **WHEN** открыт свод версии РБ 2024 Q3 до сопоставления и выбран режим «Встречные записи» - **WHEN** открыт свод версии РБ 2024 Q3 до сопоставления и выбран режим «Встречные записи»
- **THEN** переключатель «Встречные записи (1)»; сверху пара «Русский бисквит ← Пекарня Счастливочная»; в первой таблице строки - **THEN** переключатель «Встречные записи (1)»; слева пара «Русский бисквит ← Пекарня Счастливочная»; в центре строки
Счастливочной с контрагентом «Русский бисквит»; во второй таблице строки РБ без ИНН, среди них «Пекарня Счастливочная ООО» с Счастливочной с контрагентом «Русский бисквит»; справа строки РБ без ИНН, среди них «Пекарня Счастливочная ООО» с
«Указать» «Указать»
#### Scenario: Сопоставление из режима закрывает пару #### Scenario: Сопоставление из режима закрывает пару
- **WHEN** во второй таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная» - **WHEN** в правой таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная»
- **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель — - **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель —
«Встречные записи (0)» «Встречные записи (0)»
#### Scenario: Сводка ещё не получена
- **WHEN** открыт режим «Встречные записи», запрос сводки в пути
- **THEN** показан индикатор загрузки, подпись переключателя — «Встречные записи» без числа
#### Scenario: Ошибка запроса сводки
- **WHEN** запрос сводки в режиме «Встречные записи» завершился ошибкой
- **THEN** показан Alert с текстом ошибки и кнопкой «Повторить»; нажатие повторяет запрос
#### Scenario: Режим из адреса #### Scenario: Режим из адреса
- **WHEN** открыт адрес `…/versions/{id}/osv?view=pairs` - **WHEN** открыт адрес `…/versions/{id}/osv?view=pairs`
- **THEN** показан режим «Встречные записи» с первой парой; кнопка «Назад» браузера не проходит по переключениям режимов - **THEN** показан режим «Встречные записи» с первой парой; кнопка «Назад» браузера не проходит по переключениям режимов

View File

@ -11,7 +11,6 @@ import {
consStatusDescription, consStatusDescription,
counterPairsKey, counterPairsKey,
counterPairsWarning, counterPairsWarning,
decideOsvLeave,
endSentence, endSentence,
isLockedBy, isLockedBy,
isValidReason, isValidReason,
@ -19,7 +18,6 @@ import {
parseInWorkOnly, parseInWorkOnly,
rememberCounterPairsShown, rememberCounterPairsShown,
reportTabNavigation, reportTabNavigation,
resolveLeaveAfterFetch,
shouldWarnCounterPairs, shouldWarnCounterPairs,
versionLabel, versionLabel,
} from './consolidations'; } from './consolidations';
@ -224,45 +222,3 @@ 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');
});
});
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');
});
});

View File

@ -1,6 +1,6 @@
// Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные // Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные
// переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API. // переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API.
import type { CounterPairOut, OsvSummaryOut } from '../types/api'; import type { CounterPairOut } from '../types/api';
import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types'; import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types';
import { pluralRu } from './labels'; import { pluralRu } from './labels';
@ -154,30 +154,3 @@ export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTo
details: unmappedTotal > 0 ? `${base} Ещё ${unmappedTotal} строк без ИНН учтены как внешние.` : base, details: unmappedTotal > 0 ? `${base} Ещё ${unmappedTotal} строк без ИНН учтены как внешние.` : base,
}; };
}; };
/** Решение при уходе с вкладки «ОСВ»: `perform` — сводки ещё нет, запросить; `warn` — показать модалку; `go` — перейти. */
export type OsvLeaveDecision = 'perform' | 'warn' | 'go';
export const decideOsvLeave = (args: {
summary: Pick<OsvSummaryOut, 'counter_pairs'> | null;
versionId: string;
status: ConsStatus | undefined;
storage: Pick<Storage, 'getItem'> | 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';
};
/** Решение после догрузки сводки при уходе с «ОСВ»: `summary === null` — запрос не удался, не держим пользователя (`go`). */
export const resolveLeaveAfterFetch = (args: {
summary: Pick<OsvSummaryOut, 'counter_pairs'> | null;
versionId: string;
status: ConsStatus | undefined;
storage: Pick<Storage, 'getItem'> | null;
}): 'warn' | 'go' => {
if (args.summary === null) return 'go';
return decideOsvLeave(args) === 'warn' ? 'warn' : 'go';
};

View File

@ -1,5 +1,4 @@
// Режим «Встречные записи» вкладки «ОСВ»: сверху пары «таблетками», под ними на всю ширину две таблицы — // Режим «Встречные записи» вкладки «ОСВ»: слева пары, в центре строки держателя с Б, справа строки Б без ИНН и сопоставления.
// строки держателя с Б и строки Б без ИНН и сопоставления.
// Правило: 05-domain-rules.md §3 п. 9. // Правило: 05-domain-rules.md §3 п. 9.
import { Alert, Button, Input, Table } from 'antd'; import { Alert, Button, Input, Table } from 'antd';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
@ -8,7 +7,7 @@ import { getConsolidatedOsv } from '../../services/api/osv';
import type { ConsolidatedRowOut, CounterPairOut, OsvFilters } from '../../types/api'; import type { ConsolidatedRowOut, CounterPairOut, OsvFilters } from '../../types/api';
import { CounterpartyMapModal } from './CounterpartyMapModal'; import { CounterpartyMapModal } from './CounterpartyMapModal';
import { group, money } from './OsvTable'; import { group, money } from './OsvTable';
import { clampPage, missingSideEmptyText, pairLabel, syncPageState } from './counterPairs'; import { pairLabel } from './counterPairs';
import { mapEntryId } from './counterpartyMapping'; import { mapEntryId } from './counterpartyMapping';
import { OSV_PAGE_SIZE, osvErrorText } from './useConsolidatedOsv'; import { OSV_PAGE_SIZE, osvErrorText } from './useConsolidatedOsv';
@ -33,17 +32,15 @@ const PANEL_TITLE: React.CSSProperties = { fontWeight: 700, fontSize: 13, color:
function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey: number) { function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey: number) {
const [rows, setRows] = useState<ConsolidatedRowOut[]>([]); const [rows, setRows] = useState<ConsolidatedRowOut[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const requestRef = useRef(0); const requestRef = useRef(0);
const key = JSON.stringify(filters); const key = JSON.stringify(filters);
// ключ и страница — одно состояние: смена фильтра даёт первую страницу и один запрос, а не два
const [rawPageState, setPageState] = useState({ key, page: 1 }); // смена фильтра — снова первая страница
const pageState = syncPageState(rawPageState, key); // biome-ignore lint/correctness/useExhaustiveDependencies: key — сериализованный filters
// ключ сменился — состояние сразу пересоздаётся (возврат к прежнему ключу не вернёт старую страницу) useEffect(() => setPage(1), [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 — перечитать после сопоставления // biome-ignore lint/correctness/useExhaustiveDependencies: key — сериализованный filters; reloadKey — перечитать после сопоставления
useEffect(() => { useEffect(() => {
@ -60,7 +57,7 @@ function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey:
if (requestId !== requestRef.current) return; if (requestId !== requestRef.current) return;
// страница ушла за конец (последнюю строку сопоставили) — сразу на последнюю существующую // страница ушла за конец (последнюю строку сопоставили) — сразу на последнюю существующую
if (r.items.length === 0 && page > 1) { if (r.items.length === 0 && page > 1) {
setPage(clampPage(page, r.total, OSV_PAGE_SIZE)); setPage(Math.max(1, Math.ceil(r.total / OSV_PAGE_SIZE)));
return; return;
} }
setRows(r.items); setRows(r.items);
@ -101,8 +98,8 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
}, [selectedKey]); }, [selectedKey]);
const centerFilters: OsvFilters | null = selected ? { legal_entity_id: selected.holder_id, counterparty_id: selected.missing_id } : null; const centerFilters: OsvFilters | null = selected ? { legal_entity_id: selected.holder_id, counterparty_id: selected.missing_id } : null;
// нижнюю таблицу не запрашиваем, если у Б нет строк или нет строк без ИНН: показываем текст // таблицу справа не запрашиваем, если у Б нет строк или нет строк без ИНН: показываем текст
const rightHasRows = !!selected && missingSideEmptyText(selected) === null; const rightHasRows = !!selected && selected.missing_rows_total > 0 && selected.missing_unmapped_rows > 0;
const rightFilters: OsvFilters | null = const rightFilters: OsvFilters | null =
selected && rightHasRows ? { legal_entity_id: selected.missing_id, unmapped: true, ...(debouncedQ ? { q: debouncedQ } : {}) } : null; selected && rightHasRows ? { legal_entity_id: selected.missing_id, unmapped: true, ...(debouncedQ ? { q: debouncedQ } : {}) } : null;
@ -110,11 +107,11 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
const right = usePagedRows(versionId, rightFilters, reloadKey); const right = usePagedRows(versionId, rightFilters, reloadKey);
const baseColumns: TableColumnsType<ConsolidatedRowOut> = [ const baseColumns: TableColumnsType<ConsolidatedRowOut> = [
{ key: 'account_code', title: 'Счёт', dataIndex: 'account_code', width: 90 }, { key: 'account_code', title: 'Счёт', dataIndex: 'account_code', width: 100 },
{ {
key: 'counterparty', key: 'counterparty',
title: 'Контрагент', title: 'Контрагент',
ellipsis: true, width: 240,
render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—', render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—',
}, },
group('Обороты за период', [money('turnover_debit', 'Дебет', numFormat), money('turnover_credit', 'Кредит', numFormat)]), group('Обороты за период', [money('turnover_debit', 'Дебет', numFormat), money('turnover_credit', 'Кредит', numFormat)]),
@ -124,6 +121,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
...baseColumns, ...baseColumns,
{ {
key: 'map', key: 'map',
fixed: 'right',
title: 'ИНН', title: 'ИНН',
align: 'center', align: 'center',
width: 100, width: 100,
@ -145,11 +143,16 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
return <div style={{ padding: 32, textAlign: 'center', color: MUTED, fontSize: 14 }}>Все участники периметра видят друг друга</div>; return <div style={{ padding: 32, textAlign: 'center', color: MUTED, fontSize: 14 }}>Все участники периметра видят друг друга</div>;
} }
const rightEmpty = missingSideEmptyText(selected); let rightEmpty: string | null = null;
if (selected.missing_rows_total === 0) {
rightEmpty = `У ${selected.missing_name} в своде нет строк: ОСВ не привязаны или по ним пробелы`;
} else if (selected.missing_unmapped_rows === 0) {
rightEmpty = 'Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте';
}
return ( return (
<div style={{ display: 'flex', flexDirection: 'column', gap: 16, flex: '1 1 auto', minWidth: 0, minHeight: 0, overflowY: 'auto' }}> <div style={{ display: 'flex', gap: 12, flex: '1 1 auto', minHeight: 0, alignItems: 'flex-start' }}>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}> <div style={{ ...PANEL, flex: '0 0 280px', maxHeight: 'calc(100vh - 320px)', overflowY: 'auto' }}>
{pairs.map((p) => { {pairs.map((p) => {
const active = p.holder_id === selected.holder_id && p.missing_id === selected.missing_id; const active = p.holder_id === selected.holder_id && p.missing_id === selected.missing_id;
return ( return (
@ -159,7 +162,6 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
onClick={() => onSelect(p.holder_id, p.missing_id)} onClick={() => onSelect(p.holder_id, p.missing_id)}
style={{ style={{
textAlign: 'left', textAlign: 'left',
maxWidth: 360,
cursor: 'pointer', cursor: 'pointer',
padding: '8px 10px', padding: '8px 10px',
borderRadius: 10, borderRadius: 10,
@ -175,7 +177,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
); );
})} })}
</div> </div>
<div style={PANEL}> <div style={{ ...PANEL, flex: '1 1 0' }}>
<div style={PANEL_TITLE}> <div style={PANEL_TITLE}>
У {selected.holder_name} есть записи с {selected.missing_name} У {selected.holder_name} есть записи с {selected.missing_name}
</div> </div>
@ -187,8 +189,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
loading={center.loading} loading={center.loading}
columns={baseColumns} columns={baseColumns}
dataSource={center.rows} dataSource={center.rows}
tableLayout='fixed' scroll={{ x: 'max-content' }}
scroll={{ x: 750 }}
pagination={{ pagination={{
current: center.page, current: center.page,
pageSize: OSV_PAGE_SIZE, pageSize: OSV_PAGE_SIZE,
@ -200,7 +201,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
}} }}
/> />
</div> </div>
<div style={PANEL}> <div style={{ ...PANEL, flex: '1 1 0' }}>
<div style={PANEL_TITLE}>У {selected.missing_name} — строки без ИНН и без сопоставления</div> <div style={PANEL_TITLE}>У {selected.missing_name} — строки без ИНН и без сопоставления</div>
{rightEmpty ? ( {rightEmpty ? (
<div style={{ padding: 16, color: MUTED, fontSize: 13, background: '#f9fafb', borderRadius: 10 }}>{rightEmpty}</div> <div style={{ padding: 16, color: MUTED, fontSize: 13, background: '#f9fafb', borderRadius: 10 }}>{rightEmpty}</div>
@ -221,8 +222,7 @@ export function CounterPairsView({ versionId, pairs, selected, numFormat, onSele
loading={right.loading} loading={right.loading}
columns={rightColumns} columns={rightColumns}
dataSource={right.rows} dataSource={right.rows}
tableLayout='fixed' scroll={{ x: 'max-content' }}
scroll={{ x: 850 }}
locale={{ emptyText: debouncedQ ? 'По поиску строк нет' : 'Строк нет' }} locale={{ emptyText: debouncedQ ? 'По поиску строк нет' : 'Строк нет' }}
pagination={{ pagination={{
current: right.page, current: right.page,

View File

@ -1,17 +1,10 @@
import { WarningOutlined } from '@ant-design/icons'; import { WarningOutlined } from '@ant-design/icons';
import { Button, Modal, Segmented, Tooltip } from 'antd'; import { Button, Modal, Segmented, Tooltip } from 'antd';
import { useEffect, useRef, useState } from 'react'; import { useState } from 'react';
import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { getOsvSummary } from '../../services/api/osv';
import type { CounterPairOut, OsvSummaryOut } from '../../types/api'; import type { CounterPairOut, OsvSummaryOut } from '../../types/api';
import type { ConsStatus } from '../../types/consolidations/common_types'; import type { ConsStatus } from '../../types/consolidations/common_types';
import { import { counterPairsWarning, rememberCounterPairsShown, reportTabNavigation, shouldWarnCounterPairs } from '../../utils/consolidations';
counterPairsWarning,
decideOsvLeave,
rememberCounterPairsShown,
reportTabNavigation,
resolveLeaveAfterFetch,
} from '../../utils/consolidations';
import { pairsViewSearch } from './counterPairs'; import { pairsViewSearch } from './counterPairs';
const TAB_CONFIG = [ const TAB_CONFIG = [
@ -70,8 +63,7 @@ export function OsvTabs({ summary, versionId, status }: Props) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { tab } = useParams<{ tab?: string }>(); const { tab } = useParams<{ tab?: string }>();
// переход, ожидающий решения в модалке; сводка — та, по которой модалка показана (только на время модалки) const [pending, setPending] = useState<string | null>(null);
const [pending, setPending] = useState<{ key: string; summary: OsvSummaryOut | null } | null>(null);
const activeKey = tab || 'osv'; const activeKey = tab || 'osv';
@ -80,46 +72,13 @@ export function OsvTabs({ summary, versionId, status }: Props) {
navigate(to, options); navigate(to, options);
}; };
const [checking, setChecking] = useState(false); const handleTabChange = (value: string | number) => {
// актуальные версия и признак «компонент жив» — ответ запроса, пришедший после смены версии/размонтирования, игнорируется
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); const newKey = String(value);
if (newKey === activeKey || checking) return; if (newKey === activeKey) return;
if (activeKey !== 'osv') { const pairs = summary?.counter_pairs ?? [];
go(newKey); if (activeKey === 'osv' && shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())) {
return; rememberCounterPairsShown(versionId, pairs, safeSessionStorage());
} setPending(newKey);
const storage = safeSessionStorage();
let found: OsvSummaryOut | null = summary;
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 {
result = await getOsvSummary(requestedFor);
} catch {
// не удалось проверить — не держим пользователя на вкладке
}
if (!liveRef.current.mounted) return;
setChecking(false);
if (liveRef.current.versionId !== requestedFor) return;
found = result;
decision = resolveLeaveAfterFetch({ summary: result, versionId, status, storage });
}
if (decision === 'warn' && found) {
rememberCounterPairsShown(versionId, found.counter_pairs, storage);
setPending({ key: newKey, summary: found });
} else { } else {
go(newKey); go(newKey);
} }
@ -151,14 +110,13 @@ export function OsvTabs({ summary, versionId, status }: Props) {
<Segmented <Segmented
value={activeKey} value={activeKey}
onChange={handleTabChange} onChange={handleTabChange}
disabled={checking}
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }} style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
options={options} options={options}
/> />
<CounterPairsModal <CounterPairsModal
open={pending !== null} open={pending !== null}
pairs={pending?.summary?.counter_pairs ?? []} pairs={summary?.counter_pairs ?? []}
unmappedTotal={pending?.summary?.unmapped_total ?? 0} unmappedTotal={summary?.unmapped_total ?? 0}
onShow={() => { onShow={() => {
setPending(null); setPending(null);
navigate( navigate(
@ -168,7 +126,7 @@ export function OsvTabs({ summary, versionId, status }: Props) {
}} }}
onStay={() => setPending(null)} onStay={() => setPending(null)}
onGo={() => { onGo={() => {
const next = pending?.key; const next = pending;
setPending(null); setPending(null);
if (next) go(next); if (next) go(next);
}} }}

View File

@ -1,15 +1,6 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import type { CounterPairOut } from '../../types/api'; import type { CounterPairOut } from '../../types/api';
import { import { pairLabel, pairsViewSearch, readPairsView, selectPair } from './counterPairs';
clampPage,
missingSideEmptyText,
pairLabel,
pairsViewSearch,
readPairsView,
selectPair,
switchPairsView,
syncPageState,
} from './counterPairs';
const pair = (holder: string, missing: string): CounterPairOut => ({ const pair = (holder: string, missing: string): CounterPairOut => ({
holder_id: holder, holder_id: holder,
@ -24,16 +15,7 @@ const pair = (holder: string, missing: string): CounterPairOut => ({
describe('readPairsView / pairsViewSearch', () => { describe('readPairsView / pairsViewSearch', () => {
it('table по умолчанию', () => { it('table по умолчанию', () => {
expect(readPairsView(new URLSearchParams(''))).toEqual({ view: 'table', holder: null, missing: null }); expect(readPairsView(new URLSearchParams(''))).toEqual({ view: 'table', holder: null, missing: null });
expect(pairsViewSearch({ view: 'table', holder: null, missing: null })).toBe(''); expect(pairsViewSearch({ view: 'table', holder: 'a', missing: 'b' })).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('туда-обратно', () => { it('туда-обратно', () => {
const v = { view: 'pairs' as const, holder: 'a', missing: 'b' }; const v = { view: 'pairs' as const, holder: 'a', missing: 'b' };
@ -63,49 +45,3 @@ describe('pairLabel', () => {
expect(pairLabel(pair('a', 'b'))).toBe('Mb ← Ha'); 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);
});
});
describe('syncPageState (M-8, M-13)', () => {
it('ключ тот же — состояние то же', () => {
const s = { key: 'a', page: 3 };
expect(syncPageState(s, 'a')).toBe(s);
});
it('ключ сменился — {новый ключ, 1}', () => {
expect(syncPageState({ key: 'a', page: 3 }, 'b')).toEqual({ key: 'b', page: 1 });
});
it('A (стр. 3) → B → A: снова первая страница, а не прежняя', () => {
const onB = syncPageState({ key: 'a', page: 3 }, 'b');
expect(syncPageState(onB, 'a')).toEqual({ key: 'a', page: 1 });
});
});

View File

@ -4,23 +4,18 @@ import type { CounterPairOut } from '../../types/api';
export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null }; export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null };
/** `?view=pairs[&holder=..&missing=..]`; всё остальное — обычная таблица свода. */ /** `?view=pairs[&holder=..&missing=..]`; всё остальное — обычная таблица свода. */
export const readPairsView = (search: URLSearchParams): PairsView => ({ export const readPairsView = (search: URLSearchParams): PairsView =>
view: search.get('view') === 'pairs' ? 'pairs' : 'table', search.get('view') === 'pairs'
holder: search.get('holder'), ? { view: 'pairs', holder: search.get('holder'), missing: search.get('missing') }
missing: search.get('missing'), : { view: 'table', holder: null, missing: null };
});
/** Переключение «Свод» / «Встречные записи»: выбранная пара не теряется. */ /** Строка запроса для режима; у таблицы пусто. */
export const switchPairsView = (v: PairsView, view: PairsView['view']): PairsView => ({ ...v, view });
/** Строка запроса для режима; пара (`holder`/`missing`) хранится и в режиме «Свод» — для возврата. */
export const pairsViewSearch = (v: PairsView): string => { export const pairsViewSearch = (v: PairsView): string => {
const params = new URLSearchParams(); if (v.view !== 'pairs') return '';
if (v.view === 'pairs') params.set('view', 'pairs'); const params = new URLSearchParams({ view: 'pairs' });
if (v.holder) params.set('holder', v.holder); if (v.holder) params.set('holder', v.holder);
if (v.missing) params.set('missing', v.missing); if (v.missing) params.set('missing', v.missing);
const text = params.toString(); return `?${params.toString()}`;
return text ? `?${text}` : '';
}; };
/** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */ /** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */
@ -28,19 +23,3 @@ export const selectPair = (pairs: readonly CounterPairOut[], holder: string | nu
pairs.find((p) => p.holder_id === holder && p.missing_id === missing) ?? pairs[0] ?? 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}`; 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)));
/** Состояние страницы под ключом фильтра: другой ключ — `{ключ, 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 };

View File

@ -2,8 +2,8 @@ import { message } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
import { getConsolidatedOsv, getOsvSummary } from '../../services/api/osv'; import { getConsolidatedOsv, getOsvSummary } from '../../services/api/osv';
import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api'; import type { ConsolidatedRowOut, OsvFilters, OsvSummaryOut } from '../../types/api';
import { type OsvFilterDraft, type OsvFilterState, applyFilterPatch } from './osvFilterState'; import { type OsvFilterDraft, applyFilterPatch } from './osvFilterState';
export const OSV_PAGE_SIZE = 500; export const OSV_PAGE_SIZE = 500;
@ -17,21 +17,14 @@ export function osvErrorText(e: unknown): string {
return 'Не удалось получить сводную ОСВ'; return 'Не удалось получить сводную ОСВ';
} }
/** export function useConsolidatedOsv(versionId: string) {
* @param pairsMode режим «Встречные записи»: строки свода не нужны и не запрашиваются (сводка — запрашивается). const [filters, setFilters] = useState<OsvFilters>({});
* Фильтры и страница — одно состояние: смена фильтра с возвратом на 1-ю страницу даёт один запрос, а не два. const [page, setPage] = useState(1);
*/
export function useConsolidatedOsv(versionId: string, pairsMode = false) {
const [query, setQuery] = useState<OsvFilterState>({ filters: {}, page: 1 });
const { filters, page } = query;
const setPage = useCallback((p: number) => setQuery((q) => ({ ...q, page: p })), []);
const [rows, setRows] = useState<ConsolidatedRowOut[]>([]); const [rows, setRows] = useState<ConsolidatedRowOut[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<OsvSummaryOut | null>(null); const [summary, setSummary] = useState<OsvSummaryOut | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// ошибка загрузки сводки (для режима пар: без неё там не на что смотреть)
const [summaryError, setSummaryError] = useState<string | null>(null);
// номер последнего запроса: ответы более старых запросов игнорируются // номер последнего запроса: ответы более старых запросов игнорируются
const requestRef = useRef(0); const requestRef = useRef(0);
@ -42,15 +35,9 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) {
const requestId = ++summaryRef.current; const requestId = ++summaryRef.current;
try { try {
const s = await getOsvSummary(versionId); const s = await getOsvSummary(versionId);
if (requestId === summaryRef.current) { if (requestId === summaryRef.current) setSummary(s);
setSummary(s);
setSummaryError(null);
}
} catch (e: unknown) { } catch (e: unknown) {
if (requestId === summaryRef.current) { if (requestId === summaryRef.current) message.error(osvErrorText(e));
message.error(osvErrorText(e));
setSummaryError(osvErrorText(e));
}
} }
}, [versionId]); }, [versionId]);
@ -84,24 +71,20 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) {
}, [versionId, filters, page]); }, [versionId, filters, page]);
useEffect(() => { useEffect(() => {
if (pairsMode) {
// ответ ещё летящего запроса строк больше не нужен
requestRef.current++;
setLoading(false);
return;
}
void load(); void load();
}, [load, pairsMode]); }, [load]);
const patchFilters = (patch: OsvFilterDraft) => { const patchFilters = (patch: OsvFilterDraft) => {
setQuery((q) => applyFilterPatch(q, patch)); const next = applyFilterPatch({ filters, page }, patch);
setFilters(next.filters);
setPage(next.page);
}; };
/** После ручного сопоставления: «Осталось N» и строки меняются сразу у нескольких строк (автоприменение) — перечитать обе. */ /** После ручного сопоставления: «Осталось N» и строки меняются сразу у нескольких строк (автоприменение) — перечитать обе. */
const refresh = () => { const refresh = () => {
if (!pairsMode) void load(); void load();
void loadSummary(); void loadSummary();
}; };
return { filters, patchFilters, page, setPage, rows, total, summary, summaryError, loadSummary, loading, error, refresh }; return { filters, patchFilters, page, setPage, rows, total, summary, loading, error, refresh };
} }

View File

@ -1,5 +1,5 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, Segmented, Select, Spin, message } from 'antd'; import { Alert, Button, Segmented, Select, message } from 'antd';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useParams, useSearchParams } from 'react-router-dom'; import { useParams, useSearchParams } from 'react-router-dom';
import { ApiError } from '../../api/client'; import { ApiError } from '../../api/client';
@ -11,7 +11,7 @@ import { CounterPairsView } from '../ConsolidatedOsv/CounterPairsView';
import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal'; import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal';
import { OsvGaps } from '../ConsolidatedOsv/OsvGaps'; import { OsvGaps } from '../ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../ConsolidatedOsv/OsvTable'; import { OsvTable } from '../ConsolidatedOsv/OsvTable';
import { pairsViewSearch, readPairsView, selectPair, switchPairsView } from '../ConsolidatedOsv/counterPairs'; import { pairsViewSearch, readPairsView, selectPair } from '../ConsolidatedOsv/counterPairs';
import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping'; import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv'; import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv';
import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay'; import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
@ -28,11 +28,7 @@ export function OsvContent({ card, onSummaryChange }: Props) {
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ) // строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null); const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const [searchParams, setSearchParams] = useSearchParams(); const osv = useConsolidatedOsv(version_id ?? '');
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 { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
const download = async () => { const download = async () => {
@ -50,8 +46,12 @@ export function OsvContent({ card, onSummaryChange }: Props) {
} }
}; };
const s = osv.summary;
const [searchParams, setSearchParams] = useSearchParams();
const pairsView = readPairsView(searchParams);
const pairs = s?.counter_pairs ?? []; const pairs = s?.counter_pairs ?? [];
const selectedPair = selectPair(pairs, pairsView.holder, pairsView.missing); const selectedPair = selectPair(pairs, pairsView.holder, pairsView.missing);
const inPairs = pairsView.view === 'pairs';
const setView = (v: Parameters<typeof pairsViewSearch>[0]) => setSearchParams(pairsViewSearch(v), { replace: true }); const setView = (v: Parameters<typeof pairsViewSearch>[0]) => setSearchParams(pairsViewSearch(v), { replace: true });
const unmapped = s?.unmapped_total ?? 0; const unmapped = s?.unmapped_total ?? 0;
const onlyUnmapped = osv.filters.unmapped === true; const onlyUnmapped = osv.filters.unmapped === true;
@ -71,36 +71,6 @@ export function OsvContent({ card, onSummaryChange }: Props) {
// вне вкладки «ОСВ» предупреждать не о чем // вне вкладки «ОСВ» предупреждать не о чем
useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]); useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]);
const renderPairsBody = () => {
if (s) {
return (
<CounterPairsView
versionId={version_id ?? ''}
pairs={pairs}
selected={selectedPair}
numFormat={format}
onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })}
onMapped={osv.refresh}
/>
);
}
if (osv.summaryError) {
return (
<Alert
type='error'
showIcon
title={osv.summaryError}
action={
<Button size='small' onClick={() => void osv.loadSummary()}>
Повторить
</Button>
}
/>
);
}
return <Spin style={{ alignSelf: 'center', margin: 24 }} />;
};
return ( return (
<> <>
<main <main
@ -140,11 +110,12 @@ export function OsvContent({ card, onSummaryChange }: Props) {
</div> </div>
<Segmented <Segmented
value={pairsView.view} value={pairsView.view}
onChange={(v) => setView(switchPairsView(pairsView, v === 'pairs' ? 'pairs' : 'table'))} onChange={(v) =>
setView(v === 'pairs' ? { view: 'pairs', holder: null, missing: null } : { view: 'table', holder: null, missing: null })
}
options={[ options={[
{ label: 'Свод', value: 'table' }, { label: 'Свод', value: 'table' },
// пока сводки нет — без числа { label: `Встречные записи (${pairs.length})`, value: 'pairs' },
{ label: s ? `Встречные записи (${pairs.length})` : 'Встречные записи', value: 'pairs' },
]} ]}
style={{ alignSelf: 'flex-start' }} style={{ alignSelf: 'flex-start' }}
/> />
@ -178,7 +149,16 @@ export function OsvContent({ card, onSummaryChange }: Props) {
</div> </div>
)} )}
{inPairs ? ( {inPairs ? (
renderPairsBody() s && (
<CounterPairsView
versionId={version_id ?? ''}
pairs={pairs}
selected={selectedPair}
numFormat={format}
onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })}
onMapped={osv.refresh}
/>
)
) : ( ) : (
<> <>
<OsvGaps gaps={s?.gaps ?? []} /> <OsvGaps gaps={s?.gaps ?? []} />