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:
parent
65c3dc2de7
commit
2c7bdf19b0
@ -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 />} />
|
||||
|
||||
99
src/layouts/ConsolidatedOsvPage.tsx
Normal file
99
src/layouts/ConsolidatedOsvPage.tsx
Normal 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} />;
|
||||
}
|
||||
@ -9,3 +9,4 @@ export { GroupsLayout } from './Groups';
|
||||
export { AuditLogging } from './AuditLogging';
|
||||
export { Admin } from './Admin';
|
||||
export { DocumentPage } from './DocumentPage';
|
||||
export { ConsolidatedOsvPage } from './ConsolidatedOsvPage';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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('не сопоставлен');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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) : 'не сопоставлен');
|
||||
|
||||
54
src/widgets/ConsolidatedOsv/OsvFilters.tsx
Normal file
54
src/widgets/ConsolidatedOsv/OsvFilters.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
27
src/widgets/ConsolidatedOsv/OsvGaps.tsx
Normal file
27
src/widgets/ConsolidatedOsv/OsvGaps.tsx
Normal 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>
|
||||
)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
63
src/widgets/ConsolidatedOsv/OsvTable.tsx
Normal file
63
src/widgets/ConsolidatedOsv/OsvTable.tsx
Normal 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}` }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
75
src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts
Normal file
75
src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts
Normal 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 };
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user