Skip to content
Projects
Groups
Snippets
Help
Loading...
Sign in / Register
Toggle navigation
R
risk_manager_new
Project
Project
Details
Activity
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Registry
Registry
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
David Yang
risk_manager_new
Commits
19c9905e
Commit
19c9905e
authored
Sep 30, 2026
by
drigle
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: add import preflight validation and confirmation
parent
1e1adc14
Hide whitespace changes
Inline
Side-by-side
Showing
8 changed files
with
1440 additions
and
290 deletions
+1440
-290
package.json
package.json
+1
-0
manifest.json
public/manifest.json
+1
-1
ImportPreflightDialog.jsx
src/pages/risk_management/ImportPreflightDialog.jsx
+156
-0
RiskManagementListPage.jsx
src/pages/risk_management/RiskManagementListPage.jsx
+68
-8
risk-management.business-flow.md
src/pages/risk_management/risk-management.business-flow.md
+24
-11
RiskManagerService.js
src/services/RiskManagerService.js
+65
-270
riskImportPreflight.js
src/services/riskImportPreflight.js
+453
-0
risk-import-preflight.test.mjs
tests/risk-import-preflight.test.mjs
+672
-0
No files found.
package.json
View file @
19c9905e
...
@@ -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"
,
...
...
public/manifest.json
View file @
19c9905e
{
{
"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"
,
...
...
src/pages/risk_management/ImportPreflightDialog.jsx
0 → 100644
View file @
19c9905e
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
>
);
}
src/pages/risk_management/RiskManagementListPage.jsx
View file @
19c9905e
...
@@ -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
}
...
...
src/pages/risk_management/risk-management.business-flow.md
View file @
19c9905e
...
@@ -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. 行操作与异常处理
...
...
src/services/RiskManagerService.js
View file @
19c9905e
...
@@ -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
(
';'
);
page
:
0
,
const
error
=
new
Error
(
message
||
'导入预检未通过,请修正文件后重新预检'
);
page_size
:
1000
,
error
.
importReport
=
report
;
mode
:
'full'
,
throw
error
;
filter
:
{
title
:
{
$in
:
fundTitles
}
},
});
const
fundsByTitle
=
new
Map
(
recordsFrom
(
fundResult
).
map
((
record
)
=>
[
record
.
title
,
record
]));
const
missingFunds
=
fundTitles
.
filter
((
title
)
=>
!
fundsByTitle
.
has
(
title
));
if
(
missingFunds
.
length
>
0
)
{
throw
new
Error
(
`没有找到基金产品:
${
missingFunds
.
join
(
'、'
)}
`
);
}
}
const
subject
=
unwrapSubject
(
await
loadSubject
(
SUBJECT_INDICATOR
));
const
groups
=
new
Map
();
const
fields
=
subject
?.
fields
||
[];
report
.
entries
.
forEach
((
entry
)
=>
{
for
(
const
row
of
rows
)
{
const
targetId
=
entry
.
target
?.
_id
;
const
metadata
=
await
metadataFromIndicatorRow
(
row
,
fields
);
if
(
!
targetId
)
throw
new
Error
(
`Excel 第
${
entry
.
rowNumber
}
行缺少有效的导入目标,请重新预检`
);
const
inserted
=
await
importRecords
(
SUBJECT_INDICATOR
,
[{
title
:
row
[
'@标题'
]
||
row
[
'指标描述'
]
||
'导入指标'
,
metadata
}]);
if
(
!
groups
.
has
(
targetId
))
groups
.
set
(
targetId
,
[]);
const
target
=
fundsByTitle
.
get
(
String
(
row
[
'基金名称'
]).
trim
());
groups
.
get
(
targetId
).
push
(
entry
);
const
newIds
=
insertedIdsFrom
(
inserted
);
});
if
(
target
&&
newIds
.
length
>
0
)
{
let
completedRows
=
0
;
const
current
=
uniqueIds
(
target
.
metadata
?.
contract_terms
);
for
(
const
entries
of
groups
.
values
())
{
await
updateRecord
(
target
.
_id
,
{
metadata
:
{
contract_terms
:
[...
new
Set
([...
current
,
...
newIds
])]
}
});
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_size
:
1
,
mode
:
'full'
,
filter
:
{
_id
:
entries
[
0
].
target
.
_id
,
status
:
'active'
},
});
const
target
=
recordsFrom
(
latest
)[
0
];
if
(
!
target
)
throw
new
Error
(
'导入目标已删除或不可访问,请重新预检'
);
stage
=
'记录写入'
;
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
:
rows
.
length
};
return { count: completedRows };
}
async importFundProductTemplate({ file }) {
const report = await this.preflightImport({ file, actionKey: 'import-template' });
return this.executePreparedImport(report);
}
}
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 };
}
}
}
}
...
...
src/services/riskImportPreflight.js
0 → 100644
View file @
19c9905e
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`
);
}
tests/risk-import-preflight.test.mjs
0 → 100644
View file @
19c9905e
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
);
});
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment