Демо-прототип: от создания консолидации до сводной ОСВ #4
@ -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);
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
src/services/api/documents.ts
Normal file
23
src/services/api/documents.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
86
src/services/api/files.test.ts
Normal file
86
src/services/api/files.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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`);
|
||||
}
|
||||
|
||||
18
src/services/api/osv.test.ts
Normal file
18
src/services/api/osv.test.ts
Normal 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
25
src/services/api/osv.ts
Normal 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)}`);
|
||||
@ -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) => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
},
|
||||
},
|
||||
|
||||
@ -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 : 'Ошибка');
|
||||
}
|
||||
|
||||
240
src/types/api.ts
240
src/types/api.ts
@ -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;
|
||||
}
|
||||
|
||||
@ -68,6 +68,8 @@ export interface IVersionCard {
|
||||
members: VersionMember[];
|
||||
}
|
||||
|
||||
export type VersionCard = IVersionCard;
|
||||
|
||||
/** Строка таблицы «Организации»: `docs` — служебная колонка с кнопкой, значения не имеет. */
|
||||
export type MemberRow = VersionCardRow & { docs?: never };
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}}
|
||||
/>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user