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 (
+
+
+
+
+
+
+
+
+ } loading={exporting} onClick={download}>
+ Скачать xlsx
+
+
+ {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 (
+
+
+
+
+
+
+
+
+
+
+
+ Только несопоставленные
+
+
+
+
+
+
+ );
+}
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 };
+}