Commit 19c9905e authored by drigle's avatar drigle

feat: add import preflight validation and confirmation

parent 1e1adc14
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
"private": true, "private": true,
"scripts": { "scripts": {
"build": "max build", "build": "max build",
"test:import": "node --experimental-vm-modules --test tests/risk-import-preflight.test.mjs",
"dev": "max dev", "dev": "max dev",
"postinstall": "max setup", "postinstall": "max setup",
"setup": "max setup", "setup": "max setup",
......
{ {
"name": "risk_manager_new", "name": "risk_manager_new",
"version": "1.0.1", "version": "1.0.2",
"build": "abc", "build": "abc",
"description": "风险管理应用", "description": "风险管理应用",
"date": "2025-07-14 19:27:20", "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'; ...@@ -2,6 +2,7 @@ import SubjectRecordWorkbench from '@/components/SubjectRecordWorkbench';
import { ArrowDownTrayIcon } from '@/components/AppIcons'; import { ArrowDownTrayIcon } from '@/components/AppIcons';
import { ACTION_LABELS, RISK_MANAGEMENT_SUBJECTS } from '@/config/riskManagementConfig'; import { ACTION_LABELS, RISK_MANAGEMENT_SUBJECTS } from '@/config/riskManagementConfig';
import RiskManagerService from '@/services/RiskManagerService'; import RiskManagerService from '@/services/RiskManagerService';
import ImportPreflightDialog from './ImportPreflightDialog';
import { Button, Tooltip, toast } from '@heroui/react'; import { Button, Tooltip, toast } from '@heroui/react';
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useMemo, useRef, useState } from 'react';
...@@ -16,7 +17,9 @@ function RiskManagementListPage({ subjectName }) { ...@@ -16,7 +17,9 @@ function RiskManagementListPage({ subjectName }) {
const gridConfig = definition.gridRecordConfig || {}; const gridConfig = definition.gridRecordConfig || {};
const service = useMemo(() => new RiskManagerService(), []); const service = useMemo(() => new RiskManagerService(), []);
const importInputRef = useRef(null); 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 [actionLoading, setActionLoading] = useState(false);
const runAction = useCallback(async (actionKey, context = {}, file) => { const runAction = useCallback(async (actionKey, context = {}, file) => {
...@@ -54,22 +57,65 @@ function RiskManagementListPage({ subjectName }) { ...@@ -54,22 +57,65 @@ function RiskManagementListPage({ subjectName }) {
const runConfiguredAction = useCallback((actionKey, context) => { const runConfiguredAction = useCallback((actionKey, context) => {
if (actionKey.startsWith('import-')) { if (actionKey.startsWith('import-')) {
pendingImportRef.current = actionKey; pendingImportRef.current = { actionKey, context };
importInputRef.current?.click(); importInputRef.current?.click();
return; return;
} }
runAction(actionKey, context); runAction(actionKey, context);
}, [runAction]); }, [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) => ({ const mapAction = useCallback((action) => ({
...action, ...action,
disabled: (context) => actionLoading || ( disabled: (context) => actionLoading || Boolean(importState) || (
typeof action.disabled === 'function' typeof action.disabled === 'function'
? action.disabled(context) ? action.disabled(context)
: Boolean(action.disabled) : Boolean(action.disabled)
), ),
onClick: (context) => runConfiguredAction(action.key, context), onClick: (context) => runConfiguredAction(action.key, context),
}), [actionLoading, runConfiguredAction]); }), [actionLoading, importState, runConfiguredAction]);
const toolbarActions = (gridConfig.toolbarActions || []) const toolbarActions = (gridConfig.toolbarActions || [])
.filter((action) => !(gridConfig.readonly?.hideImportBtn && action.key.startsWith('import-'))) .filter((action) => !(gridConfig.readonly?.hideImportBtn && action.key.startsWith('import-')))
...@@ -95,15 +141,29 @@ function RiskManagementListPage({ subjectName }) { ...@@ -95,15 +141,29 @@ function RiskManagementListPage({ subjectName }) {
accept=".xlsx,.xls,.csv" accept=".xlsx,.xls,.csv"
onChange={(event) => { onChange={(event) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
const actionKey = pendingImportRef.current; const session = pendingImportRef.current;
event.target.value = ''; event.target.value = '';
pendingImportRef.current = ''; pendingImportRef.current = null;
if (file && actionKey) { if (file && session) {
runAction(actionKey, {}, file); 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 <SubjectRecordWorkbench
subjectName={subjectName} subjectName={subjectName}
title={definition.title || subjectName} title={definition.title || subjectName}
......
...@@ -58,17 +58,30 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也 ...@@ -58,17 +58,30 @@ HeroUI Pro `DataGrid` 和 `RecordDetailDrawer`。同一套多维表格组件也
- “查看投监表最近更新”要求至少两条历史版本,满足条件后打开比较页面;不足时提示 - “查看投监表最近更新”要求至少两条历史版本,满足条件后打开比较页面;不足时提示
用户先创建新版本。 用户先创建新版本。
## 4. 导入与关联 ## 4. 导入预检与关联
- 基金产品初始化模版从文档 `fund_product_template` 下载;导入前先校验每行的基金名称 ### 4.1 功能入口与状态
均能匹配 `fund_product`,再按指标 Subject 定义创建 `indicator_library` 记录,并将新记录
`_id` 合并到目标基金的 `contract_terms`;目标基金缺失时不会开始写入。 - 适用于基金产品“导入”、投监表模版“导入模板关联指标”、托管人及管理人“导入托管基金关联方清单”三个 Excel/CSV 入口。入口可见性继续遵循应用配置的 `hideImportBtn`;基金初始化模版从文档 `fund_product_template` 下载。
- 投监表模版指标导入先按“模板标题 + 模板类别”定位模版,再批量创建指标并更新 - 选择文件后打开“导入预检”弹窗,状态依次为:读取文件 / 校验中 → 发现问题或预检通过 → 用户确认导入 → 写入和关联更新 → 成功或失败。校验与写入期间禁用重复操作,弹窗不能通过背景或 Escape 关闭。
`associated_indicators`;导出反向生成包含模版信息的 Excel。 - 预检只读取第一张工作表和 Subject/目标/引用记录,整份文件检查结束前不调用写入接口。忽略全空数据行,保留实际 Excel 行号、单元格地址;表头首尾空格自动清理,重复和未知表头、有内容但没有表头的列、Excel 公式错误均反馈为错误。
- 托管人及管理人导入先校验整批数据的“公司名称 + 基金类型”一致,并定位现有托管人记录, - 有问题时汇总全部错误,显示工作表、Excel 行号、单元格、字段、原始值、原因、修改建议及合法选项;错误列表每页 20 项,可下载完整的“预检错误”Excel 清单。业务修正文件后点击“重新选择文件”再次预检,有任何问题均不能确认导入。
成功后按 `relation_related_party` Subject 字段动态转换并创建关联方,最后以新记录 `_id[]` - 全部通过后显示待导入行数,点击“确认导入”才写入预检所得数据;取消或关闭弹窗不写入。成功后显示实际完成行数,并刷新原导入入口所在的工作台。
合并更新 `related_parties`,不会覆盖原有的关联关系。公司不存在、批次混用公司/类型或接口未返回 ID 时在写入前停止
并提示修正导入文件;关联关系更新失败时保留错误提示,便于管理员重试或清理该批数据。 ### 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. 行操作与异常处理 ## 5. 行操作与异常处理
......
...@@ -15,11 +15,8 @@ import { ...@@ -15,11 +15,8 @@ import {
} from './DataService'; } from './DataService';
import BaseService from './BaseService'; import BaseService from './BaseService';
import { APPLICATION_BASE_PATH } from '@/config/application'; import { APPLICATION_BASE_PATH } from '@/config/application';
import { import { getReferenceSubjectName } from '@/utils/subjectView';
getReferenceSubjectName, import { buildRiskImportPreflight } from './riskImportPreflight';
isDateFieldType,
toIsoDateString,
} from '@/utils/subjectView';
const SUBJECT_FUND_PRODUCT = 'fund_product'; const SUBJECT_FUND_PRODUCT = 'fund_product';
const SUBJECT_INDICATOR = 'indicator_library'; const SUBJECT_INDICATOR = 'indicator_library';
...@@ -81,13 +78,6 @@ function downloadWorkbook(rows, filename, sheetName = '数据') { ...@@ -81,13 +78,6 @@ function downloadWorkbook(rows, filename, sheetName = '数据') {
XLSX.writeFile(workbook, filename); XLSX.writeFile(workbook, filename);
} }
async function readWorkbook(file) {
if (!file) return [];
const workbook = XLSX.read(await file.arrayBuffer(), { type: 'array' });
const worksheet = workbook.Sheets[workbook.SheetNames[0]];
return XLSX.utils.sheet_to_json(worksheet, { defval: '' });
}
function fieldValue(record, field) { function fieldValue(record, field) {
return record?.metadata?.[field] ?? record?.[field] ?? ''; return record?.metadata?.[field] ?? record?.[field] ?? '';
} }
...@@ -119,24 +109,6 @@ function insertedIdsFrom(response) { ...@@ -119,24 +109,6 @@ function insertedIdsFrom(response) {
.filter(Boolean); .filter(Boolean);
} }
function responseRecords(response) {
return recordsFrom(response).filter(Boolean);
}
function parseImportedJsonValue(value, label) {
if (typeof value !== 'string' || !value.trim()) return value;
try {
return JSON.parse(value);
} catch {
throw new Error(`${label || '对象字段'}的 JSON 格式不正确,请检查导入文件`);
}
}
function parseImportedBooleanValue(value) {
if (typeof value === 'boolean') return value;
return ['true', '1', '是', 'yes'].includes(String(value).trim().toLowerCase());
}
function referenceSubjectFromDefinition(subject, fieldName, fallback) { function referenceSubjectFromDefinition(subject, fieldName, fallback) {
const field = subject?.fields?.find((candidate) => candidate.name === fieldName); const field = subject?.fields?.find((candidate) => candidate.name === fieldName);
return getReferenceSubjectName(field) || fallback; return getReferenceSubjectName(field) || fallback;
...@@ -249,139 +221,6 @@ function defaultRelatedPartyRow(fundCategory) { ...@@ -249,139 +221,6 @@ function defaultRelatedPartyRow(fundCategory) {
}; };
} }
async function metadataFromIndicatorRow(row, fields) {
const metadata = {};
for (const field of fields) {
const rawValue = row?.[field.label];
if (field.type === 'reference') {
const values = asArray(rawValue).flatMap((value) =>
typeof value === 'string' ? value.split(',').map((item) => item.trim()).filter(Boolean) : [value],
);
if (field.name === 'laws_regulations' && values.length > 0) {
const result = await queryRecords({
subject: 'external_law_new',
page: 0,
page_size: 1000,
filter: { title: { $in: values } },
mode: 'basic',
});
const ids = responseRecords(result).map((record) => record._id);
if (ids.length !== values.length) {
throw new Error(`${field.label || field.name}存在无法匹配的法规,请检查导入名称`);
}
metadata[field.name] = field.multiple ? ids : ids[0] || null;
} else if (field.name === 'company_basis' && values.length > 0) {
const result = await queryRecords({
subject: 'internal_regulation',
page: 0,
page_size: 1000,
filter: { 'metadata.regulation_name': { $in: values } },
mode: 'basic',
});
const ids = responseRecords(result).map((record) => record._id);
if (ids.length !== values.length) {
throw new Error(`${field.label || field.name}存在无法匹配的内部制度,请检查导入名称`);
}
metadata[field.name] = field.multiple ? ids : ids[0] || null;
} else {
const referenceSubject = getReferenceSubjectName(field);
let ids = values
.map((value) => (typeof value === 'object' ? idOf(value) : (/^[a-f\d]{24}$/i.test(String(value)) ? String(value) : '')))
.filter(Boolean);
if (referenceSubject && values.some((value) => typeof value === 'string')) {
const result = await queryRecords({
subject: referenceSubject,
page: 0,
page_size: 1000,
filter: { $or: [{ title: { $in: values } }, { name: { $in: values } }] },
mode: 'basic',
});
const matched = recordsFrom(result);
const byLabel = new Map(matched.flatMap((record) => [
[record.title, record._id],
[record.name, record._id],
]).filter(([label, id]) => label && id));
ids = values.map((value) => {
if (typeof value === 'object') return idOf(value);
return /^[a-f\d]{24}$/i.test(String(value)) ? String(value) : byLabel.get(value);
}).filter(Boolean);
if (ids.length !== values.length) {
throw new Error(`${field.label || field.name}存在无法匹配的引用值,请使用有效名称后重试`);
}
} else if (values.some((value) => typeof value === 'string' && !/^[a-f\d]{24}$/i.test(String(value)))) {
throw new Error(`${field.label || field.name}缺少引用对象配置,无法将名称转换为 ID`);
}
metadata[field.name] = field.multiple ? ids : ids[0] || null;
}
continue;
}
const values = field.multiple
? (typeof rawValue === 'string'
? rawValue.split(',').map((item) => item.trim()).filter(Boolean)
: asArray(rawValue).filter((item) => item !== null && item !== undefined && item !== ''))
: null;
if (field.multiple) {
if (isDateFieldType(field.type)) {
metadata[field.name] = values.map(toIsoDateString);
} else if (field.type === 'number') {
metadata[field.name] = values.map(Number).filter(Number.isFinite);
} else if (field.type === 'boolean') {
metadata[field.name] = values.map(parseImportedBooleanValue);
} else if (['object', 'form', 'subject'].includes(field.type)) {
metadata[field.name] = values.map((value) =>
parseImportedJsonValue(value, field.label || field.name),
);
} else {
metadata[field.name] = values;
}
} else if (rawValue === undefined || rawValue === '') {
metadata[field.name] = null;
} else if (isDateFieldType(field.type)) {
metadata[field.name] = toIsoDateString(rawValue);
} else if (field.type === 'number') {
metadata[field.name] = Number(rawValue);
} else if (field.type === 'boolean') {
metadata[field.name] = parseImportedBooleanValue(rawValue);
} else if (['object', 'form', 'subject'].includes(field.type) && typeof rawValue === 'string') {
metadata[field.name] = parseImportedJsonValue(rawValue, field.label || field.name);
} else {
metadata[field.name] = rawValue;
}
}
return metadata;
}
function normalizeImportedFieldValue(rawValue, field) {
const values = field.multiple
? (typeof rawValue === 'string'
? rawValue.split(',').map((item) => item.trim()).filter(Boolean)
: asArray(rawValue).filter((item) => item !== null && item !== undefined && item !== ''))
: null;
if (field.multiple) {
if (field.type === 'user' || ['reference', 'document', 'view', 'template', 'task', 'tag', 'group', 'category'].includes(field.type)) {
return values.map((value) => value?._id || value).filter(Boolean);
}
if (isDateFieldType(field.type)) return values.map(toIsoDateString);
if (field.type === 'number') return values.map(Number).filter(Number.isFinite);
if (field.type === 'boolean') return values.map(parseImportedBooleanValue);
if (['object', 'form', 'subject'].includes(field.type)) {
return values.map((value) => parseImportedJsonValue(value, field.label || field.name));
}
return values;
}
if (rawValue === undefined || rawValue === '') return null;
if (field.type === 'user' || ['reference', 'document', 'view', 'template', 'task', 'tag', 'group', 'category'].includes(field.type)) {
return rawValue?._id || rawValue;
}
if (isDateFieldType(field.type)) return toIsoDateString(rawValue);
if (field.type === 'number') return Number(rawValue);
if (field.type === 'boolean') return parseImportedBooleanValue(rawValue);
if (['object', 'form', 'subject'].includes(field.type)) {
return parseImportedJsonValue(rawValue, field.label || field.name);
}
return rawValue;
}
export class RiskManagerService extends BaseService { export class RiskManagerService extends BaseService {
async createData({ subjectName, title, metadata = {} }) { async createData({ subjectName, title, metadata = {} }) {
if (!subjectName) throw new Error('缺少新增对象'); if (!subjectName) throw new Error('缺少新增对象');
...@@ -680,36 +519,68 @@ export class RiskManagerService extends BaseService { ...@@ -680,36 +519,68 @@ export class RiskManagerService extends BaseService {
return { url: document.url }; return { url: document.url };
} }
async importFundProductTemplate({ file }) { async preflightImport({ file, actionKey }) {
const rows = await readWorkbook(file); return buildRiskImportPreflight({ file, actionKey }, { loadSubject, queryRecords, getReferenceSubjectName });
if (rows.length === 0) throw new Error('导入文件没有数据'); }
const fundTitles = [...new Set(rows.map((row) => String(row?.['基金名称'] || '').trim()).filter(Boolean))];
if (fundTitles.length === 0) throw new Error('导入文件缺少基金名称'); async executePreparedImport(report) {
const fundResult = await queryRecords({ if (!report?.canImport || report.issues.length || !report.entries.length) {
subject: SUBJECT_FUND_PRODUCT, const message = report?.issues?.map((issue) => `第 ${issue.rowNumber} 行“${issue.field}”:${issue.reason}`).join(';');
const error = new Error(message || '导入预检未通过,请修正文件后重新预检');
error.importReport = report;
throw error;
}
const groups = new Map();
report.entries.forEach((entry) => {
const targetId = entry.target?._id;
if (!targetId) throw new Error(`Excel 第 ${entry.rowNumber} 行缺少有效的导入目标,请重新预检`);
if (!groups.has(targetId)) groups.set(targetId, []);
groups.get(targetId).push(entry);
});
let completedRows = 0;
for (const entries of groups.values()) {
let importedIds = [];
let stage = '目标复核';
try {
const targetSubject = report.actionKey === 'import-template' ? SUBJECT_FUND_PRODUCT
: report.actionKey === 'import-template-indicator' ? SUBJECT_TEMPLATE : SUBJECT_CUSTODIAN;
const latest = await queryRecords({
subject: targetSubject,
page: 0, page: 0,
page_size: 1000, page_size: 1,
mode: 'full', mode: 'full',
filter: { title: { $in: fundTitles } }, filter: { _id: entries[0].target._id, status: 'active' },
}); });
const fundsByTitle = new Map(recordsFrom(fundResult).map((record) => [record.title, record])); const target = recordsFrom(latest)[0];
const missingFunds = fundTitles.filter((title) => !fundsByTitle.has(title)); if (!target) throw new Error('导入目标已删除或不可访问,请重新预检');
if (missingFunds.length > 0) { stage = '记录写入';
throw new Error(`没有找到基金产品:${missingFunds.join('、')}`); const imported = await importRecords(report.subjectName, entries.map((entry) => entry.record));
importedIds = insertedIdsFrom(imported);
if (importedIds.length !== entries.length) {
throw new Error(`接口返回了 ${importedIds.length} 个记录 ID,预期 ${entries.length} 个,导入结果不完整`);
}
const relationField = report.actionKey === 'import-template' ? 'contract_terms'
: report.actionKey === 'import-template-indicator' ? 'associated_indicators' : 'related_parties';
const ids = report.actionKey === 'import-template-indicator' ? importedIds
: [...new Set([...uniqueIds(target.metadata?.[relationField]), ...importedIds])];
stage = '关联更新';
await updateRecord(target._id, { metadata: { [relationField]: ids } });
completedRows += entries.length;
} catch (error) {
const rows = entries.map((entry) => entry.rowNumber).join('、');
const status = stage === '目标复核' ? '目标复核失败,尚未写入该组数据'
: stage === '关联更新' ? '记录已写入,但关联更新失败' : '记录写入未完整完成';
const failure = new Error(`Excel 第 ${rows} 行${status}:${error?.message || '接口请求失败'}。已完整导入 ${completedRows} 行。请核对系统数据后处理,避免重复导入。`);
failure.importedIds = importedIds;
throw failure;
}
}
return { count: completedRows };
} }
const subject = unwrapSubject(await loadSubject(SUBJECT_INDICATOR));
const fields = subject?.fields || []; async importFundProductTemplate({ file }) {
for (const row of rows) { const report = await this.preflightImport({ file, actionKey: 'import-template' });
const metadata = await metadataFromIndicatorRow(row, fields); return this.executePreparedImport(report);
const inserted = await importRecords(SUBJECT_INDICATOR, [{ title: row['@标题'] || row['指标描述'] || '导入指标', metadata }]);
const target = fundsByTitle.get(String(row['基金名称']).trim());
const newIds = insertedIdsFrom(inserted);
if (target && newIds.length > 0) {
const current = uniqueIds(target.metadata?.contract_terms);
await updateRecord(target._id, { metadata: { contract_terms: [...new Set([...current, ...newIds])] } });
}
}
return { count: rows.length };
} }
async exportFundProductTemplateIndicator({ records }) { async exportFundProductTemplateIndicator({ records }) {
...@@ -727,89 +598,13 @@ export class RiskManagerService extends BaseService { ...@@ -727,89 +598,13 @@ export class RiskManagerService extends BaseService {
} }
async importFundProductTemplateIndicator({ file }) { async importFundProductTemplateIndicator({ file }) {
const rows = await readWorkbook(file); const report = await this.preflightImport({ file, actionKey: 'import-template-indicator' });
if (rows.length === 0) throw new Error('导入文件没有数据'); return this.executePreparedImport(report);
const [first] = rows;
const templateResult = await queryRecords({ subject: SUBJECT_TEMPLATE, page: 0, page_size: 1, filter: { title: first['模板标题'], 'metadata.type': first['模板类别'] }, mode: 'full' });
const template = recordsFrom(templateResult)[0];
if (!template) throw new Error('没有找到对应的投监表模板');
const subject = unwrapSubject(await loadSubject(SUBJECT_INDICATOR));
const fields = subject?.fields || [];
const recordsToImport = [];
for (const row of rows) {
recordsToImport.push({
title: row['@标题'] || row['指标描述'] || '导入指标',
metadata: await metadataFromIndicatorRow(row, fields),
});
}
const imported = await importRecords(SUBJECT_INDICATOR, recordsToImport);
const indicatorIds = insertedIdsFrom(imported);
if (indicatorIds.length === 0) throw new Error('指标导入未返回记录 ID');
await updateRecord(template._id, { metadata: { associated_indicators: indicatorIds } });
return { count: rows.length };
} }
async importManagedFundRelatedPartiesList({ file }) { async importManagedFundRelatedPartiesList({ file }) {
const rows = await readWorkbook(file); const report = await this.preflightImport({ file, actionKey: 'import-managed-parties' });
if (rows.length === 0) throw new Error('导入文件没有数据'); return this.executePreparedImport(report);
const companyName = String(rows[0]?.['公司名称'] || '').trim();
const fundType = String(rows[0]?.['类型'] || rows[0]?.['类别'] || '').trim();
if (!companyName || !fundType) throw new Error('导入文件缺少公司名称或基金类型');
const mixedCompany = rows.some((row) => String(row?.['公司名称'] || '').trim() !== companyName);
const mixedType = rows.some((row) => String(row?.['类型'] || row?.['类别'] || '').trim() !== fundType);
if (mixedCompany || mixedType) throw new Error('同一批导入数据必须属于同一家公司和基金类型');
const managerResult = await queryRecords({
subject: SUBJECT_CUSTODIAN,
page: 0,
page_size: 1,
filter: { 'metadata.corporate_name': companyName, 'metadata.fund_type': fundType },
mode: 'full',
});
const manager = recordsFrom(managerResult)[0];
if (!manager) throw new Error('托管人或管理人公司不存在');
const relationSubject = unwrapSubject(await loadSubject(SUBJECT_RELATION));
const fields = relationSubject?.fields || [];
if (fields.length === 0) throw new Error('关联关系 Subject 定义加载失败');
const aliases = {
acting_counterparty_including_trading_income_platform: [
'作为交易对手',
'作为交易对手(含银行间交易、交易所固定收益/大宗交易平台)',
'作为交易对手(含银行间交易、交易所固定收益/大宗交易平台)',
],
counterparty_deposit_type: ['作为交易对手(存款类)', '作为交易对手(存款类)'],
managed_investment_portfolio: ['管理的投资组合', '管理的投资组合(包括银行理财产品)', '管理的投资组合(包括银行理财产品)'],
issued_securities: ['发行的证券'],
underwriting_securities: ['承销期内承销的证券'],
underwriting_sub_securities: ['承销期内主承销的证券'],
underwriting_distributed_sub_securities: ['承销期内副主承销/分销的证券'],
};
const readRowValue = (row, field) => {
const keys = [field.label, field.name, ...(aliases[field.name] || [])];
return keys.map((key) => row?.[key]).find((value) => value !== undefined && value !== '') ?? '';
};
const relationRecords = rows.map((row) => {
const metadata = {};
fields.forEach((field) => {
const rawValue = readRowValue(row, field);
metadata[field.name] = normalizeImportedFieldValue(rawValue, field);
});
return {
title: row['标题'] || row['关联方名称'] || '导入关联方',
metadata,
};
});
const imported = await importRecords(SUBJECT_RELATION, relationRecords);
const relationIds = insertedIdsFrom(imported);
if (relationIds.length === 0) throw new Error('关联方导入未返回记录 ID');
await updateRecord(manager._id, {
metadata: {
related_parties: [...new Set([
...uniqueIds(manager.metadata?.related_parties),
...relationIds,
])],
},
});
return { count: rows.length };
} }
} }
......
import * as XLSX from 'xlsx/xlsx.mjs';
const IMPORT_SUBJECTS = {
'import-template': 'indicator_library',
'import-template-indicator': 'indicator_library',
'import-managed-parties': 'relation_related_party'
};
const MULTI_VALUE_SEPARATOR = /[,,、;;\r\n]+/;
const OBJECT_ID_PATTERN = /^[a-f\d]{24}$/i;
const REFERENCE_TYPES = new Set(['reference', 'user', 'document', 'view', 'template', 'task', 'tag', 'group', 'category']);
const FIELD_ALIASES = {
acting_counterparty_including_trading_income_platform: ['作为交易对手', '作为交易对手(含银行间交易、交易所固定收益/大宗交易平台)'],
counterparty_deposit_type: ['作为交易对手(存款类)'],
managed_investment_portfolio: ['管理的投资组合', '管理的投资组合(包括银行理财产品)'],
management_type: ['管理类型']
};
const isEmpty = value => value == null || typeof value === 'string' && !value.trim() || Array.isArray(value) && !value.length;
const textOf = value => String(value ?? '').trim();
const recordsFrom = response => [response?.records, response?.data?.records, response?.data, response].find(Array.isArray) || [];
const unwrapSubject = response => response?.data || response?.subject || response;
export function displayImportValue(value) {
if (isEmpty(value)) return '(空)';
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
function fieldKeys(field) {
return [field.label, field.name, ...(FIELD_ALIASES[field.name] || [])].filter(Boolean);
}
function readField(row, field, fundTypeKey) {
// Legacy files use “类型” for 公募/专户; the relation's management_type also has this label.
const keys = fieldKeys(field).filter(key => !(field.name === 'management_type' && fundTypeKey === '类型' && key === '类型'));
const key = keys.find(candidate => !isEmpty(row.values[candidate])) || keys.find(candidate => candidate in row.values) || keys[0];
return {
key,
value: row.values[key]
};
}
function readWorkbook(workbook, report) {
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
report.sheetName = sheetName || '';
report.date1904 = Boolean(workbook.Workbook?.WBProps?.date1904);
if (!sheet?.['!ref']) return [];
const range = XLSX.utils.decode_range(sheet['!ref']);
report.headerRowNumber = range.s.r + 1;
const headers = new Map();
const unnamedColumns = [];
for (let col = range.s.c; col <= range.e.c; col += 1) {
const address = XLSX.utils.encode_cell({
r: range.s.r,
c: col
});
const header = textOf(sheet[address]?.v);
if (!header) {
unnamedColumns.push(col);
continue;
}
if (headers.has(header)) {
report.issues.push({
rowNumber: range.s.r + 1,
cell: address,
field: header,
value: header,
reason: '表头重复,无法确定字段对应的列',
suggestion: '删除重复列,保留一个同名表头'
});
} else {
headers.set(header, col);
}
}
report.headers = [...headers.keys()];
const rows = [];
for (let index = range.s.r + 1; index <= range.e.r; index += 1) {
const values = {};
const cells = {};
for (const [header, col] of headers) {
const address = XLSX.utils.encode_cell({
r: index,
c: col
});
const cell = sheet[address];
values[header] = cell?.v ?? '';
cells[header] = address;
if (cell?.t === 'e') {
report.issues.push({
rowNumber: index + 1,
cell: address,
field: header,
value: cell.w || '#ERROR!',
reason: '单元格包含 Excel 公式错误',
suggestion: '修正公式后保存,或使用有效的计算结果替换公式'
});
}
}
let hasUnnamedValue = false;
for (const col of unnamedColumns) {
const address = XLSX.utils.encode_cell({
r: index,
c: col
});
const value = sheet[address]?.v;
if (!isEmpty(value)) {
hasUnnamedValue = true;
report.issues.push({
rowNumber: index + 1,
cell: address,
field: `第 ${XLSX.utils.encode_col(col)} 列(缺少表头)`,
value: displayImportValue(value),
reason: '有数据但缺少表头,无法对应字段',
suggestion: '补充系统字段表头,或移除该列内容'
});
}
}
if (hasUnnamedValue || Object.values(values).some(value => !isEmpty(value))) {
rows.push({
rowNumber: index + 1,
values,
cells
});
}
}
return rows;
}
function addIssue(report, row, field, value, reason, suggestion, columnKey = field) {
report.issues.push({
rowNumber: row?.rowNumber || report.headerRowNumber || 1,
cell: row?.cells[columnKey] || '',
field,
value: displayImportValue(value),
reason,
suggestion
});
}
function parseDate(value, date1904) {
let parts;
let parsed;
if (typeof value === 'number') {
parts = XLSX.SSF.parse_date_code(value, {
date1904
});
if (!parts) throw new Error('不是有效的 Excel 日期');
parsed = new Date(Date.UTC(parts.y, parts.m - 1, parts.d, parts.H, parts.M, parts.S, Math.round((parts.u || 0) * 1000)));
} else {
const text = textOf(value);
const match = text.match(/^(\d{4})[-/](\d{1,2})[-/](\d{1,2})(.*)$/);
if (!match) throw new Error('日期格式不正确');
const [, year, month, day, rest] = match;
parts = {
y: Number(year),
m: Number(month),
d: Number(day)
};
const timeParts = rest.match(/^[T ](\d{2}):(\d{2})(?::(\d{2}))?/);
if (timeParts && (Number(timeParts[1]) > 23 || Number(timeParts[2]) > 59 || Number(timeParts[3] || 0) > 59)) throw new Error('时间不存在');
const dateText = `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
if (rest && !/^[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,3})?)?(?:Z|[+-]\d{2}:\d{2})?$/.test(rest)) throw new Error('日期时间格式不正确');
parsed = new Date(rest ? `${dateText}${rest.replace(/^ /, 'T')}${/(Z|[+-]\d{2}:\d{2})$/.test(rest) ? '' : 'Z'}` : `${dateText}T00:00:00.000Z`);
}
const calendar = new Date(Date.UTC(parts.y, parts.m - 1, parts.d));
if (!Number.isFinite(parsed.getTime()) || calendar.getUTCFullYear() !== parts.y || calendar.getUTCMonth() + 1 !== parts.m || calendar.getUTCDate() !== parts.d) throw new Error('日期不存在');
return parsed.toISOString();
}
function splitValues(value, field) {
if (!field.multiple) return [value];
if (Array.isArray(value)) return value;
if (['object', 'form', 'subject'].includes(field.type)) {
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
if (!Array.isArray(parsed)) throw new Error('多值对象必须使用 JSON 数组');
return parsed;
}
if (field.type === 'select' && (field.settings?.options || []).some(option => {
const candidates = typeof option === 'object' ? [option.value, option.label, option.title] : [option];
return candidates.some(candidate => textOf(candidate) === textOf(value));
})) return [value];
return typeof value === 'string' ? value.split(MULTI_VALUE_SEPARATOR).map(textOf).filter(Boolean) : [value];
}
async function validateField(value, field, context, label) {
const {
report,
row,
key,
dependencies,
getSubject,
referenceCache
} = context;
const issue = (reason, suggestion) => addIssue(report, row, label, value, reason, suggestion, key);
if (isEmpty(value)) {
if (field.required || field.isRequired || field.is_required) issue('必填字段为空', `填写“${label}”${key in row.values ? '' : '并补充对应表头'}`);
return field.multiple ? [] : null;
}
let values;
try {
values = splitValues(value, field);
} catch (error) {
issue(error.message === '多值对象必须使用 JSON 数组' ? error.message : 'JSON 格式不正确', '使用合法 JSON;多值对象使用 [{…}, {…}] 格式');
return null;
}
if (!values.length) {
issue('只有分隔符,没有有效值', '填写有效值;可选字段没有内容时请留空');
return null;
}
const converted = [];
for (const item of values) {
const text = textOf(item);
if (field.type === 'select') {
const options = (field.settings?.options || []).map(option => typeof option === 'object' ? {
value: option.value ?? option.label ?? option.title,
label: option.label ?? option.title ?? option.value
} : {
value: option,
label: option
});
const match = options.find(option => textOf(option.value) === text || textOf(option.label) === text);
if (!match) issue(`“${text}”不在允许的选项范围内`, options.length ? `可选值:${options.map(option => option.label).join('、')};多值使用逗号、顿号、分号或换行分隔,单选只能填写一项` : '该字段未配置选项,请联系管理员检查字段定义');else converted.push(match.value);
} else if (field.type === 'number') {
if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i.test(text) || !Number.isFinite(Number(text))) issue(`“${text}”不是有效数字`, '填写数字,不要附加单位、百分号或占位标点');else converted.push(Number(text));
} else if (['date', 'datetime', 'date_time'].includes(field.type)) {
try {
converted.push(parseDate(item, report.date1904));
} catch (error) {
issue(`“${text}”${error.message}`, '使用 Excel 日期或 YYYY-MM-DD / ISO 8601,检查日期是否存在');
}
} else if (field.type === 'boolean') {
const normalized = text.toLowerCase();
if (['true', '1', '是', 'yes'].includes(normalized)) converted.push(true);else if (['false', '0', '否', 'no'].includes(normalized)) converted.push(false);else issue(`“${text}”不是有效的是/否值`, '填写 是/否、true/false 或 1/0;没有内容时请留空');
} else if (REFERENCE_TYPES.has(field.type)) {
const refConfig = field.settings?.subject;
const refSubject = dependencies.getReferenceSubjectName?.(field) || (typeof refConfig === 'object' ? refConfig.name : refConfig);
if (!refSubject || field.type !== 'reference') {
issue('引用对象配置不足,无法核对引用是否存在', '请联系管理员检查该字段的引用对象配置');
continue;
}
const name = typeof item === 'object' ? item?._id : text;
const cacheKey = `${refSubject}:${field.name}:${name}`;
if (!referenceCache.has(cacheKey)) {
const filters = OBJECT_ID_PATTERN.test(name || '') ? [{
_id: name
}] : [{
title: name
}, {
name
}, ...(field.name === 'company_basis' ? [{
'metadata.regulation_name': name
}] : [])];
referenceCache.set(cacheKey, recordsFrom(await dependencies.queryRecords({
subject: refSubject,
page: 0,
page_size: 2,
mode: 'basic',
filter: {
$or: filters,
status: 'active'
}
})));
}
const matches = referenceCache.get(cacheKey);
if (matches.length !== 1 || !matches[0]?._id) issue(matches.length > 1 ? `“${name}”匹配到多条引用记录` : `“${name}”未找到可用的引用记录`, '填写引用对象中已存在的准确名称或记录 ID;检查标点和分隔符');else converted.push(matches[0]._id);
} else if (['object', 'form', 'subject'].includes(field.type)) {
let parsed;
try {
parsed = typeof item === 'string' ? JSON.parse(item) : item;
} catch {
issue('JSON 格式不正确', '检查双引号、逗号和括号,使用合法 JSON');
continue;
}
if (!parsed || typeof parsed !== 'object' || field.type === 'subject' && Array.isArray(parsed)) {
issue('应填写 JSON 对象', '使用 {"字段名": "值"} 格式');
continue;
}
if (field.type === 'subject') {
const subjectName = typeof field.settings?.subject === 'object' ? field.settings.subject.name : field.settings?.subject;
if (!subjectName) {
issue('子对象缺少 Subject 配置', '请联系管理员补充字段配置');
continue;
}
if ((context.subjectPath || []).includes(subjectName)) {
issue('子对象递归定义存在循环', '请联系管理员检查 Subject 配置');
continue;
}
const subject = await getSubject(subjectName);
const nested = {};
const childNames = new Set(subject.fields.map(child => child.name));
for (const childName of Object.keys(parsed)) {
if (!childNames.has(childName)) addIssue(report, row, `${label}.${childName}`, parsed[childName], '子对象包含未定义字段', '使用子对象 Subject 中定义的字段名称', key);
}
for (const child of subject.fields) nested[child.name] = await validateField(parsed[child.name], child, {
...context,
subjectPath: [...(context.subjectPath || []), subjectName]
}, `${label}.${child.label || child.name}`);
converted.push(nested);
} else converted.push(parsed);
} else {
// Text is free form; keep meaningful punctuation and internal whitespace.
converted.push(typeof item === 'string' ? item.trim() : String(item));
}
}
return field.multiple ? [...new Set(converted)] : converted[0] ?? null;
}
async function queryTargets(rows, report, actionKey, dependencies, getSubject) {
const targets = new Map();
const fundTypeKey = report.headers.includes('类别') ? '类别' : '类型';
let expectedBatch;
let typeField;
if (actionKey === 'import-template-indicator') typeField = (await getSubject('investment_supervision_form')).fields.find(field => field.name === 'type');
if (actionKey === 'import-managed-parties') typeField = (await getSubject('custodian_and_manager')).fields.find(field => field.name === 'fund_type');
for (const row of rows) {
const titleKey = actionKey === 'import-template' ? '基金名称' : actionKey === 'import-template-indicator' ? '模板标题' : '公司名称';
const typeKey = actionKey === 'import-template-indicator' ? '模板类别' : fundTypeKey;
const title = textOf(row.values[titleKey]);
let type = actionKey === 'import-template' ? '' : textOf(row.values[typeKey]);
if (!title) addIssue(report, row, titleKey, row.values[titleKey], '目标名称为空或缺少表头', `填写“${titleKey}”,使用系统中的准确名称`);
if (actionKey !== 'import-template') {
if (!type) {
addIssue(report, row, typeKey, row.values[typeKey], '目标类别为空或缺少表头', `填写“${typeKey}”`);
} else if (typeField) {
const storedType = await validateField(type, { ...typeField, multiple: false }, {
report, row, key: typeKey, dependencies, getSubject, referenceCache: new Map(),
}, typeKey);
if (storedType !== null) type = String(storedType);
} else {
throw new Error('目标对象缺少类别字段定义,请联系管理员检查 Subject');
}
}
if (!title || actionKey !== 'import-template' && !type) continue;
const batchKey = JSON.stringify([title, type]);
if (actionKey !== 'import-template') {
expectedBatch ||= batchKey;
if (expectedBatch !== batchKey) addIssue(report, row, `${titleKey} / ${typeKey}`, `${title} / ${type}`, '与本文件第一条数据的目标不一致', '同一文件只能导入同一个模板和类别,或同一家公司和基金类型,请拆分文件');
}
if (!targets.has(batchKey)) {
const subject = actionKey === 'import-template' ? 'fund_product' : actionKey === 'import-template-indicator' ? 'investment_supervision_form' : 'custodian_and_manager';
const filter = actionKey === 'import-template' ? {
title
} : actionKey === 'import-template-indicator' ? {
title,
'metadata.type': type
} : {
'metadata.corporate_name': title,
'metadata.fund_type': type
};
targets.set(batchKey, recordsFrom(await dependencies.queryRecords({
subject,
page: 0,
page_size: 2,
mode: 'full',
filter: {
...filter,
status: 'active'
}
})));
}
const matches = targets.get(batchKey);
if (matches.length !== 1 || !matches[0]?._id) addIssue(report, row, titleKey, title, matches.length > 1 ? '目标匹配到多条记录,无法唯一确定' : '未找到对应的基金、模板或公司记录', '检查名称、类别与标点,使用系统中的准确值');
row.target = matches.length === 1 ? matches[0] : null;
}
return fundTypeKey;
}
export async function buildRiskImportPreflight({
file,
actionKey
}, dependencies) {
const subjectName = IMPORT_SUBJECTS[actionKey];
if (!subjectName) throw new Error('不支持的导入类型');
const report = {
actionKey,
subjectName,
fileName: file?.name || '导入文件',
sheetName: '',
totalRows: 0,
issues: [],
entries: []
};
let workbook;
try {
workbook = XLSX.read(await file.arrayBuffer(), {
type: 'array'
});
} catch {
throw new Error('无法读取导入文件,请使用有效的 Excel 或 CSV 文件');
}
const rows = readWorkbook(workbook, report);
report.totalRows = rows.length;
if (!rows.length) addIssue(report, null, '文件', '', '导入文件没有数据', '保留第一行表头,并在其下填写业务数据');
const subjects = new Map();
const getSubject = async name => {
if (!subjects.has(name)) {
const subject = unwrapSubject(await dependencies.loadSubject(name));
if (!Array.isArray(subject?.fields) || !subject.fields.length) throw new Error(`“${name}”字段定义加载失败,无法完成预检`);
subjects.set(name, subject);
}
return subjects.get(name);
};
if (rows.length) {
const subject = await getSubject(subjectName);
const fundTypeKey = await queryTargets(rows, report, actionKey, dependencies, getSubject);
const referenceCache = new Map();
const fields = subject.fields.filter(field => !['formula'].includes(field.type));
const knownHeaders = new Set(['@标题', '标题', ...(actionKey === 'import-template' ? ['基金名称'] : actionKey === 'import-template-indicator' ? ['模板标题', '模板类别'] : ['公司名称', fundTypeKey]), ...fields.flatMap(fieldKeys)]);
for (const header of report.headers) if (!knownHeaders.has(header)) addIssue(report, null, header, header, '表头不对应当前对象的字段,内容无法导入', '使用最新模板的表头或系统字段名称,移除多余列');
for (const row of rows) {
const metadata = {};
for (const field of fields) {
const {
key,
value
} = readField(row, field, actionKey === 'import-managed-parties' ? fundTypeKey : '');
const rawValue = actionKey === 'import-managed-parties' && field.name === 'type' && isEmpty(value) ? row.values[fundTypeKey] : value;
metadata[field.name] = await validateField(rawValue, field, {
report,
row,
key,
dependencies,
getSubject,
referenceCache,
subjectPath: [subjectName]
}, key || field.label || field.name);
}
report.entries.push({
rowNumber: row.rowNumber,
target: row.target,
record: {
title: textOf(row.values['@标题'] || row.values['标题'] || row.values['指标描述'] || row.values['关联方名称']) || (subjectName === 'indicator_library' ? '导入指标' : '导入关联方'),
metadata
}
});
}
}
report.issues = report.issues.map((issue, index) => ({
...issue,
id: String(index),
sheetName: report.sheetName
}));
const dataRowNumbers = new Set(rows.map(row => row.rowNumber));
report.errorRows = new Set(report.issues.filter(issue => dataRowNumbers.has(issue.rowNumber)).map(issue => issue.rowNumber)).size;
report.canImport = report.totalRows > 0 && report.issues.length === 0;
return report;
}
export function exportImportIssues(report) {
const workbook = XLSX.utils.book_new();
const rows = report.issues.map(issue => ({
工作表: issue.sheetName,
Excel行号: issue.rowNumber,
单元格: issue.cell,
字段: issue.field,
原始值: issue.value,
问题: issue.reason,
修改建议: issue.suggestion
}));
const sheet = XLSX.utils.json_to_sheet(rows);
sheet['!cols'] = [18, 12, 12, 28, 36, 48, 80].map(wch => ({
wch
}));
XLSX.utils.book_append_sheet(workbook, sheet, '预检错误');
XLSX.writeFile(workbook, `${report.fileName.replace(/\.[^.]+$/, '')}-预检错误.xlsx`);
}
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import vm from 'node:vm';
import * as XLSX from 'xlsx/xlsx.mjs';
import { buildRiskImportPreflight } from '../src/services/riskImportPreflight.js';
const FIELD_DEFINITIONS = {
indicator_library: [{
name: 'indicator_description',
label: '指标描述',
type: 'long_text'
}, {
name: 'level',
label: '层级',
type: 'select',
multiple: true,
settings: {
options: ['投资范围', '股票投资', '概述']
}
}, {
name: 'monitoring_frequency',
label: '监控频率',
type: 'select',
multiple: true,
settings: {
options: ['每日', '实时']
}
}, {
name: 'laws_regulations',
label: '法律法规',
type: 'reference',
multiple: true,
settings: {
subject: 'external_law'
}
}, {
name: 'company_basis',
label: '公司内部要求或其他依据',
type: 'reference',
multiple: true,
settings: {
subject: 'internal_regulation'
}
}, {
name: 'sort',
label: '排序',
type: 'number'
}],
investment_supervision_form: [{
name: 'type',
label: '类别',
type: 'select',
settings: {
options: ['公募', '专户']
}
}],
custodian_and_manager: [{
name: 'fund_type',
label: '基金类型',
type: 'select',
settings: {
options: ['公募', '专户']
}
}],
relation_related_party: [{
name: 'related_party_type',
label: '关联方类型',
type: 'select',
settings: {
options: ['管理人', '托管人', '本行主要股东;本行持股5%及5%以上的股东']
}
}, {
name: 'related_party_name',
label: '关联方名称',
type: 'text'
}, {
name: 'type',
label: '类别',
type: 'select',
settings: {
options: ['公募', '专户']
}
}, {
name: 'management_type',
label: '类型',
type: 'select',
settings: {
options: ['管理人', '托管人']
}
}, {
name: 'counterparty_deposit_type',
label: '作为交易对手(存款类)',
type: 'long_text'
}]
};
const TARGETS = {
fund_product: [{
_id: 'fund1',
title: '基金甲',
metadata: {
contract_terms: ['old']
}
}, {
_id: 'fund2',
title: '基金乙',
metadata: {}
}],
investment_supervision_form: [{
_id: 'template1',
title: '模板甲',
metadata: {
type: '公募',
associated_indicators: ['old']
}
}, {
_id: 'template2',
title: '模板乙',
metadata: {
type: '公募'
}
}],
custodian_and_manager: [{
_id: 'manager1',
title: '公司甲',
metadata: {
corporate_name: '公司甲',
fund_type: '公募',
related_parties: ['old']
}
}],
external_law: [{
_id: '691234567890123456789012',
title: '法规甲',
status: 'active'
}],
internal_regulation: [{
_id: '691234567890123456789013',
title: '制度甲',
metadata: {
regulation_name: '内部制度甲'
},
status: 'active'
}]
};
function matches(record, filter) {
return Object.entries(filter).every(([key, value]) => {
if (key === '$or') return value.some(item => matches(record, item));
if (key === 'status') return (record.status || 'active') === value;
const actual = key.split('.').reduce((current, part) => current?.[part], record);
return actual === value;
});
}
function dependenciesFor({
fields = FIELD_DEFINITIONS,
targets = TARGETS
} = {}) {
const calls = [];
return {
calls,
loadSubject: async name => {
calls.push(['subject', name]);
return {
fields: fields[name]
};
},
queryRecords: async query => {
calls.push(['query', query]);
return {
records: (targets[query.subject] || []).filter(record => matches(record, query.filter)).slice(0, query.page_size)
};
}
};
}
function fileFrom(table, options = {}) {
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, XLSX.utils.aoa_to_sheet(table), '业务数据');
if (options.date1904) workbook.Workbook = {
WBProps: {
date1904: true
}
};
const bytes = XLSX.write(workbook, {
type: 'buffer',
bookType: 'xlsx'
});
return {
name: '业务导入.xlsx',
arrayBuffer: async () => bytes
};
}
const preflight = (table, actionKey = 'import-template-indicator', deps = dependenciesFor()) => buildRiskImportPreflight({
actionKey,
file: fileFrom(table)
}, deps);
const templateHeaders = ['模板标题', '模板类别', '指标描述', '层级', '监控频率', '排序'];
const validTemplateRow = ['模板甲', '公募', '说明:1、可保留中文标点;', '投资范围', '每日', 2];
test('中文和英文分隔符均拆分多选,自由文本标点保留', async () => {
for (const separator of [',', ',', '、', ';', ';', '\n', '\r\n']) {
const report = await preflight([templateHeaders, ['模板甲', '公募', validTemplateRow[2], `投资范围${separator}股票投资`, `每日${separator}实时`, 0]]);
assert.equal(report.canImport, true);
assert.deepEqual(report.entries[0].record.metadata.level, ['投资范围', '股票投资']);
assert.equal(report.entries[0].record.metadata.indicator_description, validTemplateRow[2]);
assert.equal(report.entries[0].record.metadata.sort, 0);
}
});
test('集中收集不同字段错误,保留原始值、合法选项和 Excel 行号', async () => {
const report = await preflight([templateHeaders, validTemplateRow, [], ['模板甲', '公募', '第二条', '投资范围,错误选项', '—', 'abc']]);
assert.equal(report.canImport, false);
assert.equal(report.totalRows, 2);
assert.equal(report.errorRows, 1);
assert.equal(report.issues.length, 3);
assert.ok(report.issues.every(issue => issue.rowNumber === 4 && issue.sheetName === '业务数据'));
assert.equal(report.issues[0].cell, 'D4');
assert.equal(report.issues[0].value, '投资范围,错误选项');
assert.match(report.issues[0].suggestion, /股票投资/);
});
test('单选值中的合法标点不能被拆分', async () => {
const report = await preflight([['公司名称', '类别', '关联方名称', '关联方类型', '类型'], ['公司甲', '公募', '关联甲', '本行主要股东;本行持股5%及5%以上的股东', '托管人']], 'import-managed-parties');
assert.equal(report.canImport, true);
assert.equal(report.entries[0].record.metadata.management_type, '托管人');
});
test('托管清单兼容旧“类型”列和中文括号表头,类别写入元数据', async () => {
const report = await preflight([['公司名称', '类型', '关联方名称', '关联方类型', '作为交易对手(存款类)'], ['公司甲', '公募', '关联甲', '管理人', '1、不超过5%;需审批。']], 'import-managed-parties');
assert.equal(report.canImport, true);
assert.equal(report.entries[0].record.metadata.type, '公募');
assert.equal(report.entries[0].record.metadata.management_type, null);
assert.equal(report.entries[0].record.metadata.counterparty_deposit_type, '1、不超过5%;需审批。');
});
test('每行模板目标一致性都会检查', async () => {
const report = await preflight([templateHeaders, validTemplateRow, ['模板乙', '公募', '描述', '概述', '每日', 1]]);
assert.equal(report.canImport, false);
assert.ok(report.issues.some(issue => issue.rowNumber === 3 && issue.reason.includes('不一致')));
});
test('基金初始化允许多只基金,但空白和不存在的目标逐行反馈', async () => {
const report = await preflight([['基金名称', '指标描述'], ['基金甲', '描述'], ['', '描述'], ['不存在基金', '描述']], 'import-template');
assert.equal(report.issues.length, 2);
assert.deepEqual(report.issues.map(issue => issue.rowNumber), [3, 4]);
});
test('目标类别也使用 Subject 的选项校验', async () => {
const report = await preflight([templateHeaders, ['模板甲', '公募,', '描述', '概述', '每日', 1]]);
assert.ok(report.issues.some(issue => issue.field === '模板类别' && issue.reason.includes('选项范围')));
});
test('重复、未知和带首尾空格的表头按真实列处理', async () => {
const good = await preflight([[' 模板标题 ', '模板类别', '指标描述'], ['模板甲', '公募', '描述']]);
assert.equal(good.canImport, true);
const bad = await preflight([['模板标题', '模板类别', '层级', '层级', '监控频率错字'], ['模板甲', '公募', '概述', '概述', '每日']]);
assert.ok(bad.issues.some(issue => issue.cell === 'D1' && issue.reason.includes('重复')));
assert.ok(bad.issues.some(issue => issue.field === '监控频率错字' && issue.rowNumber === 1));
});
test('按运行时规则检查必填,缺少表头也能反馈', async () => {
const fields = {
...FIELD_DEFINITIONS,
indicator_library: [{
name: 'description',
label: '必填描述',
type: 'text',
isRequired: true
}]
};
const report = await preflight([['模板标题', '模板类别'], ['模板甲', '公募']], 'import-template-indicator', dependenciesFor({
fields
}));
assert.equal(report.canImport, false);
assert.equal(report.issues[0].field, '必填描述');
assert.match(report.issues[0].suggestion, /表头/);
});
test('引用按配置对象匹配、只写 ID,并缓存重复查询', async () => {
const deps = dependenciesFor();
const report = await preflight([['模板标题', '模板类别', '法律法规', '公司内部要求或其他依据'], ['模板甲', '公募', '法规甲', '内部制度甲'], ['模板甲', '公募', '法规甲', '内部制度甲']], 'import-template-indicator', deps);
assert.equal(report.canImport, true);
assert.deepEqual(report.entries[0].record.metadata.laws_regulations, ['691234567890123456789012']);
assert.deepEqual(report.entries[0].record.metadata.company_basis, ['691234567890123456789013']);
assert.equal(deps.calls.filter(([kind, query]) => kind === 'query' && query.subject === 'external_law').length, 1);
});
test('重复引用名称、无效 ID 和缺失引用全部反馈', async () => {
const targets = {
...TARGETS,
external_law: [...TARGETS.external_law, {
_id: '691234567890123456789014',
title: '法规甲'
}]
};
const report = await preflight([['模板标题', '模板类别', '法律法规'], ['模板甲', '公募', '法规甲,缺失法规,691234567890123456789099']], 'import-template-indicator', dependenciesFor({
targets
}));
assert.equal(report.issues.length, 3);
assert.ok(report.issues[0].reason.includes('多条'));
assert.ok(report.issues.slice(1).every(issue => issue.reason.includes('未找到')));
});
test('布尔非法值不会静默变成 false,多值数字也不会丢弃坏值', async () => {
const fields = {
...FIELD_DEFINITIONS,
indicator_library: [{
name: 'enabled',
label: '启用',
type: 'boolean'
}, {
name: 'numbers',
label: '数字',
type: 'number',
multiple: true
}]
};
const report = await preflight([['模板标题', '模板类别', '启用', '数字'], ['模板甲', '公募', '或许', '1,abc,2']], 'import-template-indicator', dependenciesFor({
fields
}));
assert.equal(report.issues.length, 2);
const good = await preflight([['模板标题', '模板类别', '启用', '数字'], ['模板甲', '公募', '否', '0,2']], 'import-template-indicator', dependenciesFor({
fields
}));
assert.equal(good.entries[0].record.metadata.enabled, false);
assert.deepEqual(good.entries[0].record.metadata.numbers, [0, 2]);
});
test('多选只有标点时反馈,单选不能填写多项', async () => {
const report = await preflight([templateHeaders, ['模板甲', '公募', '描述', ',;', '每日', 1]]);
assert.ok(report.issues.some(issue => issue.reason.includes('只有分隔符')));
const relation = await preflight([['公司名称', '类别', '关联方类型'], ['公司甲', '公募', '管理人,托管人']], 'import-managed-parties');
assert.ok(relation.issues.some(issue => issue.field === '关联方类型' && issue.reason.includes('选项范围')));
});
test('日期严格校验真实日历和时间,支持 Excel 日期及 ISO', async () => {
const fields = {
...FIELD_DEFINITIONS,
indicator_library: [{
name: 'date',
label: '日期',
type: 'date'
}]
};
const deps = dependenciesFor({
fields
});
for (const invalid of ['2026-02-30', '2026-13-01', '2026-09-30T24:00:00Z', '—']) {
const report = await preflight([['模板标题', '模板类别', '日期'], ['模板甲', '公募', invalid]], 'import-template-indicator', deps);
assert.equal(report.canImport, false, invalid);
}
for (const value of ['2026-09-30', '2026/9/30', 46295, '2026-09-30T08:00:00+08:00']) {
const report = await preflight([['模板标题', '模板类别', '日期'], ['模板甲', '公募', value]], 'import-template-indicator', deps);
assert.equal(report.canImport, true, String(value));
assert.equal(report.entries[0].record.metadata.date, '2026-09-30T00:00:00.000Z');
}
});
test('Excel 1904 日期系统正确转换', async () => {
const deps = dependenciesFor({
fields: {
...FIELD_DEFINITIONS,
indicator_library: [{
name: 'date',
label: '日期',
type: 'date'
}]
}
});
const report = await buildRiskImportPreflight({
actionKey: 'import-template-indicator',
file: fileFrom([['模板标题', '模板类别', '日期'], ['模板甲', '公募', 1]], {
date1904: true
})
}, deps);
assert.equal(report.entries[0].record.metadata.date, '1904-01-02T00:00:00.000Z');
});
test('递归读取子对象规则,JSON 中的逗号保留,子字段错误定位父单元格', async () => {
const fields = {
...FIELD_DEFINITIONS,
indicator_library: [{
name: 'children',
label: '子对象',
type: 'subject',
multiple: true,
settings: {
subject: 'child'
}
}],
child: [{
name: 'name',
label: '名称',
type: 'text',
required: true
}, {
name: 'kind',
label: '类别',
type: 'select',
settings: {
options: ['甲']
}
}]
};
const good = await preflight([['模板标题', '模板类别', '子对象'], ['模板甲', '公募', '[{"name":"包含,逗号","kind":"甲"}]']], 'import-template-indicator', dependenciesFor({
fields
}));
assert.equal(good.canImport, true);
assert.deepEqual(good.entries[0].record.metadata.children, [{
name: '包含,逗号',
kind: '甲'
}]);
const bad = await preflight([['模板标题', '模板类别', '子对象'], ['模板甲', '公募', '[{"kind":"乙"}]']], 'import-template-indicator', dependenciesFor({
fields
}));
assert.equal(bad.issues.length, 2);
assert.ok(bad.issues.every(issue => issue.cell === 'C2' && issue.field.startsWith('子对象.')));
});
test('JSON 格式和类型错误均不会中断其他字段预检', async () => {
const fields = {
...FIELD_DEFINITIONS,
indicator_library: [{
name: 'object',
label: '对象',
type: 'object'
}, {
name: 'number',
label: '数字',
type: 'number'
}]
};
const report = await preflight([['模板标题', '模板类别', '对象', '数字'], ['模板甲', '公募', '{错误JSON}', 'abc']], 'import-template-indicator', dependenciesFor({
fields
}));
assert.equal(report.issues.length, 2);
});
test('空文件、无定义、API 故障和不支持的导入入口不能通过', async () => {
const empty = await preflight([templateHeaders]);
assert.equal(empty.canImport, false);
assert.match(empty.issues[0].reason, /没有数据/);
await assert.rejects(preflight([templateHeaders, validTemplateRow], 'import-template-indicator', dependenciesFor({
fields: {}
})), /字段定义加载失败/);
await assert.rejects(preflight([templateHeaders, validTemplateRow], 'import-template-indicator', {
...dependenciesFor(),
queryRecords: async () => {
throw new Error('没有查询权限');
}
}), /没有查询权限/);
await assert.rejects(preflight([], 'unknown'), /不支持/);
});
async function serviceWith(deps, overrides = {}) {
const writes = [];
const serviceApi = {
batchRemoveRecords: async () => {},
cloneRecord: async () => {},
countRecords: async () => {},
createRecord: async () => {},
executeAction: async () => {},
getSubjectView: async () => {},
getSubjectViewDetail: async () => {},
getDocument: async () => {},
...deps,
importRecords: async (subject, records) => {
writes.push(['import', subject, records]);
return {
insertedIds: Object.fromEntries(records.map((_, index) => [index, `new${writes.length}-${index}`]))
};
},
updateRecord: async (id, payload) => {
writes.push(['update', id, payload]);
},
...overrides
};
delete serviceApi.calls;
const synthetic = values => new vm.SyntheticModule(Object.keys(values), function () {
for (const [key, value] of Object.entries(values)) this.setExport(key, value);
});
const main = new vm.SourceTextModule(await readFile(new URL('../src/services/RiskManagerService.js', import.meta.url), 'utf8'));
const preflightModule = new vm.SourceTextModule(await readFile(new URL('../src/services/riskImportPreflight.js', import.meta.url), 'utf8'));
const xlsx = synthetic(XLSX);
await main.link(specifier => {
if (specifier === 'xlsx' || specifier === 'xlsx/xlsx.mjs') return xlsx;
if (specifier === './riskImportPreflight') return preflightModule;
if (specifier === './DataService') return synthetic(serviceApi);
if (specifier === './BaseService') return synthetic({
default: class {}
});
if (specifier === '@/config/application') return synthetic({
APPLICATION_BASE_PATH: '/risk_manager_new/'
});
if (specifier === '@/utils/subjectView') return synthetic({
getReferenceSubjectName: field => field.settings?.subject || ''
});
throw new Error(`Unexpected dependency: ${specifier}`);
});
await main.evaluate();
return {
service: new main.namespace.default(),
writes
};
}
test('后续行预检失败时,三个入口都不会写入任何记录', async () => {
const {
service,
writes
} = await serviceWith(dependenciesFor());
await assert.rejects(service.importFundProductTemplateIndicator({
file: fileFrom([templateHeaders, validTemplateRow, ['模板甲', '公募', '描述', '错误选项', '每日', 1]])
}), /第 3 行/);
await assert.rejects(service.importFundProductTemplate({
file: fileFrom([['基金名称', '层级'], ['基金甲', '概述'], ['基金甲', '错误选项']])
}), /第 3 行/);
await assert.rejects(service.importManagedFundRelatedPartiesList({
file: fileFrom([['公司名称', '类别', '关联方类型'], ['公司甲', '公募', '管理人'], ['公司甲', '公募', '错误选项']])
}), /第 3 行/);
assert.equal(writes.length, 0);
});
test('预检通过前无写入,确认后同基金指标合并且不丢失已有引用', async () => {
const {
service,
writes
} = await serviceWith(dependenciesFor());
const report = await service.preflightImport({
actionKey: 'import-template',
file: fileFrom([['基金名称', '层级'], ['基金甲', '概述'], ['基金甲', '投资范围']])
});
assert.equal(writes.length, 0);
assert.equal((await service.executePreparedImport(report)).count, 2);
assert.equal(writes.length, 2);
assert.deepEqual(writes[1][2].metadata.contract_terms, ['old', 'new1-0', 'new1-1']);
});
test('模板指标替换与关联关系追加保持原有业务规则', async () => {
const {
service,
writes
} = await serviceWith(dependenciesFor());
await service.importFundProductTemplateIndicator({
file: fileFrom([templateHeaders, validTemplateRow])
});
assert.deepEqual(writes[1][2].metadata.associated_indicators, ['new1-0']);
await service.importManagedFundRelatedPartiesList({
file: fileFrom([['公司名称', '类别', '关联方类型'], ['公司甲', '公募', '管理人']])
});
assert.deepEqual(writes[3][2].metadata.related_parties, ['old', 'new3-0']);
});
test('关联更新失败反馈具体行号、阶段,停止后续组写入', async () => {
const {
service,
writes
} = await serviceWith(dependenciesFor(), {
updateRecord: async () => {
throw new Error('关联更新权限不足');
}
});
const report = await service.preflightImport({
actionKey: 'import-template',
file: fileFrom([['基金名称', '层级'], ['基金甲', '概述'], ['基金乙', '投资范围']])
});
await assert.rejects(service.executePreparedImport(report), error => /第 2 行记录已写入,但关联更新失败/.test(error.message) && /已完整导入 0 行/.test(error.message) && error.importedIds.length === 1);
assert.equal(writes.length, 1);
});
test('接口返回 ID 不完整不能显示导入成功或继续关联更新', async () => {
const {
service,
writes
} = await serviceWith(dependenciesFor(), {
importRecords: async () => ({
insertedIds: {}
})
});
const report = await service.preflightImport({
actionKey: 'import-template-indicator',
file: fileFrom([templateHeaders, validTemplateRow])
});
await assert.rejects(service.executePreparedImport(report), /第 2 行记录写入未完整完成/);
assert.equal(writes.length, 0);
});
test('远程写入失败保留业务错误和已完整成功的行数', async () => {
let imports = 0;
const {
service
} = await serviceWith(dependenciesFor(), {
importRecords: async () => {
imports += 1;
if (imports === 2) throw new Error('服务端主键冲突');
return {
insertedIds: {
0: 'new1'
}
};
}
});
const report = await service.preflightImport({
actionKey: 'import-template',
file: fileFrom([['基金名称', '层级'], ['基金甲', '概述'], ['基金乙', '投资范围']])
});
await assert.rejects(service.executePreparedImport(report), /第 3 行.*服务端主键冲突.*已完整导入 1 行/);
});
test('有值但没有表头的列和 Excel 公式错误不会静默遗漏', async () => {
const missingHeader = await preflight([['模板标题', '模板类别', ''], ['模板甲', '公募', '未识别内容']]);
assert.equal(missingHeader.canImport, false);
assert.ok(missingHeader.issues.some(issue => issue.cell === 'C2' && issue.reason.includes('缺少表头')));
const workbook = XLSX.utils.book_new();
const sheet = XLSX.utils.aoa_to_sheet([['模板标题', '模板类别', '排序'], ['模板甲', '公募', 15]]);
sheet.C2 = {
t: 'e',
v: 15,
w: '#VALUE!'
};
XLSX.utils.book_append_sheet(workbook, sheet, '业务数据');
const bytes = XLSX.write(workbook, {
type: 'buffer',
bookType: 'xlsx'
});
const report = await buildRiskImportPreflight({
actionKey: 'import-template-indicator',
file: {
name: '公式.xlsx',
arrayBuffer: async () => bytes
}
}, dependenciesFor());
assert.equal(report.canImport, false);
assert.ok(report.issues.some(issue => issue.cell === 'C2' && issue.reason.includes('公式错误')));
});
test('多值选择的显示文案含合法标点时保留完整文案并转换存储值', async () => {
const fields = {
...FIELD_DEFINITIONS,
indicator_library: [{
name: 'option',
label: '选项',
type: 'select',
multiple: true,
settings: {
options: [{
value: 'one',
label: '甲、乙'
}]
}
}]
};
const report = await preflight([['模板标题', '模板类别', '选项'], ['模板甲', '公募', '甲、乙']], 'import-template-indicator', dependenciesFor({
fields
}));
assert.equal(report.canImport, true);
assert.deepEqual(report.entries[0].record.metadata.option, ['one']);
});
test('目标类别显示文案按选项的存储值查询', async () => {
const fields = {
...FIELD_DEFINITIONS,
investment_supervision_form: [{ name: 'type', label: '类别', type: 'select', settings: { options: [{ value: 'public', label: '公募' }] } }],
};
const targets = {
...TARGETS,
investment_supervision_form: [{ _id: 'template1', title: '模板甲', metadata: { type: 'public' } }],
};
const report = await preflight([templateHeaders, validTemplateRow], 'import-template-indicator', dependenciesFor({ fields, targets }));
assert.equal(report.canImport, true);
assert.equal(report.entries[0].target._id, 'template1');
});
test('确认导入使用目标的最新引用,不覆盖等待预检期间的新增关联', async () => {
const deps = dependenciesFor();
let changed = false;
const originalQuery = deps.queryRecords;
deps.queryRecords = async (query) => {
const response = await originalQuery(query);
return query.subject === 'fund_product' ? {
records: response.records.map((record) => ({ ...record, metadata: { ...record.metadata, contract_terms: ['old', ...(changed ? ['recent'] : [])] } })),
} : response;
};
const { service, writes } = await serviceWith(deps);
const report = await service.preflightImport({ actionKey: 'import-template', file: fileFrom([['基金名称', '层级'], ['基金甲', '概述']]) });
changed = true;
await service.executePreparedImport(report);
assert.deepEqual(writes[1][2].metadata.contract_terms, ['old', 'recent', 'new1-0']);
});
test('预检后目标被删除,确认时不会向该目标组写入数据', async () => {
const deps = dependenciesFor();
let removed = false;
const originalQuery = deps.queryRecords;
deps.queryRecords = async (query) => removed ? { records: [] } : originalQuery(query);
const { service, writes } = await serviceWith(deps);
const report = await service.preflightImport({ actionKey: 'import-template', file: fileFrom([['基金名称', '层级'], ['基金甲', '概述']]) });
removed = true;
await assert.rejects(service.executePreparedImport(report), /第 2 行目标复核失败,尚未写入该组数据/);
assert.equal(writes.length, 0);
});
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