Compare commits

...

27 Commits

Author SHA1 Message Date
wkc
80b2f1b39a 忽略output 2026-03-13 15:18:53 +08:00
wkc
b90c2e53b8 优化员工资产与亲属导入模板 2026-03-13 15:15:27 +08:00
wkc
d63bdbf7b7 新增NAS一键打包部署脚本及Docker部署方案 2026-03-13 15:13:18 +08:00
wkc
77f53cb991 完善Excel模板文本格式与资产状态下拉 2026-03-13 15:10:13 +08:00
wkc
d2f7810b46 合并员工亲属资产前端实施 2026-03-13 13:48:57 +08:00
wkc
1dc6c66ed2 完成员工亲属资产前端实施 2026-03-13 13:48:12 +08:00
wkc
d683522cc1 继续执行家属资产任务合并至dev」} פּראָדוקOops need 2026-03-13 11:06:23 +08:00
wkc
51b32c5d0c 合并员工亲属资产后端实施 2026-03-13 11:03:18 +08:00
wkc
b6df65706c 完成亲属资产后端联调验证 2026-03-13 10:55:38 +08:00
wkc
4a3ea462b4 修复亲属关系列表分页SQL拼接问题 2026-03-13 10:27:47 +08:00
wkc
79fe98f1dd 限制编辑亲属证件信息变更 2026-03-13 10:03:27 +08:00
wkc
4dd7c273f2 修复员工资产空导入返回500问题 2026-03-13 10:02:23 +08:00
wkc
328aaa7ff2 新增亲属资产导入控制器 2026-03-13 09:57:42 +08:00
wkc
936961c705 改造亲属关系聚合保存亲属资产 2026-03-13 09:57:10 +08:00
wkc
54b81191aa 扩展亲属关系聚合资产字段 2026-03-13 09:56:37 +08:00
wkc
e36f13b6b5 实现亲属资产服务与导入服务 2026-03-13 09:47:58 +08:00
wkc
70bdce7bda 新增亲属资产领域对象与映射 2026-03-13 09:47:21 +08:00
wkc
472457c69b 新增亲属资产表结构设计 2026-03-13 09:46:50 +08:00
wkc
58e022fe64 实现员工资产维护前端功能 2026-03-12 18:42:41 +08:00
wkc
3481c37d55 Merge branch 'codex/employee-asset-maintenance-backend' into dev 2026-03-12 16:40:16 +08:00
wkc
bac3cf094e 新增员工资产信息后端实施计划 2026-03-12 16:33:07 +08:00
wkc
4258d74809 新增员工亲属资产维护设计与实施计划 2026-03-12 16:29:07 +08:00
wkc
606aab6bb4 新增员工资产信息设计与建表脚本 2026-03-12 16:08:30 +08:00
wkc
f2c4b6148a Add asset info table schema 2026-03-12 15:53:46 +08:00
wkc
dfcae72cec 更新员工资产信息归户规则设计与计划 2026-03-12 15:52:15 +08:00
wkc
58c59ecd12 新增员工资产信息设计与实施计划 2026-03-12 15:38:04 +08:00
wkc
6a3cfa9ea6 Add file size update after upload 2026-03-12 13:41:40 +08:00
111 changed files with 10767 additions and 114 deletions

9
.env.example Normal file
View File

@@ -0,0 +1,9 @@
# Docker 对外端口
FRONTEND_PORT=62319
BACKEND_PORT=62318
LSFX_MOCK_PORT=62320
# Spring Boot 运行配置
SPRING_PROFILES_ACTIVE=local
RUOYI_PROFILE=/app/data/ruoyi
JAVA_OPTS=-Xms512m -Xmx1024m

7
.gitignore vendored
View File

@@ -40,9 +40,11 @@ nbdist/
######################################################################
# Others
*.log
*.pyc
*.xml.versionsBackup
*.swp
nul
__pycache__/
# Git Worktrees
.worktrees/
@@ -70,3 +72,8 @@ db_config.conf
/.playwright-cli/
# Local deployment bundles
.deploy/
output/

View File

@@ -1,18 +1,17 @@
{
"mcpServers": {
"mysql": {
"args": [
"-y",
"@fhuang/mcp-mysql-server"
],
"command": "npx",
"env": {
"MYSQL_DATABASE": "ccdi",
"MYSQL_HOST": "116.62.17.81",
"MYSQL_PASSWORD": "Kfcx@1234",
"MYSQL_PORT": "3306",
"MYSQL_USER": "root"
}
}
"mysql": {
"command": "node",
"args": [
"C:/Users/wkc/.codex/mcp-tools/mysql-server/node_modules/@fhuang/mcp-mysql-server/build/index.js"
],
"env": {
"MYSQL_DATABASE": "ccdi",
"MYSQL_HOST": "116.62.17.81",
"MYSQL_PASSWORD": "Kfcx@1234",
"MYSQL_PORT": "3306",
"MYSQL_USER": "root"
}
}
}
}

View File

@@ -0,0 +1,996 @@
可疑行为排查模型,,,,,,,,,
序号,模型名称,描述,业务口径,代码,,,,,
1,大额交易,"关注账户(包括本人、亲属、注册主体等账户),房、车采购等大额消费,异常纳税支出等。
除工资收入外的大额流入,大额的额度可在排查参数输入页面进行设置
修改默认限额,且年流水交易额超过默认限额。
大额存现或短时间多次存现
大额转账或频繁转账,大额的定义数字可在排查参数输入页面进行设置","1.备注或对交易对手是房产公司、二手房、车辆销售公司、物业公司等。
2.有税务支出记录
3.同一交易对手(除家庭成员外、本单位代发工资)单笔超过 设置限额或累计交易金额超过 设置限额的资金流入;
4.年流水交易额超过  设置限额;
5.大额存现或短时间多次存现,单笔超过 设置限额;
6.大额转账或频繁转账,单笔超过 设置限额。","---员工及其亲属购买车房支出金额
select id_card
,sum(amount_dr) as amount_dr
from
(
select t1.id_card
,amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
union all
select t1.person_id
,amount_dr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷'
or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
)
group by id_card;
----员工及其亲属税务支出金额
select id_card
,sum(amount_dr) as amount_dr
from
(
select t1.id_card
,amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr > 0
union all
select t1.person_id
,amount_dr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr > 0
)
group by id_card;
--员工与同一交易对手(非亲属)的最大一笔收入交易金额
select id_card
,max(max_amount_cr) as max_amount_cr
from
(
select
t1.id_card
,t1.customer_account_name
,t1.max_amount_cr
from
(
select t1.id_card
,customer_account_name
,max(amount_cr) as max_amount_cr
from ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and t2.le_account_name <> t2.customer_account_name
and customer_account_name not like '%代发%'
and customer_account_name not like '%工资%'
and user_memo not like '%代发%'
and user_memo not like '%工资%'
and user_memo not like '%奖金%'
and user_memo not like '%薪酬%'
and user_memo not like '%薪金%'
and user_memo not like '%补贴%'
and user_memo not like '%薪%'
and user_memo not like '%年终奖%'
and user_memo not like '%年金%'
and user_memo not like '%加班费%'
and user_memo not like '%劳务费%'
and user_memo not like '%劳务外包%'
and user_memo not like '%提成%'
and user_memo not like '%劳务派遣%'
and user_memo not like '%绩效%'
and user_memo not like '%酬劳%'
and user_memo not like '%PAYROLL%'
and user_memo not like '%SALA%'
and user_memo not like '%CPF%'
and user_memo not like '%directors%fees%'
and user_memo not like '%批量代付%'
and cash_type not like '%代发%'
and cash_type not like '%工资%'
and cash_type not like '%劳务费%'
and amount_cr > 0
group by id_card,customer_account_name
) t1
left join ccdi_staff_fmy_relation t2
on t1.id_card = t2.person_id
and t1.customer_account_name = t2.relation_name
where t2.person_id is null;
) group by id_card;
--员工与同一交易对手(非亲属)的累计收入交易金额
select
t1.id_card
,t1.customer_account_name
,t1.amount_cr
from
(
select t1.id_card
,customer_account_name
,sum(amount_cr) as amount_cr
from ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and t2.le_account_name <> t2.customer_account_name
and customer_account_name not like '%代发%'
and customer_account_name not like '%工资%'
and user_memo not like '%代发%'
and user_memo not like '%工资%'
and user_memo not like '%奖金%'
and user_memo not like '%薪酬%'
and user_memo not like '%薪金%'
and user_memo not like '%补贴%'
and user_memo not like '%薪%'
and user_memo not like '%年终奖%'
and user_memo not like '%年金%'
and user_memo not like '%加班费%'
and user_memo not like '%劳务费%'
and user_memo not like '%劳务外包%'
and user_memo not like '%提成%'
and user_memo not like '%劳务派遣%'
and user_memo not like '%绩效%'
and user_memo not like '%酬劳%'
and user_memo not like '%PAYROLL%'
and user_memo not like '%SALA%'
and user_memo not like '%CPF%'
and user_memo not like '%directors%fees%'
and user_memo not like '%批量代付%'
and cash_type not like '%代发%'
and cash_type not like '%工资%'
and cash_type not like '%劳务费%'
group by id_card,customer_account_name
having sum(amount_cr)>0
) t1
left join ccdi_staff_fmy_relation t2
on t1.id_card = t2.person_id
and t1.customer_account_name = t2.relation_name
where t2.person_id is null;
--员工及其亲属 年交易金额
select id_card
,sum(trans_amount) as trans_amount
from
(
select t1.id_card
,amount_dr + amount_cr as trans_amount
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and TRX_DATE >= $$$ --近一年
and t2.le_account_name <> t2.customer_account_name --排除同名交易
union all
select t1.person_id
,amount_dr + amount_cr as trans_amount
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and TRX_DATE >= $$$ --近一年
and t2.le_account_name <> t2.customer_account_name --排除同名交易
)
group by id_card;
---员工及其亲属 最大一笔存现单笔金额
select id_card
,max(amount_cr) as amount_cr
FROM
(
select t1.id_card
,max(amount_cr) as amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr>0
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
group by t1.id_card
union all
select t1.person_id
,max(amount_cr) as amount_cr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and amount_cr>0
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
group by t1.person_id
)
group by id_card
;
--员工及其亲属 存现总金额
select id_card,sum(amount_cr) as amount_cr
from
(
select t1.id_card
,amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr>0
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
union all
select t1.person_id
,amount_cr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and amount_cr>0
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
)group by id_card
;
--员工及其亲属 大额现金存入次数
select id_card,count(1)
from
(
select t1.id_card
,amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr> $$$$$$
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
union all
select t1.person_id
,amount_cr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and amount_cr> $$$$$$$
and (
(((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or
((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%')
)
)group by id_card
;
--员工及其亲属 大额转账次数
select id_card,count(1)
from
(
select t1.id_card
,amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_dr> $$$$$$
and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入')
and user_memo not like '%款%'
and t2.le_account_name <> t2.customer_account_name --排除同名交易
union all
select t1.person_id
,amount_dr
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and amount_dr> $$$$$$
and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入')
and user_memo not like '%款%'
and t2.le_account_name <> t2.customer_account_name --排除同名交易
)
group by id_card
;",,,,,
2,异常交易,"员工及关系人与客户之间非正常资金往来
关系人中没有收入或低收入人员有大额资金交易","1.员工及关系人与客户及关系人之间有超过1000元以上的资金往来客户指信贷类客户包括贷款户、担保人中介库人员包括中介注册的主体及主体关系人。
2.关系人中没有收入或月收入低于 3000元 的人员单笔或累计交易金额超过 10万元。","--员工及其亲属与贷款客户、担保户、中介有异常交易
with loan_cust_acct as (
select t2.aa01ac15
from
(
select substr(nfabcsid,4) as nfabcsid
from odsdb.blfmconf --贷款合同文件
where nfaacost in ('3','5','7') --合同状态
and substr(nfaabrno,1,3) = '902' --机构
and del_f = '0'
group by substr(nfabcsid,4)
) t1
inner join
(
select
aa01ac15 --账号
,aa62cfno
from sjfx_pro.bdfmhqaa_orc
where del_f = 0
and substr(trim(aa47brno),1,3) = '902' --机构号修改
and rcstrs1b <>'9'
AND aa15zhzt ='1' -- 账户状态 1-正常 2-销户 3-新开户 4-结清
group by aa01ac15 ,aa62cfno
) t2
on t1.nfabcsid = t2.aa62cfno
) ,
assure_cust_acct as (
select t2.aa01ac15
from
(
select asseure_sign
from xdzx.assure_infomation
where del_f= '0'
and assure_state <> '2'
and substr(create_org,1,3)='902'
group by asseure_sign
) t1
inner join
(
select
aa01ac15 --账号
,aa03csno
from sjfx_pro.bdfmhqaa_orc
where del_f = 0
and substr(trim(aa47brno),1,3) = '902' --机构号修改
and rcstrs1b <>'9'
AND aa15zhzt ='1' -- 账户状态 1-正常 2-销户 3-新开户 4-结清
group by aa01ac15 ,aa03csno
) t2
on t1.asseure_sign = t2.aa03csno
)
select distinct id_card
from
(
select id_card
,customer_account_no
from
(
select t1.id_card
,customer_account_no
,amount_dr + amount_cr as trans_amount
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and customer_account_no is not null
union all
select t1.person_id
,customer_account_no
,amount_dr + amount_cr as trans_amount
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and customer_account_no is not null
) group by id_card,customer_account_no
having sum(customer_account_no)>1000
) a
where exists (select 1 from loan_cust_acct b on a.customer_account_no = b.aa01ac15)
or exists (select 1 from assure_cust_acct c on a.customer_account_no = c.aa01ac15)
or exists (select 1 from 中介名单 d on a.customer_account_no = d.中介账号);
--员工亲属低收入但交易金额高
select distinct person_id
from
(
select person_id
,relation_cert_no
,avg(amount_cr) as avg_amount_cr
from
(
select t1.person_id
,t1.relation_cert_no
,substr(trx_time,1,7)
,sum(amount_cr) as amount_cr--收入金额
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and t2.le_account_name <> t2.customer_account_name --排除同名交易
group by t1.person_id,t1.relation_cert_no,substr(trx_time,1,7)
)
group by person_id,relation_cert_no
having avg(amount_cr)<=3000 ---月均收入不超过3000
) t1
left join
(
select t1.relation_cert_no
,sum(amount_cr + amount_dr) as trans_amount
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
group by t1.relation_cert_no
having sum(amount_cr + amount_dr) >= 100000
or max(amount_cr) >= 100000
or max(amount_dr) >= 100000
) t2
on t1.relation_cert_no = t2.relation_cert_no
where t2.relation_cert_no is not null
;
",,,,,
3,疑似赌博,"通过多人多次在相近时间有转账、微信转账、支付宝转账发生,且额度在可疑区间。金额区间可在排查设置页面进行设置
大额购买彩票行为
疑似赌球行为
疑似网络赌博行为","1.多人只2人及以上多次指2次以上相近时间指同一天。
2.备注或交易摘要、对手有“游戏、抖币、体彩、福彩”等字眼","--员工 疑似赌博
select distinct id_card
from
(
select t1.id_card,trx_time
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and ((amount_dr>= $$$ and amount_dr<=$$$) or (amount_cr>= $$$ and amount_cr<=$$$)) -----转入转出金额区间
and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|红包|网转|转入' or cash_type rlike '转帐|转账|红包|网转|转入')
and user_memo not like '%款%'
and t2.le_account_name <> t2.customer_account_name --排除同名交易
group by t1.id_card,trx_time
having count(distinct customer_account_name)>=2
and count(1)>=2
);
--员工 网络赌博、体彩
select t1.id_card
,amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_dr> 0
and (user_memo rlike '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注'
or customer_account_name rlike '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注')
;
",,,,,
4,可疑关系,除与配偶、子女外发生特殊金额交易如1314、520。可在排查参数输入页面进行设置,除与配偶、子女外发生特殊金额交易 1314元、520元 等具有特殊含义的金额。,"--员工 可疑关系
select distinct t1.id_card
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
left join
ccdi_staff_fmy_relation t3
on t1.id_card = t3.person_id
and t2.customer_account_name = t3.relation_name
where t2.project_id = PROJECT_ID
and t3.relation_type not in (配偶,子女) --关系类型按实际数据的码值确定
and (amount_dr in (520,1314) or amount_cr in (520,1314))
;",,,,,
5,可疑兼职,除本行工资收入外,有固定收入,"1.除本行工资收入外,每月有固定收入,固定收入金额自行设置。
2.每季或每年从固定交易对手转入金额金额可设区间值如5000-10000。
3.转入资金摘要有“工资”、“分红”、“红利”、“利息(非银行结息)”等收入","--员工 可疑兼职
select t1.id_card
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and bank <> 'ZJRCU'
and (customer_account_name rlike '代发|工资'
or user_memo rlike '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
or cash_type rlike '代发|工资|劳务费'
)
group by t1.id_card
having sum(amount_cr)>0
;
--员工 可疑固定收入
select distinct id_card
from
(
select id_card
,customer_account_name
,count(1) as income_qrt
,stddev(amount_total) as stddev_amount
from
(
select
id_card
,customer_account_name
,trans_qrt
,count(1) as trans_cnt
,sum(amount_cr) as amount_total
from
(
select t1.id_card
,customer_account_name
,amount_cr
,concat(year(trx_time),'-Q',quarter(trx_time)) as trans_qrt --每季度的固定收入
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and amount_cr>0 --每笔收入金额
and t2.le_account_name <> t2.customer_account_name --排除同名交易
)
group by id_card,customer_account_name,trans_qrt
having count(1) > 3 --每季度大于3笔
and sum(amount_cr) between 3000 and 10000 ---该季度总收入,区间自行设置
)
group by id_card,customer_account_name
having count(1) >= 2 --2个及以上季度
and stddev(amount_total)<2000 --标准差小于2000标识稳定收入
);",,,,,
6,可疑财产,"通过分析车险、房屋险、水电费、燃气费、物业费、车位费、租金、卫生费等缴纳判断车产、房产信息
有转出到售房公司交易,但本人及亲属名下无新增房产
新增住房信息(或有入住新房),但无购房交易、无定金、房款、装修款、设计费记录等
购房资金溯源,是否存在异常收入或向客户借入行为
与家庭收入不匹配的豪华房产","1.购房资金溯源,购房前账户资金来源构成。
2.员工及关系人有购房交易,但名下房产无新增登记。
3.员工及关系人有物业缴费记录,但名下房产无新增登记。
4.员工及关系人有5000元以上的纳税记录但名下无房产车产新增登记。
5.有新增登记购房,但无相关购房交易记录。
6.入信新房但近期无购房、装修等支出。
7.与家庭年收入不匹配的豪华房产其评估价值超过家庭年收入的 10倍。","--员工及其亲属购买房产但无资产登记
select t1.id_card
from
(
select id_card
,min(trx_time) as trx_time
from
(
select t1.id_card
,trx_time
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|房款|首付'
or customer_account_name rlike '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
union all
select t1.person_id
,trx_time
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '(购|买).*房|房款|首付'
or customer_account_name rlike '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局')
and amount_dr > 0
)
group by id_card
) t1
left join
(
select person_id
,max(updated_at) as updated_at
from ccdi_asset_info
where asset_main_type = 不动产 --根据具体数据确定码值
and asset_sub_type in (住宅,商铺) --根据具体数据确定码值
and asset_status = 正常 --根据具体数据确定码值
group by person_id
) t2
on t1.id_card = t2.person_id
where t1.trx_time > t2.updated_at --购买时间大于最近一次资产更新时间
or t2.person_id is null;
--有物业缴费记录但无房产登记
select t1.id_card
FROM
(
select id_card
from
(
select t1.id_card
,trx_time
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '物业|物业费|管理费|物业服务|综合服务'
or customer_account_name rlike '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区')
and amount_dr > 0
union all
select t1.person_id
,trx_time
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '物业|物业费|管理费|物业服务|综合服务'
or customer_account_name rlike '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区')
and amount_dr > 0
)
group by id_card
) t1
left join
(
select person_id
,max(updated_at) as updated_at
from ccdi_asset_info
where asset_main_type = 不动产
and asset_sub_type in (住宅,商铺)
and asset_status = 正常
group by person_id
) t2
on t1.id_card = t2.person_id
where t2.person_id is null;
----有5000元以上的纳税记录但无房产登记
select t1.id_card
FROM
(
select id_card
from
(
select t1.id_card
,trx_time
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr >= 5000
union all
select t1.person_id
,trx_time
from
ccdi_staff_fmy_relation t1
inner join
ccdi_bank_statement t2
on t1.relation_cert_no = t2.cret_no
where t1.status = 1
and t2.project_id = PROJECT_ID
and (user_memo rlike '税务|缴税|税款'
or customer_account_name rlike '税务|税务局|国库|国家金库|财政')
and amount_dr >= 5000
)
group by id_card
) t1
left join
(
select person_id
,max(updated_at) as updated_at
from ccdi_asset_info
where asset_main_type = 不动产
and asset_sub_type in (住宅,商铺)
and asset_status = 正常
group by person_id
) t2
on t1.id_card = t2.person_id
where t2.person_id is null;
",,,,,
7,可疑外汇交易,异常购汇、结汇、跨境结汇,单笔购汇、结汇或跨境结汇金额超过限额。,"--员工 可疑外汇交易
select t1.id_card
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and customer_account_name rlike '银行|外汇|售汇|结汇|国家外汇管理局|外汇'
user_memo rlike '购汇|结汇|换汇|外汇|汇率|外币|现汇|结汇水单|外币兑换人民币|结汇入账|外汇结汇|外汇买入|购外币|购买外汇|外币'
group by t1.id_card
having sum(amount_cr)>100000
or sum(amount_dr)>100000 --限额自行设置
;",,,,,
8,可疑付息,客户经理管户的客户在智柜、柜面连续代交利息人数比较多,客户经理管户的客户在智柜、柜面连续 代交利息且代交人数超过 2人。,"---管户经理与贷款客户有交易往来
with cust_loan_duty as (
select t1.id_card
,t2.customer_isn
from
ccdi_base_staff t1
left join
(
select customer_isn,creditor_code
from xdzx.loan_liability
where status = '1'
and product_code <> '15AC'
group by customer_isn,creditor_code
UNION
select t2.nfaacsno,t1.primary_principal
from xdzx.loan_duty t1
left join odsdb.blfmconf t2
on t1.contract_no = t2.nfaacono
where t1.status='1'
and t1.product_code ='15AC'
group by t2.nfaacsno,t1.primary_principal
) t2
on t1.staff_id = t2.creditor_code
)
select cret_no
FROM
(
select t1.cret_no,t2.customer_isn
from ccdi_bank_statement t1
inner join cust_loan_duty t2
on t1.cret_no = t2.id_card
left join
(
select CINOCSNO
,DFANAC19
,CDNOAC19
from odsdb.BWFMDCIM
where rcstrs1b <> '9'
and del_f = '0'
and OWONBRNO like '902%'
group by CINOCSNO,DFANAC19,CDNOAC19
) t3
on t2.customer_isn = t3.CINOCSNO
where project_id = PROJECT_ID
and (t1.customer_account_no = t3.DFANAC19--账号
or t1.customer_account_no = t3.CDNOAC19)--卡号
group by t1.cret_no,t2.customer_isn
having t1.amount_cr +t1.amount_dr > 0
)
group by cret_no
having count(customer_isn) > 2 --交易贷款客户超过两人
;",,,,,
9,可疑采购,"1.提示可能化整为零的采购
2.提示向同一企业或同一人实控的企业采购集中度过高的情况",单笔采购金额超过 10万元提示向同一企业或同一人实控的企业采购集中度过高单个供应商采购额占总采购额比例超过 70%。,"--单笔采购金额大于10万
select t1.id_card
from ccdi_base_staff t1
inner join
(
select applicant_id
from ccdi_purchase_transaction
where actual_amount>100000
UNION
select purchase_leader_id
from ccdi_purchase_transaction
where actual_amount>100000
) t2
on t1.staff_id = t2.applicant_id
;
----单个供应商采购金额占项目总采购额超百分之70
with project_total as (
select purchase_id
,sum(actual_amount) as total_amount
from ccdi_purchase_transaction
group by purchase_id
),
supplier_project AS (
select t1.applicant_id,t1.purchase_leader_id
from
(
select applicant_id
,purchase_leader_id
,purchase_id
,supplier_uscc
,sum(actual_amount) as supply_amount
from ccdi_purchase_transaction
group by applicant_id,purchase_leader_id,purchase_id,supplier_uscc
) t1
left join project_total t2
ON t1.purchase_id = t2.purchase_id
where t1.supply_amount / t2.total_amount > 0.7
)
select t2.id_card
(
select applicant_id
from supplier_project
union
select purchase_leader_id
from supplier_project
) t1
inner join
ccdi_base_staff t2
on t1.applicant_id = t2.staff_id;",,,,,
10,异常行为,"1.每天长时间电话、频繁电话、微信电话
2.丰收互联交易IP地址与属地IP地址段不匹配
3.家庭老人、非家庭关系人银证大额转账
4.微信支付宝频繁提现
5.工资发放后立即转出大部分资金的行为
6.工资发放后除代扣项目外,几乎不使用的情况
7.涉诉情况
8.大额炒股
9.操控他人账户交易
","1.每天电话、微信通话时长超过 2小时 或同一对像通话次数超过5次。
2.丰收互联交易IP地址与属地IP地址段不匹配。
3.家庭老人、非家庭关系人银证大额转账,单笔超过设置限额。
4.微信、支付宝单日提现次数超过设置次 或单日累计提现金额超过 设置限额。
5.工资发放后 24小时内 转出超过 80% 的资金。
6.工资发放后除代扣项目外连续30天 无任何消费或转账记录。
7.大额炒股单次三方资管交易金额超过 100万元。
8.多次代理他人账户交易,或登录员工手机操作他人丰收互联交易。
","--支付宝微信单日提现金额和笔数
select t1.id_card
,t2.trx_time
,count(1) as trans_cnt
,sum(amount_cr) as trans_amt
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (user_memo rlike '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现'
or customer_account_name rlike '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现')
and amount_cr >= 0
group by t1.id_card,t2.trx_time;
--员工 发工资后1天内转出80%以上
select t1.id_card
from
(
select t1.id_card
,t2.trx_time as sala_time
,t2.amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and bank = 'ZJRCU'
and (customer_account_name rlike '代发|工资'
or user_memo rlike '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees'
or cash_type rlike '代发|工资|劳务费'
and amount_cr > 0
)
) t1
left join
(
select t1.id_card
,t2.trx_time
,t2.amount_dr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and bank = 'ZJRCU'
and amount_dr > 0
) t2
on t1.id_card = t2.id_card
where t2.trx_time between t1.sala_time and date_add(t1.sala_time,interval 1 day)
group by t1.id_card
having sum(t2.amount_dr) / sum(t1.amount_cr) >0.8
;
--炒股、单次三方资管交易金额超100万
select distinct id_card
FROM
(
select t1.id_card
,t2.amount_cr
from
ccdi_base_staff t1
inner join
ccdi_bank_statement t2
on t1.id_card = t2.cret_no
where project_id = PROJECT_ID
and (customer_account_name rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财'
or user_memo rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财'
or cash_type rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财'
)
and amount_dr > 1000000
);",,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
,,,,,,,,,
1 可疑行为排查模型
2 序号 模型名称 描述 业务口径 代码
3 1 大额交易 关注账户(包括本人、亲属、注册主体等账户),房、车采购等大额消费,异常纳税支出等。 除工资收入外的大额流入,大额的额度可在排查参数输入页面进行设置 修改默认限额,且年流水交易额超过默认限额。 大额存现或短时间多次存现 大额转账或频繁转账,大额的定义数字可在排查参数输入页面进行设置 1.备注或对交易对手是房产公司、二手房、车辆销售公司、物业公司等。 2.有税务支出记录 3.同一交易对手(除家庭成员外、本单位代发工资)单笔超过 设置限额或累计交易金额超过 设置限额的资金流入; 4.年流水交易额超过  设置限额; 5.大额存现或短时间多次存现,单笔超过 设置限额; 6.大额转账或频繁转账,单笔超过 设置限额。 ---员工及其亲属购买车房支出金额 select id_card ,sum(amount_dr) as amount_dr from ( select t1.id_card ,amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 union all select t1.person_id ,amount_dr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|(购|买).*车|车款|房款|首付|(房|车).*贷' or customer_account_name rlike '汽车销售|汽车金融|4S店|汽贸|车行|房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 ) group by id_card; ----员工及其亲属税务支出金额 select id_card ,sum(amount_dr) as amount_dr from ( select t1.id_card ,amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr > 0 union all select t1.person_id ,amount_dr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr > 0 ) group by id_card; --员工与同一交易对手(非亲属)的最大一笔收入交易金额 select id_card ,max(max_amount_cr) as max_amount_cr from ( select t1.id_card ,t1.customer_account_name ,t1.max_amount_cr from ( select t1.id_card ,customer_account_name ,max(amount_cr) as max_amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and t2.le_account_name <> t2.customer_account_name and customer_account_name not like '%代发%' and customer_account_name not like '%工资%' and user_memo not like '%代发%' and user_memo not like '%工资%' and user_memo not like '%奖金%' and user_memo not like '%薪酬%' and user_memo not like '%薪金%' and user_memo not like '%补贴%' and user_memo not like '%薪%' and user_memo not like '%年终奖%' and user_memo not like '%年金%' and user_memo not like '%加班费%' and user_memo not like '%劳务费%' and user_memo not like '%劳务外包%' and user_memo not like '%提成%' and user_memo not like '%劳务派遣%' and user_memo not like '%绩效%' and user_memo not like '%酬劳%' and user_memo not like '%PAYROLL%' and user_memo not like '%SALA%' and user_memo not like '%CPF%' and user_memo not like '%directors%fees%' and user_memo not like '%批量代付%' and cash_type not like '%代发%' and cash_type not like '%工资%' and cash_type not like '%劳务费%' and amount_cr > 0 group by id_card,customer_account_name ) t1 left join ccdi_staff_fmy_relation t2 on t1.id_card = t2.person_id and t1.customer_account_name = t2.relation_name where t2.person_id is null; ) group by id_card; --员工与同一交易对手(非亲属)的累计收入交易金额 select t1.id_card ,t1.customer_account_name ,t1.amount_cr from ( select t1.id_card ,customer_account_name ,sum(amount_cr) as amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and t2.le_account_name <> t2.customer_account_name and customer_account_name not like '%代发%' and customer_account_name not like '%工资%' and user_memo not like '%代发%' and user_memo not like '%工资%' and user_memo not like '%奖金%' and user_memo not like '%薪酬%' and user_memo not like '%薪金%' and user_memo not like '%补贴%' and user_memo not like '%薪%' and user_memo not like '%年终奖%' and user_memo not like '%年金%' and user_memo not like '%加班费%' and user_memo not like '%劳务费%' and user_memo not like '%劳务外包%' and user_memo not like '%提成%' and user_memo not like '%劳务派遣%' and user_memo not like '%绩效%' and user_memo not like '%酬劳%' and user_memo not like '%PAYROLL%' and user_memo not like '%SALA%' and user_memo not like '%CPF%' and user_memo not like '%directors%fees%' and user_memo not like '%批量代付%' and cash_type not like '%代发%' and cash_type not like '%工资%' and cash_type not like '%劳务费%' group by id_card,customer_account_name having sum(amount_cr)>0 ) t1 left join ccdi_staff_fmy_relation t2 on t1.id_card = t2.person_id and t1.customer_account_name = t2.relation_name where t2.person_id is null; --员工及其亲属 年交易金额 select id_card ,sum(trans_amount) as trans_amount from ( select t1.id_card ,amount_dr + amount_cr as trans_amount from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and TRX_DATE >= $$$ --近一年 and t2.le_account_name <> t2.customer_account_name --排除同名交易 union all select t1.person_id ,amount_dr + amount_cr as trans_amount from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and TRX_DATE >= $$$ --近一年 and t2.le_account_name <> t2.customer_account_name --排除同名交易 ) group by id_card; ---员工及其亲属 最大一笔存现单笔金额 select id_card ,max(amount_cr) as amount_cr FROM ( select t1.id_card ,max(amount_cr) as amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr>0 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) group by t1.id_card union all select t1.person_id ,max(amount_cr) as amount_cr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and amount_cr>0 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) group by t1.person_id ) group by id_card ; --员工及其亲属 存现总金额 select id_card,sum(amount_cr) as amount_cr from ( select t1.id_card ,amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr>0 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) union all select t1.person_id ,amount_cr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and amount_cr>0 and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) )group by id_card ; --员工及其亲属 大额现金存入次数 select id_card,count(1) from ( select t1.id_card ,amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr> $$$$$$ and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) union all select t1.person_id ,amount_cr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and amount_cr> $$$$$$$ and ( (((user_memo like '%现金%' and user_memo not like '%金管理%' and user_memo not like '%金添利%' and user_memo not like '%现金利%' and user_memo not like '%现金宝%' and user_memo not like '%金分析%' ) or user_memo like '%存现%' or user_memo like '%现存%' or cash_type like '%现金%' or cash_type like '%存现%' or cash_type like '%现存%' or cash_type like '%金存入%' or user_memo like '%金存入%' or (user_memo like '%ATM%' and (user_memo like '%存款%' or user_memo like '%转入%')) or (cash_type like '%ATM%' and (cash_type like '%存款%' or cash_type like '%转入%'))) and (customer_account_name = '' or customer_account_name = '无' or customer_account_name like '%存现%') or user_memo like '%DEPOSIT%') or ((customer_account_name = '库存现金' or ((user_memo like '%现金存款%' or user_memo like '%自助存款%' or user_memo like '%CRS存款%' or cash_type like '%现金存款%' or cash_type like '%自助存款%' or cash_type like '%本行CRS存款%' or cash_type like '%柜面%' or user_memo like '%柜面%') and customer_account_name = '' )) or (customer_account_name = '现金' and user_memo not like '%借款%') or user_memo like '%本行ATM%') ) )group by id_card ; --员工及其亲属 大额转账次数 select id_card,count(1) from ( select t1.id_card ,amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_dr> $$$$$$ and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入') and user_memo not like '%款%' and t2.le_account_name <> t2.customer_account_name --排除同名交易 union all select t1.person_id ,amount_dr from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and amount_dr> $$$$$$ and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入' or cash_type rlike '转帐|转账|汇入|转存|红包|汇款|网转|转入') and user_memo not like '%款%' and t2.le_account_name <> t2.customer_account_name --排除同名交易 ) group by id_card ;
4 2 异常交易 员工及关系人与客户之间非正常资金往来 关系人中没有收入或低收入人员有大额资金交易 1.员工及关系人与客户及关系人之间有超过1000元以上的资金往来;客户指信贷类客户包括贷款户、担保人,中介库人员,包括中介注册的主体及主体关系人。 2.关系人中没有收入或月收入低于 3000元 的人员,单笔或累计交易金额超过 10万元。 --员工及其亲属与贷款客户、担保户、中介有异常交易 with loan_cust_acct as ( select t2.aa01ac15 from ( select substr(nfabcsid,4) as nfabcsid from odsdb.blfmconf --贷款合同文件 where nfaacost in ('3','5','7') --合同状态 and substr(nfaabrno,1,3) = '902' --机构 and del_f = '0' group by substr(nfabcsid,4) ) t1 inner join ( select aa01ac15 --账号 ,aa62cfno from sjfx_pro.bdfmhqaa_orc where del_f = 0 and substr(trim(aa47brno),1,3) = '902' --机构号修改 and rcstrs1b <>'9' AND aa15zhzt ='1' -- 账户状态 1-正常 2-销户 3-新开户 4-结清 group by aa01ac15 ,aa62cfno ) t2 on t1.nfabcsid = t2.aa62cfno ) , assure_cust_acct as ( select t2.aa01ac15 from ( select asseure_sign from xdzx.assure_infomation where del_f= '0' and assure_state <> '2' and substr(create_org,1,3)='902' group by asseure_sign ) t1 inner join ( select aa01ac15 --账号 ,aa03csno from sjfx_pro.bdfmhqaa_orc where del_f = 0 and substr(trim(aa47brno),1,3) = '902' --机构号修改 and rcstrs1b <>'9' AND aa15zhzt ='1' -- 账户状态 1-正常 2-销户 3-新开户 4-结清 group by aa01ac15 ,aa03csno ) t2 on t1.asseure_sign = t2.aa03csno ) select distinct id_card from ( select id_card ,customer_account_no from ( select t1.id_card ,customer_account_no ,amount_dr + amount_cr as trans_amount from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and customer_account_no is not null union all select t1.person_id ,customer_account_no ,amount_dr + amount_cr as trans_amount from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and customer_account_no is not null ) group by id_card,customer_account_no having sum(customer_account_no)>1000 ) a where exists (select 1 from loan_cust_acct b on a.customer_account_no = b.aa01ac15) or exists (select 1 from assure_cust_acct c on a.customer_account_no = c.aa01ac15) or exists (select 1 from 中介名单 d on a.customer_account_no = d.中介账号); --员工亲属低收入但交易金额高 select distinct person_id from ( select person_id ,relation_cert_no ,avg(amount_cr) as avg_amount_cr from ( select t1.person_id ,t1.relation_cert_no ,substr(trx_time,1,7) ,sum(amount_cr) as amount_cr--收入金额 from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and t2.le_account_name <> t2.customer_account_name --排除同名交易 group by t1.person_id,t1.relation_cert_no,substr(trx_time,1,7) ) group by person_id,relation_cert_no having avg(amount_cr)<=3000 ---月均收入不超过3000 ) t1 left join ( select t1.relation_cert_no ,sum(amount_cr + amount_dr) as trans_amount from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID group by t1.relation_cert_no having sum(amount_cr + amount_dr) >= 100000 or max(amount_cr) >= 100000 or max(amount_dr) >= 100000 ) t2 on t1.relation_cert_no = t2.relation_cert_no where t2.relation_cert_no is not null ;
5 3 疑似赌博 通过多人多次在相近时间有转账、微信转账、支付宝转账发生,且额度在可疑区间。金额区间可在排查设置页面进行设置 大额购买彩票行为 疑似赌球行为 疑似网络赌博行为 1.多人只2人及以上,多次指2次以上,相近时间指同一天。 2.备注或交易摘要、对手有“游戏、抖币、体彩、福彩”等字眼 --员工 疑似赌博 select distinct id_card from ( select t1.id_card,trx_time from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and ((amount_dr>= $$$ and amount_dr<=$$$) or (amount_cr>= $$$ and amount_cr<=$$$)) -----转入转出金额区间 and (customer_account_name rlike '转账' or user_memo rlike '转帐|转账|红包|网转|转入' or cash_type rlike '转帐|转账|红包|网转|转入') and user_memo not like '%款%' and t2.le_account_name <> t2.customer_account_name --排除同名交易 group by t1.id_card,trx_time having count(distinct customer_account_name)>=2 and count(1)>=2 ); --员工 网络赌博、体彩 select t1.id_card ,amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_dr> 0 and (user_memo rlike '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注' or customer_account_name rlike '游戏|抖币|体彩|福彩|彩票|赌|球|外围|博彩|六合|时时彩|赛车|赌场|筹码|盘口|返水|洗码|庄家|闲家|百家乐|斗牛|炸金花|牌九|麻将|捕鱼|电子游艺|投注') ;
6 4 可疑关系 除与配偶、子女外发生特殊金额交易,如1314、520。可在排查参数输入页面进行设置 除与配偶、子女外,发生特殊金额交易,如 1314元、520元 等具有特殊含义的金额。 --员工 可疑关系 select distinct t1.id_card from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no left join ccdi_staff_fmy_relation t3 on t1.id_card = t3.person_id and t2.customer_account_name = t3.relation_name where t2.project_id = PROJECT_ID and t3.relation_type not in (配偶,子女) --关系类型按实际数据的码值确定 and (amount_dr in (520,1314) or amount_cr in (520,1314)) ;
7 5 可疑兼职 除本行工资收入外,有固定收入 1.除本行工资收入外,每月有固定收入,固定收入金额自行设置。 2.每季或每年从固定交易对手转入金额,金额可设区间值,如5000-10000。 3.转入资金摘要有“工资”、“分红”、“红利”、“利息(非银行结息)”等收入 --员工 可疑兼职 select t1.id_card from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and bank <> 'ZJRCU' and (customer_account_name rlike '代发|工资' or user_memo rlike '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees' or cash_type rlike '代发|工资|劳务费' ) group by t1.id_card having sum(amount_cr)>0 ; --员工 可疑固定收入 select distinct id_card from ( select id_card ,customer_account_name ,count(1) as income_qrt ,stddev(amount_total) as stddev_amount from ( select id_card ,customer_account_name ,trans_qrt ,count(1) as trans_cnt ,sum(amount_cr) as amount_total from ( select t1.id_card ,customer_account_name ,amount_cr ,concat(year(trx_time),'-Q',quarter(trx_time)) as trans_qrt --每季度的固定收入 from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and amount_cr>0 --每笔收入金额 and t2.le_account_name <> t2.customer_account_name --排除同名交易 ) group by id_card,customer_account_name,trans_qrt having count(1) > 3 --每季度大于3笔 and sum(amount_cr) between 3000 and 10000 ---该季度总收入,区间自行设置 ) group by id_card,customer_account_name having count(1) >= 2 --2个及以上季度 and stddev(amount_total)<2000 --标准差小于2000,标识稳定收入 );
8 6 可疑财产 通过分析车险、房屋险、水电费、燃气费、物业费、车位费、租金、卫生费等缴纳判断车产、房产信息 有转出到售房公司交易,但本人及亲属名下无新增房产 新增住房信息(或有入住新房),但无购房交易、无定金、房款、装修款、设计费记录等 购房资金溯源,是否存在异常收入或向客户借入行为 与家庭收入不匹配的豪华房产 1.购房资金溯源,购房前账户资金来源构成。 2.员工及关系人有购房交易,但名下房产无新增登记。 3.员工及关系人有物业缴费记录,但名下房产无新增登记。 4.员工及关系人有5000元以上的纳税记录,但名下无房产车产新增登记。 5.有新增登记购房,但无相关购房交易记录。 6.入信新房但近期无购房、装修等支出。 7.与家庭年收入不匹配的豪华房产,其评估价值超过家庭年收入的 10倍。 --员工及其亲属购买房产但无资产登记 select t1.id_card from ( select id_card ,min(trx_time) as trx_time from ( select t1.id_card ,trx_time from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|房款|首付' or customer_account_name rlike '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 union all select t1.person_id ,trx_time from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '(购|买).*房|房款|首付' or customer_account_name rlike '房地产|置业|置地|地产|房产|不动产|链家|贝壳|我爱我家|房管局') and amount_dr > 0 ) group by id_card ) t1 left join ( select person_id ,max(updated_at) as updated_at from ccdi_asset_info where asset_main_type = 不动产 --根据具体数据确定码值 and asset_sub_type in (住宅,商铺) --根据具体数据确定码值 and asset_status = 正常 --根据具体数据确定码值 group by person_id ) t2 on t1.id_card = t2.person_id where t1.trx_time > t2.updated_at --购买时间大于最近一次资产更新时间 or t2.person_id is null; --有物业缴费记录但无房产登记 select t1.id_card FROM ( select id_card from ( select t1.id_card ,trx_time from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '物业|物业费|管理费|物业服务|综合服务' or customer_account_name rlike '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区') and amount_dr > 0 union all select t1.person_id ,trx_time from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '物业|物业费|管理费|物业服务|综合服务' or customer_account_name rlike '物业|小区|花园|苑|中心|大厦|业委会|业主委员会|置业|房地产|服务中心|管理处|社区') and amount_dr > 0 ) group by id_card ) t1 left join ( select person_id ,max(updated_at) as updated_at from ccdi_asset_info where asset_main_type = 不动产 and asset_sub_type in (住宅,商铺) and asset_status = 正常 group by person_id ) t2 on t1.id_card = t2.person_id where t2.person_id is null; ----有5000元以上的纳税记录但无房产登记 select t1.id_card FROM ( select id_card from ( select t1.id_card ,trx_time from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr >= 5000 union all select t1.person_id ,trx_time from ccdi_staff_fmy_relation t1 inner join ccdi_bank_statement t2 on t1.relation_cert_no = t2.cret_no where t1.status = 1 and t2.project_id = PROJECT_ID and (user_memo rlike '税务|缴税|税款' or customer_account_name rlike '税务|税务局|国库|国家金库|财政') and amount_dr >= 5000 ) group by id_card ) t1 left join ( select person_id ,max(updated_at) as updated_at from ccdi_asset_info where asset_main_type = 不动产 and asset_sub_type in (住宅,商铺) and asset_status = 正常 group by person_id ) t2 on t1.id_card = t2.person_id where t2.person_id is null;
9 7 可疑外汇交易 异常购汇、结汇、跨境结汇 单笔购汇、结汇或跨境结汇金额超过限额。 --员工 可疑外汇交易 select t1.id_card from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and customer_account_name rlike '银行|外汇|售汇|结汇|国家外汇管理局|外汇' user_memo rlike '购汇|结汇|换汇|外汇|汇率|外币|现汇|结汇水单|外币兑换人民币|结汇入账|外汇结汇|外汇买入|购外币|购买外汇|外币' group by t1.id_card having sum(amount_cr)>100000 or sum(amount_dr)>100000 --限额自行设置 ;
10 8 可疑付息 客户经理管户的客户在智柜、柜面连续代交利息人数比较多 客户经理管户的客户在智柜、柜面连续 代交利息,且代交人数超过 2人。 ---管户经理与贷款客户有交易往来 with cust_loan_duty as ( select t1.id_card ,t2.customer_isn from ccdi_base_staff t1 left join ( select customer_isn,creditor_code from xdzx.loan_liability where status = '1' and product_code <> '15AC' group by customer_isn,creditor_code UNION select t2.nfaacsno,t1.primary_principal from xdzx.loan_duty t1 left join odsdb.blfmconf t2 on t1.contract_no = t2.nfaacono where t1.status='1' and t1.product_code ='15AC' group by t2.nfaacsno,t1.primary_principal ) t2 on t1.staff_id = t2.creditor_code ) select cret_no FROM ( select t1.cret_no,t2.customer_isn from ccdi_bank_statement t1 inner join cust_loan_duty t2 on t1.cret_no = t2.id_card left join ( select CINOCSNO ,DFANAC19 ,CDNOAC19 from odsdb.BWFMDCIM where rcstrs1b <> '9' and del_f = '0' and OWONBRNO like '902%' group by CINOCSNO,DFANAC19,CDNOAC19 ) t3 on t2.customer_isn = t3.CINOCSNO where project_id = PROJECT_ID and (t1.customer_account_no = t3.DFANAC19--账号 or t1.customer_account_no = t3.CDNOAC19)--卡号 group by t1.cret_no,t2.customer_isn having t1.amount_cr +t1.amount_dr > 0 ) group by cret_no having count(customer_isn) > 2 --交易贷款客户超过两人 ;
11 9 可疑采购 1.提示可能化整为零的采购 2.提示向同一企业或同一人实控的企业采购集中度过高的情况 单笔采购金额超过 10万元;提示向同一企业或同一人实控的企业采购集中度过高,单个供应商采购额占总采购额比例超过 70%。 --单笔采购金额大于10万 select t1.id_card from ccdi_base_staff t1 inner join ( select applicant_id from ccdi_purchase_transaction where actual_amount>100000 UNION select purchase_leader_id from ccdi_purchase_transaction where actual_amount>100000 ) t2 on t1.staff_id = t2.applicant_id ; ----单个供应商采购金额占项目总采购额超百分之70 with project_total as ( select purchase_id ,sum(actual_amount) as total_amount from ccdi_purchase_transaction group by purchase_id ), supplier_project AS ( select t1.applicant_id,t1.purchase_leader_id from ( select applicant_id ,purchase_leader_id ,purchase_id ,supplier_uscc ,sum(actual_amount) as supply_amount from ccdi_purchase_transaction group by applicant_id,purchase_leader_id,purchase_id,supplier_uscc ) t1 left join project_total t2 ON t1.purchase_id = t2.purchase_id where t1.supply_amount / t2.total_amount > 0.7 ) select t2.id_card ( select applicant_id from supplier_project union select purchase_leader_id from supplier_project ) t1 inner join ccdi_base_staff t2 on t1.applicant_id = t2.staff_id;
12 10 异常行为 1.每天长时间电话、频繁电话、微信电话 2.丰收互联交易IP地址与属地IP地址段不匹配 3.家庭老人、非家庭关系人银证大额转账 4.微信支付宝频繁提现 5.工资发放后立即转出大部分资金的行为 6.工资发放后除代扣项目外,几乎不使用的情况 7.涉诉情况 8.大额炒股 9.操控他人账户交易 1.每天电话、微信通话时长超过 2小时 或同一对像通话次数超过5次。 2.丰收互联交易IP地址与属地IP地址段不匹配。 3.家庭老人、非家庭关系人银证大额转账,单笔超过设置限额。 4.微信、支付宝单日提现次数超过设置次 或单日累计提现金额超过 设置限额。 5.工资发放后 24小时内 转出超过 80% 的资金。 6.工资发放后除代扣项目外,连续30天 无任何消费或转账记录。 7.大额炒股,单次三方资管交易金额超过 100万元。 8.多次代理他人账户交易,或登录员工手机操作他人丰收互联交易。 --支付宝微信单日提现金额和笔数 select t1.id_card ,t2.trx_time ,count(1) as trans_cnt ,sum(amount_cr) as trans_amt from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (user_memo rlike '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现' or customer_account_name rlike '财付通|微信零钱|微信|wechat|WeChat|Tenpay|支付宝|Alipay|提现') and amount_cr >= 0 group by t1.id_card,t2.trx_time; --员工 发工资后1天内转出80%以上 select t1.id_card from ( select t1.id_card ,t2.trx_time as sala_time ,t2.amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and bank = 'ZJRCU' and (customer_account_name rlike '代发|工资' or user_memo rlike '代发|工资|分红|红利|奖金|薪酬|薪金|补贴|薪|年终奖|年金|加班费|劳务费|劳务外包|提成|劳务派遣|绩效|酬劳|批量代付|PAYROLL|SALA|CPF|directors.*fees' or cash_type rlike '代发|工资|劳务费' and amount_cr > 0 ) ) t1 left join ( select t1.id_card ,t2.trx_time ,t2.amount_dr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and bank = 'ZJRCU' and amount_dr > 0 ) t2 on t1.id_card = t2.id_card where t2.trx_time between t1.sala_time and date_add(t1.sala_time,interval 1 day) group by t1.id_card having sum(t2.amount_dr) / sum(t1.amount_cr) >0.8 ; --炒股、单次三方资管交易金额超100万 select distinct id_card FROM ( select t1.id_card ,t2.amount_cr from ccdi_base_staff t1 inner join ccdi_bank_statement t2 on t1.id_card = t2.cret_no where project_id = PROJECT_ID and (customer_account_name rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财' or user_memo rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财' or cash_type rlike '证券|国泰君安|中信建投|中金|基金|期货|信托|同花顺|理财' ) and amount_dr > 1000000 );
13
14
15
16
17
18
19
20
21
22

View File

@@ -0,0 +1,16 @@
,资产信息表 ccdi_asset_info,,,,,
英文字段名,中文字段名,字段类型,字段长度,是否主键,是否允许为空,字段说明/备注
asset_id,资产ID,VARCHAR,32,,,唯一标识每一条资产记录
person_id,客户ID,VARCHAR,32,,,关联客户主表的ID用于建立外键索引
asset_main_type,资产大类,VARCHAR,20,,,如:不动产、金融资产、流动资产、其他
asset_sub_type,资产小类,VARCHAR,50,,,如:住宅、商铺、股票、基金、存款、汽车、收藏品
asset_name,资产名称,VARCHAR,200,,,具体的资产描述如“XX小区3号楼2单元”、“招商银行活期”
ownership_ratio,产权占比,DECIMAL,"5,2",,,持有比例(%),如 100.00 表示全资50.00 表示共有
purchase_eval_date,购买/评估日期,DATE,-,,,资产购置日期或最近一次评估日期
original_value,资产原值,DECIMAL,"15,2",,,购买时的历史成本(单位:元)
current_value,当前估值,DECIMAL,"15,2",,,核心字段:最新的市场估值(单位:元)
valuation_date,估值截止日期,DATE,-,,,当前估值的有效截止日期
asset_status,资产状态,VARCHAR,10,,,正常、冻结、处置中、报废
remarks,备注,VARCHAR,500,,,其他补充信息
created_at,创建时间,DATETIME,-,,,记录创建的时间
updated_at,更新时间,DATETIME,-,,,记录最后更新的时间
1 资产信息表 :ccdi_asset_info
2 英文字段名 中文字段名 字段类型 字段长度 是否主键 是否允许为空 字段说明/备注
3 asset_id 资产ID VARCHAR 32 唯一标识每一条资产记录
4 person_id 客户ID VARCHAR 32 关联客户主表的ID,用于建立外键索引
5 asset_main_type 资产大类 VARCHAR 20 如:不动产、金融资产、流动资产、其他
6 asset_sub_type 资产小类 VARCHAR 50 如:住宅、商铺、股票、基金、存款、汽车、收藏品
7 asset_name 资产名称 VARCHAR 200 具体的资产描述,如“XX小区3号楼2单元”、“招商银行活期”
8 ownership_ratio 产权占比 DECIMAL 5,2 持有比例(%),如 100.00 表示全资,50.00 表示共有
9 purchase_eval_date 购买/评估日期 DATE - 资产购置日期或最近一次评估日期
10 original_value 资产原值 DECIMAL 15,2 购买时的历史成本(单位:元)
11 current_value 当前估值 DECIMAL 15,2 核心字段:最新的市场估值(单位:元)
12 valuation_date 估值截止日期 DATE - 当前估值的有效截止日期
13 asset_status 资产状态 VARCHAR 10 正常、冻结、处置中、报废
14 remarks 备注 VARCHAR 500 其他补充信息
15 created_at 创建时间 DATETIME - 记录创建的时间
16 updated_at 更新时间 DATETIME - 记录最后更新的时间

View File

@@ -45,6 +45,13 @@
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,102 @@
package com.ruoyi.info.collection.controller;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.vo.AssetImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportResultVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.service.ICcdiAssetInfoImportService;
import com.ruoyi.info.collection.utils.EasyExcelUtil;
import com.ruoyi.common.annotation.Log;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.ArrayList;
import java.util.List;
/**
* 亲属资产信息Controller
*
* @author ruoyi
* @date 2026-03-12
*/
@Tag(name = "亲属资产信息管理")
@RestController
@RequestMapping("/ccdi/assetInfo")
public class CcdiAssetInfoController extends BaseController {
@Resource
private ICcdiAssetInfoImportService assetInfoImportService;
/**
* 下载导入模板
*/
@Operation(summary = "下载亲属资产导入模板")
@PostMapping("/importTemplate")
public void importTemplate(HttpServletResponse response) {
EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiAssetInfoExcel.class, "亲属资产信息");
}
/**
* 导入亲属资产信息
*/
@Operation(summary = "导入亲属资产信息")
@PreAuthorize("@ss.hasPermi('ccdi:staffFmyRelation:import')")
@Log(title = "亲属资产信息", businessType = BusinessType.IMPORT)
@PostMapping("/importData")
public AjaxResult importData(MultipartFile file) throws Exception {
List<CcdiAssetInfoExcel> list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiAssetInfoExcel.class);
if (list == null || list.isEmpty()) {
return warn("至少需要一条数据");
}
String taskId = assetInfoImportService.importAssetInfo(list);
ImportResultVO result = new ImportResultVO();
result.setTaskId(taskId);
result.setStatus("PROCESSING");
result.setMessage("导入任务已提交,正在后台处理");
return AjaxResult.success("导入任务已提交,正在后台处理", result);
}
/**
* 查询导入状态
*/
@Operation(summary = "查询亲属资产导入状态")
@PreAuthorize("@ss.hasPermi('ccdi:staffFmyRelation:import')")
@GetMapping("/importStatus/{taskId}")
public AjaxResult getImportStatus(@PathVariable String taskId) {
return success(assetInfoImportService.getImportStatus(taskId));
}
/**
* 查询导入失败记录
*/
@Operation(summary = "查询亲属资产导入失败记录")
@PreAuthorize("@ss.hasPermi('ccdi:staffFmyRelation:import')")
@GetMapping("/importFailures/{taskId}")
public TableDataInfo getImportFailures(
@PathVariable String taskId,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
List<AssetImportFailureVO> failures = assetInfoImportService.getImportFailures(taskId);
int fromIndex = (pageNum - 1) * pageSize;
int toIndex = Math.min(fromIndex + pageSize, failures.size());
if (fromIndex >= failures.size()) {
return getDataTable(new ArrayList<>(), failures.size());
}
return getDataTable(failures.subList(fromIndex, toIndex), failures.size());
}
}

View File

@@ -0,0 +1,83 @@
package com.ruoyi.info.collection.domain;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 资产信息对象 ccdi_asset_info
*
* @author ruoyi
* @date 2026-03-12
*/
@Data
@TableName("ccdi_asset_info")
public class CcdiAssetInfo implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 资产ID */
@TableId(type = IdType.AUTO)
private Long assetId;
/** 归属员工证件号 */
private String familyId;
/** 资产实际持有人证件号 */
private String personId;
/** 资产大类 */
private String assetMainType;
/** 资产小类 */
private String assetSubType;
/** 资产名称 */
private String assetName;
/** 产权占比 */
private BigDecimal ownershipRatio;
/** 购买/评估日期 */
private Date purchaseEvalDate;
/** 资产原值 */
private BigDecimal originalValue;
/** 当前估值 */
private BigDecimal currentValue;
/** 估值截止日期 */
private Date valuationDate;
/** 资产状态 */
private String assetStatus;
/** 备注 */
private String remarks;
/** 创建者 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/** 更新者 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}

View File

@@ -0,0 +1,79 @@
package com.ruoyi.info.collection.domain.dto;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 亲属资产信息DTO
*
* @author ruoyi
* @date 2026-03-12
*/
@Data
@Schema(description = "亲属资产信息")
public class CcdiAssetInfoDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 资产大类 */
@NotBlank(message = "资产大类不能为空")
@Size(max = 20, message = "资产大类长度不能超过20个字符")
@Schema(description = "资产大类")
private String assetMainType;
/** 资产小类 */
@NotBlank(message = "资产小类不能为空")
@Size(max = 50, message = "资产小类长度不能超过50个字符")
@Schema(description = "资产小类")
private String assetSubType;
/** 资产名称 */
@NotBlank(message = "资产名称不能为空")
@Size(max = 200, message = "资产名称长度不能超过200个字符")
@Schema(description = "资产名称")
private String assetName;
/** 产权占比 */
@Schema(description = "产权占比")
private BigDecimal ownershipRatio;
/** 购买/评估日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
@Schema(description = "购买/评估日期")
private Date purchaseEvalDate;
/** 资产原值 */
@Schema(description = "资产原值")
private BigDecimal originalValue;
/** 当前估值 */
@NotNull(message = "当前估值不能为空")
@Schema(description = "当前估值")
private BigDecimal currentValue;
/** 估值截止日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
@Schema(description = "估值截止日期")
private Date valuationDate;
/** 资产状态 */
@NotBlank(message = "资产状态不能为空")
@Size(max = 10, message = "资产状态长度不能超过10个字符")
@Schema(description = "资产状态")
private String assetStatus;
/** 备注 */
@Size(max = 500, message = "备注长度不能超过500个字符")
@Schema(description = "备注")
private String remarks;
}

View File

@@ -9,6 +9,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 员工信息新增 DTO
@@ -51,4 +52,7 @@ public class CcdiBaseStaffAddDTO implements Serializable {
/** 状态 */
@NotBlank(message = "状态不能为空")
private String status;
/** 资产信息列表 */
private List<CcdiAssetInfoDTO> assetInfoList;
}

View File

@@ -9,6 +9,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 员工信息编辑 DTO
@@ -49,4 +50,7 @@ public class CcdiBaseStaffEditDTO implements Serializable {
/** 状态 */
private String status;
/** 资产信息列表 */
private List<CcdiAssetInfoDTO> assetInfoList;
}

View File

@@ -10,6 +10,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 员工亲属关系新增DTO
@@ -116,4 +117,8 @@ public class CcdiStaffFmyRelationAddDTO implements Serializable {
/** 备注 */
@Schema(description = "备注")
private String remark;
/** 亲属资产列表 */
@Schema(description = "亲属资产列表")
private List<CcdiAssetInfoDTO> assetInfoList;
}

View File

@@ -11,6 +11,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 员工亲属关系编辑DTO
@@ -122,4 +123,8 @@ public class CcdiStaffFmyRelationEditDTO implements Serializable {
/** 备注 */
@Schema(description = "备注")
private String remark;
/** 亲属资产列表 */
@Schema(description = "亲属资产列表")
private List<CcdiAssetInfoDTO> assetInfoList;
}

View File

@@ -0,0 +1,89 @@
package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 亲属资产信息Excel导入导出对象
*
* @author ruoyi
* @date 2026-03-12
*/
@Data
public class CcdiAssetInfoExcel implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 关系人证件号 */
@ExcelProperty(value = "关系人证件号*", index = 0)
@ColumnWidth(22)
@Required
@TextFormat
private String personId;
/** 资产大类 */
@ExcelProperty(value = "资产大类*", index = 1)
@ColumnWidth(16)
@Required
private String assetMainType;
/** 资产小类 */
@ExcelProperty(value = "资产小类*", index = 2)
@ColumnWidth(18)
@Required
private String assetSubType;
/** 资产名称 */
@ExcelProperty(value = "资产名称*", index = 3)
@ColumnWidth(24)
@Required
private String assetName;
/** 产权占比 */
@ExcelProperty(value = "产权占比", index = 4)
@ColumnWidth(12)
private BigDecimal ownershipRatio;
/** 购买/评估日期 */
@ExcelProperty(value = "购买/评估日期", index = 5)
@ColumnWidth(16)
private Date purchaseEvalDate;
/** 资产原值 */
@ExcelProperty(value = "资产原值", index = 6)
@ColumnWidth(16)
private BigDecimal originalValue;
/** 当前估值 */
@ExcelProperty(value = "当前估值*", index = 7)
@ColumnWidth(16)
@Required
private BigDecimal currentValue;
/** 估值截止日期 */
@ExcelProperty(value = "估值截止日期", index = 8)
@ColumnWidth(16)
private Date valuationDate;
/** 资产状态 */
@ExcelProperty(value = "资产状态*", index = 9)
@ColumnWidth(14)
@DictDropdown(dictType = "ccdi_asset_status")
@Required
private String assetStatus;
/** 备注 */
@ExcelProperty(value = "备注", index = 10)
@ColumnWidth(28)
private String remarks;
}

View File

@@ -4,6 +4,7 @@ import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data;
import java.io.Serial;
@@ -63,6 +64,7 @@ public class CcdiStaffFmyRelationExcel implements Serializable {
@ExcelProperty(value = "关系人证件号码*", index = 6)
@ColumnWidth(20)
@Required
@TextFormat
private String relationCertNo;
/** 手机号码1 */

View File

@@ -0,0 +1,49 @@
package com.ruoyi.info.collection.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
* 亲属资产信息导入失败记录VO
*
* @author ruoyi
* @date 2026-03-12
*/
@Data
@Schema(description = "亲属资产信息导入失败记录")
public class AssetImportFailureVO {
/** 关系人证件号 */
@Schema(description = "关系人证件号")
private String personId;
/** 资产大类 */
@Schema(description = "资产大类")
private String assetMainType;
/** 资产小类 */
@Schema(description = "资产小类")
private String assetSubType;
/** 资产名称 */
@Schema(description = "资产名称")
private String assetName;
/** 产权占比 */
@Schema(description = "产权占比")
private BigDecimal ownershipRatio;
/** 当前估值 */
@Schema(description = "当前估值")
private BigDecimal currentValue;
/** 资产状态 */
@Schema(description = "资产状态")
private String assetStatus;
/** 错误信息 */
@Schema(description = "错误信息")
private String errorMessage;
}

View File

@@ -0,0 +1,70 @@
package com.ruoyi.info.collection.domain.vo;
import com.fasterxml.jackson.annotation.JsonFormat;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.math.BigDecimal;
import java.util.Date;
/**
* 资产信息VO
*
* @author ruoyi
* @date 2026-03-12
*/
@Data
@Schema(description = "资产信息")
public class CcdiAssetInfoVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 资产实际持有人证件号 */
@Schema(description = "资产实际持有人证件号")
private String personId;
/** 资产大类 */
@Schema(description = "资产大类")
private String assetMainType;
/** 资产小类 */
@Schema(description = "资产小类")
private String assetSubType;
/** 资产名称 */
@Schema(description = "资产名称")
private String assetName;
/** 产权占比 */
@Schema(description = "产权占比")
private BigDecimal ownershipRatio;
/** 购买/评估日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
@Schema(description = "购买/评估日期")
private Date purchaseEvalDate;
/** 资产原值 */
@Schema(description = "资产原值")
private BigDecimal originalValue;
/** 当前估值 */
@Schema(description = "当前估值")
private BigDecimal currentValue;
/** 估值截止日期 */
@JsonFormat(pattern = "yyyy-MM-dd")
@Schema(description = "估值截止日期")
private Date valuationDate;
/** 资产状态 */
@Schema(description = "资产状态")
private String assetStatus;
/** 备注 */
@Schema(description = "备注")
private String remarks;
}

View File

@@ -5,6 +5,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 员工信息 VO
@@ -56,4 +57,7 @@ public class CcdiBaseStaffVO implements Serializable {
/** 更新者 */
private String updateBy;
/** 资产信息列表 */
private List<CcdiAssetInfoVO> assetInfoList;
}

View File

@@ -7,6 +7,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 员工亲属关系VO
@@ -141,4 +142,8 @@ public class CcdiStaffFmyRelationVO implements Serializable {
/** 更新人 */
@Schema(description = "更新人")
private String updatedBy;
/** 亲属资产列表 */
@Schema(description = "亲属资产列表")
private List<CcdiAssetInfoVO> assetInfoList;
}

View File

@@ -0,0 +1,78 @@
package com.ruoyi.info.collection.handler;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.write.handler.SheetWriteHandler;
import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
import com.ruoyi.common.annotation.TextFormat;
import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataFormat;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* Excel文本列写入处理器
*
* @author ruoyi
*/
@Slf4j
public class TextFormatWriteHandler implements SheetWriteHandler {
private final Class<?> modelClass;
public TextFormatWriteHandler(Class<?> modelClass) {
this.modelClass = modelClass;
}
@Override
public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) {
Workbook workbook = writeWorkbookHolder.getWorkbook();
Sheet sheet = writeSheetHolder.getSheet();
CellStyle textStyle = createTextStyle(workbook);
for (Field field : getAllFields(modelClass)) {
if (field.getAnnotation(TextFormat.class) == null) {
continue;
}
Integer columnIndex = getColumnIndex(field);
if (columnIndex == null) {
log.warn("字段[{}]没有指定@ExcelProperty的index跳过文本格式设置", field.getName());
continue;
}
sheet.setDefaultColumnStyle(columnIndex, textStyle);
log.info("成功将列[{}]设置为文本格式", columnIndex);
}
}
private CellStyle createTextStyle(Workbook workbook) {
DataFormat dataFormat = workbook.createDataFormat();
CellStyle style = workbook.createCellStyle();
style.setDataFormat(dataFormat.getFormat("@"));
return style;
}
private List<Field> getAllFields(Class<?> clazz) {
List<Field> fields = new ArrayList<>();
while (clazz != null && clazz != Object.class) {
fields.addAll(Arrays.asList(clazz.getDeclaredFields()));
clazz = clazz.getSuperclass();
}
return fields;
}
private Integer getColumnIndex(Field field) {
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
if (excelProperty != null && excelProperty.index() >= 0) {
return excelProperty.index();
}
return null;
}
}

View File

@@ -0,0 +1,85 @@
package com.ruoyi.info.collection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import org.apache.ibatis.annotations.Param;
import java.util.List;
import java.util.Map;
/**
* 资产信息 数据层
*
* @author ruoyi
* @date 2026-03-12
*/
public interface CcdiAssetInfoMapper extends BaseMapper<CcdiAssetInfo> {
/**
* 按归属员工身份证号查询资产列表
*
* @param familyId 归属员工身份证号
* @return 资产列表
*/
List<CcdiAssetInfo> selectByFamilyId(@Param("familyId") String familyId);
/**
* 按归属键查询资产列表
*
* @param familyId 归属员工证件号
* @param personId 资产实际持有人证件号
* @return 资产列表
*/
List<CcdiAssetInfo> selectByFamilyIdAndPersonId(@Param("familyId") String familyId,
@Param("personId") String personId);
/**
* 按归属员工身份证号删除资产
*
* @param familyId 归属员工身份证号
* @return 影响行数
*/
int deleteByFamilyId(@Param("familyId") String familyId);
/**
* 按归属键删除资产
*
* @param familyId 归属员工证件号
* @param personId 资产实际持有人证件号
* @return 影响行数
*/
int deleteByFamilyIdAndPersonId(@Param("familyId") String familyId,
@Param("personId") String personId);
/**
* 批量删除归属员工资产
*
* @param familyIds 归属员工身份证号列表
* @return 影响行数
*/
int deleteByFamilyIds(@Param("familyIds") List<String> familyIds);
/**
* 按资产实际持有人身份证号查询资产列表
*
* @param personId 资产实际持有人身份证号
* @return 资产列表
*/
List<CcdiAssetInfo> selectByPersonId(@Param("personId") String personId);
/**
* 批量插入资产数据
*
* @param list 资产列表
* @return 影响行数
*/
int insertBatch(@Param("list") List<CcdiAssetInfo> list);
/**
* 按关系人证件号查询归属员工候选
*
* @param relationCertNos 关系人证件号列表
* @return 归属映射
*/
List<Map<String, String>> selectOwnerCandidatesByRelationCertNos(@Param("relationCertNos") List<String> relationCertNos);
}

View File

@@ -0,0 +1,49 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.vo.AssetImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import java.util.List;
/**
* 员工资产信息异步导入 服务层
*
* @author ruoyi
* @date 2026-03-12
*/
public interface ICcdiAssetInfoImportService {
/**
* 启动异步导入任务
*
* @param excelList Excel实体列表
* @return 任务ID
*/
String importAssetInfo(List<CcdiAssetInfoExcel> excelList);
/**
* 异步导入员工资产数据
*
* @param excelList Excel实体列表
* @param taskId 任务ID
* @param userName 用户名
*/
void importAssetInfoAsync(List<CcdiAssetInfoExcel> excelList, String taskId, String userName);
/**
* 查询导入状态
*
* @param taskId 任务ID
* @return 导入状态
*/
ImportStatusVO getImportStatus(String taskId);
/**
* 查询导入失败记录
*
* @param taskId 任务ID
* @return 失败记录列表
*/
List<AssetImportFailureVO> getImportFailures(String taskId);
}

View File

@@ -0,0 +1,74 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import com.ruoyi.info.collection.domain.dto.CcdiAssetInfoDTO;
import java.util.List;
/**
* 员工资产信息 服务层
*
* @author ruoyi
* @date 2026-03-12
*/
public interface ICcdiAssetInfoService {
/**
* 按归属员工身份证号查询资产列表
*
* @param familyId 归属员工身份证号
* @return 资产列表
*/
List<CcdiAssetInfo> selectByFamilyId(String familyId);
/**
* 按归属键查询资产列表
*
* @param familyId 归属员工证件号
* @param personId 资产实际持有人证件号
* @return 资产列表
*/
List<CcdiAssetInfo> selectByFamilyIdAndPersonId(String familyId, String personId);
/**
* 按归属员工身份证号覆盖资产列表
*
* @param familyId 归属员工身份证号
* @param assetInfoList 资产列表
*/
void replaceByFamilyId(String familyId, List<CcdiAssetInfoDTO> assetInfoList);
/**
* 按归属键覆盖资产列表
*
* @param familyId 归属员工证件号
* @param personId 资产实际持有人证件号
* @param assetInfoList 资产列表
*/
void replaceByFamilyIdAndPersonId(String familyId, String personId, List<CcdiAssetInfoDTO> assetInfoList);
/**
* 删除单个员工资产
*
* @param familyId 归属员工身份证号
* @return 影响行数
*/
int deleteByFamilyId(String familyId);
/**
* 按归属键删除资产
*
* @param familyId 归属员工证件号
* @param personId 资产实际持有人证件号
* @return 影响行数
*/
int deleteByFamilyIdAndPersonId(String familyId, String personId);
/**
* 批量删除员工资产
*
* @param familyIds 归属员工身份证号列表
* @return 影响行数
*/
int deleteByFamilyIds(List<String> familyIds);
}

View File

@@ -0,0 +1,226 @@
package com.ruoyi.info.collection.service.impl;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.vo.AssetImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportResult;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.mapper.CcdiAssetInfoMapper;
import com.ruoyi.info.collection.service.ICcdiAssetInfoImportService;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.context.annotation.Lazy;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 亲属资产信息异步导入服务层处理
*
* @author ruoyi
* @date 2026-03-12
*/
@Service
@EnableAsync
public class CcdiAssetInfoImportServiceImpl implements ICcdiAssetInfoImportService {
private static final String STATUS_KEY_PREFIX = "import:assetInfo:";
@Resource
private CcdiAssetInfoMapper assetInfoMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Lazy
@Resource
private ICcdiAssetInfoImportService assetInfoImportService;
@Override
@Transactional
public String importAssetInfo(List<CcdiAssetInfoExcel> excelList) {
if (excelList == null || excelList.isEmpty()) {
throw new RuntimeException("至少需要一条数据");
}
String taskId = UUID.randomUUID().toString();
Map<String, Object> statusData = new HashMap<>();
statusData.put("taskId", taskId);
statusData.put("status", "PROCESSING");
statusData.put("totalCount", excelList.size());
statusData.put("successCount", 0);
statusData.put("failureCount", 0);
statusData.put("progress", 0);
statusData.put("startTime", System.currentTimeMillis());
statusData.put("message", "正在处理...");
String statusKey = STATUS_KEY_PREFIX + taskId;
redisTemplate.opsForHash().putAll(statusKey, statusData);
redisTemplate.expire(statusKey, 7, TimeUnit.DAYS);
assetInfoImportService.importAssetInfoAsync(excelList, taskId, currentUserName());
return taskId;
}
@Override
@Async
@Transactional
public void importAssetInfoAsync(List<CcdiAssetInfoExcel> excelList, String taskId, String userName) {
List<CcdiAssetInfo> successList = new ArrayList<>();
List<AssetImportFailureVO> failures = new ArrayList<>();
List<String> personIds = excelList.stream()
.map(CcdiAssetInfoExcel::getPersonId)
.filter(StringUtils::isNotEmpty)
.distinct()
.toList();
Map<String, Set<String>> ownerMap = buildOwnerMap(personIds);
for (CcdiAssetInfoExcel excel : excelList) {
try {
validateExcel(excel);
Set<String> familyIds = ownerMap.get(excel.getPersonId());
if (familyIds == null || familyIds.isEmpty()) {
throw new RuntimeException("未找到亲属资产归属员工");
}
if (familyIds.size() > 1) {
throw new RuntimeException("亲属资产归属员工不唯一");
}
CcdiAssetInfo assetInfo = new CcdiAssetInfo();
BeanUtils.copyProperties(excel, assetInfo);
assetInfo.setFamilyId(familyIds.iterator().next());
assetInfo.setCreateBy(userName);
assetInfo.setUpdateBy(userName);
successList.add(assetInfo);
} catch (Exception e) {
AssetImportFailureVO failureVO = new AssetImportFailureVO();
BeanUtils.copyProperties(excel, failureVO);
failureVO.setErrorMessage(e.getMessage());
failures.add(failureVO);
}
}
if (!successList.isEmpty()) {
assetInfoMapper.insertBatch(successList);
}
if (!failures.isEmpty()) {
redisTemplate.opsForValue().set(STATUS_KEY_PREFIX + taskId + ":failures", failures, 7, TimeUnit.DAYS);
}
ImportResult result = new ImportResult();
result.setTotalCount(excelList.size());
result.setSuccessCount(successList.size());
result.setFailureCount(failures.size());
updateImportStatus(taskId, failures.isEmpty() ? "SUCCESS" : "PARTIAL_SUCCESS", result);
}
@Override
public ImportStatusVO getImportStatus(String taskId) {
String key = STATUS_KEY_PREFIX + taskId;
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
throw new RuntimeException("任务不存在或已过期");
}
Map<Object, Object> statusMap = redisTemplate.opsForHash().entries(key);
ImportStatusVO statusVO = new ImportStatusVO();
statusVO.setTaskId((String) statusMap.get("taskId"));
statusVO.setStatus((String) statusMap.get("status"));
statusVO.setTotalCount((Integer) statusMap.get("totalCount"));
statusVO.setSuccessCount((Integer) statusMap.get("successCount"));
statusVO.setFailureCount((Integer) statusMap.get("failureCount"));
statusVO.setProgress((Integer) statusMap.get("progress"));
statusVO.setStartTime((Long) statusMap.get("startTime"));
statusVO.setEndTime((Long) statusMap.get("endTime"));
statusVO.setMessage((String) statusMap.get("message"));
return statusVO;
}
@Override
public List<AssetImportFailureVO> getImportFailures(String taskId) {
Object failuresObj = redisTemplate.opsForValue().get(STATUS_KEY_PREFIX + taskId + ":failures");
if (failuresObj == null) {
return List.of();
}
return JSON.parseArray(JSON.toJSONString(failuresObj), AssetImportFailureVO.class);
}
private Map<String, Set<String>> buildOwnerMap(List<String> personIds) {
Map<String, Set<String>> result = new LinkedHashMap<>();
mergeOwnerMappings(result, assetInfoMapper.selectOwnerCandidatesByRelationCertNos(personIds));
return result;
}
private void mergeOwnerMappings(Map<String, Set<String>> result, List<Map<String, String>> mappings) {
if (mappings == null) {
return;
}
for (Map<String, String> mapping : mappings) {
String personId = mapping.get("personId");
String familyId = mapping.get("familyId");
if (StringUtils.isEmpty(personId) || StringUtils.isEmpty(familyId)) {
continue;
}
result.computeIfAbsent(personId, key -> new java.util.LinkedHashSet<>()).add(familyId);
}
}
private void validateExcel(CcdiAssetInfoExcel excel) {
if (StringUtils.isEmpty(excel.getPersonId())) {
throw new RuntimeException("关系人证件号不能为空");
}
if (StringUtils.isEmpty(excel.getAssetMainType())) {
throw new RuntimeException("资产大类不能为空");
}
if (StringUtils.isEmpty(excel.getAssetSubType())) {
throw new RuntimeException("资产小类不能为空");
}
if (StringUtils.isEmpty(excel.getAssetName())) {
throw new RuntimeException("资产名称不能为空");
}
if (excel.getCurrentValue() == null) {
throw new RuntimeException("当前估值不能为空");
}
if (StringUtils.isEmpty(excel.getAssetStatus())) {
throw new RuntimeException("资产状态不能为空");
}
}
private void updateImportStatus(String taskId, String status, ImportResult result) {
Map<String, Object> statusData = new HashMap<>();
statusData.put("status", status);
statusData.put("successCount", result.getSuccessCount());
statusData.put("failureCount", result.getFailureCount());
statusData.put("progress", 100);
statusData.put("endTime", System.currentTimeMillis());
statusData.put("message", "SUCCESS".equals(status)
? "全部成功!共导入" + result.getTotalCount() + "条数据"
: "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "");
redisTemplate.opsForHash().putAll(STATUS_KEY_PREFIX + taskId, statusData);
}
private String currentUserName() {
try {
return SecurityUtils.getUsername();
} catch (Exception e) {
return "system";
}
}
}

View File

@@ -0,0 +1,129 @@
package com.ruoyi.info.collection.service.impl;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import com.ruoyi.info.collection.domain.dto.CcdiAssetInfoDTO;
import com.ruoyi.info.collection.mapper.CcdiAssetInfoMapper;
import com.ruoyi.info.collection.service.ICcdiAssetInfoService;
import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.Collections;
import java.util.List;
/**
* 员工资产信息 服务层处理
*
* @author ruoyi
* @date 2026-03-12
*/
@Service
public class CcdiAssetInfoServiceImpl implements ICcdiAssetInfoService {
@Resource
private CcdiAssetInfoMapper assetInfoMapper;
@Override
public List<CcdiAssetInfo> selectByFamilyId(String familyId) {
return assetInfoMapper.selectByFamilyId(familyId);
}
@Override
public List<CcdiAssetInfo> selectByFamilyIdAndPersonId(String familyId, String personId) {
return assetInfoMapper.selectByFamilyIdAndPersonId(familyId, personId);
}
@Override
@Transactional
public void replaceByFamilyId(String familyId, List<CcdiAssetInfoDTO> assetInfoList) {
replaceAssets(familyId, familyId, assetInfoList, true);
}
@Override
@Transactional
public void replaceByFamilyIdAndPersonId(String familyId, String personId, List<CcdiAssetInfoDTO> assetInfoList) {
replaceAssets(familyId, personId, assetInfoList, false);
}
@Override
public int deleteByFamilyId(String familyId) {
return assetInfoMapper.deleteByFamilyId(familyId);
}
@Override
public int deleteByFamilyIdAndPersonId(String familyId, String personId) {
return assetInfoMapper.deleteByFamilyIdAndPersonId(familyId, personId);
}
@Override
public int deleteByFamilyIds(List<String> familyIds) {
if (familyIds == null || familyIds.isEmpty()) {
return 0;
}
return assetInfoMapper.deleteByFamilyIds(familyIds);
}
private void replaceAssets(String familyId, String personId, List<CcdiAssetInfoDTO> assetInfoList, boolean deleteByFamilyOnly) {
if (deleteByFamilyOnly) {
assetInfoMapper.deleteByFamilyId(familyId);
} else {
assetInfoMapper.deleteByFamilyIdAndPersonId(familyId, personId);
}
if (assetInfoList == null || assetInfoList.isEmpty()) {
return;
}
List<CcdiAssetInfo> saveList = assetInfoList.stream()
.filter(item -> !isEmptyRow(item))
.map(item -> {
validateAsset(item);
return toEntity(familyId, personId, item);
})
.toList();
if (!saveList.isEmpty()) {
assetInfoMapper.insertBatch(saveList);
}
}
private CcdiAssetInfo toEntity(String familyId, String personId, CcdiAssetInfoDTO dto) {
CcdiAssetInfo assetInfo = new CcdiAssetInfo();
BeanUtils.copyProperties(dto, assetInfo);
assetInfo.setFamilyId(familyId);
assetInfo.setPersonId(personId);
return assetInfo;
}
private boolean isEmptyRow(CcdiAssetInfoDTO dto) {
return StringUtils.isEmpty(dto.getAssetMainType())
&& StringUtils.isEmpty(dto.getAssetSubType())
&& StringUtils.isEmpty(dto.getAssetName())
&& dto.getCurrentValue() == null
&& StringUtils.isEmpty(dto.getAssetStatus())
&& dto.getOwnershipRatio() == null
&& dto.getPurchaseEvalDate() == null
&& dto.getOriginalValue() == null
&& dto.getValuationDate() == null
&& StringUtils.isEmpty(dto.getRemarks());
}
private void validateAsset(CcdiAssetInfoDTO dto) {
if (StringUtils.isEmpty(dto.getAssetMainType())) {
throw new RuntimeException("资产大类不能为空");
}
if (StringUtils.isEmpty(dto.getAssetSubType())) {
throw new RuntimeException("资产小类不能为空");
}
if (StringUtils.isEmpty(dto.getAssetName())) {
throw new RuntimeException("资产名称不能为空");
}
if (dto.getCurrentValue() == null) {
throw new RuntimeException("当前估值不能为空");
}
if (StringUtils.isEmpty(dto.getAssetStatus())) {
throw new RuntimeException("资产状态不能为空");
}
}
}

View File

@@ -7,10 +7,12 @@ import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO;
import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffOptionVO;
import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffVO;
import com.ruoyi.info.collection.enums.EmployeeStatus;
import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper;
import com.ruoyi.info.collection.service.ICcdiAssetInfoService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffImportService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffService;
import com.ruoyi.common.utils.StringUtils;
@@ -40,6 +42,9 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private ICcdiAssetInfoService assetInfoService;
/**
* 查询员工列表
*
@@ -104,7 +109,15 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
@Override
public CcdiBaseStaffVO selectBaseStaffById(Long staffId) {
CcdiBaseStaff staff = baseStaffMapper.selectById(staffId);
return convertToVO(staff);
CcdiBaseStaffVO vo = convertToVO(staff);
if (staff != null) {
vo.setAssetInfoList(assetInfoService.selectByFamilyId(staff.getIdCard()).stream().map(asset -> {
CcdiAssetInfoVO assetInfoVO = new CcdiAssetInfoVO();
BeanUtils.copyProperties(asset, assetInfoVO);
return assetInfoVO;
}).toList());
}
return vo;
}
/**
@@ -131,6 +144,7 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
CcdiBaseStaff staff = new CcdiBaseStaff();
BeanUtils.copyProperties(addDTO, staff);
int result = baseStaffMapper.insert(staff);
assetInfoService.replaceByFamilyId(addDTO.getIdCard(), addDTO.getAssetInfoList());
return result;
}
@@ -144,6 +158,11 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
@Override
@Transactional
public int updateBaseStaff(CcdiBaseStaffEditDTO editDTO) {
CcdiBaseStaff existing = baseStaffMapper.selectById(editDTO.getStaffId());
if (existing == null) {
throw new RuntimeException("员工不存在");
}
// 检查身份证号唯一性(排除自己)
if (StringUtils.isNotEmpty(editDTO.getIdCard())) {
LambdaQueryWrapper<CcdiBaseStaff> wrapper = new LambdaQueryWrapper<>();
@@ -158,6 +177,11 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
BeanUtils.copyProperties(editDTO, staff);
int result = baseStaffMapper.updateById(staff);
if (!StringUtils.equals(existing.getIdCard(), editDTO.getIdCard())) {
assetInfoService.deleteByFamilyId(existing.getIdCard());
}
assetInfoService.replaceByFamilyId(editDTO.getIdCard(), editDTO.getAssetInfoList());
return result;
}
@@ -170,7 +194,13 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
@Override
@Transactional
public int deleteBaseStaffByIds(Long[] staffIds) {
return baseStaffMapper.deleteBatchIds(List.of(staffIds));
List<Long> idList = List.of(staffIds);
List<String> familyIds = baseStaffMapper.selectBatchIds(idList).stream()
.map(CcdiBaseStaff::getIdCard)
.filter(StringUtils::isNotEmpty)
.toList();
assetInfoService.deleteByFamilyIds(familyIds);
return baseStaffMapper.deleteBatchIds(idList);
}
/**

View File

@@ -1,13 +1,16 @@
package com.ruoyi.info.collection.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation;
import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel;
import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO;
import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper;
import com.ruoyi.info.collection.service.ICcdiAssetInfoService;
import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationImportService;
import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationService;
import com.ruoyi.common.utils.SecurityUtils;
@@ -19,6 +22,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -42,6 +46,9 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Resource
private ICcdiAssetInfoService assetInfoService;
/**
* 查询员工亲属关系列表
*
@@ -90,7 +97,19 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
*/
@Override
public CcdiStaffFmyRelationVO selectRelationById(Long id) {
return relationMapper.selectRelationById(id);
CcdiStaffFmyRelationVO relationVO = relationMapper.selectRelationById(id);
if (relationVO == null) {
return null;
}
if (StringUtils.isNotEmpty(relationVO.getPersonId()) && StringUtils.isNotEmpty(relationVO.getRelationCertNo())) {
List<CcdiAssetInfoVO> assetInfoList = assetInfoService
.selectByFamilyIdAndPersonId(relationVO.getPersonId(), relationVO.getRelationCertNo())
.stream()
.map(this::toAssetInfoVO)
.toList();
relationVO.setAssetInfoList(assetInfoList);
}
return relationVO;
}
/**
@@ -114,6 +133,7 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
}
int result = relationMapper.insert(relation);
assetInfoService.replaceByFamilyIdAndPersonId(addDTO.getPersonId(), addDTO.getRelationCertNo(), addDTO.getAssetInfoList());
return result;
}
@@ -126,9 +146,19 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
@Override
@Transactional
public int updateRelation(CcdiStaffFmyRelationEditDTO editDTO) {
CcdiStaffFmyRelation existing = relationMapper.selectById(editDTO.getId());
if (existing == null) {
throw new RuntimeException("员工亲属关系不存在");
}
if (!StringUtils.equals(existing.getRelationCertType(), editDTO.getRelationCertType())
|| !StringUtils.equals(existing.getRelationCertNo(), editDTO.getRelationCertNo())) {
throw new RuntimeException("关系人证件类型/证件号码不允许修改");
}
CcdiStaffFmyRelation relation = new CcdiStaffFmyRelation();
BeanUtils.copyProperties(editDTO, relation);
int result = relationMapper.updateById(relation);
assetInfoService.replaceByFamilyIdAndPersonId(editDTO.getPersonId(), editDTO.getRelationCertNo(), editDTO.getAssetInfoList());
return result;
}
@@ -141,7 +171,15 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
@Override
@Transactional
public int deleteRelationByIds(Long[] ids) {
return relationMapper.deleteBatchIds(java.util.List.of(ids));
List<Long> idList = java.util.List.of(ids);
List<CcdiStaffFmyRelation> relationList = relationMapper.selectBatchIds(idList);
for (CcdiStaffFmyRelation relation : relationList) {
if (StringUtils.isEmpty(relation.getPersonId()) || StringUtils.isEmpty(relation.getRelationCertNo())) {
continue;
}
assetInfoService.deleteByFamilyIdAndPersonId(relation.getPersonId(), relation.getRelationCertNo());
}
return relationMapper.deleteBatchIds(idList);
}
/**
@@ -184,4 +222,10 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
return taskId;
}
private CcdiAssetInfoVO toAssetInfoVO(CcdiAssetInfo assetInfo) {
CcdiAssetInfoVO assetInfoVO = new CcdiAssetInfoVO();
BeanUtils.copyProperties(assetInfo, assetInfoVO);
return assetInfoVO;
}
}

View File

@@ -5,6 +5,7 @@ import com.alibaba.excel.write.handler.WriteHandler;
import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
import com.ruoyi.info.collection.handler.DictDropdownWriteHandler;
import com.ruoyi.info.collection.handler.RequiredFieldWriteHandler;
import com.ruoyi.info.collection.handler.TextFormatWriteHandler;
import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;
@@ -174,6 +175,7 @@ public class EasyExcelUtil {
.sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new DictDropdownWriteHandler(clazz))
.registerWriteHandler(new TextFormatWriteHandler(clazz))
.registerWriteHandler(new RequiredFieldWriteHandler(clazz))
.doWrite(List.of());
} catch (IOException e) {
@@ -200,6 +202,7 @@ public class EasyExcelUtil {
.sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new DictDropdownWriteHandler(clazz))
.registerWriteHandler(new TextFormatWriteHandler(clazz))
.registerWriteHandler(new RequiredFieldWriteHandler(clazz))
.doWrite(List.of());
} catch (IOException e) {
@@ -226,6 +229,7 @@ public class EasyExcelUtil {
.sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new DictDropdownWriteHandler(clazz))
.registerWriteHandler(new TextFormatWriteHandler(clazz))
.registerWriteHandler(new RequiredFieldWriteHandler(clazz))
.doWrite(list);
} catch (IOException e) {
@@ -253,6 +257,7 @@ public class EasyExcelUtil {
.sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new DictDropdownWriteHandler(clazz))
.registerWriteHandler(new TextFormatWriteHandler(clazz))
.registerWriteHandler(new RequiredFieldWriteHandler(clazz))
.doWrite(list);
} catch (IOException e) {

View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.info.collection.mapper.CcdiAssetInfoMapper">
<resultMap id="CcdiAssetInfoResultMap" type="com.ruoyi.info.collection.domain.CcdiAssetInfo">
<id property="assetId" column="asset_id"/>
<result property="familyId" column="family_id"/>
<result property="personId" column="person_id"/>
<result property="assetMainType" column="asset_main_type"/>
<result property="assetSubType" column="asset_sub_type"/>
<result property="assetName" column="asset_name"/>
<result property="ownershipRatio" column="ownership_ratio"/>
<result property="purchaseEvalDate" column="purchase_eval_date"/>
<result property="originalValue" column="original_value"/>
<result property="currentValue" column="current_value"/>
<result property="valuationDate" column="valuation_date"/>
<result property="assetStatus" column="asset_status"/>
<result property="remarks" column="remarks"/>
<result property="createBy" column="create_by"/>
<result property="createTime" column="create_time"/>
<result property="updateBy" column="update_by"/>
<result property="updateTime" column="update_time"/>
</resultMap>
<select id="selectByFamilyId" resultMap="CcdiAssetInfoResultMap">
SELECT
asset_id, family_id, person_id, asset_main_type, asset_sub_type, asset_name,
ownership_ratio, purchase_eval_date, original_value, current_value,
valuation_date, asset_status, remarks, create_by, create_time, update_by, update_time
FROM ccdi_asset_info
WHERE family_id = #{familyId}
ORDER BY create_time DESC, asset_id DESC
</select>
<select id="selectByFamilyIdAndPersonId" resultMap="CcdiAssetInfoResultMap">
SELECT
asset_id, family_id, person_id, asset_main_type, asset_sub_type, asset_name,
ownership_ratio, purchase_eval_date, original_value, current_value,
valuation_date, asset_status, remarks, create_by, create_time, update_by, update_time
FROM ccdi_asset_info
WHERE family_id = #{familyId}
AND person_id = #{personId}
ORDER BY create_time DESC, asset_id DESC
</select>
<delete id="deleteByFamilyId">
DELETE FROM ccdi_asset_info
WHERE family_id = #{familyId}
</delete>
<delete id="deleteByFamilyIdAndPersonId">
DELETE FROM ccdi_asset_info
WHERE family_id = #{familyId}
AND person_id = #{personId}
</delete>
<delete id="deleteByFamilyIds">
DELETE FROM ccdi_asset_info
WHERE family_id IN
<foreach collection="familyIds" item="familyId" open="(" separator="," close=")">
#{familyId}
</foreach>
</delete>
<insert id="insertBatch">
INSERT INTO ccdi_asset_info
(family_id, person_id, asset_main_type, asset_sub_type, asset_name,
ownership_ratio, purchase_eval_date, original_value, current_value,
valuation_date, asset_status, remarks, create_by, create_time, update_by, update_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.familyId}, #{item.personId}, #{item.assetMainType}, #{item.assetSubType}, #{item.assetName},
#{item.ownershipRatio}, #{item.purchaseEvalDate}, #{item.originalValue}, #{item.currentValue},
#{item.valuationDate}, #{item.assetStatus}, #{item.remarks}, #{item.createBy}, NOW(), #{item.updateBy}, NOW())
</foreach>
</insert>
<select id="selectOwnerCandidatesByRelationCertNos" resultType="map">
SELECT
relation_cert_no AS personId,
person_id AS familyId
FROM ccdi_staff_fmy_relation
WHERE is_emp_family = 1
AND relation_cert_no IN
<foreach collection="relationCertNos" item="relationCertNo" open="(" separator="," close=")">
#{relationCertNo}
</foreach>
</select>
</mapper>

View File

@@ -46,33 +46,32 @@
r.created_by, r.create_time, r.updated_by, r.update_time
FROM ccdi_staff_fmy_relation r
LEFT JOIN ccdi_base_staff s ON r.person_id = s.id_card
<where>
r.is_emp_family = 1
<if test="query.personId != null and query.personId != ''">
AND r.person_id = #{query.personId}
</if>
<if test="query.personName != null and query.personName != ''">
AND s.name LIKE CONCAT('%', #{query.personName}, '%')
</if>
<if test="query.relationType != null and query.relationType != ''">
AND r.relation_type = #{query.relationType}
</if>
<if test="query.relationName != null and query.relationName != ''">
AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%')
</if>
<if test="query.status != null">
AND r.status = #{query.status}
</if>
<if test="query.dataSource != null and query.dataSource != ''">
AND r.data_source = #{query.dataSource}
</if>
<if test="query.effectiveDateStart != null">
AND r.effective_date &gt;= #{query.effectiveDateStart}
</if>
<if test="query.effectiveDateEnd != null">
AND r.effective_date &lt;= #{query.effectiveDateEnd}
</if>
</where>
WHERE 1 = 1
AND r.is_emp_family = 1
<if test="query.personId != null and query.personId != ''">
AND r.person_id = #{query.personId}
</if>
<if test="query.personName != null and query.personName != ''">
AND s.name LIKE CONCAT('%', #{query.personName}, '%')
</if>
<if test="query.relationType != null and query.relationType != ''">
AND r.relation_type = #{query.relationType}
</if>
<if test="query.relationName != null and query.relationName != ''">
AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%')
</if>
<if test="query.status != null">
AND r.status = #{query.status}
</if>
<if test="query.dataSource != null and query.dataSource != ''">
AND r.data_source = #{query.dataSource}
</if>
<if test="query.effectiveDateStart != null">
AND r.effective_date &gt;= #{query.effectiveDateStart}
</if>
<if test="query.effectiveDateEnd != null">
AND r.effective_date &lt;= #{query.effectiveDateEnd}
</if>
ORDER BY r.create_time DESC
</select>
@@ -101,33 +100,32 @@
r.created_by, r.create_time, r.updated_by, r.update_time
FROM ccdi_staff_fmy_relation r
LEFT JOIN ccdi_base_staff s ON r.person_id = s.id_card
<where>
r.is_emp_family = 1
<if test="query.personId != null and query.personId != ''">
AND r.person_id = #{query.personId}
</if>
<if test="query.personName != null and query.personName != ''">
AND s.name LIKE CONCAT('%', #{query.personName}, '%')
</if>
<if test="query.relationType != null and query.relationType != ''">
AND r.relation_type = #{query.relationType}
</if>
<if test="query.relationName != null and query.relationName != ''">
AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%')
</if>
<if test="query.status != null">
AND r.status = #{query.status}
</if>
<if test="query.dataSource != null and query.dataSource != ''">
AND r.data_source = #{query.dataSource}
</if>
<if test="query.effectiveDateStart != null">
AND r.effective_date &gt;= #{query.effectiveDateStart}
</if>
<if test="query.effectiveDateEnd != null">
AND r.effective_date &lt;= #{query.effectiveDateEnd}
</if>
</where>
WHERE 1 = 1
AND r.is_emp_family = 1
<if test="query.personId != null and query.personId != ''">
AND r.person_id = #{query.personId}
</if>
<if test="query.personName != null and query.personName != ''">
AND s.name LIKE CONCAT('%', #{query.personName}, '%')
</if>
<if test="query.relationType != null and query.relationType != ''">
AND r.relation_type = #{query.relationType}
</if>
<if test="query.relationName != null and query.relationName != ''">
AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%')
</if>
<if test="query.status != null">
AND r.status = #{query.status}
</if>
<if test="query.dataSource != null and query.dataSource != ''">
AND r.data_source = #{query.dataSource}
</if>
<if test="query.effectiveDateStart != null">
AND r.effective_date &gt;= #{query.effectiveDateStart}
</if>
<if test="query.effectiveDateEnd != null">
AND r.effective_date &lt;= #{query.effectiveDateEnd}
</if>
ORDER BY r.create_time DESC
</select>

View File

@@ -0,0 +1,119 @@
package com.ruoyi.info.collection.controller;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.vo.AssetImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportResultVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.service.ICcdiAssetInfoImportService;
import com.ruoyi.info.collection.utils.EasyExcelUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiAssetInfoControllerTest {
@InjectMocks
private CcdiAssetInfoController controller;
@Mock
private ICcdiAssetInfoImportService assetInfoImportService;
@Test
void importData_shouldReturnWarnWhenExcelHasNoRows() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"asset-empty.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"empty".getBytes(StandardCharsets.UTF_8)
);
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiAssetInfoExcel.class)))
.thenReturn(List.of());
AjaxResult result = controller.importData(file);
assertEquals(HttpStatus.WARN, result.get(AjaxResult.CODE_TAG));
assertEquals("至少需要一条数据", result.get(AjaxResult.MSG_TAG));
}
}
@Test
void importData_shouldReturnSuccessWhenTaskCreated() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"asset.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"asset".getBytes(StandardCharsets.UTF_8)
);
CcdiAssetInfoExcel excel = new CcdiAssetInfoExcel();
excel.setPersonId("320101199001010011");
when(assetInfoImportService.importAssetInfo(List.of(excel))).thenReturn("task-1");
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiAssetInfoExcel.class)))
.thenReturn(List.of(excel));
AjaxResult result = controller.importData(file);
assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
assertEquals("导入任务已提交,正在后台处理", result.get(AjaxResult.MSG_TAG));
ImportResultVO data = (ImportResultVO) result.get(AjaxResult.DATA_TAG);
assertEquals("task-1", data.getTaskId());
}
}
@Test
void getImportStatus_shouldDelegateToImportService() {
ImportStatusVO statusVO = new ImportStatusVO();
statusVO.setTaskId("task-2");
when(assetInfoImportService.getImportStatus("task-2")).thenReturn(statusVO);
AjaxResult result = controller.getImportStatus("task-2");
assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
assertEquals(statusVO, result.get(AjaxResult.DATA_TAG));
}
@Test
void getImportFailures_shouldReturnPagedRows() {
AssetImportFailureVO failure1 = new AssetImportFailureVO();
failure1.setPersonId("A1");
AssetImportFailureVO failure2 = new AssetImportFailureVO();
failure2.setPersonId("A2");
when(assetInfoImportService.getImportFailures("task-3")).thenReturn(List.of(failure1, failure2));
TableDataInfo result = controller.getImportFailures("task-3", 2, 1);
assertEquals(2, result.getTotal());
assertEquals(1, result.getRows().size());
assertEquals("A2", ((AssetImportFailureVO) result.getRows().get(0)).getPersonId());
}
@Test
void importTemplate_shouldUseRelativeAssetTemplateName() {
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
controller.importTemplate(null);
mocked.verify(() -> EasyExcelUtil.importTemplateWithDictDropdown(null, CcdiAssetInfoExcel.class, "亲属资产信息"));
}
}
}

View File

@@ -0,0 +1,166 @@
package com.ruoyi.info.collection.mapper;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.apache.ibatis.type.TypeAliasRegistry;
import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.io.InputStream;
import java.math.BigDecimal;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiAssetInfoMapperTest {
private static final String RESOURCE = "mapper/info/collection/CcdiAssetInfoMapper.xml";
@Test
void selectByFamilyIdAndPersonId_shouldFilterByOwnershipKey() throws Exception {
MappedStatement mappedStatement = loadMappedStatement(
"com.ruoyi.info.collection.mapper.CcdiAssetInfoMapper.selectByFamilyIdAndPersonId");
String sql = renderSql(mappedStatement, Map.of(
"familyId", "320101199001010011",
"personId", "320101199201010022"
));
assertTrue(sql.contains("FROM ccdi_asset_info"), sql);
assertTrue(sql.contains("WHERE family_id = ?"), sql);
assertTrue(sql.contains("AND person_id = ?"), sql);
}
@Test
void deleteByFamilyIdAndPersonId_shouldFilterByOwnershipKey() throws Exception {
MappedStatement mappedStatement = loadMappedStatement(
"com.ruoyi.info.collection.mapper.CcdiAssetInfoMapper.deleteByFamilyIdAndPersonId");
String sql = renderSql(mappedStatement, Map.of(
"familyId", "320101199001010011",
"personId", "320101199201010022"
));
assertTrue(sql.contains("DELETE FROM ccdi_asset_info"), sql);
assertTrue(sql.contains("WHERE family_id = ?"), sql);
assertTrue(sql.contains("AND person_id = ?"), sql);
}
@Test
void insertBatch_shouldIncludeAllBusinessColumns() throws Exception {
MappedStatement mappedStatement = loadMappedStatement(
"com.ruoyi.info.collection.mapper.CcdiAssetInfoMapper.insertBatch");
CcdiAssetInfo assetInfo = new CcdiAssetInfo();
assetInfo.setFamilyId("320101199001010011");
assetInfo.setPersonId("320101199201010022");
assetInfo.setAssetMainType("车辆");
assetInfo.setAssetSubType("小汽车");
assetInfo.setAssetName("家庭车辆");
assetInfo.setCurrentValue(new BigDecimal("100000.00"));
assetInfo.setAssetStatus("正常");
String sql = renderSql(mappedStatement, Map.of("list", List.of(assetInfo)));
assertTrue(sql.contains("INSERT INTO ccdi_asset_info"), sql);
assertTrue(sql.contains("family_id"), sql);
assertTrue(sql.contains("person_id"), sql);
assertTrue(sql.contains("asset_main_type"), sql);
assertTrue(sql.contains("current_value"), sql);
assertTrue(sql.contains("asset_status"), sql);
}
@Test
void ownerLookupQuery_shouldResolveFromFamilyRelationOnly() throws Exception {
MappedStatement familyStatement = loadMappedStatement(
"com.ruoyi.info.collection.mapper.CcdiAssetInfoMapper.selectOwnerCandidatesByRelationCertNos");
String familySql = renderSql(familyStatement, Map.of("relationCertNos", List.of("B")));
assertTrue(familySql.contains("FROM ccdi_staff_fmy_relation"), familySql);
assertTrue(familySql.contains("relation_cert_no AS personId"), familySql);
assertTrue(familySql.contains("person_id AS familyId"), familySql);
assertTrue(familySql.contains("relation_cert_no IN"), familySql);
assertTrue(familySql.contains("is_emp_family = 1"), familySql);
assertFalse(familySql.contains("FROM ccdi_base_staff"), familySql);
}
private MappedStatement loadMappedStatement(String statementId) throws Exception {
Configuration configuration = new Configuration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource()));
registerTypeAliases(configuration.getTypeAliasRegistry());
configuration.getLanguageRegistry().register(XMLLanguageDriver.class);
configuration.addMapper(CcdiAssetInfoMapper.class);
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
XMLMapperBuilder xmlMapperBuilder =
new XMLMapperBuilder(inputStream, configuration, RESOURCE, configuration.getSqlFragments());
xmlMapperBuilder.parse();
}
return configuration.getMappedStatement(statementId);
}
private String renderSql(MappedStatement mappedStatement, Map<String, Object> params) {
BoundSql boundSql = mappedStatement.getBoundSql(new HashMap<>(params));
return boundSql.getSql().replaceAll("\\s+", " ").trim();
}
private void registerTypeAliases(TypeAliasRegistry typeAliasRegistry) {
typeAliasRegistry.registerAlias("map", Map.class);
}
private static class NoOpDataSource implements DataSource {
@Override
public java.sql.Connection getConnection() {
throw new UnsupportedOperationException("Not required for SQL rendering tests");
}
@Override
public java.sql.Connection getConnection(String username, String password) {
throw new UnsupportedOperationException("Not required for SQL rendering tests");
}
@Override
public java.io.PrintWriter getLogWriter() {
return null;
}
@Override
public void setLogWriter(java.io.PrintWriter out) {
}
@Override
public void setLoginTimeout(int seconds) {
}
@Override
public int getLoginTimeout() {
return 0;
}
@Override
public java.util.logging.Logger getParentLogger() {
return java.util.logging.Logger.getGlobal();
}
@Override
public <T> T unwrap(Class<T> iface) {
throw new UnsupportedOperationException("Not supported");
}
@Override
public boolean isWrapperFor(Class<?> iface) {
return false;
}
}
}

View File

@@ -0,0 +1,119 @@
package com.ruoyi.info.collection.mapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.github.pagehelper.parser.defaults.DefaultCountSqlParser;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationQueryDTO;
import org.apache.ibatis.builder.xml.XMLMapperBuilder;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.scripting.xmltags.XMLLanguageDriver;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.apache.ibatis.type.TypeAliasRegistry;
import org.junit.jupiter.api.Test;
import javax.sql.DataSource;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiStaffFmyRelationMapperTest {
private static final String RESOURCE = "mapper/info/collection/CcdiStaffFmyRelationMapper.xml";
@Test
void selectRelationPage_shouldRenderWhitespaceBeforeDynamicAndClause() throws Exception {
MappedStatement mappedStatement = loadMappedStatement(
"com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper.selectRelationPage");
CcdiStaffFmyRelationQueryDTO queryDTO = new CcdiStaffFmyRelationQueryDTO();
queryDTO.setPersonId("320101199001010011");
String sql = renderSql(mappedStatement, Map.of(
"page", new Page<>(1, 10),
"query", queryDTO
));
String countSql = normalizeSql(new DefaultCountSqlParser().getSmartCountSql(sql, "0"));
assertTrue(sql.contains("WHERE 1 = 1 AND r.is_emp_family = 1 AND r.person_id = ?"), sql);
assertFalse(sql.contains("1AND"), sql);
assertFalse(countSql.contains("1AND"), countSql);
}
private MappedStatement loadMappedStatement(String statementId) throws Exception {
Configuration configuration = new Configuration();
configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), new NoOpDataSource()));
registerTypeAliases(configuration.getTypeAliasRegistry());
configuration.getLanguageRegistry().register(XMLLanguageDriver.class);
configuration.addMapper(CcdiStaffFmyRelationMapper.class);
try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream(RESOURCE)) {
XMLMapperBuilder xmlMapperBuilder =
new XMLMapperBuilder(inputStream, configuration, RESOURCE, configuration.getSqlFragments());
xmlMapperBuilder.parse();
}
return configuration.getMappedStatement(statementId);
}
private String renderSql(MappedStatement mappedStatement, Map<String, Object> params) {
BoundSql boundSql = mappedStatement.getBoundSql(new HashMap<>(params));
return normalizeSql(boundSql.getSql());
}
private String normalizeSql(String sql) {
return sql.replaceAll("\\s+", " ").trim();
}
private void registerTypeAliases(TypeAliasRegistry typeAliasRegistry) {
typeAliasRegistry.registerAlias("map", Map.class);
}
private static class NoOpDataSource implements DataSource {
@Override
public java.sql.Connection getConnection() {
throw new UnsupportedOperationException("Not required for SQL rendering tests");
}
@Override
public java.sql.Connection getConnection(String username, String password) {
throw new UnsupportedOperationException("Not required for SQL rendering tests");
}
@Override
public java.io.PrintWriter getLogWriter() {
return null;
}
@Override
public void setLogWriter(java.io.PrintWriter out) {
}
@Override
public void setLoginTimeout(int seconds) {
}
@Override
public int getLoginTimeout() {
return 0;
}
@Override
public java.util.logging.Logger getParentLogger() {
return java.util.logging.Logger.getGlobal();
}
@Override
public <T> T unwrap(Class<T> iface) {
throw new UnsupportedOperationException("Not supported");
}
@Override
public boolean isWrapperFor(Class<?> iface) {
return false;
}
}
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.dto.CcdiAssetInfoDTO;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiAssetInfoDesignContractTest {
@Test
void aggregateSaveDto_shouldNotExposePersonId() {
Set<String> fieldNames = Arrays.stream(CcdiAssetInfoDTO.class.getDeclaredFields())
.map(field -> field.getName())
.collect(Collectors.toSet());
assertFalse(fieldNames.contains("personId"));
}
@Test
void importExcel_shouldStillExposePersonIdForRelationCertNoMatching() {
Set<String> fieldNames = Arrays.stream(CcdiAssetInfoExcel.class.getDeclaredFields())
.map(field -> field.getName())
.collect(Collectors.toSet());
assertTrue(fieldNames.contains("personId"));
}
}

View File

@@ -0,0 +1,205 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.vo.AssetImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.mapper.CcdiAssetInfoMapper;
import com.ruoyi.info.collection.service.impl.CcdiAssetInfoImportServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.startsWith;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiAssetInfoImportServiceImplTest {
@InjectMocks
private CcdiAssetInfoImportServiceImpl service;
@Mock
private CcdiAssetInfoMapper assetInfoMapper;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ICcdiAssetInfoImportService assetInfoImportService;
@Mock
private HashOperations<String, Object, Object> hashOperations;
@Mock
private ValueOperations<String, Object> valueOperations;
@Test
void assetInfoExcel_shouldExcludeAssetIdAndFamilyId() {
Set<String> fieldNames = Arrays.stream(CcdiAssetInfoExcel.class.getDeclaredFields())
.map(field -> field.getName())
.collect(Collectors.toSet());
assertFalse(fieldNames.contains("assetId"));
assertFalse(fieldNames.contains("familyId"));
assertTrue(fieldNames.contains("personId"));
}
@Test
void importAssetInfo_shouldUseDedicatedAssetTaskKeys() {
List<CcdiAssetInfoExcel> excelList = List.of(buildExcel("320101199001010011", "房产"));
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
String taskId = service.importAssetInfo(excelList);
verify(hashOperations).putAll(eq("import:assetInfo:" + taskId), anyMap());
verify(redisTemplate).expire("import:assetInfo:" + taskId, 7, TimeUnit.DAYS);
verify(assetInfoImportService).importAssetInfoAsync(eq(excelList), eq(taskId), any());
}
@Test
void importAssetInfoAsync_shouldResolveFamilyIdFromFamilyRelationForRelativeAsset() {
CcdiAssetInfoExcel excel = buildExcel("320101199001010011", "房产");
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(assetInfoMapper.selectOwnerCandidatesByRelationCertNos(List.of("320101199001010011")))
.thenReturn(List.of(owner("320101199001010011", "320101199009090099")));
service.importAssetInfoAsync(List.of(excel), "task-1", "tester");
ArgumentCaptor<List<CcdiAssetInfo>> captor = ArgumentCaptor.forClass(List.class);
verify(assetInfoMapper).insertBatch(captor.capture());
assertEquals("320101199009090099", captor.getValue().get(0).getFamilyId());
assertEquals("320101199001010011", captor.getValue().get(0).getPersonId());
}
@Test
void importAssetInfoAsync_shouldResolveFamilyIdFromFamilyRelationIdCard() {
CcdiAssetInfoExcel excel = buildExcel("320101199201010022", "车辆");
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(assetInfoMapper.selectOwnerCandidatesByRelationCertNos(List.of("320101199201010022")))
.thenReturn(List.of(owner("320101199201010022", "320101199001010011")));
service.importAssetInfoAsync(List.of(excel), "task-2", "tester");
ArgumentCaptor<List<CcdiAssetInfo>> captor = ArgumentCaptor.forClass(List.class);
verify(assetInfoMapper).insertBatch(captor.capture());
assertEquals("320101199001010011", captor.getValue().get(0).getFamilyId());
assertEquals("320101199201010022", captor.getValue().get(0).getPersonId());
}
@Test
void importAssetInfoAsync_shouldStoreFailureRowsOnlyForBadRecords() {
CcdiAssetInfoExcel good = buildExcel("320101199001010011", "房产");
CcdiAssetInfoExcel bad = buildExcel("320101199001010099", "车辆");
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(assetInfoMapper.selectOwnerCandidatesByRelationCertNos(List.of("320101199001010011", "320101199001010099")))
.thenReturn(List.of(owner("320101199001010011", "320101199009090099")));
service.importAssetInfoAsync(List.of(good, bad), "task-3", "tester");
ArgumentCaptor<List<CcdiAssetInfo>> insertCaptor = ArgumentCaptor.forClass(List.class);
verify(assetInfoMapper).insertBatch(insertCaptor.capture());
assertEquals(1, insertCaptor.getValue().size());
assertEquals("320101199009090099", insertCaptor.getValue().get(0).getFamilyId());
ArgumentCaptor<Object> failureCaptor = ArgumentCaptor.forClass(Object.class);
verify(valueOperations).set(eq("import:assetInfo:task-3:failures"), failureCaptor.capture(), eq(7L), eq(TimeUnit.DAYS));
List<?> failures = (List<?>) failureCaptor.getValue();
assertEquals(1, failures.size());
AssetImportFailureVO failure = (AssetImportFailureVO) failures.get(0);
assertEquals("320101199001010099", failure.getPersonId());
assertTrue(failure.getErrorMessage().contains("未找到亲属资产归属员工"));
}
@Test
void importAssetInfoAsync_shouldFailWhenOwnerIsAmbiguous() {
CcdiAssetInfoExcel excel = buildExcel("320101199201010022", "车辆");
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(assetInfoMapper.selectOwnerCandidatesByRelationCertNos(List.of("320101199201010022")))
.thenReturn(List.of(
owner("320101199201010022", "320101199001010011"),
owner("320101199201010022", "320101199001010033")
));
service.importAssetInfoAsync(List.of(excel), "task-4", "tester");
verify(assetInfoMapper, never()).insertBatch(any());
ArgumentCaptor<Object> failureCaptor = ArgumentCaptor.forClass(Object.class);
verify(valueOperations).set(eq("import:assetInfo:task-4:failures"), failureCaptor.capture(), eq(7L), eq(TimeUnit.DAYS));
AssetImportFailureVO failure = (AssetImportFailureVO) ((List<?>) failureCaptor.getValue()).get(0);
assertTrue(failure.getErrorMessage().contains("亲属资产归属员工不唯一"));
}
@Test
void getImportStatusAndFailures_shouldUseAssetPrefixes() {
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(redisTemplate.hasKey("import:assetInfo:task-5")).thenReturn(true);
when(hashOperations.entries("import:assetInfo:task-5")).thenReturn(Map.of(
"taskId", "task-5",
"status", "SUCCESS",
"totalCount", 1,
"successCount", 1,
"failureCount", 0,
"progress", 100,
"startTime", 1L,
"endTime", 2L,
"message", "全部成功"
));
AssetImportFailureVO failureVO = new AssetImportFailureVO();
failureVO.setPersonId("320101199001010099");
failureVO.setErrorMessage("未找到亲属资产归属员工");
when(valueOperations.get("import:assetInfo:task-5:failures")).thenReturn(List.of(failureVO));
ImportStatusVO statusVO = service.getImportStatus("task-5");
List<AssetImportFailureVO> failures = service.getImportFailures("task-5");
assertEquals("task-5", statusVO.getTaskId());
assertEquals("SUCCESS", statusVO.getStatus());
assertNotNull(failures);
assertEquals(1, failures.size());
assertEquals("320101199001010099", failures.get(0).getPersonId());
}
private CcdiAssetInfoExcel buildExcel(String personId, String assetMainType) {
CcdiAssetInfoExcel excel = new CcdiAssetInfoExcel();
excel.setPersonId(personId);
excel.setAssetMainType(assetMainType);
excel.setAssetSubType(assetMainType + "小类");
excel.setAssetName(assetMainType + "名称");
excel.setCurrentValue(new BigDecimal("100.00"));
excel.setAssetStatus("正常");
return excel;
}
private Map<String, String> owner(String personId, String familyId) {
return Map.of("personId", personId, "familyId", familyId);
}
}

View File

@@ -0,0 +1,130 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import com.ruoyi.info.collection.domain.dto.CcdiAssetInfoDTO;
import com.ruoyi.info.collection.mapper.CcdiAssetInfoMapper;
import com.ruoyi.info.collection.service.impl.CcdiAssetInfoServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.invocation.Invocation;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockingDetails;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiAssetInfoServiceImplTest {
@InjectMocks
private CcdiAssetInfoServiceImpl service;
@Mock
private CcdiAssetInfoMapper assetInfoMapper;
@Test
void selectByFamilyId_shouldReturnMapperResult() {
List<CcdiAssetInfo> expected = List.of(new CcdiAssetInfo());
when(assetInfoMapper.selectByFamilyId("320101199001010011")).thenReturn(expected);
List<CcdiAssetInfo> result = service.selectByFamilyId("320101199001010011");
assertSame(expected, result);
}
@Test
void replaceByFamilyIdAndPersonId_shouldDeleteThenInsertNormalizedRows() throws Exception {
CcdiAssetInfoDTO familyOwnedAsset = buildDto("房产");
CcdiAssetInfoDTO anotherAsset = buildDto("车辆");
Method method = CcdiAssetInfoServiceImpl.class.getMethod(
"replaceByFamilyIdAndPersonId", String.class, String.class, List.class);
method.invoke(service, "320101199001010011", "A123456789", List.of(familyOwnedAsset, anotherAsset));
Invocation deleteInvocation = mockingDetails(assetInfoMapper).getInvocations().stream()
.filter(invocation -> "deleteByFamilyIdAndPersonId".equals(invocation.getMethod().getName()))
.findFirst()
.orElseThrow();
assertEquals("320101199001010011", deleteInvocation.getArguments()[0]);
assertEquals("A123456789", deleteInvocation.getArguments()[1]);
ArgumentCaptor<List<CcdiAssetInfo>> captor = ArgumentCaptor.forClass(List.class);
verify(assetInfoMapper).insertBatch(captor.capture());
List<CcdiAssetInfo> savedList = captor.getValue();
assertEquals(2, savedList.size());
assertEquals("320101199001010011", savedList.get(0).getFamilyId());
assertEquals("A123456789", savedList.get(0).getPersonId());
assertEquals("320101199001010011", savedList.get(1).getFamilyId());
assertEquals("A123456789", savedList.get(1).getPersonId());
assertEquals("房产", savedList.get(0).getAssetMainType());
assertEquals("车辆", savedList.get(1).getAssetMainType());
}
@Test
void replaceByFamilyIdAndPersonId_shouldIgnoreEmptyRows() throws Exception {
CcdiAssetInfoDTO emptyRow = new CcdiAssetInfoDTO();
Method method = CcdiAssetInfoServiceImpl.class.getMethod(
"replaceByFamilyIdAndPersonId", String.class, String.class, List.class);
method.invoke(service, "320101199001010011", "A123456789", List.of(emptyRow));
Invocation deleteInvocation = mockingDetails(assetInfoMapper).getInvocations().stream()
.filter(invocation -> "deleteByFamilyIdAndPersonId".equals(invocation.getMethod().getName()))
.findFirst()
.orElseThrow();
assertEquals("320101199001010011", deleteInvocation.getArguments()[0]);
assertEquals("A123456789", deleteInvocation.getArguments()[1]);
verify(assetInfoMapper, never()).insertBatch(anyList());
}
@Test
void replaceByFamilyIdAndPersonId_shouldValidateRequiredFields() throws Exception {
CcdiAssetInfoDTO invalid = new CcdiAssetInfoDTO();
invalid.setAssetMainType("房产");
invalid.setAssetSubType("商品房");
invalid.setAssetName("测试房产");
invalid.setAssetStatus("正常");
Method method = CcdiAssetInfoServiceImpl.class.getMethod(
"replaceByFamilyIdAndPersonId", String.class, String.class, List.class);
InvocationTargetException exception = assertThrows(InvocationTargetException.class,
() -> method.invoke(service, "320101199001010011", "A123456789", List.of(invalid)));
assertEquals("当前估值不能为空", exception.getCause().getMessage());
}
@Test
void deleteByFamilyIds_shouldDelegateToMapper() {
List<String> familyIds = List.of("320101199001010011", "320101199001010033");
when(assetInfoMapper.deleteByFamilyIds(familyIds)).thenReturn(2);
int result = service.deleteByFamilyIds(familyIds);
assertEquals(2, result);
verify(assetInfoMapper).deleteByFamilyIds(eq(familyIds));
}
private CcdiAssetInfoDTO buildDto(String assetMainType) {
CcdiAssetInfoDTO dto = new CcdiAssetInfoDTO();
dto.setAssetMainType(assetMainType);
dto.setAssetSubType(assetMainType + "小类");
dto.setAssetName(assetMainType + "名称");
dto.setCurrentValue(new BigDecimal("100.00"));
dto.setAssetStatus("正常");
return dto;
}
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.dto.CcdiAssetInfoDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffEditDTO;
import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO;
import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffVO;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertSame;
class CcdiBaseStaffServiceAssetAggregationTest {
@Test
void addDto_shouldExposeAssetInfoList() {
CcdiBaseStaffAddDTO addDTO = new CcdiBaseStaffAddDTO();
List<CcdiAssetInfoDTO> assetInfoList = List.of(new CcdiAssetInfoDTO());
addDTO.setAssetInfoList(assetInfoList);
assertSame(assetInfoList, addDTO.getAssetInfoList());
}
@Test
void editDto_shouldExposeAssetInfoList() {
CcdiBaseStaffEditDTO editDTO = new CcdiBaseStaffEditDTO();
List<CcdiAssetInfoDTO> assetInfoList = List.of(new CcdiAssetInfoDTO());
editDTO.setAssetInfoList(assetInfoList);
assertSame(assetInfoList, editDTO.getAssetInfoList());
}
@Test
void staffVo_shouldExposeAssetInfoList() {
CcdiBaseStaffVO staffVO = new CcdiBaseStaffVO();
List<CcdiAssetInfoVO> assetInfoList = List.of(new CcdiAssetInfoVO());
staffVO.setAssetInfoList(assetInfoList);
assertSame(assetInfoList, staffVO.getAssetInfoList());
}
}

View File

@@ -0,0 +1,184 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import com.ruoyi.info.collection.domain.CcdiBaseStaff;
import com.ruoyi.info.collection.domain.dto.CcdiAssetInfoDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffEditDTO;
import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffVO;
import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper;
import com.ruoyi.info.collection.service.impl.CcdiBaseStaffServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiBaseStaffServiceImplTest {
@InjectMocks
private CcdiBaseStaffServiceImpl service;
@Mock
private CcdiBaseStaffMapper baseStaffMapper;
@Mock
private ICcdiBaseStaffImportService importAsyncService;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ICcdiAssetInfoService assetInfoService;
@Test
void insertBaseStaff_shouldPersistEmployeeThenReplaceAssetsUsingEmployeeIdCard() {
CcdiBaseStaffAddDTO addDTO = new CcdiBaseStaffAddDTO();
addDTO.setStaffId(1001L);
addDTO.setName("张三");
addDTO.setDeptId(10L);
addDTO.setIdCard("320101199001010011");
addDTO.setPhone("13812345678");
addDTO.setStatus("0");
addDTO.setAssetInfoList(List.of(
buildAssetDto("房产"),
buildAssetDto("车辆")
));
when(baseStaffMapper.selectById(1001L)).thenReturn(null);
when(baseStaffMapper.selectCount(any())).thenReturn(0L);
when(baseStaffMapper.insert(any(CcdiBaseStaff.class))).thenReturn(1);
int result = service.insertBaseStaff(addDTO);
assertEquals(1, result);
ArgumentCaptor<List<CcdiAssetInfoDTO>> captor = ArgumentCaptor.forClass(List.class);
verify(assetInfoService).replaceByFamilyId(eq("320101199001010011"), captor.capture());
List<CcdiAssetInfoDTO> savedAssets = captor.getValue();
assertEquals(2, savedAssets.size());
assertEquals("房产", savedAssets.get(0).getAssetMainType());
assertEquals("车辆", savedAssets.get(1).getAssetMainType());
}
@Test
void updateBaseStaff_shouldReplaceAssetsForCurrentIdCard() {
CcdiBaseStaff existing = new CcdiBaseStaff();
existing.setStaffId(1001L);
existing.setIdCard("320101199001010011");
CcdiBaseStaffEditDTO editDTO = new CcdiBaseStaffEditDTO();
editDTO.setStaffId(1001L);
editDTO.setDeptId(10L);
editDTO.setName("张三");
editDTO.setIdCard("320101199001010011");
editDTO.setPhone("13812345678");
editDTO.setStatus("0");
editDTO.setAssetInfoList(List.of(buildAssetDto("车辆")));
when(baseStaffMapper.selectById(1001L)).thenReturn(existing);
when(baseStaffMapper.selectCount(any())).thenReturn(0L);
when(baseStaffMapper.updateById(any(CcdiBaseStaff.class))).thenReturn(1);
int result = service.updateBaseStaff(editDTO);
assertEquals(1, result);
verify(assetInfoService, never()).deleteByFamilyId("320101199001010011");
verify(assetInfoService).replaceByFamilyId("320101199001010011", editDTO.getAssetInfoList());
}
@Test
void updateBaseStaff_shouldDeleteOldAssetsWhenIdCardChanges() {
CcdiBaseStaff existing = new CcdiBaseStaff();
existing.setStaffId(1001L);
existing.setIdCard("320101199001010099");
CcdiBaseStaffEditDTO editDTO = new CcdiBaseStaffEditDTO();
editDTO.setStaffId(1001L);
editDTO.setDeptId(10L);
editDTO.setName("张三");
editDTO.setIdCard("320101199001010011");
editDTO.setPhone("13812345678");
editDTO.setStatus("0");
editDTO.setAssetInfoList(List.of(buildAssetDto("车辆")));
when(baseStaffMapper.selectById(1001L)).thenReturn(existing);
when(baseStaffMapper.selectCount(any())).thenReturn(0L);
when(baseStaffMapper.updateById(any(CcdiBaseStaff.class))).thenReturn(1);
service.updateBaseStaff(editDTO);
verify(assetInfoService).deleteByFamilyId("320101199001010099");
verify(assetInfoService).replaceByFamilyId("320101199001010011", editDTO.getAssetInfoList());
}
@Test
void selectBaseStaffById_shouldReturnAssetInfoList() {
CcdiBaseStaff staff = new CcdiBaseStaff();
staff.setStaffId(1001L);
staff.setName("张三");
staff.setIdCard("320101199001010011");
staff.setStatus("0");
CcdiAssetInfo assetInfo = new CcdiAssetInfo();
assetInfo.setFamilyId("320101199001010011");
assetInfo.setPersonId("320101199201010022");
assetInfo.setAssetMainType("车辆");
assetInfo.setAssetSubType("小汽车");
assetInfo.setAssetName("家庭车辆");
assetInfo.setCurrentValue(new BigDecimal("100000.00"));
assetInfo.setAssetStatus("正常");
when(baseStaffMapper.selectById(1001L)).thenReturn(staff);
when(assetInfoService.selectByFamilyId("320101199001010011")).thenReturn(List.of(assetInfo));
CcdiBaseStaffVO result = service.selectBaseStaffById(1001L);
assertNotNull(result.getAssetInfoList());
assertEquals(1, result.getAssetInfoList().size());
assertEquals("320101199201010022", result.getAssetInfoList().get(0).getPersonId());
assertEquals("车辆", result.getAssetInfoList().get(0).getAssetMainType());
}
@Test
void deleteBaseStaffByIds_shouldCascadeDeleteAssets() {
CcdiBaseStaff staff1 = new CcdiBaseStaff();
staff1.setStaffId(1001L);
staff1.setIdCard("320101199001010011");
CcdiBaseStaff staff2 = new CcdiBaseStaff();
staff2.setStaffId(1002L);
staff2.setIdCard("320101199001010022");
when(baseStaffMapper.selectBatchIds(List.of(1001L, 1002L))).thenReturn(List.of(staff1, staff2));
when(baseStaffMapper.deleteBatchIds(List.of(1001L, 1002L))).thenReturn(2);
int result = service.deleteBaseStaffByIds(new Long[]{1001L, 1002L});
assertEquals(2, result);
verify(assetInfoService).deleteByFamilyIds(List.of("320101199001010011", "320101199001010022"));
}
private CcdiAssetInfoDTO buildAssetDto(String assetMainType) {
CcdiAssetInfoDTO dto = new CcdiAssetInfoDTO();
dto.setAssetMainType(assetMainType);
dto.setAssetSubType(assetMainType + "小类");
dto.setAssetName(assetMainType + "名称");
dto.setCurrentValue(new BigDecimal("100.00"));
dto.setAssetStatus("正常");
return dto;
}
}

View File

@@ -0,0 +1,45 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.dto.CcdiAssetInfoDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO;
import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO;
import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertSame;
class CcdiStaffFmyRelationAssetAggregationTest {
@Test
void addDto_shouldExposeAssetInfoList() {
CcdiStaffFmyRelationAddDTO addDTO = new CcdiStaffFmyRelationAddDTO();
List<CcdiAssetInfoDTO> assetInfoList = List.of(new CcdiAssetInfoDTO());
addDTO.setAssetInfoList(assetInfoList);
assertSame(assetInfoList, addDTO.getAssetInfoList());
}
@Test
void editDto_shouldExposeAssetInfoList() {
CcdiStaffFmyRelationEditDTO editDTO = new CcdiStaffFmyRelationEditDTO();
List<CcdiAssetInfoDTO> assetInfoList = List.of(new CcdiAssetInfoDTO());
editDTO.setAssetInfoList(assetInfoList);
assertSame(assetInfoList, editDTO.getAssetInfoList());
}
@Test
void relationVo_shouldExposeAssetInfoList() {
CcdiStaffFmyRelationVO relationVO = new CcdiStaffFmyRelationVO();
List<CcdiAssetInfoVO> assetInfoList = List.of(new CcdiAssetInfoVO());
relationVO.setAssetInfoList(assetInfoList);
assertSame(assetInfoList, relationVO.getAssetInfoList());
}
}

View File

@@ -0,0 +1,200 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiAssetInfo;
import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation;
import com.ruoyi.info.collection.domain.dto.CcdiAssetInfoDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO;
import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO;
import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper;
import com.ruoyi.info.collection.service.impl.CcdiStaffFmyRelationServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.inOrder;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiStaffFmyRelationServiceImplTest {
@InjectMocks
private CcdiStaffFmyRelationServiceImpl service;
@Mock
private CcdiStaffFmyRelationMapper relationMapper;
@Mock
private ICcdiStaffFmyRelationImportService relationImportService;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private ICcdiAssetInfoService assetInfoService;
@Test
void selectRelationById_shouldAggregateAssetInfoList() {
CcdiStaffFmyRelationVO relationVO = new CcdiStaffFmyRelationVO();
relationVO.setId(10L);
relationVO.setPersonId("320101199001010011");
relationVO.setRelationCertNo("A123456789");
CcdiAssetInfo assetInfo = new CcdiAssetInfo();
assetInfo.setFamilyId("320101199001010011");
assetInfo.setPersonId("A123456789");
assetInfo.setAssetMainType("房产");
when(relationMapper.selectRelationById(10L)).thenReturn(relationVO);
when(assetInfoService.selectByFamilyIdAndPersonId("320101199001010011", "A123456789"))
.thenReturn(List.of(assetInfo));
CcdiStaffFmyRelationVO result = service.selectRelationById(10L);
assertNotNull(result.getAssetInfoList());
assertEquals(1, result.getAssetInfoList().size());
assertEquals("A123456789", result.getAssetInfoList().get(0).getPersonId());
assertEquals("房产", result.getAssetInfoList().get(0).getAssetMainType());
}
@Test
void insertRelation_shouldSaveRelationThenReplaceRelativeAssets() {
CcdiStaffFmyRelationAddDTO addDTO = new CcdiStaffFmyRelationAddDTO();
addDTO.setPersonId("320101199001010011");
addDTO.setRelationType("配偶");
addDTO.setRelationName("李四");
addDTO.setRelationCertType("护照");
addDTO.setRelationCertNo("A123456789");
addDTO.setAssetInfoList(List.of(buildAssetDto("房产")));
when(relationMapper.insert(any(CcdiStaffFmyRelation.class))).thenReturn(1);
int result = service.insertRelation(addDTO);
assertEquals(1, result);
ArgumentCaptor<CcdiStaffFmyRelation> relationCaptor = ArgumentCaptor.forClass(CcdiStaffFmyRelation.class);
verify(relationMapper).insert(relationCaptor.capture());
assertEquals("MANUAL", relationCaptor.getValue().getDataSource());
assertEquals(Boolean.TRUE, relationCaptor.getValue().getIsEmpFamily());
assertEquals(Boolean.FALSE, relationCaptor.getValue().getIsCustFamily());
verify(assetInfoService).replaceByFamilyIdAndPersonId("320101199001010011", "A123456789", addDTO.getAssetInfoList());
}
@Test
void updateRelation_shouldRejectRelationCertChange() {
CcdiStaffFmyRelation existing = new CcdiStaffFmyRelation();
existing.setId(10L);
existing.setRelationCertType("护照");
existing.setRelationCertNo("A123456789");
CcdiStaffFmyRelationEditDTO editDTO = new CcdiStaffFmyRelationEditDTO();
editDTO.setId(10L);
editDTO.setPersonId("320101199001010011");
editDTO.setRelationType("配偶");
editDTO.setRelationName("李四");
editDTO.setRelationCertType("身份证");
editDTO.setRelationCertNo("A123456789");
when(relationMapper.selectById(10L)).thenReturn(existing);
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.updateRelation(editDTO));
assertEquals("关系人证件类型/证件号码不允许修改", exception.getMessage());
verify(relationMapper, never()).updateById(any(CcdiStaffFmyRelation.class));
verify(assetInfoService, never()).replaceByFamilyIdAndPersonId(any(), any(), any());
}
@Test
void updateRelation_shouldRejectRelationCertNoChange() {
CcdiStaffFmyRelation existing = new CcdiStaffFmyRelation();
existing.setId(10L);
existing.setRelationCertType("护照");
existing.setRelationCertNo("A123456789");
CcdiStaffFmyRelationEditDTO editDTO = new CcdiStaffFmyRelationEditDTO();
editDTO.setId(10L);
editDTO.setPersonId("320101199001010011");
editDTO.setRelationType("配偶");
editDTO.setRelationName("李四");
editDTO.setRelationCertType("护照");
editDTO.setRelationCertNo("B987654321");
when(relationMapper.selectById(10L)).thenReturn(existing);
RuntimeException exception = assertThrows(RuntimeException.class, () -> service.updateRelation(editDTO));
assertEquals("关系人证件类型/证件号码不允许修改", exception.getMessage());
verify(relationMapper, never()).updateById(any(CcdiStaffFmyRelation.class));
verify(assetInfoService, never()).replaceByFamilyIdAndPersonId(any(), any(), any());
}
@Test
void updateRelation_shouldReplaceAssetsByOwnershipKey() {
CcdiStaffFmyRelation existing = new CcdiStaffFmyRelation();
existing.setId(10L);
existing.setRelationCertType("护照");
existing.setRelationCertNo("A123456789");
CcdiStaffFmyRelationEditDTO editDTO = new CcdiStaffFmyRelationEditDTO();
editDTO.setId(10L);
editDTO.setPersonId("320101199001010011");
editDTO.setRelationType("配偶");
editDTO.setRelationName("李四");
editDTO.setRelationCertType("护照");
editDTO.setRelationCertNo("A123456789");
editDTO.setAssetInfoList(List.of(buildAssetDto("车辆")));
when(relationMapper.selectById(10L)).thenReturn(existing);
when(relationMapper.updateById(any(CcdiStaffFmyRelation.class))).thenReturn(1);
int result = service.updateRelation(editDTO);
assertEquals(1, result);
verify(assetInfoService).replaceByFamilyIdAndPersonId("320101199001010011", "A123456789", editDTO.getAssetInfoList());
}
@Test
void deleteRelationByIds_shouldDeleteRelativeAssetsBeforeDeletingRelations() {
CcdiStaffFmyRelation relation1 = new CcdiStaffFmyRelation();
relation1.setId(10L);
relation1.setPersonId("320101199001010011");
relation1.setRelationCertNo("A123456789");
CcdiStaffFmyRelation relation2 = new CcdiStaffFmyRelation();
relation2.setId(11L);
relation2.setPersonId("320101199001010022");
relation2.setRelationCertNo("B987654321");
when(relationMapper.selectBatchIds(List.of(10L, 11L))).thenReturn(List.of(relation1, relation2));
when(relationMapper.deleteBatchIds(List.of(10L, 11L))).thenReturn(2);
int result = service.deleteRelationByIds(new Long[]{10L, 11L});
assertEquals(2, result);
var order = inOrder(assetInfoService, relationMapper);
order.verify(assetInfoService).deleteByFamilyIdAndPersonId("320101199001010011", "A123456789");
order.verify(assetInfoService).deleteByFamilyIdAndPersonId("320101199001010022", "B987654321");
order.verify(relationMapper).deleteBatchIds(List.of(10L, 11L));
}
private CcdiAssetInfoDTO buildAssetDto(String assetMainType) {
CcdiAssetInfoDTO dto = new CcdiAssetInfoDTO();
dto.setAssetMainType(assetMainType);
dto.setAssetSubType(assetMainType + "小类");
dto.setAssetName(assetMainType + "名称");
dto.setCurrentValue(new BigDecimal("100.00"));
dto.setAssetStatus("正常");
return dto;
}
}

View File

@@ -0,0 +1,98 @@
package com.ruoyi.info.collection.utils;
import com.ruoyi.common.core.domain.entity.SysDictData;
import com.ruoyi.common.utils.DictUtils;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddress;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.junit.jupiter.api.Test;
import org.mockito.MockedStatic;
import org.springframework.mock.web.MockHttpServletResponse;
import java.io.ByteArrayInputStream;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mockStatic;
class EasyExcelUtilTemplateTest {
@Test
void importTemplateWithDictDropdown_shouldAddAssetStatusDropdownToAssetTemplate() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
try (MockedStatic<DictUtils> mocked = mockStatic(DictUtils.class)) {
mocked.when(() -> DictUtils.getDictCache("ccdi_asset_status"))
.thenReturn(List.of(
buildDictData("正常"),
buildDictData("冻结"),
buildDictData("处置中"),
buildDictData("报废")
));
EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiAssetInfoExcel.class, "亲属资产信息");
}
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
Sheet sheet = workbook.getSheetAt(0);
assertTrue(hasValidationOnColumn(sheet, 9), "资产状态列应包含下拉校验");
}
}
@Test
void importTemplateWithDictDropdown_shouldFormatCertificateColumnsAsText() throws Exception {
MockHttpServletResponse assetResponse = new MockHttpServletResponse();
MockHttpServletResponse familyResponse = new MockHttpServletResponse();
try (MockedStatic<DictUtils> mocked = mockStatic(DictUtils.class)) {
mocked.when(() -> DictUtils.getDictCache("ccdi_asset_status"))
.thenReturn(List.of(buildDictData("正常")));
mocked.when(() -> DictUtils.getDictCache("ccdi_relation_type"))
.thenReturn(List.of(buildDictData("配偶")));
mocked.when(() -> DictUtils.getDictCache("ccdi_indiv_gender"))
.thenReturn(List.of(buildDictData("")));
mocked.when(() -> DictUtils.getDictCache("ccdi_certificate_type"))
.thenReturn(List.of(buildDictData("居民身份证")));
EasyExcelUtil.importTemplateWithDictDropdown(assetResponse, CcdiAssetInfoExcel.class, "亲属资产信息");
EasyExcelUtil.importTemplateWithDictDropdown(familyResponse, CcdiStaffFmyRelationExcel.class, "员工亲属关系信息");
}
try (Workbook assetWorkbook = WorkbookFactory.create(new ByteArrayInputStream(assetResponse.getContentAsByteArray()));
Workbook familyWorkbook = WorkbookFactory.create(new ByteArrayInputStream(familyResponse.getContentAsByteArray()))) {
assertTextColumn(assetWorkbook.getSheetAt(0), 0);
assertTextColumn(familyWorkbook.getSheetAt(0), 6);
}
}
private void assertTextColumn(Sheet sheet, int columnIndex) {
CellStyle style = sheet.getColumnStyle(columnIndex);
assertNotNull(style, "文本列应设置默认样式");
assertEquals("@", style.getDataFormatString(), "证件号列应使用文本格式");
}
private boolean hasValidationOnColumn(Sheet sheet, int columnIndex) {
for (DataValidation validation : sheet.getDataValidations()) {
for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) {
if (address.getFirstColumn() <= columnIndex && address.getLastColumn() >= columnIndex) {
return true;
}
}
}
return false;
}
private SysDictData buildDictData(String label) {
SysDictData dictData = new SysDictData();
dictData.setDictLabel(label);
dictData.setDictValue(label);
return dictData;
}
}

View File

@@ -656,6 +656,9 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
if (StringUtils.hasText(fileName)) {
record.setFileName(fileName);
}
if (logItem.getFileSize() != null) {
record.setFileSize(logItem.getFileSize());
}
log.info("【文件上传】文件状态: status={}, uploadStatusDesc={}", status, uploadStatusDesc);
boolean parseSuccess = status != null && status == -5

View File

@@ -223,31 +223,37 @@ class CcdiFileUploadServiceImplTest {
verify(bankStatementMapper).deleteByProjectIdAndBatchId(PROJECT_ID, LOG_ID);
}
// @Test
// void processPullBankInfoAsync_shouldUpdateFileNameFromStatusResponse() {
// when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
// when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
// .thenReturn(buildCheckParseStatusResponse(false));
// when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse("XX身份证.xlsx"));
// when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
// .thenReturn(buildEmptyBankStatementResponse());
//
// CcdiFileUploadRecord record = buildRecord();
// service.processPullBankInfoAsync(
// PROJECT_ID,
// LSFX_PROJECT_ID,
// record,
// "110101199001018888",
// "2026-03-01",
// "2026-03-10",
// 9527L
// );
//
// verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
// "XX身份证.xlsx".equals(item.getFileName())));
// verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
// "parsed_success".equals(item.getFileStatus())));
// }
@Test
void processPullBankInfoAsync_shouldUpdateFileSizeFromStatusResponse() {
GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("XX身份证.xlsx");
statusResponse.getData().getLogs().get(0).setFileSize(2048L);
when(lsfxClient.fetchInnerFlow(any())).thenReturn(buildFetchInnerFlowResponse(LOG_ID));
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse);
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
.thenReturn(buildEmptyBankStatementResponse());
CcdiFileUploadRecord record = buildRecord();
record.setFileSize(0L);
service.processPullBankInfoAsync(
PROJECT_ID,
LSFX_PROJECT_ID,
record,
"110101199001018888",
"2026-03-01",
"2026-03-10"
);
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
Long.valueOf(2048L).equals(item.getFileSize())
&& "XX身份证.xlsx".equals(item.getFileName())
&& "parsed_success".equals(item.getFileStatus()))
);
}
// @Test
// void processPullBankInfoAsync_shouldMarkParsedFailedWhenFetchInnerFlowThrows() {

33
deploy/deploy-to-nas.bat Normal file
View File

@@ -0,0 +1,33 @@
@echo off
setlocal EnableExtensions EnableDelayedExpansion
set "SCRIPT_DIR=%~dp0"
set "SERVER_HOST=116.62.17.81"
set "SERVER_PORT=9444"
set "SERVER_USERNAME=wkc"
set "SERVER_PASSWORD=wkc@0825"
set "REMOTE_ROOT=/volume1/webapp/ccdi"
set "DRY_RUN="
set /a POSITION=0
:parse_args
if "%~1"=="" goto run_script
if /I "%~1"=="--dry-run" (
set "DRY_RUN=-DryRun"
) else (
set /a POSITION+=1
if !POSITION!==1 set "SERVER_HOST=%~1"
if !POSITION!==2 set "SERVER_PORT=%~1"
if !POSITION!==3 set "SERVER_USERNAME=%~1"
if !POSITION!==4 set "SERVER_PASSWORD=%~1"
if !POSITION!==5 set "REMOTE_ROOT=%~1"
)
shift
goto parse_args
:run_script
powershell -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%deploy.ps1" -ServerHost "%SERVER_HOST%" -Port "%SERVER_PORT%" -Username "%SERVER_USERNAME%" -Password "%SERVER_PASSWORD%" -RemoteRoot "%REMOTE_ROOT%" %DRY_RUN%
set "EXIT_CODE=%ERRORLEVEL%"
endlocal & exit /b %EXIT_CODE%

112
deploy/deploy.ps1 Normal file
View File

@@ -0,0 +1,112 @@
param(
[string]$ServerHost = "116.62.17.81",
[int]$Port = 9444,
[string]$Username = "wkc",
[string]$Password = "wkc@0825",
[string]$RemoteRoot = "/volume1/webapp/ccdi",
[switch]$DryRun
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
$repoRoot = (Resolve-Path (Join-Path $scriptDir "..")).Path
$stageRoot = Join-Path $repoRoot ".deploy\\ccdi-package"
if ($DryRun) {
Write-Host "[DryRun] 一键部署参数预览"
Write-Host "Host: $ServerHost"
Write-Host "Port: $Port"
Write-Host "Username: $Username"
Write-Host "RemoteRoot: $RemoteRoot"
exit 0
}
function Ensure-Command {
param([string]$CommandName)
if (-not (Get-Command $CommandName -ErrorAction SilentlyContinue)) {
throw "缺少命令: $CommandName"
}
}
function Reset-Directory {
param([string]$Path)
if (Test-Path $Path) {
[System.IO.Directory]::Delete($Path, $true)
}
New-Item -ItemType Directory -Path $Path | Out-Null
}
function Copy-ItemSafe {
param(
[string]$Source,
[string]$Destination
)
Copy-Item -Path $Source -Destination $Destination -Recurse -Force
}
Write-Host "[1/5] 检查本地环境"
Ensure-Command "mvn"
Ensure-Command "npm"
Ensure-Command "python"
Write-Host "[2/5] 打包后端"
Push-Location $repoRoot
try {
mvn clean package -DskipTests
if ($LASTEXITCODE -ne 0) {
throw "后端打包失败"
}
} finally {
Pop-Location
}
Write-Host "[3/5] 打包前端"
Push-Location (Join-Path $repoRoot "ruoyi-ui")
try {
npm run build:prod
if ($LASTEXITCODE -ne 0) {
throw "前端打包失败"
}
} finally {
Pop-Location
}
Write-Host "[4/5] 组装部署目录"
Reset-Directory $stageRoot
New-Item -ItemType Directory -Path (Join-Path $stageRoot "backend") | Out-Null
New-Item -ItemType Directory -Path (Join-Path $stageRoot "frontend") | Out-Null
Copy-ItemSafe (Join-Path $repoRoot "docker") (Join-Path $stageRoot "docker")
Copy-ItemSafe (Join-Path $repoRoot "lsfx-mock-server") (Join-Path $stageRoot "lsfx-mock-server")
Copy-ItemSafe (Join-Path $repoRoot "ruoyi-ui\\dist") (Join-Path $stageRoot "frontend\\dist")
Copy-ItemSafe (Join-Path $repoRoot "docker-compose.yml") (Join-Path $stageRoot "docker-compose.yml")
Copy-ItemSafe (Join-Path $repoRoot ".env.example") (Join-Path $stageRoot ".env.example")
Copy-ItemSafe (Join-Path $repoRoot "ruoyi-admin\\target\\ruoyi-admin.jar") (Join-Path $stageRoot "backend\\ruoyi-admin.jar")
Write-Host "[5/5] 上传并远端部署"
$paramikoCheck = @'
import importlib.util
import sys
sys.exit(0 if importlib.util.find_spec("paramiko") else 1)
'@
$paramikoCheck | python -
if ($LASTEXITCODE -ne 0) {
python -m pip install --user paramiko
if ($LASTEXITCODE -ne 0) {
throw "安装 paramiko 失败"
}
}
python (Join-Path $scriptDir "remote-deploy.py") `
--host $ServerHost `
--port $Port `
--username $Username `
--password $Password `
--local-root $stageRoot `
--remote-root $RemoteRoot
if ($LASTEXITCODE -ne 0) {
throw "远端部署失败"
}

177
deploy/remote-deploy.py Normal file
View File

@@ -0,0 +1,177 @@
import argparse
import os
import posixpath
import shlex
import sys
from pathlib import Path
import paramiko
SKIP_DIRS = {"__pycache__", ".pytest_cache", ".git"}
SKIP_FILES = {".DS_Store"}
def parse_args():
parser = argparse.ArgumentParser(description="Upload CCDI deployment package and run docker compose remotely.")
parser.add_argument("--host", required=True)
parser.add_argument("--port", type=int, required=True)
parser.add_argument("--username", required=True)
parser.add_argument("--password", required=True)
parser.add_argument("--local-root", required=True)
parser.add_argument("--remote-root", required=True)
return parser.parse_args()
def ensure_remote_dir(ssh, remote_path):
command = f"mkdir -p {shlex.quote(remote_path)}"
exit_code, output, error = run_command(ssh, command)
if exit_code != 0:
raise RuntimeError(f"Failed to create remote directory {remote_path}:\n{output}\n{error}")
def resolve_sftp_root(sftp, shell_root):
parts = [part for part in shell_root.split("/") if part]
for index in range(len(parts)):
candidate = "/" + "/".join(parts[index:])
try:
sftp.listdir(candidate)
return candidate
except OSError:
continue
raise RuntimeError(f"Unable to resolve SFTP path for remote root: {shell_root}")
def upload_tree(ssh, sftp, local_root, shell_remote_root, sftp_remote_root):
for current_root, dirs, files in os.walk(local_root):
dirs[:] = [directory for directory in dirs if directory not in SKIP_DIRS]
relative = os.path.relpath(current_root, local_root)
relative_posix = "" if relative == "." else relative.replace("\\", "/")
shell_remote_dir = shell_remote_root if not relative_posix else posixpath.join(shell_remote_root, relative_posix)
sftp_remote_dir = sftp_remote_root if not relative_posix else posixpath.join(sftp_remote_root, relative_posix)
ensure_remote_dir(ssh, shell_remote_dir)
for file_name in files:
if file_name in SKIP_FILES:
continue
local_file = os.path.join(current_root, file_name)
remote_file = posixpath.join(sftp_remote_dir, file_name)
sftp.put(local_file, remote_file)
def run_command(ssh, command):
stdin, stdout, stderr = ssh.exec_command(command)
exit_code = stdout.channel.recv_exit_status()
output = stdout.read().decode("utf-8", errors="ignore")
error = stderr.read().decode("utf-8", errors="ignore")
return exit_code, output, error
def sudo_prefix(password):
return f"printf '%s\\n' {shlex.quote(password)} | sudo -S -p '' "
def detect_compose_command(ssh, password):
daemon_prefix = ""
daemon_checks = [
("", "docker ps >/dev/null 2>&1"),
(sudo_prefix(password), f"{sudo_prefix(password)}docker ps >/dev/null 2>&1"),
]
for prefix, probe in daemon_checks:
exit_code, _, _ = run_command(ssh, probe)
if exit_code == 0:
daemon_prefix = prefix
break
else:
raise RuntimeError("Docker daemon is not accessible on remote host.")
checks = [
(f"{daemon_prefix}docker compose", f"{daemon_prefix}docker compose version"),
(f"{daemon_prefix}docker-compose", f"{daemon_prefix}docker-compose --version"),
]
for compose_cmd, probe in checks:
exit_code, _, _ = run_command(ssh, probe)
if exit_code == 0:
return compose_cmd
raise RuntimeError("Docker Compose command not found on remote host.")
def main():
args = parse_args()
local_root = Path(args.local_root).resolve()
remote_root = args.remote_root.rstrip("/")
if not local_root.exists():
raise FileNotFoundError(f"Local root does not exist: {local_root}")
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh.connect(
hostname=args.host,
port=args.port,
username=args.username,
password=args.password,
timeout=20,
)
sftp = ssh.open_sftp()
try:
pre_clean = (
"set -e;"
f"mkdir -p {shlex.quote(remote_root)};"
f"mkdir -p {shlex.quote(posixpath.join(remote_root, 'runtime/ruoyi'))};"
f"mkdir -p {shlex.quote(posixpath.join(remote_root, 'runtime/logs/backend'))};"
f"rm -rf {shlex.quote(posixpath.join(remote_root, 'backend'))} "
f"{shlex.quote(posixpath.join(remote_root, 'frontend'))} "
f"{shlex.quote(posixpath.join(remote_root, 'docker'))} "
f"{shlex.quote(posixpath.join(remote_root, 'lsfx-mock-server'))};"
f"rm -f {shlex.quote(posixpath.join(remote_root, 'docker-compose.yml'))} "
f"{shlex.quote(posixpath.join(remote_root, '.env.example'))};"
)
exit_code, output, error = run_command(ssh, pre_clean)
if exit_code != 0:
raise RuntimeError(f"Remote cleanup failed:\n{output}\n{error}")
sftp_remote_root = resolve_sftp_root(sftp, remote_root)
upload_tree(ssh, sftp, str(local_root), remote_root, sftp_remote_root)
compose_cmd = detect_compose_command(ssh, args.password)
deploy_command = (
"set -e;"
f"cd {shlex.quote(remote_root)};"
f"{compose_cmd} up -d --build;"
f"{compose_cmd} ps;"
)
exit_code, output, error = run_command(ssh, deploy_command)
if exit_code != 0:
raise RuntimeError(f"Remote deploy failed:\n{output}\n{error}")
logs_command = (
"set -e;"
f"cd {shlex.quote(remote_root)};"
f"{compose_cmd} logs backend --tail 120;"
)
_, logs_output, logs_error = run_command(ssh, logs_command)
print("=== DEPLOY OUTPUT ===")
print(output.strip())
if error.strip():
print("=== DEPLOY STDERR ===")
print(error.strip())
if logs_output.strip():
print("=== BACKEND LOGS ===")
print(logs_output.strip())
if logs_error.strip():
print("=== BACKEND LOG STDERR ===")
print(logs_error.strip())
finally:
sftp.close()
ssh.close()
if __name__ == "__main__":
try:
main()
except Exception as exc:
print(str(exc), file=sys.stderr)
sys.exit(1)

45
docker-compose.yml Normal file
View File

@@ -0,0 +1,45 @@
services:
backend:
build:
context: .
dockerfile: docker/backend/Dockerfile
container_name: ccdi-backend
restart: unless-stopped
environment:
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-local}
RUOYI_PROFILE: ${RUOYI_PROFILE:-/app/data/ruoyi}
JAVA_OPTS: ${JAVA_OPTS:--Xms512m -Xmx1024m}
ports:
- "${BACKEND_PORT:-62318}:8080"
- "${LSFX_MOCK_PORT:-62320}:8000"
volumes:
- ./runtime/ruoyi:/app/data/ruoyi
- ./runtime/logs/backend:/app/logs
lsfx-mock-server:
build:
context: .
dockerfile: docker/mock/Dockerfile
container_name: ccdi-lsfx-mock
restart: unless-stopped
depends_on:
- backend
network_mode: "service:backend"
environment:
APP_NAME: 流水分析Mock服务
APP_VERSION: 1.0.0
DEBUG: "false"
HOST: 0.0.0.0
PORT: 8000
PARSE_DELAY_SECONDS: 4
frontend:
build:
context: .
dockerfile: docker/frontend/Dockerfile
container_name: ccdi-frontend
restart: unless-stopped
depends_on:
- backend
ports:
- "${FRONTEND_PORT:-62319}:80"

11
docker/backend/Dockerfile Normal file
View File

@@ -0,0 +1,11 @@
FROM eclipse-temurin:21-jre-jammy
WORKDIR /app
COPY backend/ruoyi-admin.jar /app/ruoyi-admin.jar
RUN mkdir -p /app/data/ruoyi /app/logs
EXPOSE 8080
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS} -jar /app/ruoyi-admin.jar"]

View File

@@ -0,0 +1,8 @@
FROM nginx:stable-alpine
COPY docker/frontend/nginx.conf /etc/nginx/conf.d/default.conf
COPY frontend/dist/ /usr/share/nginx/html/
RUN chmod -R a+rX /usr/share/nginx/html
EXPOSE 80

View File

@@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /prod-api/ {
proxy_pass http://backend:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location /v3/api-docs/ {
proxy_pass http://backend:8080/v3/api-docs/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

15
docker/mock/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY lsfx-mock-server/requirements.txt /tmp/requirements.txt
RUN pip install --no-cache-dir -r /tmp/requirements.txt
COPY lsfx-mock-server /app
EXPOSE 8000
CMD ["python", "main.py"]

View File

@@ -0,0 +1,354 @@
# Employee Asset Maintenance Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Add backend support for employee asset maintenance, including aggregated employee detail/save, employee-asset cascade delete by employee family ID card, and asynchronous asset import that auto-resolves the owning employee from the asset holder ID card.
**Architecture:** Keep employee maintenance on the existing `CcdiBaseStaff` aggregate, and introduce a new `CcdiAssetInfo` resource in `ccdi-info-collection`. Asset rows use `family_id` for the owning employee ID card and `person_id` for the actual holder ID card. Employee add/edit/detail/delete will orchestrate asset persistence by `family_id` inside transactions, while asset import will mirror the existing employee import flow and resolve `family_id` from either the employee table or the employee family-relation table.
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, XML mapper SQL, EasyExcel, Redis, Maven
---
### Task 1: Add the asset table SQL and document the data contract
**Files:**
- Create: `sql/2026-03-12_ccdi_asset_info.sql`
- Review: `assets/资产信息表.csv`
- Review: `docs/plans/2026-03-12-employee-asset-maintenance-design.md`
**Step 1: Write the SQL script**
Create `ccdi_asset_info` with:
- `asset_id BIGINT` auto increment primary key
- `family_id VARCHAR(18)` storing owning employee `id_card`
- `person_id VARCHAR(18)` storing actual asset holder `id_card`
- business columns from the approved design
- audit columns `create_by`, `create_time`, `update_by`, `update_time`
- indexes `idx_family_id`, `idx_person_id`, and `idx_asset_main_type`
**Step 2: Verify the script matches the approved constraints**
Confirm the script:
- does not include `asset_id` in any import-facing note
- keeps the field names `family_id` and `person_id`
- stores owning employee linkage in `family_id`
- stores actual holder linkage in `person_id`
**Step 3: Commit**
```bash
git add sql/2026-03-12_ccdi_asset_info.sql docs/plans/2026-03-12-employee-asset-maintenance-design.md
git commit -m "新增员工资产信息设计与建表脚本"
```
### Task 2: Add the asset domain, DTO, VO, Excel, and mapper skeletons
**Files:**
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiAssetInfo.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiAssetInfoDTO.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiAssetInfoVO.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiAssetInfoExcel.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/AssetImportFailureVO.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiAssetInfoMapper.java`
- Create: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiAssetInfoMapper.xml`
**Step 1: Create the entity**
Model `CcdiAssetInfo` with MyBatis Plus annotations and audit fields consistent with existing `CcdiBaseStaff`.
**Step 2: Create request and response objects**
Add one DTO for nested employee-form submission and one VO for employee detail response.
**Step 3: Create the Excel and failure record objects**
`CcdiAssetInfoExcel` must exclude `asset_id` and include `person_id`.
It must not require `family_id` in the import template because `family_id` is resolved automatically during import.
**Step 4: Create mapper methods**
Define methods for:
- query by `family_id`
- delete by `family_id`
- delete by `family_id` list
- query by `person_id`
- batch insert
- import lookup by employee `id_card`
- import lookup by employee family-relation ID card
### Task 3: Extend employee DTO and VO aggregation
**Files:**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffAddDTO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiBaseStaffEditDTO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiBaseStaffVO.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffServiceAssetAggregationTest.java`
**Step 1: Write the failing test**
Add a focused service test that asserts employee detail, add DTO, and edit DTO support `assetInfoList`.
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -Dtest=CcdiBaseStaffServiceAssetAggregationTest
```
Expected: FAIL because `assetInfoList` does not exist yet.
**Step 3: Add the aggregate fields**
Add `List<CcdiAssetInfoDTO>` to add/edit DTOs and `List<CcdiAssetInfoVO>` to the employee VO.
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -Dtest=CcdiBaseStaffServiceAssetAggregationTest
```
Expected: PASS
### Task 4: Add asset service interfaces and focused persistence methods
**Files:**
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiAssetInfoService.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiAssetInfoImportService.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAssetInfoServiceImpl.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiAssetInfoServiceImplTest.java`
**Step 1: Write the failing test**
Cover these behaviors:
- query all assets by `family_id`
- replace all assets for one employee
- delete assets by one or more employee ID cards
- ignore empty rows when replacing assets
- preserve `person_id` as the actual holder while forcing `family_id` to the owning employee ID card
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -Dtest=CcdiAssetInfoServiceImplTest
```
Expected: FAIL because the asset service does not exist yet.
**Step 3: Implement minimal service logic**
Implement methods:
- `selectByFamilyId`
- `replaceByFamilyId`
- `deleteByFamilyId`
- `deleteByFamilyIds`
Use batch delete + batch insert.
**Step 4: Run test to verify it passes**
Run:
```bash
mvn test -Dtest=CcdiAssetInfoServiceImplTest
```
Expected: PASS
### Task 5: Update employee service to aggregate asset query and transactional save
**Files:**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiBaseStaffService.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffServiceImpl.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiBaseStaffController.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiBaseStaffServiceImplTest.java`
**Step 1: Write the failing test**
Add or extend service tests for:
- add employee with multiple assets
- edit employee and replace assets
- edit employee with changed `id_card`
- detail query returns `assetInfoList`
- delete employee cascades asset deletion
- employee self-owned asset uses `family_id = person_id = employee.idCard`
- employee family asset uses `family_id = employee.idCard` and `person_id = relative.idCard`
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -Dtest=CcdiBaseStaffServiceImplTest
```
Expected: FAIL because employee service does not yet coordinate asset persistence.
**Step 3: Implement the aggregate behavior**
Update `CcdiBaseStaffServiceImpl` to:
- inject `ICcdiAssetInfoService`
- load assets during detail query by `family_id = employee.id_card`
- save employee first, then replace assets by current employee `id_card`
- for each asset row, set `family_id = employee.id_card` and preserve submitted `person_id`
- capture old `id_card` during edit and clean old asset records when it changes
- delete asset rows before deleting employee rows
**Step 4: Run the focused test again**
Run:
```bash
mvn test -Dtest=CcdiBaseStaffServiceImplTest
```
Expected: PASS
### Task 6: Add asset import controller and async import service
**Files:**
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiAssetInfoController.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiAssetInfoImportServiceImpl.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiAssetInfoImportService.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiAssetInfoImportServiceImplTest.java`
**Step 1: Write the failing test**
Cover:
- import template entity excludes `asset_id`
- import template does not require `family_id`
- import resolves `family_id` when `person_id` matches an employee `id_card`
- import resolves `family_id` when `person_id` matches an employee family-relation ID card
- import fails when `person_id` does not match either an employee or an employee family-relation ID card
- import fails when one `person_id` maps to multiple employees
- import stores failure records only for bad rows
- import status and failure list use dedicated asset task keys
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -Dtest=CcdiAssetInfoImportServiceImplTest
```
Expected: FAIL because no asset import pipeline exists yet.
**Step 3: Implement the import flow**
Mirror the employee import design with asset-specific names:
- Redis key prefix `import:assetInfo:`
- asynchronous import execution
- failure record caching for 7 days
- controller endpoints for template, upload, status, and failures
- resolve `family_id` automatically from `person_id`
**Step 4: Run the focused test again**
Run:
```bash
mvn test -Dtest=CcdiAssetInfoImportServiceImplTest
```
Expected: PASS
### Task 7: Add mapper XML SQL for batch asset operations
**Files:**
- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiAssetInfoMapper.xml`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiAssetInfoMapperTest.java`
**Step 1: Write the failing test**
Verify:
- select by `family_id`
- select by `person_id`
- batch insert multiple assets
- delete by one `family_id`
- delete by multiple `family_id` values
- employee existence lookup by `id_card`
- employee family-relation existence lookup by relative `id_card`
**Step 2: Run test to verify it fails**
Run:
```bash
mvn test -Dtest=CcdiAssetInfoMapperTest
```
Expected: FAIL because the mapper XML SQL is incomplete.
**Step 3: Implement minimal XML SQL**
Add:
- result map
- select by `family_id`
- select by `person_id`
- delete by `family_id`
- delete by `family_id` list
- batch insert
- employee existence lookup
- employee family-relation lookup
**Step 4: Run the mapper test**
Run:
```bash
mvn test -Dtest=CcdiAssetInfoMapperTest
```
Expected: PASS
### Task 8: Verify the backend changes end to end
**Files:**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/`
- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/`
- Modify: `sql/2026-03-12_ccdi_asset_info.sql`
**Step 1: Run focused backend tests**
Run:
```bash
mvn test -Dtest=CcdiBaseStaffServiceAssetAggregationTest,CcdiAssetInfoServiceImplTest,CcdiBaseStaffServiceImplTest,CcdiAssetInfoImportServiceImplTest,CcdiAssetInfoMapperTest
```
Expected: all focused tests pass.
**Step 2: Run module compile verification**
Run:
```bash
mvn clean compile
```
Expected: compile succeeds without Java or mapper XML errors.
**Step 3: Commit**
```bash
git add sql/2026-03-12_ccdi_asset_info.sql ccdi-info-collection/src/main/java/com/ruoyi/info/collection ccdi-info-collection/src/main/resources/mapper/info/collection docs/plans/2026-03-12-employee-asset-maintenance-backend-implementation.md
git commit -m "新增员工资产信息后端实施计划"
```

View File

@@ -0,0 +1,285 @@
# 员工资产信息维护设计
## 背景
员工信息维护页面当前仅支持维护员工基础信息,不支持维护员工名下资产信息。现需在现有员工维护页面中补齐资产信息的新增、编辑、删除、详情展示和导入能力,并与现有员工导入交互保持一致。
本次设计基于 `2026-03-12` 确认了以下业务约束:
- 资产表保留字段名 `person_id`
- 资产表新增 `family_id`
- `family_id` 存归属员工身份证号
- `person_id` 存资产实际持有人身份证号
- 员工本人资产:`family_id = person_id = 员工身份证号`
- 员工亲属资产:`family_id = 员工身份证号``person_id = 亲属身份证号`
- `asset_id` 改为数据库自增主键,不出现在导入模板中
- 资产导入模板不要求填写 `family_id`
- 导入员工资产信息时,系统根据 `person_id` 自动填充归属员工的 `id_card``family_id`
- 资产导入失败记录入口需与员工导入失败记录入口明确区分,按钮文案为“查看员工资产导入失败记录”
## 目标
- 在员工信息维护页新增员工资产信息维护能力
- 在员工新增和编辑弹窗中支持员工资产信息的添加、编辑、删除
- 在员工详情弹窗中展示该员工全部资产信息
- 在员工列表页新增“导入资产信息”入口,并复用现有异步导入交互
- 删除员工时同步删除该员工全部资产信息
## 非目标
- 不新增独立的“员工资产信息管理”菜单页面
- 不在资产维护中暴露 `asset_id`
- 不允许用户在前端手工填写 `person_id`
- 不改造现有员工列表查询条件和分页结构
## 现状
当前员工维护能力主要集中在以下位置:
- 前端页面:`ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- 前端接口:`ruoyi-ui/src/api/ccdiBaseStaff.js`
- 后端控制器:`ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiBaseStaffController.java`
- 后端服务:`ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffServiceImpl.java`
- 导入服务:`ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiBaseStaffImportServiceImpl.java`
现有员工导入采用异步任务 + Redis 状态轮询 + 失败记录分页查询的模式,前端使用本地存储保存最近一次导入任务信息。
## 方案对比
### 方案一:员工页内嵌资产子表聚合维护
- 员工新增、编辑、详情接口返回员工主信息和资产列表聚合结果
- 员工弹窗内部直接维护资产子表
- 资产导入使用独立接口,但交互完全复用员工导入模式
优点:
- 最符合当前需求和现有页面使用习惯
- 用户一次打开员工弹窗即可维护完整信息
- 删除员工时做事务级联最直接
缺点:
- 员工 DTO、VO、Service、前端表单都需要联动调整
### 方案二:资产作为独立模块实现,员工页只嵌入调用
- 后端和前端都按独立资源建设资产模块
- 员工页通过额外接口拉取并嵌入展示
优点:
- 模块边界更清晰
- 未来扩展独立资产菜单更容易
缺点:
- 本次需求明显超出必要范围
- 页面状态和接口交互更复杂
### 方案三:员工页只加资产查看和导入,编辑改为二级弹窗
- 员工弹窗中通过二级弹窗维护资产
优点:
- 对现有员工表单侵入较小
缺点:
- 交互割裂
- 不符合“新增和编辑弹窗中支持添加、编辑、删除”的要求
## 最终方案
采用方案一:在员工信息维护页内嵌资产信息子表,员工接口作为聚合接口返回和保存资产列表;资产导入保持独立入口和独立后端接口,但沿用现有员工导入的上传、异步处理、结果通知、失败记录查询交互。
## 数据模型设计
### 数据表
新增 `ccdi_asset_info` 表,字段来源于 `assets/资产信息表.csv`,并做以下调整:
- `asset_id``BIGINT` 自增主键
- `family_id``VARCHAR(18)`,保存归属员工身份证号,关联 `ccdi_base_staff.id_card`
- `person_id``VARCHAR(18)`,保存资产实际持有人身份证号
- 审计字段沿用当前项目规范,由后端自动填充
建议字段如下:
- `asset_id`
- `family_id`
- `person_id`
- `asset_main_type`
- `asset_sub_type`
- `asset_name`
- `ownership_ratio`
- `purchase_eval_date`
- `original_value`
- `current_value`
- `valuation_date`
- `asset_status`
- `remarks`
- `create_by`
- `create_time`
- `update_by`
- `update_time`
建议索引:
- `idx_family_id(family_id)`
- `idx_person_id(person_id)`
- `idx_asset_main_type(asset_main_type)`
## 后端设计
### 新增资产信息模块对象
`ccdi-info-collection` 中新增:
- `domain/CcdiAssetInfo.java`
- `domain/dto/CcdiAssetInfoDTO.java`
- `domain/vo/CcdiAssetInfoVO.java`
- `domain/excel/CcdiAssetInfoExcel.java`
- `domain/vo/AssetImportFailureVO.java`
- `mapper/CcdiAssetInfoMapper.java`
- `service/ICcdiAssetInfoService.java`
- `service/ICcdiAssetInfoImportService.java`
- `service/impl/CcdiAssetInfoServiceImpl.java`
- `service/impl/CcdiAssetInfoImportServiceImpl.java`
- `controller/CcdiAssetInfoController.java`
- `resources/mapper/info/collection/CcdiAssetInfoMapper.xml`
### 扩展员工聚合接口
扩展现有员工 DTO 和 VO
- `CcdiBaseStaffAddDTO.assetInfoList`
- `CcdiBaseStaffEditDTO.assetInfoList`
- `CcdiBaseStaffVO.assetInfoList`
后端聚合规则:
- 查询员工详情时,按员工 `id_card` 查询 `family_id = 员工身份证号` 的全部资产并组装到 `assetInfoList`
- 新增员工时,先保存员工,再以员工 `id_card` 回填资产列表中的 `family_id`
- 编辑员工时,更新员工主信息,再按员工身份证号重建以 `family_id` 归户的资产列表
- 删除员工时,先按 `family_id` 删除资产再删员工,整个过程置于同一事务内
### 编辑时的资产处理策略
编辑员工时不要求前端传递资产行状态,直接按“当前完整列表”覆盖:
- 获取编辑前员工旧身份证号
- 更新员工主信息
- 如果身份证号变更,按旧身份证号删除 `family_id = 旧身份证号` 的旧资产
- 按当前最新身份证号删除 `family_id = 当前身份证号` 的对应资产
- 当前员工本人资产设置 `family_id = person_id = 当前员工身份证号`
- 当前员工亲属资产设置 `family_id = 当前员工身份证号``person_id = 资产实际持有人身份证号`
- 批量插入最新资产列表
该策略实现简单,能直接覆盖资产新增、编辑、删除三种变化。
### 资产导入接口
新增独立资产导入接口:
- `POST /ccdi/assetInfo/importTemplate`
- `POST /ccdi/assetInfo/importData`
- `GET /ccdi/assetInfo/importStatus/{taskId}`
- `GET /ccdi/assetInfo/importFailures/{taskId}`
导入交互沿用现有员工导入设计:
- 异步任务执行
- Redis 保存任务状态
- 失败记录单独缓存
- 前端轮询状态并显示通知
### 校验规则
#### 员工保存时
- 员工基础信息仍沿用现有校验规则
- `assetInfoList` 中空行自动过滤
- 资产必填项:`personId``assetMainType``assetSubType``assetName``currentValue``assetStatus`
- 数值和日期格式必须合法
- 后端强制回填 `family_id = 员工 id_card`
-`personId = 员工 id_card` 时,视为员工本人资产
-`personId != 员工 id_card` 时,视为员工亲属资产
#### 资产导入时
- 模板中仅要求填写 `person_id`,不要求填写 `family_id`
-`person_id` 能匹配员工 `id_card`,视为员工本人资产,自动填充 `family_id = 该员工 id_card`
-`person_id` 不能匹配员工 `id_card`,则继续匹配员工亲属关系中的亲属身份证号,匹配成功后自动填充对应员工的 `id_card``family_id`
-`person_id` 同时匹配到多个员工家庭,则导入失败,原因标记为“资产归属员工不唯一”
-`person_id` 在员工和员工亲属关系中均无法匹配,则导入失败
- 模板中不包含 `asset_id`
- 允许同一员工导入多条资产
- 失败记录仅返回失败数据,不返回成功数据
## 前端设计
### 列表页
`ruoyi-ui/src/views/ccdiBaseStaff/index.vue` 的按钮区新增:
- “导入资产信息”按钮
- “查看员工资产导入失败记录”按钮
资产导入相关状态全部独立维护:
- 独立弹窗状态
- 独立轮询定时器
- 独立任务 ID
- 独立 localStorage key
- 独立失败记录弹窗和分页状态
### 新增和编辑弹窗
在现有员工弹窗的“基本信息”下方新增“资产信息”分区,采用可编辑子表形式。
子表字段:
- 资产实际持有人身份证号
- 资产大类
- 资产小类
- 资产名称
- 产权占比
- 购买/评估日期
- 资产原值
- 当前估值
- 估值截止日期
- 资产状态
- 备注
- 操作
交互规则:
- 分区右侧提供“新增资产”按钮
- 每行支持删除
- 编辑时回显 `assetInfoList`
- 前端不展示 `asset_id``family_id`
- 前端允许填写 `personId`,用于表示资产实际持有人身份证号
-`personId` 与当前员工身份证号一致时,视为本人资产;不一致时,视为亲属资产
### 详情弹窗
在“员工详情”弹窗中新增“资产信息”区域,使用只读表格展示全部资产。
若无资产数据,显示“暂无资产信息”空状态。
详情表格建议增加:
- `personId`:资产实际持有人身份证号
- `ownerType`:本人 / 亲属
## 数据流
### 员工新增
前端提交员工基础信息和 `assetInfoList`,后端保存员工后按员工 `id_card` 为每条资产回填 `family_id`,再批量保存资产。
### 员工编辑
前端拉取员工详情回显基础信息和资产列表,用户修改后提交完整列表,后端按最新员工身份证号重建 `family_id = 员工身份证号` 的资产明细。
### 员工详情
后端查询员工主信息,再按 `family_id = 员工身份证号` 查询资产列表并一并返回。
### 员工删除
后端先按 `family_id = 员工身份证号` 删除资产,再删除员工主记录。
### 资产导入
前端上传资产 Excel后端根据 `person_id` 自动识别归属员工并回填 `family_id`,异步校验并批量插入资产数据,失败记录通过独立入口查看。
## 异常处理
- 资产导入时,若 `person_id` 无法匹配员工本人或员工亲属,记录失败原因
- 资产导入时,若 `person_id` 对应多个员工家庭,记录归属不唯一失败原因
- 员工编辑时若身份证号变更,必须同步处理旧资产清理和新资产重建
- 员工删除和员工编辑资产重建均使用事务,防止主从数据不一致
- 前端提示文案中明确区分“员工导入”和“员工资产导入”
## 验收标准
- 员工列表页新增“导入资产信息”按钮
- 资产导入失败时显示“查看员工资产导入失败记录”按钮
- 员工新增和编辑弹窗中可添加、编辑、删除资产信息
- 员工详情弹窗中可查看该员工全部资产信息
- 删除员工后,该员工资产信息同步删除
- 资产导入模板不包含 `asset_id`
- 资产导入模板不填写 `family_id`
- 资产导入使用 `person_id` 识别资产实际持有人,并自动回填归属员工的 `family_id`

View File

@@ -0,0 +1,579 @@
# 员工资产信息维护前端实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 在员工信息维护页面中增加员工资产的新增、编辑、删除、详情展示、导入和失败记录查看能力,并保持现有员工导入交互不回归。
**Architecture:** 保持 `ccdiBaseStaff` 作为唯一页面入口,在现有员工新增、编辑、详情弹窗中扩展 `assetInfoList` 子表。资产导入复用当前员工导入的弹窗上传、异步轮询、localStorage 状态恢复、失败记录分页查看模型,但所有资产导入状态、文案、存储 key、轮询方法和失败记录弹窗都单独维护避免与员工导入互相污染。
**Tech Stack:** Vue 2, Element UI, 若依前端脚手架, Axios request 封装, `npm run build:prod`
---
### Task 1: 扩展前端 API 封装员工资产导入能力
**Files:**
- Modify: `ruoyi-ui/src/api/ccdiBaseStaff.js`
- Create: `ruoyi-ui/src/api/ccdiAssetInfo.js`
- Test: `ruoyi-ui/tests/unit/employee-asset-api-contract.test.js`
**Step 1: 先写失败校验脚本**
新增一个轻量级源码断言脚本,校验以下约束:
- `ccdiAssetInfo.js` 文件存在
- 资产导入 API 包含下载模板、提交导入、查询状态、查询失败记录
- 员工新增和编辑接口允许传递 `assetInfoList`
**Step 2: 运行校验确认失败**
Run:
```bash
node ruoyi-ui/tests/unit/employee-asset-api-contract.test.js
```
Expected: FAIL因为资产导入 API 文件尚不存在。
**Step 3: 编写最小 API 封装**
`ccdiAssetInfo.js` 中新增:
- `importAssetTemplate`
- `importAssetData`
- `getAssetImportStatus`
- `getAssetImportFailures`
接口路径统一使用:
- `/ccdi/assetInfo/importTemplate`
- `/ccdi/assetInfo/importData`
- `/ccdi/assetInfo/importStatus/{taskId}`
- `/ccdi/assetInfo/importFailures/{taskId}`
`ccdiBaseStaff.js` 继续保留员工增删改查,不把资产导入接口混入员工 API 文件。
**Step 4: 再次运行校验**
Run:
```bash
node ruoyi-ui/tests/unit/employee-asset-api-contract.test.js
```
Expected: PASS
**Step 5: 提交**
```bash
git add ruoyi-ui/src/api/ccdiBaseStaff.js ruoyi-ui/src/api/ccdiAssetInfo.js ruoyi-ui/tests/unit/employee-asset-api-contract.test.js
git commit -m "新增员工资产前端接口封装"
```
### Task 2: 扩展员工表单模型,支持聚合 `assetInfoList`
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- Test: `ruoyi-ui/tests/unit/employee-asset-submit-flow.test.js`
**Step 1: 先写失败校验脚本**
校验以下行为:
- `reset()` 默认初始化 `assetInfoList: []`
- `handleAdd()` 打开新增弹窗时带空资产列表
- `handleUpdate()` 回显详情时保留接口返回的 `assetInfoList`
- `submitForm()` 提交前会附带 `assetInfoList`
**Step 2: 运行校验确认失败**
Run:
```bash
node ruoyi-ui/tests/unit/employee-asset-submit-flow.test.js
```
Expected: FAIL因为当前 `form` 里还没有资产字段。
**Step 3: 编写最小表单改造**
调整 `index.vue` 中以下逻辑:
- `form` 默认值增加 `assetInfoList: []`
- `reset()` 同步重置 `assetInfoList`
- `handleAdd()` 明确初始化空数组
- `handleUpdate()``handleDetail()` 对返回值中的 `assetInfoList` 做空值兜底
- 新增 `normalizeAssetInfoList()`,提交前过滤全空行
注意:
- 前端不传 `familyId`
- 前端保留用户输入的 `personId`
- 不在前端生成 `assetId`
**Step 4: 再次运行校验**
Run:
```bash
node ruoyi-ui/tests/unit/employee-asset-submit-flow.test.js
```
Expected: PASS
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiBaseStaff/index.vue ruoyi-ui/tests/unit/employee-asset-submit-flow.test.js
git commit -m "扩展员工表单资产聚合字段"
```
### Task 3: 在新增和编辑弹窗中加入“资产信息”可编辑子表
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- Test: `ruoyi-ui/tests/unit/employee-asset-maintenance-layout.test.js`
**Step 1: 先写失败校验脚本**
断言页面模板出现以下结构:
- “资产信息”分区标题
- “新增资产”按钮
- 资产子表或空状态容器
- 资产实际持有人身份证号输入框
- 每行删除按钮
**Step 2: 运行校验确认失败**
Run:
```bash
node ruoyi-ui/tests/unit/employee-asset-maintenance-layout.test.js
```
Expected: FAIL因为当前员工弹窗只有基本信息。
**Step 3: 编写最小弹窗结构**
在基本信息区域下方新增“资产信息”分区,并增加以下辅助方法:
- `createEmptyAssetRow()`
- `handleAddAsset()`
- `handleRemoveAsset(index)`
- `hasAssetContent(row)`
子表字段包含:
- `personId`
- `assetMainType`
- `assetSubType`
- `assetName`
- `ownershipRatio`
- `purchaseEvalDate`
- `originalValue`
- `currentValue`
- `valuationDate`
- `assetStatus`
- `remarks`
- `operation`
交互要求:
- 允许多行新增
- 每行支持删除
- 空列表时显示“暂无资产信息,请点击新增资产”
**Step 4: 再次运行校验**
Run:
```bash
node ruoyi-ui/tests/unit/employee-asset-maintenance-layout.test.js
```
Expected: PASS
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiBaseStaff/index.vue ruoyi-ui/tests/unit/employee-asset-maintenance-layout.test.js
git commit -m "新增员工资产编辑子表"
```
### Task 4: 为资产子表补充前端校验与填写提示
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
**Step 1: 增加资产行字段校验**
至少校验以下前端规则:
- `personId` 格式为合法身份证号
- `assetMainType``assetSubType``assetName``currentValue``assetStatus` 为必填
- 金额字段允许为空,但填写时必须是合法数字
- 日期字段使用 `value-format="yyyy-MM-dd"`
**Step 2: 增加交互提示**
在资产分区标题或字段提示中明确说明:
- `personId` 表示资产实际持有人身份证号
- 如果 `personId` 等于当前员工身份证号,则视为员工本人资产
- 如果 `personId` 不等于当前员工身份证号,则视为员工亲属资产
**Step 3: 保持前端不做归属推导**
不要在前端根据 `personId` 生成 `familyId`,该逻辑完全交给后端。
**Step 4: 构建验证**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected: 构建通过,无模板语法错误。
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiBaseStaff/index.vue
git commit -m "补充员工资产表单校验与提示"
```
### Task 5: 在详情弹窗中展示员工全部资产信息
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- Test: `ruoyi-ui/tests/unit/employee-asset-maintenance-layout.test.js`
**Step 1: 扩展详情弹窗布局**
在现有“基本信息”卡片下方新增“资产信息”区块。
**Step 2: 增加只读资产表格**
展示字段:
- `personId`
- `ownerType`
- `assetMainType`
- `assetSubType`
- `assetName`
- `ownershipRatio`
- `currentValue`
- `assetStatus`
- `remarks`
其中 `ownerType` 可在前端根据 `personId === employeeDetail.idCard` 计算展示“本人”或“亲属”。
**Step 3: 增加空状态**
`employeeDetail.assetInfoList` 为空时显示“暂无资产信息”。
**Step 4: 回归校验**
再次确认原有基本信息展示不受影响:
- 姓名
- 柜员号
- 所属部门
- 身份证号
- 电话
- 入职时间
- 状态
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiBaseStaff/index.vue ruoyi-ui/tests/unit/employee-asset-maintenance-layout.test.js
git commit -m "新增员工资产详情展示"
```
### Task 6: 增加独立的资产导入入口、弹窗和状态模型
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- Test: `ruoyi-ui/tests/unit/employee-asset-import-ui.test.js`
**Step 1: 先写失败校验脚本**
校验页面存在以下独立状态:
- 资产导入按钮“导入资产信息”
- 资产失败记录按钮“查看员工资产导入失败记录”
- 独立 `assetUpload` 弹窗对象
- 独立 `assetPollingTimer`
- 独立 `assetCurrentTaskId`
- 独立 `assetFailureDialogVisible`
- 独立 localStorage key
**Step 2: 运行校验确认失败**
Run:
```bash
node ruoyi-ui/tests/unit/employee-asset-import-ui.test.js
```
Expected: FAIL因为当前页面只有员工导入状态。
**Step 3: 编写最小导入 UI**
在按钮区新增:
- “导入资产信息”
- “查看员工资产导入失败记录”
`data()` 中新增一套独立状态,例如:
- `assetUpload`
- `assetImportResultVisible`
- `assetImportResultContent`
- `assetPollingTimer`
- `assetShowFailureButton`
- `assetCurrentTaskId`
- `assetFailureDialogVisible`
- `assetFailureList`
- `assetFailureLoading`
- `assetFailureTotal`
- `assetFailureQueryParams`
**Step 4: 新增资产导入弹窗**
复用员工导入交互结构,但文案调整为:
- 标题:`员工资产数据导入`
- 模板下载按钮:下载员工资产模板
- 提示文案:系统将根据 `personId/person_id` 自动识别归属员工
**Step 5: 再次运行校验**
Run:
```bash
node ruoyi-ui/tests/unit/employee-asset-import-ui.test.js
```
Expected: PASS
**Step 6: 提交**
```bash
git add ruoyi-ui/src/views/ccdiBaseStaff/index.vue ruoyi-ui/tests/unit/employee-asset-import-ui.test.js
git commit -m "新增员工资产导入交互状态"
```
### Task 7: 接通资产导入上传、轮询、状态恢复和失败记录查询
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- Check: `ruoyi-ui/src/api/ccdiAssetInfo.js`
**Step 1: 增加资产导入方法**
至少实现以下方法:
- `handleAssetImport()`
- `handleAssetImportDialogClose()`
- `importAssetTemplate()`
- `handleAssetFileUploadProgress()`
- `handleAssetFileSuccess()`
- `startAssetImportStatusPolling(taskId)`
- `handleAssetImportComplete(statusResult)`
- `viewAssetImportFailures()`
- `getAssetFailureList()`
**Step 2: 增加独立 localStorage 管理**
新增一组与员工导入隔离的方法:
- `saveAssetImportTaskToStorage()`
- `getAssetImportTaskFromStorage()`
- `clearAssetImportTaskFromStorage()`
- `restoreAssetImportState()`
- `getLastAssetImportTooltip()`
- `clearAssetImportHistory()`
建议 key 使用:
```text
employee_asset_import_last_task
```
**Step 3: 严格区分员工导入与资产导入**
保持现有员工导入逻辑不回归:
- 员工导入仍使用 `employee_import_last_task`
- 员工失败记录仍显示“查看导入失败记录”
- 资产失败记录按钮固定显示“查看员工资产导入失败记录”
**Step 4: 处理资产导入完成通知**
通知文案应显式区分:
- `员工资产导入任务已提交`
- `员工资产导入完成`
- `成功 X 条,失败 Y 条`
**Step 5: 接通失败记录弹窗**
弹窗标题为:
```text
员工资产导入失败记录
```
表格字段展示:
- `familyId`
- `personId`
- `assetMainType`
- `assetSubType`
- `assetName`
- `errorMessage`
若后端失败记录不返回 `familyId`,前端允许显示为空,不自行推导。
**Step 6: 构建验证**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected: 构建通过,资产导入方法和模板绑定完整。
**Step 7: 提交**
```bash
git add ruoyi-ui/src/views/ccdiBaseStaff/index.vue ruoyi-ui/src/api/ccdiAssetInfo.js
git commit -m "接通员工资产导入轮询与失败记录"
```
### Task 8: 调整样式与可读性,避免资产子表破坏现有弹窗布局
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
**Step 1: 调整编辑弹窗样式**
补充以下样式:
- 资产分区标题
- 资产表格容器
- 空状态
- 表头提示文案
- 详情页资产区块
**Step 2: 保持桌面端可读性**
检查 `1200px` 编辑弹窗在加入资产表后是否仍可读,必要时:
- 增加横向滚动容器
- 调整列宽
- 对备注列使用 `show-overflow-tooltip`
**Step 3: 检查窄屏降级**
至少保证:
- 弹窗内部不会内容挤压到不可操作
- 资产操作列始终可点击
- 详情表格可以横向滚动
**Step 4: 构建验证**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected: 样式改动不影响构建。
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiBaseStaff/index.vue
git commit -m "优化员工资产页面样式与布局"
```
### Task 9: 执行前端联调与最终验证
**Files:**
- Check: `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- Check: `ruoyi-ui/src/api/ccdiBaseStaff.js`
- Check: `ruoyi-ui/src/api/ccdiAssetInfo.js`
- Check: `ruoyi-ui/tests/unit/*.test.js`
**Step 1: 运行源码断言脚本**
Run:
```bash
node ruoyi-ui/tests/unit/employee-asset-api-contract.test.js
node ruoyi-ui/tests/unit/employee-asset-maintenance-layout.test.js
node ruoyi-ui/tests/unit/employee-asset-submit-flow.test.js
node ruoyi-ui/tests/unit/employee-asset-import-ui.test.js
```
Expected: 全部 PASS
**Step 2: 运行生产构建**
Run:
```bash
cd ruoyi-ui
npm run build:prod
```
Expected: 构建成功
**Step 3: 启动本地前端联调**
Run:
```bash
cd ruoyi-ui
npm run dev
```
Expected: 本地员工信息维护页面可访问
**Step 4: 手工验证关键场景**
- 新增员工时添加 1 条本人资产并保存
- 新增员工时添加 1 条亲属资产并保存
- 编辑员工时增加、修改、删除资产行
- 打开员工详情时查看资产信息区
- 执行员工资产导入并检查轮询通知
- 导入失败后打开“查看员工资产导入失败记录”
- 刷新页面后验证资产导入失败记录按钮可恢复
- 清除资产导入历史后验证按钮消失
- 回归验证原员工导入功能未受影响
**Step 5: 整理问题并做最小修复**
优先排查:
- `assetInfoList` 回显为空数组时模板报错
- 资产日期字段格式不一致
- 两套导入轮询定时器互相覆盖
- localStorage key 混用导致员工和资产失败记录串数据
**Step 6: 最终提交**
```bash
git add ruoyi-ui/src/api/ccdiBaseStaff.js ruoyi-ui/src/api/ccdiAssetInfo.js ruoyi-ui/src/views/ccdiBaseStaff/index.vue ruoyi-ui/tests/unit docs/plans/2026-03-12-employee-asset-maintenance-frontend-implementation.md
git commit -m "新增员工资产信息前端实施计划"
```

View File

@@ -0,0 +1,35 @@
# Pull Bank Info Date Limit Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Confirm the new date restriction is fully handled in the frontend and does not require backend changes.
**Architecture:** The allowed date window is enforced in the Vue dialog before the request is submitted. The backend request contract remains `projectId`, `idCards`, `startDate`, and `endDate`, so this plan records a no-op backend implementation boundary and the verification needed to avoid accidental API changes.
**Tech Stack:** Java 21, Spring Boot 3, Maven, existing `ccdi-project` upload APIs
---
### Task 1: Verify backend impact is zero
**Files:**
- Review: `docs/plans/2026-03-12-pull-bank-info-date-limit-design.md`
- Review: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/controller/`
- Review: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/`
**Step 1: Confirm bug scope**
Check that the requirement only changes frontend date selection and frontend submit validation.
**Step 2: Verify request contract stays unchanged**
Confirm the request still submits the same fields:
- `projectId`
- `idCards`
- `startDate`
- `endDate`
**Step 3: Keep backend code unchanged**
Do not modify controller, service, mapper, DTO, or test classes for this task.

View File

@@ -0,0 +1,45 @@
# 拉取本行信息日期范围限制设计
## 背景
项目详情页“上传数据”中的“拉取本行信息”弹窗当前允许选择今天及未来日期,这与业务规则不符。用户只能拉取截止到当前日期前一天的数据。
## 目标
- 日期范围组件中禁用今天及未来日期
- 提交时兜底校验,阻止异常方式带入今天或未来日期
- 保持现有接口、文件解析和弹窗结构不变
## 非目标
- 不调整后端接口
- 不修改现有日期范围字段格式
- 不改变“至少输入一个身份证号”和“必须完整选择时间跨度”的现有校验
## 方案对比
### 方案一:仅在日期面板禁选
- 优点:改动最小,交互直观
- 缺点:若后续通过脚本赋值或异常回填带入无效日期,提交时缺少保护
### 方案二:仅在提交时校验
- 优点:实现简单
- 缺点:用户仍然可以在面板中选到无效日期,体验较差
### 方案三:日期面板禁选 + 提交兜底校验
- 优点:同时覆盖交互层和数据层,最稳妥
- 缺点:多一小段前端校验逻辑
## 最终方案
采用方案三。
在前端日期范围选择器上增加 `picker-options.disabledDate`,将本地当前日期零点及之后的日期全部禁用。以 `2026-03-12` 为例,最晚只能选择到 `2026-03-11`
在提交逻辑中新增“最大可选日期”为昨天的兜底校验。如果开始日期或结束日期晚于昨天,则阻止提交并提示“时间跨度最晚只能选择到昨天”。
## 影响范围
- 前端组件:`ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- 前端测试:`ruoyi-ui/tests/unit/`
- 后端:无代码改动,仅保留接口契约不变
## 验收标准
- 日期面板无法选择今天和未来日期
- 通过异常赋值带入今天或未来日期时,提交会被拦截
- 原有弹窗交互和上传相关逻辑无回归

View File

@@ -0,0 +1,111 @@
# Pull Bank Info Date Limit Frontend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Restrict the pull-bank-info date range so users can only select dates up to yesterday and block invalid submissions.
**Architecture:** Keep the existing dialog and request flow in `UploadData.vue`. Add one focused source-level regression test that asserts the date picker has an explicit restriction hook, then implement a shared “yesterday” boundary for both Element UI `disabledDate` behavior and submit-time validation.
**Tech Stack:** Vue 2, Element UI 2, scoped SFC logic, Node-based source assertions in `ruoyi-ui/tests/unit`
---
### Task 1: Add a regression test for the date restriction hook
**Files:**
- Create: `ruoyi-ui/tests/unit/upload-data-pull-bank-info-date-limit.test.js`
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
**Step 1: Write the failing test**
Create a source-based test that verifies the pull-bank-info date picker:
- uses `picker-options`
- binds to a dedicated `pullBankInfoDatePickerOptions`
- the script contains a helper for calculating the latest allowed date
**Step 2: Run test to verify it fails**
Run:
```bash
node ruoyi-ui/tests/unit/upload-data-pull-bank-info-date-limit.test.js
```
Expected: FAIL because the current component does not define the new date limit hook yet.
### Task 2: Implement date picker restrictions
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Test: `ruoyi-ui/tests/unit/upload-data-pull-bank-info-date-limit.test.js`
**Step 1: Add the date picker option**
Bind the pull-bank-info date range picker to `pullBankInfoDatePickerOptions`.
**Step 2: Add the latest allowed date helper**
Add a method or computed-backed helper that returns yesterday based on the browser local date.
**Step 3: Disable today and future dates**
Implement `disabledDate` so dates greater than or equal to today 00:00 are disabled.
**Step 4: Run the new test**
Run:
```bash
node ruoyi-ui/tests/unit/upload-data-pull-bank-info-date-limit.test.js
```
Expected: PASS
### Task 3: Add submit-time fallback validation
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
- Test: `ruoyi-ui/tests/unit/upload-data-pull-bank-info-date-limit.test.js`
**Step 1: Reuse the same date boundary in submit logic**
Before calling `pullBankInfo(payload)`, verify both selected dates are not later than yesterday.
**Step 2: Show a clear warning**
If validation fails, warn the user with a concise message such as `时间跨度最晚只能选择到昨天`.
**Step 3: Keep existing validations intact**
Retain the current empty-ID-card and incomplete-date-range checks.
### Task 4: Run regression verification
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/UploadData.vue`
**Step 1: Run focused tests**
Run:
```bash
node ruoyi-ui/tests/unit/upload-data-pull-bank-info-date-limit.test.js
node ruoyi-ui/tests/unit/upload-data-pull-bank-info-dialog-layout.test.js
node ruoyi-ui/tests/unit/upload-data-batch-upload.test.js
node ruoyi-ui/tests/unit/upload-data-file-list-settings.test.js
```
Expected: all tests pass.
**Step 2: Run build verification**
Run:
```bash
npm run build:prod
```
Workdir: `ruoyi-ui`
Expected: build succeeds without introducing Vue template or script errors.

View File

@@ -0,0 +1,310 @@
# 员工亲属资产维护后端实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为员工亲属关系维护页面补齐亲属资产的后端聚合保存、详情查询、删除级联和异步导入能力。
**Architecture:**`CcdiStaffFmyRelation` 为聚合根扩展 `assetInfoList`,由资产模块负责持久化和导入,由亲属关系服务负责在新增、编辑、删除、详情等场景中协调资产数据。编辑时通过固定的 `family_id + person_id` 覆盖重建亲属资产,不引入额外关联字段。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MyBatis XML, Redis, EasyExcel, Lombok
---
### Task 1: 补齐资产表 SQL 与索引设计
**Files:**
- Create: `D:\ccdi\ccdi\sql\ccdi_asset_info.sql`
- Check: `D:\ccdi\ccdi\docs\plans\2026-03-12-staff-family-asset-maintenance-design.md`
**Step 1: 写出资产表建表 SQL**
- 新增 `ccdi_asset_info`
- 主键使用 `asset_id BIGINT AUTO_INCREMENT`
- 字段包含 `family_id``person_id` 和资产业务字段
- 审计字段遵循项目现有风格
**Step 2: 为归属查询补索引**
- 添加 `idx_family_id`
- 添加 `idx_person_id`
- 添加联合索引 `idx_family_person`
**Step 3: 自查字段口径**
- 确认 `family_id` 保存员工证件号
- 确认 `person_id` 保存亲属证件号
- 确认不新增 `relation_id`
**Step 4: 记录执行命令**
Run: `Get-Content 'D:\ccdi\ccdi\sql\ccdi_asset_info.sql'`
Expected: 能看到完整建表语句与索引定义
**Step 5: 提交**
```bash
git add sql/ccdi_asset_info.sql
git commit -m "新增亲属资产表结构设计"
```
### Task 2: 创建资产领域对象与映射接口
**Files:**
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\domain\CcdiAssetInfo.java`
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\domain\dto\CcdiAssetInfoDTO.java`
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\domain\vo\CcdiAssetInfoVO.java`
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\domain\excel\CcdiAssetInfoExcel.java`
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\domain\vo\AssetImportFailureVO.java`
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\mapper\CcdiAssetInfoMapper.java`
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\resources\mapper\info\collection\CcdiAssetInfoMapper.xml`
**Step 1: 创建领域实体与 DTO/VO**
- `CcdiAssetInfo` 映射表字段
- `CcdiAssetInfoDTO` 承载亲属关系聚合保存时的子表数据
- `CcdiAssetInfoVO` 用于详情回显
**Step 2: 创建 Excel 与失败记录对象**
- `CcdiAssetInfoExcel` 用于模板下载与导入解析
- `AssetImportFailureVO` 仅返回失败记录
**Step 3: 定义 Mapper 能力**
-`family_id + person_id` 查询资产列表
-`family_id + person_id` 删除资产
- 批量插入资产
- 导入场景下按亲属证件号查询归属员工候选
**Step 4: 运行编译检查**
Run: `mvn -pl ccdi-info-collection -am -DskipTests compile`
Expected: 新增类与 XML 能被正常加载,无编译错误
**Step 5: 提交**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiAssetInfoMapper.xml
git commit -m "新增亲属资产领域对象与映射"
```
### Task 3: 实现资产服务与导入服务
**Files:**
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\service\ICcdiAssetInfoService.java`
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\service\ICcdiAssetInfoImportService.java`
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\service\impl\CcdiAssetInfoServiceImpl.java`
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\service\impl\CcdiAssetInfoImportServiceImpl.java`
**Step 1: 实现基础资产服务**
- 查询当前亲属资产列表
- 按归属键删除资产
- 批量保存资产
- 过滤空行并校验必填字段、数值、日期
**Step 2: 实现资产导入异步服务**
- 初始化 Redis 状态
- 解析 Excel 数据
- 通过 `relation_cert_no` 反查亲属关系
- 识别无法匹配和归属不唯一场景
- 仅缓存失败记录
**Step 3: 明确 Redis Key 规则**
- 状态 key`import:assetInfo:{taskId}`
- 失败 key`import:assetInfo:{taskId}:failures`
**Step 4: 运行编译检查**
Run: `mvn -pl ccdi-info-collection -am -DskipTests compile`
Expected: 服务接口与实现均编译通过
**Step 5: 提交**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service
git commit -m "实现亲属资产服务与导入服务"
```
### Task 4: 扩展亲属关系 DTO 与 VO 聚合资产列表
**Files:**
- Modify: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\domain\dto\CcdiStaffFmyRelationAddDTO.java`
- Modify: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\domain\dto\CcdiStaffFmyRelationEditDTO.java`
- Modify: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\domain\vo\CcdiStaffFmyRelationVO.java`
**Step 1: 为新增 DTO 增加 `assetInfoList`**
- 类型使用 `List<CcdiAssetInfoDTO>`
- 保持与前端子表数据结构一致
**Step 2: 为编辑 DTO 增加 `assetInfoList`**
- 与新增 DTO 保持一致
- 保留现有校验规则
**Step 3: 为详情 VO 增加 `assetInfoList`**
- 类型使用 `List<CcdiAssetInfoVO>`
- 用于详情和编辑回显
**Step 4: 编译验证**
Run: `mvn -pl ccdi-info-collection -am -DskipTests compile`
Expected: DTO、VO 依赖关系正常
**Step 5: 提交**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffFmyRelationAddDTO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiStaffFmyRelationEditDTO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiStaffFmyRelationVO.java
git commit -m "扩展亲属关系聚合资产字段"
```
### Task 5: 改造亲属关系服务聚合保存与详情查询
**Files:**
- Modify: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\service\impl\CcdiStaffFmyRelationServiceImpl.java`
- Check: `D:\ccdi\ccdi\ccdi-info-collection\src\main\resources\mapper\info\collection\CcdiStaffFmyRelationMapper.xml`
**Step 1: 注入资产服务**
- 在服务实现中注入 `ICcdiAssetInfoService`
**Step 2: 改造详情查询**
- 查询亲属关系主记录
-`personId + relationCertNo` 查询资产列表
- 回填 `assetInfoList`
**Step 3: 改造新增逻辑**
- 保存亲属关系
- 回填资产归属键
- 批量保存资产
**Step 4: 改造编辑逻辑**
- 查询旧记录
- 校验证件类型、证件号码未被修改
- 更新主记录
- 删除旧资产
- 保存新资产列表
**Step 5: 改造删除逻辑**
- 根据待删 ID 先查询关系记录
- 循环按 `family_id + person_id` 删除资产
- 再批量删除亲属关系
**Step 6: 编译验证**
Run: `mvn -pl ccdi-info-collection -am -DskipTests compile`
Expected: 亲属关系服务改造后可通过编译
**Step 7: 提交**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationServiceImpl.java
git commit -m "改造亲属关系聚合保存亲属资产"
```
### Task 6: 新增资产导入控制器并接入下载模板与状态查询
**Files:**
- Create: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\controller\CcdiAssetInfoController.java`
**Step 1: 添加模板下载接口**
- `POST /ccdi/assetInfo/importTemplate`
**Step 2: 添加导入接口**
- `POST /ccdi/assetInfo/importData`
- 解析 Excel 后提交异步任务
**Step 3: 添加状态与失败记录接口**
- `GET /ccdi/assetInfo/importStatus/{taskId}`
- `GET /ccdi/assetInfo/importFailures/{taskId}`
**Step 4: 核对权限标识**
- 使用 `ccdi:staffFmyRelation:import` 还是新增独立权限
- 若采用独立权限,记录需同步补菜单 SQL
**Step 5: 编译验证**
Run: `mvn -pl ccdi-info-collection -am -DskipTests compile`
Expected: 控制器注册正常
**Step 6: 提交**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiAssetInfoController.java
git commit -m "新增亲属资产导入控制器"
```
### Task 7: 补充亲属关系编辑防御校验
**Files:**
- Modify: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\service\impl\CcdiStaffFmyRelationServiceImpl.java`
**Step 1: 查询旧记录**
- 编辑保存前按 `id` 查询旧关系
**Step 2: 比较证件类型与证件号码**
- 若值发生变化,抛出业务异常
**Step 3: 明确错误文案**
- 返回“关系人证件类型/证件号码不允许修改”
**Step 4: 编译验证**
Run: `mvn -pl ccdi-info-collection -am -DskipTests compile`
Expected: 校验逻辑编译通过
**Step 5: 提交**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationServiceImpl.java
git commit -m "限制编辑亲属证件信息变更"
```
### Task 8: 执行后端回归验证
**Files:**
- Check: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\controller\CcdiStaffFmyRelationController.java`
- Check: `D:\ccdi\ccdi\ccdi-info-collection\src\main\java\com\ruoyi\info\collection\controller\CcdiAssetInfoController.java`
**Step 1: 编译后端模块**
Run: `mvn -pl ccdi-info-collection -am clean compile`
Expected: BUILD SUCCESS
**Step 2: 启动应用后手工验证接口**
Run: `mvn -pl ruoyi-admin -am spring-boot:run`
Expected: 应用可正常启动
**Step 3: 验证关键场景**
- 查询亲属关系详情返回 `assetInfoList`
- 新增/编辑亲属关系能保存资产
- 删除亲属关系后资产被同步删除
- 亲属资产导入可生成任务状态和失败记录
**Step 4: 整理验证记录**
- 将实际验证结果补充到开发记录或 PR 描述
**Step 5: 提交**
```bash
git add .
git commit -m "完成亲属资产后端联调验证"
```

View File

@@ -0,0 +1,332 @@
# 员工亲属资产维护设计
## 背景
现有员工亲属关系维护页面 `http://localhost/maintain/staffFmyRelation` 已支持员工亲属关系的新增、编辑、删除、详情、导入导出,但尚不支持维护亲属名下资产信息。
当前仓库中已有员工资产维护设计文档 [2026-03-12-employee-asset-maintenance-design.md](/D:/ccdi/ccdi/docs/plans/2026-03-12-employee-asset-maintenance-design.md),其核心约束是通过 `family_id` 表示归属员工,通过 `person_id` 表示资产实际持有人。本次需求需要将该能力调整到“员工亲属关系维护页面”中,并明确仅维护亲属资产,不包含员工本人资产。
本次设计于 2026-03-12 确认以下业务口径:
- 维护入口为员工亲属关系页面 `staffFmyRelation`
- 仅维护当前亲属关系对应的亲属资产,不包含员工本人资产
- 不新增独立的亲属资产菜单页面
- 不新增 `relation_id` 等额外资产归属字段
- `family_id` 存员工证件号,对应亲属关系中的 `person_id`
- `person_id` 存亲属证件号,对应亲属关系中的 `relation_cert_no`
- 亲属资产中的 `person_id` 不强制要求为身份证号,存证件号即可
- 编辑亲属关系时,`relationCertType``relationCertNo` 禁止修改
## 目标
- 在员工亲属关系维护页中新增亲属资产维护能力
- 在亲属关系新增/编辑弹窗中支持维护当前亲属名下资产
- 在亲属关系详情弹窗中展示当前亲属全部资产
- 在亲属关系列表页新增亲属资产导入入口,并复用现有异步导入交互
- 删除亲属关系时同步删除该亲属名下资产
## 非目标
- 不维护员工本人资产
- 不新增独立“亲属资产维护”菜单
- 不新增 `relation_id`
- 不在列表页直接展开显示资产明细
- 不改造现有亲属关系查询条件和分页结构
## 现状
当前员工亲属关系维护能力主要集中在以下位置:
- 前端页面:`ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- 前端接口:`ruoyi-ui/src/api/ccdiStaffFmyRelation.js`
- 后端控制器:`ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffFmyRelationController.java`
- 后端服务:`ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffFmyRelationServiceImpl.java`
- 后端映射:`ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiStaffFmyRelationMapper.xml`
当前一条亲属关系记录的关键字段如下:
- `person_id`:员工证件号
- `relation_cert_type`:亲属证件类型
- `relation_cert_no`:亲属证件号
数据库对 `ccdi_staff_fmy_relation` 设有唯一约束 `uk_person_cert(person_id, relation_cert_no)`,因此一条亲属关系在业务上可以稳定地通过“员工证件号 + 亲属证件号”定位,这也正好可以复用为亲属资产的归属键。
## 方案对比
### 方案一:在亲属关系弹窗内嵌资产子表聚合维护
- 亲属关系详情接口聚合返回 `assetInfoList`
- 亲属关系新增、编辑时同步保存资产列表
- 亲属资产导入采用独立接口,但交互复用现有导入模式
优点:
- 最符合“在亲属关系维护页面维护亲属资产”的使用预期
- 页面上下文最完整,用户不需要在多个弹窗间切换
- 删除亲属关系时级联删除资产最直接
缺点:
- 需要扩展 DTO、VO、Service 和前端弹窗结构
### 方案二:在亲属关系页增加“维护资产”二级弹窗
- 亲属关系页保留现状
- 通过“维护资产”按钮打开独立二级弹窗编辑资产
优点:
- 对现有亲属关系表单侵入较小
缺点:
- 交互割裂
- 用户维护一条亲属关系时无法同时看到关系信息与资产信息
### 方案三:只新增资产查看与导入,不支持页面内编辑
优点:
- 开发量最小
缺点:
- 不满足“添加维护功能”的核心诉求
## 最终方案
采用方案一:在员工亲属关系维护页的新增、编辑、详情弹窗中内嵌“亲属资产信息”分区,由亲属关系接口作为聚合接口返回和保存 `assetInfoList`;同时新增“亲属资产导入”独立接口,并沿用现有亲属关系导入的异步处理与失败记录交互。
## 数据模型设计
### 资产归属规则
亲属资产的归属键定义如下:
- `family_id = 当前亲属关系.person_id`
- `person_id = 当前亲属关系.relation_cert_no`
含义如下:
- `family_id` 表示归属员工
- `person_id` 表示亲属资产实际持有人
- 当前页面只查询和保存满足 `family_id = 员工证件号``person_id = 亲属证件号` 的资产
### 数据表
新增 `ccdi_asset_info` 表,字段来源参考资产设计文档和 `assets/资产信息表.csv`,本次继续沿用以下关键字段:
- `asset_id``BIGINT` 自增主键
- `family_id``VARCHAR(100)`,保存员工证件号
- `person_id``VARCHAR(100)`,保存亲属证件号
- `asset_main_type`
- `asset_sub_type`
- `asset_name`
- `ownership_ratio`
- `purchase_eval_date`
- `original_value`
- `current_value`
- `valuation_date`
- `asset_status`
- `remarks`
- `create_by`
- `create_time`
- `update_by`
- `update_time`
建议索引:
- `idx_family_id(family_id)`
- `idx_person_id(person_id)`
- `idx_family_person(family_id, person_id)`
## 后端设计
### 新增资产模块对象
`ccdi-info-collection` 中新增:
- `domain/CcdiAssetInfo.java`
- `domain/dto/CcdiAssetInfoDTO.java`
- `domain/vo/CcdiAssetInfoVO.java`
- `domain/excel/CcdiAssetInfoExcel.java`
- `domain/vo/AssetImportFailureVO.java`
- `mapper/CcdiAssetInfoMapper.java`
- `service/ICcdiAssetInfoService.java`
- `service/ICcdiAssetInfoImportService.java`
- `service/impl/CcdiAssetInfoServiceImpl.java`
- `service/impl/CcdiAssetInfoImportServiceImpl.java`
- `controller/CcdiAssetInfoController.java`
- `resources/mapper/info/collection/CcdiAssetInfoMapper.xml`
### 扩展亲属关系聚合接口
扩展现有亲属关系 DTO 和 VO
- `CcdiStaffFmyRelationAddDTO.assetInfoList`
- `CcdiStaffFmyRelationEditDTO.assetInfoList`
- `CcdiStaffFmyRelationVO.assetInfoList`
聚合规则:
- 查询详情时,按当前亲属关系的 `personId + relationCertNo` 查询资产列表并组装到 `assetInfoList`
- 新增亲属关系时,先保存关系,再为资产统一回填 `family_id``person_id`
- 编辑亲属关系时,更新关系主信息,再按当前固定归属键重建资产列表
- 删除亲属关系时,先删资产再删亲属关系,整体使用事务
### 编辑时的特殊约束
编辑亲属关系时:
- 前端禁止修改 `relationCertType`
- 前端禁止修改 `relationCertNo`
- 后端读取旧记录后做防御校验
- 若请求中的 `relationCertType``relationCertNo` 与旧值不一致,直接拒绝保存并提示“关系人证件类型/证件号码不允许修改”
### 资产保存策略
编辑当前亲属关系时不要求前端传资产行状态,直接按“当前完整列表覆盖”处理:
- 查询当前亲属关系旧记录
- 校验亲属证件类型、亲属证件号未被修改
- 更新亲属关系主信息
-`family_id = personId``person_id = relationCertNo` 删除旧资产
- 将当前提交的 `assetInfoList` 统一回填归属键后批量插入
由于编辑时禁止修改亲属证件类型和证件号,因此不需要处理资产跨亲属迁移问题。
### 资产导入接口
新增独立资产导入接口:
- `POST /ccdi/assetInfo/importTemplate`
- `POST /ccdi/assetInfo/importData`
- `GET /ccdi/assetInfo/importStatus/{taskId}`
- `GET /ccdi/assetInfo/importFailures/{taskId}`
导入归属规则:
- 模板中 `person_id` 填亲属证件号
- 模板中不要求填写 `family_id`
- 系统通过 `ccdi_staff_fmy_relation.relation_cert_no = person_id` 反查亲属关系
- 若能唯一匹配,则自动回填对应记录的 `person_id``family_id`
- 若找不到匹配关系,导入失败
- 若匹配到多条不同员工关系,导入失败,原因记为“亲属资产归属员工不唯一”
## 前端设计
### 列表页
`ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue` 按钮区新增:
- “导入亲属资产信息”按钮
- “查看亲属资产导入失败记录”按钮
资产导入状态全部独立维护:
- 独立上传弹窗状态
- 独立轮询定时器
- 独立任务 ID
- 独立 `localStorage key`
- 独立失败记录弹窗和分页状态
### 新增和编辑弹窗
在现有亲属关系弹窗的“基本信息”下方新增“亲属资产信息”分区,采用可编辑子表。
子表字段:
- 资产大类
- 资产小类
- 资产名称
- 产权占比
- 购买/评估日期
- 资产原值
- 当前估值
- 估值截止日期
- 资产状态
- 备注
- 操作
交互规则:
- 分区右侧提供“新增资产”按钮
- 每行支持删除
- 编辑时回显 `assetInfoList`
- 前端不展示 `asset_id``family_id``person_id`
- 新增资产前若当前尚未录入亲属证件类型或证件号码,则禁用新增资产并提示“请先填写关系人证件信息”
- 编辑时 `relationCertType``relationCertNo` 置灰禁用
### 详情弹窗
在详情弹窗中新增“亲属资产信息”区域,使用只读表格展示当前亲属名下资产。
若无资产数据,显示“暂无亲属资产信息”。
## 数据流
### 新增亲属关系
前端提交亲属关系基础信息和 `assetInfoList`,后端保存亲属关系后,按当前记录的 `personId``relationCertNo` 为每条资产回填 `family_id``person_id`,再批量保存。
### 编辑亲属关系
前端拉取详情回显基础信息和资产列表,用户修改后提交完整列表。后端校验证件类型、证件号码未变更后,按当前亲属关系归属键重建资产明细。
### 查询详情
后端查询亲属关系主信息,再按 `family_id = personId``person_id = relationCertNo` 查询资产列表并一并返回。
### 删除亲属关系
后端先删除当前亲属名下资产,再删除亲属关系主记录。
### 导入亲属资产
前端上传亲属资产 Excel后端根据 `person_id = 亲属证件号` 识别归属亲属关系并自动回填 `family_id`,异步校验并批量插入资产数据,失败记录通过独立入口查看。
## 校验规则
### 亲属关系保存时
- 沿用现有亲属关系校验规则
- 编辑时禁止修改 `relationCertType`
- 编辑时禁止修改 `relationCertNo`
- `assetInfoList` 中整行为空的数据自动过滤
### 亲属资产保存时
- 资产必填项:`assetMainType``assetSubType``assetName``currentValue``assetStatus`
- 数值字段必须合法
- 日期字段必须合法
- 后端不信任前端传入的归属字段,统一强制回填:
- `family_id = 当前亲属关系.personId`
- `person_id = 当前亲属关系.relationCertNo`
## 异常处理
- 编辑亲属关系时,若请求试图修改 `relationCertType``relationCertNo`,后端直接报错
- 删除亲属关系与删除资产使用同一事务,避免数据不一致
- 查询详情时若资产查询异常,整体接口返回失败,避免前端收到半残数据
- 亲属资产导入时,若亲属证件号无法匹配亲属关系,记录失败原因
- 亲属资产导入时,若一个亲属证件号匹配多个员工关系,记录“亲属资产归属员工不唯一”
- 前端提示文案中明确区分“亲属关系导入”和“亲属资产导入”
## 验收标准
- `staffFmyRelation` 新增/编辑弹窗中可新增、编辑、删除亲属资产
- 编辑亲属关系时,关系人证件类型和证件号码不可修改
- 详情弹窗中可查看该亲属全部资产信息
- 删除亲属关系后,对应亲属资产同步删除
- 亲属资产支持独立导入,并可查看亲属资产导入失败记录
- 亲属资产导入模板不要求填写 `family_id`
- 系统根据 `person_id = 亲属证件号` 自动回填归属员工证件号到 `family_id`
## 测试建议
- 新增亲属关系但不维护资产
- 新增亲属关系并维护单条、多条资产
- 编辑亲属关系时新增、修改、删除资产
- 验证编辑时不能修改亲属证件类型与证件号码
- 删除亲属关系时资产级联删除
- 亲属资产导入成功、无法匹配、匹配不唯一三类场景

View File

@@ -0,0 +1,314 @@
# 员工亲属资产维护前端实施计划
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 在员工亲属关系维护页面中增加亲属资产的可视化维护、详情展示和资产导入交互能力。
**Architecture:** 保持 `ccdiStaffFmyRelation` 为唯一页面入口,在现有新增、编辑、详情弹窗中扩展亲属资产子表,并新增独立的亲属资产导入状态、弹窗和失败记录视图。资产归属字段不在前端暴露,提交时仅上传资产业务字段列表。
**Tech Stack:** Vue 2, Element UI, 若依前端脚手架, Axios request 封装
---
### Task 1: 扩展前端 API 封装资产导入与聚合保存字段
**Files:**
- Modify: `D:\ccdi\ccdi\ruoyi-ui\src\api\ccdiStaffFmyRelation.js`
- Create: `D:\ccdi\ccdi\ruoyi-ui\src\api\ccdiAssetInfo.js`
**Step 1: 保持亲属关系 API 支持传递 `assetInfoList`**
- `addRelation`
- `updateRelation`
- `getRelation`
**Step 2: 新增资产导入 API 文件**
- 下载模板
- 提交导入
- 查询状态
- 查询失败记录
**Step 3: 自查接口路径**
- 使用 `/ccdi/assetInfo/*`
- 与现有 `staffFmyRelation` API 区分清楚
**Step 4: 运行构建检查**
Run: `npm run build:prod`
Expected: API 引用路径无语法错误
**Step 5: 提交**
```bash
git add ruoyi-ui/src/api/ccdiStaffFmyRelation.js ruoyi-ui/src/api/ccdiAssetInfo.js
git commit -m "新增亲属资产前端接口封装"
```
### Task 2: 扩展亲属关系表单模型与重置逻辑
**Files:**
- Modify: `D:\ccdi\ccdi\ruoyi-ui\src\views\ccdiStaffFmyRelation\index.vue`
**Step 1: 为 `form` 增加 `assetInfoList`**
- 默认值设为空数组
- 重置时同步清空
**Step 2: 处理详情与编辑回显**
- `getRelation` 返回后回填 `assetInfoList`
- 保证空值时回退为 `[]`
**Step 3: 在提交前过滤空资产行**
- 仅保留有实际内容的资产
- 不在前端传 `familyId``personId`
**Step 4: 本地验证**
Run: `npm run build:prod`
Expected: 页面脚本编译通过
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue
git commit -m "扩展亲属关系表单资产模型"
```
### Task 3: 在新增/编辑弹窗中加入亲属资产子表
**Files:**
- Modify: `D:\ccdi\ccdi\ruoyi-ui\src\views\ccdiStaffFmyRelation\index.vue`
**Step 1: 新增“亲属资产信息”分区**
- 放在基本信息下方
- 右侧增加“新增资产”按钮
**Step 2: 渲染可编辑子表**
- 字段包含资产大类、资产小类、资产名称、产权占比、购买/评估日期、资产原值、当前估值、估值截止日期、资产状态、备注、操作
**Step 3: 支持资产行增删**
- 新增空白行
- 删除当前行
**Step 4: 对新增资产按钮加前置限制**
- 新增模式下未填写 `relationCertType``relationCertNo` 时禁用
- 提示“请先填写关系人证件信息”
**Step 5: 构建验证**
Run: `npm run build:prod`
Expected: 模板与脚本通过编译
**Step 6: 提交**
```bash
git add ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue
git commit -m "新增亲属资产编辑子表"
```
### Task 4: 锁定编辑场景下的亲属证件字段
**Files:**
- Modify: `D:\ccdi\ccdi\ruoyi-ui\src\views\ccdiStaffFmyRelation\index.vue`
**Step 1: 将 `relationCertType` 在编辑场景下置灰**
- `:disabled="!isAdd"`
**Step 2: 将 `relationCertNo` 在编辑场景下置灰**
- `:disabled="!isAdd"`
**Step 3: 核对员工身份证号选择器**
- 保持当前新增可选、编辑禁改逻辑
**Step 4: 构建验证**
Run: `npm run build:prod`
Expected: 字段禁用逻辑无模板错误
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue
git commit -m "锁定亲属证件字段编辑能力"
```
### Task 5: 在详情弹窗展示亲属资产信息
**Files:**
- Modify: `D:\ccdi\ccdi\ruoyi-ui\src\views\ccdiStaffFmyRelation\index.vue`
**Step 1: 增加详情分区**
- 标题为“亲属资产信息”
**Step 2: 使用只读表格展示资产**
- 展示关键资产字段
- 不展示归属键和主键
**Step 3: 增加空状态**
- 无资产时显示“暂无亲属资产信息”
**Step 4: 构建验证**
Run: `npm run build:prod`
Expected: 详情模板渲染通过
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue
git commit -m "新增亲属资产详情展示"
```
### Task 6: 新增亲属资产导入入口与状态管理
**Files:**
- Modify: `D:\ccdi\ccdi\ruoyi-ui\src\views\ccdiStaffFmyRelation\index.vue`
**Step 1: 在按钮区新增入口**
- “导入亲属资产信息”
- “查看亲属资产导入失败记录”
**Step 2: 新增独立上传弹窗状态**
- `assetUpload`
- `assetImportPollingTimer`
- `assetCurrentTaskId`
**Step 3: 新增独立 localStorage key**
- 例如 `staff_fmy_asset_import_last_task`
**Step 4: 新增资产导入失败记录弹窗与分页状态**
- 与现有亲属关系导入分开维护
**Step 5: 构建验证**
Run: `npm run build:prod`
Expected: 导入状态变量与模板绑定正常
**Step 6: 提交**
```bash
git add ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue
git commit -m "新增亲属资产导入交互状态"
```
### Task 7: 接通资产导入上传、轮询与失败记录查询
**Files:**
- Modify: `D:\ccdi\ccdi\ruoyi-ui\src\views\ccdiStaffFmyRelation\index.vue`
- Check: `D:\ccdi\ccdi\ruoyi-ui\src\api\ccdiAssetInfo.js`
**Step 1: 实现模板下载**
- 调用资产导入模板接口
**Step 2: 实现上传成功回调**
- 校验返回的 `taskId`
- 保存任务状态
- 启动轮询
**Step 3: 实现轮询完成处理**
- 根据成功/失败数展示通知
- 刷新页面列表
- 控制失败记录按钮显示
**Step 4: 实现失败记录查询**
- 独立调用资产失败记录接口
- 处理记录过期和网络错误提示
**Step 5: 构建验证**
Run: `npm run build:prod`
Expected: 资产导入流程相关方法通过编译
**Step 6: 提交**
```bash
git add ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue
git commit -m "接通亲属资产导入轮询与失败记录"
```
### Task 8: 调整样式与交互可读性
**Files:**
- Modify: `D:\ccdi\ccdi\ruoyi-ui\src\views\ccdiStaffFmyRelation\index.vue`
**Step 1: 为资产分区补样式**
- 表格区间距
- 空状态样式
- 分区标题样式
**Step 2: 检查弹窗宽度**
- 保证新增/编辑/详情在资产表加入后仍可读
**Step 3: 检查移动端/窄屏降级**
- 避免列宽完全挤压
**Step 4: 构建验证**
Run: `npm run build:prod`
Expected: 样式变更不影响构建
**Step 5: 提交**
```bash
git add ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue
git commit -m "优化亲属资产页面样式与布局"
```
### Task 9: 执行前端联调验证
**Files:**
- Check: `D:\ccdi\ccdi\ruoyi-ui\src\views\ccdiStaffFmyRelation\index.vue`
**Step 1: 运行生产构建**
Run: `cd ruoyi-ui && npm run build:prod`
Expected: 构建成功
**Step 2: 启动前端开发环境**
Run: `cd ruoyi-ui && npm run dev`
Expected: 本地页面可访问
**Step 3: 手工验证页面场景**
- 新增亲属关系并添加资产
- 编辑亲属关系并维护资产
- 验证证件类型、证件号码禁改
- 查看详情资产区
- 触发亲属资产导入和失败记录查看
**Step 4: 整理验证结果**
- 记录已验证场景和遗留问题
**Step 5: 提交**
```bash
git add .
git commit -m "完成亲属资产前端联调验证"
```

View File

@@ -0,0 +1,136 @@
# CCDI Docker 后端部署 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为 Spring Boot 后端与 `lsfx mock server` 建立可构建、可上传、可在服务器运行的 Docker 部署链路。
**Architecture:** 后端产物继续使用 Maven 构建出的 `ruoyi-admin.jar`,运行时通过 Java 21 容器加载 `local` profile。`lsfx mock server` 作为独立 Python 服务纳入仓库,并在 Compose 中与后端共享网络命名空间,以兼容现有 `http://localhost:8000` 配置。
**Tech Stack:** Maven, Spring Boot 3, Java 21, Docker Compose, Python 3.11, FastAPI, PowerShell, Paramiko
---
### Task 1: 整理 `lsfx mock server` 到主仓库
**Files:**
- Create: `lsfx-mock-server/**`
- Modify: `docs/plans/2026-03-13-ccdi-docker-deployment-design.md`
- Test: `lsfx-mock-server/tests/test_api.py`
**Step 1: 复制并清理运行文件**
- 从现有工作树复制 `main.py``config/``models/``routers/``services/``utils/``requirements.txt``tests/`
- 排除 `__pycache__``.pytest_cache`
**Step 2: 运行 mock server 测试**
Run: `python -m pytest lsfx-mock-server/tests -q`
Expected: 测试通过,接口与健康检查可用
**Step 3: 修正最小必要问题**
- 若路径、依赖或导入失败,仅做最小修复
**Step 4: 记录目录用途**
-`lsfx-mock-server/README.md` 补充与主项目集成的启动说明
### Task 2: 编写后端与 mock 的 Docker 文件
**Files:**
- Create: `docker/backend/Dockerfile`
- Create: `docker/mock/Dockerfile`
- Modify: `lsfx-mock-server/README.md`
**Step 1: 创建后端镜像定义**
- 使用 Java 21 运行时镜像
- 工作目录统一为 `/app`
- 复制 `backend/ruoyi-admin.jar`
- 默认入口使用 `java -jar /app/ruoyi-admin.jar`
**Step 2: 创建 mock 镜像定义**
- 使用 `python:3.11-slim`
- 安装 `lsfx-mock-server/requirements.txt`
- 启动 `python main.py`
**Step 3: 本地验证镜像定义**
Run: `docker build -f docker/mock/Dockerfile -t ccdi-lsfx-mock:test .`
Expected: 构建成功
### Task 3: 编写 Compose 编排
**Files:**
- Create: `docker-compose.yml`
- Create: `.env.example`
**Step 1: 定义 `backend` 服务**
- 端口映射 `62318:8080`
- 环境变量包含 `SPRING_PROFILES_ACTIVE=local``RUOYI_PROFILE=/app/data/ruoyi`
- 卷挂载运行目录与日志目录
**Step 2: 定义 `lsfx-mock-server` 服务**
- 使用 `network_mode: "service:backend"`
- 依赖 `backend`
- 不额外对外暴露端口
**Step 3: 做配置校验**
Run: `docker compose config`
Expected: Compose 文件能正常展开且无语法错误
### Task 4: 编写后端打包与远端部署脚本
**Files:**
- Create: `deploy/deploy.ps1`
- Create: `deploy/remote-deploy.py`
**Step 1: 编写本地打包流程**
- 执行 Maven 打包
- 收集 `ruoyi-admin.jar`
- 检查 `lsfx-mock-server` 运行文件完整性
**Step 2: 编写上传脚本**
- 使用 Paramiko 建立 SSH 与 SFTP 连接
- 创建远端目录 `/volume1/webapp/ccdi`
- 上传 Compose、Dockerfile、后端 JAR、mock 目录
**Step 3: 编写远端启动命令**
- 兼容 `docker compose``docker-compose`
- 执行 `up -d --build`
- 返回容器状态与后端日志摘要
### Task 5: 构建与联调验证
**Files:**
- Modify: `docs/plans/2026-03-13-ccdi-docker-deployment-design.md`
**Step 1: 本地构建后端**
Run: `mvn clean package -DskipTests`
Expected: `ruoyi-admin/target/ruoyi-admin.jar` 生成成功
**Step 2: 本地跑通 Compose 校验**
Run: `docker compose config`
Expected: 无错误
**Step 3: 远端部署验证**
- 验证 `backend` 容器启动
- 验证 `mock server` 在后端网络命名空间内可访问
- 验证 `http://116.62.17.81:62318/swagger-ui/index.html`
**Step 4: 提交**
```bash
git add lsfx-mock-server docker docker-compose.yml .env.example deploy docs/plans/2026-03-13-ccdi-docker-deployment-*.md
git commit -m "新增Docker后端部署方案"
```

View File

@@ -0,0 +1,156 @@
# CCDI Docker 部署设计
**日期**: 2026-03-13
**目标**: 将当前项目的前端、后端与 `lsfx mock server` 打包后上传到服务器 `116.62.17.81:9444``/volume1/webapp/ccdi`,并使用 Docker 统一部署运行。
## 背景与约束
- 前端对外端口固定为 `62319`
- 后端对外端口固定为 `62318`
- `lsfx mock server` 对外端口固定为 `62320`
- 后端运行时必须使用 Java 21
- 后端运行 profile 固定为 `local`
- 后端继续使用现有 [`application-local.yml`](/D:/ccdi/ccdi/ruoyi-admin/src/main/resources/application-local.yml) 中的 MySQL、Redis 与 `lsfx.api.base-url`
- `lsfx.api.base-url` 当前为 `http://localhost:8000`,希望不改动既有配置
- 服务端部署根目录固定为 `/volume1/webapp/ccdi`
## 方案选择
### 方案一:`mock server` 与后端共用网络命名空间
前端、后端、`mock server` 全部使用 Docker 部署,其中 `lsfx mock server` 通过 `network_mode: "service:backend"` 与后端共享网络命名空间。
优点:
- 不需要修改 `application-local.yml` 中的 `http://localhost:8000`
- 后端容器内访问 `localhost:8000` 时,实际就是同网络命名空间内的 `mock server`
- 对外暴露前端、后端和 `lsfx mock server` 端口,同时仍保持后端对 `localhost:8000` 的兼容访问
缺点:
- Compose 编排方式比普通三容器互联稍特殊
### 方案二:三服务独立组网
后端访问 `http://lsfx-mock-server:8000`
优点:
- Compose 结构最常规
缺点:
- 需要修改现有 `local` 配置,不符合本次要求
### 方案三:本地构建镜像后上传镜像包
优点:
- 服务器上不需要源码级构建
缺点:
- 容易受到本地与服务器架构差异影响
- 镜像体积大,上传与迭代成本高
## 最终方案
采用方案一。
## 部署架构
### 前端
- 本地执行 `npm run build:prod`
- 使用 Nginx 容器托管 `ruoyi-ui/dist`
- Nginx 将 `/prod-api``/v3/api-docs` 反向代理到后端容器 `http://backend:8080`
- Docker 对外暴露 `62319`
### 后端
- 本地执行 `mvn clean package -DskipTests`
- 使用 Java 21 运行 `ruoyi-admin/target/ruoyi-admin.jar`
- 通过环境变量设置:
- `SPRING_PROFILES_ACTIVE=local`
- `RUOYI_PROFILE=/app/data/ruoyi`
- Docker 对外暴露 `62318`
- 同时额外映射 `62320 -> 8000`,让宿主机可直接访问共享网络命名空间中的 `lsfx mock server`
### LSFX Mock Server
- 将现有 FastAPI 实现整理为主仓库正式目录
- 使用 Python 3.11 容器运行
- 默认监听 `8000`
- 通过后端共享网络命名空间,对外暴露 `62320`
- 通过 `network_mode: "service:backend"` 让后端继续使用 `http://localhost:8000`
## 目录规划
服务器目录规划如下:
```text
/volume1/webapp/ccdi/
├── docker-compose.yml
├── .env
├── deploy/
│ ├── deploy.ps1
│ └── remote-deploy.py
├── docker/
│ ├── backend/Dockerfile
│ ├── frontend/Dockerfile
│ ├── frontend/nginx.conf
│ └── mock/Dockerfile
├── backend/
│ └── ruoyi-admin.jar
├── frontend/
│ └── dist/
├── lsfx-mock-server/
└── runtime/
├── ruoyi/
└── logs/
```
## 关键配置设计
### `ruoyi.profile`
当前 [`application-local.yml`](/D:/ccdi/ccdi/ruoyi-admin/src/main/resources/application-local.yml) 未定义 `ruoyi.profile`。后端代码中的 [`RuoYiConfig.java`](/D:/ccdi/ccdi/ruoyi-common/src/main/java/com/ruoyi/common/config/RuoYiConfig.java) 依赖该值计算上传、导入与头像目录。
因此在 Docker 运行时通过环境变量补充:
```text
RUOYI_PROFILE=/app/data/ruoyi
```
并挂载到服务器目录,确保容器重启后数据保留。
### 反向代理
前端仍保持生产构建时的 `VUE_APP_BASE_API=/prod-api`避免改动业务代码。Nginx 负责将:
- `/prod-api/` 转发到 `http://backend:8080/`
- `/v3/api-docs/` 转发到 `http://backend:8080/v3/api-docs/`
## 部署流程
1. 本地整理并提交部署文件
2. 本地打包前端与后端产物
3. 本地通过 SSH/SFTP 上传到服务器目标路径
4. 远端执行 `docker compose up -d --build`
5. 验证前端、后端、`mock server` 与代理链路
## 验证点
- `http://116.62.17.81:62319` 可打开前端
- `http://116.62.17.81:62318/swagger-ui/index.html` 可访问后端文档
- `http://116.62.17.81:62320/docs` 可访问 `lsfx mock server` 文档
- 前端登录与接口请求经 `/prod-api` 正常转发
- 后端容器可访问 `http://localhost:8000`
- `mock server` 健康检查正常
## 风险与处理
- 若服务器仅支持 `docker-compose`,部署脚本需兼容 `docker compose``docker-compose`
- 若服务器无法访问 `192.168.0.111` 上的 MySQL/Redis则后端启动会失败本次不改该配置
- 若服务器无 Docker 运行环境,需要先补齐 Docker 与 Compose 插件

View File

@@ -0,0 +1,89 @@
# CCDI Docker 前端部署 Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为 Vue 前端建立可打包、可容器化、可上传并在服务器通过 Nginx 对外提供服务的 Docker 部署链路。
**Architecture:** 前端继续使用现有 `npm run build:prod` 产出 `dist`,容器内由 Nginx 提供静态资源与反向代理。通过 `/prod-api``/v3/api-docs` 将请求转发到后端容器,保持现有业务代码与生产环境变量不变。
**Tech Stack:** Vue 2, npm, Nginx, Docker Compose, PowerShell, Paramiko
---
### Task 1: 定义前端容器与 Nginx 代理
**Files:**
- Create: `docker/frontend/Dockerfile`
- Create: `docker/frontend/nginx.conf`
- Modify: `docs/plans/2026-03-13-ccdi-docker-deployment-design.md`
**Step 1: 创建前端镜像定义**
- 基于 `nginx:stable-alpine`
- 复制 `frontend/dist` 到 Nginx 静态目录
- 复制自定义 `nginx.conf`
**Step 2: 配置反向代理**
- `/` 返回前端 `index.html`
- `/prod-api/` 代理到 `http://backend:8080/`
- `/v3/api-docs/` 代理到 `http://backend:8080/v3/api-docs/`
**Step 3: 校验 Nginx 配置**
Run: `docker run --rm -v ${PWD}/docker/frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro nginx:stable-alpine nginx -t`
Expected: syntax is ok
### Task 2: 编写前端打包收集流程
**Files:**
- Modify: `deploy/deploy.ps1`
- Create: `frontend/.gitkeep`
**Step 1: 构建前端**
Run: `npm --prefix ruoyi-ui run build:prod`
Expected: `ruoyi-ui/dist` 生成成功
**Step 2: 收集部署目录**
-`ruoyi-ui/dist` 复制到 `frontend/dist`
- 保持部署目录与 Dockerfile 输入一致
### Task 3: 将前端加入 Compose
**Files:**
- Modify: `docker-compose.yml`
- Modify: `.env.example`
**Step 1: 定义 `frontend` 服务**
- 暴露 `62319:80`
- 依赖 `backend`
**Step 2: 校验 Compose**
Run: `docker compose config`
Expected: 前端服务、依赖与端口映射正确
### Task 4: 联调验证
**Files:**
- Modify: `docs/plans/2026-03-13-ccdi-docker-deployment-design.md`
**Step 1: 检查前端生产产物**
- 验证 `dist/index.html``static/` 文件生成
**Step 2: 远端验证访问**
- 验证 `http://116.62.17.81:62319`
- 登录后检查浏览器请求是否发往 `/prod-api`
- 验证 Swagger 页面可通过前端入口转发访问
**Step 3: 提交**
```bash
git add docker/frontend deploy/deploy.ps1 docker-compose.yml .env.example docs/plans/2026-03-13-ccdi-docker-deployment-*.md
git commit -m "新增Docker前端部署方案"
```

View File

@@ -0,0 +1,69 @@
# Deploy To NAS Backend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 为现有后端打包与远端部署链路增加一个可被 `.bat` 入口复用的 `DryRun` 模式。
**Architecture:** 保持 `deploy.ps1` 作为真实执行器不变,仅增加参数解析和轻量分支,让 BAT 可以先走快速验证,再走真实部署。底层上传与远端部署逻辑继续复用现有 Python 脚本。
**Tech Stack:** PowerShell, Python, Docker Compose, Windows CMD
---
### Task 1: 为 `deploy.ps1` 增加 DryRun 模式
**Files:**
- Modify: `deploy/deploy.ps1`
- Test: `tests/deploy/test_deploy_to_nas.py`
**Step 1: 写失败测试**
```python
def test_deploy_ps1_dry_run_prints_target():
...
```
**Step 2: 运行测试确认失败**
Run: `py -3.12 -m pytest tests/deploy/test_deploy_to_nas.py::test_deploy_ps1_dry_run_prints_target -q`
Expected: 失败,因为 `deploy.ps1` 还不支持 `-DryRun`
**Step 3: 最小实现**
- 新增 `-DryRun` 开关
- 打印 `Host/Port/Username/RemoteRoot`
- 直接返回成功
**Step 4: 重新运行测试**
Run: `py -3.12 -m pytest tests/deploy/test_deploy_to_nas.py::test_deploy_ps1_dry_run_prints_target -q`
Expected: 通过
### Task 2: 保持真实部署行为不变
**Files:**
- Modify: `deploy/deploy.ps1`
- Modify: `deploy/remote-deploy.py`
- Test: `tests/deploy/test_deploy_to_nas.py`
**Step 1: 写失败测试**
```python
def test_deploy_ps1_still_accepts_default_parameters():
...
```
**Step 2: 运行测试确认失败**
Run: `py -3.12 -m pytest tests/deploy/test_deploy_to_nas.py::test_deploy_ps1_still_accepts_default_parameters -q`
Expected: 因缺少对应输出或参数处理失败而不通过
**Step 3: 最小实现**
- 保持默认 NAS 参数
- 保持真实执行路径不变
**Step 4: 运行测试**
Run: `py -3.12 -m pytest tests/deploy/test_deploy_to_nas.py -q`
Expected: 通过

View File

@@ -0,0 +1,110 @@
# 一键部署 BAT 入口设计
**日期**: 2026-03-13
**目标**: 在现有 PowerShell 与 Python 部署链路之上,新增一个 Windows 下可直接双击或命令行执行的 `.bat` 入口脚本,用于一键打包前后端并部署到 NAS。
## 背景
当前仓库已经有以下部署能力:
- [`deploy/deploy.ps1`](/D:/ccdi/ccdi/deploy/deploy.ps1):负责本地打包、组装部署目录、上传到 NAS、远端执行 Docker Compose
- [`deploy/remote-deploy.py`](/D:/ccdi/ccdi/deploy/remote-deploy.py):负责 SSH/SFTP 上传与远端 Docker 部署
但 Windows 用户直接使用时仍需要显式调用 PowerShell不够直观。
## 方案选择
### 方案一:薄封装 BAT 入口
新增一个 `deploy/deploy-to-nas.bat`,只做以下几件事:
- 定位仓库根目录
- 调用 PowerShell 执行 `deploy.ps1`
- 提供默认的 NAS 连接参数
- 原样透传退出码
优点:
- 复用现有稳定链路
- 维护成本最低
- 双击和命令行都能使用
缺点:
- 底层仍依赖 PowerShell、Python、Maven、npm
### 方案二:把所有逻辑都改写到 BAT
优点:
- 形式上只有一个入口文件
缺点:
- BAT 对目录处理、错误处理、网络部署支持差
- 可维护性明显下降
### 方案三BAT + 独立配置文件
优点:
- 多环境切换更灵活
缺点:
- 对当前固定 NAS 场景偏重
## 最终方案
采用方案一。
## 设计细节
### 入口脚本
新增 [`deploy/deploy-to-nas.bat`](/D:/ccdi/ccdi/deploy/deploy-to-nas.bat)。
职责:
- 默认使用:
- Host: `116.62.17.81`
- Port: `9444`
- Username: `wkc`
- Password: `wkc@0825`
- RemoteRoot: `/volume1/webapp/ccdi`
- 支持命令行覆盖参数
- 统一调用 `powershell -ExecutionPolicy Bypass -File deploy.ps1`
### 可验证性
为避免每次验证都真的触发完整部署,给 [`deploy/deploy.ps1`](/D:/ccdi/ccdi/deploy/deploy.ps1) 增加一个 `-DryRun` 开关:
- 打印将要使用的目标参数
- 不执行 Maven、npm、上传与远端部署
- 直接返回 `0`
这样 `.bat` 可以配合 `--dry-run` 做快速回归验证。
### 参数约定
BAT 入口参数顺序:
```text
deploy-to-nas.bat [host] [port] [username] [password] [remoteRoot] [--dry-run]
```
如果不传,则使用默认值。
## 验证方式
1. `cmd /c deploy\deploy-to-nas.bat --dry-run`
2. 确认输出中的 NAS 地址、端口、路径与默认值一致
3. 可选:`cmd /c deploy\deploy-to-nas.bat 116.62.17.81 9444 wkc wkc@0825 /volume1/webapp/ccdi --dry-run`
4. 最终运行无 `--dry-run` 的真实部署
## 风险与处理
- 若用户机器禁止 PowerShell 脚本执行BAT 通过 `-ExecutionPolicy Bypass` 绕过当前会话限制
- 若路径中存在空格BAT 需统一用双引号包裹
- 若密码中存在特殊字符BAT 只做原样透传,不自行拼接复杂 shell 表达式

View File

@@ -0,0 +1,68 @@
# Deploy To NAS Frontend Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** 新增一个 Windows 下可双击执行的 `.bat` 一键入口,默认触发前后端打包并部署到 NAS。
**Architecture:** 通过 `deploy-to-nas.bat` 作为薄封装入口,把默认参数与可选覆盖参数转交给现有 `deploy.ps1`。BAT 只负责入口体验,不承载核心部署逻辑。
**Tech Stack:** Windows CMD, PowerShell, pytest
---
### Task 1: 新增 BAT 入口脚本
**Files:**
- Create: `deploy/deploy-to-nas.bat`
- Test: `tests/deploy/test_deploy_to_nas.py`
**Step 1: 写失败测试**
```python
def test_bat_dry_run_uses_default_nas_target():
...
```
**Step 2: 运行测试确认失败**
Run: `py -3.12 -m pytest tests/deploy/test_deploy_to_nas.py::test_bat_dry_run_uses_default_nas_target -q`
Expected: 失败,因为 BAT 文件不存在
**Step 3: 最小实现**
- 新建 BAT 文件
- 默认调用 `deploy.ps1`
- 支持 `--dry-run`
**Step 4: 运行测试**
Run: `py -3.12 -m pytest tests/deploy/test_deploy_to_nas.py::test_bat_dry_run_uses_default_nas_target -q`
Expected: 通过
### Task 2: 支持参数覆盖
**Files:**
- Modify: `deploy/deploy-to-nas.bat`
- Test: `tests/deploy/test_deploy_to_nas.py`
**Step 1: 写失败测试**
```python
def test_bat_dry_run_accepts_override_arguments():
...
```
**Step 2: 运行测试确认失败**
Run: `py -3.12 -m pytest tests/deploy/test_deploy_to_nas.py::test_bat_dry_run_accepts_override_arguments -q`
Expected: 失败,因为 BAT 未透传覆盖参数
**Step 3: 最小实现**
- 按位置参数传递 host、port、username、password、remoteRoot
-`--dry-run` 透传给 PowerShell
**Step 4: 全量测试**
Run: `py -3.12 -m pytest tests/deploy/test_deploy_to_nas.py -q`
Expected: 通过

View File

@@ -0,0 +1,16 @@
# 应用配置
APP_NAME=流水分析Mock服务
APP_VERSION=1.0.0
DEBUG=true
# 服务器配置
HOST=0.0.0.0
PORT=8000
# 模拟配置
PARSE_DELAY_SECONDS=4
MAX_FILE_SIZE=10485760
# 初始ID配置
INITIAL_PROJECT_ID=1000
INITIAL_LOG_ID=10000

3
lsfx-mock-server/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
__pycache__/
.pytest_cache/
*.pyc

244
lsfx-mock-server/README.md Normal file
View File

@@ -0,0 +1,244 @@
# 流水分析 Mock 服务器
基于 Python + FastAPI 的独立 Mock 服务器,用于模拟流水分析平台的 7 个核心接口。
## ✨ 特性
-**完整的接口模拟** - 实现所有 7 个核心接口
-**文件解析延迟** - 使用 FastAPI 后台任务模拟 4 秒解析延迟
-**错误场景触发** - 通过 `error_XXXX` 标记触发所有 8 个错误码
-**自动 API 文档** - Swagger UI 和 ReDoc 自动生成
-**配置驱动** - JSON 模板文件,易于修改响应数据
-**零配置启动** - 开箱即用,无需数据库
## 🚀 快速开始
### 1. 安装依赖
```bash
pip install -r requirements.txt
```
### 2. 启动服务
```bash
python main.py
```
或使用 uvicorn支持热重载
```bash
uvicorn main:app --reload --host 0.0.0.0 --port 8000
```
### 3. 访问 API 文档
- **Swagger UI**: http://localhost:8000/docs
- **ReDoc**: http://localhost:8000/redoc
## 📖 使用示例
### 正常流程
```python
import requests
# 1. 获取 Token
response = requests.post(
"http://localhost:8000/account/common/getToken",
json={
"projectNo": "test_project_001",
"entityName": "测试企业",
"userId": "902001",
"userName": "902001",
"appId": "remote_app",
"appSecretCode": "test_secret_code_12345",
"role": "VIEWER",
"orgCode": "902000",
"departmentCode": "902000"
}
)
token_data = response.json()
project_id = token_data["data"]["projectId"]
# 2. 上传文件
files = {"file": ("test.csv", open("test.csv", "rb"), "text/csv")}
response = requests.post(
"http://localhost:8000/watson/api/project/remoteUploadSplitFile",
files=files,
data={"groupId": project_id}
)
log_id = response.json()["data"]["uploadLogList"][0]["logId"]
# 3. 轮询检查解析状态
import time
for i in range(10):
response = requests.post(
"http://localhost:8000/watson/api/project/upload/getpendings",
json={"groupId": project_id, "inprogressList": str(log_id)}
)
result = response.json()
if not result["data"]["parsing"]:
print("解析完成")
break
time.sleep(1)
# 4. 获取银行流水
response = requests.post(
"http://localhost:8000/watson/api/project/getBSByLogId",
json={
"groupId": project_id,
"logId": log_id,
"pageNow": 1,
"pageSize": 10
}
)
```
### 错误场景测试
```python
# 触发 40101 错误appId错误
response = requests.post(
"http://localhost:8000/account/common/getToken",
json={
"projectNo": "test_error_40101", # 包含错误标记
"entityName": "测试企业",
"userId": "902001",
"userName": "902001",
"appId": "remote_app",
"appSecretCode": "test_secret_code_12345",
"role": "VIEWER",
"orgCode": "902000",
"departmentCode": "902000"
}
)
# 返回: {"code": "40101", "message": "appId错误", ...}
```
## 🔧 配置说明
### 环境变量
创建 `.env` 文件(参考 `.env.example`
```bash
# 应用配置
APP_NAME=流水分析Mock服务
APP_VERSION=1.0.0
DEBUG=true
# 服务器配置
HOST=0.0.0.0
PORT=8000
# 模拟配置
PARSE_DELAY_SECONDS=4
MAX_FILE_SIZE=10485760
```
### 响应模板
修改 `config/responses/` 下的 JSON 文件可以自定义响应数据:
- `token.json` - Token 响应模板
- `upload.json` - 上传文件响应模板
- `parse_status.json` - 解析状态响应模板
- `bank_statement.json` - 银行流水响应模板
## 🐳 Docker 部署
### 使用 Docker
```bash
# 构建镜像
docker build -t lsfx-mock-server .
# 运行容器
docker run -d -p 8000:8000 --name lsfx-mock lsfx-mock-server
```
### 使用 Docker Compose
```bash
docker-compose up -d
```
## 📁 项目结构
```
lsfx-mock-server/
├── main.py # 应用入口
├── config/
│ ├── settings.py # 全局配置
│ └── responses/ # 响应模板
├── models/
│ ├── request.py # 请求模型
│ └── response.py # 响应模型
├── services/
│ ├── token_service.py # Token 管理
│ ├── file_service.py # 文件上传和解析
│ └── statement_service.py # 流水数据管理
├── routers/
│ └── api.py # API 路由
├── utils/
│ ├── error_simulator.py # 错误模拟
│ └── response_builder.py # 响应构建器
└── tests/ # 测试套件
```
## 🧪 运行测试
```bash
# 运行所有测试
pytest tests/ -v
# 生成覆盖率报告
pytest tests/ -v --cov=. --cov-report=html
```
## 🔌 API 接口列表
| 接口 | 方法 | 路径 | 描述 |
|------|------|------|------|
| 1 | POST | `/account/common/getToken` | 获取 Token |
| 2 | POST | `/watson/api/project/remoteUploadSplitFile` | 上传文件 |
| 3 | POST | `/watson/api/project/getJZFileOrZjrcuFile` | 拉取行内流水 |
| 4 | POST | `/watson/api/project/upload/getpendings` | 检查解析状态 |
| 5 | POST | `/watson/api/project/batchDeleteUploadFile` | 删除文件 |
| 6 | POST | `/watson/api/project/getBSByLogId` | 获取银行流水 |
## ⚠️ 错误码列表
| 错误码 | 描述 |
|--------|------|
| 40101 | appId错误 |
| 40102 | appSecretCode错误 |
| 40104 | 可使用项目次数为0无法创建项目 |
| 40105 | 只读模式下无法新建项目 |
| 40106 | 错误的分析类型,不在规定的取值范围内 |
| 40107 | 当前系统不支持的分析类型 |
| 40108 | 当前用户所属行社无权限 |
| 501014 | 无行内流水文件 |
## 🛠️ 开发指南
### 添加新接口
1.`models/request.py``models/response.py` 中添加模型
2.`services/` 中添加服务类
3.`routers/api.py` 中添加路由
4.`config/responses/` 中添加响应模板
5. 编写测试
### 修改响应数据
直接编辑 `config/responses/` 下的 JSON 文件,重启服务即可生效。
## 📝 License
MIT
## 🤝 Contributing
欢迎提交 Issue 和 Pull Request

View File

@@ -0,0 +1,106 @@
{
"success_response": {
"code": "200",
"data": {
"bankStatementList": [
{
"accountId": 0,
"accountMaskNo": "101015251071645",
"accountingDate": "2024-02-01",
"accountingDateId": 20240201,
"archivingFlag": 0,
"attachments": 0,
"balanceAmount": 4814.82,
"bank": "ZJRCU",
"bankComments": "",
"bankStatementId": 12847662,
"bankTrxNumber": "1a10458dd5c3366d7272285812d434fc",
"batchId": 19135,
"cashType": "1",
"commentsNum": 0,
"crAmount": 0,
"cretNo": "230902199012261247",
"currency": "CNY",
"customerAccountMaskNo": "597671502",
"customerBank": "",
"customerId": -1,
"customerName": "小店",
"customerReference": "",
"downPaymentFlag": 0,
"drAmount": 245.8,
"exceptionType": "",
"groupId": 16238,
"internalFlag": 0,
"leId": 16308,
"leName": "张传伟",
"overrideBsId": 0,
"paymentMethod": "",
"sourceCatalogId": 0,
"split": 0,
"subBankstatementId": 0,
"toDoFlag": 0,
"transAmount": 245.8,
"transFlag": "P",
"transTypeId": 0,
"transformAmount": 0,
"transformCrAmount": 0,
"transformDrAmount": 0,
"transfromBalanceAmount": 0,
"trxBalance": 0,
"trxDate": "2024-02-01 10:33:44",
"userMemo": "财付通消费_小店"
},
{
"accountId": 0,
"accountMaskNo": "101015251071645",
"accountingDate": "2024-02-02",
"accountingDateId": 20240202,
"archivingFlag": 0,
"attachments": 0,
"balanceAmount": 5000.00,
"bank": "ZJRCU",
"bankComments": "",
"bankStatementId": 12847663,
"bankTrxNumber": "2b20568ee6d4477e8383396923e545gd",
"batchId": 19135,
"cashType": "1",
"commentsNum": 0,
"crAmount": 185.18,
"cretNo": "230902199012261247",
"currency": "CNY",
"customerAccountMaskNo": "123456789",
"customerBank": "",
"customerId": -1,
"customerName": "支付宝",
"customerReference": "",
"downPaymentFlag": 0,
"drAmount": 0,
"exceptionType": "",
"groupId": 16238,
"internalFlag": 0,
"leId": 16308,
"leName": "张传伟",
"overrideBsId": 0,
"paymentMethod": "",
"sourceCatalogId": 0,
"split": 0,
"subBankstatementId": 0,
"toDoFlag": 0,
"transAmount": 185.18,
"transFlag": "R",
"transTypeId": 0,
"transformAmount": 0,
"transformCrAmount": 0,
"transformDrAmount": 0,
"transfromBalanceAmount": 0,
"trxBalance": 0,
"trxDate": "2024-02-02 14:22:18",
"userMemo": "支付宝转账_支付宝"
}
],
"totalCount": 131
},
"status": "200",
"successResponse": true
}
}

View File

@@ -0,0 +1,41 @@
{
"success_response": {
"code": "200",
"data": {
"parsing": false,
"pendingList": [
{
"accountNoList": [],
"bankName": "ZJRCU",
"dataTypeInfo": ["CSV", ","],
"downloadFileName": "230902199012261247_20260201_20260201_1772096608615.csv",
"enterpriseNameList": [],
"filePackageId": "cde6c7cf5cab48e8892f0c1c36b2aa7d",
"fileSize": 53101,
"fileUploadBy": 448,
"fileUploadByUserName": "admin@support.com",
"fileUploadTime": "2026-02-27 09:50:18",
"isSplit": 0,
"leId": 16210,
"logId": "{log_id}",
"logMeta": "{\"lostHeader\":[],\"balanceAmount\":true}",
"logType": "bankstatement",
"loginLeId": 16210,
"lostHeader": [],
"realBankName": "ZJRCU",
"rows": 0,
"source": "http",
"status": -5,
"templateName": "ZJRCU_T251114",
"totalRecords": 131,
"trxDateEndId": 20240228,
"trxDateStartId": 20240201,
"uploadFileName": "230902199012261247_20260201_20260201_1772096608615.csv",
"uploadStatusDesc": "data.wait.confirm.newaccount"
}
]
},
"status": "200",
"successResponse": true
}
}

View File

@@ -0,0 +1,15 @@
{
"success_response": {
"code": "200",
"data": {
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.mock_token_{project_id}",
"projectId": "{project_id}",
"projectNo": "{project_no}",
"entityName": "{entity_name}",
"analysisType": 0
},
"message": "create.token.success",
"status": "200",
"successResponse": true
}
}

View File

@@ -0,0 +1,49 @@
{
"success_response": {
"code": "200",
"data": {
"accountsOfLog": {
"{log_id}": [
{
"bank": "BSX",
"accountName": "测试账户",
"accountNo": "6222021234567890",
"currency": "CNY"
}
]
},
"uploadLogList": [
{
"accountNoList": [],
"bankName": "BSX",
"dataTypeInfo": ["CSV", ","],
"downloadFileName": "测试流水.csv",
"enterpriseNameList": [],
"filePackageId": "14b13103010e4d32b5406c764cfe3644",
"fileSize": 46724,
"fileUploadBy": 448,
"fileUploadByUserName": "admin@support.com",
"fileUploadTime": "{upload_time}",
"leId": 10724,
"logId": "{log_id}",
"logMeta": "{\"lostHeader\":[],\"balanceAmount\":true}",
"logType": "bankstatement",
"loginLeId": 10724,
"realBankName": "BSX",
"rows": 0,
"source": "http",
"status": -5,
"templateName": "BSX_T240925",
"totalRecords": 280,
"trxDateEndId": 20240905,
"trxDateStartId": 20230914,
"uploadFileName": "测试流水.csv",
"uploadStatusDesc": "data.wait.confirm.newaccount"
}
],
"uploadStatus": 1
},
"status": "200",
"successResponse": true
}
}

View File

@@ -0,0 +1,30 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
"""全局配置类"""
# 应用配置
APP_NAME: str = "流水分析Mock服务"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
# 服务器配置
HOST: str = "0.0.0.0"
PORT: int = 8000
# 模拟配置
PARSE_DELAY_SECONDS: int = 4 # 文件解析延迟秒数
MAX_FILE_SIZE: int = 10485760 # 10MB
# 测试数据配置
INITIAL_PROJECT_ID: int = 1000
INITIAL_LOG_ID: int = 10000
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()

80
lsfx-mock-server/main.py Normal file
View File

@@ -0,0 +1,80 @@
"""
流水分析Mock服务器 - 主应用入口
基于 FastAPI 实现的 Mock 服务器,用于模拟流水分析平台的 7 个核心接口
"""
from fastapi import FastAPI
from routers import api
from config.settings import settings
# 创建 FastAPI 应用实例
app = FastAPI(
title=settings.APP_NAME,
description="""
## 流水分析 Mock 服务器
模拟流水分析平台的 7 个核心接口,用于开发和测试。
### 主要功能
- **Token管理** - 创建项目并获取访问Token
- **文件上传** - 上传流水文件支持异步解析4秒延迟
- **行内流水** - 拉取行内流水数据
- **解析状态** - 轮询检查文件解析状态
- **文件删除** - 批量删除上传的文件
- **流水查询** - 分页获取银行流水数据
### 错误模拟
在请求参数中包含 `error_XXXX` 标记可触发对应的错误响应。
例如:`projectNo: "test_error_40101"` 将返回 40101 错误。
### 使用方式
1. 获取Token: POST /account/common/getToken
2. 上传文件: POST /watson/api/project/remoteUploadSplitFile
3. 轮询解析状态: POST /watson/api/project/upload/getpendings
4. 获取流水: POST /watson/api/project/getBSByLogId
""",
version=settings.APP_VERSION,
docs_url="/docs",
redoc_url="/redoc",
)
# 包含 API 路由
app.include_router(api.router, tags=["流水分析接口"])
@app.get("/", summary="服务根路径")
async def root():
"""服务根路径,返回基本信息"""
return {
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
"swagger_docs": "/docs",
"redoc": "/redoc",
"status": "running",
}
@app.get("/health", summary="健康检查")
async def health_check():
"""健康检查端点"""
return {
"status": "healthy",
"service": settings.APP_NAME,
"version": settings.APP_VERSION,
}
if __name__ == "__main__":
import uvicorn
# 启动服务器
uvicorn.run(
app,
host=settings.HOST,
port=settings.PORT,
log_level="debug" if settings.DEBUG else "info",
)

View File

@@ -0,0 +1 @@
# Models package

View File

@@ -0,0 +1,53 @@
from pydantic import BaseModel, Field
from typing import Optional, List
class GetTokenRequest(BaseModel):
"""获取Token请求模型"""
projectNo: str = Field(..., description="项目编号格式902000_当前时间戳")
entityName: str = Field(..., description="项目名称")
userId: str = Field(..., description="操作人员编号,固定值")
userName: str = Field(..., description="操作人员姓名,固定值")
appId: str = Field("remote_app", description="应用ID固定值")
appSecretCode: str = Field(..., description="安全码md5(projectNo + '_' + entityName + '_' + dXj6eHRmPv)")
role: str = Field("VIEWER", description="角色,固定值")
orgCode: str = Field(..., description="行社机构号,固定值")
entityId: Optional[str] = Field(None, description="企业统信码或个人身份证号")
xdRelatedPersons: Optional[str] = Field(None, description="信贷关联人信息")
jzDataDateId: Optional[str] = Field("0", description="拉取指定日期推送过来的金综链流水")
innerBSStartDateId: Optional[str] = Field("0", description="拉取行内流水开始日期")
innerBSEndDateId: Optional[str] = Field("0", description="拉取行内流水结束日期")
analysisType: str = Field("-1", description="分析类型,固定值")
departmentCode: str = Field(..., description="客户经理所属营业部/分理处的机构编码")
class FetchInnerFlowRequest(BaseModel):
"""拉取行内流水请求模型"""
groupId: int = Field(..., description="项目id")
customerNo: str = Field(..., description="客户身份证号")
dataChannelCode: str = Field(..., description="校验码")
requestDateId: int = Field(..., description="发起请求的时间")
dataStartDateId: int = Field(..., description="拉取开始日期")
dataEndDateId: int = Field(..., description="拉取结束日期")
uploadUserId: int = Field(..., description="柜员号")
class CheckParseStatusRequest(BaseModel):
"""检查文件解析状态请求模型"""
groupId: int = Field(..., description="项目id")
inprogressList: str = Field(..., description="文件id列表逗号分隔")
class GetBankStatementRequest(BaseModel):
"""获取银行流水请求模型"""
groupId: int = Field(..., description="项目id")
logId: int = Field(..., description="文件id")
pageNow: int = Field(..., description="当前页码")
pageSize: int = Field(..., description="查询条数")
class DeleteFilesRequest(BaseModel):
"""删除文件请求模型"""
groupId: int = Field(..., description="项目id")
logIds: List[int] = Field(..., description="文件id数组")
userId: int = Field(..., description="用户柜员号")

View File

@@ -0,0 +1,187 @@
from pydantic import BaseModel, Field
from typing import Optional, List, Dict, Any
# ==================== Token相关模型 ====================
class TokenData(BaseModel):
"""Token数据"""
token: str = Field(..., description="token")
projectId: int = Field(..., description="见知项目Id")
projectNo: str = Field(..., description="项目编号")
entityName: str = Field(..., description="项目名称")
analysisType: int = Field(0, description="分析类型")
class GetTokenResponse(BaseModel):
"""获取Token响应"""
code: str = Field("200", description="返回码")
data: Optional[TokenData] = Field(None, description="返回数据")
message: str = Field("create.token.success", description="返回消息")
status: str = Field("200", description="状态")
successResponse: bool = Field(True, description="是否成功响应")
# ==================== 文件上传相关模型 ====================
class AccountInfo(BaseModel):
"""账户信息"""
bank: str = Field(..., description="银行")
accountName: str = Field(..., description="账户名称")
accountNo: str = Field(..., description="账号")
currency: str = Field(..., description="币种")
class UploadLogItem(BaseModel):
"""上传日志项"""
accountNoList: List[str] = Field(default=[], description="账号列表")
bankName: str = Field(..., description="银行名称")
dataTypeInfo: List[str] = Field(default=[], description="数据类型信息")
downloadFileName: str = Field(..., description="下载文件名")
enterpriseNameList: List[str] = Field(default=[], description="企业名称列表")
filePackageId: str = Field(..., description="文件包ID")
fileSize: int = Field(..., description="文件大小")
fileUploadBy: int = Field(..., description="上传者ID")
fileUploadByUserName: str = Field(..., description="上传者用户名")
fileUploadTime: str = Field(..., description="上传时间")
leId: int = Field(..., description="企业ID")
logId: int = Field(..., description="日志ID")
logMeta: str = Field(..., description="日志元数据")
logType: str = Field(..., description="日志类型")
loginLeId: int = Field(..., description="登录企业ID")
realBankName: str = Field(..., description="真实银行名称")
rows: int = Field(0, description="行数")
source: str = Field(..., description="来源")
status: int = Field(-5, description="状态值")
templateName: str = Field(..., description="模板名称")
totalRecords: int = Field(0, description="总记录数")
trxDateEndId: int = Field(..., description="交易结束日期ID")
trxDateStartId: int = Field(..., description="交易开始日期ID")
uploadFileName: str = Field(..., description="上传文件名")
uploadStatusDesc: str = Field(..., description="上传状态描述")
class UploadFileResponse(BaseModel):
"""上传文件响应"""
code: str = Field("200", description="返回码")
data: Optional[Dict[str, Any]] = Field(None, description="返回数据")
status: str = Field("200", description="状态")
successResponse: bool = Field(True, description="是否成功响应")
# ==================== 检查解析状态相关模型 ====================
class PendingItem(BaseModel):
"""待处理项"""
accountNoList: List[str] = Field(default=[], description="账号列表")
bankName: str = Field(..., description="银行名称")
dataTypeInfo: List[str] = Field(default=[], description="数据类型信息")
downloadFileName: str = Field(..., description="下载文件名")
enterpriseNameList: List[str] = Field(default=[], description="企业名称列表")
filePackageId: str = Field(..., description="文件包ID")
fileSize: int = Field(..., description="文件大小")
fileUploadBy: int = Field(..., description="上传者ID")
fileUploadByUserName: str = Field(..., description="上传者用户名")
fileUploadTime: str = Field(..., description="上传时间")
isSplit: int = Field(0, description="是否分割")
leId: int = Field(..., description="企业ID")
logId: int = Field(..., description="日志ID")
logMeta: str = Field(..., description="日志元数据")
logType: str = Field(..., description="日志类型")
loginLeId: int = Field(..., description="登录企业ID")
lostHeader: List[str] = Field(default=[], description="丢失的头部")
realBankName: str = Field(..., description="真实银行名称")
rows: int = Field(0, description="行数")
source: str = Field(..., description="来源")
status: int = Field(-5, description="状态值")
templateName: str = Field(..., description="模板名称")
totalRecords: int = Field(0, description="总记录数")
trxDateEndId: int = Field(..., description="交易结束日期ID")
trxDateStartId: int = Field(..., description="交易开始日期ID")
uploadFileName: str = Field(..., description="上传文件名")
uploadStatusDesc: str = Field(..., description="上传状态描述")
class CheckParseStatusResponse(BaseModel):
"""检查解析状态响应"""
code: str = Field("200", description="返回码")
data: Optional[Dict[str, Any]] = Field(None, description="返回数据包含parsing和pendingList")
status: str = Field("200", description="状态")
successResponse: bool = Field(True, description="是否成功响应")
# ==================== 银行流水相关模型 ====================
class BankStatementItem(BaseModel):
"""银行流水项"""
accountId: int = Field(0, description="账号ID")
accountMaskNo: str = Field(..., description="账号")
accountingDate: str = Field(..., description="记账日期")
accountingDateId: int = Field(..., description="记账日期ID")
archivingFlag: int = Field(0, description="归档标志")
attachments: int = Field(0, description="附件数")
balanceAmount: float = Field(..., description="余额")
bank: str = Field(..., description="银行")
bankComments: str = Field("", description="银行注释")
bankStatementId: int = Field(..., description="流水ID")
bankTrxNumber: str = Field(..., description="银行交易号")
batchId: int = Field(..., description="批次ID")
cashType: str = Field("1", description="现金类型")
commentsNum: int = Field(0, description="评论数")
crAmount: float = Field(0, description="贷方金额")
cretNo: str = Field(..., description="证件号")
currency: str = Field("CNY", description="币种")
customerAccountMaskNo: str = Field(..., description="客户账号")
customerBank: str = Field("", description="客户银行")
customerId: int = Field(-1, description="客户ID")
customerName: str = Field(..., description="客户名称")
customerReference: str = Field("", description="客户参考")
downPaymentFlag: int = Field(0, description="首付标志")
drAmount: float = Field(0, description="借方金额")
exceptionType: str = Field("", description="异常类型")
groupId: int = Field(0, description="项目ID")
internalFlag: int = Field(0, description="内部标志")
leId: int = Field(..., description="企业ID")
leName: str = Field(..., description="企业名称")
overrideBsId: int = Field(0, description="覆盖流水ID")
paymentMethod: str = Field("", description="支付方式")
sourceCatalogId: int = Field(0, description="来源目录ID")
split: int = Field(0, description="分割")
subBankstatementId: int = Field(0, description="子流水ID")
toDoFlag: int = Field(0, description="待办标志")
transAmount: float = Field(..., description="交易金额")
transFlag: str = Field("P", description="交易标志")
transTypeId: int = Field(0, description="交易类型ID")
transformAmount: int = Field(0, description="转换金额")
transformCrAmount: int = Field(0, description="转换贷方金额")
transformDrAmount: int = Field(0, description="转换借方金额")
transfromBalanceAmount: int = Field(0, description="转换余额")
trxBalance: int = Field(0, description="交易余额")
trxDate: str = Field(..., description="交易日期")
userMemo: str = Field(..., description="用户备注")
class GetBankStatementResponse(BaseModel):
"""获取银行流水响应"""
code: str = Field("200", description="返回码")
data: Optional[Dict[str, Any]] = Field(None, description="返回数据包含bankStatementList和totalCount")
status: str = Field("200", description="状态")
successResponse: bool = Field(True, description="是否成功响应")
# ==================== 其他响应模型 ====================
class FetchInnerFlowResponse(BaseModel):
"""拉取行内流水响应"""
code: str = Field("200", description="返回码")
data: Optional[Dict[str, Any]] = Field(None, description="返回数据")
status: str = Field("200", description="状态")
successResponse: bool = Field(True, description="是否成功响应")
class DeleteFilesResponse(BaseModel):
"""删除文件响应"""
code: str = Field("200", description="返回码")
data: Optional[Dict[str, str]] = Field(None, description="返回数据")
status: str = Field("200", description="状态")
successResponse: bool = Field(True, description="是否成功响应")

View File

@@ -0,0 +1,8 @@
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
pydantic-settings==2.1.0
python-multipart==0.0.6
pytest>=7.0.0
pytest-cov>=4.0.0
httpx==0.27.2

View File

@@ -0,0 +1 @@
# Routers package

View File

@@ -0,0 +1,165 @@
from fastapi import APIRouter, BackgroundTasks, UploadFile, File, Form
from services.token_service import TokenService
from services.file_service import FileService
from services.statement_service import StatementService
from utils.error_simulator import ErrorSimulator
from typing import List, Optional
# 创建路由器
router = APIRouter()
# 初始化服务实例
token_service = TokenService()
file_service = FileService()
statement_service = StatementService()
# ==================== 接口1获取Token ====================
@router.post("/account/common/getToken")
async def get_token(
projectNo: str = Form(..., description="项目编号格式902000_当前时间戳"),
entityName: str = Form(..., description="项目名称"),
userId: str = Form(..., description="操作人员编号,固定值"),
userName: str = Form(..., description="操作人员姓名,固定值"),
appId: str = Form("remote_app", description="应用ID固定值"),
appSecretCode: str = Form(..., description="安全码"),
role: str = Form("VIEWER", description="角色,固定值"),
orgCode: str = Form(..., description="行社机构号,固定值"),
entityId: Optional[str] = Form(None, description="企业统信码或个人身份证号"),
xdRelatedPersons: Optional[str] = Form(None, description="信贷关联人信息"),
jzDataDateId: str = Form("0", description="拉取指定日期推送过来的金综链流水"),
innerBSStartDateId: str = Form("0", description="拉取行内流水开始日期"),
innerBSEndDateId: str = Form("0", description="拉取行内流水结束日期"),
analysisType: str = Form("-1", description="分析类型,固定值"),
departmentCode: str = Form(..., description="客户经理所属营业部/分理处的机构编码"),
):
"""创建项目并获取访问Token
如果 projectNo 包含 error_XXXX 标记,将返回对应的错误响应
"""
# 检测错误标记
error_code = ErrorSimulator.detect_error_marker(projectNo)
if error_code:
return ErrorSimulator.build_error_response(error_code)
# 构建请求数据字典
request_data = {
"projectNo": projectNo,
"entityName": entityName,
"userId": userId,
"userName": userName,
"appId": appId,
"appSecretCode": appSecretCode,
"role": role,
"orgCode": orgCode,
"entityId": entityId,
"xdRelatedPersons": xdRelatedPersons,
"jzDataDateId": jzDataDateId,
"innerBSStartDateId": innerBSStartDateId,
"innerBSEndDateId": innerBSEndDateId,
"analysisType": analysisType,
"departmentCode": departmentCode,
}
# 正常流程
return token_service.create_token(request_data)
# ==================== 接口2上传文件 ====================
@router.post("/watson/api/project/remoteUploadSplitFile")
async def upload_file(
background_tasks: BackgroundTasks,
groupId: int = Form(..., description="项目ID"),
file: UploadFile = File(..., description="流水文件"),
):
"""上传流水文件
文件将立即返回并在后台延迟4秒完成解析
"""
return await file_service.upload_file(groupId, file, background_tasks)
# ==================== 接口3拉取行内流水 ====================
@router.post("/watson/api/project/getJZFileOrZjrcuFile")
async def fetch_inner_flow(
groupId: int = Form(..., description="项目id"),
customerNo: str = Form(..., description="客户身份证号"),
dataChannelCode: str = Form(..., description="校验码"),
requestDateId: int = Form(..., description="发起请求的时间"),
dataStartDateId: int = Form(..., description="拉取开始日期"),
dataEndDateId: int = Form(..., description="拉取结束日期"),
uploadUserId: int = Form(..., description="柜员号"),
):
"""拉取行内流水
如果 customerNo 包含 error_XXXX 标记,将返回对应的错误响应
"""
# 检测错误标记
error_code = ErrorSimulator.detect_error_marker(customerNo)
if error_code:
return ErrorSimulator.build_error_response(error_code)
# 构建请求字典
request_data = {
"groupId": groupId,
"customerNo": customerNo,
"dataChannelCode": dataChannelCode,
"requestDateId": requestDateId,
"dataStartDateId": dataStartDateId,
"dataEndDateId": dataEndDateId,
"uploadUserId": uploadUserId,
}
# 正常流程
return file_service.fetch_inner_flow(request_data)
# ==================== 接口4检查文件解析状态 ====================
@router.post("/watson/api/project/upload/getpendings")
async def check_parse_status(
groupId: int = Form(..., description="项目id"),
inprogressList: str = Form(..., description="文件id列表逗号分隔"),
):
"""检查文件解析状态
返回文件是否还在解析中parsing字段
"""
return file_service.check_parse_status(groupId, inprogressList)
# ==================== 接口5删除文件 ====================
@router.post("/watson/api/project/batchDeleteUploadFile")
async def delete_files(
groupId: int = Form(..., description="项目id"),
logIds: str = Form(..., description="文件id数组逗号分隔如: 10001,10002"),
userId: int = Form(..., description="用户柜员号"),
):
"""批量删除上传的文件
根据logIds列表删除对应的文件记录
"""
# 将逗号分隔的字符串转换为整数列表
log_id_list = [int(id.strip()) for id in logIds.split(",")]
return file_service.delete_files(groupId, log_id_list, userId)
# ==================== 接口6获取银行流水 ====================
@router.post("/watson/api/project/getBSByLogId")
async def get_bank_statement(
groupId: int = Form(..., description="项目id"),
logId: int = Form(..., description="文件id"),
pageNow: int = Form(..., description="当前页码"),
pageSize: int = Form(..., description="查询条数"),
):
"""获取银行流水列表
支持分页查询pageNow, pageSize
"""
# 构建请求字典
request_data = {
"groupId": groupId,
"logId": logId,
"pageNow": pageNow,
"pageSize": pageSize,
}
return statement_service.get_bank_statement(request_data)

View File

@@ -0,0 +1 @@
# Services package

View File

@@ -0,0 +1,150 @@
from fastapi import BackgroundTasks, UploadFile
from utils.response_builder import ResponseBuilder
from config.settings import settings
from typing import Dict, List, Union
import time
from datetime import datetime
class FileService:
"""文件上传和解析服务"""
def __init__(self):
self.file_records = {} # logId -> record
self.parsing_status = {} # logId -> is_parsing
self.log_counter = settings.INITIAL_LOG_ID
async def upload_file(
self, group_id: int, file: UploadFile, background_tasks: BackgroundTasks
) -> Dict:
"""上传文件并启动后台解析任务
Args:
group_id: 项目ID
file: 上传的文件
background_tasks: FastAPI后台任务
Returns:
上传响应字典
"""
# 生成唯一logId
self.log_counter += 1
log_id = self.log_counter
# 获取当前时间
upload_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
# 立即存储文件记录(初始状态:解析中)
self.file_records[log_id] = {
"logId": log_id,
"groupId": group_id,
"status": -5,
"uploadStatusDesc": "parsing",
"uploadFileName": file.filename,
"fileSize": 0, # 简化处理
"bankName": "MOCK",
"uploadTime": upload_time,
}
# 标记为解析中
self.parsing_status[log_id] = True
# 启动后台任务,延迟解析
background_tasks.add_task(
self._simulate_parsing, log_id, settings.PARSE_DELAY_SECONDS
)
# 构建响应
response = ResponseBuilder.build_success_response(
"upload", log_id=log_id, upload_time=upload_time
)
return response
def _simulate_parsing(self, log_id: int, delay_seconds: int):
"""后台任务:模拟文件解析过程
Args:
log_id: 日志ID
delay_seconds: 延迟秒数
"""
time.sleep(delay_seconds)
# 解析完成,更新状态
if log_id in self.file_records:
self.file_records[log_id]["uploadStatusDesc"] = (
"data.wait.confirm.newaccount"
)
self.parsing_status[log_id] = False
def check_parse_status(self, group_id: int, inprogress_list: str) -> Dict:
"""检查文件解析状态
Args:
group_id: 项目ID
inprogress_list: 文件ID列表逗号分隔
Returns:
解析状态响应字典
"""
# 解析logId列表
log_ids = [int(x.strip()) for x in inprogress_list.split(",") if x.strip()]
# 检查是否还在解析中
is_parsing = any(
self.parsing_status.get(log_id, False) for log_id in log_ids
)
# 获取待处理列表
pending_list = [
self.file_records[log_id]
for log_id in log_ids
if log_id in self.file_records
]
return {
"code": "200",
"data": {"parsing": is_parsing, "pendingList": pending_list},
"status": "200",
"successResponse": True,
}
def delete_files(self, group_id: int, log_ids: List[int], user_id: int) -> Dict:
"""删除文件
Args:
group_id: 项目ID
log_ids: 文件ID列表
user_id: 用户ID
Returns:
删除响应字典
"""
# 删除文件记录
for log_id in log_ids:
self.file_records.pop(log_id, None)
self.parsing_status.pop(log_id, None)
return {
"code": "200",
"data": {"message": "delete.files.success"},
"status": "200",
"successResponse": True,
}
def fetch_inner_flow(self, request: Union[Dict, object]) -> Dict:
"""拉取行内流水(模拟无数据场景)
Args:
request: 拉取流水请求(可以是字典或对象)
Returns:
流水响应字典
"""
# 模拟无行内流水文件场景
return {
"code": "200",
"data": {"code": "501014", "message": "无行内流水文件"},
"status": "200",
"successResponse": True,
}

View File

@@ -0,0 +1,40 @@
from utils.response_builder import ResponseBuilder
from typing import Dict, Union
class StatementService:
"""流水数据服务"""
def get_bank_statement(self, request: Union[Dict, object]) -> Dict:
"""获取银行流水列表
Args:
request: 获取银行流水请求(可以是字典或对象)
Returns:
银行流水响应字典
"""
# 支持 dict 或对象
if isinstance(request, dict):
page_now = request.get("pageNow", 1)
page_size = request.get("pageSize", 10)
else:
page_now = request.pageNow
page_size = request.pageSize
# 加载模板
template = ResponseBuilder.load_template("bank_statement")
statements = template["success_response"]["data"]["bankStatementList"]
total_count = len(statements)
# 模拟分页
start = (page_now - 1) * page_size
end = start + page_size
page_data = statements[start:end]
return {
"code": "200",
"data": {"bankStatementList": page_data, "totalCount": total_count},
"status": "200",
"successResponse": True,
}

View File

@@ -0,0 +1,57 @@
from models.request import GetTokenRequest
from utils.response_builder import ResponseBuilder
from config.settings import settings
from typing import Dict, Union
class TokenService:
"""Token管理服务"""
def __init__(self):
self.project_counter = settings.INITIAL_PROJECT_ID
self.tokens = {} # projectId -> token_data
def create_token(self, request: Union[GetTokenRequest, Dict]) -> Dict:
"""创建Token
Args:
request: 获取Token请求可以是 GetTokenRequest 对象或字典)
Returns:
Token响应字典
"""
# 支持 dict 或 GetTokenRequest 对象
if isinstance(request, dict):
project_no = request.get("projectNo")
entity_name = request.get("entityName")
else:
project_no = request.projectNo
entity_name = request.entityName
# 生成唯一项目ID
self.project_counter += 1
project_id = self.project_counter
# 构建响应
response = ResponseBuilder.build_success_response(
"token",
project_id=project_id,
project_no=project_no,
entity_name=entity_name
)
# 存储token信息
self.tokens[project_id] = response.get("data")
return response
def get_project(self, project_id: int) -> Dict:
"""获取项目信息
Args:
project_id: 项目ID
Returns:
项目信息字典
"""
return self.tokens.get(project_id)

View File

@@ -0,0 +1 @@
# Tests package

View File

@@ -0,0 +1,34 @@
"""
Pytest 配置和共享 fixtures
"""
import pytest
from fastapi.testclient import TestClient
import sys
import os
# 添加项目根目录到 sys.path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from main import app
@pytest.fixture
def client():
"""创建测试客户端"""
return TestClient(app)
@pytest.fixture
def sample_token_request():
"""示例 Token 请求 - 返回 form-data 格式的数据"""
return {
"projectNo": "test_project_001",
"entityName": "测试企业",
"userId": "902001",
"userName": "902001",
"appId": "remote_app",
"appSecretCode": "test_secret_code_12345",
"role": "VIEWER",
"orgCode": "902000",
"departmentCode": "902000",
}

View File

@@ -0,0 +1 @@
# Integration tests package

View File

@@ -0,0 +1,125 @@
"""
集成测试 - 完整的接口调用流程测试
"""
import pytest
import time
def test_complete_workflow(client):
"""测试完整的接口调用流程"""
# 1. 获取 Token
response = client.post(
"/account/common/getToken",
data={
"projectNo": "integration_test_001",
"entityName": "集成测试企业",
"userId": "902001",
"userName": "902001",
"appId": "remote_app",
"appSecretCode": "test_secret_code_12345",
"role": "VIEWER",
"orgCode": "902000",
"departmentCode": "902000",
},
)
assert response.status_code == 200
token_data = response.json()
assert token_data["code"] == "200"
project_id = token_data["data"]["projectId"]
token = token_data["data"]["token"]
assert token is not None
# 2. 上传文件(模拟)
# 注意:在测试环境中,我们跳过实际的文件上传,直接测试其他接口
# 3. 检查解析状态
response = client.post(
"/watson/api/project/upload/getpendings",
data={"groupId": project_id, "inprogressList": "10001"},
)
assert response.status_code == 200
status_data = response.json()
assert "parsing" in status_data["data"]
# 4. 获取银行流水
response = client.post(
"/watson/api/project/getBSByLogId",
data={
"groupId": project_id,
"logId": 10001,
"pageNow": 1,
"pageSize": 10,
},
)
assert response.status_code == 200
statement_data = response.json()
assert statement_data["code"] == "200"
assert "bankStatementList" in statement_data["data"]
assert "totalCount" in statement_data["data"]
def test_all_error_codes(client):
"""测试所有错误码"""
error_codes = ["40101", "40102", "40104", "40105", "40106", "40107", "40108"]
for error_code in error_codes:
response = client.post(
"/account/common/getToken",
data={
"projectNo": f"test_error_{error_code}",
"entityName": "测试企业",
"userId": "902001",
"userName": "902001",
"appId": "remote_app",
"appSecretCode": "test_secret_code_12345",
"role": "VIEWER",
"orgCode": "902000",
"departmentCode": "902000",
},
)
assert response.status_code == 200
data = response.json()
assert data["code"] == error_code, f"错误码 {error_code} 未正确触发"
assert data["successResponse"] == False
def test_pagination(client):
"""测试分页功能"""
# 获取 Token
response = client.post(
"/account/common/getToken",
data={
"projectNo": "pagination_test",
"entityName": "分页测试",
"userId": "902001",
"userName": "902001",
"appId": "remote_app",
"appSecretCode": "test_secret_code_12345",
"role": "VIEWER",
"orgCode": "902000",
"departmentCode": "902000",
},
)
project_id = response.json()["data"]["projectId"]
# 测试第一页
response = client.post(
"/watson/api/project/getBSByLogId",
data={"groupId": project_id, "logId": 10001, "pageNow": 1, "pageSize": 1},
)
page1 = response.json()
# 测试第二页
response = client.post(
"/watson/api/project/getBSByLogId",
data={"groupId": project_id, "logId": 10001, "pageNow": 2, "pageSize": 1},
)
page2 = response.json()
# 验证总记录数相同
assert page1["data"]["totalCount"] == page2["data"]["totalCount"]
# 验证页码不同
if page1["data"]["totalCount"] > 1:
assert len(page1["data"]["bankStatementList"]) == 1
assert len(page2["data"]["bankStatementList"]) >= 0

View File

@@ -0,0 +1,50 @@
"""
API 端点测试
"""
def test_root_endpoint(client):
"""测试根路径"""
response = client.get("/")
assert response.status_code == 200
data = response.json()
assert data["status"] == "running"
assert "swagger_docs" in data
def test_health_check(client):
"""测试健康检查端点"""
response = client.get("/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "healthy"
def test_get_token_success(client, sample_token_request):
"""测试获取 Token - 成功场景"""
response = client.post("/account/common/getToken", data=sample_token_request)
assert response.status_code == 200
data = response.json()
assert data["code"] == "200"
assert "token" in data["data"]
assert "projectId" in data["data"]
def test_get_token_error_40101(client):
"""测试获取 Token - 错误场景 40101"""
request_data = {
"projectNo": "test_error_40101",
"entityName": "测试企业",
"userId": "902001",
"userName": "902001",
"appId": "remote_app",
"appSecretCode": "test_secret_code_12345",
"role": "VIEWER",
"orgCode": "902000",
"departmentCode": "902000",
}
response = client.post("/account/common/getToken", data=request_data)
assert response.status_code == 200
data = response.json()
assert data["code"] == "40101"
assert data["successResponse"] == False

View File

@@ -0,0 +1 @@
# Utils package

View File

@@ -0,0 +1,49 @@
from typing import Dict, Optional
import re
class ErrorSimulator:
"""错误场景模拟器"""
# 错误码映射表
ERROR_CODES = {
"40101": {"code": "40101", "message": "appId错误"},
"40102": {"code": "40102", "message": "appSecretCode错误"},
"40104": {"code": "40104", "message": "可使用项目次数为0无法创建项目"},
"40105": {"code": "40105", "message": "只读模式下无法新建项目"},
"40106": {"code": "40106", "message": "错误的分析类型,不在规定的取值范围内"},
"40107": {"code": "40107", "message": "当前系统不支持的分析类型"},
"40108": {"code": "40108", "message": "当前用户所属行社无权限"},
"501014": {"code": "501014", "message": "无行内流水文件"},
}
@staticmethod
def detect_error_marker(value: str) -> Optional[str]:
"""检测字符串中的错误标记
规则:如果字符串包含 error_XXXX则返回 XXXX
例如:
- "project_error_40101" -> "40101"
- "test_error_501014" -> "501014"
"""
if not value:
return None
pattern = r'error_(\d+)'
match = re.search(pattern, value)
if match:
return match.group(1)
return None
@staticmethod
def build_error_response(error_code: str) -> Optional[Dict]:
"""构建错误响应"""
if error_code in ErrorSimulator.ERROR_CODES:
error_info = ErrorSimulator.ERROR_CODES[error_code]
return {
"code": error_info["code"],
"message": error_info["message"],
"status": error_info["code"],
"successResponse": False
}
return None

View File

@@ -0,0 +1,69 @@
import json
from pathlib import Path
from typing import Dict, Any
import copy
class ResponseBuilder:
"""响应构建器"""
TEMPLATE_DIR = Path(__file__).parent.parent / "config" / "responses"
@staticmethod
def load_template(template_name: str) -> Dict:
"""加载 JSON 模板
Args:
template_name: 模板名称(不含.json扩展名
Returns:
模板字典
"""
file_path = ResponseBuilder.TEMPLATE_DIR / f"{template_name}.json"
with open(file_path, 'r', encoding='utf-8') as f:
return json.load(f)
@staticmethod
def replace_placeholders(template: Dict, **kwargs) -> Dict:
"""递归替换占位符
Args:
template: 模板字典
**kwargs: 占位符键值对
Returns:
替换后的字典
"""
def replace_value(value):
if isinstance(value, str):
result = value
for key, val in kwargs.items():
placeholder = f"{{{key}}}"
if placeholder in result:
result = result.replace(placeholder, str(val))
return result
elif isinstance(value, dict):
return {k: replace_value(v) for k, v in value.items()}
elif isinstance(value, list):
return [replace_value(item) for item in value]
return value
# 深拷贝模板,避免修改原始数据
return replace_value(copy.deepcopy(template))
@staticmethod
def build_success_response(template_name: str, **kwargs) -> Dict:
"""构建成功响应
Args:
template_name: 模板名称
**kwargs: 占位符键值对
Returns:
响应字典
"""
template = ResponseBuilder.load_template(template_name)
return ResponseBuilder.replace_placeholders(
template["success_response"],
**kwargs
)

View File

@@ -101,3 +101,38 @@ spring:
max-active: 8
# #连接池最大阻塞等待时间(使用负值表示没有限制)
max-wait: -1ms
# 流水分析平台配置
lsfx:
api:
# Mock Server本地测试
base-url: http://localhost:8000
# 测试环境
# base-url: http://158.234.196.5:82/c4c3
# 生产环境
# base-url: http://64.202.32.176/c4c3
# 认证配置
app-id: remote_app
app-secret: dXj6eHRmPv # 见知提供的密钥
client-id: c2017e8d105c435a96f86373635b6a09 # 测试环境固定值
# 接口路径配置
endpoints:
get-token: /account/common/getToken
upload-file: /watson/api/project/remoteUploadSplitFile
fetch-inner-flow: /watson/api/project/getJZFileOrZjrcuFile
check-parse-status: /watson/api/project/upload/getpendings
get-bank-statement: /watson/api/project/getBSByLogId
# 新增接口
get-file-upload-status: /watson/api/project/bs/upload
delete-files: /watson/api/project/batchDeleteUploadFile
# RestTemplate配置
connection-timeout: 30000 # 连接超时30秒
read-timeout: 60000 # 读取超时60秒
# 连接池配置
pool:
max-total: 100 # 最大连接数
default-max-per-route: 20 # 每个路由最大连接数

View File

@@ -0,0 +1,17 @@
package com.ruoyi.common.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Excel文本格式注解
* 用于在生成Excel模板时将列设置为文本格式避免证件号等内容被自动转成数值。
*
* @author ruoyi
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface TextFormat {
}

View File

@@ -0,0 +1,35 @@
import request from '@/utils/request'
// 下载资产导入模板
export function importAssetTemplate() {
return request({
url: '/ccdi/assetInfo/importTemplate',
method: 'post'
})
}
// 导入资产数据
export function importAssetData(data) {
return request({
url: '/ccdi/assetInfo/importData',
method: 'post',
data: data
})
}
// 查询资产导入状态
export function getAssetImportStatus(taskId) {
return request({
url: '/ccdi/assetInfo/importStatus/' + taskId,
method: 'get'
})
}
// 查询资产导入失败记录
export function getAssetImportFailures(taskId, pageNum, pageSize) {
return request({
url: '/ccdi/assetInfo/importFailures/' + taskId,
method: 'get',
params: { pageNum, pageSize }
})
}

View File

@@ -67,6 +67,16 @@
v-hasPermi="['ccdi:employee:import']"
>导入</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-upload"
size="mini"
@click="handleAssetImport"
v-hasPermi="['ccdi:employee:import']"
>导入资产信息</el-button>
</el-col>
<el-col :span="1.5" v-if="showFailureButton">
<el-tooltip
:content="getLastImportTooltip()"
@@ -81,6 +91,20 @@
>查看导入失败记录</el-button>
</el-tooltip>
</el-col>
<el-col :span="1.5" v-if="assetShowFailureButton">
<el-tooltip
:content="getLastAssetImportTooltip()"
placement="top"
>
<el-button
type="warning"
plain
icon="el-icon-warning-outline"
size="mini"
@click="viewAssetImportFailures"
>查看员工资产导入失败记录</el-button>
</el-tooltip>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
@@ -138,7 +162,7 @@
/>
<!-- 添加或修改对话框 -->
<el-dialog :title="title" :visible.sync="open" width="1200px" append-to-body class="employee-edit-dialog">
<el-dialog :title="title" :visible.sync="open" width="80%" append-to-body class="employee-edit-dialog">
<el-form ref="form" :model="form" :rules="rules" label-width="90px">
<!-- 基本信息 -->
<div class="section-header">基本信息</div>
@@ -192,6 +216,86 @@
<el-radio label="1">离职</el-radio>
</el-radio-group>
</el-form-item>
<div class="section-header">
<span>资产信息</span>
<el-button type="primary" plain size="mini" icon="el-icon-plus" @click="handleAddAsset">新增资产</el-button>
</div>
<div class="assets-helper">
<div>新增编辑时无需填写实际持有人身份证号</div>
<div>系统会默认带入并保留已有归属信息</div>
</div>
<el-form-item label-width="0" prop="assetInfoList">
<div v-if="!form.assetInfoList || !form.assetInfoList.length" class="empty-assets">
<i class="el-icon-office-building"></i>
<span>暂无资产信息请点击新增资产</span>
</div>
<div v-else class="assets-table-wrapper">
<el-table :data="form.assetInfoList" border class="assets-table">
<el-table-column label="资产大类" min-width="140">
<template slot-scope="scope">
<el-input v-model="scope.row.assetMainType" placeholder="请输入资产大类" />
</template>
</el-table-column>
<el-table-column label="资产小类" min-width="140">
<template slot-scope="scope">
<el-input v-model="scope.row.assetSubType" placeholder="请输入资产小类" />
</template>
</el-table-column>
<el-table-column label="资产名称" min-width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.assetName" placeholder="请输入资产名称" />
</template>
</el-table-column>
<el-table-column label="产权占比" min-width="120">
<template slot-scope="scope">
<el-input v-model="scope.row.ownershipRatio" placeholder="请输入产权占比" />
</template>
</el-table-column>
<el-table-column label="购买/评估日期" min-width="160">
<template slot-scope="scope">
<el-date-picker v-model="scope.row.purchaseEvalDate" type="date" value-format="yyyy-MM-dd" placeholder="选择日期" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="资产原值" min-width="120">
<template slot-scope="scope">
<el-input v-model="scope.row.originalValue" placeholder="请输入资产原值" />
</template>
</el-table-column>
<el-table-column label="当前估值" min-width="120">
<template slot-scope="scope">
<el-input v-model="scope.row.currentValue" placeholder="请输入当前估值" />
</template>
</el-table-column>
<el-table-column label="估值截止日期" min-width="160">
<template slot-scope="scope">
<el-date-picker v-model="scope.row.valuationDate" type="date" value-format="yyyy-MM-dd" placeholder="选择日期" style="width: 100%" />
</template>
</el-table-column>
<el-table-column label="资产状态" min-width="140">
<template slot-scope="scope">
<el-select v-model="scope.row.assetStatus" placeholder="请选择资产状态">
<el-option
v-for="option in assetStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="备注" min-width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.remarks" placeholder="请输入备注" />
</template>
</el-table-column>
<el-table-column label="操作" width="100" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-delete" @click="handleRemoveAsset(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
</el-form>
<div slot="footer" class="dialog-footer">
<el-button @click="cancel">取消</el-button>
@@ -200,7 +304,7 @@
</el-dialog>
<!-- 详情对话框 -->
<el-dialog title="员工详情" :visible.sync="detailOpen" width="900px" append-to-body class="employee-detail-dialog">
<el-dialog title="员工详情" :visible.sync="detailOpen" width="80%" append-to-body class="employee-detail-dialog">
<div class="detail-container">
<!-- 基本信息卡片 -->
<div class="info-section">
@@ -226,6 +330,30 @@
</el-descriptions-item>
</el-descriptions>
</div>
<div class="info-section">
<div class="section-title">
<i class="el-icon-office-building"></i>
<span>资产信息</span>
</div>
<div v-if="!employeeDetail.assetInfoList || !employeeDetail.assetInfoList.length" class="empty-assets-detail">
暂无资产信息
</div>
<el-table v-else :data="employeeDetail.assetInfoList" border class="detail-assets-table">
<el-table-column label="资产实际持有人身份证号" prop="personId" min-width="220" />
<el-table-column label="归属类型" prop="ownerType" min-width="100">
<template slot-scope="scope">
<span>{{ formatAssetOwnerType(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column label="资产大类" prop="assetMainType" min-width="120" />
<el-table-column label="资产小类" prop="assetSubType" min-width="120" />
<el-table-column label="资产名称" prop="assetName" min-width="140" />
<el-table-column label="产权占比" prop="ownershipRatio" min-width="100" />
<el-table-column label="当前估值" prop="currentValue" min-width="120" />
<el-table-column label="资产状态" prop="assetStatus" min-width="120" />
<el-table-column label="备注" prop="remarks" min-width="160" show-overflow-tooltip />
</el-table>
</div>
</div>
<div slot="footer" class="dialog-footer">
<el-button @click="detailOpen = false" icon="el-icon-close"> </el-button>
@@ -270,6 +398,41 @@
@close="handleImportResultClose"
/>
<el-dialog :title="assetUpload.title" :visible.sync="assetUpload.open" width="400px" append-to-body @close="handleAssetImportDialogClose" v-loading="assetUpload.isUploading" element-loading-text="正在导入员工资产数据,请稍候..." element-loading-spinner="el-icon-loading" element-loading-background="rgba(0, 0, 0, 0.7)">
<el-upload
ref="assetUpload"
:limit="1"
accept=".xlsx, .xls"
:headers="assetUpload.headers"
:action="assetUpload.url"
:disabled="assetUpload.isUploading"
:on-progress="handleAssetFileUploadProgress"
:on-success="handleAssetFileSuccess"
:auto-upload="false"
drag
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">
<el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline;" @click="importAssetTemplate">下载员工资产模板</el-link>
</div>
<div class="el-upload__tip" slot="tip">
<span>仅允许导入"xls""xlsx"格式文件系统将根据 personId/person_id 自动识别归属员工</span>
</div>
</el-upload>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitAssetFileForm" :loading="assetUpload.isUploading"> </el-button>
<el-button @click="assetUpload.open = false" :disabled="assetUpload.isUploading"> </el-button>
</div>
</el-dialog>
<import-result-dialog
:visible.sync="assetImportResultVisible"
:content="assetImportResultContent"
title="员工资产导入结果"
@close="handleAssetImportResultClose"
/>
<!-- 导入失败记录对话框 -->
<el-dialog
title="导入失败记录"
@@ -306,6 +469,43 @@
<el-button type="danger" plain @click="clearImportHistory">清除历史记录</el-button>
</div>
</el-dialog>
<el-dialog
title="员工资产导入失败记录"
:visible.sync="assetFailureDialogVisible"
width="1200px"
append-to-body
>
<el-alert
v-if="lastAssetImportInfo"
:title="lastAssetImportInfo"
type="info"
:closable="false"
style="margin-bottom: 15px"
/>
<el-table :data="assetFailureList" v-loading="assetFailureLoading">
<el-table-column label="归属员工身份证号" prop="familyId" align="center" min-width="180" />
<el-table-column label="资产实际持有人身份证号" prop="personId" align="center" min-width="180" />
<el-table-column label="资产大类" prop="assetMainType" align="center" />
<el-table-column label="资产小类" prop="assetSubType" align="center" />
<el-table-column label="资产名称" prop="assetName" align="center" />
<el-table-column label="失败原因" prop="errorMessage" align="center" min-width="200" :show-overflow-tooltip="true" />
</el-table>
<pagination
v-show="assetFailureTotal > 0"
:total="assetFailureTotal"
:page.sync="assetFailureQueryParams.pageNum"
:limit.sync="assetFailureQueryParams.pageSize"
@pagination="getAssetFailureList"
/>
<div slot="footer" class="dialog-footer">
<el-button @click="assetFailureDialogVisible = false">关闭</el-button>
<el-button type="danger" plain @click="clearAssetImportHistory">清除资产导入历史</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -319,6 +519,10 @@ import {
listBaseStaff,
updateBaseStaff
} from "@/api/ccdiBaseStaff";
import {
getAssetImportFailures,
getAssetImportStatus
} from "@/api/ccdiAssetInfo";
import {deptTreeSelect} from "@/api/system/user";
import {getToken} from "@/utils/auth";
import Treeselect from "@riophae/vue-treeselect";
@@ -375,6 +579,12 @@ export default {
},
// 表单参数
form: {},
assetStatusOptions: [
{ label: "正常", value: "正常" },
{ label: "冻结", value: "冻结" },
{ label: "处置中", value: "处置中" },
{ label: "报废", value: "报废" }
],
// 表单校验
rules: {
name: [
@@ -418,12 +628,17 @@ export default {
// 导入结果弹窗
importResultVisible: false,
importResultContent: "",
assetImportResultVisible: false,
assetImportResultContent: "",
// 轮询定时器
pollingTimer: null,
assetPollingTimer: null,
// 是否显示查看失败记录按钮
showFailureButton: false,
assetShowFailureButton: false,
// 当前导入任务ID
currentTaskId: null,
assetCurrentTaskId: null,
// 失败记录对话框
failureDialogVisible: false,
failureList: [],
@@ -432,6 +647,21 @@ export default {
failureQueryParams: {
pageNum: 1,
pageSize: 10
},
assetUpload: {
open: false,
title: "",
isUploading: false,
headers: { Authorization: "Bearer " + getToken() },
url: process.env.VUE_APP_BASE_API + "/ccdi/assetInfo/importData"
},
assetFailureDialogVisible: false,
assetFailureList: [],
assetFailureLoading: false,
assetFailureTotal: 0,
assetFailureQueryParams: {
pageNum: 1,
pageSize: 10
}
};
},
@@ -445,12 +675,25 @@ export default {
return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}`;
}
return '';
},
lastAssetImportInfo() {
const savedTask = this.getAssetImportTaskFromStorage();
if (savedTask && savedTask.totalCount) {
return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}`;
}
return '';
}
},
watch: {
'form.idCard'(newIdCard, oldIdCard) {
this.syncAssetPersonIds(newIdCard, oldIdCard);
}
},
created() {
this.getList();
this.getDeptTree();
this.restoreImportState(); // 新增:恢复导入状态
this.restoreAssetImportState();
},
beforeDestroy() {
// 组件销毁时清除定时器
@@ -458,6 +701,10 @@ export default {
clearInterval(this.pollingTimer);
this.pollingTimer = null;
}
if (this.assetPollingTimer) {
clearInterval(this.assetPollingTimer);
this.assetPollingTimer = null;
}
},
methods: {
/**
@@ -522,6 +769,48 @@ export default {
console.error('清除导入任务状态失败:', error);
}
},
saveAssetImportTaskToStorage(taskData) {
try {
const data = {
...taskData,
saveTime: Date.now()
};
localStorage.setItem('employee_asset_import_last_task', JSON.stringify(data));
} catch (error) {
console.error('保存资产导入任务状态失败:', error);
}
},
getAssetImportTaskFromStorage() {
try {
const data = localStorage.getItem('employee_asset_import_last_task');
if (!data) return null;
const task = JSON.parse(data);
if (!task || !task.taskId) {
this.clearAssetImportTaskFromStorage();
return null;
}
const sevenDays = 7 * 24 * 60 * 60 * 1000;
if (task.saveTime && Date.now() - task.saveTime > sevenDays) {
this.clearAssetImportTaskFromStorage();
return null;
}
return task;
} catch (error) {
console.error('读取资产导入任务状态失败:', error);
this.clearAssetImportTaskFromStorage();
return null;
}
},
clearAssetImportTaskFromStorage() {
try {
localStorage.removeItem('employee_asset_import_last_task');
} catch (error) {
console.error('清除资产导入任务状态失败:', error);
}
},
/**
* 恢复导入状态
* 在created()钩子中调用
@@ -541,6 +830,20 @@ export default {
this.showFailureButton = true;
}
},
restoreAssetImportState() {
const savedTask = this.getAssetImportTaskFromStorage();
if (!savedTask) {
this.assetShowFailureButton = false;
this.assetCurrentTaskId = null;
return;
}
if (savedTask.hasFailures && savedTask.taskId) {
this.assetCurrentTaskId = savedTask.taskId;
this.assetShowFailureButton = true;
}
},
/**
* 获取上次导入的提示信息
* @returns {String} 提示文本
@@ -554,6 +857,15 @@ export default {
}
return '';
},
getLastAssetImportTooltip() {
const savedTask = this.getAssetImportTaskFromStorage();
if (savedTask && savedTask.saveTime) {
const date = new Date(savedTask.saveTime);
const timeStr = this.parseTime(date, '{y}-{m}-{d} {h}:{i}');
return `上次员工资产导入: ${timeStr}`;
}
return '';
},
/**
* 清除导入历史记录
* 用户手动触发
@@ -571,6 +883,19 @@ export default {
this.$message.success('已清除');
}).catch(() => {});
},
clearAssetImportHistory() {
this.$confirm('确认清除上次员工资产导入记录?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.clearAssetImportTaskFromStorage();
this.assetShowFailureButton = false;
this.assetCurrentTaskId = null;
this.assetFailureDialogVisible = false;
this.$message.success('已清除');
}).catch(() => {});
},
/** 查询员工列表 */
getList() {
this.loading = true;
@@ -615,10 +940,135 @@ export default {
phone: null,
hireDate: null,
status: "0",
relatives: []
relatives: [],
assetInfoList: []
};
this.resetForm("form");
},
normalizeAssetInfoList() {
const assetInfoList = Array.isArray(this.form.assetInfoList)
? this.form.assetInfoList
: [];
return assetInfoList.filter(item => {
if (!item || typeof item !== "object") {
return false;
}
return Object.keys(item).some(key => {
const value = item[key];
return value !== null && value !== undefined && String(value).trim() !== "";
});
});
},
validateAssetInfoList(assetInfoList) {
const requiredFields = [
{ key: "personId", label: "资产实际持有人身份证号" },
{ key: "assetMainType", label: "资产大类" },
{ key: "assetSubType", label: "资产小类" },
{ key: "assetName", label: "资产名称" },
{ key: "currentValue", label: "当前估值" },
{ key: "assetStatus", label: "资产状态" }
];
const numericFields = [
{ key: "ownershipRatio", label: "产权占比" },
{ key: "originalValue", label: "资产原值" },
{ key: "currentValue", label: "当前估值" }
];
for (let index = 0; index < assetInfoList.length; index++) {
const asset = assetInfoList[index];
const rowNo = index + 1;
for (const field of requiredFields) {
const value = asset[field.key];
if (value === null || value === undefined || String(value).trim() === "") {
this.$modal.msgError(`${rowNo}条资产的${field.label}不能为空`);
return false;
}
}
if (!idCardPattern.test(asset.personId)) {
this.$modal.msgError(`${rowNo}条资产的资产实际持有人身份证号格式不正确`);
return false;
}
for (const field of numericFields) {
const value = asset[field.key];
if (value !== null && value !== undefined && String(value).trim() !== "") {
if (!/^-?\d+(\.\d+)?$/.test(String(value).trim())) {
this.$modal.msgError(`${rowNo}条资产的${field.label}格式不正确`);
return false;
}
}
}
if (!this.assetStatusOptions.some(option => option.value === asset.assetStatus)) {
this.$modal.msgError(`${rowNo}条资产的资产状态不在允许范围内`);
return false;
}
}
return true;
},
createEmptyAssetRow(defaultPersonId = "") {
return {
personId: defaultPersonId || "",
assetMainType: "",
assetSubType: "",
assetName: "",
ownershipRatio: "",
purchaseEvalDate: "",
originalValue: "",
currentValue: "",
valuationDate: "",
assetStatus: "",
remarks: ""
};
},
hasAssetContent(row) {
if (!row || typeof row !== "object") {
return false;
}
return Object.keys(row).some(key => {
const value = row[key];
return value !== null && value !== undefined && String(value).trim() !== "";
});
},
syncAssetPersonIds(newIdCard, oldIdCard) {
if (!Array.isArray(this.form.assetInfoList)) {
return;
}
this.form.assetInfoList = this.form.assetInfoList.map(asset => {
if (!asset || typeof asset !== "object") {
return asset;
}
const shouldSync = !asset.personId || asset.personId === oldIdCard;
if (!shouldSync) {
return asset;
}
return {
...asset,
personId: newIdCard || ""
};
});
},
handleAddAsset() {
if (!Array.isArray(this.form.assetInfoList)) {
this.form.assetInfoList = [];
}
this.form.assetInfoList.push(this.createEmptyAssetRow(this.form.idCard));
},
handleRemoveAsset(index) {
if (!Array.isArray(this.form.assetInfoList)) {
return;
}
this.form.assetInfoList.splice(index, 1);
},
formatAssetOwnerType(asset) {
if (!asset) {
return "-";
}
return asset.personId && asset.personId === this.employeeDetail.idCard ? "本人" : "亲属";
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1;
@@ -638,6 +1088,7 @@ export default {
/** 新增按钮操作 */
handleAdd() {
this.reset();
this.form.assetInfoList = [];
this.isAdd = true;
this.open = true;
this.title = "新增员工";
@@ -646,7 +1097,10 @@ export default {
handleDetail(row) {
const staffId = row.staffId;
getBaseStaff(staffId).then(response => {
this.employeeDetail = response.data;
this.employeeDetail = {
...response.data,
assetInfoList: response.data.assetInfoList || []
};
this.detailOpen = true;
});
},
@@ -656,7 +1110,10 @@ export default {
this.isAdd = false;
const staffId = row.staffId || this.ids[0];
getBaseStaff(staffId).then(response => {
this.form = response.data;
this.form = {
...response.data,
assetInfoList: response.data.assetInfoList || []
};
this.open = true;
this.title = "编辑员工";
});
@@ -665,6 +1122,10 @@ export default {
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
this.form.assetInfoList = this.normalizeAssetInfoList();
if (!this.validateAssetInfoList(this.form.assetInfoList)) {
return;
}
if (this.isAdd) {
addBaseStaff(this.form).then(response => {
this.$modal.msgSuccess("新增成功");
@@ -697,6 +1158,10 @@ export default {
this.upload.title = "员工数据导入";
this.upload.open = true;
},
handleAssetImport() {
this.assetUpload.title = "员工资产数据导入";
this.assetUpload.open = true;
},
/** 导入对话框关闭事件 */
handleImportDialogClose() {
this.$nextTick(() => {
@@ -705,14 +1170,27 @@ export default {
}
});
},
handleAssetImportDialogClose() {
this.$nextTick(() => {
if (this.$refs.assetUpload) {
this.$refs.assetUpload.clearFiles();
}
});
},
/** 下载模板操作 */
importTemplate() {
this.download('ccdi/baseStaff/importTemplate', {}, `员工信息模板_${new Date().getTime()}.xlsx`)
},
importAssetTemplate() {
this.download('ccdi/assetInfo/importTemplate', {}, `员工资产信息模板_${new Date().getTime()}.xlsx`)
},
// 文件上传中处理
handleFileUploadProgress(event, file, fileList) {
this.upload.isUploading = true;
},
handleAssetFileUploadProgress() {
this.assetUpload.isUploading = true;
},
// 文件上传成功处理
handleFileSuccess(response, file, fileList) {
this.upload.isUploading = false;
@@ -763,11 +1241,59 @@ export default {
this.$modal.msgError(response.msg);
}
},
handleAssetFileSuccess(response) {
this.assetUpload.isUploading = false;
this.assetUpload.open = false;
if (response.code === 200) {
if (!response.data || !response.data.taskId) {
this.$modal.msgError('员工资产导入任务创建失败:缺少任务ID');
this.assetUpload.isUploading = false;
this.assetUpload.open = true;
return;
}
const taskId = response.data.taskId;
if (this.assetPollingTimer) {
clearInterval(this.assetPollingTimer);
this.assetPollingTimer = null;
}
this.clearAssetImportTaskFromStorage();
this.saveAssetImportTaskToStorage({
taskId: taskId,
status: 'PROCESSING',
timestamp: Date.now(),
hasFailures: false
});
this.assetShowFailureButton = false;
this.assetCurrentTaskId = taskId;
this.$notify({
title: '员工资产导入任务已提交',
message: '正在后台处理中,处理完成后将通知您',
type: 'info',
duration: 3000
});
this.startAssetImportStatusPolling(taskId);
} else if (response.code === 601) {
this.$modal.msgWarning(response.msg);
} else {
this.$modal.msgError(response.msg);
}
},
// 导入结果弹窗关闭
handleImportResultClose() {
this.importResultVisible = false;
this.importResultContent = "";
},
handleAssetImportResultClose() {
this.assetImportResultVisible = false;
this.assetImportResultContent = "";
},
/** 开始轮询导入状态 */
startImportStatusPolling(taskId) {
let pollCount = 0;
@@ -796,6 +1322,32 @@ export default {
}
}, 2000); // 每2秒轮询一次
},
startAssetImportStatusPolling(taskId) {
let pollCount = 0;
const maxPolls = 150;
this.assetPollingTimer = setInterval(async () => {
try {
pollCount++;
if (pollCount > maxPolls) {
clearInterval(this.assetPollingTimer);
this.$modal.msgWarning('员工资产导入任务处理超时,请联系管理员');
return;
}
const response = await getAssetImportStatus(taskId);
if (response.data && response.data.status !== 'PROCESSING') {
clearInterval(this.assetPollingTimer);
this.handleAssetImportComplete(response.data);
}
} catch (error) {
clearInterval(this.assetPollingTimer);
this.$modal.msgError('查询员工资产导入状态失败: ' + error.message);
}
}, 2000);
},
/** 处理导入完成 */
handleImportComplete(statusResult) {
// 更新localStorage中的任务状态
@@ -833,11 +1385,47 @@ export default {
this.getList();
}
},
handleAssetImportComplete(statusResult) {
this.saveAssetImportTaskToStorage({
taskId: statusResult.taskId,
status: statusResult.status,
hasFailures: statusResult.failureCount > 0,
totalCount: statusResult.totalCount,
successCount: statusResult.successCount,
failureCount: statusResult.failureCount
});
if (statusResult.status === 'SUCCESS') {
this.$notify({
title: '员工资产导入完成',
message: `全部成功!共导入${statusResult.totalCount}条数据`,
type: 'success',
duration: 5000
});
this.assetShowFailureButton = false;
this.getList();
} else if (statusResult.failureCount > 0) {
this.$notify({
title: '员工资产导入完成',
message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}`,
type: 'warning',
duration: 5000
});
this.assetShowFailureButton = true;
this.assetCurrentTaskId = statusResult.taskId;
this.getList();
}
},
/** 查看导入失败记录 */
viewImportFailures() {
this.failureDialogVisible = true;
this.getFailureList();
},
viewAssetImportFailures() {
this.assetFailureDialogVisible = true;
this.getAssetFailureList();
},
/** 查询失败记录列表 */
getFailureList() {
this.failureLoading = true;
@@ -876,9 +1464,46 @@ export default {
}
});
},
getAssetFailureList() {
this.assetFailureLoading = true;
getAssetImportFailures(
this.assetCurrentTaskId,
this.assetFailureQueryParams.pageNum,
this.assetFailureQueryParams.pageSize
).then(response => {
this.assetFailureList = response.rows;
this.assetFailureTotal = response.total;
this.assetFailureLoading = false;
}).catch(error => {
this.assetFailureLoading = false;
if (error.response) {
const status = error.response.status;
if (status === 404) {
this.$modal.msgWarning('员工资产导入记录已过期,无法查看失败记录');
this.clearAssetImportTaskFromStorage();
this.assetShowFailureButton = false;
this.assetCurrentTaskId = null;
this.assetFailureDialogVisible = false;
} else if (status === 500) {
this.$modal.msgError('服务器错误,请稍后重试');
} else {
this.$modal.msgError(`查询失败: ${error.response.data.msg || '未知错误'}`);
}
} else if (error.request) {
this.$modal.msgError('网络连接失败,请检查网络');
} else {
this.$modal.msgError('查询员工资产失败记录失败: ' + error.message);
}
});
},
// 提交上传文件
submitFileForm() {
this.$refs.upload.submit();
},
submitAssetFileForm() {
this.$refs.assetUpload.submit();
}
}
};
@@ -898,6 +1523,7 @@ export default {
background: #f9fafb;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
}
.employee-detail-dialog .section-title {
@@ -921,6 +1547,20 @@ export default {
background: #fff;
}
.employee-detail-dialog .detail-assets-table {
background: #fff;
border-radius: 4px;
overflow: hidden;
}
.employee-detail-dialog .empty-assets-detail {
padding: 32px 0;
text-align: center;
color: #909399;
background: #fff;
border-radius: 4px;
}
.employee-detail-dialog .relatives-container {
background: #fff;
border-radius: 4px;
@@ -976,6 +1616,50 @@ export default {
margin-right: 20px;
}
.employee-edit-dialog .assets-helper {
margin: -4px 0 12px;
padding: 10px 12px;
background: #f4f8ff;
border: 1px solid #d9ecff;
border-radius: 6px;
color: #606266;
line-height: 1.8;
}
.employee-edit-dialog .assets-table-wrapper {
width: 100%;
overflow-x: auto;
}
.employee-edit-dialog .assets-table {
min-width: 1460px;
}
.employee-edit-dialog .assets-table .el-input,
.employee-edit-dialog .assets-table .el-date-editor {
width: 100%;
}
.employee-edit-dialog .empty-assets {
text-align: center;
padding: 30px 0;
color: #909399;
border: 1px dashed #dcdfe6;
border-radius: 4px;
background: #fafafa;
}
.employee-edit-dialog .empty-assets i {
display: block;
font-size: 30px;
color: #c0c4cc;
margin-bottom: 8px;
}
.employee-edit-dialog .empty-assets span {
font-size: 13px;
}
.employee-edit-dialog .relatives-table {
width: 100%;
}

View File

@@ -270,6 +270,7 @@
v-model="pullBankInfoForm.dateRange"
type="daterange"
value-format="yyyy-MM-dd"
:picker-options="pullBankInfoDatePickerOptions"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
@@ -502,6 +503,13 @@ export default {
pollingInterval: 5000,
};
},
computed: {
pullBankInfoDatePickerOptions() {
return {
disabledDate: (time) => this.isPullBankInfoDateDisabled(time),
};
},
},
created() {
// 加载初始数据
// this.loadInitialData();
@@ -860,6 +868,36 @@ export default {
this.idCardFileList = [];
this.parsingIdCardFile = false;
},
getPullBankInfoTodayStart() {
const today = new Date();
today.setHours(0, 0, 0, 0);
return today;
},
getPullBankInfoMaxSelectableDate() {
const yesterday = this.getPullBankInfoTodayStart();
yesterday.setDate(yesterday.getDate() - 1);
return yesterday;
},
parsePullBankInfoDate(dateValue) {
if (!dateValue) return null;
const [year, month, day] = String(dateValue)
.split("-")
.map((item) => Number(item));
if (![year, month, day].every(Number.isFinite)) {
return null;
}
return new Date(year, month - 1, day);
},
isPullBankInfoDateDisabled(time) {
return time.getTime() >= this.getPullBankInfoTodayStart().getTime();
},
hasInvalidPullBankInfoDateRange(dateRange) {
const maxSelectableDate = this.getPullBankInfoMaxSelectableDate();
return (dateRange || []).some((dateValue) => {
const date = this.parsePullBankInfoDate(dateValue);
return !date || date.getTime() > maxSelectableDate.getTime();
});
},
buildFinalIdCardList() {
return this.parseIdCardText(this.pullBankInfoForm.idCardText);
},
@@ -877,6 +915,11 @@ export default {
return;
}
if (this.hasInvalidPullBankInfoDateRange([startDate, endDate])) {
this.$message.warning("时间跨度最晚只能选择到昨天");
return;
}
this.pullBankInfoLoading = true;
try {

View File

@@ -62,6 +62,16 @@
v-hasPermi="['ccdi:staffFmyRelation:import']"
>导入</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-upload"
size="mini"
@click="handleAssetImport"
v-hasPermi="['ccdi:staffFmyRelation:import']"
>导入亲属资产信息</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="warning"
@@ -86,6 +96,20 @@
>查看导入失败记录</el-button>
</el-tooltip>
</el-col>
<el-col :span="1.5" v-if="assetShowFailureButton">
<el-tooltip
:content="getLastAssetImportTooltip()"
placement="top"
>
<el-button
type="warning"
plain
icon="el-icon-warning-outline"
size="mini"
@click="viewAssetImportFailures"
>查看亲属资产导入失败记录</el-button>
</el-tooltip>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList"></right-toolbar>
</el-row>
@@ -153,7 +177,7 @@
/>
<!-- 添加或修改对话框 -->
<el-dialog :title="title" :visible.sync="open" width="800px" append-to-body>
<el-dialog :title="title" :visible.sync="open" width="80%" append-to-body class="relation-edit-dialog">
<el-form ref="form" :model="form" :rules="rules" label-width="140px">
<el-divider content-position="left">基本信息</el-divider>
<el-row :gutter="16">
@@ -218,7 +242,7 @@
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="关系人证件类型" prop="relationCertType">
<el-select v-model="form.relationCertType" placeholder="请选择证件类型" style="width: 100%">
<el-select v-model="form.relationCertType" placeholder="请选择证件类型" style="width: 100%" :disabled="!isAdd">
<el-option
v-for="item in certTypeOptions"
:key="item.value"
@@ -230,7 +254,7 @@
</el-col>
<el-col :span="12">
<el-form-item label="关系人证件号码" prop="relationCertNo">
<el-input v-model="form.relationCertNo" placeholder="请输入证件号码" maxlength="100" />
<el-input v-model="form.relationCertNo" placeholder="请输入证件号码" maxlength="100" :disabled="!isAdd" />
</el-form-item>
</el-col>
</el-row>
@@ -319,6 +343,98 @@
<el-form-item label="关系详细描述" prop="relationDesc">
<el-input v-model="form.relationDesc" type="textarea" :rows="2" placeholder="请输入关系详细描述" />
</el-form-item>
<div class="asset-section-header">
<span>亲属资产信息</span>
<el-tooltip :disabled="canAddAsset" content="请先填写关系人证件信息" placement="top">
<span>
<el-button type="primary" plain size="mini" icon="el-icon-plus" :disabled="!canAddAsset" @click="handleAddAsset">新增资产</el-button>
</span>
</el-tooltip>
</div>
<el-form-item label-width="0" prop="assetInfoList">
<div v-if="!form.assetInfoList || !form.assetInfoList.length" class="empty-assets">
<i class="el-icon-office-building"></i>
<span>暂无亲属资产信息请点击新增资产</span>
</div>
<div v-else class="assets-table-wrapper">
<el-table :data="form.assetInfoList" border class="assets-table">
<el-table-column label="资产大类" min-width="140">
<template slot-scope="scope">
<el-input v-model="scope.row.assetMainType" placeholder="请输入资产大类" />
</template>
</el-table-column>
<el-table-column label="资产小类" min-width="140">
<template slot-scope="scope">
<el-input v-model="scope.row.assetSubType" placeholder="请输入资产小类" />
</template>
</el-table-column>
<el-table-column label="资产名称" min-width="160">
<template slot-scope="scope">
<el-input v-model="scope.row.assetName" placeholder="请输入资产名称" />
</template>
</el-table-column>
<el-table-column label="产权占比" min-width="120">
<template slot-scope="scope">
<el-input v-model="scope.row.ownershipRatio" placeholder="请输入产权占比" />
</template>
</el-table-column>
<el-table-column label="购买/评估日期" min-width="160">
<template slot-scope="scope">
<el-date-picker
v-model="scope.row.purchaseEvalDate"
type="date"
value-format="yyyy-MM-dd"
placeholder="选择日期"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="资产原值" min-width="120">
<template slot-scope="scope">
<el-input v-model="scope.row.originalValue" placeholder="请输入资产原值" />
</template>
</el-table-column>
<el-table-column label="当前估值" min-width="120">
<template slot-scope="scope">
<el-input v-model="scope.row.currentValue" placeholder="请输入当前估值" />
</template>
</el-table-column>
<el-table-column label="估值截止日期" min-width="160">
<template slot-scope="scope">
<el-date-picker
v-model="scope.row.valuationDate"
type="date"
value-format="yyyy-MM-dd"
placeholder="选择日期"
style="width: 100%"
/>
</template>
</el-table-column>
<el-table-column label="资产状态" min-width="140">
<template slot-scope="scope">
<el-select v-model="scope.row.assetStatus" placeholder="请选择资产状态" style="width: 100%">
<el-option
v-for="option in assetStatusOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="备注" min-width="180">
<template slot-scope="scope">
<el-input v-model="scope.row.remarks" placeholder="请输入备注" />
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template slot-scope="scope">
<el-button type="text" size="mini" icon="el-icon-delete" @click="handleRemoveAsset(scope.$index)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
</el-form-item>
<el-form-item label="备注" prop="remark">
<el-input v-model="form.remark" type="textarea" :rows="3" placeholder="请输入备注" />
</el-form-item>
@@ -330,7 +446,7 @@
</el-dialog>
<!-- 详情对话框 -->
<el-dialog title="员工亲属关系详情" :visible.sync="detailOpen" width="800px" append-to-body>
<el-dialog title="员工亲属关系详情" :visible.sync="detailOpen" width="80%" append-to-body class="relation-detail-dialog">
<div class="detail-container">
<el-divider content-position="left">基本信息</el-divider>
<el-descriptions :column="2" border>
@@ -366,6 +482,31 @@
<el-descriptions-item label="备注" :span="2">{{ relationDetail.remark || '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">亲属资产信息</el-divider>
<div v-if="!relationDetail.assetInfoList || !relationDetail.assetInfoList.length" class="empty-assets-detail">
暂无亲属资产信息
</div>
<el-table v-else :data="relationDetail.assetInfoList" border class="detail-assets-table">
<el-table-column label="资产大类" prop="assetMainType" min-width="140" />
<el-table-column label="资产小类" prop="assetSubType" min-width="140" />
<el-table-column label="资产名称" prop="assetName" min-width="160" />
<el-table-column label="产权占比" prop="ownershipRatio" min-width="120" />
<el-table-column label="购买/评估日期" prop="purchaseEvalDate" min-width="140">
<template slot-scope="scope">
<span>{{ scope.row.purchaseEvalDate ? parseTime(scope.row.purchaseEvalDate, '{y}-{m}-{d}') : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="资产原值" prop="originalValue" min-width="120" />
<el-table-column label="当前估值" prop="currentValue" min-width="120" />
<el-table-column label="估值截止日期" prop="valuationDate" min-width="140">
<template slot-scope="scope">
<span>{{ scope.row.valuationDate ? parseTime(scope.row.valuationDate, '{y}-{m}-{d}') : '-' }}</span>
</template>
</el-table-column>
<el-table-column label="资产状态" prop="assetStatus" min-width="120" />
<el-table-column label="备注" prop="remarks" min-width="180" show-overflow-tooltip />
</el-table>
<el-divider content-position="left">审计信息</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="创建时间">
@@ -418,6 +559,44 @@
</div>
</el-dialog>
<el-dialog
:title="assetUpload.title"
:visible.sync="assetUpload.open"
width="400px"
append-to-body
@close="handleAssetImportDialogClose"
v-loading="assetUpload.isUploading"
element-loading-text="正在导入亲属资产数据,请稍候..."
element-loading-spinner="el-icon-loading"
element-loading-background="rgba(0, 0, 0, 0.7)"
>
<el-upload
ref="assetUpload"
:limit="1"
accept=".xlsx, .xls"
:headers="assetUpload.headers"
:action="assetUpload.url"
:disabled="assetUpload.isUploading"
:on-progress="handleAssetFileUploadProgress"
:on-success="handleAssetFileSuccess"
:auto-upload="false"
drag
>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处<em>点击上传</em></div>
<div class="el-upload__tip" slot="tip">
<el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline;" @click="importAssetTemplate">下载亲属资产模板</el-link>
</div>
<div class="el-upload__tip" slot="tip">
<span>仅允许导入"xls""xlsx"格式文件系统将根据关系人证件号自动识别归属员工</span>
</div>
</el-upload>
<div slot="footer" class="dialog-footer">
<el-button type="primary" @click="submitAssetFileForm" :loading="assetUpload.isUploading"> </el-button>
<el-button @click="assetUpload.open = false" :disabled="assetUpload.isUploading"> </el-button>
</div>
</el-dialog>
<!-- 导入失败记录对话框 -->
<el-dialog
title="导入失败记录"
@@ -453,6 +632,44 @@
<el-button type="danger" plain @click="clearImportHistory">清除历史记录</el-button>
</div>
</el-dialog>
<el-dialog
title="亲属资产导入失败记录"
:visible.sync="assetFailureDialogVisible"
width="1200px"
append-to-body
>
<el-alert
v-if="lastAssetImportInfo"
:title="lastAssetImportInfo"
type="info"
:closable="false"
style="margin-bottom: 15px"
/>
<el-table :data="assetFailureList" v-loading="assetFailureLoading">
<el-table-column label="关系人证件号" prop="personId" align="center" min-width="180" />
<el-table-column label="资产大类" prop="assetMainType" align="center" min-width="120" />
<el-table-column label="资产小类" prop="assetSubType" align="center" min-width="120" />
<el-table-column label="资产名称" prop="assetName" align="center" min-width="150" />
<el-table-column label="当前估值" prop="currentValue" align="center" min-width="120" />
<el-table-column label="资产状态" prop="assetStatus" align="center" min-width="120" />
<el-table-column label="失败原因" prop="errorMessage" align="center" min-width="220" :show-overflow-tooltip="true" />
</el-table>
<pagination
v-show="assetFailureTotal > 0"
:total="assetFailureTotal"
:page.sync="assetFailureQueryParams.pageNum"
:limit.sync="assetFailureQueryParams.pageSize"
@pagination="getAssetFailureList"
/>
<div slot="footer" class="dialog-footer">
<el-button @click="assetFailureDialogVisible = false">关闭</el-button>
<el-button type="danger" plain @click="clearAssetImportHistory">清除资产导入历史</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -466,6 +683,10 @@ import {
listRelation,
updateRelation
} from "@/api/ccdiStaffFmyRelation";
import {
getAssetImportFailures,
getAssetImportStatus
} from "@/api/ccdiAssetInfo";
import {listBaseStaff} from "@/api/ccdiBaseStaff";
import {getToken} from "@/utils/auth";
import EnumTag from '@/components/EnumTag'
@@ -516,6 +737,12 @@ export default {
},
// 表单参数
form: {},
assetStatusOptions: [
{ label: "正常", value: "正常" },
{ label: "冻结", value: "冻结" },
{ label: "处置中", value: "处置中" },
{ label: "报废", value: "报废" }
],
// 表单校验
rules: {
personId: [
@@ -569,6 +796,24 @@ export default {
pageNum: 1,
pageSize: 10
},
assetUpload: {
open: false,
title: "",
isUploading: false,
headers: { Authorization: "Bearer " + getToken() },
url: process.env.VUE_APP_BASE_API + "/ccdi/assetInfo/importData"
},
assetImportPollingTimer: null,
assetShowFailureButton: false,
assetCurrentTaskId: null,
assetFailureDialogVisible: false,
assetFailureList: [],
assetFailureLoading: false,
assetFailureTotal: 0,
assetFailureQueryParams: {
pageNum: 1,
pageSize: 10
},
// 员工选项
staffOptions: [],
staffLoading: false
@@ -584,11 +829,22 @@ export default {
return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}`;
}
return '';
},
lastAssetImportInfo() {
const savedTask = this.getAssetImportTaskFromStorage();
if (savedTask && savedTask.totalCount) {
return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}`;
}
return '';
},
canAddAsset() {
return Boolean(this.form.relationCertType && String(this.form.relationCertNo || '').trim())
}
},
created() {
this.getList();
this.restoreImportState();
this.restoreAssetImportState();
this.loadEnumOptions();
},
beforeDestroy() {
@@ -596,6 +852,10 @@ export default {
clearInterval(this.importPollingTimer);
this.importPollingTimer = null;
}
if (this.assetImportPollingTimer) {
clearInterval(this.assetImportPollingTimer);
this.assetImportPollingTimer = null;
}
},
methods: {
/**
@@ -652,6 +912,18 @@ export default {
this.showFailureButton = true;
}
},
restoreAssetImportState() {
const savedTask = this.getAssetImportTaskFromStorage();
if (!savedTask) {
this.assetShowFailureButton = false;
this.assetCurrentTaskId = null;
return;
}
if (savedTask.hasFailures && savedTask.taskId) {
this.assetCurrentTaskId = savedTask.taskId;
this.assetShowFailureButton = true;
}
},
/**
* 获取上次导入的提示信息
*/
@@ -664,6 +936,15 @@ export default {
}
return '';
},
getLastAssetImportTooltip() {
const savedTask = this.getAssetImportTaskFromStorage();
if (savedTask && savedTask.saveTime) {
const date = new Date(savedTask.saveTime);
const timeStr = this.parseTime(date, '{y}-{m}-{d} {h}:{i}');
return `上次亲属资产导入: ${timeStr}`;
}
return '';
},
// 取消按钮
cancel() {
this.open = false;
@@ -690,11 +971,101 @@ export default {
effectiveDate: null,
invalidDate: null,
status: 1,
remark: null
remark: null,
assetInfoList: []
};
this.staffOptions = [];
this.resetForm("form");
},
createEmptyAssetRow() {
return {
assetMainType: "",
assetSubType: "",
assetName: "",
ownershipRatio: "",
purchaseEvalDate: "",
originalValue: "",
currentValue: "",
valuationDate: "",
assetStatus: "",
remarks: ""
};
},
hasAssetContent(row) {
if (!row || typeof row !== "object") {
return false;
}
return Object.keys(row).some(key => {
const value = row[key];
return value !== null && value !== undefined && String(value).trim() !== "";
});
},
normalizeAssetInfoList() {
const assetInfoList = Array.isArray(this.form.assetInfoList)
? this.form.assetInfoList
: [];
return assetInfoList.filter(item => this.hasAssetContent(item));
},
validateAssetInfoList(assetInfoList) {
const requiredFields = [
{ key: "assetMainType", label: "资产大类" },
{ key: "assetSubType", label: "资产小类" },
{ key: "assetName", label: "资产名称" },
{ key: "currentValue", label: "当前估值" },
{ key: "assetStatus", label: "资产状态" }
];
const numericFields = [
{ key: "ownershipRatio", label: "产权占比" },
{ key: "originalValue", label: "资产原值" },
{ key: "currentValue", label: "当前估值" }
];
for (let index = 0; index < assetInfoList.length; index++) {
const asset = assetInfoList[index];
const rowNo = index + 1;
for (const field of requiredFields) {
const value = asset[field.key];
if (value === null || value === undefined || String(value).trim() === "") {
this.$modal.msgError(`${rowNo}条资产的${field.label}不能为空`);
return false;
}
}
for (const field of numericFields) {
const value = asset[field.key];
if (value !== null && value !== undefined && String(value).trim() !== "") {
if (!/^-?\d+(\.\d+)?$/.test(String(value).trim())) {
this.$modal.msgError(`${rowNo}条资产的${field.label}格式不正确`);
return false;
}
}
}
if (!this.assetStatusOptions.some(option => option.value === asset.assetStatus)) {
this.$modal.msgError(`${rowNo}条资产的资产状态不在允许范围内`);
return false;
}
}
return true;
},
handleAddAsset() {
if (!this.canAddAsset) {
this.$modal.msgWarning("请先填写关系人证件信息");
return;
}
if (!Array.isArray(this.form.assetInfoList)) {
this.form.assetInfoList = [];
}
this.form.assetInfoList.push(this.createEmptyAssetRow());
},
handleRemoveAsset(index) {
if (!Array.isArray(this.form.assetInfoList)) {
return;
}
this.form.assetInfoList.splice(index, 1);
},
/** 搜索按钮操作 */
handleQuery() {
this.queryParams.pageNum = 1;
@@ -723,7 +1094,10 @@ export default {
this.reset();
const id = row.id || this.ids[0];
getRelation(id).then(response => {
this.form = response.data;
this.form = {
...response.data,
assetInfoList: response.data.assetInfoList || []
};
// 加载员工信息以支持下拉显示
if (this.form.personId) {
this.searchStaff(this.form.personId);
@@ -737,7 +1111,10 @@ export default {
handleDetail(row) {
const id = row.id;
getRelation(id).then(response => {
this.relationDetail = response.data;
this.relationDetail = {
...response.data,
assetInfoList: response.data.assetInfoList || []
};
this.detailOpen = true;
});
},
@@ -745,14 +1122,30 @@ export default {
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
this.form.assetInfoList = this.normalizeAssetInfoList();
if (!this.validateAssetInfoList(this.form.assetInfoList)) {
return;
}
const payload = {
...this.form,
assetInfoList: this.form.assetInfoList.map(item => ({ ...item }))
};
payload.assetInfoList.forEach((asset, index) => {
delete payload.assetInfoList[index].familyId;
delete payload.assetInfoList[index].personId;
delete asset.familyId;
delete asset.personId;
});
if (this.isAdd) {
addRelation(this.form).then(response => {
addRelation(payload).then(response => {
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
});
} else {
updateRelation(this.form).then(response => {
updateRelation(payload).then(response => {
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
@@ -782,14 +1175,24 @@ export default {
this.upload.title = "员工亲属关系数据导入";
this.upload.open = true;
},
handleAssetImport() {
this.assetUpload.title = "亲属资产数据导入";
this.assetUpload.open = true;
},
/** 下载模板操作 */
importTemplate() {
this.download('ccdi/staffFmyRelation/importTemplate', {}, `员工亲属关系导入模板_${new Date().getTime()}.xlsx`);
},
importAssetTemplate() {
this.download('ccdi/assetInfo/importTemplate', {}, `亲属资产信息模板_${new Date().getTime()}.xlsx`);
},
// 文件上传中处理
handleFileUploadProgress(event, file, fileList) {
this.upload.isUploading = true;
},
handleAssetFileUploadProgress() {
this.assetUpload.isUploading = true;
},
// 文件上传成功处理
handleFileSuccess(response, file, fileList) {
this.upload.isUploading = false;
@@ -834,6 +1237,49 @@ export default {
this.$modal.msgError(response.msg);
}
},
handleAssetFileSuccess(response) {
this.assetUpload.isUploading = false;
this.assetUpload.open = false;
if (response.code === 200) {
if (!response.data || !response.data.taskId) {
this.$modal.msgError('亲属资产导入任务创建失败:缺少任务ID');
this.assetUpload.isUploading = false;
this.assetUpload.open = true;
return;
}
const taskId = response.data.taskId;
if (this.assetImportPollingTimer) {
clearInterval(this.assetImportPollingTimer);
this.assetImportPollingTimer = null;
}
this.clearAssetImportTaskFromStorage();
this.saveAssetImportTaskToStorage({
taskId: taskId,
status: 'PROCESSING',
timestamp: Date.now(),
hasFailures: false
});
this.assetShowFailureButton = false;
this.assetCurrentTaskId = taskId;
this.$notify({
title: '亲属资产导入任务已提交',
message: '正在后台处理中,处理完成后将通知您',
type: 'info',
duration: 3000
});
this.startAssetImportStatusPolling(taskId);
} else {
this.$modal.msgError(response.msg);
}
},
/** 开始轮询导入状态 */
startImportStatusPolling(taskId) {
let pollCount = 0;
@@ -861,6 +1307,32 @@ export default {
}
}, 2000);
},
startAssetImportStatusPolling(taskId) {
let pollCount = 0;
const maxPolls = 150;
this.assetImportPollingTimer = setInterval(async () => {
try {
pollCount++;
if (pollCount > maxPolls) {
clearInterval(this.assetImportPollingTimer);
this.$modal.msgWarning('亲属资产导入任务处理超时,请联系管理员');
return;
}
const response = await getAssetImportStatus(taskId);
if (response.data && response.data.status !== 'PROCESSING') {
clearInterval(this.assetImportPollingTimer);
this.handleAssetImportComplete(response.data);
}
} catch (error) {
clearInterval(this.assetImportPollingTimer);
this.$modal.msgError('查询亲属资产导入状态失败: ' + error.message);
}
}, 2000);
},
/** 查询失败记录列表 */
getFailureList() {
this.failureLoading = true;
@@ -885,11 +1357,42 @@ export default {
}
});
},
getAssetFailureList() {
this.assetFailureLoading = true;
getAssetImportFailures(
this.assetCurrentTaskId,
this.assetFailureQueryParams.pageNum,
this.assetFailureQueryParams.pageSize
).then(response => {
this.assetFailureList = response.rows;
this.assetFailureTotal = response.total;
this.assetFailureLoading = false;
}).catch(error => {
this.assetFailureLoading = false;
if (error.response && error.response.status === 404) {
this.$modal.msgWarning('亲属资产导入记录已过期,无法查看失败记录');
this.clearAssetImportTaskFromStorage();
this.assetShowFailureButton = false;
this.assetCurrentTaskId = null;
this.assetFailureDialogVisible = false;
} else if (error.response && error.response.status === 500) {
this.$modal.msgError('服务器错误,请稍后重试');
} else if (error.request) {
this.$modal.msgError('网络连接失败,请检查网络');
} else {
this.$modal.msgError('查询亲属资产失败记录失败');
}
});
},
/** 查看导入失败记录 */
viewImportFailures() {
this.failureDialogVisible = true;
this.getFailureList();
},
viewAssetImportFailures() {
this.assetFailureDialogVisible = true;
this.getAssetFailureList();
},
/** 处理导入完成 */
handleImportComplete(statusResult) {
this.saveImportTaskToStorage({
@@ -922,14 +1425,60 @@ export default {
this.getList();
}
},
handleAssetImportComplete(statusResult) {
this.saveAssetImportTaskToStorage({
taskId: statusResult.taskId,
status: statusResult.status,
hasFailures: statusResult.failureCount > 0,
totalCount: statusResult.totalCount,
successCount: statusResult.successCount,
failureCount: statusResult.failureCount
});
if (statusResult.status === 'SUCCESS') {
this.$notify({
title: '亲属资产导入完成',
message: `全部成功!共导入${statusResult.totalCount}条数据`,
type: 'success',
duration: 5000
});
this.assetShowFailureButton = false;
this.getList();
} else if (statusResult.failureCount > 0) {
this.$notify({
title: '亲属资产导入完成',
message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}`,
type: 'warning',
duration: 5000
});
this.assetShowFailureButton = true;
this.assetCurrentTaskId = statusResult.taskId;
this.getList();
}
},
// 提交上传文件
submitFileForm() {
this.$refs.upload.submit();
},
submitAssetFileForm() {
this.$refs.assetUpload.submit();
},
// 关闭导入对话框
handleImportDialogClose() {
this.upload.isUploading = false;
this.$refs.upload.clearFiles();
this.$nextTick(() => {
if (this.$refs.upload) {
this.$refs.upload.clearFiles();
}
});
},
handleAssetImportDialogClose() {
this.assetUpload.isUploading = false;
this.$nextTick(() => {
if (this.$refs.assetUpload) {
this.$refs.assetUpload.clearFiles();
}
});
},
/**
* 保存导入任务到localStorage
@@ -945,6 +1494,17 @@ export default {
console.error('保存导入任务状态失败:', error);
}
},
saveAssetImportTaskToStorage(taskData) {
try {
const data = {
...taskData,
saveTime: Date.now()
};
localStorage.setItem('staff_fmy_asset_import_last_task', JSON.stringify(data));
} catch (error) {
console.error('保存亲属资产导入任务状态失败:', error);
}
},
/**
* 从localStorage读取导入任务
*/
@@ -972,6 +1532,30 @@ export default {
return null;
}
},
getAssetImportTaskFromStorage() {
try {
const data = localStorage.getItem('staff_fmy_asset_import_last_task');
if (!data) return null;
const task = JSON.parse(data);
if (!task || !task.taskId) {
this.clearAssetImportTaskFromStorage();
return null;
}
const sevenDays = 7 * 24 * 60 * 60 * 1000;
if (Date.now() - task.saveTime > sevenDays) {
this.clearAssetImportTaskFromStorage();
return null;
}
return task;
} catch (error) {
console.error('读取亲属资产导入任务状态失败:', error);
this.clearAssetImportTaskFromStorage();
return null;
}
},
/**
* 清除导入历史记录
*/
@@ -988,6 +1572,19 @@ export default {
this.$message.success('已清除');
}).catch(() => {});
},
clearAssetImportHistory() {
this.$confirm('确认清除上次亲属资产导入记录?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}).then(() => {
this.clearAssetImportTaskFromStorage();
this.assetShowFailureButton = false;
this.assetCurrentTaskId = null;
this.assetFailureDialogVisible = false;
this.$message.success('已清除');
}).catch(() => {});
},
/**
* 清除localStorage中的导入任务
*/
@@ -997,6 +1594,13 @@ export default {
} catch (error) {
console.error('清除导入任务状态失败:', error);
}
},
clearAssetImportTaskFromStorage() {
try {
localStorage.removeItem('staff_fmy_asset_import_last_task');
} catch (error) {
console.error('清除亲属资产导入任务状态失败:', error);
}
}
}
};
@@ -1006,4 +1610,65 @@ export default {
.detail-container {
padding: 0 20px;
}
.relation-detail-dialog .detail-assets-table {
margin-bottom: 16px;
}
.relation-detail-dialog .empty-assets-detail {
padding: 32px 0;
margin-bottom: 16px;
text-align: center;
color: #909399;
background: #fff;
border-radius: 4px;
border: 1px dashed #dcdfe6;
}
.asset-section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin: 8px 0 12px;
padding-top: 8px;
border-top: 1px solid #ebeef5;
color: #303133;
font-size: 14px;
font-weight: 500;
}
.assets-table-wrapper {
width: 100%;
overflow-x: auto;
}
.assets-table {
min-width: 1440px;
}
.assets-table .el-input,
.assets-table .el-date-editor,
.assets-table .el-select {
width: 100%;
}
.empty-assets {
padding: 30px 0;
text-align: center;
color: #909399;
border: 1px dashed #dcdfe6;
border-radius: 4px;
background: #fafafa;
}
.empty-assets i {
display: block;
margin-bottom: 8px;
font-size: 30px;
color: #c0c4cc;
}
.empty-assets span {
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,44 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const baseStaffApiPath = path.resolve(
__dirname,
"../../src/api/ccdiBaseStaff.js"
);
const assetApiPath = path.resolve(
__dirname,
"../../src/api/ccdiAssetInfo.js"
);
assert(fs.existsSync(baseStaffApiPath), "未找到员工 API 文件 ccdiBaseStaff.js");
assert(fs.existsSync(assetApiPath), "未找到员工资产 API 文件 ccdiAssetInfo.js");
const baseStaffSource = fs.readFileSync(baseStaffApiPath, "utf8");
const assetSource = fs.readFileSync(assetApiPath, "utf8");
[
"export function addBaseStaff(data)",
"export function updateBaseStaff(data)",
"data: data",
].forEach((token) => {
assert(
baseStaffSource.includes(token),
`员工 API 需保持透传聚合保存数据: ${token}`
);
});
[
"export function importAssetTemplate()",
"export function importAssetData(data)",
"export function getAssetImportStatus(taskId)",
"export function getAssetImportFailures(taskId, pageNum, pageSize)",
"/ccdi/assetInfo/importTemplate",
"/ccdi/assetInfo/importData",
"/ccdi/assetInfo/importStatus/",
"/ccdi/assetInfo/importFailures/",
].forEach((token) => {
assert(assetSource.includes(token), `员工资产 API 缺少关键契约: ${token}`);
});
console.log("employee-asset-api-contract test passed");

Some files were not shown because too many files have changed in this diff Show More