diff --git a/web/src/utils/consolidations.test.ts b/web/src/utils/consolidations.test.ts index 2b8bd77..5ff5850 100644 --- a/web/src/utils/consolidations.test.ts +++ b/web/src/utils/consolidations.test.ts @@ -11,6 +11,7 @@ import { consStatusDescription, counterPairsKey, counterPairsWarning, + decideOsvLeave, endSentence, isLockedBy, isValidReason, @@ -222,3 +223,25 @@ describe('counterPairsWarning', () => { ); }); }); + +describe('decideOsvLeave (T4.2)', () => { + const pairs = [{ holder_id: 'a', missing_id: 'b' } as CounterPairOut]; + const noStorage = { getItem: () => null }; + const base = { versionId: 'v', status: 'CONSOLIDATION_RF' as ConsStatus, storage: noStorage }; + it('сводки нет и версия открыта — запросить', () => { + expect(decideOsvLeave({ ...base, summary: null })).toBe('perform'); + }); + it('версия закрыта — переход без запроса', () => { + expect(decideOsvLeave({ ...base, status: 'VERIFIED', summary: null })).toBe('go'); + }); + it('сводка есть, пары есть и не показывали — предупредить', () => { + expect(decideOsvLeave({ ...base, summary: { counter_pairs: pairs } })).toBe('warn'); + }); + it('сводка есть, пар нет — перейти', () => { + expect(decideOsvLeave({ ...base, summary: { counter_pairs: [] } })).toBe('go'); + }); + it('список уже показывали — перейти', () => { + const shown = { getItem: () => 'a>b' }; + expect(decideOsvLeave({ ...base, storage: shown, summary: { counter_pairs: pairs } })).toBe('go'); + }); +}); diff --git a/web/src/utils/consolidations.ts b/web/src/utils/consolidations.ts index 7124db0..1832061 100644 --- a/web/src/utils/consolidations.ts +++ b/web/src/utils/consolidations.ts @@ -1,6 +1,6 @@ // Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные // переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API. -import type { CounterPairOut } from '../types/api'; +import type { CounterPairOut, OsvSummaryOut } from '../types/api'; import type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types'; import { pluralRu } from './labels'; @@ -154,3 +154,19 @@ export const counterPairsWarning = (pairs: readonly CounterPairOut[], unmappedTo details: unmappedTotal > 0 ? `${base} Ещё ${unmappedTotal} строк без ИНН учтены как внешние.` : base, }; }; + +/** Решение при уходе с вкладки «ОСВ»: `perform` — сводки ещё нет, запросить; `warn` — показать модалку; `go` — перейти. */ +export type OsvLeaveDecision = 'perform' | 'warn' | 'go'; + +export const decideOsvLeave = (args: { + summary: Pick | null; + versionId: string; + status: ConsStatus | undefined; + storage: Pick | null; +}): OsvLeaveDecision => { + const { summary, versionId, status, storage } = args; + // закрытой версии модалка не нужна — и запрашивать нечего + if (status && FINISHED_STATUSES.includes(status)) return 'go'; + if (summary === null) return 'perform'; + return shouldWarnCounterPairs(versionId, status, summary.counter_pairs, storage) ? 'warn' : 'go'; +}; diff --git a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx index a2524b6..f9e421f 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTabs.tsx @@ -2,9 +2,10 @@ import { WarningOutlined } from '@ant-design/icons'; import { Button, Modal, Segmented, Tooltip } from 'antd'; import { useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; +import { getOsvSummary } from '../../services/api/osv'; import type { CounterPairOut, OsvSummaryOut } from '../../types/api'; import type { ConsStatus } from '../../types/consolidations/common_types'; -import { counterPairsWarning, rememberCounterPairsShown, reportTabNavigation, shouldWarnCounterPairs } from '../../utils/consolidations'; +import { counterPairsWarning, decideOsvLeave, rememberCounterPairsShown, reportTabNavigation } from '../../utils/consolidations'; import { pairsViewSearch } from './counterPairs'; const TAB_CONFIG = [ @@ -72,12 +73,36 @@ export function OsvTabs({ summary, versionId, status }: Props) { navigate(to, options); }; - const handleTabChange = (value: string | number) => { + // сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал) + const [fetched, setFetched] = useState(null); + const [checking, setChecking] = useState(false); + const shownPairs = (summary ?? fetched)?.counter_pairs ?? []; + + const handleTabChange = async (value: string | number) => { const newKey = String(value); - if (newKey === activeKey) return; - const pairs = summary?.counter_pairs ?? []; - if (activeKey === 'osv' && shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())) { - rememberCounterPairsShown(versionId, pairs, safeSessionStorage()); + if (newKey === activeKey || checking) return; + if (activeKey !== 'osv') { + go(newKey); + return; + } + const storage = safeSessionStorage(); + let known = summary ?? fetched; + let decision = decideOsvLeave({ summary: known, versionId, status, storage }); + if (decision === 'perform') { + setChecking(true); + try { + known = await getOsvSummary(versionId); + setFetched(known); + decision = decideOsvLeave({ summary: known, versionId, status, storage }); + } catch { + // не удалось проверить — не держим пользователя на вкладке + decision = 'go'; + } finally { + setChecking(false); + } + } + if (decision === 'warn' && known) { + rememberCounterPairsShown(versionId, known.counter_pairs, storage); setPending(newKey); } else { go(newKey); @@ -110,13 +135,14 @@ export function OsvTabs({ summary, versionId, status }: Props) { { setPending(null); navigate( diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts index 0e40837..f56f96a 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { CounterPairOut } from '../../types/api'; -import { pairLabel, pairsViewSearch, readPairsView, selectPair } from './counterPairs'; +import { pairLabel, pairsViewSearch, readPairsView, selectPair, switchPairsView } from './counterPairs'; const pair = (holder: string, missing: string): CounterPairOut => ({ holder_id: holder, @@ -15,7 +15,16 @@ const pair = (holder: string, missing: string): CounterPairOut => ({ describe('readPairsView / pairsViewSearch', () => { it('table по умолчанию', () => { expect(readPairsView(new URLSearchParams(''))).toEqual({ view: 'table', holder: null, missing: null }); - expect(pairsViewSearch({ view: 'table', holder: 'a', missing: 'b' })).toBe(''); + expect(pairsViewSearch({ view: 'table', holder: null, missing: null })).toBe(''); + }); + it('пара сохраняется в режиме «Свод» (T5.5)', () => { + const v = { view: 'table' as const, holder: 'a', missing: 'b' }; + const s = pairsViewSearch(v); + expect(s).toBe('?holder=a&missing=b'); + expect(readPairsView(new URLSearchParams(s))).toEqual(v); + // переключение «Свод» → «Встречные записи» возвращает ту же пару + expect(switchPairsView({ view: 'pairs', holder: 'a', missing: 'b' }, 'table')).toEqual(v); + expect(switchPairsView(v, 'pairs')).toEqual({ view: 'pairs', holder: 'a', missing: 'b' }); }); it('туда-обратно', () => { const v = { view: 'pairs' as const, holder: 'a', missing: 'b' }; diff --git a/web/src/widgets/ConsolidatedOsv/counterPairs.ts b/web/src/widgets/ConsolidatedOsv/counterPairs.ts index 214190d..363e8d1 100644 --- a/web/src/widgets/ConsolidatedOsv/counterPairs.ts +++ b/web/src/widgets/ConsolidatedOsv/counterPairs.ts @@ -4,18 +4,23 @@ import type { CounterPairOut } from '../../types/api'; export type PairsView = { view: 'table' | 'pairs'; holder: string | null; missing: string | null }; /** `?view=pairs[&holder=..&missing=..]`; всё остальное — обычная таблица свода. */ -export const readPairsView = (search: URLSearchParams): PairsView => - search.get('view') === 'pairs' - ? { view: 'pairs', holder: search.get('holder'), missing: search.get('missing') } - : { view: 'table', holder: null, missing: null }; +export const readPairsView = (search: URLSearchParams): PairsView => ({ + view: search.get('view') === 'pairs' ? 'pairs' : 'table', + holder: search.get('holder'), + missing: search.get('missing'), +}); -/** Строка запроса для режима; у таблицы пусто. */ +/** Переключение «Свод» / «Встречные записи»: выбранная пара не теряется. */ +export const switchPairsView = (v: PairsView, view: PairsView['view']): PairsView => ({ ...v, view }); + +/** Строка запроса для режима; пара (`holder`/`missing`) хранится и в режиме «Свод» — для возврата. */ export const pairsViewSearch = (v: PairsView): string => { - if (v.view !== 'pairs') return ''; - const params = new URLSearchParams({ view: 'pairs' }); + const params = new URLSearchParams(); + if (v.view === 'pairs') params.set('view', 'pairs'); if (v.holder) params.set('holder', v.holder); if (v.missing) params.set('missing', v.missing); - return `?${params.toString()}`; + const text = params.toString(); + return text ? `?${text}` : ''; }; /** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */ diff --git a/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts b/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts index e9a2a7b..b8739a6 100644 --- a/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts +++ b/web/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts @@ -2,8 +2,8 @@ import { message } from 'antd'; import { useCallback, useEffect, useRef, useState } from 'react'; import { ApiError } from '../../api/client'; import { getConsolidatedOsv, getOsvSummary } from '../../services/api/osv'; -import type { ConsolidatedRowOut, OsvFilters, OsvSummaryOut } from '../../types/api'; -import { type OsvFilterDraft, applyFilterPatch } from './osvFilterState'; +import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api'; +import { type OsvFilterDraft, type OsvFilterState, applyFilterPatch } from './osvFilterState'; export const OSV_PAGE_SIZE = 500; @@ -17,9 +17,14 @@ export function osvErrorText(e: unknown): string { return 'Не удалось получить сводную ОСВ'; } -export function useConsolidatedOsv(versionId: string) { - const [filters, setFilters] = useState({}); - const [page, setPage] = useState(1); +/** + * @param pairsMode режим «Встречные записи»: строки свода не нужны и не запрашиваются (сводка — запрашивается). + * Фильтры и страница — одно состояние: смена фильтра с возвратом на 1-ю страницу даёт один запрос, а не два. + */ +export function useConsolidatedOsv(versionId: string, pairsMode = false) { + const [query, setQuery] = useState({ filters: {}, page: 1 }); + const { filters, page } = query; + const setPage = useCallback((p: number) => setQuery((q) => ({ ...q, page: p })), []); const [rows, setRows] = useState([]); const [total, setTotal] = useState(0); const [summary, setSummary] = useState(null); @@ -71,18 +76,17 @@ export function useConsolidatedOsv(versionId: string) { }, [versionId, filters, page]); useEffect(() => { + if (pairsMode) return; void load(); - }, [load]); + }, [load, pairsMode]); const patchFilters = (patch: OsvFilterDraft) => { - const next = applyFilterPatch({ filters, page }, patch); - setFilters(next.filters); - setPage(next.page); + setQuery((q) => applyFilterPatch(q, patch)); }; /** После ручного сопоставления: «Осталось N» и строки меняются сразу у нескольких строк (автоприменение) — перечитать обе. */ const refresh = () => { - void load(); + if (!pairsMode) void load(); void loadSummary(); }; diff --git a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index a7b4f90..7661d81 100644 --- a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -1,5 +1,5 @@ import { DownloadOutlined, FilterOutlined } from '@ant-design/icons'; -import { Alert, Button, Segmented, Select, message } from 'antd'; +import { Alert, Button, Segmented, Select, Spin, message } from 'antd'; import { useEffect, useState } from 'react'; import { useParams, useSearchParams } from 'react-router-dom'; import { ApiError } from '../../api/client'; @@ -11,7 +11,7 @@ import { CounterPairsView } from '../ConsolidatedOsv/CounterPairsView'; import { CounterpartyMapModal } from '../ConsolidatedOsv/CounterpartyMapModal'; import { OsvGaps } from '../ConsolidatedOsv/OsvGaps'; import { OsvTable } from '../ConsolidatedOsv/OsvTable'; -import { pairsViewSearch, readPairsView, selectPair } from '../ConsolidatedOsv/counterPairs'; +import { pairsViewSearch, readPairsView, selectPair, switchPairsView } from '../ConsolidatedOsv/counterPairs'; import { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping'; import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv'; import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay'; @@ -28,7 +28,11 @@ export function OsvContent({ card, onSummaryChange }: Props) { // строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ) const [mapping, setMapping] = useState(null); - const osv = useConsolidatedOsv(version_id ?? ''); + const [searchParams, setSearchParams] = useSearchParams(); + const pairsView = readPairsView(searchParams); + const inPairs = pairsView.view === 'pairs'; + const osv = useConsolidatedOsv(version_id ?? '', inPairs); + const s = osv.summary; const { unit, setUnit, precision, setPrecision, format } = useNumberDisplay(); const download = async () => { @@ -46,12 +50,8 @@ export function OsvContent({ card, onSummaryChange }: Props) { } }; - const s = osv.summary; - const [searchParams, setSearchParams] = useSearchParams(); - const pairsView = readPairsView(searchParams); const pairs = s?.counter_pairs ?? []; const selectedPair = selectPair(pairs, pairsView.holder, pairsView.missing); - const inPairs = pairsView.view === 'pairs'; const setView = (v: Parameters[0]) => setSearchParams(pairsViewSearch(v), { replace: true }); const unmapped = s?.unmapped_total ?? 0; const onlyUnmapped = osv.filters.unmapped === true; @@ -71,6 +71,19 @@ export function OsvContent({ card, onSummaryChange }: Props) { // вне вкладки «ОСВ» предупреждать не о чем useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]); + const pairsBody = s ? ( + setView({ view: 'pairs', holder, missing })} + onMapped={osv.refresh} + /> + ) : ( + !osv.error && + ); + return ( <>
- setView(v === 'pairs' ? { view: 'pairs', holder: null, missing: null } : { view: 'table', holder: null, missing: null }) - } + onChange={(v) => setView(switchPairsView(pairsView, v === 'pairs' ? 'pairs' : 'table'))} options={[ { label: 'Свод', value: 'table' }, - { label: `Встречные записи (${pairs.length})`, value: 'pairs' }, + // пока сводки нет — без числа + { label: s ? `Встречные записи (${pairs.length})` : 'Встречные записи', value: 'pairs' }, ]} style={{ alignSelf: 'flex-start' }} /> @@ -149,16 +161,7 @@ export function OsvContent({ card, onSummaryChange }: Props) { )} {inPairs ? ( - s && ( - setView({ view: 'pairs', holder, missing })} - onMapped={osv.refresh} - /> - ) + pairsBody ) : ( <>