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';
|
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,
|
key,
|
||||||
title,
|
title,
|
||||||
dataIndex: key,
|
dataIndex: key,
|
||||||
@ -20,7 +20,7 @@ const money = (key: MoneyKey, title: string, format: (raw: string | null) => str
|
|||||||
render: (v: string | null) => format(v),
|
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,
|
title,
|
||||||
align: 'center',
|
align: 'center',
|
||||||
children,
|
children,
|
||||||
|
|||||||
@ -1,15 +1,17 @@
|
|||||||
import { DownloadOutlined, FilterOutlined } from '@ant-design/icons';
|
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 { useEffect, useState } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams, useSearchParams } from 'react-router-dom';
|
||||||
import { ApiError } from '../../api/client';
|
import { ApiError } from '../../api/client';
|
||||||
import { exportOsv } from '../../services/api/osv';
|
import { exportOsv } from '../../services/api/osv';
|
||||||
import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api';
|
import type { ConsolidatedRowOut, OsvSummaryOut } from '../../types/api';
|
||||||
import type { VersionCard } from '../../types/consolidations/version_members';
|
import type { VersionCard } from '../../types/consolidations/version_members';
|
||||||
import { exportFilename, saveBlob } from '../../utils/download';
|
import { exportFilename, saveBlob } from '../../utils/download';
|
||||||
|
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 { 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';
|
||||||
@ -45,9 +47,22 @@ export function OsvContent({ card, onSummaryChange }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const s = osv.summary;
|
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 unmapped = s?.unmapped_total ?? 0;
|
||||||
const onlyUnmapped = osv.filters.unmapped === true;
|
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(() => {
|
useEffect(() => {
|
||||||
onSummaryChange?.(s);
|
onSummaryChange?.(s);
|
||||||
}, [s, onSummaryChange]);
|
}, [s, onSummaryChange]);
|
||||||
@ -88,8 +103,19 @@ export function OsvContent({ card, onSummaryChange }: Props) {
|
|||||||
Экспорт
|
Экспорт
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</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} />}
|
{osv.error && <Alert type='error' showIcon title={osv.error} />}
|
||||||
{unmapped > 0 && (
|
{!inPairs && unmapped > 0 && (
|
||||||
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
|
// `osv-mapping-alert` макета [30] / [32-osv-no-inn]
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@ -117,20 +143,35 @@ export function OsvContent({ card, onSummaryChange }: Props) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<OsvGaps gaps={s?.gaps ?? []} />
|
{inPairs ? (
|
||||||
<OsvTable
|
s && (
|
||||||
rows={osv.rows}
|
<CounterPairsView
|
||||||
total={osv.total}
|
versionId={version_id ?? ''}
|
||||||
page={osv.page}
|
pairs={pairs}
|
||||||
pageSize={OSV_PAGE_SIZE}
|
selected={selectedPair}
|
||||||
loading={osv.loading}
|
numFormat={format}
|
||||||
filters={osv.filters}
|
onSelect={(holder, missing) => setView({ view: 'pairs', holder, missing })}
|
||||||
members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))}
|
onMapped={osv.refresh}
|
||||||
onPage={osv.setPage}
|
/>
|
||||||
numFormat={format}
|
)
|
||||||
onFilter={osv.patchFilters}
|
) : (
|
||||||
onMap={setMapping}
|
<>
|
||||||
/>
|
<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>
|
</main>
|
||||||
{mapping && version_id && (
|
{mapping && version_id && (
|
||||||
<CounterpartyMapModal
|
<CounterpartyMapModal
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user