From 6db9fa2ae17eceac716e04d6862bed7897cb5bea Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 19:00:52 +0400 Subject: [PATCH] =?UTF-8?q?feat(#195):=20=D1=80=D0=B5=D0=B6=D0=B8=D0=BC=20?= =?UTF-8?q?=C2=AB=D0=92=D1=81=D1=82=D1=80=D0=B5=D1=87=D0=BD=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B8=C2=BB=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D0=B2=D0=BA=D0=BB=D0=B0=D0=B4=D0=BA=D0=B5=20=C2=AB=D0=9E=D0=A1?= =?UTF-8?q?=D0=92=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ConsolidatedOsv/CounterPairsView.tsx | 244 ++++++++++++++++++ web/src/widgets/ConsolidatedOsv/OsvTable.tsx | 4 +- .../ConsolidatedOsvContent.tsx | 75 ++++-- 3 files changed, 304 insertions(+), 19 deletions(-) create mode 100644 web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx diff --git a/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx new file mode 100644 index 0000000..e61b328 --- /dev/null +++ b/web/src/widgets/ConsolidatedOsv/CounterPairsView.tsx @@ -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([]); + const [total, setTotal] = useState(0); + const [page, setPage] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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(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 = [ + { 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 = [ + ...baseColumns, + { + key: 'map', + fixed: 'right', + title: 'ИНН', + align: 'center', + width: 100, + render: (_: unknown, r) => ( + + ), + }, + ]; + + const mapped = useCallback(() => { + setReloadKey((k) => k + 1); + onMapped(); + }, [onMapped]); + + if (!selected) { + return
Все участники периметра видят друг друга
; + } + + let rightEmpty: string | null = null; + if (selected.missing_rows_total === 0) { + rightEmpty = `У ${selected.missing_name} в своде нет строк: ОСВ не привязаны или по ним пробелы`; + } else if (selected.missing_unmapped_rows === 0) { + rightEmpty = 'Строк без ИНН нет: возможно, ИНН контрагента из файла не найден в справочнике или это пропуск в учёте'; + } + + return ( +
+
+ {pairs.map((p) => { + const active = p.holder_id === selected.holder_id && p.missing_id === selected.missing_id; + return ( + + ); + })} +
+
+
+ У {selected.holder_name} есть записи с {selected.missing_name} +
+ {center.error && } + + 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}`, + }} + /> +
+
+
У {selected.missing_name} — строки без ИНН и без сопоставления
+ {rightEmpty ? ( +
{rightEmpty}
+ ) : ( + <> + setQ(e.target.value)} + /> + {right.error && } + + 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}`, + }} + /> + + )} +
+ {mapping && ( + setMapping(null)} + onMapped={mapped} + /> + )} +
+ ); +} diff --git a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx index 6d4fe5a..1b5b4d8 100644 --- a/web/src/widgets/ConsolidatedOsv/OsvTable.tsx +++ b/web/src/widgets/ConsolidatedOsv/OsvTable.tsx @@ -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): TableColumnsType[number] => ({ +export const group = (title: string, children: TableColumnsType): TableColumnsType[number] => ({ title, align: 'center', children, diff --git a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx index a5d2460..9888527 100644 --- a/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx +++ b/web/src/widgets/ConsolidatedOsvContent/ConsolidatedOsvContent.tsx @@ -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[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) { Экспорт + + 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 && } - {unmapped > 0 && ( + {!inPairs && unmapped > 0 && ( // `osv-mapping-alert` макета [30] / [32-osv-no-inn]
)} - - ({ id: m.id, name: m.name }))} - onPage={osv.setPage} - numFormat={format} - onFilter={osv.patchFilters} - onMap={setMapping} - /> + {inPairs ? ( + s && ( + setView({ view: 'pairs', holder, missing })} + onMapped={osv.refresh} + /> + ) + ) : ( + <> + + ({ id: m.id, name: m.name }))} + onPage={osv.setPage} + numFormat={format} + onFilter={osv.patchFilters} + onMap={setMapping} + /> + + )} {mapping && version_id && (