feat: экран сводной ОСВ — сводка, пробелы, фильтры, экспорт xlsx (#3)

Заодно: отложенный revokeObjectURL в saveBlob (ревью Task 3).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
This commit is contained in:
Andrey Epifancev 2026-10-08 01:30:32 +04:00
parent 65c3dc2de7
commit 2c7bdf19b0
10 changed files with 353 additions and 3 deletions

View File

@ -9,6 +9,7 @@ import {
AuditLogging,
CatalogueGroup,
CatalogueJur,
ConsolidatedOsvPage,
ConsolidationPage,
DocStorage,
DocumentPage,
@ -34,6 +35,7 @@ function App() {
<Route path='/groups'>
<Route index element={<GroupsLayout />} />
<Route path=':group_id' element={<ConsolidationPage />} />
<Route path=':group_id/versions/:version_id/osv' element={<ConsolidatedOsvPage />} />
<Route path='monozaemchiki' element={<Mono />} />
</Route>
<Route path='/catalogue/groups' element={<CatalogueGroup />} />

View File

@ -0,0 +1,99 @@
import { DownloadOutlined } from '@ant-design/icons';
import { Alert, Button, Space, Statistic, message } from 'antd';
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ApiError } from '../api/client';
import PageTitle from '../components/PageTitle';
import { getVersionCard } from '../services/api/consolidations';
import { exportOsv } from '../services/api/osv';
import type { VersionCard } from '../types/consolidations/version_members';
import { exportFilename, saveBlob } from '../utils/download';
import { statusMapping } from '../widgets/ConsoleTable/consts';
import { OsvFilters } from '../widgets/ConsolidatedOsv/OsvFilters';
import { OsvGaps } from '../widgets/ConsolidatedOsv/OsvGaps';
import { OsvTable } from '../widgets/ConsolidatedOsv/OsvTable';
import { OSV_PAGE_SIZE, useConsolidatedOsv } from '../widgets/ConsolidatedOsv/useConsolidatedOsv';
function OsvContent({ versionId }: { versionId: string }) {
const [card, setCard] = useState<VersionCard | null>(null);
const [exporting, setExporting] = useState(false);
const osv = useConsolidatedOsv(versionId);
useEffect(() => {
let actual = true;
getVersionCard(versionId)
.then((c) => {
if (actual) setCard(c);
})
.catch((e: unknown) => {
if (actual) message.error(e instanceof ApiError ? e.message : 'Не удалось получить версию');
});
return () => {
actual = false;
};
}, [versionId]);
const download = async () => {
setExporting(true);
try {
const { blob, filename } = await exportOsv(versionId, osv.filters);
saveBlob(blob, filename ?? exportFilename(card?.company_group.name ?? 'ГК', card?.year ?? 0, card?.quarter ?? ''));
} catch (e: unknown) {
message.error(e instanceof ApiError ? e.message : 'Не удалось выгрузить свод');
} finally {
setExporting(false);
}
};
const s = osv.summary;
const filtered = Object.values(osv.filters).some((v) => v !== undefined && v !== '' && v !== false);
return (
<div style={{ padding: 12, display: 'flex', flexDirection: 'column', height: '100vh', gap: 12 }}>
<PageTitle
backable
location={`Отчётность › ${card?.company_group.name ?? ''} › Сводная ОСВ`}
header={card ? `Сводная ОСВ ${card.year} ${card.quarter} · версия ${card.version_no}` : 'Сводная ОСВ'}
description={card ? (statusMapping[card.status] ?? card.status) : ''}
/>
<main
style={{
flex: '1 1 auto',
minHeight: 0,
background: 'white',
borderRadius: 12,
padding: 12,
display: 'flex',
flexDirection: 'column',
gap: 12,
}}>
<Space size={32} wrap>
<Statistic title='Строк' value={s?.rows_total ?? '—'} />
<Statistic title='Документов' value={s?.documents_total ?? '—'} />
<Statistic title='Осталось сопоставить' value={s?.unmapped_total ?? '—'} />
<Statistic title='Сопоставлено автоматически' value={s?.auto_matched_total ?? '—'} />
<Button icon={<DownloadOutlined />} loading={exporting} onClick={download}>
Скачать xlsx
</Button>
</Space>
{osv.error && <Alert type='error' showIcon message={osv.error} />}
<OsvGaps gaps={s?.gaps ?? []} />
<OsvFilters members={(card?.members ?? []).map((m) => ({ id: m.id, name: m.name }))} onApply={osv.applyFilters} />
<OsvTable
rows={osv.rows}
total={osv.total}
page={osv.page}
pageSize={OSV_PAGE_SIZE}
loading={osv.loading}
filtered={filtered}
onPage={osv.setPage}
/>
</main>
</div>
);
}
export function ConsolidatedOsvPage() {
const { version_id = '' } = useParams();
// key: при смене версии всё состояние (фильтры, страница, данные) создаётся заново
return <OsvContent key={version_id} versionId={version_id} />;
}

View File

@ -9,3 +9,4 @@ export { GroupsLayout } from './Groups';
export { AuditLogging } from './AuditLogging';
export { Admin } from './Admin';
export { DocumentPage } from './DocumentPage';
export { ConsolidatedOsvPage } from './ConsolidatedOsvPage';

View File

@ -30,5 +30,6 @@ export function saveBlob(blob: Blob, filename: string): void {
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
// не сразу: Safari/Firefox иначе могут оборвать скачивание
setTimeout(() => URL.revokeObjectURL(url), 1000);
}

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { documentStatusLabel, documentTypeLabel, fileStatusLabel, periodLabel } from './labels';
import { counterpartyTypeLabel, documentStatusLabel, documentTypeLabel, fileStatusLabel, matchKindLabel, periodLabel } from './labels';
describe('labels', () => {
it('тип документа', () => {
@ -29,3 +29,19 @@ describe('labels', () => {
expect(fileStatusLabel('SUPERSEDED')).toBe('Заменён');
});
});
describe('подписи свода', () => {
it('признак контрагента', () => {
expect(counterpartyTypeLabel('GSK')).toBe('ГСК');
expect(counterpartyTypeLabel('GK')).toBe('ГК');
expect(counterpartyTypeLabel('EXTERNAL')).toBe('Внешний');
});
it('способ сопоставления', () => {
expect(matchKindLabel('INN')).toBe('по ИНН');
expect(matchKindLabel('NAME')).toBe('по имени');
expect(matchKindLabel('ALIAS')).toBe('по алиасу');
expect(matchKindLabel('MANUAL')).toBe('вручную');
expect(matchKindLabel('PROPAGATED')).toBe('автоприменение');
expect(matchKindLabel(null)).toBe('не сопоставлен');
});
});

View File

@ -1,4 +1,4 @@
import type { DocumentStatus, DocumentType, FileStatus, Quarter } from '../types/api';
import type { CounterpartyType, DocumentStatus, DocumentType, FileStatus, MatchKind, Quarter } from '../types/api';
const TYPE_LABELS: Record<DocumentType, string> = {
BALANCE: 'Бух. баланс',
@ -47,3 +47,15 @@ export function fileStatusLabel(status: FileStatus): string {
return 'Неизвестный статус';
}
}
const COUNTERPARTY_TYPE: Record<CounterpartyType, string> = { GSK: 'ГСК', GK: 'ГК', EXTERNAL: 'Внешний' };
const MATCH_KIND: Record<MatchKind, string> = {
INN: 'по ИНН',
NAME: 'по имени',
ALIAS: 'по алиасу',
MANUAL: 'вручную',
PROPAGATED: 'автоприменение',
};
export const counterpartyTypeLabel = (t: CounterpartyType): string => COUNTERPARTY_TYPE[t] ?? t;
export const matchKindLabel = (k: MatchKind | null): string => (k ? (MATCH_KIND[k] ?? k) : 'не сопоставлен');

View File

@ -0,0 +1,54 @@
import { Button, Checkbox, Form, Input, Select, Space } from 'antd';
import type { OsvFilters as Filters } from '../../types/api';
import { counterpartyTypeLabel } from '../../utils/labels';
type Props = { members: { id: string; name: string }[]; onApply: (f: Filters) => void };
export function OsvFilters({ members, onApply }: Props) {
const [form] = Form.useForm<Filters>();
return (
<Form
form={form}
layout='inline'
onFinish={(v) => onApply({ ...v, account_root: v.account_root?.trim(), q: v.q?.trim().slice(0, 500) })}>
<Form.Item name='legal_entity_id'>
<Select
allowClear
showSearch={{ optionFilterProp: 'label' }}
placeholder='Организация'
style={{ width: 240 }}
options={members.map((m) => ({ value: m.id, label: m.name }))}
/>
</Form.Item>
<Form.Item name='account_root'>
<Input allowClear placeholder='Счёт (корень), напр. 60' style={{ width: 180 }} />
</Form.Item>
<Form.Item name='counterparty_type'>
<Select
allowClear
placeholder='Признак контрагента'
style={{ width: 200 }}
options={(['GSK', 'GK', 'EXTERNAL'] as const).map((t) => ({ value: t, label: counterpartyTypeLabel(t) }))}
/>
</Form.Item>
<Form.Item name='q'>
<Input allowClear placeholder='Контрагент или ИНН' maxLength={500} style={{ width: 220 }} />
</Form.Item>
<Form.Item name='unmapped' valuePropName='checked'>
<Checkbox>Только несопоставленные</Checkbox>
</Form.Item>
<Space>
<Button type='primary' htmlType='submit'>
Применить
</Button>
<Button
onClick={() => {
form.resetFields();
onApply({});
}}>
Сбросить
</Button>
</Space>
</Form>
);
}

View File

@ -0,0 +1,27 @@
import { Collapse, List } from 'antd';
import type { GapOut } from '../../types/api';
export function OsvGaps({ gaps }: { gaps: GapOut[] }) {
if (gaps.length === 0) return null;
return (
<Collapse
items={[
{
key: 'gaps',
label: `Пробелы: ${gaps.length} — по этим счетам нет ОСВ за часть кварталов, их строк в своде нет`,
children: (
<List
size='small'
dataSource={gaps}
renderItem={(g) => (
<List.Item key={`${g.legal_entity_id}-${g.account_code}`}>
{g.legal_entity_name} — счёт {g.account_code} — нет кварталов: {g.missing_quarters.join(', ')}
</List.Item>
)}
/>
),
},
]}
/>
);
}

View File

@ -0,0 +1,63 @@
import { Table } from 'antd';
import type { TableColumnsType } from 'antd';
import type { ConsolidatedRowOut } from '../../types/api';
import { counterpartyTypeLabel, matchKindLabel } from '../../utils/labels';
import { formatAmount } from '../../utils/money';
type MoneyKey = 'opening_debit' | 'opening_credit' | 'turnover_debit' | 'turnover_credit' | 'closing_debit' | 'closing_credit';
const money = (key: MoneyKey, title: string) => ({
key,
title,
dataIndex: key,
align: 'right' as const,
width: 150,
render: (v: string | null) => formatAmount(v),
});
const columns: TableColumnsType<ConsolidatedRowOut> = [
{ key: 'legal_entity_name', title: 'Организация', dataIndex: 'legal_entity_name', width: 220, fixed: 'left' },
{ key: 'account_code', title: 'Счёт', dataIndex: 'account_code', width: 90 },
{
key: 'counterparty',
title: 'Контрагент',
width: 260,
render: (_: unknown, r) => r.counterparty_entity_name ?? r.counterparty_name ?? '—',
},
{ key: 'counterparty_inn', title: 'ИНН контрагента', dataIndex: 'counterparty_inn', width: 130 },
{ key: 'counterparty_type', title: 'Признак', dataIndex: 'counterparty_type', width: 100, render: counterpartyTypeLabel },
{ key: 'match_kind', title: 'Сопоставление', dataIndex: 'match_kind', width: 140, render: matchKindLabel },
money('opening_debit', 'Сальдо нач. Дт'),
money('opening_credit', 'Сальдо нач. Кт'),
money('turnover_debit', 'Обороты Дт'),
money('turnover_credit', 'Обороты Кт'),
money('closing_debit', 'Сальдо кон. Дт'),
money('closing_credit', 'Сальдо кон. Кт'),
];
type Props = {
rows: ConsolidatedRowOut[];
total: number;
page: number;
pageSize: number;
loading: boolean;
filtered: boolean;
onPage: (p: number) => void;
};
export function OsvTable({ rows, total, page, pageSize, loading, filtered, onPage }: Props) {
return (
<Table<ConsolidatedRowOut>
size='small'
rowKey={(r) => r.entry_ids.join(',')}
loading={loading}
columns={columns}
dataSource={rows}
locale={{
emptyText: filtered ? 'По заданным фильтрам строк нет' : 'В своде пока нет строк: загрузите и подтвердите ОСВ организаций',
}}
scroll={{ x: 1900, y: 'calc(100vh - 420px)' }}
pagination={{ current: page, pageSize, total, showSizeChanger: false, onChange: onPage, showTotal: (t) => `Строк: ${t}` }}
/>
);
}

View File

@ -0,0 +1,75 @@
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';
export const OSV_PAGE_SIZE = 500;
/** Понятный русский текст ошибки загрузки свода. */
export function osvErrorText(e: unknown): string {
if (e instanceof ApiError) {
if (e.status === 404) return 'Версия не найдена или недоступна';
if (e.status === 409) return `Сводная ОСВ сейчас недоступна: ${e.message}`;
return e.message;
}
return 'Не удалось получить сводную ОСВ';
}
export function useConsolidatedOsv(versionId: string) {
const [filters, setFilters] = useState<OsvFilters>({});
const [page, setPage] = useState(1);
const [rows, setRows] = useState<ConsolidatedRowOut[]>([]);
const [total, setTotal] = useState(0);
const [summary, setSummary] = useState<OsvSummaryOut | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
// номер последнего запроса: ответы более старых запросов игнорируются
const requestRef = useRef(0);
useEffect(() => {
let actual = true;
getOsvSummary(versionId)
.then((s) => {
if (actual) setSummary(s);
})
.catch((e: unknown) => {
if (actual) message.error(osvErrorText(e));
});
return () => {
actual = false;
};
}, [versionId]);
const load = useCallback(async () => {
const requestId = ++requestRef.current;
setLoading(true);
try {
const result = await getConsolidatedOsv(versionId, filters, OSV_PAGE_SIZE, (page - 1) * OSV_PAGE_SIZE);
if (requestId === requestRef.current) {
setRows(result.items);
setTotal(result.total);
setError(null);
}
} catch (e: unknown) {
if (requestId === requestRef.current) {
setRows([]);
setTotal(0);
setError(osvErrorText(e));
}
} finally {
if (requestId === requestRef.current) setLoading(false);
}
}, [versionId, filters, page]);
useEffect(() => {
void load();
}, [load]);
const applyFilters = (next: OsvFilters) => {
setFilters(next);
setPage(1);
};
return { filters, applyFilters, page, setPage, rows, total, summary, loading, error };
}