Compare commits
27 Commits
c68e694536
...
80b2f1b39a
| Author | SHA1 | Date | |
|---|---|---|---|
| 80b2f1b39a | |||
| b90c2e53b8 | |||
| d63bdbf7b7 | |||
| 77f53cb991 | |||
| d2f7810b46 | |||
| 1dc6c66ed2 | |||
| d683522cc1 | |||
| 51b32c5d0c | |||
| b6df65706c | |||
| 4a3ea462b4 | |||
| 79fe98f1dd | |||
| 4dd7c273f2 | |||
| 328aaa7ff2 | |||
| 936961c705 | |||
| 54b81191aa | |||
| e36f13b6b5 | |||
| 70bdce7bda | |||
| 472457c69b | |||
| 58e022fe64 | |||
| 3481c37d55 | |||
| bac3cf094e | |||
| 4258d74809 | |||
| 606aab6bb4 | |||
| f2c4b6148a | |||
| dfcae72cec | |||
| 58c59ecd12 | |||
| 6a3cfa9ea6 |
9
.env.example
Normal file
9
.env.example
Normal 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
7
.gitignore
vendored
@@ -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/
|
||||
27
.mcp.json
27
.mcp.json
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
996
assets/初核系统模型业务口径-1.27-可疑模型业务口径.csv
Normal file
996
assets/初核系统模型业务口径-1.27-可疑模型业务口径.csv
Normal 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
|
||||
);",,,,,
|
||||
,,,,,,,,,
|
||||
,,,,,,,,,
|
||||
,,,,,,,,,
|
||||
,,,,,,,,,
|
||||
,,,,,,,,,
|
||||
,,,,,,,,,
|
||||
,,,,,,,,,
|
||||
,,,,,,,,,
|
||||
,,,,,,,,,
|
||||
,,,,,,,,,
|
||||
|
16
assets/资产信息表.csv
Normal file
16
assets/资产信息表.csv
Normal 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,-,否,否,记录最后更新的时间
|
||||
|
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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("资产状态不能为空");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
@@ -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 >= #{query.effectiveDateStart}
|
||||
</if>
|
||||
<if test="query.effectiveDateEnd != null">
|
||||
AND r.effective_date <= #{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 >= #{query.effectiveDateStart}
|
||||
</if>
|
||||
<if test="query.effectiveDateEnd != null">
|
||||
AND r.effective_date <= #{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 >= #{query.effectiveDateStart}
|
||||
</if>
|
||||
<if test="query.effectiveDateEnd != null">
|
||||
AND r.effective_date <= #{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 >= #{query.effectiveDateStart}
|
||||
</if>
|
||||
<if test="query.effectiveDateEnd != null">
|
||||
AND r.effective_date <= #{query.effectiveDateEnd}
|
||||
</if>
|
||||
ORDER BY r.create_time DESC
|
||||
</select>
|
||||
|
||||
|
||||
@@ -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, "亲属资产信息"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
33
deploy/deploy-to-nas.bat
Normal 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
112
deploy/deploy.ps1
Normal 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
177
deploy/remote-deploy.py
Normal 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
45
docker-compose.yml
Normal 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
11
docker/backend/Dockerfile
Normal 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"]
|
||||
8
docker/frontend/Dockerfile
Normal file
8
docker/frontend/Dockerfile
Normal 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
|
||||
27
docker/frontend/nginx.conf
Normal file
27
docker/frontend/nginx.conf
Normal 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
15
docker/mock/Dockerfile
Normal 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"]
|
||||
@@ -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 "新增员工资产信息后端实施计划"
|
||||
```
|
||||
285
docs/plans/2026-03-12-employee-asset-maintenance-design.md
Normal file
285
docs/plans/2026-03-12-employee-asset-maintenance-design.md
Normal 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`
|
||||
@@ -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 "新增员工资产信息前端实施计划"
|
||||
```
|
||||
@@ -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.
|
||||
45
docs/plans/2026-03-12-pull-bank-info-date-limit-design.md
Normal file
45
docs/plans/2026-03-12-pull-bank-info-date-limit-design.md
Normal 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/`
|
||||
- 后端:无代码改动,仅保留接口契约不变
|
||||
|
||||
## 验收标准
|
||||
- 日期面板无法选择今天和未来日期
|
||||
- 通过异常赋值带入今天或未来日期时,提交会被拦截
|
||||
- 原有弹窗交互和上传相关逻辑无回归
|
||||
@@ -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.
|
||||
@@ -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 "完成亲属资产后端联调验证"
|
||||
```
|
||||
332
docs/plans/2026-03-12-staff-family-asset-maintenance-design.md
Normal file
332
docs/plans/2026-03-12-staff-family-asset-maintenance-design.md
Normal 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`
|
||||
|
||||
## 测试建议
|
||||
|
||||
- 新增亲属关系但不维护资产
|
||||
- 新增亲属关系并维护单条、多条资产
|
||||
- 编辑亲属关系时新增、修改、删除资产
|
||||
- 验证编辑时不能修改亲属证件类型与证件号码
|
||||
- 删除亲属关系时资产级联删除
|
||||
- 亲属资产导入成功、无法匹配、匹配不唯一三类场景
|
||||
@@ -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 "完成亲属资产前端联调验证"
|
||||
```
|
||||
@@ -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后端部署方案"
|
||||
```
|
||||
156
docs/plans/2026-03-13-ccdi-docker-deployment-design.md
Normal file
156
docs/plans/2026-03-13-ccdi-docker-deployment-design.md
Normal 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 插件
|
||||
@@ -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前端部署方案"
|
||||
```
|
||||
@@ -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: 通过
|
||||
110
docs/plans/2026-03-13-deploy-to-nas-bat-design.md
Normal file
110
docs/plans/2026-03-13-deploy-to-nas-bat-design.md
Normal 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 表达式
|
||||
@@ -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: 通过
|
||||
16
lsfx-mock-server/.env.example
Normal file
16
lsfx-mock-server/.env.example
Normal 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
3
lsfx-mock-server/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
*.pyc
|
||||
244
lsfx-mock-server/README.md
Normal file
244
lsfx-mock-server/README.md
Normal 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!
|
||||
106
lsfx-mock-server/config/responses/bank_statement.json
Normal file
106
lsfx-mock-server/config/responses/bank_statement.json
Normal 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
|
||||
}
|
||||
}
|
||||
41
lsfx-mock-server/config/responses/parse_status.json
Normal file
41
lsfx-mock-server/config/responses/parse_status.json
Normal 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
|
||||
}
|
||||
}
|
||||
15
lsfx-mock-server/config/responses/token.json
Normal file
15
lsfx-mock-server/config/responses/token.json
Normal 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
|
||||
}
|
||||
}
|
||||
49
lsfx-mock-server/config/responses/upload.json
Normal file
49
lsfx-mock-server/config/responses/upload.json
Normal 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
|
||||
}
|
||||
}
|
||||
30
lsfx-mock-server/config/settings.py
Normal file
30
lsfx-mock-server/config/settings.py
Normal 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
80
lsfx-mock-server/main.py
Normal 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",
|
||||
)
|
||||
1
lsfx-mock-server/models/__init__.py
Normal file
1
lsfx-mock-server/models/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Models package
|
||||
53
lsfx-mock-server/models/request.py
Normal file
53
lsfx-mock-server/models/request.py
Normal 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="用户柜员号")
|
||||
187
lsfx-mock-server/models/response.py
Normal file
187
lsfx-mock-server/models/response.py
Normal 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="是否成功响应")
|
||||
8
lsfx-mock-server/requirements.txt
Normal file
8
lsfx-mock-server/requirements.txt
Normal 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
|
||||
1
lsfx-mock-server/routers/__init__.py
Normal file
1
lsfx-mock-server/routers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Routers package
|
||||
165
lsfx-mock-server/routers/api.py
Normal file
165
lsfx-mock-server/routers/api.py
Normal 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)
|
||||
1
lsfx-mock-server/services/__init__.py
Normal file
1
lsfx-mock-server/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
150
lsfx-mock-server/services/file_service.py
Normal file
150
lsfx-mock-server/services/file_service.py
Normal 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,
|
||||
}
|
||||
40
lsfx-mock-server/services/statement_service.py
Normal file
40
lsfx-mock-server/services/statement_service.py
Normal 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,
|
||||
}
|
||||
57
lsfx-mock-server/services/token_service.py
Normal file
57
lsfx-mock-server/services/token_service.py
Normal 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)
|
||||
1
lsfx-mock-server/tests/__init__.py
Normal file
1
lsfx-mock-server/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Tests package
|
||||
34
lsfx-mock-server/tests/conftest.py
Normal file
34
lsfx-mock-server/tests/conftest.py
Normal 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",
|
||||
}
|
||||
1
lsfx-mock-server/tests/integration/__init__.py
Normal file
1
lsfx-mock-server/tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Integration tests package
|
||||
125
lsfx-mock-server/tests/integration/test_full_workflow.py
Normal file
125
lsfx-mock-server/tests/integration/test_full_workflow.py
Normal 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
|
||||
50
lsfx-mock-server/tests/test_api.py
Normal file
50
lsfx-mock-server/tests/test_api.py
Normal 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
|
||||
1
lsfx-mock-server/utils/__init__.py
Normal file
1
lsfx-mock-server/utils/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Utils package
|
||||
49
lsfx-mock-server/utils/error_simulator.py
Normal file
49
lsfx-mock-server/utils/error_simulator.py
Normal 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
|
||||
69
lsfx-mock-server/utils/response_builder.py
Normal file
69
lsfx-mock-server/utils/response_builder.py
Normal 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
|
||||
)
|
||||
@@ -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 # 每个路由最大连接数
|
||||
@@ -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 {
|
||||
}
|
||||
35
ruoyi-ui/src/api/ccdiAssetInfo.js
Normal file
35
ruoyi-ui/src/api/ccdiAssetInfo.js
Normal 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 }
|
||||
})
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
44
ruoyi-ui/tests/unit/employee-asset-api-contract.test.js
Normal file
44
ruoyi-ui/tests/unit/employee-asset-api-contract.test.js
Normal 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
Reference in New Issue
Block a user