From 8ebc2279790d15a96bfa65d654e1fd761963651f Mon Sep 17 00:00:00 2001 From: Veronika Tsygankova Date: Tue, 3 Nov 2026 10:06:48 +0300 Subject: [PATCH 1/3] =?UTF-8?q?AURORA-1916:=20=D0=A1=D0=BF=D1=80=D0=B0?= =?UTF-8?q?=D0=B2=D0=BE=D1=87=D0=BD=D0=B8=D0=BA=D0=B8:=20=D0=B8=D0=BC?= =?UTF-8?q?=D0=BF=D0=BE=D1=80=D1=82=20=D0=93=D0=9A=20=D0=B8=20=D0=BE=D1=80?= =?UTF-8?q?=D0=B8=D0=B3=D0=B0=D0=BD=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B9=20?= =?UTF-8?q?=D0=B8=D0=B7=20=D0=B2=D1=8B=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B8?= =?UTF-8?q?=20=D0=95=D0=A6=D0=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/auth/permissions.ts | 1 + web/src/layouts/CatalogueGroups.tsx | 35 +++++- .../api/directories/directory_import.ts | 10 ++ web/src/types/api.ts | 21 ++++ .../ImportCompanyGroupsModal.tsx | 114 ++++++++++++++++++ 5 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 web/src/services/api/directories/directory_import.ts create mode 100644 web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx diff --git a/web/src/auth/permissions.ts b/web/src/auth/permissions.ts index f6a49b8..a0ff6ae 100644 --- a/web/src/auth/permissions.ts +++ b/web/src/auth/permissions.ts @@ -1,3 +1,4 @@ // Коды прав, от которых зависит интерфейс (как в `GET /me` → `permissions`). Доступ на самом деле решает сервер: // проверки на клиенте только прячут то, что пользователю всё равно не разрешат. export const USERS_ADMIN = 'USERS_ADMIN'; +export const DIRECTORY_ADMIN = 'DIRECTORY_ADMIN'; diff --git a/web/src/layouts/CatalogueGroups.tsx b/web/src/layouts/CatalogueGroups.tsx index dfcf853..4941d21 100644 --- a/web/src/layouts/CatalogueGroups.tsx +++ b/web/src/layouts/CatalogueGroups.tsx @@ -1,7 +1,9 @@ -import { PlusOutlined } from '@ant-design/icons'; +import { PlusOutlined, UploadOutlined } from '@ant-design/icons'; import { Button, Empty, Space } from 'antd'; import { useEffect, useMemo, useState } from 'react'; import { ApiError } from '../api/client'; +import { useCurrentUser } from '../auth/MeContext'; +import { DIRECTORY_ADMIN } from '../auth/permissions'; import PageTitle from '../components/PageTitle'; import { createCatalogueJur } from '../services/api/directories/catalogue_jur'; import { @@ -30,6 +32,7 @@ import { } from '../widgets/CatalogueCompanyGroup/CatalogueCompanyGroupTabs/CatalogueCompanyGroupTabs'; import { CompanyGroupForm } from '../widgets/CatalogueCompanyGroup/CompanyGroupForm/CompanyGroupForm'; import { CompanyGroupsList } from '../widgets/CatalogueCompanyGroup/CompanyGroupsList/CompanyGroupsList'; +import { ImportCompanyGroupsModal } from '../widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal'; import { CatalogueJurForm } from '../widgets/CatalogueJur/CatalogueJurForm/CatalogueJurForm'; import { CatalogueJurTable } from '../widgets/CatalogueJur/CatalogueJurTable/CatalogueJurTable'; import { CreateEntityPickerModal } from '../widgets/CreateEntityPickerModal/CreateEntityPickerModal'; @@ -39,10 +42,12 @@ import { RelatedGroupTable } from '../widgets/RelatedGroup/RelatedGroupTable/Rel export function CatalogueGroup() { const dispatch = useAppDispatch(); const notification = useNotificationApi(); + const me = useCurrentUser(); const isGettingArchive = useAppSelector((state) => state.catalogue_jur.isGettingArchive); const [entityCreateModalOpen, setEntityCreateModalOpen] = useState(false); + const [importModalOpen, setImportModalOpen] = useState(false); const [kindForm, setKindForm] = useState(null); const [relatedGroupLoading, setRelatedGroupLoading] = useState(false); const [companyGroupEditFormOpen, setCompanyGroupEditFormOpen] = useState(false); @@ -93,6 +98,18 @@ export function CatalogueGroup() { } } + function importedHandler() { + dispatch(getCompanyGroupsThunk()); + dispatch( + getCatalogueJurThunk({ + closed: isGettingArchive, + outside_group: false, + company_group_id: activeCompanyGroup?.id, + }), + ); + getRelatedGroupsHandler(); + } + async function archiveCompanyGroupHandler() { if (!activeCompanyGroup) { return; @@ -168,9 +185,16 @@ export function CatalogueGroup() { }}> - + + {me.permissions.includes(DIRECTORY_ADMIN) && ( + + )} + +
+ {importModalOpen && ( + setImportModalOpen(false)} onImported={importedHandler} /> + )} { diff --git a/web/src/services/api/directories/directory_import.ts b/web/src/services/api/directories/directory_import.ts new file mode 100644 index 0000000..a6fedb6 --- /dev/null +++ b/web/src/services/api/directories/directory_import.ts @@ -0,0 +1,10 @@ +import { apiFetch } from '../../../api/client'; +import type { ImportReportOut, OnExisting } from '../../../types/api'; + +export function importDirectory(file: File, dryRun: boolean, onExisting: OnExisting): Promise { + const form = new FormData(); + form.append('file', file); + form.append('dry_run', String(dryRun)); + form.append('on_existing', onExisting); + return apiFetch('/directories/import', { method: 'POST', body: form }); +} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 3293854..1ca995b 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -30,6 +30,27 @@ export interface UploadResult { rejected: RejectedOut[]; } +export type OnExisting = 'skip' | 'update'; +export interface ImportReportLine { + row: number; + inn: string; + code: string; + message: string; +} +export interface ImportReportOut { + rows: number; + groups_created: number; + groups_updated: number; + groups_skipped: number; + entities_created: number; + entities_updated: number; + entities_skipped: number; + memberships_created: number; + offices_created: number; + errors: number; + lines: ImportReportLine[]; +} + /** Статусы строки формы (v5.19): ok — строка бланка; unreadable — код не распознан; missing — кода нет; detail — расшифровка. */ export type LineStatus = 'ok' | 'unreadable' | 'missing' | 'detail'; export type Severity = 'error' | 'info'; diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx new file mode 100644 index 0000000..420bb53 --- /dev/null +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx @@ -0,0 +1,114 @@ +import { FileAddOutlined } from '@ant-design/icons'; +import { Button, Checkbox, Form, Select, Upload } from 'antd'; +import type { UploadFile } from 'antd/es/upload/interface'; +import type React from 'react'; +import { useState } from 'react'; +import { ApiError } from '../../../api/client'; +import { AppModal } from '../../../components/AppModal/AppModal'; +import { importDirectory } from '../../../services/api/directories/directory_import'; +import type { OnExisting } from '../../../types/api'; +import { useNotificationApi } from '../../../utils/useNotification'; + +type Props = { + open: boolean; + onCancel: () => void; + onImported: () => void; +}; + +type FormValues = { + on_existing: OnExisting; + dry_run: boolean; +}; + +const ON_EXISTING_OPTIONS: { value: OnExisting; label: string }[] = [ + { value: 'skip', label: 'Пропустить существующие' }, + { value: 'update', label: 'Обновить существующие' }, +]; + +/** + * Импорт выгрузки ЕЦП CRM. Сценарий двухшаговый: после пробного прогона форма и файл остаются, чтобы снять галочку + * и записать тем же файлом; после записи — уведомление и закрытие окна. + */ +export const ImportCompanyGroupsModal: React.FC = ({ open, onCancel, onImported }) => { + const [form] = Form.useForm(); + const notification = useNotificationApi(); + + const [fileList, setFileList] = useState([]); + const [loading, setLoading] = useState(false); + + const handleOk = async () => { + const file = fileList[0]?.originFileObj; + if (!file) { + return; + } + const values = form.getFieldsValue(); + setLoading(true); + try { + await importDirectory(file, values.dry_run, values.on_existing); + if (values.dry_run) { + notification.info({ description: 'Пробный прогон завершён: в справочник ничего не записано' }); + return; + } + notification.success({ description: 'Импорт завершён' }); + onImported(); + onCancel(); + } catch (e) { + notification.error({ description: e instanceof ApiError ? e.message : 'Не удалось выполнить импорт' }); + } finally { + setLoading(false); + } + }; + + const handleCancel = () => { + if (!loading) { + onCancel(); + } + }; + + return ( + +
+ + @@ -109,6 +128,7 @@ export const ImportCompanyGroupsModal: React.FC = ({ open, onCancel, onIm + {dryRunReport && }
); }; diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportSummary.tsx b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportSummary.tsx new file mode 100644 index 0000000..9c810fa --- /dev/null +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportSummary.tsx @@ -0,0 +1,68 @@ +import { Col, Row, Table, Typography } from 'antd'; +import type { ImportReportOut } from '../../../types/api'; +import { CounterCard } from './CounterCard'; +import { errorLines } from './utils'; + +type Props = { + report: ImportReportOut; + fileName: string; +}; + +/** Сводка пробного прогона: счётчики по трём карточкам и строки, пропущенные из-за ошибок. */ +export function ImportSummary({ report, fileName }: Props) { + return ( + <> + + {fileName} · Предварительная проверка. Показано, какие изменения будут внесены. + + + + + + + + + + + + + + Строки с ошибками + + ({ ...line, key }))} + pagination={{ pageSize: 5, hideOnSinglePage: true, showSizeChanger: false }} + locale={{ emptyText: 'Ошибок не найдено' }} + columns={[ + { title: 'Строка', dataIndex: 'row', width: 80 }, + { title: 'ИНН', dataIndex: 'inn', width: 140 }, + { title: 'Результат', dataIndex: 'message' }, + ]} + /> + + ); +} diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/consts.ts b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/consts.ts new file mode 100644 index 0000000..6e3891b --- /dev/null +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/consts.ts @@ -0,0 +1,9 @@ +/** Коды строк, пропущенных импортом из-за ошибки (§13.2); остальные коды — успешные итоги и сопутствующие события. */ +export const ERROR_CODES = new Set([ + 'UNSUPPORTED_TYPE', + 'INVALID_INN', + 'INVALID_KPP', + 'DUPLICATE_IN_FILE', + 'LE_IN_OTHER_GROUP', + 'GK_NAME_CONFLICT', +]); diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.ts b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.ts new file mode 100644 index 0000000..d263cb2 --- /dev/null +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.ts @@ -0,0 +1,6 @@ +import type { ImportReportLine } from '../../../types/api'; +import { ERROR_CODES } from './consts'; + +export function errorLines(lines: ImportReportLine[]): ImportReportLine[] { + return lines.filter((line) => ERROR_CODES.has(line.code)); +} From a8ea8c6cc267d886a37f7fda0759c6416f50b618 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Fri, 6 Nov 2026 17:40:34 +0400 Subject: [PATCH 3/3] =?UTF-8?q?[1916]=20=D0=98=D0=BC=D0=BF=D0=BE=D1=80?= =?UTF-8?q?=D1=82=20=D0=95=D0=A6=D0=9F:=20=D1=81=D0=B2=D0=BE=D0=B4=D0=BA?= =?UTF-8?q?=D0=B0=20=D0=B8=20=D0=BF=D0=BE=D1=81=D0=BB=D0=B5=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=B8,=20=D0=BF=D1=80=D0=B5=D0=B4=D1=83?= =?UTF-8?q?=D0=BF=D1=80=D0=B5=D0=B6=D0=B4=D0=B5=D0=BD=D0=B8=D1=8F=20=D0=B2?= =?UTF-8?q?=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ревью PR #217, раунд 2: - после записи (без «Пробного прогона») показывается та же сводка с заголовком «Импорт завершён» и одной кнопкой «Закрыть»; список справочника обновляется сразу; - «ГК/ЮЛ пропущено» → «без изменений»: это GK_EXISTS/LE_EXISTS, не ошибки; - таблица «Ошибки и предупреждения»: кроме шести кодов пропуска — предупреждения RF_CREATED и UNKNOWN_FLAG (§13.2) с тегом уровня; - тесты issueLines. Co-Authored-By: Claude Opus 5.5 --- .../ImportCompanyGroupsModal.tsx | 49 ++++++++++++------- .../ImportSummary.tsx | 38 +++++++++----- .../ImportCompanyGroupsModal/consts.ts | 8 ++- .../ImportCompanyGroupsModal/utils.test.ts | 42 ++++++++++++++++ .../ImportCompanyGroupsModal/utils.ts | 18 +++++-- 5 files changed, 122 insertions(+), 33 deletions(-) create mode 100644 web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.test.ts diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx index 0e3ff9e..51a2f81 100644 --- a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/ImportCompanyGroupsModal.tsx @@ -21,14 +21,28 @@ type FormValues = { dry_run: boolean; }; +/** Отчёт последнего запуска; dryRun — был ли это пробный прогон (от него зависят заголовок, текст и кнопки сводки). */ +type ImportResult = { + report: ImportReportOut; + dryRun: boolean; +}; + const ON_EXISTING_OPTIONS: { value: OnExisting; label: string }[] = [ { value: 'skip', label: 'Пропустить существующие' }, { value: 'update', label: 'Обновить существующие' }, ]; +function modalTitle(result: ImportResult | null): string { + if (!result) { + return 'Импорт групп компаний из файла'; + } + return result.dryRun ? 'Сводка импорта' : 'Импорт завершён'; +} + /** - * Импорт выгрузки ЕЦП CRM. Пробный прогон заменяет форму сводкой: из неё — закрытие окна или возврат к форме - * («К загрузке файла», файл и значения формы сохраняются); после записи — уведомление и закрытие окна. + * Импорт выгрузки ЕЦП CRM. Ответ заменяет форму сводкой (отчёт §13.3). После пробного прогона из сводки — закрытие окна + * или возврат к форме («К загрузке файла», файл и значения формы сохраняются); после записи — только закрытие, + * список справочника обновляется сразу. */ export const ImportCompanyGroupsModal: React.FC = ({ open, onCancel, onImported }) => { const [form] = Form.useForm(); @@ -36,7 +50,7 @@ export const ImportCompanyGroupsModal: React.FC = ({ open, onCancel, onIm const [fileList, setFileList] = useState([]); const [loading, setLoading] = useState(false); - const [dryRunReport, setDryRunReport] = useState(null); + const [result, setResult] = useState(null); const handleOk = async () => { const file = fileList[0]?.originFileObj; @@ -47,13 +61,10 @@ export const ImportCompanyGroupsModal: React.FC = ({ open, onCancel, onIm setLoading(true); try { const report = await importDirectory(file, values.dry_run, values.on_existing); - if (values.dry_run) { - setDryRunReport(report); - return; + setResult({ report, dryRun: values.dry_run }); + if (!values.dry_run) { + onImported(); } - notification.success({ description: 'Импорт завершён' }); - onImported(); - onCancel(); } catch (e) { notification.error({ description: e instanceof ApiError ? e.message : 'Не удалось выполнить импорт' }); } finally { @@ -69,7 +80,7 @@ export const ImportCompanyGroupsModal: React.FC = ({ open, onCancel, onIm return ( = ({ open, onCancel, onIm cancelText='Отмена' cancelButtonProps={{ disabled: loading }} footer={ - dryRunReport ? ( -
- + result ? ( +
+ {result.dryRun && ( + + )} @@ -92,13 +105,13 @@ export const ImportCompanyGroupsModal: React.FC = ({ open, onCancel, onIm confirmLoading={loading} closable={!loading} destroyOnHidden - width={dryRunReport ? 880 : 720}> + width={result ? 880 : 720}>
+ style={{ display: result ? 'none' : undefined }}>
({ ...line, key }))} + dataSource={issueLines(report.lines).map((line, key) => ({ ...line, key }))} pagination={{ pageSize: 5, hideOnSinglePage: true, showSizeChanger: false }} - locale={{ emptyText: 'Ошибок не найдено' }} + locale={{ emptyText: 'Ошибок и предупреждений нет' }} columns={[ { title: 'Строка', dataIndex: 'row', width: 80 }, { title: 'ИНН', dataIndex: 'inn', width: 140 }, - { title: 'Результат', dataIndex: 'message' }, + { + title: 'Результат', + dataIndex: 'message', + render: (message: string, line) => ( + <> + {line.level === 'error' ? ошибка : предупреждение} + {message} + + ), + }, ]} /> diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/consts.ts b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/consts.ts index 6e3891b..36d41a1 100644 --- a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/consts.ts +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/consts.ts @@ -1,4 +1,4 @@ -/** Коды строк, пропущенных импортом из-за ошибки (§13.2); остальные коды — успешные итоги и сопутствующие события. */ +/** Коды строк, пропущенных импортом из-за ошибки (§13.2): итоговые коды пропуска, счётчик `errors`. */ export const ERROR_CODES = new Set([ 'UNSUPPORTED_TYPE', 'INVALID_INN', @@ -7,3 +7,9 @@ export const ERROR_CODES = new Set([ 'LE_IN_OTHER_GROUP', 'GK_NAME_CONFLICT', ]); + +/** + * Сопутствующие события-предупреждения (§13.2): строка импортирована, но пользователю стоит проверить — + * создан новый филиал (возможна опечатка в «Базовый РФ ГК») или флаг ГСК/ГСЗ не распознан и принят как «не входит». + */ +export const WARNING_CODES = new Set(['RF_CREATED', 'UNKNOWN_FLAG']); diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.test.ts b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.test.ts new file mode 100644 index 0000000..04b0c63 --- /dev/null +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import type { ImportReportLine } from '../../../types/api'; +import { issueLines } from './utils'; + +const line = (row: number, code: string): ImportReportLine => ({ row, inn: '7700000000', code, message: code }); + +describe('issueLines', () => { + it('оставляет шесть кодов пропуска (§13.2) с уровнем error', () => { + const codes = ['UNSUPPORTED_TYPE', 'INVALID_INN', 'INVALID_KPP', 'DUPLICATE_IN_FILE', 'LE_IN_OTHER_GROUP', 'GK_NAME_CONFLICT']; + const result = issueLines(codes.map((code, i) => line(i + 2, code))); + expect(result.map((item) => item.level)).toEqual(Array(6).fill('error')); + expect(result.map((item) => item.code)).toEqual(codes); + }); + + it('оставляет предупреждения RF_CREATED и UNKNOWN_FLAG с уровнем warning', () => { + const result = issueLines([line(2, 'RF_CREATED'), line(3, 'UNKNOWN_FLAG')]); + expect(result.map((item) => [item.code, item.level])).toEqual([ + ['RF_CREATED', 'warning'], + ['UNKNOWN_FLAG', 'warning'], + ]); + }); + + it('отбрасывает успешные итоги и прочие сопутствующие события, порядок файла сохраняет', () => { + const result = issueLines([ + line(2, 'LE_CREATED'), + line(2, 'GK_CREATED'), + line(3, 'UNKNOWN_FLAG'), + line(3, 'LE_EXISTS'), + line(4, 'INVALID_INN'), + line(5, 'GSK_CREATED'), + line(5, 'MEMBER_ADDED'), + line(5, 'LE_UPDATED'), + line(5, 'GK_UPDATED'), + line(5, 'GK_EXISTS'), + line(5, 'GSZ_CREATED'), + ]); + expect(result.map((item) => [item.row, item.code])).toEqual([ + [3, 'UNKNOWN_FLAG'], + [4, 'INVALID_INN'], + ]); + }); +}); diff --git a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.ts b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.ts index d263cb2..4e04af5 100644 --- a/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.ts +++ b/web/src/widgets/CatalogueCompanyGroup/ImportCompanyGroupsModal/utils.ts @@ -1,6 +1,18 @@ import type { ImportReportLine } from '../../../types/api'; -import { ERROR_CODES } from './consts'; +import { ERROR_CODES, WARNING_CODES } from './consts'; -export function errorLines(lines: ImportReportLine[]): ImportReportLine[] { - return lines.filter((line) => ERROR_CODES.has(line.code)); +export type IssueLevel = 'error' | 'warning'; +export type IssueLine = ImportReportLine & { level: IssueLevel }; + +/** Строки отчёта для таблицы «Ошибки и предупреждения»: пропуски и предупреждения в порядке файла, успешные итоги отброшены. */ +export function issueLines(lines: ImportReportLine[]): IssueLine[] { + const result: IssueLine[] = []; + for (const line of lines) { + if (ERROR_CODES.has(line.code)) { + result.push({ ...line, level: 'error' }); + } else if (WARNING_CODES.has(line.code)) { + result.push({ ...line, level: 'warning' }); + } + } + return result; }