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,
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');
});
});

View File

@ -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<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 { 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<OsvSummaryOut | null>(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) {
<Segmented
value={activeKey}
onChange={handleTabChange}
disabled={checking}
style={{ alignSelf: 'flex-start', maxWidth: '100%', overflowX: 'auto' }}
options={options}
/>
<CounterPairsModal
open={pending !== null}
pairs={summary?.counter_pairs ?? []}
unmappedTotal={summary?.unmapped_total ?? 0}
pairs={shownPairs}
unmappedTotal={(summary ?? fetched)?.unmapped_total ?? 0}
onShow={() => {
setPending(null);
navigate(

View File

@ -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' };

View File

@ -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`. */

View File

@ -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<OsvFilters>({});
const [page, setPage] = useState(1);
/**
* @param pairsMode режим «Встречные записи»: строки свода не нужны и не запрашиваются (сводка — запрашивается).
* Фильтры и страница — одно состояние: смена фильтра с возвратом на 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 [total, setTotal] = useState(0);
const [summary, setSummary] = useState<OsvSummaryOut | null>(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();
};

View File

@ -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<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 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<typeof pairsViewSearch>[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 ? (
<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 (
<>
<main
@ -110,12 +123,11 @@ export function OsvContent({ card, onSummaryChange }: Props) {
</div>
<Segmented
value={pairsView.view}
onChange={(v) =>
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) {
</div>
)}
{inPairs ? (
s && (
<CounterPairsView
versionId={version_id ?? ''}
pairs={pairs}
selected={selectedPair}
numFormat={format}
onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })}
onMapped={osv.refresh}
/>
)
pairsBody
) : (
<>
<OsvGaps gaps={s?.gaps ?? []} />