Commit 19c9905e authored by drigle's avatar drigle

feat: add import preflight validation and confirmation

parent 1e1adc14
......@@ -2,6 +2,7 @@
"private": true,
"scripts": {
"build": "max build",
"test:import": "node --experimental-vm-modules --test tests/risk-import-preflight.test.mjs",
"dev": "max dev",
"postinstall": "max setup",
"setup": "max setup",
......
{
"name": "risk_manager_new",
"version": "1.0.1",
"version": "1.0.2",
"build": "abc",
"description": "风险管理应用",
"date": "2025-07-14 19:27:20",
......
import { Alert, Button, Modal, Pagination, Spinner } from '@heroui/react';
import { DataGrid } from '@heroui-pro/react';
import { exportImportIssues } from '@/services/riskImportPreflight';
import { useEffect, useState } from 'react';
const PAGE_SIZE = 20;
const ISSUE_COLUMNS = [
{ id: 'rowNumber', header: 'Excel 行号', accessorKey: 'rowNumber', width: 85, isRowHeader: true },
{ id: 'cell', header: '单元格', accessorKey: 'cell', width: 75 },
{ id: 'field', header: '字段', accessorKey: 'field', width: 150 },
{ id: 'value', header: '原始值', accessorKey: 'value', width: 160 },
{ id: 'reason', header: '问题', accessorKey: 'reason', width: 220 },
{ id: 'suggestion', header: '修改建议 / 合法选项', accessorKey: 'suggestion', width: 340 },
].map((column) => ({
...column,
minWidth: column.width,
cell: (issue) => (
<div className="whitespace-pre-wrap break-words">
{issue[column.accessorKey]}
</div>
),
}));
export default function ImportPreflightDialog({
isOpen,
report,
isChecking,
isImporting,
error,
onClose,
onReselect,
onConfirm,
}) {
const [page, setPage] = useState(1);
useEffect(() => { setPage(1); }, [report]);
const busy = isChecking || isImporting;
const issues = report?.issues || [];
const canImport = report?.canImport && !busy && !error;
const pages = Math.ceil(issues.length / PAGE_SIZE);
return (
<Modal.Root>
<Modal.Backdrop
isOpen={isOpen}
isDismissable={!busy}
isKeyboardDismissDisabled={busy}
onOpenChange={(open) => { if (!open && !busy) onClose(); }}
>
<Modal.Container placement="center" scroll="inside" size="lg">
<Modal.Dialog className="sm:max-w-[68rem]">
<Modal.Header>
<Modal.Heading>导入预检</Modal.Heading>
</Modal.Header>
<Modal.Body className="flex flex-col gap-4">
{busy ? (
<div className="flex items-center gap-3" role="status">
<Spinner size="sm" />
<span>
{isChecking
? '正在读取文件并核对字段、选项和引用记录…'
: '正在导入并更新关联记录…'}
</span>
</div>
) : null}
{error ? (
<Alert status="danger">
<Alert.Content>
<Alert.Title>操作未完成</Alert.Title>
<Alert.Description>{error}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
{report ? (
<>
<p className="text-sm text-muted">
文件:{report.fileName} · 工作表:{report.sheetName || '无'} · 数据:
{report.totalRows} 行;错误定位保留原 Excel 行号。
</p>
<Alert status={report.canImport ? 'success' : 'danger'}>
<Alert.Content>
<Alert.Title>
{report.canImport
? '预检通过'
: `发现 ${issues.length} 处问题,涉及 ${report.errorRows} 行数据`}
</Alert.Title>
<Alert.Description>
{report.canImport
? `已检查全部 ${report.totalRows} 行,点击“确认导入”后写入。`
: '请根据下方明细修正文件后重新选择;本次预检尚未写入数据。原始值中的标点应与合法选项一致,可选字段没有内容时请留空。'}
</Alert.Description>
</Alert.Content>
</Alert>
{issues.length ? (
<>
<DataGrid
aria-label="导入预检错误明细"
data={issues.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE)}
columns={ISSUE_COLUMNS}
getRowId={(issue) => issue.id}
selectionMode="none"
verticalAlign="top"
contentClassName="min-w-[1030px]"
/>
{pages > 1 ? (
<Pagination className="self-end" size="sm">
<Pagination.Summary>共 {issues.length} 处问题</Pagination.Summary>
<Pagination.Content>
<Pagination.Item>
<Pagination.Previous
aria-label="上一页"
isDisabled={page === 1}
onPress={() => setPage((value) => value - 1)}
>
上一页
</Pagination.Previous>
</Pagination.Item>
<Pagination.Item>
<Pagination.Link isActive aria-label={`第 ${page} 页,共 ${pages} 页`}>
{page} / {pages}
</Pagination.Link>
</Pagination.Item>
<Pagination.Item>
<Pagination.Next
aria-label="下一页"
isDisabled={page === pages}
onPress={() => setPage((value) => value + 1)}
>
下一页
</Pagination.Next>
</Pagination.Item>
</Pagination.Content>
</Pagination>
) : null}
</>
) : null}
</>
) : null}
</Modal.Body>
<Modal.Footer className="flex-wrap">
<Button variant="secondary" isDisabled={busy} onPress={onClose}>关闭</Button>
{issues.length ? (
<Button variant="secondary" isDisabled={busy} onPress={() => exportImportIssues(report)}>
下载错误清单
</Button>
) : null}
<Button variant="secondary" isDisabled={busy} onPress={onReselect}>重新选择文件</Button>
<Button isDisabled={!canImport} isPending={isImporting} onPress={onConfirm}>
确认导入{report?.canImport ? `(${report.totalRows} 行)` : ''}
</Button>
</Modal.Footer>
</Modal.Dialog>
</Modal.Container>
</Modal.Backdrop>
</Modal.Root>
);
}
......@@ -2,6 +2,7 @@ import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench';
import { ArrowDownTrayIcon } from '@/components/AppIcons';
import { ACTION_LABELS, RISK_MANAGEMENT_SUBJECTS } from '@/config/riskManagementConfig';
import RiskManagerService from '@/services/RiskManagerService';
import ImportPreflightDialog from './ImportPreflightDialog';
import { Button, Tooltip, toast } from '@heroui/react';
import { useCallback, useMemo, useRef, useState } from 'react';
......@@ -16,7 +17,9 @@ function RiskManagementListPage({ subjectName }) {
const gridConfig = definition.gridRecordConfig || {};
const service = useMemo(() => new RiskManagerService(), []);
const importInputRef = useRef(null);
const pendingImportRef = useRef('');
const pendingImportRef = useRef(null);
const importSessionRef = useRef(null);
const [importState, setImportState] = useState(null);
const [actionLoading, setActionLoading] = useState(false);
const runAction = useCallback(async (actionKey, context = {}, file) => {
......@@ -54,22 +57,65 @@ function RiskManagementListPage({ subjectName }) {
const runConfiguredAction = useCallback((actionKey, context) => {
if (actionKey.startsWith('import-')) {
pendingImportRef.current = actionKey;
pendingImportRef.current = { actionKey, context };
importInputRef.current?.click();
return;
}
runAction(actionKey, context);
}, [runAction]);
const checkImport = useCallback(async (file, session) => {
importSessionRef.current = session;
setActionLoading(true);
session.context?.setError?.('');
setImportState({ isChecking: true, report: null, error: '' });
try {
const report = await service.preflightImport({ file, actionKey: session.actionKey });
setImportState({ isChecking: false, report, error: '' });
} catch (error) {
setImportState({ isChecking: false, report: null, error: error?.message || '预检失败,请稍后重试' });
} finally {
setActionLoading(false);
}
}, [service]);
const confirmImport = useCallback(async () => {
if (!importState?.report?.canImport || actionLoading || importState.error) return;
const context = importSessionRef.current?.context || {};
setActionLoading(true);
setImportState((state) => ({ ...state, isImporting: true }));
try {
const result = await service.executePreparedImport(importState.report);
setImportState(null);
toast.success(`成功导入 ${result.count} 行数据`);
try {
await context.reload?.({ keepFeedback: true });
} catch (reloadError) {
context.setError?.(reloadError?.message || '数据已导入,但列表刷新失败,请手动刷新');
}
} catch (error) {
const message = error?.message || '导入失败,请核对系统数据后重试';
setImportState((state) => ({ ...state, isImporting: false, error: message }));
context.setError?.(message);
try {
await context.reload?.({ keepFeedback: true });
} catch {
// Keep the import error visible even if refreshing the list also fails.
}
} finally {
setActionLoading(false);
}
}, [actionLoading, importState, service]);
const mapAction = useCallback((action) => ({
...action,
disabled: (context) => actionLoading || (
disabled: (context) => actionLoading || Boolean(importState) || (
typeof action.disabled === 'function'
? action.disabled(context)
: Boolean(action.disabled)
),
onClick: (context) => runConfiguredAction(action.key, context),
}), [actionLoading, runConfiguredAction]);
}), [actionLoading, importState, runConfiguredAction]);
const toolbarActions = (gridConfig.toolbarActions || [])
.filter((action) => !(gridConfig.readonly?.hideImportBtn && action.key.startsWith('import-')))
......@@ -95,15 +141,29 @@ function RiskManagementListPage({ subjectName }) {
accept=".xlsx,.xls,.csv"
onChange={(event) => {
const file = event.target.files?.[0];
const actionKey = pendingImportRef.current;
const session = pendingImportRef.current;
event.target.value = '';
pendingImportRef.current = '';
if (file && actionKey) {
runAction(actionKey, {}, file);
pendingImportRef.current = null;
if (file && session) {
checkImport(file, session);
}
}}
/>
<ImportPreflightDialog
isOpen={Boolean(importState)}
report={importState?.report}
isChecking={importState?.isChecking}
isImporting={importState?.isImporting}
error={importState?.error}
onClose={() => setImportState(null)}
onReselect={() => {
pendingImportRef.current = importSessionRef.current;
importInputRef.current?.click();
}}
onConfirm={confirmImport}
/>
<SubjectRecordWorkbench
subjectName={subjectName}
title={definition.title || subjectName}
......
......@@ -58,17 +58,30 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
- “查看投监表最近更新”要求至少两条历史版本,满足条件后打开比较页面;不足时提示
用户先创建新版本。
## 4. 导入与关联
- 基金产品初始化模版从文档 `fund_product_template` 下载;导入前先校验每行的基金名称
均能匹配 `fund_product`,再按指标 Subject 定义创建 `indicator_library` 记录,并将新记录
`_id` 合并到目标基金的 `contract_terms`;目标基金缺失时不会开始写入。
- 投监表模版指标导入先按“模板标题 + 模板类别”定位模版,再批量创建指标并更新
`associated_indicators`;导出反向生成包含模版信息的 Excel。
- 托管人及管理人导入先校验整批数据的“公司名称 + 基金类型”一致,并定位现有托管人记录,
成功后按 `relation_related_party` Subject 字段动态转换并创建关联方,最后以新记录 `_id[]`
合并更新 `related_parties`,不会覆盖原有的关联关系。公司不存在、批次混用公司/类型或接口未返回 ID 时在写入前停止
并提示修正导入文件;关联关系更新失败时保留错误提示,便于管理员重试或清理该批数据。
## 4. 导入预检与关联
### 4.1 功能入口与状态
- 适用于基金产品“导入”、投监表模版“导入模板关联指标”、托管人及管理人“导入托管基金关联方清单”三个 Excel/CSV 入口。入口可见性继续遵循应用配置的 `hideImportBtn`;基金初始化模版从文档 `fund_product_template` 下载。
- 选择文件后打开“导入预检”弹窗,状态依次为:读取文件 / 校验中 → 发现问题或预检通过 → 用户确认导入 → 写入和关联更新 → 成功或失败。校验与写入期间禁用重复操作,弹窗不能通过背景或 Escape 关闭。
- 预检只读取第一张工作表和 Subject/目标/引用记录,整份文件检查结束前不调用写入接口。忽略全空数据行,保留实际 Excel 行号、单元格地址;表头首尾空格自动清理,重复和未知表头、有内容但没有表头的列、Excel 公式错误均反馈为错误。
- 有问题时汇总全部错误,显示工作表、Excel 行号、单元格、字段、原始值、原因、修改建议及合法选项;错误列表每页 20 项,可下载完整的“预检错误”Excel 清单。业务修正文件后点击“重新选择文件”再次预检,有任何问题均不能确认导入。
- 全部通过后显示待导入行数,点击“确认导入”才写入预检所得数据;取消或关闭弹窗不写入。成功后显示实际完成行数,并刷新原导入入口所在的工作台。
### 4.2 字段与标点规则
- 字段定义和选择范围来自 `GET /subject/{name}`,不在页面硬编码业务选项。按 `required / isRequired / is_required` 检查必填,按 `type / multiple / settings.options` 转换、校验字段。未知表头需修正或删除,以免字段内容被静默忽略。
- 多值字段支持英文逗号、中文逗号、顿号、中英文分号和换行作为分隔符;只有分隔符没有内容时反馈错误。单选仅接受一个合法选项,选项自身的合法标点保留;选择项的显示文案转换为对应存储值。可选字段没有内容时应留空,`—`、`/`、`无` 等占位内容不能替代合法选项。
- 自由文本保留正文中的正常标点和换行,不进行全局标点删除。数字必须是有效数值,不能附加单位或占位标点,也不能静默丢弃多值中的非法内容。布尔只接受 `是/否、true/false、1/0、yes/no`,未知值不能静默转为 false。
- 日期支持 Excel 日期序号(含 1900/1904 日期系统)、`YYYY-MM-DD`、`YYYY/M/D` 和 ISO 8601 时间;检查日期与时间真实有效,提交为 ISO 8601。JSON 检查语法与对象结构,多值对象使用 JSON 数组,不能按逗号拆开 JSON;Subject 子对象先加载其字段定义,再递归检查必填、选项和类型。
- 引用按字段配置的 Subject 查找 active 记录,准确名称或 ID 必须唯一匹配;缺失、重名或配置不足都会反馈,不直接保存名称或整个引用对象。法律法规以该字段实际 `settings.subject` 为准,内部制度兼容制度名称字段。所有业务数据继续封装在 `metadata` 内,引用只写入 `_id`,子对象直接嵌套。
### 4.3 目标与关联写入
- 基金初始化逐行检查“基金名称”,所有目标必须存在且唯一;允许同一文件包含多只基金。预检全通过后按目标基金分组导入 `indicator_library`,将新记录 `_id` 合并到各基金的 `contract_terms`,保留已有指标并避免同批多行覆盖。
- 模板关联指标逐行检查“模板标题 + 模板类别”,整份文件必须属于同一个模板及类别,不能仅使用第一行定位后忽略其余行。确认后导入指标,并沿用原规则更新目标模板的 `associated_indicators`;导出反向生成含模板信息的 Excel。
- 托管清单逐行检查“公司名称 + 基金类型”,整批数据必须属于同一家公司和基金类型,且能定位唯一现有托管人记录。基金类型优先从“类别”读取,兼容旧文件仅使用“类型”的格式;当“类型”用于基金类型时,不误写到关联关系的管理类型字段。新版文件可同时使用“类别(公募/专户)”和“类型(管理人/托管人)”两列,列名分别保持“类别”和“类型”。按 `relation_related_party` 定义校验后创建关联关系,将新 `_id[]` 合并到公司的 `related_parties`。
- 确认后先重新读取目标记录,确保目标仍可用;追加关联以确认时的最新引用列表为基础,避免覆盖预检等待期间其他用户新增的关联。正式写入检查接口返回的 ID 数量与待写行数是否一致,再更新关联。记录写入或关联更新失败时停止后续目标组,在弹窗和工作台保留具体 Excel 行号、失败阶段、服务端原因和已完整完成的行数,并刷新列表供核对;不把部分完成显示为成功,不提供同一结果直接重复确认,以免重复导入。服务端写入可能已部分执行,需核对系统数据后修正或清理再重试;预检不代表多接口事务回滚。
## 5. 行操作与异常处理
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment