Демо-прототип: от создания консолидации до сводной ОСВ #4

Merged
aep merged 29 commits from feat/3-demo-prototype into test 2026-10-08 09:58:39 +03:00
17 changed files with 504 additions and 93 deletions
Showing only changes of commit 4424a2b8e8 - Show all commits

View File

@ -1,23 +1,8 @@
import { api } from '../../api/client';
import type { ConsolidationCreate } from '../../types/api';
import type { IConsolidation } from '../../types/consolidations/consolidations';
import type { IVersionCard } from '../../types/consolidations/version_members';
import type { VersionCard } from '../../types/consolidations/version_members';
export function getConsolidations(company_group_id: string) {
return api.get<IConsolidation[]>(`/company-groups/${company_group_id}/consolidations`);
}
export function getVersionData(version_id: string) {
return api.get<IVersionCard>(`/consolidations/versions/${version_id}`);
}
type FormValues = {
company_group_id: string;
related_group_id: string;
year: number;
quarter: string; // example of 2nd static field
legal_entity_ids: string[];
};
export function createConsolidation(values: FormValues) {
return api.post<unknown>('/consolidations', values);
}
export const getConsolidations = (groupId: string) => api.get<IConsolidation[]>(`/company-groups/${groupId}/consolidations`);
export const getVersionCard = (versionId: string) => api.get<VersionCard>(`/consolidations/versions/${versionId}`);
export const createConsolidation = (body: ConsolidationCreate) => api.post<VersionCard>('/consolidations', body);

View File

@ -1,21 +1,24 @@
import { api } from '../../../api/client';
import type { LegalEntityBrief, Page, RelatedGroupDetail, RelatedGroupOut } from '../../../types/api';
import type { ICompanyGroup } from '../../../types/companyGroups/companyGroups';
import type { ILegalEntityBrief, INamedItem, IPage } from '../../../types/legal_entities';
export function fetchCompanyGroups() {
return api.get<IPage<ICompanyGroup> & { total: number }>('/directories/company-groups');
}
const LE_PAGE = 200;
export function fetchOneCompanyGroup(com_group_id: string) {
return api.get<ICompanyGroup>(`/directories/company-groups/${com_group_id}`);
}
export const fetchCompanyGroups = () => api.get<ICompanyGroup[]>('/directories/company-groups');
export const fetchOneCompanyGroup = (id: string) => api.get<ICompanyGroup>(`/directories/company-groups/${id}`);
export const fetchRelatedGroups = (groupId: string) => api.get<RelatedGroupOut[]>(`/directories/company-groups/${groupId}/related-groups`);
export const fetchRelatedGroup = (id: string) => api.get<RelatedGroupDetail>(`/directories/related-groups/${id}`);
export function fetchGSKByComGroup(com_group_id: string) {
return api.get<INamedItem[]>(`/directories/company-groups/${com_group_id}/related-groups`);
}
// legal-entities?company_group_id=<ГК>&closed=false
export async function fetchGroupMembers(company_group_id: string) {
const res = await api.get<IPage<ILegalEntityBrief>>(`/directories/legal-entities?company_group_id=${company_group_id}&closed=false`);
return res?.items ?? [];
/** Все незакрытые ЮЛ ГК — дочитываем страницы, пока не получим total. */
export async function fetchAllLegalEntities(groupId: string): Promise<LegalEntityBrief[]> {
const all: LegalEntityBrief[] = [];
for (let offset = 0; ; offset += LE_PAGE) {
const page = await api.get<Page<LegalEntityBrief>>(
`/directories/legal-entities?company_group_id=${groupId}&closed=false&limit=${LE_PAGE}&offset=${offset}`,
);
all.push(...page.items);
if (all.length >= page.total || page.items.length === 0) {
return all;
}
}
}

View File

@ -0,0 +1,23 @@
import { api } from '../../api/client';
import type { DocumentOut, EntryOut, Page, StatementOut, StatementPatch } from '../../types/api';
const ENTRIES_PAGE = 2000;
export const getDocument = (id: string) => api.get<DocumentOut>(`/documents/${id}`);
export const confirmDocument = (id: string) => api.post<DocumentOut>(`/documents/${id}/confirm`);
export const getStatement = (id: string) => api.get<StatementOut>(`/documents/${id}/statement`);
export const patchStatement = (id: string, body: StatementPatch) => api.patch<StatementOut>(`/documents/${id}/statement`, body);
export const acceptChecks = (id: string, comment: string) =>
api.post<StatementOut>(`/documents/${id}/statement/checks/accept`, { comment });
/** Все строки ОСВ-документа (страницами по 2000). */
export async function getAllEntries(id: string): Promise<EntryOut[]> {
const all: EntryOut[] = [];
for (let offset = 0; ; offset += ENTRIES_PAGE) {
const page = await api.get<Page<EntryOut>>(`/documents/${id}/entries?limit=${ENTRIES_PAGE}&offset=${offset}`);
all.push(...page.items);
if (all.length >= page.total || page.items.length === 0) {
return all;
}
}
}

View File

@ -0,0 +1,86 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ApiError } from '../../api/client';
import type { FileOut, UploadResult } from '../../types/api';
import { hasPendingFiles, isUploadResult, summarizeUpload, uploadFiles } from './files';
const result: UploadResult = {
accepted: [{ file_id: 'f1', job_id: 'j1', name: 'osv.xlsx' }],
rejected: [{ name: 'dup.xlsx', code: 'DUPLICATE_FILE', message: 'Файл уже загружен', file_id: 'f0' }],
};
describe('isUploadResult', () => {
it('отличает UploadResult от detail-ошибки', () => {
expect(isUploadResult(result)).toBe(true);
expect(isUploadResult({ detail: { code: 'ENTITY_NOT_IN_PERIMETER', message: 'x' } })).toBe(false);
expect(isUploadResult(null)).toBe(false);
});
});
describe('summarizeUpload', () => {
it('имена и причины', () => {
expect(summarizeUpload(result)).toEqual({
accepted: ['osv.xlsx'],
rejected: [{ name: 'dup.xlsx', reason: 'Файл уже загружен' }],
});
});
});
describe('hasPendingFiles', () => {
const file = (status: FileOut['status']): FileOut => ({
id: status,
legal_entity_id: 'le',
original_name: null,
file_type: 'PDF',
status,
version_no: 1,
size_bytes: null,
uploaded_at: null,
job: null,
documents: [],
});
it('UPLOADED/PROCESSING — ждём', () => {
expect(hasPendingFiles([file('PROCESSED'), file('PROCESSING')])).toBe(true);
expect(hasPendingFiles([file('UPLOADED')])).toBe(true);
});
it('остальные — нет', () => {
expect(hasPendingFiles([file('PROCESSED'), file('ERROR')])).toBe(false);
expect(hasPendingFiles([])).toBe(false);
});
});
describe('uploadFiles', () => {
afterEach(() => vi.restoreAllMocks());
it('422 с UploadResult возвращается как результат', async () => {
const client = await import('../../api/client');
vi.spyOn(client, 'apiFetch').mockRejectedValue(new ApiError(422, undefined, 'x', { accepted: [], rejected: result.rejected }));
await expect(uploadFiles('le', [new File(['a'], 'dup.xlsx')], null)).resolves.toEqual({ accepted: [], rejected: result.rejected });
});
it('422 с detail пробрасывается', async () => {
const client = await import('../../api/client');
const error = new ApiError(422, 'ENTITY_NOT_IN_PERIMETER', 'ЮЛ вне периметра', { detail: { code: 'ENTITY_NOT_IN_PERIMETER' } });
vi.spyOn(client, 'apiFetch').mockRejectedValue(error);
await expect(uploadFiles('le', [new File(['a'], 'a.xlsx')], 'v1')).rejects.toBe(error);
});
it('версия попадает в query, лишних полей формы нет', async () => {
const client = await import('../../api/client');
const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue(result);
await uploadFiles('le', [new File(['a'], 'a.xlsx'), new File(['b'], 'b.pdf')], 'v1');
const [path, init] = spy.mock.calls[0];
expect(path).toBe('/legal-entities/le/files?consolidation_version_id=v1');
const form = init?.body as FormData;
expect(form.getAll('files')).toHaveLength(2);
const keys: string[] = [];
form.forEach((_, key) => keys.push(key));
expect(keys.every((k) => k === 'files')).toBe(true);
});
it('без версии — без query', async () => {
const client = await import('../../api/client');
const spy = vi.spyOn(client, 'apiFetch').mockResolvedValue(result);
await uploadFiles('le', [new File(['a'], 'a.xlsx')], null);
expect(spy.mock.calls[0][0]).toBe('/legal-entities/le/files');
});
});

View File

@ -1,19 +1,48 @@
import { api, apiFetch } from '../../api/client';
import type { IFileItem } from '../../types/files';
import type { IPage } from '../../types/legal_entities';
import { ApiError, apiFetch, apiFetchBlob } from '../../api/client';
import type { FileOut, Page, UploadResult } from '../../types/api';
export async function uploadOneFile(legal_entity_id: string, file: Blob, consolidation_version_id: string | null) {
const formData = new FormData();
formData.append('files', file);
formData.append('legal_entity_id', legal_entity_id);
formData.append('year', '2025');
formData.append('quarter', 'Q3');
await apiFetch(`/legal-entities/${legal_entity_id}/files?consolidation_version_id=${consolidation_version_id}`, {
method: 'POST',
body: formData,
});
export function isUploadResult(body: unknown): body is UploadResult {
return (
typeof body === 'object' &&
body !== null &&
Array.isArray((body as UploadResult).accepted) &&
Array.isArray((body as UploadResult).rejected)
);
}
export function fetchLegalEntityFiles(legal_entity_id: string) {
return api.get<IPage<IFileItem>>(`/legal-entities/${legal_entity_id}/files`);
/** Загрузка файлов ЮЛ одним запросом; 422 «ни один не принят» возвращается как результат, прочие ошибки — throw. */
export async function uploadFiles(legalEntityId: string, files: File[], versionId: string | null): Promise<UploadResult> {
const form = new FormData();
for (const file of files) {
form.append('files', file);
}
const query = versionId ? `?consolidation_version_id=${encodeURIComponent(versionId)}` : '';
try {
return await apiFetch<UploadResult>(`/legal-entities/${legalEntityId}/files${query}`, { method: 'POST', body: form });
} catch (e: unknown) {
if (e instanceof ApiError && e.status === 422 && isUploadResult(e.body)) {
return e.body;
}
throw e;
}
}
export function summarizeUpload(result: UploadResult): { accepted: string[]; rejected: { name: string; reason: string }[] } {
return {
accepted: result.accepted.map((a) => a.name),
rejected: result.rejected.map((r) => ({ name: r.name, reason: r.message })),
};
}
export async function listLegalEntityFiles(legalEntityId: string): Promise<FileOut[]> {
const page = await apiFetch<Page<FileOut>>(`/legal-entities/${legalEntityId}/files?limit=100`);
return page.items;
}
export function hasPendingFiles(files: FileOut[]): boolean {
return files.some((f) => f.status === 'UPLOADED' || f.status === 'PROCESSING');
}
export function fetchFileContent(fileId: string): Promise<{ blob: Blob; filename: string | null }> {
return apiFetchBlob(`/files/${fileId}/content?disposition=inline`);
}

View File

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { buildOsvQuery } from './osv';
describe('buildOsvQuery', () => {
it('только заданные фильтры', () => {
expect(buildOsvQuery({})).toBe('');
expect(buildOsvQuery({ legal_entity_id: 'le', q: ' бисквит ' })).toBe(
'?legal_entity_id=le&q=%D0%B1%D0%B8%D1%81%D0%BA%D0%B2%D0%B8%D1%82',
);
});
it('unmapped только если true, пустой q пропускается', () => {
expect(buildOsvQuery({ unmapped: false, q: ' ' })).toBe('');
expect(buildOsvQuery({ unmapped: true })).toBe('?unmapped=true');
});
it('страница', () => {
expect(buildOsvQuery({ counterparty_type: 'GSK' }, { limit: 500, offset: 1000 })).toBe('?counterparty_type=GSK&limit=500&offset=1000');
});
});

25
src/services/api/osv.ts Normal file
View File

@ -0,0 +1,25 @@
import { api, apiFetchBlob } from '../../api/client';
import type { ConsolidatedRowOut, OsvFilters, OsvSummaryOut, Page } from '../../types/api';
export function buildOsvQuery(filters: OsvFilters, page?: { limit: number; offset: number }): string {
const params = new URLSearchParams();
if (filters.legal_entity_id) params.set('legal_entity_id', filters.legal_entity_id);
if (filters.account_root) params.set('account_root', filters.account_root);
if (filters.counterparty_type) params.set('counterparty_type', filters.counterparty_type);
if (filters.unmapped) params.set('unmapped', 'true');
const q = filters.q?.trim();
if (q) params.set('q', q);
if (page) {
params.set('limit', String(page.limit));
params.set('offset', String(page.offset));
}
const s = params.toString();
return s ? `?${s}` : '';
}
const base = (versionId: string) => `/consolidations/versions/${versionId}/osv`;
export const getConsolidatedOsv = (versionId: string, filters: OsvFilters, limit: number, offset: number) =>
api.get<Page<ConsolidatedRowOut>>(`${base(versionId)}${buildOsvQuery(filters, { limit, offset })}`);
export const getOsvSummary = (versionId: string) => api.get<OsvSummaryOut>(`${base(versionId)}/summary`);
export const exportOsv = (versionId: string, filters: OsvFilters) => apiFetchBlob(`${base(versionId)}/export${buildOsvQuery(filters)}`);

View File

@ -20,8 +20,8 @@ const comGroupsSlice = createSlice({
state.error = null;
})
.addCase(getCompanyGroupsThunk.fulfilled, (state, action) => {
state.raw_rows = action.payload.items;
state.total_groups = action.payload.total;
state.raw_rows = action.payload;
state.total_groups = action.payload.length;
state.loading = false;
})
.addCase(getCompanyGroupsThunk.rejected, (state, action) => {

View File

@ -2,7 +2,7 @@ import { createAsyncThunk } from '@reduxjs/toolkit';
import { fetchCompanyGroups } from '../../../services/api/directories/company_groups';
import type { ICompanyGroup } from '../../../types/companyGroups/companyGroups';
export const getCompanyGroupsThunk = createAsyncThunk<{ items: ICompanyGroup[]; total: number }, void, { rejectValue: string }>(
export const getCompanyGroupsThunk = createAsyncThunk<ICompanyGroup[], void, { rejectValue: string }>(
'company_groups/getAll',
async (_, thunkApi) => {
try {

View File

@ -1,10 +1,10 @@
import { createSlice } from '@reduxjs/toolkit';
import type { PayloadAction } from '@reduxjs/toolkit';
import type { IFileItem } from '../../../types/files';
import type { FileOut } from '../../../types/api';
import { getFilesThunk } from './thunks';
interface FilesState {
currentOpenFiles: IFileItem[];
currentOpenFiles: FileOut[];
loading: boolean;
error: string | null;
}
@ -19,7 +19,7 @@ const filesSlice = createSlice({
name: 'files',
initialState,
reducers: {
setCurrentFiles: (state, action: PayloadAction<IFileItem[]>) => {
setCurrentFiles: (state, action: PayloadAction<FileOut[]>) => {
state.currentOpenFiles = action.payload;
},
},

View File

@ -1,13 +1,12 @@
import { createAsyncThunk } from '@reduxjs/toolkit';
import { fetchLegalEntityFiles } from '../../../services/api/files';
import type { IFileItem } from '../../../types/files';
import { listLegalEntityFiles } from '../../../services/api/files';
import type { FileOut } from '../../../types/api';
export const getFilesThunk = createAsyncThunk<IFileItem[], string, { rejectValue: string }>(
export const getFilesThunk = createAsyncThunk<FileOut[], string, { rejectValue: string }>(
'files/getCurrentFiles',
async (legalEntityId, thunkApi) => {
try {
const res = await fetchLegalEntityFiles(legalEntityId);
return res.items;
return await listLegalEntityFiles(legalEntityId);
} catch (e) {
return thunkApi.rejectWithValue(e instanceof Error ? e.message : 'Ошибка');
}

View File

@ -4,3 +4,243 @@ export type Quarter = 'Q1' | 'Q2' | 'Q3' | 'Q4';
export type DocumentType = 'BALANCE' | 'INCOME' | 'OSV' | 'DISCLOSURE' | 'OTHER';
export type DocumentStatus = 'RECOGNIZED' | 'NEEDS_CHECK' | 'CONFIRMED' | 'ERROR';
export type FileStatus = 'UPLOADED' | 'PROCESSING' | 'PROCESSED' | 'ERROR' | 'SUPERSEDED';
export interface Page<T> {
items: T[];
total: number;
limit: number;
offset: number;
}
export interface AcceptedOut {
file_id: string;
job_id: string;
name: string;
}
export interface RejectedOut {
name: string;
code: string;
message: string;
file_id?: string | null;
}
export interface UploadResult {
accepted: AcceptedOut[];
rejected: RejectedOut[];
}
export type RecognitionStatus = string; // pending | processing | done | error | timeout | dlq — только для показа
export interface JobBrief {
id: string;
status: RecognitionStatus;
error_code: string | null;
error_message: string | null;
mapping_error: string | null;
warnings: string[];
mapping_warnings: string[];
finished_at: string | null;
}
export type CheckResult = 'MATCH' | 'MISMATCH' | 'UNKNOWN';
export interface DocumentBrief {
id: string;
document_type: DocumentType;
osv_account_code: string | null;
period_year: number | null;
period_quarter: Quarter | null;
is_cumulative: boolean | null;
status: DocumentStatus;
period_check: CheckResult | null;
counterparty_check: CheckResult | null;
locator: string | null;
comments_count: number;
}
export type FileType = 'XLS' | 'XLSX' | 'XLSB' | 'PDF' | 'PNG' | 'JPG';
export interface FileOut {
id: string;
legal_entity_id: string;
original_name: string | null;
file_type: FileType;
status: FileStatus;
version_no: number;
size_bytes: number | null;
uploaded_at: string | null;
job: JobBrief | null;
documents: DocumentBrief[];
}
export interface DocumentOut extends DocumentBrief {
file_id: string;
legal_entity_id: string;
confirmed_by: string | null;
confirmed_at: string | null;
warnings: string[];
}
export interface EntryOut {
id: string;
row_number: number | null;
level: number | null;
parent_entry_id: string | null;
row_value: string | null;
group_name: string | null;
account_code: string | null;
counterparty_name: string | null;
counterparty_inn: string | null;
opening_debit: string | null;
opening_credit: string | null;
turnover_debit: string | null;
turnover_credit: string | null;
closing_debit: string | null;
closing_credit: string | null;
}
export interface HeaderFieldOut {
value: string | null;
original_value: string | null;
confidence?: string | null;
verified: boolean | null;
modified: boolean | null;
}
export interface StatementHeaderOut {
inn: HeaderFieldOut;
report_date: HeaderFieldOut;
units: HeaderFieldOut;
organization_name: string | null;
okud: string | null;
okpo: string | null;
okopf: string | null;
okfs: string | null;
address: string | null;
}
export interface ColumnOut {
id: string;
column_index: number;
label: string | null;
period_date: string | null;
is_current: boolean | null;
}
export interface ValueOut {
id: string;
column_id: string;
column_index: number;
amount: string | null;
string_value: string | null;
confidence: string | null;
is_recognized: boolean | null;
is_modified: boolean;
original_amount: string | null;
}
export interface LineOut {
id: string;
line_code: string | null;
line_name: string | null;
values: ValueOut[];
}
export interface FailedCheckOut {
column_index: number;
relation: string;
expected: string;
actual: string;
}
export interface ChecksOut {
failed: FailedCheckOut[];
soft_failed: FailedCheckOut[];
cross_form: FailedCheckOut | null;
accepted: { by: string; at: string; comment: string } | null;
}
export interface StatementOut {
document: {
id: string;
document_type: DocumentType;
status: DocumentStatus;
period_year: number | null;
period_quarter: Quarter | null;
reporting_date: string | null;
counterparty_check: CheckResult | null;
locator: string | null;
file_id: string;
};
edition: string | null;
header: StatementHeaderOut;
columns: ColumnOut[];
lines: LineOut[];
checks: ChecksOut;
}
export interface StatementPatch {
header?: { inn?: string; report_date?: string; units?: string };
values?: { line_id: string; column_id: string; amount: string | null }[];
}
export type CounterpartyType = 'GSK' | 'GK' | 'EXTERNAL';
export type MatchKind = 'INN' | 'NAME' | 'ALIAS' | 'MANUAL' | 'PROPAGATED';
export interface ConsolidatedRowOut {
entry_ids: string[];
legal_entity_id: string;
legal_entity_name: string;
legal_entity_inn: string;
account_code: string | null;
account_root: string | null;
period: string;
is_cumulative: boolean;
counterparty_name: string | null;
counterparty_inn: string | null;
counterparty_id: string | null;
counterparty_entity_name: string | null;
counterparty_type: CounterpartyType;
match_kind: MatchKind | null;
opening_debit: string | null;
opening_credit: string | null;
turnover_debit: string | null;
turnover_credit: string | null;
closing_debit: string | null;
closing_credit: string | null;
warnings: string[];
}
export interface GapOut {
legal_entity_id: string;
legal_entity_name: string;
account_code: string;
missing_quarters: Quarter[];
}
export interface OsvSummaryOut {
rows_total: number;
unmapped_total: number;
auto_matched_total: number;
documents_total: number;
gaps: GapOut[];
}
export interface OsvFilters {
legal_entity_id?: string;
account_root?: string;
counterparty_type?: CounterpartyType;
unmapped?: boolean;
q?: string;
}
export interface ConsolidationCreate {
company_group_id: string;
related_group_id: string;
year: number;
quarter: Quarter;
legal_entity_ids: string[];
}
export interface RelatedGroupOut {
id: string;
code: string;
company_group_id: string;
kind: 'gsk' | 'gsz';
name: string;
created_date: string | null;
members_count: number;
}
export interface RelatedGroupMemberOut {
legal_entity_id: string;
inn: string;
name: string;
valid_from: string;
}
export interface RelatedGroupDetail extends RelatedGroupOut {
members: RelatedGroupMemberOut[];
}
export interface LegalEntityBrief {
id: string;
name: string;
inn: string;
}

View File

@ -68,6 +68,8 @@ export interface IVersionCard {
members: VersionMember[];
}
export type VersionCard = IVersionCard;
/** Строка таблицы «Организации»: `docs` — служебная колонка с кнопкой, значения не имеет. */
export type MemberRow = VersionCardRow & { docs?: never };

View File

@ -2,7 +2,7 @@ import { Button, Space } from 'antd';
import { useEffect, useMemo, useState } from 'react';
import { ColumnSettings, DataTable, useFilteredRows, useVisibleColumns } from '../../components/GenericTable';
import { useTableFilters } from '../../components/GenericTable/useTableFilters';
import { getVersionData } from '../../services/api/consolidations';
import { getVersionCard } from '../../services/api/consolidations';
import { setSelectedLegalEntity, setSelectedVersion } from '../../store/slices/consolidations/consolidationsSlice';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { IVersionCard, MemberRow } from '../../types/consolidations/version_members';
@ -20,7 +20,7 @@ export function ConsoleLegalEntitiesTable({ onDrawerOpen }: { onDrawerOpen: () =
async function getLegalEntities() {
if (!consolidation_id) return;
setLoading(true);
const res = await getVersionData(consolidation_id);
const res = await getVersionCard(consolidation_id);
setConsolidationData(res);
setLoading(false);
}

View File

@ -3,7 +3,7 @@ import { InboxOutlined } from '@ant-design/icons';
import { Modal, Upload, message } from 'antd';
import type { RcFile, UploadFile } from 'antd/es/upload/interface';
import { useState } from 'react';
import { uploadOneFile } from '../../services/api/files';
import { summarizeUpload, uploadFiles } from '../../services/api/files';
type Props = {
entityId: string;
@ -24,31 +24,29 @@ export function UploadDocumentsModal({ entityId, open, onClose, onUploaded, cons
}
setUploading(true);
const failed: string[] = [];
// one-by-one (sequential)
for (const file of fileList) {
const raw = (file.originFileObj ?? file) as RcFile;
try {
await uploadOneFile(entityId, raw, consolidation_version_id);
} catch {
failed.push(file.name);
const files = fileList.map((f) => (f.originFileObj ?? f) as RcFile);
try {
const result = await uploadFiles(entityId, files, consolidation_version_id);
const { accepted, rejected } = summarizeUpload(result);
if (rejected.length) {
const rejectedMsg = rejected.map((r) => `${r.name}: ${r.reason}`).join('; ');
message.warning(`Загружено: ${accepted.length}, отклонено: ${rejectedMsg}`);
setFileList((prev) => prev.filter((f) => rejected.some((r) => r.name === f.name)));
setUploading(false);
return;
}
message.success('Все документы загружены');
setFileList([]);
onUploaded?.();
onClose();
} catch (e) {
message.error(e instanceof Error ? e.message : 'Ошибка загрузки');
} finally {
setUploading(false);
}
setUploading(false);
if (failed.length) {
message.error(`Failed: ${failed.join(', ')}`);
// keep only failed files so user can retry
setFileList((prev) => prev.filter((f) => failed.includes(f.name)));
return;
}
message.success('All documents uploaded');
setFileList([]);
onUploaded?.(); // refetch -> polling will also catch up, but immediate feels better
onClose();
};
const handleCancel = () => {

View File

@ -1,10 +1,10 @@
import { Checkbox, Col, Empty, Form, Input, Modal, Row, Select, Spin } from 'antd';
import type React from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { fetchGSKByComGroup, fetchGroupMembers } from '../../services/api/directories/company_groups';
import { fetchAllLegalEntities, fetchRelatedGroups } from '../../services/api/directories/company_groups';
import { getCompanyGroupsThunk } from '../../store/slices/companyGroupsSlice/thunks';
import { useAppDispatch, useAppSelector } from '../../store/store';
import type { ILegalEntityBrief, INamedItem } from '../../types/legal_entities';
import type { LegalEntityBrief, RelatedGroupOut } from '../../types/api';
type FormValues = {
company_group_id: string;
@ -26,11 +26,11 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
const { raw_rows, loading } = useAppSelector((state) => state.company_groups);
const [subgroups, setSubgroups] = useState<INamedItem[]>([]);
const [subgroups, setSubgroups] = useState<RelatedGroupOut[]>([]);
const [loadingSubgroups, setLoadingSubgroups] = useState(false);
const subgroupReqRef = useRef(0);
const [legEntities, setLegEntities] = useState<ILegalEntityBrief[]>([]);
const [legEntities, setLegEntities] = useState<LegalEntityBrief[]>([]);
const [loadingEntities, setLoadingEntities] = useState(false);
const legEntReqRef = useRef(0);
@ -115,8 +115,8 @@ export const MyModal: React.FC<Props> = ({ open, onCancel, onOk }) => {
setLoadingSubgroups(true);
setLoadingEntities(true);
try {
const dataSubgroups = await fetchGSKByComGroup(value);
const dataLegEnts = await fetchGroupMembers(value);
const dataSubgroups = await fetchRelatedGroups(value);
const dataLegEnts = await fetchAllLegalEntities(value);
if (reqId === subgroupReqRef.current) setSubgroups(dataSubgroups);
if (leReqId === legEntReqRef.current) setLegEntities(dataLegEnts);
} finally {

View File

@ -213,7 +213,10 @@ export function Sidebar() {
setModalOpen(false);
}}
onOk={async (values) => {
await createConsolidation(values);
await createConsolidation({
...values,
quarter: values.quarter as 'Q1' | 'Q2' | 'Q3' | 'Q4',
});
setModalOpen(false);
}}
/>