From e2e416282f87f2a2a1744ad77eb44d31d9fd19ec Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 21:55:47 +0400 Subject: [PATCH] =?UTF-8?q?feat(#195):=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB?= =?UTF-8?q?=D0=B0=D0=B4=D0=BA=D0=B0=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=D0=B0?= =?UTF-8?q?=20=C2=AB=D0=92=D1=81=D1=82=D1=80=D0=B5=D1=87=D0=BD=D1=8B=D0=B5?= =?UTF-8?q?=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=C2=BB=20=E2=80=94=20?= =?UTF-8?q?=D0=BF=D0=B0=D1=80=D1=8B=20=D1=81=D0=B2=D0=B5=D1=80=D1=85=D1=83?= =?UTF-8?q?,=20=D0=B4=D0=B2=D0=B5=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D1=8B=20=D0=BD=D0=B0=20=D0=B2=D1=81=D1=8E=20=D1=88=D0=B8=D1=80?= =?UTF-8?q?=D0=B8=D0=BD=D1=83;=20=D1=87=D0=B8=D1=81=D1=82=D0=B0=D1=8F=20?= =?UTF-8?q?=D0=BB=D0=BE=D0=B3=D0=B8=D0=BA=D0=B0=20=D0=BF=D1=83=D1=81=D1=82?= =?UTF-8?q?=D1=8B=D1=85=20=D1=81=D0=BE=D1=81=D1=82=D0=BE=D1=8F=D0=BD=D0=B8?= =?UTF-8?q?=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)));