feat(#195): режим «Встречные записи» на вкладке «ОСВ»
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
8194f47d1a
commit
6db9fa2ae1
244
web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx
Normal file
244
web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user