diff --git a/src/App.tsx b/src/App.tsx index 5e89264..2adc581 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -9,6 +9,7 @@ import { AuditLogging, CatalogueGroup, CatalogueJur, + ConsolidatedOsvPage, ConsolidationPage, DocStorage, DocumentPage, @@ -34,6 +35,7 @@ function App() { } /> } /> + } /> } /> } /> diff --git a/src/layouts/ConsolidatedOsvPage.tsx b/src/layouts/ConsolidatedOsvPage.tsx new file mode 100644 index 0000000..ac31eb4 --- /dev/null +++ b/src/layouts/ConsolidatedOsvPage.tsx @@ -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(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 ( +
+ +
+ + + + + + + + {osv.error && } + + ({ id: m.id, name: m.name }))} onApply={osv.applyFilters} /> + +
+
+ ); +} + +export function ConsolidatedOsvPage() { + const { version_id = '' } = useParams(); + // key: при смене версии всё состояние (фильтры, страница, данные) создаётся заново + return ; +} diff --git a/src/layouts/index.ts b/src/layouts/index.ts index 26f05ce..b006199 100644 --- a/src/layouts/index.ts +++ b/src/layouts/index.ts @@ -9,3 +9,4 @@ export { GroupsLayout } from './Groups'; export { AuditLogging } from './AuditLogging'; export { Admin } from './Admin'; export { DocumentPage } from './DocumentPage'; +export { ConsolidatedOsvPage } from './ConsolidatedOsvPage'; diff --git a/src/utils/download.ts b/src/utils/download.ts index 4a4f906..e63f8d4 100644 --- a/src/utils/download.ts +++ b/src/utils/download.ts @@ -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); } diff --git a/src/utils/labels.test.ts b/src/utils/labels.test.ts index 7d24bbe..fac10ad 100644 --- a/src/utils/labels.test.ts +++ b/src/utils/labels.test.ts @@ -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('не сопоставлен'); + }); +}); diff --git a/src/utils/labels.ts b/src/utils/labels.ts index aa1879c..84655d9 100644 --- a/src/utils/labels.ts +++ b/src/utils/labels.ts @@ -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 = { BALANCE: 'Бух. баланс', @@ -47,3 +47,15 @@ export function fileStatusLabel(status: FileStatus): string { return 'Неизвестный статус'; } } + +const COUNTERPARTY_TYPE: Record = { GSK: 'ГСК', GK: 'ГК', EXTERNAL: 'Внешний' }; +const MATCH_KIND: Record = { + 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) : 'не сопоставлен'); diff --git a/src/widgets/ConsolidatedOsv/OsvFilters.tsx b/src/widgets/ConsolidatedOsv/OsvFilters.tsx new file mode 100644 index 0000000..89f6c2d --- /dev/null +++ b/src/widgets/ConsolidatedOsv/OsvFilters.tsx @@ -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(); + return ( +
onApply({ ...v, account_root: v.account_root?.trim(), q: v.q?.trim().slice(0, 500) })}> + + + + + + + + Только несопоставленные + + + + + +
+ ); +} diff --git a/src/widgets/ConsolidatedOsv/OsvGaps.tsx b/src/widgets/ConsolidatedOsv/OsvGaps.tsx new file mode 100644 index 0000000..2b131b2 --- /dev/null +++ b/src/widgets/ConsolidatedOsv/OsvGaps.tsx @@ -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 ( + ( + + {g.legal_entity_name} — счёт {g.account_code} — нет кварталов: {g.missing_quarters.join(', ')} + + )} + /> + ), + }, + ]} + /> + ); +} diff --git a/src/widgets/ConsolidatedOsv/OsvTable.tsx b/src/widgets/ConsolidatedOsv/OsvTable.tsx new file mode 100644 index 0000000..d6f2e31 --- /dev/null +++ b/src/widgets/ConsolidatedOsv/OsvTable.tsx @@ -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 = [ + { 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 ( + + 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}` }} + /> + ); +} diff --git a/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts b/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts new file mode 100644 index 0000000..93141e2 --- /dev/null +++ b/src/widgets/ConsolidatedOsv/useConsolidatedOsv.ts @@ -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({}); + const [page, setPage] = useState(1); + const [rows, setRows] = useState([]); + const [total, setTotal] = useState(0); + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(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 }; +}