From 95171f4f50136db35c08ee1e783a22653c85d348 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 21:56:59 +0400 Subject: [PATCH] =?UTF-8?q?docs(#195):=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D1=80=D0=B5=D0=B2=D1=8C=D1=8E=20Task=208=20=E2=80=94?= =?UTF-8?q?=20=D1=84=D0=BE=D1=80=D0=BC=D1=83=D0=BB=D0=B8=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=BA=D0=B8=20=D1=80=D0=B0=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA?= =?UTF-8?q?=D0=B8,=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B0=20=D1=80?= =?UTF-8?q?=D0=B5=D1=88=D0=B5=D0=BD=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- openspec/changes/counter-pairs/design.md | 9 ++-- openspec/changes/counter-pairs/proposal.md | 2 +- .../specs/consolidated-osv-ui/spec.md | 2 +- web/src/utils/consolidations.test.ts | 21 ++++++++ web/src/utils/consolidations.ts | 11 ++++ web/src/widgets/ConsolidatedOsv/OsvTabs.tsx | 53 +++++++++++++------ .../ConsolidatedOsv/useConsolidatedOsv.ts | 21 ++++++-- .../ConsolidatedOsvContent.tsx | 43 ++++++++++----- 8 files changed, 123 insertions(+), 39 deletions(-) diff --git a/openspec/changes/counter-pairs/design.md b/openspec/changes/counter-pairs/design.md index f1593c6..2fd8efd 100644 --- a/openspec/changes/counter-pairs/design.md +++ b/openspec/changes/counter-pairs/design.md @@ -53,7 +53,7 @@ пар (`holder_id>missing_id`, отсортированные, через запятую). Чтение и запись — в `try/catch`. - `UnmappedInnModal` заменяется модалкой «Проверьте встречные записи участников»; склонение «пара/пары/пар», «участника/участников» — существующая `pluralRu` (`web/src/utils/labels.ts`). -- Над правой таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде): +- Над второй (нижней) таблицей — поле поиска по контрагенту (`q` существующего фильтра `GET …/osv`, пауза ввода как в своде): у РБ на фикстуре около тысячи строк без ИНН, листать страницы в поисках одной строки — та же «прокликай всё». - Модалка не показывается для версий в завершённых статусах (`VERIFIED`, `CANCELLED`, `ARCHIVED`, `REJECTED` — `FINISHED_STATUSES`): править там нечего. Режим «Встречные записи» доступен в любом статусе, «Указать» ведёт себя как @@ -84,10 +84,10 @@ | 27.10.2026 | «После исправлений» = список пар изменился (ключ списка пар в `sessionStorage`), а не любое «Связать» | Повтор модалки нужен, когда есть что сказать нового; «Связать» вне пар модалку не меняет | Низкая: правило в одной функции фронта | | 27.10.2026 | Строки А и Б берутся существующим `GET …/osv` с фильтрами (`counterparty_id` — новый), в сводке — только пары и счётчики | Без нового эндпоинта; пагинация и экспорт работают как в своде | Низкая | | 27.10.2026 | Учитываются все строки свода, без ограничения счетами правил элиминации и без порога по суммам | Владелец согласовал уровень компаний; строка с нулями тоже говорит, что контрагент виден | Средняя: возможны «лишние» пары из-за случайных счетов — правится фильтром в функции | -| 27.10.2026 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Три колонки на экране; для поиска пары важнее обороты и итог | Низкая: добавить колонки | +| 27.10.2026 | Колонки таблиц режима — счёт, контрагент, обороты Дт/Кт, сальдо на конец Дт/Кт (без сальдо на начало) | Колонки должны уместиться на всю ширину при 1440; для поиска пары важнее обороты и итог | Низкая: добавить колонки | | 27.10.2026 | Режим — параметр адреса `?view=pairs` (с `holder`/`missing`), переключение без записи в историю | Как вкладки отчёта (AURORA-1851); ссылку на пару можно переслать | Низкая | | 27.10.2026 | Тексты баннера и модалки — из спек этого change | Владелец согласовал смысл, формулировки мои | Низкая | -| 27.10.2026 | Поиск `q` над правой таблицей режима (находка builder check) | У РБ ~1000 строк без ИНН; без поиска разбор пары — листание страниц | Низкая: существующий фильтр API | +| 27.10.2026 | Поиск `q` над второй (нижней) таблицей режима (находка builder check) | У РБ ~1000 строк без ИНН; без поиска разбор пары — листание страниц | Низкая: существующий фильтр API | | 27.10.2026 | Модалка не показывается для версий в `FINISHED_STATUSES` (находка builder check) | Замороженную версию не исправить — предупреждение было бы шумом | Низкая | | 27.10.2026 | R-1 (design #9): `needsUnmappedWarning` удаляется в Task 4 вместе с его использованием в `OsvTabs`, а не в Task 3 | Иначе между коммитами Task 3 и 4 сборка (tsc) красная | Нулевая | | 27.10.2026 | R-2 (design #10): в тексте модалки «Ещё K строк без ИНН…» слово «строк» не склоняется, M — число разных `missing_id` (по спеке) | Спека дословна; склонение K=1 даёт всё равно неграмматичную фразу («учтены») | Низкая | @@ -95,5 +95,6 @@ | 27.10.2026 | R-4 (design #12): подпись под парой в списке — «записей: N · без ИНН у <Б>: M» вместо служебных «держатель»/«Б» | Аналитик не знает ролей «держатель» и «Б»; название организации понятнее | Низкая | | 27.10.2026 | R-5 (design #13): раскладка режима «Встречные записи» — пары одной строкой «таблеток» с переносом, под ними две таблицы на всю ширину одна под другой (вместо трёх колонок) | В трёх колонках таблицам не хватало ширины: суммы обрезались, видны были только «Обороты Дт» (находка H-1) | Низкая | -Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. | 27.10.2026 | R-6 (design #14): уход с «ОСВ» — закрытая версия (FINISHED_STATUSES) переходит без запроса сводки; при `summary = null` запрашивается сводка, при ошибке запроса переход идёт без модалки и без своего сообщения; на время догрузки переключатель вкладок блокируется; выбранная пара хранится в адресе и в режиме «Свод» (`?holder=&missing=` без `view`) | Модалка нужна только открытой версии; ошибку сводки показывает общий слой; блокировка исключает двойные клики; адрес — единственное хранилище состояния режима | Низкая | + +Отложено решением владельца 27.10.2026: строки Б с ИНН вне справочника — AURORA-1866 (разбор); подсветка кандидата по сумме — AURORA-1867. diff --git a/openspec/changes/counter-pairs/proposal.md b/openspec/changes/counter-pairs/proposal.md index 0a44aa6..33d0822 100644 --- a/openspec/changes/counter-pairs/proposal.md +++ b/openspec/changes/counter-pairs/proposal.md @@ -38,7 +38,7 @@ AURORA-1746 и замечание заказчика (Циликов А.В., 23. 27.10.2026); DBML не меняется. - Отсев строк, не являющихся организациями (зарплата, подотчётники), из счётчика `unmapped_total`. - Сверка по счетам и суммам внутри пары — это делает шахматка (§6). -- Поиск участника, ошибочно сопоставленного с другим ЮЛ: справа показываются только несопоставленные строки без ИНН. +- Поиск участника, ошибочно сопоставленного с другим ЮЛ: во второй (нижней) таблице показываются только несопоставленные строки без ИНН. - Строки Б с ИНН из файла, которого нет в справочнике (старый ИНН, опечатка): по решению 08.10.2026 они не требуют сопоставления, в правую таблицу не попадают, и такая пара из режима не закрывается. Оставлено как есть (решение владельца 27.10.2026), разбор — отдельная задача Jira; модалка и пустое состояние называют эту причину. diff --git a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md index 3499a05..6d69a71 100644 --- a/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md +++ b/openspec/changes/counter-pairs/specs/consolidated-osv-ui/spec.md @@ -42,7 +42,7 @@ «Указать» #### Scenario: Сопоставление из режима закрывает пару -- **WHEN** в правой таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная» +- **WHEN** во второй таблице у «Пекарня Счастливочная ООО» нажато «Указать» и выбрана ООО «Пекарня Счастливочная» - **THEN** после «Связать» список пар пуст, показан текст «Все участники периметра видят друг друга», переключатель — «Встречные записи (0)» diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts index 5ff5850..f8094b8 100644 --- a/web/src/utils/consolidations.test.ts +++ b/web/src/utils/consolidations.test.ts @@ -19,6 +19,7 @@ import { parseInWorkOnly, rememberCounterPairsShown, reportTabNavigation, + resolveLeaveAfterFetch, shouldWarnCounterPairs, versionLabel, } from './consolidations'; @@ -245,3 +246,23 @@ describe('decideOsvLeave (T4.2)', () => { expect(decideOsvLeave({ ...base, storage: shown, summary: { counter_pairs: pairs } })).toBe('go'); }); }); + +describe('resolveLeaveAfterFetch (T7.3)', () => { + const pairs = [{ holder_id: 'a', missing_id: 'b' } as CounterPairOut]; + const args = { versionId: 'v', status: 'CONSOLIDATION_RF' as ConsStatus, storage: { getItem: () => null } }; + it('успех с парами — warn', () => { + expect(resolveLeaveAfterFetch({ ...args, summary: { counter_pairs: pairs } })).toBe('warn'); + }); + it('успех без пар — go', () => { + expect(resolveLeaveAfterFetch({ ...args, summary: { counter_pairs: [] } })).toBe('go'); + }); + it('ошибка запроса (null) — go', () => { + expect(resolveLeaveAfterFetch({ ...args, summary: null })).toBe('go'); + }); +}); + +describe('reportTabNavigation: адрес без search (T7.4)', () => { + it('?holder=&missing= не переносятся на другую вкладку', () => { + expect(reportTabNavigation('/groups/g/versions/v/osv', 'osv', 'chess').to).toBe('/groups/g/versions/v/chess'); + }); +}); diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts index 1832061..1d5fa02 100644 --- a/web/src/utils/consolidations.ts +++ b/web/src/utils/consolidations.ts @@ -170,3 +170,14 @@ export const decideOsvLeave = (args: { if (summary === null) return 'perform'; return shouldWarnCounterPairs(versionId, status, summary.counter_pairs, storage) ? 'warn' : 'go'; }; + +/** Решение после догрузки сводки при уходе с «ОСВ»: `summary === null` — запрос не удался, не держим пользователя (`go`). */ +export const resolveLeaveAfterFetch = (args: { + summary: Pick | null; + versionId: string; + status: ConsStatus | undefined; + storage: Pick | null; +}): 'warn' | 'go' => { + if (args.summary === null) return 'go'; + return decideOsvLeave(args) === 'warn' ? 'warn' : 'go'; +}; diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index f9e421f..b9265bd 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -1,11 +1,17 @@ import { WarningOutlined } from '@ant-design/icons'; import { Button, Modal, Segmented, Tooltip } from 'antd'; -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { getOsvSummary } from '../../services/api/osv'; import type { CounterPairOut, OsvSummaryOut } from '../../types/api'; import type { ConsStatus } from '../../types/consolidations/common_types'; -import { counterPairsWarning, decideOsvLeave, rememberCounterPairsShown, reportTabNavigation } from '../../utils/consolidations'; +import { + counterPairsWarning, + decideOsvLeave, + rememberCounterPairsShown, + reportTabNavigation, + resolveLeaveAfterFetch, +} from '../../utils/consolidations'; import { pairsViewSearch } from './counterPairs'; const TAB_CONFIG = [ @@ -73,10 +79,22 @@ export function OsvTabs({ summary, versionId, status }: Props) { navigate(to, options); }; - // сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал) - const [fetched, setFetched] = useState(null); + // сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал); привязана к версии + const [fetched, setFetched] = useState<{ versionId: string; summary: OsvSummaryOut } | null>(null); const [checking, setChecking] = useState(false); - const shownPairs = (summary ?? fetched)?.counter_pairs ?? []; + const fetchedSummary = fetched?.versionId === versionId ? fetched.summary : null; + const known = summary ?? fetchedSummary; + const shownPairs = known?.counter_pairs ?? []; + + // актуальные версия и признак «компонент жив» — ответ запроса, пришедший после смены версии/размонтирования, игнорируется + const liveRef = useRef({ versionId, mounted: true }); + liveRef.current.versionId = versionId; + useEffect(() => { + liveRef.current.mounted = true; + return () => { + liveRef.current.mounted = false; + }; + }, []); const handleTabChange = async (value: string | number) => { const newKey = String(value); @@ -86,23 +104,26 @@ export function OsvTabs({ summary, versionId, status }: Props) { return; } const storage = safeSessionStorage(); - let known = summary ?? fetched; - let decision = decideOsvLeave({ summary: known, versionId, status, storage }); + let found: OsvSummaryOut | null = known; + let decision: 'warn' | 'go' | 'perform' = decideOsvLeave({ summary: found, versionId, status, storage }); if (decision === 'perform') { setChecking(true); + const requestedFor = versionId; + let result: OsvSummaryOut | null = null; try { - known = await getOsvSummary(versionId); - setFetched(known); - decision = decideOsvLeave({ summary: known, versionId, status, storage }); + result = await getOsvSummary(requestedFor); } catch { // не удалось проверить — не держим пользователя на вкладке - decision = 'go'; - } finally { - setChecking(false); } + if (!liveRef.current.mounted) return; + setChecking(false); + if (liveRef.current.versionId !== requestedFor) return; + if (result) setFetched({ versionId: requestedFor, summary: result }); + found = result; + decision = resolveLeaveAfterFetch({ summary: result, versionId, status, storage }); } - if (decision === 'warn' && known) { - rememberCounterPairsShown(versionId, known.counter_pairs, storage); + if (decision === 'warn' && found) { + rememberCounterPairsShown(versionId, found.counter_pairs, storage); setPending(newKey); } else { go(newKey); @@ -142,7 +163,7 @@ export function OsvTabs({ summary, versionId, status }: Props) { { setPending(null); navigate( diff --git a/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts b/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts index b8739a6..30f3af0 100644 --- a/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts +++ b/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts @@ -30,6 +30,8 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) { const [summary, setSummary] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); + // ошибка загрузки сводки (для режима пар: без неё там не на что смотреть) + const [summaryError, setSummaryError] = useState(null); // номер последнего запроса: ответы более старых запросов игнорируются const requestRef = useRef(0); @@ -40,9 +42,15 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) { const requestId = ++summaryRef.current; try { const s = await getOsvSummary(versionId); - if (requestId === summaryRef.current) setSummary(s); + if (requestId === summaryRef.current) { + setSummary(s); + setSummaryError(null); + } } catch (e: unknown) { - if (requestId === summaryRef.current) message.error(osvErrorText(e)); + if (requestId === summaryRef.current) { + message.error(osvErrorText(e)); + setSummaryError(osvErrorText(e)); + } } }, [versionId]); @@ -76,7 +84,12 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) { }, [versionId, filters, page]); useEffect(() => { - if (pairsMode) return; + if (pairsMode) { + // ответ ещё летящего запроса строк больше не нужен + requestRef.current++; + setLoading(false); + return; + } void load(); }, [load, pairsMode]); @@ -90,5 +103,5 @@ export function useConsolidatedOsv(versionId: string, pairsMode = false) { void loadSummary(); }; - return { filters, patchFilters, page, setPage, rows, total, summary, loading, error, refresh }; + return { filters, patchFilters, page, setPage, rows, total, summary, summaryError, loadSummary, loading, error, refresh }; } diff --git a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index 7661d81..ed10985 100644 --- a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -71,18 +71,35 @@ export function OsvContent({ card, onSummaryChange }: Props) { // вне вкладки «ОСВ» предупреждать не о чем useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]); - const pairsBody = s ? ( - setView({ view: 'pairs', holder, missing })} - onMapped={osv.refresh} - /> - ) : ( - !osv.error && - ); + const renderPairsBody = () => { + if (s) { + return ( + setView({ view: 'pairs', holder, missing })} + onMapped={osv.refresh} + /> + ); + } + if (osv.summaryError) { + return ( + void osv.loadSummary()}> + Повторить + + } + /> + ); + } + return ; + }; return ( <> @@ -161,7 +178,7 @@ export function OsvContent({ card, onSummaryChange }: Props) { )} {inPairs ? ( - pairsBody + renderPairsBody() ) : ( <>