feat(#195): переход с вкладки ОСВ, режим пар без лишних запросов, пара в адресе при «Свод»

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 21:54:05 +04:00
parent b9a55873e5
commit 8054ff205d
7 changed files with 136 additions and 50 deletions

View File

@ -11,6 +11,7 @@ import {
consStatusDescription, consStatusDescription,
counterPairsKey, counterPairsKey,
counterPairsWarning, counterPairsWarning,
decideOsvLeave,
endSentence, endSentence,
isLockedBy, isLockedBy,
isValidReason, 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');
});
});

View File

@ -1,6 +1,6 @@
// Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные // Чистые правила экранов консолидаций (спека `consolidations-ui`): подписи и пояснения статусов версии, ручные
// переходы, блокировка, «Версия», фильтр «Только в работе». Без React, store и API. // переходы, блокировка, «Версия», фильтр «Только в работе». Без 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 type { ConsRussianStatus, ConsStatus, LockOut } from '../types/consolidations/common_types';
import { pluralRu } from './labels'; import { pluralRu } from './labels';
@ -154,3 +154,19 @@ 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';
};

View File

@ -2,9 +2,10 @@ import { WarningOutlined } from '@ant-design/icons';
import { Button, Modal, Segmented, Tooltip } from 'antd'; import { Button, Modal, Segmented, Tooltip } from 'antd';
import { 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 { counterPairsWarning, rememberCounterPairsShown, reportTabNavigation, shouldWarnCounterPairs } from '../../utils/consolidations'; import { counterPairsWarning, decideOsvLeave, rememberCounterPairsShown, reportTabNavigation } from '../../utils/consolidations';
import { pairsViewSearch } from './counterPairs'; import { pairsViewSearch } from './counterPairs';
const TAB_CONFIG = [ const TAB_CONFIG = [
@ -72,12 +73,36 @@ export function OsvTabs({ summary, versionId, status }: Props) {
navigate(to, options); navigate(to, options);
}; };
const handleTabChange = (value: string | number) => { // сводка, догруженная при уходе, пока страница её не передала (или запрос страницы упал)
const [fetched, setFetched] = useState<OsvSummaryOut | null>(null);
const [checking, setChecking] = useState(false);
const shownPairs = (summary ?? fetched)?.counter_pairs ?? [];
const handleTabChange = async (value: string | number) => {
const newKey = String(value); const newKey = String(value);
if (newKey === activeKey) return; if (newKey === activeKey || checking) return;
const pairs = summary?.counter_pairs ?? []; if (activeKey !== 'osv') {
if (activeKey === 'osv' && shouldWarnCounterPairs(versionId, status, pairs, safeSessionStorage())) { go(newKey);
rememberCounterPairsShown(versionId, pairs, safeSessionStorage()); 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); setPending(newKey);
} else { } else {
go(newKey); go(newKey);
@ -110,13 +135,14 @@ 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={summary?.counter_pairs ?? []} pairs={shownPairs}
unmappedTotal={summary?.unmapped_total ?? 0} unmappedTotal={(summary ?? fetched)?.unmapped_total ?? 0}
onShow={() => { onShow={() => {
setPending(null); setPending(null);
navigate( navigate(

View File

@ -1,6 +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 { pairLabel, pairsViewSearch, readPairsView, selectPair } from './counterPairs'; import { pairLabel, pairsViewSearch, readPairsView, selectPair, switchPairsView } from './counterPairs';
const pair = (holder: string, missing: string): CounterPairOut => ({ const pair = (holder: string, missing: string): CounterPairOut => ({
holder_id: holder, holder_id: holder,
@ -15,7 +15,16 @@ 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: '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('туда-обратно', () => { it('туда-обратно', () => {
const v = { view: 'pairs' as const, holder: 'a', missing: 'b' }; const v = { view: 'pairs' as const, holder: 'a', missing: 'b' };

View File

@ -4,18 +4,23 @@ 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 => ({
search.get('view') === 'pairs' view: search.get('view') === 'pairs' ? 'pairs' : 'table',
? { view: 'pairs', holder: search.get('holder'), missing: search.get('missing') } holder: search.get('holder'),
: { view: 'table', holder: null, missing: null }; missing: search.get('missing'),
});
/** Строка запроса для режима; у таблицы пусто. */ /** Переключение «Свод» / «Встречные записи»: выбранная пара не теряется. */
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 => {
if (v.view !== 'pairs') return ''; const params = new URLSearchParams();
const params = new URLSearchParams({ view: 'pairs' }); if (v.view === 'pairs') params.set('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);
return `?${params.toString()}`; const text = params.toString();
return text ? `?${text}` : '';
}; };
/** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */ /** Заданная пара; нет такой (закрыта сопоставлением) — первая оставшаяся; список пуст — `null`. */

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, OsvFilters, OsvSummaryOut } from '../../types/api'; import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api';
import { type OsvFilterDraft, applyFilterPatch } from './osvFilterState'; import { type OsvFilterDraft, type OsvFilterState, applyFilterPatch } from './osvFilterState';
export const OSV_PAGE_SIZE = 500; export const OSV_PAGE_SIZE = 500;
@ -17,9 +17,14 @@ export function osvErrorText(e: unknown): string {
return 'Не удалось получить сводную ОСВ'; return 'Не удалось получить сводную ОСВ';
} }
export function useConsolidatedOsv(versionId: string) { /**
const [filters, setFilters] = useState<OsvFilters>({}); * @param pairsMode режим «Встречные записи»: строки свода не нужны и не запрашиваются (сводка — запрашивается).
const [page, setPage] = useState(1); * Фильтры и страница — одно состояние: смена фильтра с возвратом на 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);
@ -71,18 +76,17 @@ export function useConsolidatedOsv(versionId: string) {
}, [versionId, filters, page]); }, [versionId, filters, page]);
useEffect(() => { useEffect(() => {
if (pairsMode) return;
void load(); void load();
}, [load]); }, [load, pairsMode]);
const patchFilters = (patch: OsvFilterDraft) => { const patchFilters = (patch: OsvFilterDraft) => {
const next = applyFilterPatch({ filters, page }, patch); setQuery((q) => applyFilterPatch(q, patch));
setFilters(next.filters);
setPage(next.page);
}; };
/** После ручного сопоставления: «Осталось N» и строки меняются сразу у нескольких строк (автоприменение) — перечитать обе. */ /** После ручного сопоставления: «Осталось N» и строки меняются сразу у нескольких строк (автоприменение) — перечитать обе. */
const refresh = () => { const refresh = () => {
void load(); if (!pairsMode) void load();
void loadSummary(); void loadSummary();
}; };

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, message } from 'antd'; import { Alert, Button, Segmented, Select, Spin, 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 } from '../ConsolidatedOsv/counterPairs'; import { pairsViewSearch, readPairsView, selectPair, switchPairsView } 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,7 +28,11 @@ export function OsvContent({ card, onSummaryChange }: Props) {
// строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ) // строка, для которой открыт выбор организации («Указать» или ИНН сопоставленного ЮЛ)
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null); const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(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 { unit, setUnit, precision, setPrecision, format } = useNumberDisplay();
const download = async () => { 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 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,6 +71,19 @@ export function OsvContent({ card, onSummaryChange }: Props) {
// вне вкладки «ОСВ» предупреждать не о чем // вне вкладки «ОСВ» предупреждать не о чем
useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]); useEffect(() => () => onSummaryChange?.(null), [onSummaryChange]);
const pairsBody = s ? (
<CounterPairsView
versionId={version_id ?? ''}
pairs={pairs}
selected={selectedPair}
numFormat={format}
onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })}
onMapped={osv.refresh}
/>
) : (
!osv.error && <Spin style={{ alignSelf: 'center', margin: 24 }} />
);
return ( return (
<> <>
<main <main
@ -110,12 +123,11 @@ export function OsvContent({ card, onSummaryChange }: Props) {
</div> </div>
<Segmented <Segmented
value={pairsView.view} value={pairsView.view}
onChange={(v) => onChange={(v) => setView(switchPairsView(pairsView, v === 'pairs' ? 'pairs' : 'table'))}
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' }}
/> />
@ -149,16 +161,7 @@ export function OsvContent({ card, onSummaryChange }: Props) {
</div> </div>
)} )}
{inPairs ? ( {inPairs ? (
s && ( pairsBody
<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 ?? []} />