feat(#195): режим «Встречные записи» на вкладке «ОСВ»

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-27 19:00:52 +04:00
parent 8194f47d1a
commit 6db9fa2ae1
3 changed files with 304 additions and 19 deletions

View File

@ -0,0 +1,244 @@
// Режим «Встречные записи» вкладки «ОСВ»: слева пары, в центре строки держателя с Б, справа строки Б без ИНН и сопоставления.
// Правило: 05-domain-rules.md §3 п. 9.
import { Alert, Button, Input, Table } from 'antd';
import type { TableColumnsType } from 'antd';
import { useCallback, useEffect, useRef, useState } from 'react';
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 { mapEntryId } from './counterpartyMapping';
import { OSV_PAGE_SIZE, osvErrorText } from './useConsolidatedOsv';
type Props = {
versionId: string;
pairs: readonly CounterPairOut[];
/** Выбранная пара; `null` — пар нет. */
selected: CounterPairOut | null;
numFormat: (raw: string | null) => string;
onSelect: (holder: string, missing: string) => void;
/** После сопоставления: перечитать сводку (список пар) наверху. */
onMapped: () => void;
};
const MAX_QUERY = 500;
const SEARCH_DELAY_MS = 300;
const MUTED = '#667085';
const PANEL: React.CSSProperties = { display: 'flex', flexDirection: 'column', gap: 8, minWidth: 0 };
const PANEL_TITLE: React.CSSProperties = { fontWeight: 700, fontSize: 13, color: '#344054' };
/** Страница строк свода по фильтру; ответы устаревших запросов игнорируются. */
function usePagedRows(versionId: string, filters: OsvFilters | null, reloadKey: number) {
const [rows, setRows] = useState<ConsolidatedRowOut[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const requestRef = useRef(0);
const key = JSON.stringify(filters);
// смена фильтра — снова первая страница
// biome-ignore lint/correctness/useExhaustiveDependencies: key — сериализованный filters
useEffect(() => setPage(1), [key]);
// biome-ignore lint/correctness/useExhaustiveDependencies: key — сериализованный filters; reloadKey — перечитать после сопоставления
useEffect(() => {
const requestId = ++requestRef.current;
if (!filters) {
setRows([]);
setTotal(0);
setLoading(false);
return;
}
setLoading(true);
getConsolidatedOsv(versionId, filters, OSV_PAGE_SIZE, (page - 1) * OSV_PAGE_SIZE)
.then((r) => {
if (requestId !== requestRef.current) return;
setRows(r.items);
setTotal(r.total);
setError(null);
})
.catch((e: unknown) => {
if (requestId !== requestRef.current) return;
setRows([]);
setTotal(0);
setError(osvErrorText(e));
})
.finally(() => {
if (requestId === requestRef.current) setLoading(false);
});
}, [versionId, key, page, reloadKey]);
return { rows, total, page, setPage, loading, error };
}
export function CounterPairsView({ versionId, pairs, selected, numFormat, onSelect, onMapped }: Props) {
const [q, setQ] = useState('');
const [debouncedQ, setDebouncedQ] = useState('');
const [mapping, setMapping] = useState<ConsolidatedRowOut | null>(null);
const [reloadKey, setReloadKey] = useState(0);
useEffect(() => {
const t = setTimeout(() => setDebouncedQ(q.trim()), SEARCH_DELAY_MS);
return () => clearTimeout(t);
}, [q]);
// другая пара — поиск начинается заново
const selectedKey = selected ? `${selected.holder_id}|${selected.missing_id}` : '';
// biome-ignore lint/correctness/useExhaustiveDependencies: сброс при смене пары
useEffect(() => {
setQ('');
setDebouncedQ('');
}, [selectedKey]);
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 rightFilters: OsvFilters | null =
selected && rightHasRows ? { legal_entity_id: selected.missing_id, unmapped: true, ...(debouncedQ ? { q: debouncedQ } : {}) } : null;
const center = usePagedRows(versionId, centerFilters, reloadKey);
const right = usePagedRows(versionId, rightFilters, reloadKey);
const baseColumns: TableColumnsType<ConsolidatedRowOut> = [
{ key: 'account_code', title: 'Счёт', dataIndex: 'account_code', width: 100 },
{
key: 'counterparty',
title: 'Контрагент',
width: 240,
render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—',
},
group('Обороты за период', [money('turnover_debit', 'Дебет', numFormat), money('turnover_credit', 'Кредит', numFormat)]),
group('Сальдо на конец периода', [money('closing_debit', 'Дебет', numFormat), money('closing_credit', 'Кредит', numFormat)]),
];
const rightColumns: TableColumnsType<ConsolidatedRowOut> = [
...baseColumns,
{
key: 'map',
fixed: 'right',
title: 'ИНН',
align: 'center',
width: 100,
render: (_: unknown, r) => (
<Button type='link' style={{ padding: 0, height: 'auto', color: '#b42318', fontWeight: 700 }} onClick={() => setMapping(r)}>
Указать
</Button>
),
},
];
const mapped = useCallback(() => {
setReloadKey((k) => k + 1);
onMapped();
}, [onMapped]);
if (!selected) {
return <div style={{ padding: 32, textAlign: 'center', color: MUTED, fontSize: 14 }}>Все участники периметра видят друг друга</div>;
}
let rightEmpty: string | null = null;
if (selected.missing_rows_total === 0) {
rightEmpty = `У ${selected.missing_name} в своде нет строк: ОСВ не привязаны или по ним пробелы`;
} else if (selected.missing_unmapped_rows === 0) {
rightEmpty = 'Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте';
}
return (
<div style={{ display: 'flex', gap: 12, flex: '1 1 auto', minHeight: 0, alignItems: 'flex-start' }}>
<div style={{ ...PANEL, flex: '0 0 280px', maxHeight: 'calc(100vh - 320px)', overflowY: 'auto' }}>
{pairs.map((p) => {
const active = p.holder_id === selected.holder_id && p.missing_id === selected.missing_id;
return (
<button
type='button'
key={`${p.holder_id}|${p.missing_id}`}
onClick={() => onSelect(p.holder_id, p.missing_id)}
style={{
textAlign: 'left',
cursor: 'pointer',
padding: '8px 10px',
borderRadius: 10,
border: `1px solid ${active ? '#86d19a' : '#e4e7ec'}`,
background: active ? '#eefbf1' : '#fff',
color: '#101828',
}}>
<div style={{ fontSize: 13, fontWeight: 600 }}>{pairLabel(p)}</div>
<div style={{ fontSize: 11, color: MUTED }}>
строк у держателя: {p.holder_rows} · без ИНН у Б: {p.missing_unmapped_rows}
</div>
</button>
);
})}
</div>
<div style={{ ...PANEL, flex: '1 1 0' }}>
<div style={PANEL_TITLE}>
У {selected.holder_name} есть записи с {selected.missing_name}
</div>
{center.error && <Alert type='error' showIcon title={center.error} />}
<Table<ConsolidatedRowOut>
bordered
size='small'
rowKey={(r) => r.entry_ids.join(',')}
loading={center.loading}
columns={baseColumns}
dataSource={center.rows}
scroll={{ x: 'max-content' }}
pagination={{
current: center.page,
pageSize: OSV_PAGE_SIZE,
total: center.total,
showSizeChanger: false,
onChange: center.setPage,
hideOnSinglePage: true,
showTotal: (t) => `Строк: ${t}`,
}}
/>
</div>
<div style={{ ...PANEL, flex: '1 1 0' }}>
<div style={PANEL_TITLE}>У {selected.missing_name} — строки без ИНН и без сопоставления</div>
{rightEmpty ? (
<div style={{ padding: 16, color: MUTED, fontSize: 13, background: '#f9fafb', borderRadius: 10 }}>{rightEmpty}</div>
) : (
<>
<Input.Search
allowClear
placeholder='Поиск по названию контрагента'
maxLength={MAX_QUERY}
value={q}
onChange={(e) => setQ(e.target.value)}
/>
{right.error && <Alert type='error' showIcon title={right.error} />}
<Table<ConsolidatedRowOut>
bordered
size='small'
rowKey={(r) => r.entry_ids.join(',')}
loading={right.loading}
columns={rightColumns}
dataSource={right.rows}
scroll={{ x: 'max-content' }}
locale={{ emptyText: debouncedQ ? 'По поиску строк нет' : 'Строк нет' }}
pagination={{
current: right.page,
pageSize: OSV_PAGE_SIZE,
total: right.total,
showSizeChanger: false,
onChange: right.setPage,
hideOnSinglePage: true,
showTotal: (t) => `Строк: ${t}`,
}}
/>
</>
)}
</div>
{mapping && (
<CounterpartyMapModal
key={mapEntryId(mapping)}
versionId={versionId}
row={mapping}
onClose={() => setMapping(null)}
onMapped={mapped}
/>
)}
</div>
);
}

View File

@ -11,7 +11,7 @@ import type { OsvFilterDraft } from './osvFilterState';
type MoneyKey = 'opening_debit' | 'opening_credit' | 'turnover_debit' | 'turnover_credit' | 'closing_debit' | 'closing_credit';
const money = (key: MoneyKey, title: string, format: (raw: string | null) => string) => ({
export const money = (key: MoneyKey, title: string, format: (raw: string | null) => string) => ({
key,
title,
dataIndex: key,
@ -20,7 +20,7 @@ const money = (key: MoneyKey, title: string, format: (raw: string | null) => str
render: (v: string | null) => format(v),
});
const group = (title: string, children: TableColumnsType<ConsolidatedRowOut>): TableColumnsType<ConsolidatedRowOut>[number] => ({
export const group = (title: string, children: TableColumnsType<ConsolidatedRowOut>): TableColumnsType<ConsolidatedRowOut>[number] => ({
title,
align: 'center',
children,

View File

@ -1,15 +1,17 @@
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
import { Alert, Button, Select, message } from 'antd';
import { Alert, Button, Segmented, Select, message } from 'antd';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, useSearchParams } from 'react-router-dom';
import { ApiError } from '../../api/client';
import { exportOsv } from '../../services/api/osv';
import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api';
import type { VersionCard } from '../../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../../utils/download';
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 { mapEntryId } from '../ConsolidatedOsv/counterpartyMapping';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../ConsolidatedOsv/useConsolidatedOsv';
import { PRECISIONS, UNITS, useNumberDisplay } from '../ConsolidatedOsv/useNumberDisplay';
@ -45,9 +47,22 @@ 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;
// выбранную пару закрыли сопоставлением — в адресе первая оставшаяся
useEffect(() => {
if (!inPairs || !s) return;
if (selectedPair && (selectedPair.holder_id !== pairsView.holder || selectedPair.missing_id !== pairsView.missing)) {
setView({ view: 'pairs', holder: selectedPair.holder_id, missing: selectedPair.missing_id });
}
});
useEffect(() => {
onSummaryChange?.(s);
}, [s, onSummaryChange]);
@ -88,8 +103,19 @@ export function OsvContent({ card, onSummaryChange }: Props) {
Экспорт
</Button>
</div>
<Segmented
value={pairsView.view}
onChange={(v) =>
setView(v === 'pairs' ? { view: 'pairs', holder: null, missing: null } : { view: 'table', holder: null, missing: null })
}
options={[
{ label: 'Свод', value: 'table' },
{ label: `Встречные записи (${pairs.length})`, value: 'pairs' },
]}
style={{ alignSelf: 'flex-start' }}
/>
{osv.error && <Alert type='error' showIcon title={osv.error} />}
{unmapped > 0 && (
{!inPairs && unmapped > 0 && (
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
<div
style={{
@ -117,20 +143,35 @@ export function OsvContent({ card, onSummaryChange }: Props) {
</Button>
</div>
)}
<OsvGaps gaps={s?.gaps ?? []} />
<OsvTable
rows={osv.rows}
total={osv.total}
page={osv.page}
pageSize={OSV_PAGE_SIZE}
loading={osv.loading}
filters={osv.filters}
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
onPage={osv.setPage}
numFormat={format}
onFilter={osv.patchFilters}
onMap={setMapping}
/>
{inPairs ? (
s && (
<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 ?? []} />
<OsvTable
rows={osv.rows}
total={osv.total}
page={osv.page}
pageSize={OSV_PAGE_SIZE}
loading={osv.loading}
filters={osv.filters}
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
onPage={osv.setPage}
numFormat={format}
onFilter={osv.patchFilters}
onMap={setMapping}
/>
</>
)}
</main>
{mapping && version_id && (
<CounterpartyMapModal