Compare commits

...

34 Commits

Author SHA1 Message Date
wkc
0bf73a923f 生产配置 2026-05-09 10:28:00 +08:00
wkc
ec67794f88 新增生产统一部署脚本 2026-05-08 13:32:07 +08:00
wkc
3ef45bc398 Fix PDF font loading for project overview reports 2026-05-08 10:51:42 +08:00
wkc
37e17ac903 新增专项排查图谱展示 2026-05-08 10:22:00 +08:00
wkc
d561d068d6 新增专项排查图谱前端实施计划 2026-05-07 18:53:00 +08:00
wkc
43bc0e4f65 新增专项排查图谱嵌入设计 2026-05-07 18:41:55 +08:00
wkc
3fe78d8d3a 展示员工亲属实体关联统信码 2026-05-07 09:20:06 +08:00
wkc
4c58966529 调整招聘信息毕业年月选择控件 2026-05-07 01:07:52 +08:00
wkc
3bc60fedeb 完善招聘信息主键关联与工作经历维护 2026-05-07 01:04:23 +08:00
wkc
4d1acc7484 招聘导入模板增加招聘类型下拉框 2026-05-07 00:13:00 +08:00
wkc
402a0c3e2f 修复导入模板格式和必填标记 2026-05-07 00:01:27 +08:00
wkc
5980ed0790 Update import templates and relation query fields 2026-05-06 23:37:32 +08:00
wkc
75cb8967da 回测四类导入自动补入实体库 2026-05-06 23:31:43 +08:00
wkc
90a5c42313 合并实体库自动补入与双Sheet导入修复 2026-05-06 20:53:29 +08:00
wkc
356bcdd6de 修复双Sheet资产单独导入任务ID 2026-05-06 20:50:09 +08:00
wkc
9a60371a8f uat配置更新 2026-05-06 20:33:40 +08:00
wkc
380f9b4e7a 移除.DS_Store跟踪 2026-05-06 20:33:01 +08:00
wkc
928f65dfca 修订: 中介实体补入无需机构名称 2026-05-06 18:30:03 +08:00
wkc
c64146ac40 调整信息维护页面并修复项目概览统计 2026-05-06 18:22:26 +08:00
wkc
0541ce0ac6 计划: 员工资产导入与实体库自动补入修复 2026-05-06 18:02:19 +08:00
wkc
26c639134e 修订: 明确资产Sheet单独导入规则 2026-05-06 17:29:32 +08:00
wkc
0f7b57e824 修订: 完善员工资产导入与实体库补入设计 2026-05-06 17:24:25 +08:00
wkc
104e8697fe 设计: 员工资产导入与实体库自动补入修复 2026-05-06 17:18:21 +08:00
wkc
bbc6a2050b 统一项目分析弹窗圆角样式 2026-05-06 17:03:55 +08:00
wkc
bf7a4c0538 Merge remote-tracking branch 'origin/dev-ui' into dev 2026-05-06 16:15:20 +08:00
wkc
b2e177dd24 修复流水上传原始文件名保持 2026-05-06 15:05:36 +08:00
wkc
2071d04c08 修复流水分析上传文件名传递 2026-05-06 14:49:47 +08:00
wkc
4988ab5944 设计: 保持上传流水原始文件名 2026-05-06 14:22:05 +08:00
wkc
c00d5475e6 Add import dropdown validation 2026-05-06 14:04:21 +08:00
wkc
0b64532959 新增导入下拉框校验实施计划 2026-04-30 16:58:34 +08:00
wkc
9f0ad4ce87 完善导入下拉框校验设计 2026-04-30 16:39:58 +08:00
wkc
75b5989774 新增导入下拉框校验设计文档 2026-04-30 16:36:52 +08:00
wkc
d8c069a836 uat配置文件 2026-04-30 09:36:34 +08:00
wkc
26be75adad 实现关联业务自动补入实体库 2026-04-26 17:23:47 +08:00
168 changed files with 9386 additions and 2588 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
.gitignore vendored
View File

@@ -84,6 +84,8 @@ logs/
ruoyi-ui/vue.config.js ruoyi-ui/vue.config.js
ruoyi-ui/dist.zip
*/src/test/ */src/test/
.pytest_cache/ .pytest_cache/

View File

@@ -51,6 +51,7 @@
"msg": "查询成功", "msg": "查询成功",
"rows": [ "rows": [
{ {
"id": 1002,
"recruitId": "REC20250205001", "recruitId": "REC20250205001",
"recruitName": "2025春季校园招聘", "recruitName": "2025春季校园招聘",
"posName": "Java开发工程师", "posName": "Java开发工程师",
@@ -80,19 +81,19 @@
### 1.2 查询招聘信息详情 ### 1.2 查询招聘信息详情
**接口描述:** 根据招聘项目编号查询详细信息 **接口描述:** 根据招聘信息主键ID查询详细信息
**请求方式:** `GET` **请求方式:** `GET`
**接口路径:** `/ccdi/staffRecruitment/{recruitId}` **接口路径:** `/ccdi/staffRecruitment/{id}`
**权限标识:** `ccdi:staffRecruitment:query` **权限标识:** `ccdi:staffRecruitment:query`
**路径参数:** **路径参数:**
| 参数名 | 类型 | 必填 | 说明 | 示例值 | | 参数名 | 类型 | 必填 | 说明 | 示例值 |
|-----------|--------|----|--------|----------------| |------|------|----|--------------|-----|
| recruitId | String | 是 | 招聘项目编号 | REC20250205001 | | id | Long | 是 | 招聘信息主键ID | 1002 |
**响应示例:** **响应示例:**
@@ -101,6 +102,7 @@
"code": 200, "code": 200,
"msg": "操作成功", "msg": "操作成功",
"data": { "data": {
"id": 1002,
"recruitId": "REC20250205001", "recruitId": "REC20250205001",
"recruitName": "2025春季校园招聘", "recruitName": "2025春季校园招聘",
"posName": "Java开发工程师", "posName": "Java开发工程师",
@@ -237,15 +239,15 @@
**请求方式:** `DELETE` **请求方式:** `DELETE`
**接口路径:** `/ccdi/staffRecruitment/{recruitIds}` **接口路径:** `/ccdi/staffRecruitment/{ids}`
**权限标识:** `ccdi:staffRecruitment:remove` **权限标识:** `ccdi:staffRecruitment:remove`
**路径参数:** **路径参数:**
| 参数名 | 类型 | 必填 | 说明 | 示例值 | | 参数名 | 类型 | 必填 | 说明 | 示例值 |
|------------|----------|----|------------------|-------------------------------| |------|------|----|-----------------------|----------|
| recruitIds | String[] | 是 | 招聘项目编号数组,多个用逗号分隔 | REC20250205001,REC20250205002 | | ids | Long[] | 是 | 招聘信息主键ID数组,多个用逗号分隔 | 1002,1003 |
**响应示例:** **响应示例:**
@@ -276,7 +278,7 @@
| 序号 | 字段名 | 说明 | 必填 | | 序号 | 字段名 | 说明 | 必填 |
|----|----------|-----------|----| |----|----------|-----------|----|
| 1 | 招聘项目编号 | 唯一标识 | 是 | | 1 | 招聘项目编号 | 允许重复 | 是 |
| 2 | 招聘项目名称 | - | 是 | | 2 | 招聘项目名称 | - | 是 |
| 3 | 职位名称 | - | 是 | | 3 | 职位名称 | - | 是 |
| 4 | 职位类别 | - | 是 | | 4 | 职位类别 | - | 是 |
@@ -326,7 +328,7 @@
```json ```json
{ {
"code": 500, "code": 500,
"msg": "很抱歉,导入完成!成功 8 条,失败 2 条,错误如下:<br/>1、招聘项目编号 REC001 导入失败:该招聘项目编号已存在<br/>2、招聘项目编号 REC002 导入失败:证件号码格式不正确" "msg": "很抱歉,导入完成!成功 8 条,失败 2 条,错误如下:<br/>1、招聘项目编号 REC001 导入失败:历史工作经历匹配到多条招聘主信息<br/>2、招聘项目编号 REC002 导入失败:证件号码格式不正确"
} }
``` ```
@@ -375,14 +377,14 @@ Excel导入导出对象,使用EasyExcel注解。
| 401 | 未授权,请先登录 | | 401 | 未授权,请先登录 |
| 403 | 无权限访问 | | 403 | 无权限访问 |
| 404 | 资源不存在 | | 404 | 资源不存在 |
| 409 | 主键冲突 | | 409 | 数据冲突 |
| 500 | 服务器内部错误 | | 500 | 服务器内部错误 |
### 常见业务错误 ### 常见业务错误
| 错误信息 | 说明 | | 错误信息 | 说明 |
|------------|--------------------| |------------|--------------------|
| 该招聘项目编号已存在 | 新增时recruitId重复 | | 历史工作经历匹配到多条招聘主信息 | 招聘项目编号重复且候选人、项目名、职位名仍无法唯一匹配从表归属 |
| 招聘项目编号不能为空 | recruitId字段为空 | | 招聘项目编号不能为空 | recruitId字段为空 |
| 证件号码格式不正确 | 身份证号格式验证失败 | | 证件号码格式不正确 | 身份证号格式验证失败 |
| 毕业年月格式不正确 | candGrad不是YYYYMM格式 | | 毕业年月格式不正确 | candGrad不是YYYYMM格式 |

View File

@@ -1,22 +1,23 @@
4.员工招聘信息表ccdi_staff_recruitment,,,,,, 4.员工招聘信息表ccdi_staff_recruitment,,,,,,
序号,字段名,类型,默认值,是否可为空,是否主键,注释 序号,字段名,类型,默认值,是否可为空,是否主键,注释
1,recruit_id,VARCHAR(32),,,,招聘项目编号 1,id,BIGINT,,,,主键ID
2,recruit_name,VARCHAR(100),,,,招聘项目名称 2,recruit_id,VARCHAR(32),,,,招聘项目编号(允许重复)
3,pos_name,VARCHAR(100),,,,职位名称 3,recruit_name,VARCHAR(100),,,,招聘项目名称
4,pos_category,VARCHAR(50),,,,职位类别 4,pos_name,VARCHAR(100),,,,职位名称
5,pos_desc,TEXT,,,,职位描述 5,pos_category,VARCHAR(50),,,,职位类别
6,cand_name,VARCHAR(20),,,,应聘人员姓名 6,pos_desc,TEXT,,,,职位描述
7,cand_edu,VARCHAR(20),,,,应聘人员学历 7,cand_name,VARCHAR(20),,,,应聘人员姓名
8,cand_id,VARCHAR(18),,,,应聘人员证件号码 8,cand_edu,VARCHAR(20),,,,应聘人员学历
9,cand_school,VARCHAR(50),,,,应聘人员毕业院校 9,cand_id,VARCHAR(18),,,,应聘人员证件号码
10,cand_major,VARCHAR(30),,,,应聘人员专业 10,cand_school,VARCHAR(50),,,,应聘人员毕业院校
11,cand_grad,VARCHAR(6),,,,应聘人员毕业年月 11,cand_major,VARCHAR(30),,,,应聘人员专业
12,admit_status,VARCHAR(10),,,,记录录用情况:录用、未录用、放弃等 12,cand_grad,VARCHAR(6),,,,应聘人员毕业年月
13,interviewer_name1,VARCHAR(20),,,,面试官1姓名 13,admit_status,VARCHAR(10),,,,记录录用情况:录用、未录用、放弃等
14,interviewer_id1,VARCHAR(10),,,,面试官1工号 14,interviewer_name1,VARCHAR(20),,,,面试官1姓名
13,interviewer_name2,VARCHAR(20),,,,面试官2姓名 15,interviewer_id1,VARCHAR(10),,,,面试官1工号
14,interviewer_id2,VARCHAR(10),,,,面试官2工号 16,interviewer_name2,VARCHAR(20),,,,面试官2姓名
16,created_by,VARCHAR(20),-,,,记录创建人 17,interviewer_id2,VARCHAR(10),,,,面试官2工号
17,updated_by,VARCHAR(20),-,,,记录更新 18,created_by,VARCHAR(20),-,,,记录创建
18,create_time,VARCHAR(10),0000-00-00,,,创建时间 19,updated_by,VARCHAR(20),-,,,记录更新人
19,update_time,VARCHAR(10),0000-00-00,,,更新时间 20,create_time,VARCHAR(10),0000-00-00,,,创建时间
21,update_time,VARCHAR(10),0000-00-00,,,更新时间
1 4.员工招聘信息表:ccdi_staff_recruitment
2 序号 字段名 类型 默认值 是否可为空 是否主键 注释
3 1 recruit_id id VARCHAR(32) BIGINT 招聘项目编号 主键ID
4 2 recruit_name recruit_id VARCHAR(100) VARCHAR(32) 招聘项目名称 招聘项目编号(允许重复)
5 3 pos_name recruit_name VARCHAR(100) 职位名称 招聘项目名称
6 4 pos_category pos_name VARCHAR(50) VARCHAR(100) 职位类别 职位名称
7 5 pos_desc pos_category TEXT VARCHAR(50) 职位描述 职位类别
8 6 cand_name pos_desc VARCHAR(20) TEXT 应聘人员姓名 职位描述
9 7 cand_edu cand_name VARCHAR(20) 应聘人员学历 应聘人员姓名
10 8 cand_id cand_edu VARCHAR(18) VARCHAR(20) 应聘人员证件号码 应聘人员学历
11 9 cand_school cand_id VARCHAR(50) VARCHAR(18) 应聘人员毕业院校 应聘人员证件号码
12 10 cand_major cand_school VARCHAR(30) VARCHAR(50) 应聘人员专业 应聘人员毕业院校
13 11 cand_grad cand_major VARCHAR(6) VARCHAR(30) 应聘人员毕业年月 应聘人员专业
14 12 admit_status cand_grad VARCHAR(10) VARCHAR(6) 记录录用情况:录用、未录用、放弃等 应聘人员毕业年月
15 13 interviewer_name1 admit_status VARCHAR(20) VARCHAR(10) 面试官1姓名 记录录用情况:录用、未录用、放弃等
16 14 interviewer_id1 interviewer_name1 VARCHAR(10) VARCHAR(20) 面试官1工号 面试官1姓名
17 13 15 interviewer_name2 interviewer_id1 VARCHAR(20) VARCHAR(10) 面试官2姓名 面试官1工号
18 14 16 interviewer_id2 interviewer_name2 VARCHAR(10) VARCHAR(20) 面试官2工号 面试官2姓名
19 16 17 created_by interviewer_id2 VARCHAR(20) VARCHAR(10) - 记录创建人 面试官2工号
20 17 18 updated_by created_by VARCHAR(20) - 记录更新人 记录创建人
21 18 19 create_time updated_by VARCHAR(10) VARCHAR(20) 0000-00-00 - 创建时间 记录更新人
22 19 20 update_time create_time VARCHAR(10) 0000-00-00 更新时间 创建时间
23 21 update_time VARCHAR(10) 0000-00-00 更新时间

3
assets/图谱.txt Normal file
View File

@@ -0,0 +1,3 @@
关系图谱http://64.202.65.112:8082/atlas/refactor/#/home/graph/downloadService?id=lanxitest&mode=K_EXPAND&type=NORMAL&atlasToken=2C914E5E1FBFBC4AD15163E0AB03B800&params={"vId":"rel_node/15942f5b84bada01ccd25f5e5678ac22"}
资金流图谱http://64.202.65.112:8082/atlas/refactor/#/home/graph/downloadService?id=ccdi_lanxi_trans&mode=K_EXPAND&type=NORMAL&atlasToken=F4BBA291A285858BAF4526C6EC312388&params={"vId":"idno_node/f2f797081494c5c0555a3bbf0f57c5e7"}

View File

@@ -7,7 +7,6 @@ import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel; import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel; import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import com.ruoyi.info.collection.domain.vo.*; import com.ruoyi.info.collection.domain.vo.*;
import com.ruoyi.info.collection.service.ICcdiBaseStaffAssetImportService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffImportService; import com.ruoyi.info.collection.service.ICcdiBaseStaffImportService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffService; import com.ruoyi.info.collection.service.ICcdiBaseStaffService;
import com.ruoyi.info.collection.utils.EasyExcelUtil; import com.ruoyi.info.collection.utils.EasyExcelUtil;
@@ -47,9 +46,6 @@ public class CcdiBaseStaffController extends BaseController {
@Resource @Resource
private ICcdiBaseStaffImportService importAsyncService; private ICcdiBaseStaffImportService importAsyncService;
@Resource
private ICcdiBaseStaffAssetImportService baseStaffAssetImportService;
/** /**
* 查询员工列表 * 查询员工列表
*/ */
@@ -161,14 +157,7 @@ public class CcdiBaseStaffController extends BaseController {
return error("至少需要一条数据"); return error("至少需要一条数据");
} }
BaseStaffImportSubmitResultVO result = new BaseStaffImportSubmitResultVO(); BaseStaffImportSubmitResultVO result = baseStaffService.importBaseStaffWithAssets(staffList, assetList);
if (hasStaffRows) {
result.setStaffTaskId(baseStaffService.importBaseStaff(staffList));
}
if (hasAssetRows) {
result.setAssetTaskId(baseStaffAssetImportService.importAssetInfo(assetList));
}
result.setMessage(buildImportSubmitMessage(hasStaffRows, hasAssetRows));
return AjaxResult.success("导入任务已提交,正在后台处理", result); return AjaxResult.success("导入任务已提交,正在后台处理", result);
} }
@@ -215,13 +204,4 @@ public class CcdiBaseStaffController extends BaseController {
return getDataTable(pageData, failures.size()); return getDataTable(pageData, failures.size());
} }
private String buildImportSubmitMessage(boolean hasStaffRows, boolean hasAssetRows) {
if (hasStaffRows && hasAssetRows) {
return "已提交员工信息和员工资产信息导入任务";
}
if (hasStaffRows) {
return "已提交员工信息导入任务";
}
return "已提交员工资产信息导入任务";
}
} }

View File

@@ -10,7 +10,6 @@ import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportFailureVO; import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportFailureVO;
import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportSubmitResultVO; import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportSubmitResultVO;
import com.ruoyi.info.collection.service.ICcdiAssetInfoImportService;
import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationImportService; import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationImportService;
import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationService; import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationService;
import com.ruoyi.info.collection.utils.EasyExcelUtil; import com.ruoyi.info.collection.utils.EasyExcelUtil;
@@ -51,9 +50,6 @@ public class CcdiStaffFmyRelationController extends BaseController {
@Resource @Resource
private ICcdiStaffFmyRelationImportService relationImportService; private ICcdiStaffFmyRelationImportService relationImportService;
@Resource
private ICcdiAssetInfoImportService assetInfoImportService;
/** /**
* 查询员工亲属关系列表 * 查询员工亲属关系列表
*/ */
@@ -157,15 +153,7 @@ public class CcdiStaffFmyRelationController extends BaseController {
return error("至少需要一条数据"); return error("至少需要一条数据");
} }
StaffFmyRelationImportSubmitResultVO result = new StaffFmyRelationImportSubmitResultVO(); StaffFmyRelationImportSubmitResultVO result = relationService.importRelationWithAssets(relationList, assetList);
if (hasRelationRows) {
result.setRelationTaskId(relationService.importRelation(relationList));
}
if (hasAssetRows) {
result.setAssetTaskId(assetInfoImportService.importAssetInfo(assetList));
}
result.setMessage(buildImportSubmitMessage(hasRelationRows, hasAssetRows));
return AjaxResult.success("导入任务已提交,正在后台处理", result); return AjaxResult.success("导入任务已提交,正在后台处理", result);
} }
@@ -211,13 +199,4 @@ public class CcdiStaffFmyRelationController extends BaseController {
return getDataTable(pageData, failures.size()); return getDataTable(pageData, failures.size());
} }
private String buildImportSubmitMessage(boolean hasRelationRows, boolean hasAssetRows) {
if (hasRelationRows && hasAssetRows) {
return "已提交员工亲属关系和亲属资产信息导入任务";
}
if (hasRelationRows) {
return "已提交员工亲属关系导入任务";
}
return "已提交亲属资产信息导入任务";
}
} }

View File

@@ -69,9 +69,9 @@ public class CcdiStaffRecruitmentController extends BaseController {
*/ */
@Operation(summary = "获取招聘信息详细信息") @Operation(summary = "获取招聘信息详细信息")
@PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:query')") @PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:query')")
@GetMapping(value = "/{recruitId}") @GetMapping(value = "/{id}")
public AjaxResult getInfo(@PathVariable String recruitId) { public AjaxResult getInfo(@PathVariable Long id) {
return success(recruitmentService.selectRecruitmentById(recruitId)); return success(recruitmentService.selectRecruitmentById(id));
} }
/** /**
@@ -102,9 +102,9 @@ public class CcdiStaffRecruitmentController extends BaseController {
@Operation(summary = "删除招聘信息") @Operation(summary = "删除招聘信息")
@PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:remove')") @PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:remove')")
@Log(title = "员工招聘信息", businessType = BusinessType.DELETE) @Log(title = "员工招聘信息", businessType = BusinessType.DELETE)
@DeleteMapping("/{recruitIds}") @DeleteMapping("/{ids}")
public AjaxResult remove(@PathVariable String[] recruitIds) { public AjaxResult remove(@PathVariable Long[] ids) {
return toAjax(recruitmentService.deleteRecruitmentByIds(recruitIds)); return toAjax(recruitmentService.deleteRecruitmentByIds(ids));
} }
/** /**

View File

@@ -94,6 +94,6 @@ public class CcdiEnterpriseBaseInfo implements Serializable {
/** 风险等级1-高风险, 2-中风险, 3-低风险 */ /** 风险等级1-高风险, 2-中风险, 3-低风险 */
private String riskLevel; private String riskLevel;
/** 企业来源GENERAL-一般企业, EMP_RELATION-员工关系人, CREDIT_CUSTOMER-信贷客户, INTERMEDIARY-中介, BOTH-兼有 */ /** 企业来源GENERAL-一般企业, EMP_RELATION-员工关系人, CREDIT_CUSTOMER-信贷客户, SUPPLIER-供应商, INTERMEDIARY-中介, BOTH-兼有 */
private String entSource; private String entSource;
} }

View File

@@ -22,8 +22,11 @@ public class CcdiStaffRecruitment implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** 主键ID */
@TableId(type = IdType.AUTO)
private Long id;
/** 招聘记录编号 */ /** 招聘记录编号 */
@TableId(type = IdType.INPUT)
private String recruitId; private String recruitId;
/** 招聘项目名称 */ /** 招聘项目名称 */

View File

@@ -28,6 +28,9 @@ public class CcdiStaffRecruitmentWork implements Serializable {
@TableId(type = IdType.AUTO) @TableId(type = IdType.AUTO)
private Long id; private Long id;
/** 关联招聘信息主键ID */
private Long recruitmentId;
/** 关联招聘记录编号 */ /** 关联招聘记录编号 */
private String recruitId; private String recruitId;

View File

@@ -33,6 +33,10 @@ public class CcdiCustFmyRelationQueryDTO implements Serializable {
@Schema(description = "关系人姓名") @Schema(description = "关系人姓名")
private String relationName; private String relationName;
/** 关系人身份证号 */
@Schema(description = "关系人身份证号")
private String relationCertNo;
/** 状态 */ /** 状态 */
@Schema(description = "状态0-无效1-有效") @Schema(description = "状态0-无效1-有效")
private Integer status; private Integer status;

View File

@@ -37,6 +37,10 @@ public class CcdiStaffFmyRelationQueryDTO implements Serializable {
@Schema(description = "关系人姓名") @Schema(description = "关系人姓名")
private String relationName; private String relationName;
/** 关系人身份证号 */
@Schema(description = "关系人身份证号")
private String relationCertNo;
/** 状态 */ /** 状态 */
@Schema(description = "状态0-无效1-有效") @Schema(description = "状态0-无效1-有效")
private Integer status; private Integer status;

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.domain.dto;
import com.ruoyi.info.collection.annotation.EnumValid; import com.ruoyi.info.collection.annotation.EnumValid;
import com.ruoyi.info.collection.enums.AdmitStatus; import com.ruoyi.info.collection.enums.AdmitStatus;
import com.ruoyi.info.collection.enums.RecruitType; import com.ruoyi.info.collection.enums.RecruitType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size; import jakarta.validation.constraints.Size;
@@ -10,6 +11,7 @@ import lombok.Data;
import java.io.Serial; import java.io.Serial;
import java.io.Serializable; import java.io.Serializable;
import java.util.List;
/** /**
* 员工招聘信息新增DTO * 员工招聘信息新增DTO
@@ -102,4 +104,8 @@ public class CcdiStaffRecruitmentAddDTO implements Serializable {
/** 面试官2工号 */ /** 面试官2工号 */
@Size(max = 10, message = "面试官2工号长度不能超过10个字符") @Size(max = 10, message = "面试官2工号长度不能超过10个字符")
private String interviewerId2; private String interviewerId2;
/** 历史工作经历列表 */
@Valid
private List<CcdiStaffRecruitmentWorkEditDTO> workExperienceList;
} }

View File

@@ -26,8 +26,13 @@ public class CcdiStaffRecruitmentEditDTO implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** 主键ID */
@NotNull(message = "招聘信息ID不能为空")
private Long id;
/** 招聘记录编号 */ /** 招聘记录编号 */
@NotNull(message = "招聘记录编号不能为空") @NotBlank(message = "招聘记录编号不能为空")
@Size(max = 32, message = "招聘记录编号长度不能超过32个字符")
private String recruitId; private String recruitId;
/** 招聘项目名称 */ /** 招聘项目名称 */

View File

@@ -2,6 +2,7 @@ package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -25,6 +26,7 @@ public class CcdiAccountInfoExcel implements Serializable {
@ExcelProperty(value = "证件号*", index = 1) @ExcelProperty(value = "证件号*", index = 1)
@ColumnWidth(24) @ColumnWidth(24)
@TextFormat
private String ownerId; private String ownerId;
@ExcelProperty(value = "账户姓名*", index = 2) @ExcelProperty(value = "账户姓名*", index = 2)
@@ -33,6 +35,7 @@ public class CcdiAccountInfoExcel implements Serializable {
@ExcelProperty(value = "账户号码*", index = 3) @ExcelProperty(value = "账户号码*", index = 3)
@ColumnWidth(28) @ColumnWidth(28)
@TextFormat
private String accountNo; private String accountNo;
@ExcelProperty(value = "账户类型*", index = 4) @ExcelProperty(value = "账户类型*", index = 4)
@@ -49,6 +52,7 @@ public class CcdiAccountInfoExcel implements Serializable {
@ExcelProperty(value = "银行代码", index = 7) @ExcelProperty(value = "银行代码", index = 7)
@ColumnWidth(16) @ColumnWidth(16)
@TextFormat
private String bankCode; private String bankCode;
@ExcelProperty(value = "币种", index = 8) @ExcelProperty(value = "币种", index = 8)

View File

@@ -26,14 +26,14 @@ public class CcdiBaseStaffAssetInfoExcel implements Serializable {
/** 员工身份证号 */ /** 员工身份证号 */
@ExcelProperty(value = "员工身份证号*", index = 0) @ExcelProperty(value = "员工身份证号*", index = 0)
@ColumnWidth(22) @ColumnWidth(24)
@Required @Required
@TextFormat @TextFormat
private String personId; private String personId;
/** 资产大类 */ /** 资产大类 */
@ExcelProperty(value = "资产大类*", index = 1) @ExcelProperty(value = "资产大类*", index = 1)
@ColumnWidth(16) @ColumnWidth(18)
@Required @Required
private String assetMainType; private String assetMainType;
@@ -51,39 +51,39 @@ public class CcdiBaseStaffAssetInfoExcel implements Serializable {
/** 产权占比 */ /** 产权占比 */
@ExcelProperty(value = "产权占比", index = 4) @ExcelProperty(value = "产权占比", index = 4)
@ColumnWidth(12) @ColumnWidth(14)
private BigDecimal ownershipRatio; private BigDecimal ownershipRatio;
/** 购买/评估日期 */ /** 购买/评估日期 */
@ExcelProperty(value = "购买/评估日期", index = 5) @ExcelProperty(value = "购买/评估日期", index = 5)
@ColumnWidth(16) @ColumnWidth(20)
private Date purchaseEvalDate; private Date purchaseEvalDate;
/** 资产原值 */ /** 资产原值 */
@ExcelProperty(value = "资产原值", index = 6) @ExcelProperty(value = "资产原值", index = 6)
@ColumnWidth(16) @ColumnWidth(18)
private BigDecimal originalValue; private BigDecimal originalValue;
/** 当前估值 */ /** 当前估值 */
@ExcelProperty(value = "当前估值*", index = 7) @ExcelProperty(value = "当前估值*", index = 7)
@ColumnWidth(16) @ColumnWidth(18)
@Required @Required
private BigDecimal currentValue; private BigDecimal currentValue;
/** 估值截止日期 */ /** 估值截止日期 */
@ExcelProperty(value = "估值截止日期", index = 8) @ExcelProperty(value = "估值截止日期", index = 8)
@ColumnWidth(16) @ColumnWidth(20)
private Date valuationDate; private Date valuationDate;
/** 资产状态 */ /** 资产状态 */
@ExcelProperty(value = "资产状态*", index = 9) @ExcelProperty(value = "资产状态*", index = 9)
@ColumnWidth(14) @ColumnWidth(16)
@DictDropdown(dictType = "ccdi_asset_status") @DictDropdown(dictType = "ccdi_asset_status")
@Required @Required
private String assetStatus; private String assetStatus;
/** 备注 */ /** 备注 */
@ExcelProperty(value = "备注", index = 10) @ExcelProperty(value = "备注", index = 10)
@ColumnWidth(28) @ColumnWidth(32)
private String remarks; private String remarks;
} }

View File

@@ -4,6 +4,7 @@ import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.DictDropdown; import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.annotation.Required; import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -25,54 +26,56 @@ public class CcdiBaseStaffExcel implements Serializable {
/** 姓名 */ /** 姓名 */
@ExcelProperty(value = "姓名", index = 0) @ExcelProperty(value = "姓名", index = 0)
@ColumnWidth(15) @ColumnWidth(16)
@Required @Required
private String name; private String name;
/** 员工ID */ /** 员工ID */
@ExcelProperty(value = "员工ID", index = 1) @ExcelProperty(value = "员工ID", index = 1)
@ColumnWidth(15) @ColumnWidth(18)
@Required @Required
private Long staffId; private Long staffId;
/** 所属部门ID */ /** 所属部门ID */
@ExcelProperty(value = "所属部门ID", index = 2) @ExcelProperty(value = "所属部门ID", index = 2)
@ColumnWidth(15) @ColumnWidth(20)
@Required @Required
private Long deptId; private Long deptId;
/** 身份证号 */ /** 身份证号 */
@ExcelProperty(value = "身份证号", index = 3) @ExcelProperty(value = "身份证号", index = 3)
@ColumnWidth(20) @ColumnWidth(24)
@Required @Required
@TextFormat
private String idCard; private String idCard;
/** 电话 */ /** 电话 */
@ExcelProperty(value = "电话", index = 4) @ExcelProperty(value = "电话", index = 4)
@ColumnWidth(15) @ColumnWidth(18)
@Required @Required
@TextFormat
private String phone; private String phone;
/** 年收入 */ /** 年收入 */
@ExcelProperty(value = "年收入(元/年)", index = 5) @ExcelProperty(value = "年收入(元/年)", index = 5)
@ColumnWidth(18) @ColumnWidth(20)
private BigDecimal annualIncome; private BigDecimal annualIncome;
/** 入职时间 */ /** 入职时间 */
@ExcelProperty(value = "入职时间", index = 6) @ExcelProperty(value = "入职时间", index = 6)
@ColumnWidth(15) @ColumnWidth(18)
private Date hireDate; private Date hireDate;
/** 是否党员 */ /** 是否党员 */
@ExcelProperty(value = "是否党员", index = 7) @ExcelProperty(value = "是否党员", index = 7)
@ColumnWidth(12) @ColumnWidth(16)
@DictDropdown(dictType = "ccdi_yes_no_flag") @DictDropdown(dictType = "ccdi_yes_no_flag")
@Required @Required
private Integer partyMember; private Integer partyMember;
/** 状态 */ /** 状态 */
@ExcelProperty(value = "状态", index = 8) @ExcelProperty(value = "状态", index = 8)
@ColumnWidth(10) @ColumnWidth(14)
@DictDropdown(dictType = "ccdi_employee_status") @DictDropdown(dictType = "ccdi_employee_status")
@Required @Required
private String status; private String status;

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.Required; import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -26,6 +27,7 @@ public class CcdiCustEnterpriseRelationExcel implements Serializable {
@ExcelProperty(value = "身份证号", index = 0) @ExcelProperty(value = "身份证号", index = 0)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
@TextFormat
@Schema(description = "身份证号") @Schema(description = "身份证号")
private String personId; private String personId;
@@ -33,6 +35,7 @@ public class CcdiCustEnterpriseRelationExcel implements Serializable {
@ExcelProperty(value = "统一社会信用代码", index = 1) @ExcelProperty(value = "统一社会信用代码", index = 1)
@ColumnWidth(25) @ColumnWidth(25)
@Required @Required
@TextFormat
@Schema(description = "统一社会信用代码") @Schema(description = "统一社会信用代码")
private String socialCreditCode; private String socialCreditCode;

View File

@@ -4,6 +4,7 @@ import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.DictDropdown; import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.annotation.Required; import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -26,6 +27,7 @@ public class CcdiCustFmyRelationExcel implements Serializable {
@ExcelProperty(value = "信贷客户身份证号*", index = 0) @ExcelProperty(value = "信贷客户身份证号*", index = 0)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
@TextFormat
private String personId; private String personId;
/** 关系类型 */ /** 关系类型 */
@@ -63,16 +65,19 @@ public class CcdiCustFmyRelationExcel implements Serializable {
@ExcelProperty(value = "关系人证件号码*", index = 6) @ExcelProperty(value = "关系人证件号码*", index = 6)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
@TextFormat
private String relationCertNo; private String relationCertNo;
/** 手机号码1 */ /** 手机号码1 */
@ExcelProperty(value = "手机号码1", index = 7) @ExcelProperty(value = "手机号码1", index = 7)
@ColumnWidth(15) @ColumnWidth(15)
@TextFormat
private String mobilePhone1; private String mobilePhone1;
/** 手机号码2 */ /** 手机号码2 */
@ExcelProperty(value = "手机号码2", index = 8) @ExcelProperty(value = "手机号码2", index = 8)
@ColumnWidth(15) @ColumnWidth(15)
@TextFormat
private String mobilePhone2; private String mobilePhone2;
/** 微信名称1 */ /** 微信名称1 */

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.DictDropdown; import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -23,6 +24,7 @@ public class CcdiEnterpriseBaseInfoExcel implements Serializable {
@ExcelProperty(value = "统一社会信用代码*", index = 0) @ExcelProperty(value = "统一社会信用代码*", index = 0)
@ColumnWidth(24) @ColumnWidth(24)
@TextFormat
private String socialCreditCode; private String socialCreditCode;
@ExcelProperty(value = "企业名称*", index = 1) @ExcelProperty(value = "企业名称*", index = 1)
@@ -66,6 +68,7 @@ public class CcdiEnterpriseBaseInfoExcel implements Serializable {
@ExcelProperty(value = "法定代表人证件号码", index = 10) @ExcelProperty(value = "法定代表人证件号码", index = 10)
@ColumnWidth(24) @ColumnWidth(24)
@TextFormat
private String legalCertNo; private String legalCertNo;
@ExcelProperty(value = "股东1", index = 11) @ExcelProperty(value = "股东1", index = 11)

View File

@@ -2,6 +2,8 @@ package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -17,17 +19,21 @@ public class CcdiIntermediaryEnterpriseRelationExcel implements Serializable {
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** 中介本人证件号码 */ /** 中介本人证件号码 */
@ExcelProperty(value = "中介本人证件号码*", index = 0) @ExcelProperty(value = "中介本人证件号码", index = 0)
@ColumnWidth(24) @ColumnWidth(24)
@Required
@TextFormat
private String ownerPersonId; private String ownerPersonId;
/** 统一社会信用代码 */ /** 统一社会信用代码 */
@ExcelProperty(value = "统一社会信用代码*", index = 1) @ExcelProperty(value = "统一社会信用代码", index = 1)
@ColumnWidth(24) @ColumnWidth(24)
@Required
@TextFormat
private String socialCreditCode; private String socialCreditCode;
/** 关联职务 */ /** 关联职务 */
@ExcelProperty(value = "关联职务", index = 2) @ExcelProperty(value = "关联职务", index = 2)
@ColumnWidth(20) @ColumnWidth(20)
private String relationPersonPost; private String relationPersonPost;

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.DictDropdown; import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -29,6 +30,7 @@ public class CcdiIntermediaryEntityExcel implements Serializable {
/** 统一社会信用代码 */ /** 统一社会信用代码 */
@ExcelProperty(value = "统一社会信用代码*", index = 1) @ExcelProperty(value = "统一社会信用代码*", index = 1)
@ColumnWidth(20) @ColumnWidth(20)
@TextFormat
private String socialCreditCode; private String socialCreditCode;
/** 主体类型 */ /** 主体类型 */
@@ -77,6 +79,7 @@ public class CcdiIntermediaryEntityExcel implements Serializable {
/** 法定代表人证件号码 */ /** 法定代表人证件号码 */
@ExcelProperty(value = "法定代表人证件号码", index = 10) @ExcelProperty(value = "法定代表人证件号码", index = 10)
@ColumnWidth(20) @ColumnWidth(20)
@TextFormat
private String legalCertNo; private String legalCertNo;
/** 股东1 */ /** 股东1 */

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.DictDropdown; import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -52,11 +53,13 @@ public class CcdiIntermediaryPersonExcel implements Serializable {
/** 证件号码 */ /** 证件号码 */
@ExcelProperty(value = "证件号码*", index = 5) @ExcelProperty(value = "证件号码*", index = 5)
@ColumnWidth(20) @ColumnWidth(20)
@TextFormat
private String personId; private String personId;
/** 手机号码 */ /** 手机号码 */
@ExcelProperty(value = "手机号码", index = 6) @ExcelProperty(value = "手机号码", index = 6)
@ColumnWidth(15) @ColumnWidth(15)
@TextFormat
private String mobile; private String mobile;
/** 微信号 */ /** 微信号 */
@@ -77,6 +80,7 @@ public class CcdiIntermediaryPersonExcel implements Serializable {
/** 企业统一信用码 */ /** 企业统一信用码 */
@ExcelProperty(value = "企业统一信用码", index = 10) @ExcelProperty(value = "企业统一信用码", index = 10)
@ColumnWidth(20) @ColumnWidth(20)
@TextFormat
private String socialCreditCode; private String socialCreditCode;
/** 职位 */ /** 职位 */
@@ -87,6 +91,7 @@ public class CcdiIntermediaryPersonExcel implements Serializable {
/** 关联中介本人证件号码 */ /** 关联中介本人证件号码 */
@ExcelProperty(value = "关联中介本人证件号码", index = 12) @ExcelProperty(value = "关联中介本人证件号码", index = 12)
@ColumnWidth(24) @ColumnWidth(24)
@TextFormat
private String relatedNumId; private String relatedNumId;
/** 备注 */ /** 备注 */

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.Required; import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -26,6 +27,7 @@ public class CcdiPurchaseTransactionExcel implements Serializable {
@ExcelProperty(value = "采购事项ID", index = 0) @ExcelProperty(value = "采购事项ID", index = 0)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
@TextFormat
private String purchaseId; private String purchaseId;
/** 采购类别 */ /** 采购类别 */
@@ -138,6 +140,7 @@ public class CcdiPurchaseTransactionExcel implements Serializable {
@ExcelProperty(value = "申请人工号", index = 21) @ExcelProperty(value = "申请人工号", index = 21)
@ColumnWidth(15) @ColumnWidth(15)
@Required @Required
@TextFormat
private String applicantId; private String applicantId;
/** 申请人姓名 */ /** 申请人姓名 */
@@ -155,6 +158,7 @@ public class CcdiPurchaseTransactionExcel implements Serializable {
/** 采购负责人工号 */ /** 采购负责人工号 */
@ExcelProperty(value = "采购负责人工号", index = 24) @ExcelProperty(value = "采购负责人工号", index = 24)
@ColumnWidth(15) @ColumnWidth(15)
@TextFormat
private String purchaseLeaderId; private String purchaseLeaderId;
/** 采购负责人姓名 */ /** 采购负责人姓名 */

View File

@@ -4,6 +4,7 @@ import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.DictDropdown; import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.annotation.Required; import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -21,6 +22,7 @@ public class CcdiPurchaseTransactionSupplierExcel implements Serializable {
@ExcelProperty(value = "采购事项ID", index = 0) @ExcelProperty(value = "采购事项ID", index = 0)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
@TextFormat
private String purchaseId; private String purchaseId;
@ExcelProperty(value = "供应商名称", index = 1) @ExcelProperty(value = "供应商名称", index = 1)
@@ -30,6 +32,7 @@ public class CcdiPurchaseTransactionSupplierExcel implements Serializable {
@ExcelProperty(value = "供应商统一信用代码", index = 2) @ExcelProperty(value = "供应商统一信用代码", index = 2)
@ColumnWidth(25) @ColumnWidth(25)
@TextFormat
private String supplierUscc; private String supplierUscc;
@ExcelProperty(value = "供应商联系人", index = 3) @ExcelProperty(value = "供应商联系人", index = 3)
@@ -38,10 +41,12 @@ public class CcdiPurchaseTransactionSupplierExcel implements Serializable {
@ExcelProperty(value = "供应商联系电话", index = 4) @ExcelProperty(value = "供应商联系电话", index = 4)
@ColumnWidth(18) @ColumnWidth(18)
@TextFormat
private String contactPhone; private String contactPhone;
@ExcelProperty(value = "供应商银行账户", index = 5) @ExcelProperty(value = "供应商银行账户", index = 5)
@ColumnWidth(20) @ColumnWidth(20)
@TextFormat
private String supplierBankAccount; private String supplierBankAccount;
@ExcelProperty(value = "是否中标", index = 6) @ExcelProperty(value = "是否中标", index = 6)

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.Required; import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data; import lombok.Data;
@@ -26,6 +27,7 @@ public class CcdiStaffEnterpriseRelationExcel implements Serializable {
@ExcelProperty(value = "亲属身份证号", index = 0) @ExcelProperty(value = "亲属身份证号", index = 0)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
@TextFormat
@Schema(description = "亲属身份证号") @Schema(description = "亲属身份证号")
private String personId; private String personId;
@@ -33,6 +35,7 @@ public class CcdiStaffEnterpriseRelationExcel implements Serializable {
@ExcelProperty(value = "统一社会信用代码", index = 1) @ExcelProperty(value = "统一社会信用代码", index = 1)
@ColumnWidth(25) @ColumnWidth(25)
@Required @Required
@TextFormat
@Schema(description = "统一社会信用代码") @Schema(description = "统一社会信用代码")
private String socialCreditCode; private String socialCreditCode;

View File

@@ -28,6 +28,7 @@ public class CcdiStaffFmyRelationExcel implements Serializable {
@ExcelProperty(value = "员工身份证号*", index = 0) @ExcelProperty(value = "员工身份证号*", index = 0)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
@TextFormat
private String personId; private String personId;
/** 关系类型 */ /** 关系类型 */
@@ -71,11 +72,13 @@ public class CcdiStaffFmyRelationExcel implements Serializable {
/** 手机号码1 */ /** 手机号码1 */
@ExcelProperty(value = "手机号码1", index = 7) @ExcelProperty(value = "手机号码1", index = 7)
@ColumnWidth(15) @ColumnWidth(15)
@TextFormat
private String mobilePhone1; private String mobilePhone1;
/** 手机号码2 */ /** 手机号码2 */
@ExcelProperty(value = "手机号码2", index = 8) @ExcelProperty(value = "手机号码2", index = 8)
@ColumnWidth(15) @ColumnWidth(15)
@TextFormat
private String mobilePhone2; private String mobilePhone2;
/** 家庭成员年收入 */ /** 家庭成员年收入 */

View File

@@ -4,6 +4,7 @@ import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.DictDropdown; import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.annotation.Required; import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -21,10 +22,11 @@ public class CcdiStaffRecruitmentExcel implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** 招聘项目编号 */ /** 招聘记录编号 */
@ExcelProperty(value = "招聘项目编号", index = 0) @ExcelProperty(value = "招聘记录编号", index = 0)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
@TextFormat
private String recruitId; private String recruitId;
/** 招聘项目名称 */ /** 招聘项目名称 */
@@ -51,66 +53,76 @@ public class CcdiStaffRecruitmentExcel implements Serializable {
@Required @Required
private String posDesc; private String posDesc;
/** 应聘人员姓名 */
@ExcelProperty(value = "应聘人员姓名", index = 5)
@ColumnWidth(15)
@Required
private String candName;
/** 应聘人员学历 */
@ExcelProperty(value = "应聘人员学历", index = 6)
@ColumnWidth(15)
@Required
private String candEdu;
/** 应聘人员证件号码 */
@ExcelProperty(value = "应聘人员证件号码", index = 7)
@ColumnWidth(20)
@Required
private String candId;
/** 应聘人员毕业院校 */
@ExcelProperty(value = "应聘人员毕业院校", index = 8)
@ColumnWidth(20)
@Required
private String candSchool;
/** 应聘人员专业 */
@ExcelProperty(value = "应聘人员专业", index = 9)
@ColumnWidth(15)
@Required
private String candMajor;
/** 应聘人员毕业年月 */
@ExcelProperty(value = "应聘人员毕业年月", index = 10)
@ColumnWidth(15)
@Required
private String candGrad;
/** 录用情况 */ /** 录用情况 */
@ExcelProperty(value = "录用情况", index = 11) @ExcelProperty(value = "录用情况", index = 5)
@ColumnWidth(10) @ColumnWidth(10)
@DictDropdown(dictType = "ccdi_admit_status") @DictDropdown(dictType = "ccdi_admit_status")
@Required @Required
private String admitStatus; private String admitStatus;
/** 候选人姓名 */
@ExcelProperty(value = "候选人姓名", index = 6)
@ColumnWidth(15)
@Required
private String candName;
/** 招聘类型 */
@ExcelProperty(value = "招聘类型", index = 7)
@ColumnWidth(12)
@DictDropdown(dictType = "ccdi_recruit_type")
@Required
private String recruitType;
/** 应聘人员学历 */
@ExcelProperty(value = "学历", index = 8)
@ColumnWidth(15)
@Required
private String candEdu;
/** 应聘人员证件号码 */
@ExcelProperty(value = "证件号码", index = 9)
@ColumnWidth(20)
@Required
@TextFormat
private String candId;
/** 应聘人员毕业年月 */
@ExcelProperty(value = "毕业年月", index = 10)
@ColumnWidth(15)
@Required
private String candGrad;
/** 应聘人员毕业院校 */
@ExcelProperty(value = "毕业院校", index = 11)
@ColumnWidth(20)
@Required
private String candSchool;
/** 应聘人员专业 */
@ExcelProperty(value = "专业", index = 12)
@ColumnWidth(15)
@Required
private String candMajor;
/** 面试官1姓名 */ /** 面试官1姓名 */
@ExcelProperty(value = "面试官1姓名", index = 12) @ExcelProperty(value = "面试官1姓名", index = 13)
@ColumnWidth(15) @ColumnWidth(15)
private String interviewerName1; private String interviewerName1;
/** 面试官1工号 */ /** 面试官1工号 */
@ExcelProperty(value = "面试官1工号", index = 13) @ExcelProperty(value = "面试官1工号", index = 14)
@ColumnWidth(15) @ColumnWidth(15)
@TextFormat
private String interviewerId1; private String interviewerId1;
/** 面试官2姓名 */ /** 面试官2姓名 */
@ExcelProperty(value = "面试官2姓名", index = 14) @ExcelProperty(value = "面试官2姓名", index = 15)
@ColumnWidth(15) @ColumnWidth(15)
private String interviewerName2; private String interviewerName2;
/** 面试官2工号 */ /** 面试官2工号 */
@ExcelProperty(value = "面试官2工号", index = 15) @ExcelProperty(value = "面试官2工号", index = 16)
@ColumnWidth(15) @ColumnWidth(15)
@TextFormat
private String interviewerId2; private String interviewerId2;
} }

View File

@@ -3,6 +3,7 @@ package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth; import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.Required; import com.ruoyi.common.annotation.Required;
import com.ruoyi.common.annotation.TextFormat;
import lombok.Data; import lombok.Data;
import java.io.Serial; import java.io.Serial;
@@ -24,6 +25,7 @@ public class CcdiStaffRecruitmentWorkExcel implements Serializable {
@ExcelProperty(value = "招聘记录编号", index = 0) @ExcelProperty(value = "招聘记录编号", index = 0)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
@TextFormat
private String recruitId; private String recruitId;
/** 候选人姓名 */ /** 候选人姓名 */
@@ -61,20 +63,20 @@ public class CcdiStaffRecruitmentWorkExcel implements Serializable {
@ColumnWidth(18) @ColumnWidth(18)
private String departmentName; private String departmentName;
/** 岗位 */ /** 岗位名称 */
@ExcelProperty(value = "岗位", index = 7) @ExcelProperty(value = "岗位名称", index = 7)
@ColumnWidth(20) @ColumnWidth(20)
@Required @Required
private String positionName; private String positionName;
/** 入职年月 */ /** 入职年月 */
@ExcelProperty(value = "入职年月", index = 8) @ExcelProperty(value = "入职时间", index = 8)
@ColumnWidth(12) @ColumnWidth(12)
@Required @Required
private String jobStartMonth; private String jobStartMonth;
/** 离职年月 */ /** 离职年月 */
@ExcelProperty(value = "离职年月", index = 9) @ExcelProperty(value = "离职时间", index = 9)
@ColumnWidth(12) @ColumnWidth(12)
private String jobEndMonth; private String jobEndMonth;
@@ -83,8 +85,8 @@ public class CcdiStaffRecruitmentWorkExcel implements Serializable {
@ColumnWidth(30) @ColumnWidth(30)
private String departureReason; private String departureReason;
/** 工作内容 */ /** 主要工作内容 */
@ExcelProperty(value = "工作内容", index = 11) @ExcelProperty(value = "主要工作内容", index = 11)
@ColumnWidth(35) @ColumnWidth(35)
private String workContent; private String workContent;

View File

@@ -19,6 +19,9 @@ public class CcdiStaffRecruitmentVO implements Serializable {
@Serial @Serial
private static final long serialVersionUID = 1L; private static final long serialVersionUID = 1L;
/** 主键ID */
private Long id;
/** 招聘记录编号 */ /** 招聘记录编号 */
private String recruitId; private String recruitId;

View File

@@ -22,7 +22,7 @@ public class IntermediaryEnterpriseRelationImportFailureVO implements Serializab
@Schema(description = "统一社会信用代码") @Schema(description = "统一社会信用代码")
private String socialCreditCode; private String socialCreditCode;
@Schema(description = "关联职务") @Schema(description = "关联职务")
private String relationPersonPost; private String relationPersonPost;
@Schema(description = "备注") @Schema(description = "备注")

View File

@@ -10,6 +10,7 @@ public enum EnterpriseSource {
GENERAL("GENERAL", "一般企业"), GENERAL("GENERAL", "一般企业"),
EMP_RELATION("EMP_RELATION", "员工关系人"), EMP_RELATION("EMP_RELATION", "员工关系人"),
CREDIT_CUSTOMER("CREDIT_CUSTOMER", "信贷客户"), CREDIT_CUSTOMER("CREDIT_CUSTOMER", "信贷客户"),
SUPPLIER("SUPPLIER", "供应商"),
INTERMEDIARY("INTERMEDIARY", "中介"), INTERMEDIARY("INTERMEDIARY", "中介"),
BOTH("BOTH", "兼有"); BOTH("BOTH", "兼有");

View File

@@ -1,15 +1,28 @@
package com.ruoyi.info.collection.handler; package com.ruoyi.info.collection.handler;
import com.alibaba.excel.annotation.ExcelProperty; import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.write.handler.SheetWriteHandler; import com.alibaba.excel.metadata.Head;
import com.alibaba.excel.metadata.data.WriteCellData;
import com.alibaba.excel.write.handler.CellWriteHandler;
import com.alibaba.excel.write.metadata.holder.WriteTableHolder;
import com.alibaba.excel.write.metadata.holder.WriteSheetHolder; import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
import com.ruoyi.common.annotation.Required; import com.ruoyi.common.annotation.Required;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.apache.poi.ss.usermodel.*; import org.apache.poi.ss.usermodel.BorderStyle;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.Font;
import org.apache.poi.ss.usermodel.HorizontalAlignment;
import org.apache.poi.ss.usermodel.IndexedColors;
import org.apache.poi.ss.usermodel.VerticalAlignment;
import org.apache.poi.ss.usermodel.Workbook;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.util.*; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
/** /**
* EasyExcel必填字段标注处理器 * EasyExcel必填字段标注处理器
@@ -18,13 +31,18 @@ import java.util.*;
* @author ruoyi * @author ruoyi
*/ */
@Slf4j @Slf4j
public class RequiredFieldWriteHandler implements SheetWriteHandler { public class RequiredFieldWriteHandler implements CellWriteHandler {
/** /**
* 实体类Class对象 * 实体类Class对象
*/ */
private final Class<?> modelClass; private final Class<?> modelClass;
/**
* 必填字段列索引集合
*/
private final Set<Integer> requiredColumns;
/** /**
* 构造函数 * 构造函数
* *
@@ -32,39 +50,30 @@ public class RequiredFieldWriteHandler implements SheetWriteHandler {
*/ */
public RequiredFieldWriteHandler(Class<?> modelClass) { public RequiredFieldWriteHandler(Class<?> modelClass) {
this.modelClass = modelClass; this.modelClass = modelClass;
this.requiredColumns = parseRequiredFields();
} }
@Override @Override
public void afterSheetCreate(WriteWorkbookHolder writeWorkbookHolder, WriteSheetHolder writeSheetHolder) { public void afterCellDispose(WriteSheetHolder writeSheetHolder,
// 获取工作表 WriteTableHolder writeTableHolder,
Sheet sheet = writeSheetHolder.getSheet(); List<WriteCellData<?>> cellDataList,
Cell cell,
// 获取表头行第1行索引为0 Head head,
Row headerRow = sheet.getRow(0); Integer relativeRowIndex,
if (headerRow == null) { Boolean isHead) {
log.warn("表头行不存在,跳过必填字段标注"); if (!Boolean.TRUE.equals(isHead) || cell == null || !requiredColumns.contains(cell.getColumnIndex())) {
return; return;
} }
// 创建红色字体样式 Workbook workbook = cell.getSheet().getWorkbook();
Workbook workbook = writeWorkbookHolder.getWorkbook();
CellStyle redStyle = createRedFontStyle(workbook); CellStyle redStyle = createRedFontStyle(workbook);
// 解析实体类中的必填字段 String originalValue = cell.getStringCellValue();
Set<Integer> requiredColumns = parseRequiredFields(); if (originalValue != null && !originalValue.endsWith("*")) {
cell.setCellValue(originalValue + "*");
// 为必填字段的表头添加红色星号
for (Integer columnIndex : requiredColumns) {
Cell cell = headerRow.getCell(columnIndex);
if (cell != null) {
String originalValue = cell.getStringCellValue();
// 添加红色星号
cell.setCellValue(originalValue + "*");
// 应用红色样式到星号
cell.setCellStyle(redStyle);
log.info("为列[{}]的表头添加必填标记(*)", columnIndex);
}
} }
cell.setCellStyle(redStyle);
log.info("为列[{}]的表头添加必填标记(*)", cell.getColumnIndex());
} }
/** /**

View File

@@ -30,10 +30,10 @@ public interface CcdiStaffRecruitmentMapper extends BaseMapper<CcdiStaffRecruitm
/** /**
* 查询招聘信息详情 * 查询招聘信息详情
* *
* @param recruitId 招聘项目编号 * @param id 主键ID
* @return 招聘信息VO * @return 招聘信息VO
*/ */
CcdiStaffRecruitmentVO selectRecruitmentById(@Param("recruitId") String recruitId); CcdiStaffRecruitmentVO selectRecruitmentById(@Param("id") Long id);
/** /**
* 批量插入招聘信息数据 * 批量插入招聘信息数据

View File

@@ -5,6 +5,8 @@ import com.ruoyi.info.collection.domain.vo.AssetImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
/** /**
* 亲属资产信息异步导入 服务层 * 亲属资产信息异步导入 服务层
@@ -31,6 +33,19 @@ public interface ICcdiAssetInfoImportService {
*/ */
void importAssetInfoAsync(List<CcdiAssetInfoExcel> excelList, String taskId, String userName); void importAssetInfoAsync(List<CcdiAssetInfoExcel> excelList, String taskId, String userName);
/**
* 同步执行亲属资产导入可附加同一文件亲属关系Sheet成功导入的归属映射
*
* @param excelList Excel实体列表
* @param taskId 任务ID
* @param userName 用户名
* @param extraOwnerMappings 附加归属映射key为亲属证件号value为归属员工证件号集合
*/
void importAssetInfoSync(List<CcdiAssetInfoExcel> excelList,
String taskId,
String userName,
Map<String, Set<String>> extraOwnerMappings);
/** /**
* 查询导入状态 * 查询导入状态
* *

View File

@@ -5,6 +5,8 @@ import com.ruoyi.info.collection.domain.vo.BaseStaffAssetImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
/** /**
* 员工资产信息异步导入 服务层 * 员工资产信息异步导入 服务层
@@ -31,6 +33,19 @@ public interface ICcdiBaseStaffAssetImportService {
*/ */
void importAssetInfoAsync(List<CcdiBaseStaffAssetInfoExcel> excelList, String taskId, String userName); void importAssetInfoAsync(List<CcdiBaseStaffAssetInfoExcel> excelList, String taskId, String userName);
/**
* 同步执行员工资产导入可附加同一文件员工Sheet成功导入的归属映射
*
* @param excelList Excel实体列表
* @param taskId 任务ID
* @param userName 用户名
* @param extraOwnerMappings 附加归属映射key为资产持有人证件号value为归属员工证件号集合
*/
void importAssetInfoSync(List<CcdiBaseStaffAssetInfoExcel> excelList,
String taskId,
String userName,
Map<String, Set<String>> extraOwnerMappings);
/** /**
* 查询导入状态 * 查询导入状态
* *

View File

@@ -5,6 +5,7 @@ import com.ruoyi.info.collection.domain.vo.ImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import java.util.List; import java.util.List;
import java.util.Set;
/** /**
* @Author: wkc * @Author: wkc
@@ -19,6 +20,15 @@ public interface ICcdiBaseStaffImportService {
*/ */
void importBaseStaffAsync(List<CcdiBaseStaffExcel> excelList, String taskId); void importBaseStaffAsync(List<CcdiBaseStaffExcel> excelList, String taskId);
/**
* 同步执行员工导入并返回本轮成功员工身份证号
*
* @param excelList Excel数据列表
* @param taskId 任务ID
* @return 成功导入的身份证号集合
*/
Set<String> importBaseStaffSync(List<CcdiBaseStaffExcel> excelList, String taskId);
/** /**
* 查询导入状态 * 查询导入状态
* *

View File

@@ -4,7 +4,9 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffQueryDTO; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel; import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import com.ruoyi.info.collection.domain.vo.BaseStaffImportSubmitResultVO;
import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffOptionVO; import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffOptionVO;
import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffVO; import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffVO;
@@ -83,6 +85,16 @@ public interface ICcdiBaseStaffService {
*/ */
String importBaseStaff(List<CcdiBaseStaffExcel> excelList); String importBaseStaff(List<CcdiBaseStaffExcel> excelList);
/**
* 导入员工信息和员工资产双Sheet数据
*
* @param staffList 员工信息Sheet
* @param assetList 员工资产Sheet
* @return 提交结果
*/
BaseStaffImportSubmitResultVO importBaseStaffWithAssets(List<CcdiBaseStaffExcel> staffList,
List<CcdiBaseStaffAssetInfoExcel> assetList);
/** /**
* 查询员工下拉列表 * 查询员工下拉列表
* 支持按员工ID或姓名模糊搜索只返回在职员工 * 支持按员工ID或姓名模糊搜索只返回在职员工

View File

@@ -5,6 +5,7 @@ import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportFailureVO; import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportFailureVO;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 员工亲属关系异步导入 服务层 * 员工亲属关系异步导入 服务层
@@ -23,6 +24,16 @@ public interface ICcdiStaffFmyRelationImportService {
*/ */
void importRelationAsync(List<CcdiStaffFmyRelationExcel> excelList, String taskId, String userName); void importRelationAsync(List<CcdiStaffFmyRelationExcel> excelList, String taskId, String userName);
/**
* 同步执行员工亲属关系导入并返回本轮成功关系映射
*
* @param excelList Excel实体列表
* @param taskId 任务ID
* @param userName 用户名
* @return key为亲属证件号value为归属员工证件号
*/
Map<String, String> importRelationSync(List<CcdiStaffFmyRelationExcel> excelList, String taskId, String userName);
/** /**
* 查询导入失败记录 * 查询导入失败记录
* *

View File

@@ -4,8 +4,10 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationQueryDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel;
import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO;
import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportSubmitResultVO;
import java.util.List; import java.util.List;
@@ -81,4 +83,14 @@ public interface ICcdiStaffFmyRelationService {
* @return 任务ID * @return 任务ID
*/ */
String importRelation(List<CcdiStaffFmyRelationExcel> excelList); String importRelation(List<CcdiStaffFmyRelationExcel> excelList);
/**
* 导入员工亲属关系和亲属资产双Sheet数据
*
* @param relationList 员工亲属关系Sheet
* @param assetList 亲属资产Sheet
* @return 提交结果
*/
StaffFmyRelationImportSubmitResultVO importRelationWithAssets(List<CcdiStaffFmyRelationExcel> relationList,
List<CcdiAssetInfoExcel> assetList);
} }

View File

@@ -46,10 +46,10 @@ public interface ICcdiStaffRecruitmentService {
/** /**
* 查询招聘信息详情 * 查询招聘信息详情
* *
* @param recruitId 招聘项目编号 * @param id 主键ID
* @return 招聘信息VO * @return 招聘信息VO
*/ */
CcdiStaffRecruitmentVO selectRecruitmentById(String recruitId); CcdiStaffRecruitmentVO selectRecruitmentById(Long id);
/** /**
* 新增招聘信息 * 新增招聘信息
@@ -70,10 +70,10 @@ public interface ICcdiStaffRecruitmentService {
/** /**
* 批量删除招聘信息 * 批量删除招聘信息
* *
* @param recruitIds 需要删除的招聘项目编号 * @param ids 需要删除的招聘信息ID
* @return 结果 * @return 结果
*/ */
int deleteRecruitmentByIds(String[] recruitIds); int deleteRecruitmentByIds(Long[] ids);
/** /**
* 导入招聘信息数据 * 导入招聘信息数据

View File

@@ -82,6 +82,15 @@ public class CcdiAssetInfoImportServiceImpl implements ICcdiAssetInfoImportServi
@Async @Async
@Transactional @Transactional
public void importAssetInfoAsync(List<CcdiAssetInfoExcel> excelList, String taskId, String userName) { public void importAssetInfoAsync(List<CcdiAssetInfoExcel> excelList, String taskId, String userName) {
importAssetInfoSync(excelList, taskId, userName, Map.of());
}
@Override
@Transactional
public void importAssetInfoSync(List<CcdiAssetInfoExcel> excelList,
String taskId,
String userName,
Map<String, Set<String>> extraOwnerMappings) {
List<CcdiAssetInfo> successList = new ArrayList<>(); List<CcdiAssetInfo> successList = new ArrayList<>();
List<AssetImportFailureVO> failures = new ArrayList<>(); List<AssetImportFailureVO> failures = new ArrayList<>();
@@ -92,6 +101,7 @@ public class CcdiAssetInfoImportServiceImpl implements ICcdiAssetInfoImportServi
.toList(); .toList();
Map<String, Set<String>> ownerMap = buildOwnerMap(personIds); Map<String, Set<String>> ownerMap = buildOwnerMap(personIds);
mergeOwnerMappings(ownerMap, extraOwnerMappings);
for (int i = 0; i < excelList.size(); i++) { for (int i = 0; i < excelList.size(); i++) {
CcdiAssetInfoExcel excel = excelList.get(i); CcdiAssetInfoExcel excel = excelList.get(i);
@@ -189,6 +199,18 @@ public class CcdiAssetInfoImportServiceImpl implements ICcdiAssetInfoImportServi
} }
} }
private void mergeOwnerMappings(Map<String, Set<String>> result, Map<String, Set<String>> mappings) {
if (mappings == null || mappings.isEmpty()) {
return;
}
for (Map.Entry<String, Set<String>> entry : mappings.entrySet()) {
if (StringUtils.isEmpty(entry.getKey()) || entry.getValue() == null || entry.getValue().isEmpty()) {
continue;
}
result.computeIfAbsent(entry.getKey(), key -> new java.util.LinkedHashSet<>()).addAll(entry.getValue());
}
}
private void validateExcel(CcdiAssetInfoExcel excel) { private void validateExcel(CcdiAssetInfoExcel excel) {
if (StringUtils.isEmpty(excel.getPersonId())) { if (StringUtils.isEmpty(excel.getPersonId())) {
throw new RuntimeException("亲属证件号不能为空"); throw new RuntimeException("亲属证件号不能为空");

View File

@@ -81,6 +81,15 @@ public class CcdiBaseStaffAssetImportServiceImpl implements ICcdiBaseStaffAssetI
@Async @Async
@Transactional @Transactional
public void importAssetInfoAsync(List<CcdiBaseStaffAssetInfoExcel> excelList, String taskId, String userName) { public void importAssetInfoAsync(List<CcdiBaseStaffAssetInfoExcel> excelList, String taskId, String userName) {
importAssetInfoSync(excelList, taskId, userName, Map.of());
}
@Override
@Transactional
public void importAssetInfoSync(List<CcdiBaseStaffAssetInfoExcel> excelList,
String taskId,
String userName,
Map<String, Set<String>> extraOwnerMappings) {
List<CcdiAssetInfo> successList = new ArrayList<>(); List<CcdiAssetInfo> successList = new ArrayList<>();
List<BaseStaffAssetImportFailureVO> failures = new ArrayList<>(); List<BaseStaffAssetImportFailureVO> failures = new ArrayList<>();
@@ -91,6 +100,7 @@ public class CcdiBaseStaffAssetImportServiceImpl implements ICcdiBaseStaffAssetI
.toList(); .toList();
Map<String, Set<String>> ownerMap = buildOwnerMap(personIds); Map<String, Set<String>> ownerMap = buildOwnerMap(personIds);
mergeOwnerMappings(ownerMap, extraOwnerMappings);
Set<String> existingAssetKeys = buildExistingAssetKeys(personIds); Set<String> existingAssetKeys = buildExistingAssetKeys(personIds);
Set<String> importedAssetKeys = new java.util.LinkedHashSet<>(); Set<String> importedAssetKeys = new java.util.LinkedHashSet<>();
@@ -207,6 +217,18 @@ public class CcdiBaseStaffAssetImportServiceImpl implements ICcdiBaseStaffAssetI
} }
} }
private void mergeOwnerMappings(Map<String, Set<String>> result, Map<String, Set<String>> mappings) {
if (mappings == null || mappings.isEmpty()) {
return;
}
for (Map.Entry<String, Set<String>> entry : mappings.entrySet()) {
if (StringUtils.isEmpty(entry.getKey()) || entry.getValue() == null || entry.getValue().isEmpty()) {
continue;
}
result.computeIfAbsent(entry.getKey(), key -> new java.util.LinkedHashSet<>()).addAll(entry.getValue());
}
}
private void validateExcel(CcdiBaseStaffAssetInfoExcel excel) { private void validateExcel(CcdiBaseStaffAssetInfoExcel excel) {
if (StringUtils.isEmpty(excel.getPersonId())) { if (StringUtils.isEmpty(excel.getPersonId())) {
throw new RuntimeException("员工身份证号不能为空"); throw new RuntimeException("员工身份证号不能为空");

View File

@@ -23,6 +23,7 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.*; import java.util.*;
@@ -51,6 +52,12 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi
@Override @Override
@Async @Async
public void importBaseStaffAsync(List<CcdiBaseStaffExcel> excelList, String taskId) { public void importBaseStaffAsync(List<CcdiBaseStaffExcel> excelList, String taskId) {
importBaseStaffSync(excelList, taskId);
}
@Override
@Transactional
public Set<String> importBaseStaffSync(List<CcdiBaseStaffExcel> excelList, String taskId) {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
// 记录导入开始 // 记录导入开始
@@ -153,6 +160,11 @@ public class CcdiBaseStaffImportServiceImpl implements ICcdiBaseStaffImportServi
long duration = System.currentTimeMillis() - startTime; long duration = System.currentTimeMillis() - startTime;
ImportLogUtils.logImportComplete(log, taskId, "员工基础信息", ImportLogUtils.logImportComplete(log, taskId, "员工基础信息",
excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration);
return newRecords.stream()
.map(CcdiBaseStaff::getIdCard)
.filter(StringUtils::isNotEmpty)
.collect(Collectors.toCollection(LinkedHashSet::new));
} }
/** /**

View File

@@ -6,15 +6,19 @@ import com.ruoyi.info.collection.domain.CcdiBaseStaff;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffQueryDTO; import com.ruoyi.info.collection.domain.dto.CcdiBaseStaffQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel; import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import com.ruoyi.info.collection.domain.vo.BaseStaffImportSubmitResultVO;
import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO; import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO;
import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffOptionVO; import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffOptionVO;
import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffVO; import com.ruoyi.info.collection.domain.vo.CcdiBaseStaffVO;
import com.ruoyi.info.collection.enums.EmployeeStatus; import com.ruoyi.info.collection.enums.EmployeeStatus;
import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper; import com.ruoyi.info.collection.mapper.CcdiBaseStaffMapper;
import com.ruoyi.info.collection.service.ICcdiAssetInfoService; import com.ruoyi.info.collection.service.ICcdiAssetInfoService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffAssetImportService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffImportService; import com.ruoyi.info.collection.service.ICcdiBaseStaffImportService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffService; import com.ruoyi.info.collection.service.ICcdiBaseStaffService;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils; import org.springframework.beans.BeanUtils;
@@ -46,6 +50,12 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
@Resource @Resource
private ICcdiAssetInfoService assetInfoService; private ICcdiAssetInfoService assetInfoService;
@Resource
private ICcdiBaseStaffAssetImportService baseStaffAssetImportService;
@Resource
private CcdiDualSheetImportOrchestrationService dualSheetImportOrchestrationService;
/** /**
* 查询员工列表 * 查询员工列表
* *
@@ -218,28 +228,52 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
@Transactional @Transactional
public String importBaseStaff(List<CcdiBaseStaffExcel> excelList) { public String importBaseStaff(List<CcdiBaseStaffExcel> excelList) {
String taskId = UUID.randomUUID().toString(); String taskId = UUID.randomUUID().toString();
long startTime = System.currentTimeMillis(); initializeImportStatus("import:baseStaff:", taskId, excelList.size());
// 初始化Redis状态
String statusKey = "import:baseStaff:" + taskId;
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", startTime);
statusData.put("message", "正在处理...");
redisTemplate.opsForHash().putAll(statusKey, statusData);
redisTemplate.expire(statusKey, 7, java.util.concurrent.TimeUnit.DAYS);
importAsyncService.importBaseStaffAsync(excelList, taskId); importAsyncService.importBaseStaffAsync(excelList, taskId);
return taskId; return taskId;
} }
@Override
@Transactional
public BaseStaffImportSubmitResultVO importBaseStaffWithAssets(List<CcdiBaseStaffExcel> staffList,
List<CcdiBaseStaffAssetInfoExcel> assetList) {
boolean hasStaffRows = staffList != null && !staffList.isEmpty();
boolean hasAssetRows = assetList != null && !assetList.isEmpty();
if (!hasStaffRows && !hasAssetRows) {
throw new RuntimeException("至少需要一条数据");
}
BaseStaffImportSubmitResultVO result = new BaseStaffImportSubmitResultVO();
result.setMessage(buildImportSubmitMessage(hasStaffRows, hasAssetRows));
if (hasStaffRows && !hasAssetRows) {
result.setStaffTaskId(importBaseStaff(staffList));
return result;
}
if (!hasStaffRows) {
result.setAssetTaskId(baseStaffAssetImportService.importAssetInfo(assetList));
return result;
}
String staffTaskId = UUID.randomUUID().toString();
String assetTaskId = UUID.randomUUID().toString();
initializeImportStatus("import:baseStaff:", staffTaskId, staffList.size());
initializeImportStatus("import:baseStaffAsset:", assetTaskId, assetList.size());
result.setStaffTaskId(staffTaskId);
result.setAssetTaskId(assetTaskId);
dualSheetImportOrchestrationService.importBaseStaffWithAssetsAsync(
staffList,
staffTaskId,
assetList,
assetTaskId,
currentUserName()
);
return result;
}
/** /**
* 查询员工下拉列表 * 查询员工下拉列表
* 支持按员工ID或姓名模糊搜索只返回在职员工 * 支持按员工ID或姓名模糊搜索只返回在职员工
@@ -252,6 +286,40 @@ public class CcdiBaseStaffServiceImpl implements ICcdiBaseStaffService {
return baseStaffMapper.selectStaffOptions(query); return baseStaffMapper.selectStaffOptions(query);
} }
private void initializeImportStatus(String keyPrefix, String taskId, int totalCount) {
Map<String, Object> statusData = new HashMap<>();
statusData.put("taskId", taskId);
statusData.put("status", "PROCESSING");
statusData.put("totalCount", totalCount);
statusData.put("successCount", 0);
statusData.put("failureCount", 0);
statusData.put("progress", 0);
statusData.put("startTime", System.currentTimeMillis());
statusData.put("message", "正在处理...");
String statusKey = keyPrefix + taskId;
redisTemplate.opsForHash().putAll(statusKey, statusData);
redisTemplate.expire(statusKey, 7, java.util.concurrent.TimeUnit.DAYS);
}
private String buildImportSubmitMessage(boolean hasStaffRows, boolean hasAssetRows) {
if (hasStaffRows && hasAssetRows) {
return "已提交员工信息和员工资产信息导入任务";
}
if (hasStaffRows) {
return "已提交员工信息导入任务";
}
return "已提交员工资产信息导入任务";
}
private String currentUserName() {
try {
return SecurityUtils.getUsername();
} catch (Exception e) {
return "system";
}
}
/** /**
* 构建查询条件 * 构建查询条件
*/ */

View File

@@ -7,8 +7,11 @@ import com.ruoyi.info.collection.domain.excel.CcdiCustEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.CustEnterpriseRelationImportFailureVO; import com.ruoyi.info.collection.domain.vo.CustEnterpriseRelationImportFailureVO;
import com.ruoyi.info.collection.domain.vo.ImportResult; import com.ruoyi.info.collection.domain.vo.ImportResult;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.ICcdiCustEnterpriseRelationImportService; import com.ruoyi.info.collection.service.ICcdiCustEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import com.ruoyi.info.collection.utils.ImportLogUtils; import com.ruoyi.info.collection.utils.ImportLogUtils;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -43,6 +46,9 @@ public class CcdiCustEnterpriseRelationImportServiceImpl implements ICcdiCustEnt
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Resource
private EnterpriseAutoFillService enterpriseAutoFillService;
@Override @Override
@Async @Async
@Transactional @Transactional
@@ -127,6 +133,15 @@ public class CcdiCustEnterpriseRelationImportServiceImpl implements ICcdiCustEnt
// 批量插入新数据 // 批量插入新数据
if (!newRecords.isEmpty()) { if (!newRecords.isEmpty()) {
enterpriseAutoFillService.ensureExistsBatch(newRecords.stream()
.map(item -> new EnterpriseAutoFillService.EnterpriseFillItem(
item.getSocialCreditCode(),
item.getEnterpriseName(),
EnterpriseSource.CREDIT_CUSTOMER.getCode(),
DataSource.IMPORT.getCode(),
userName
))
.toList());
ImportLogUtils.logBatchOperationStart(log, taskId, "插入", ImportLogUtils.logBatchOperationStart(log, taskId, "插入",
(newRecords.size() + 499) / 500, 500); (newRecords.size() + 499) / 500, 500);
saveBatch(newRecords, 500); saveBatch(newRecords, 500);

View File

@@ -8,9 +8,12 @@ import com.ruoyi.info.collection.domain.dto.CcdiCustEnterpriseRelationEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiCustEnterpriseRelationQueryDTO; import com.ruoyi.info.collection.domain.dto.CcdiCustEnterpriseRelationQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiCustEnterpriseRelationExcel; import com.ruoyi.info.collection.domain.excel.CcdiCustEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.CcdiCustEnterpriseRelationVO; import com.ruoyi.info.collection.domain.vo.CcdiCustEnterpriseRelationVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.ICcdiCustEnterpriseRelationImportService; import com.ruoyi.info.collection.service.ICcdiCustEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.ICcdiCustEnterpriseRelationService; import com.ruoyi.info.collection.service.ICcdiCustEnterpriseRelationService;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -43,6 +46,9 @@ public class CcdiCustEnterpriseRelationServiceImpl implements ICcdiCustEnterpris
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Resource
private EnterpriseAutoFillService enterpriseAutoFillService;
/** /**
* 查询信贷客户实体关联列表 * 查询信贷客户实体关联列表
* *
@@ -135,6 +141,14 @@ public class CcdiCustEnterpriseRelationServiceImpl implements ICcdiCustEnterpris
relation.setDataSource("MANUAL"); relation.setDataSource("MANUAL");
} }
enterpriseAutoFillService.ensureExists(new EnterpriseAutoFillService.EnterpriseFillItem(
addDTO.getSocialCreditCode(),
addDTO.getEnterpriseName(),
EnterpriseSource.CREDIT_CUSTOMER.getCode(),
DataSource.MANUAL.getCode(),
SecurityUtils.getUsername()
));
int result = relationMapper.insert(relation); int result = relationMapper.insert(relation);
return result; return result;

View File

@@ -0,0 +1,90 @@
package com.ruoyi.info.collection.service.impl;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel;
import com.ruoyi.info.collection.service.ICcdiAssetInfoImportService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffAssetImportService;
import com.ruoyi.info.collection.service.ICcdiBaseStaffImportService;
import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationImportService;
import jakarta.annotation.Resource;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 双Sheet导入后台顺序编排。
*/
@Service
public class CcdiDualSheetImportOrchestrationService {
@Resource
private ICcdiBaseStaffImportService baseStaffImportService;
@Resource
private ICcdiBaseStaffAssetImportService baseStaffAssetImportService;
@Resource
private ICcdiStaffFmyRelationImportService relationImportService;
@Resource
private ICcdiAssetInfoImportService assetInfoImportService;
@Async
public void importBaseStaffWithAssetsAsync(List<CcdiBaseStaffExcel> staffList,
String staffTaskId,
List<CcdiBaseStaffAssetInfoExcel> assetList,
String assetTaskId,
String userName) {
Set<String> successIdCards = baseStaffImportService.importBaseStaffSync(staffList, staffTaskId);
baseStaffAssetImportService.importAssetInfoSync(
assetList,
assetTaskId,
userName,
buildSelfOwnerMappings(successIdCards)
);
}
@Async
public void importRelationWithAssetsAsync(List<CcdiStaffFmyRelationExcel> relationList,
String relationTaskId,
List<CcdiAssetInfoExcel> assetList,
String assetTaskId,
String userName) {
Map<String, String> successRelationMappings = relationImportService.importRelationSync(relationList, relationTaskId, userName);
assetInfoImportService.importAssetInfoSync(
assetList,
assetTaskId,
userName,
buildRelationOwnerMappings(successRelationMappings)
);
}
private Map<String, Set<String>> buildSelfOwnerMappings(Set<String> idCards) {
Map<String, Set<String>> result = new LinkedHashMap<>();
if (idCards == null || idCards.isEmpty()) {
return result;
}
for (String idCard : idCards) {
result.computeIfAbsent(idCard, key -> new LinkedHashSet<>()).add(idCard);
}
return result;
}
private Map<String, Set<String>> buildRelationOwnerMappings(Map<String, String> relationMappings) {
Map<String, Set<String>> result = new LinkedHashMap<>();
if (relationMappings == null || relationMappings.isEmpty()) {
return result;
}
for (Map.Entry<String, String> entry : relationMappings.entrySet()) {
result.computeIfAbsent(entry.getKey(), key -> new LinkedHashSet<>()).add(entry.getValue());
}
return result;
}
}

View File

@@ -131,14 +131,18 @@ public class CcdiEnterpriseBaseInfoImportServiceImpl implements ICcdiEnterpriseB
if (!excel.getSocialCreditCode().matches("^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$")) { if (!excel.getSocialCreditCode().matches("^[0-9A-HJ-NPQRTUWXY]{2}\\d{6}[0-9A-HJ-NPQRTUWXY]{10}$")) {
throw new RuntimeException("统一社会信用代码格式不正确"); throw new RuntimeException("统一社会信用代码格式不正确");
} }
String riskLevel = EnterpriseRiskLevel.resolveCode(StringUtils.trim(excel.getRiskLevel()));
if (riskLevel == null) {
throw new RuntimeException("风险等级不在允许范围内");
}
String entSource = EnterpriseSource.resolveCode(StringUtils.trim(excel.getEntSource())); String entSource = EnterpriseSource.resolveCode(StringUtils.trim(excel.getEntSource()));
if (entSource == null) { if (entSource == null) {
throw new RuntimeException("企业来源不在允许范围内"); throw new RuntimeException("企业来源不在允许范围内");
} }
String riskLevel = EnterpriseRiskLevel.resolveCode(StringUtils.trim(excel.getRiskLevel()));
if (riskLevel == null) {
if (EnterpriseSource.INTERMEDIARY.getCode().equals(entSource) && StringUtils.isEmpty(excel.getRiskLevel())) {
riskLevel = "1";
} else {
throw new RuntimeException("风险等级不在允许范围内");
}
}
if (existingCreditCodes.contains(excel.getSocialCreditCode())) { if (existingCreditCodes.contains(excel.getSocialCreditCode())) {
throw new RuntimeException(String.format("统一社会信用代码[%s]已存在,请勿重复导入", excel.getSocialCreditCode())); throw new RuntimeException(String.format("统一社会信用代码[%s]已存在,请勿重复导入", excel.getSocialCreditCode()));

View File

@@ -3,16 +3,17 @@ package com.ruoyi.info.collection.service.impl;
import com.alibaba.fastjson2.JSON; import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.info.collection.domain.CcdiBizIntermediary; import com.ruoyi.info.collection.domain.CcdiBizIntermediary;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation; import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.ImportResult; import com.ruoyi.info.collection.domain.vo.ImportResult;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO; import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper; import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import com.ruoyi.info.collection.utils.ImportLogUtils; import com.ruoyi.info.collection.utils.ImportLogUtils;
import com.ruoyi.common.utils.IdCardUtil; import com.ruoyi.common.utils.IdCardUtil;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
@@ -54,10 +55,10 @@ public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcd
private CcdiBizIntermediaryMapper intermediaryMapper; private CcdiBizIntermediaryMapper intermediaryMapper;
@Resource @Resource
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper; private RedisTemplate<String, Object> redisTemplate;
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private EnterpriseAutoFillService enterpriseAutoFillService;
@Override @Override
@Async @Async
@@ -67,7 +68,6 @@ public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcd
ImportLogUtils.logImportStart(log, taskId, "中介实体关联关系", excelList.size(), userName); ImportLogUtils.logImportStart(log, taskId, "中介实体关联关系", excelList.size(), userName);
Map<String, String> ownerBizIdByPersonId = getOwnerBizIdByPersonId(excelList); Map<String, String> ownerBizIdByPersonId = getOwnerBizIdByPersonId(excelList);
Set<String> existingEnterpriseCodes = getExistingEnterpriseCodes(excelList);
Set<String> existingCombinations = getExistingRelationCombinations(ownerBizIdByPersonId, excelList); Set<String> existingCombinations = getExistingRelationCombinations(ownerBizIdByPersonId, excelList);
List<CcdiIntermediaryEnterpriseRelation> successRecords = new ArrayList<>(); List<CcdiIntermediaryEnterpriseRelation> successRecords = new ArrayList<>();
@@ -79,15 +79,14 @@ public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcd
try { try {
validateExcel(excel); validateExcel(excel);
String ownerBizId = ownerBizIdByPersonId.get(excel.getOwnerPersonId()); String ownerPersonId = trim(excel.getOwnerPersonId());
String socialCreditCode = trim(excel.getSocialCreditCode());
String ownerBizId = ownerBizIdByPersonId.get(ownerPersonId);
if (StringUtils.isEmpty(ownerBizId)) { if (StringUtils.isEmpty(ownerBizId)) {
throw new RuntimeException("中介本人不存在,请先导入或维护中介本人信息"); throw new RuntimeException("中介本人不存在,请先导入或维护中介本人信息");
} }
if (!existingEnterpriseCodes.contains(excel.getSocialCreditCode())) {
throw new RuntimeException("统一社会信用代码不存在于系统机构表");
}
String combination = ownerBizId + "|" + excel.getSocialCreditCode(); String combination = ownerBizId + "|" + socialCreditCode;
if (existingCombinations.contains(combination)) { if (existingCombinations.contains(combination)) {
throw new RuntimeException("中介实体关联关系已存在,请勿重复导入"); throw new RuntimeException("中介实体关联关系已存在,请勿重复导入");
} }
@@ -98,6 +97,9 @@ public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcd
CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation(); CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation();
BeanUtils.copyProperties(excel, relation); BeanUtils.copyProperties(excel, relation);
relation.setIntermediaryBizId(ownerBizId); relation.setIntermediaryBizId(ownerBizId);
relation.setSocialCreditCode(socialCreditCode);
relation.setRelationPersonPost(trim(excel.getRelationPersonPost()));
relation.setRemark(trim(excel.getRemark()));
relation.setCreatedBy(userName); relation.setCreatedBy(userName);
relation.setUpdatedBy(userName); relation.setUpdatedBy(userName);
successRecords.add(relation); successRecords.add(relation);
@@ -109,6 +111,15 @@ public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcd
} }
if (!successRecords.isEmpty()) { if (!successRecords.isEmpty()) {
enterpriseAutoFillService.ensureExistsBatch(successRecords.stream()
.map(item -> new EnterpriseAutoFillService.EnterpriseFillItem(
item.getSocialCreditCode(),
null,
EnterpriseSource.INTERMEDIARY.getCode(),
DataSource.IMPORT.getCode(),
userName
))
.toList());
saveBatch(successRecords, 500); saveBatch(successRecords, 500);
} }
if (!failures.isEmpty()) { if (!failures.isEmpty()) {
@@ -159,6 +170,7 @@ public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcd
private Map<String, String> getOwnerBizIdByPersonId(List<CcdiIntermediaryEnterpriseRelationExcel> excelList) { private Map<String, String> getOwnerBizIdByPersonId(List<CcdiIntermediaryEnterpriseRelationExcel> excelList) {
List<String> ownerPersonIds = excelList.stream() List<String> ownerPersonIds = excelList.stream()
.map(CcdiIntermediaryEnterpriseRelationExcel::getOwnerPersonId) .map(CcdiIntermediaryEnterpriseRelationExcel::getOwnerPersonId)
.map(this::trim)
.filter(StringUtils::isNotEmpty) .filter(StringUtils::isNotEmpty)
.distinct() .distinct()
.collect(Collectors.toList()); .collect(Collectors.toList());
@@ -173,32 +185,16 @@ public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcd
.collect(Collectors.toMap(CcdiBizIntermediary::getPersonId, CcdiBizIntermediary::getBizId, (left, right) -> left)); .collect(Collectors.toMap(CcdiBizIntermediary::getPersonId, CcdiBizIntermediary::getBizId, (left, right) -> left));
} }
private Set<String> getExistingEnterpriseCodes(List<CcdiIntermediaryEnterpriseRelationExcel> excelList) {
List<String> socialCreditCodes = excelList.stream()
.map(CcdiIntermediaryEnterpriseRelationExcel::getSocialCreditCode)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (socialCreditCodes.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<CcdiEnterpriseBaseInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiEnterpriseBaseInfo::getSocialCreditCode, socialCreditCodes);
return enterpriseBaseInfoMapper.selectList(wrapper).stream()
.map(CcdiEnterpriseBaseInfo::getSocialCreditCode)
.collect(Collectors.toSet());
}
private Set<String> getExistingRelationCombinations(Map<String, String> ownerBizIdByPersonId, private Set<String> getExistingRelationCombinations(Map<String, String> ownerBizIdByPersonId,
List<CcdiIntermediaryEnterpriseRelationExcel> excelList) { List<CcdiIntermediaryEnterpriseRelationExcel> excelList) {
List<String> combinations = excelList.stream() List<String> combinations = excelList.stream()
.map(excel -> { .map(excel -> {
String ownerBizId = ownerBizIdByPersonId.get(excel.getOwnerPersonId()); String ownerBizId = ownerBizIdByPersonId.get(trim(excel.getOwnerPersonId()));
if (StringUtils.isEmpty(ownerBizId) || StringUtils.isEmpty(excel.getSocialCreditCode())) { String socialCreditCode = trim(excel.getSocialCreditCode());
if (StringUtils.isEmpty(ownerBizId) || StringUtils.isEmpty(socialCreditCode)) {
return null; return null;
} }
return ownerBizId + "|" + excel.getSocialCreditCode(); return ownerBizId + "|" + socialCreditCode;
}) })
.filter(StringUtils::isNotEmpty) .filter(StringUtils::isNotEmpty)
.distinct() .distinct()
@@ -210,24 +206,33 @@ public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcd
} }
private void validateExcel(CcdiIntermediaryEnterpriseRelationExcel excel) { private void validateExcel(CcdiIntermediaryEnterpriseRelationExcel excel) {
if (StringUtils.isEmpty(excel.getOwnerPersonId())) { String ownerPersonId = trim(excel.getOwnerPersonId());
String socialCreditCode = trim(excel.getSocialCreditCode());
String relationPersonPost = trim(excel.getRelationPersonPost());
String remark = trim(excel.getRemark());
if (StringUtils.isEmpty(ownerPersonId)) {
throw new RuntimeException("中介本人证件号码不能为空"); throw new RuntimeException("中介本人证件号码不能为空");
} }
if (StringUtils.isEmpty(excel.getSocialCreditCode())) { if (StringUtils.isEmpty(socialCreditCode)) {
throw new RuntimeException("统一社会信用代码不能为空"); throw new RuntimeException("统一社会信用代码不能为空");
} }
String ownerPersonIdError = IdCardUtil.getErrorMessage(excel.getOwnerPersonId()); String ownerPersonIdError = IdCardUtil.getErrorMessage(ownerPersonId);
if (ownerPersonIdError != null) { if (ownerPersonIdError != null) {
throw new RuntimeException("中介本人证件号码" + ownerPersonIdError); throw new RuntimeException("中介本人证件号码" + ownerPersonIdError);
} }
if (StringUtils.isNotEmpty(excel.getRelationPersonPost()) && excel.getRelationPersonPost().length() > 100) { if (StringUtils.isNotEmpty(relationPersonPost) && relationPersonPost.length() > 100) {
throw new RuntimeException("关联职务长度不能超过100个字符"); throw new RuntimeException("关联职务长度不能超过100个字符");
} }
if (StringUtils.isNotEmpty(excel.getRemark()) && excel.getRemark().length() > 500) { if (StringUtils.isNotEmpty(remark) && remark.length() > 500) {
throw new RuntimeException("备注长度不能超过500个字符"); throw new RuntimeException("备注长度不能超过500个字符");
} }
} }
private String trim(String value) {
return value == null ? null : value.trim();
}
private IntermediaryEnterpriseRelationImportFailureVO createFailureVO(CcdiIntermediaryEnterpriseRelationExcel excel, private IntermediaryEnterpriseRelationImportFailureVO createFailureVO(CcdiIntermediaryEnterpriseRelationExcel excel,
String errorMessage) { String errorMessage) {
IntermediaryEnterpriseRelationImportFailureVO failure = new IntermediaryEnterpriseRelationImportFailureVO(); IntermediaryEnterpriseRelationImportFailureVO failure = new IntermediaryEnterpriseRelationImportFailureVO();

View File

@@ -14,6 +14,8 @@ import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEntityDetailVO;
import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryPersonDetailVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryPersonDetailVO;
import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryRelativeVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryRelativeVO;
import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryVO; import com.ruoyi.info.collection.domain.vo.CcdiIntermediaryVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper; import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper; import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
@@ -22,6 +24,7 @@ import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImpo
import com.ruoyi.info.collection.service.ICcdiIntermediaryEntityImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryEntityImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService; import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryService; import com.ruoyi.info.collection.service.ICcdiIntermediaryService;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -69,6 +72,9 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Resource
private EnterpriseAutoFillService enterpriseAutoFillService;
/** /**
* 分页查询中介列表 * 分页查询中介列表
* 使用XML联合查询实现,支持个人中介和实体中介的灵活查询 * 使用XML联合查询实现,支持个人中介和实体中介的灵活查询
@@ -302,6 +308,13 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation(); CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation();
BeanUtils.copyProperties(addDTO, relation); BeanUtils.copyProperties(addDTO, relation);
relation.setIntermediaryBizId(owner.getBizId()); relation.setIntermediaryBizId(owner.getBizId());
enterpriseAutoFillService.ensureExists(new EnterpriseAutoFillService.EnterpriseFillItem(
addDTO.getSocialCreditCode(),
null,
EnterpriseSource.INTERMEDIARY.getCode(),
DataSource.MANUAL.getCode(),
SecurityUtils.getUsername()
));
return enterpriseRelationMapper.insert(relation); return enterpriseRelationMapper.insert(relation);
} }
@@ -317,6 +330,13 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation(); CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation();
BeanUtils.copyProperties(editDTO, relation); BeanUtils.copyProperties(editDTO, relation);
relation.setIntermediaryBizId(existing.getIntermediaryBizId()); relation.setIntermediaryBizId(existing.getIntermediaryBizId());
enterpriseAutoFillService.ensureExists(new EnterpriseAutoFillService.EnterpriseFillItem(
editDTO.getSocialCreditCode(),
null,
EnterpriseSource.INTERMEDIARY.getCode(),
DataSource.MANUAL.getCode(),
SecurityUtils.getUsername()
));
return enterpriseRelationMapper.updateById(relation); return enterpriseRelationMapper.updateById(relation);
} }
@@ -520,9 +540,6 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
private void validateEnterpriseRelation(String bizId, String socialCreditCode, Long excludeId) { private void validateEnterpriseRelation(String bizId, String socialCreditCode, Long excludeId) {
requireIntermediaryPerson(bizId); requireIntermediaryPerson(bizId);
if (enterpriseBaseInfoMapper.selectById(socialCreditCode) == null) {
throw new RuntimeException("关联机构不存在");
}
boolean exists = enterpriseRelationMapper.existsByIntermediaryBizIdAndSocialCreditCode(bizId, socialCreditCode); boolean exists = enterpriseRelationMapper.existsByIntermediaryBizIdAndSocialCreditCode(bizId, socialCreditCode);
if (exists) { if (exists) {
if (excludeId == null) { if (excludeId == null) {

View File

@@ -9,9 +9,12 @@ import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExc
import com.ruoyi.info.collection.domain.vo.ImportResult; import com.ruoyi.info.collection.domain.vo.ImportResult;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.PurchaseTransactionImportFailureVO; import com.ruoyi.info.collection.domain.vo.PurchaseTransactionImportFailureVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionMapper; import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionMapper;
import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionSupplierMapper; import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionSupplierMapper;
import com.ruoyi.info.collection.service.ICcdiPurchaseTransactionImportService; import com.ruoyi.info.collection.service.ICcdiPurchaseTransactionImportService;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import com.ruoyi.info.collection.utils.ImportLogUtils; import com.ruoyi.info.collection.utils.ImportLogUtils;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -53,6 +56,9 @@ public class CcdiPurchaseTransactionImportServiceImpl implements ICcdiPurchaseTr
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Resource
private EnterpriseAutoFillService enterpriseAutoFillService;
@Override @Override
@Async @Async
@Transactional @Transactional
@@ -183,6 +189,7 @@ public class CcdiPurchaseTransactionImportServiceImpl implements ICcdiPurchaseTr
// 批量插入新数据 // 批量插入新数据
if (!newTransactions.isEmpty()) { if (!newTransactions.isEmpty()) {
autoFillSupplierEnterprises(newSuppliers, userName);
ImportLogUtils.logBatchOperationStart(log, taskId, "插入", ImportLogUtils.logBatchOperationStart(log, taskId, "插入",
(newTransactions.size() + 499) / 500, 500); (newTransactions.size() + 499) / 500, 500);
saveBatch(newTransactions, 500); saveBatch(newTransactions, 500);
@@ -328,6 +335,19 @@ public class CcdiPurchaseTransactionImportServiceImpl implements ICcdiPurchaseTr
} }
} }
private void autoFillSupplierEnterprises(List<CcdiPurchaseTransactionSupplier> supplierList, String userName) {
enterpriseAutoFillService.ensureExistsBatch(supplierList.stream()
.filter(item -> StringUtils.isNotEmpty(item.getSupplierUscc()))
.map(item -> new EnterpriseAutoFillService.EnterpriseFillItem(
item.getSupplierUscc(),
item.getSupplierName(),
EnterpriseSource.SUPPLIER.getCode(),
DataSource.IMPORT.getCode(),
userName
))
.toList());
}
/** /**
* 验证采购交易数据 * 验证采购交易数据
* *

View File

@@ -11,10 +11,13 @@ import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionExcel;
import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel; import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel;
import com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionVO; import com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionVO;
import com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionSupplierVO; import com.ruoyi.info.collection.domain.vo.CcdiPurchaseTransactionSupplierVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionMapper; import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionMapper;
import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionSupplierMapper; import com.ruoyi.info.collection.mapper.CcdiPurchaseTransactionSupplierMapper;
import com.ruoyi.info.collection.service.ICcdiPurchaseTransactionImportService; import com.ruoyi.info.collection.service.ICcdiPurchaseTransactionImportService;
import com.ruoyi.info.collection.service.ICcdiPurchaseTransactionService; import com.ruoyi.info.collection.service.ICcdiPurchaseTransactionService;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -55,6 +58,9 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Resource
private EnterpriseAutoFillService enterpriseAutoFillService;
/** /**
* 查询采购交易列表 * 查询采购交易列表
* *
@@ -134,6 +140,7 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact
CcdiPurchaseTransaction transaction = new CcdiPurchaseTransaction(); CcdiPurchaseTransaction transaction = new CcdiPurchaseTransaction();
BeanUtils.copyProperties(addDTO, transaction); BeanUtils.copyProperties(addDTO, transaction);
fillWinnerSummary(transaction, supplierList); fillWinnerSummary(transaction, supplierList);
autoFillSupplierEnterprises(supplierList, DataSource.MANUAL.getCode(), SecurityUtils.getUsername());
int result = transactionMapper.insert(transaction); int result = transactionMapper.insert(transaction);
saveSuppliers(supplierList); saveSuppliers(supplierList);
@@ -331,6 +338,21 @@ public class CcdiPurchaseTransactionServiceImpl implements ICcdiPurchaseTransact
} }
} }
private void autoFillSupplierEnterprises(List<CcdiPurchaseTransactionSupplier> supplierList,
String dataSource,
String userName) {
enterpriseAutoFillService.ensureExistsBatch(supplierList.stream()
.filter(item -> StringUtils.isNotEmpty(item.getSupplierUscc()))
.map(item -> new EnterpriseAutoFillService.EnterpriseFillItem(
item.getSupplierUscc(),
item.getSupplierName(),
EnterpriseSource.SUPPLIER.getCode(),
dataSource,
userName
))
.toList());
}
private List<CcdiPurchaseTransactionSupplierVO> selectSupplierListByPurchaseId(String purchaseId) { private List<CcdiPurchaseTransactionSupplierVO> selectSupplierListByPurchaseId(String purchaseId) {
return supplierMapper.selectList( return supplierMapper.selectList(
new LambdaQueryWrapper<CcdiPurchaseTransactionSupplier>() new LambdaQueryWrapper<CcdiPurchaseTransactionSupplier>()

View File

@@ -9,9 +9,12 @@ import com.ruoyi.info.collection.domain.excel.CcdiStaffEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.ImportResult; import com.ruoyi.info.collection.domain.vo.ImportResult;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO; import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.StaffEnterpriseRelationImportFailureVO; import com.ruoyi.info.collection.domain.vo.StaffEnterpriseRelationImportFailureVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.ICcdiStaffEnterpriseRelationImportService; import com.ruoyi.info.collection.service.ICcdiStaffEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import com.ruoyi.info.collection.utils.ImportLogUtils; import com.ruoyi.info.collection.utils.ImportLogUtils;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -49,6 +52,9 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE
@Resource @Resource
private CcdiStaffFmyRelationMapper familyRelationMapper; private CcdiStaffFmyRelationMapper familyRelationMapper;
@Resource
private EnterpriseAutoFillService enterpriseAutoFillService;
@Override @Override
@Async @Async
@Transactional @Transactional
@@ -147,6 +153,15 @@ public class CcdiStaffEnterpriseRelationImportServiceImpl implements ICcdiStaffE
// 批量插入新数据 // 批量插入新数据
if (!newRecords.isEmpty()) { if (!newRecords.isEmpty()) {
enterpriseAutoFillService.ensureExistsBatch(newRecords.stream()
.map(item -> new EnterpriseAutoFillService.EnterpriseFillItem(
item.getSocialCreditCode(),
item.getEnterpriseName(),
EnterpriseSource.EMP_RELATION.getCode(),
DataSource.IMPORT.getCode(),
userName
))
.toList());
ImportLogUtils.logBatchOperationStart(log, taskId, "插入", ImportLogUtils.logBatchOperationStart(log, taskId, "插入",
(newRecords.size() + 499) / 500, 500); (newRecords.size() + 499) / 500, 500);
saveBatch(newRecords, 500); saveBatch(newRecords, 500);

View File

@@ -10,10 +10,13 @@ import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiStaffEnterpriseRelationExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationOptionVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationOptionVO;
import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationVO;
import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.ICcdiStaffEnterpriseRelationImportService; import com.ruoyi.info.collection.service.ICcdiStaffEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.ICcdiStaffEnterpriseRelationService; import com.ruoyi.info.collection.service.ICcdiStaffEnterpriseRelationService;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import com.ruoyi.common.utils.SecurityUtils; import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils; import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
@@ -49,6 +52,9 @@ public class CcdiStaffEnterpriseRelationServiceImpl implements ICcdiStaffEnterpr
@Resource @Resource
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Resource
private EnterpriseAutoFillService enterpriseAutoFillService;
/** /**
* 查询员工实体关系列表 * 查询员工实体关系列表
* *
@@ -144,6 +150,14 @@ public class CcdiStaffEnterpriseRelationServiceImpl implements ICcdiStaffEnterpr
relation.setDataSource("MANUAL"); relation.setDataSource("MANUAL");
} }
enterpriseAutoFillService.ensureExists(new EnterpriseAutoFillService.EnterpriseFillItem(
addDTO.getSocialCreditCode(),
addDTO.getEnterpriseName(),
EnterpriseSource.EMP_RELATION.getCode(),
DataSource.MANUAL.getCode(),
SecurityUtils.getUsername()
));
int result = relationMapper.insert(relation); int result = relationMapper.insert(relation);
return result; return result;

View File

@@ -57,6 +57,12 @@ public class CcdiStaffFmyRelationImportServiceImpl implements ICcdiStaffFmyRelat
@Async @Async
@Transactional @Transactional
public void importRelationAsync(List<CcdiStaffFmyRelationExcel> excelList, String taskId, String userName) { public void importRelationAsync(List<CcdiStaffFmyRelationExcel> excelList, String taskId, String userName) {
importRelationSync(excelList, taskId, userName);
}
@Override
@Transactional
public Map<String, String> importRelationSync(List<CcdiStaffFmyRelationExcel> excelList, String taskId, String userName) {
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
// 记录导入开始 // 记录导入开始
@@ -213,6 +219,15 @@ public class CcdiStaffFmyRelationImportServiceImpl implements ICcdiStaffFmyRelat
long duration = System.currentTimeMillis() - startTime; long duration = System.currentTimeMillis() - startTime;
ImportLogUtils.logImportComplete(log, taskId, "员工亲属关系", ImportLogUtils.logImportComplete(log, taskId, "员工亲属关系",
excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration); excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration);
return newRecords.stream()
.filter(item -> StringUtils.isNotEmpty(item.getRelationCertNo()) && StringUtils.isNotEmpty(item.getPersonId()))
.collect(Collectors.toMap(
CcdiStaffFmyRelation::getRelationCertNo,
CcdiStaffFmyRelation::getPersonId,
(left, right) -> left,
LinkedHashMap::new
));
} }
/** /**

View File

@@ -6,11 +6,14 @@ import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationQueryDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffFmyRelationQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel;
import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO; import com.ruoyi.info.collection.domain.vo.CcdiAssetInfoVO;
import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO; import com.ruoyi.info.collection.domain.vo.CcdiStaffFmyRelationVO;
import com.ruoyi.info.collection.domain.vo.StaffFmyRelationImportSubmitResultVO;
import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper;
import com.ruoyi.info.collection.service.ICcdiAssetInfoImportService;
import com.ruoyi.info.collection.service.ICcdiAssetInfoService; import com.ruoyi.info.collection.service.ICcdiAssetInfoService;
import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationImportService; import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationImportService;
import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationService; import com.ruoyi.info.collection.service.ICcdiStaffFmyRelationService;
@@ -51,9 +54,15 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
@Resource @Resource
private ICcdiAssetInfoService assetInfoService; private ICcdiAssetInfoService assetInfoService;
@Resource
private ICcdiAssetInfoImportService assetInfoImportService;
@Resource @Resource
private CcdiStaffEnterpriseRelationMapper staffEnterpriseRelationMapper; private CcdiStaffEnterpriseRelationMapper staffEnterpriseRelationMapper;
@Resource
private CcdiDualSheetImportOrchestrationService dualSheetImportOrchestrationService;
/** /**
* 查询员工亲属关系列表 * 查询员工亲属关系列表
* *
@@ -207,25 +216,11 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
// 生成任务ID // 生成任务ID
String taskId = UUID.randomUUID().toString(); String taskId = UUID.randomUUID().toString();
long startTime = System.currentTimeMillis();
// 获取当前用户名 // 获取当前用户名
String userName = SecurityUtils.getUsername(); String userName = SecurityUtils.getUsername();
// 初始化Redis状态 initializeImportStatus("import:staffFmyRelation:", taskId, excelList.size());
String statusKey = "import:staffFmyRelation:" + taskId;
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", startTime);
statusData.put("message", "正在处理...");
redisTemplate.opsForHash().putAll(statusKey, statusData);
redisTemplate.expire(statusKey, 7, TimeUnit.DAYS);
// 调用异步导入服务 // 调用异步导入服务
relationImportService.importRelationAsync(excelList, taskId, userName); relationImportService.importRelationAsync(excelList, taskId, userName);
@@ -233,6 +228,79 @@ public class CcdiStaffFmyRelationServiceImpl implements ICcdiStaffFmyRelationSer
return taskId; return taskId;
} }
@Override
@Transactional
public StaffFmyRelationImportSubmitResultVO importRelationWithAssets(List<CcdiStaffFmyRelationExcel> relationList,
List<CcdiAssetInfoExcel> assetList) {
boolean hasRelationRows = relationList != null && !relationList.isEmpty();
boolean hasAssetRows = assetList != null && !assetList.isEmpty();
if (!hasRelationRows && !hasAssetRows) {
throw new RuntimeException("至少需要一条数据");
}
StaffFmyRelationImportSubmitResultVO result = new StaffFmyRelationImportSubmitResultVO();
result.setMessage(buildImportSubmitMessage(hasRelationRows, hasAssetRows));
if (hasRelationRows && !hasAssetRows) {
result.setRelationTaskId(importRelation(relationList));
return result;
}
if (!hasRelationRows) {
result.setAssetTaskId(assetInfoImportService.importAssetInfo(assetList));
return result;
}
String relationTaskId = UUID.randomUUID().toString();
String assetTaskId = UUID.randomUUID().toString();
initializeImportStatus("import:staffFmyRelation:", relationTaskId, relationList.size());
initializeImportStatus("import:assetInfo:", assetTaskId, assetList.size());
result.setRelationTaskId(relationTaskId);
result.setAssetTaskId(assetTaskId);
dualSheetImportOrchestrationService.importRelationWithAssetsAsync(
relationList,
relationTaskId,
assetList,
assetTaskId,
currentUserName()
);
return result;
}
private void initializeImportStatus(String keyPrefix, String taskId, int totalCount) {
Map<String, Object> statusData = new HashMap<>();
statusData.put("taskId", taskId);
statusData.put("status", "PROCESSING");
statusData.put("totalCount", totalCount);
statusData.put("successCount", 0);
statusData.put("failureCount", 0);
statusData.put("progress", 0);
statusData.put("startTime", System.currentTimeMillis());
statusData.put("message", "正在处理...");
String statusKey = keyPrefix + taskId;
redisTemplate.opsForHash().putAll(statusKey, statusData);
redisTemplate.expire(statusKey, 7, TimeUnit.DAYS);
}
private String buildImportSubmitMessage(boolean hasRelationRows, boolean hasAssetRows) {
if (hasRelationRows && hasAssetRows) {
return "已提交员工亲属关系和亲属资产信息导入任务";
}
if (hasRelationRows) {
return "已提交员工亲属关系导入任务";
}
return "已提交亲属资产信息导入任务";
}
private String currentUserName() {
try {
return SecurityUtils.getUsername();
} catch (Exception e) {
return "system";
}
}
private CcdiAssetInfoVO toAssetInfoVO(CcdiAssetInfo assetInfo) { private CcdiAssetInfoVO toAssetInfoVO(CcdiAssetInfo assetInfo) {
CcdiAssetInfoVO assetInfoVO = new CcdiAssetInfoVO(); CcdiAssetInfoVO assetInfoVO = new CcdiAssetInfoVO();
BeanUtils.copyProperties(assetInfo, assetInfoVO); BeanUtils.copyProperties(assetInfo, assetInfoVO);

View File

@@ -165,12 +165,8 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
return new MainImportResult(Collections.emptyMap(), 0); return new MainImportResult(Collections.emptyMap(), 0);
} }
Set<String> existingRecruitIds = getExistingRecruitIds( Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap = new LinkedHashMap<>();
mainRows.stream().map(MainImportRow::data).toList() int successCount = 0;
);
Set<String> processedRecruitIds = new HashSet<>();
List<CcdiStaffRecruitment> newRecords = new ArrayList<>();
Map<String, CcdiStaffRecruitment> importedRecruitmentMap = new LinkedHashMap<>();
for (int index = 0; index < mainRows.size(); index++) { for (int index = 0; index < mainRows.size(); index++) {
MainImportRow mainRow = mainRows.get(index); MainImportRow mainRow = mainRows.get(index);
@@ -178,36 +174,22 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
try { try {
CcdiStaffRecruitmentAddDTO addDTO = new CcdiStaffRecruitmentAddDTO(); CcdiStaffRecruitmentAddDTO addDTO = new CcdiStaffRecruitmentAddDTO();
BeanUtils.copyProperties(excel, addDTO); BeanUtils.copyProperties(excel, addDTO);
addDTO.setRecruitType(RecruitType.inferCode(addDTO.getRecruitName())); addDTO.setRecruitType(normalizeRecruitType(excel.getRecruitType()));
validateRecruitmentData(addDTO, mainRow.sheetRowNum()); validateRecruitmentData(addDTO, mainRow.sheetRowNum());
String recruitId = trim(excel.getRecruitId()); String recruitId = trim(excel.getRecruitId());
if (existingRecruitIds.contains(recruitId)) {
throw buildValidationException(
MAIN_SHEET_NAME,
List.of(mainRow.sheetRowNum()),
String.format("招聘记录编号[%s]已存在,请勿重复导入", recruitId)
);
}
if (!processedRecruitIds.add(recruitId)) {
throw buildValidationException(
MAIN_SHEET_NAME,
List.of(mainRow.sheetRowNum()),
String.format("招聘记录编号[%s]在导入文件中重复,已跳过此条记录", recruitId)
);
}
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
BeanUtils.copyProperties(excel, recruitment); BeanUtils.copyProperties(excel, recruitment);
recruitment.setRecruitId(recruitId); recruitment.setRecruitId(recruitId);
recruitment.setRecruitType(addDTO.getRecruitType()); recruitment.setRecruitType(addDTO.getRecruitType());
recruitment.setCreatedBy(userName); recruitment.setCreatedBy(userName);
recruitment.setUpdatedBy(userName); recruitment.setUpdatedBy(userName);
newRecords.add(recruitment); recruitmentMapper.insert(recruitment);
importedRecruitmentMap.put(recruitId, recruitment); successCount++;
addRecruitment(importedRecruitmentMap, recruitment);
ImportLogUtils.logProgress(log, taskId, index + 1, mainRows.size(), newRecords.size(), failures.size()); ImportLogUtils.logProgress(log, taskId, index + 1, mainRows.size(), successCount, failures.size());
} catch (Exception exception) { } catch (Exception exception) {
FailureMeta failureMeta = resolveFailureMeta(exception, List.of(mainRow.sheetRowNum()), MAIN_SHEET_NAME); FailureMeta failureMeta = resolveFailureMeta(exception, List.of(mainRow.sheetRowNum()), MAIN_SHEET_NAME);
failures.add(buildFailure(excel, failureMeta.sheetName(), failureMeta.sheetRowNum(), exception.getMessage())); failures.add(buildFailure(excel, failureMeta.sheetName(), failureMeta.sheetRowNum(), exception.getMessage()));
@@ -221,16 +203,11 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
} }
} }
if (!newRecords.isEmpty()) { return new MainImportResult(importedRecruitmentMap, successCount);
ImportLogUtils.logBatchOperationStart(log, taskId, "插入招聘信息", (newRecords.size() + 499) / 500, 500);
saveBatch(newRecords, 500);
}
return new MainImportResult(importedRecruitmentMap, newRecords.size());
} }
private int importWorkSheet(List<WorkImportRow> workRows, private int importWorkSheet(List<WorkImportRow> workRows,
Map<String, CcdiStaffRecruitment> importedRecruitmentMap, Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap,
List<RecruitmentImportFailureVO> failures, List<RecruitmentImportFailureVO> failures,
String userName, String userName,
String taskId) { String taskId) {
@@ -238,7 +215,7 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
return 0; return 0;
} }
Map<String, CcdiStaffRecruitment> existingRecruitmentMap = Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> existingRecruitmentMap =
getExistingRecruitmentMap(workRows, importedRecruitmentMap); getExistingRecruitmentMap(workRows, importedRecruitmentMap);
Map<String, List<WorkImportRow>> groupedRows = groupWorkRows(workRows); Map<String, List<WorkImportRow>> groupedRows = groupWorkRows(workRows);
int successCount = 0; int successCount = 0;
@@ -248,15 +225,18 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
processedGroups++; processedGroups++;
WorkImportRow firstRow = recruitWorkRows.get(0); WorkImportRow firstRow = recruitWorkRows.get(0);
String recruitId = trim(firstRow.data().getRecruitId()); String recruitId = trim(firstRow.data().getRecruitId());
CcdiStaffRecruitment recruitment = importedRecruitmentMap.get(recruitId);
if (recruitment == null) {
recruitment = existingRecruitmentMap.get(recruitId);
}
try { try {
RecruitmentMatchKey matchKey = buildMatchKey(firstRow.data());
CcdiStaffRecruitment recruitment = resolveMatchedRecruitment(
matchKey,
importedRecruitmentMap,
existingRecruitmentMap,
extractWorkRowNums(recruitWorkRows)
);
validateWorkGroup(recruitWorkRows, recruitment); validateWorkGroup(recruitWorkRows, recruitment);
if (StringUtils.isNotEmpty(recruitId) && hasExistingWorkHistory(recruitId)) { if (recruitment != null && hasExistingWorkHistory(recruitment.getId())) {
throw buildValidationException( throw buildValidationException(
WORK_SHEET_NAME, WORK_SHEET_NAME,
extractWorkRowNums(recruitWorkRows), extractWorkRowNums(recruitWorkRows),
@@ -264,7 +244,7 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
); );
} }
List<CcdiStaffRecruitmentWork> entities = buildWorkEntities(recruitWorkRows, userName); List<CcdiStaffRecruitmentWork> entities = buildWorkEntities(recruitWorkRows, recruitment, userName);
entities.forEach(entity -> recruitmentWorkMapper.insert(entity)); entities.forEach(entity -> recruitmentWorkMapper.insert(entity));
successCount += recruitWorkRows.size(); successCount += recruitWorkRows.size();
@@ -299,33 +279,59 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
} }
private String buildWorkGroupKey(WorkImportRow workRow) { private String buildWorkGroupKey(WorkImportRow workRow) {
String recruitId = trim(workRow.data().getRecruitId()); RecruitmentMatchKey key = buildMatchKey(workRow.data());
if (StringUtils.isNotEmpty(recruitId)) { if (key.isComplete()) {
return recruitId; return key.value();
} }
return "__ROW__" + workRow.sheetRowNum(); return "__ROW__" + workRow.sheetRowNum();
} }
private Map<String, CcdiStaffRecruitment> getExistingRecruitmentMap(List<WorkImportRow> workRows, private Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> getExistingRecruitmentMap(
Map<String, CcdiStaffRecruitment> importedRecruitmentMap) { List<WorkImportRow> workRows,
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap
) {
LinkedHashSet<String> recruitIds = workRows.stream() LinkedHashSet<String> recruitIds = workRows.stream()
.filter(row -> !importedRecruitmentMap.containsKey(buildMatchKey(row.data())))
.map(row -> trim(row.data().getRecruitId())) .map(row -> trim(row.data().getRecruitId()))
.filter(StringUtils::isNotEmpty) .filter(StringUtils::isNotEmpty)
.filter(recruitId -> !importedRecruitmentMap.containsKey(recruitId))
.collect(Collectors.toCollection(LinkedHashSet::new)); .collect(Collectors.toCollection(LinkedHashSet::new));
if (recruitIds.isEmpty()) { if (recruitIds.isEmpty()) {
return Collections.emptyMap(); return Collections.emptyMap();
} }
List<CcdiStaffRecruitment> recruitments = recruitmentMapper.selectBatchIds(recruitIds); List<CcdiStaffRecruitment> recruitments = selectRecruitmentsByRecruitIds(recruitIds);
return recruitments.stream().collect(Collectors.toMap(CcdiStaffRecruitment::getRecruitId, item -> item)); Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> result = new LinkedHashMap<>();
recruitments.forEach(item -> addRecruitment(result, item));
return result;
} }
private List<CcdiStaffRecruitmentWork> buildWorkEntities(List<WorkImportRow> workRows, String userName) { private CcdiStaffRecruitment resolveMatchedRecruitment(
RecruitmentMatchKey matchKey,
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap,
Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> existingRecruitmentMap,
List<Integer> rowNums
) {
List<CcdiStaffRecruitment> matchedRecruitments = new ArrayList<>();
matchedRecruitments.addAll(importedRecruitmentMap.getOrDefault(matchKey, Collections.emptyList()));
matchedRecruitments.addAll(existingRecruitmentMap.getOrDefault(matchKey, Collections.emptyList()));
if (matchedRecruitments.size() > 1) {
throw buildValidationException(
WORK_SHEET_NAME,
rowNums,
String.format("招聘记录编号[%s]匹配到多条招聘主信息,无法确定历史工作经历归属", matchKey.recruitId())
);
}
return matchedRecruitments.isEmpty() ? null : matchedRecruitments.get(0);
}
private List<CcdiStaffRecruitmentWork> buildWorkEntities(List<WorkImportRow> workRows,
CcdiStaffRecruitment recruitment,
String userName) {
List<CcdiStaffRecruitmentWork> entities = new ArrayList<>(); List<CcdiStaffRecruitmentWork> entities = new ArrayList<>();
for (WorkImportRow workRow : workRows) { for (WorkImportRow workRow : workRows) {
CcdiStaffRecruitmentWork entity = new CcdiStaffRecruitmentWork(); CcdiStaffRecruitmentWork entity = new CcdiStaffRecruitmentWork();
BeanUtils.copyProperties(workRow.data(), entity); BeanUtils.copyProperties(workRow.data(), entity);
entity.setRecruitId(trim(workRow.data().getRecruitId())); entity.setRecruitmentId(recruitment.getId());
entity.setRecruitId(recruitment.getRecruitId());
entity.setCreatedBy(userName); entity.setCreatedBy(userName);
entity.setUpdatedBy(userName); entity.setUpdatedBy(userName);
entities.add(entity); entities.add(entity);
@@ -333,29 +339,9 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
return entities; return entities;
} }
private Set<String> getExistingRecruitIds(List<CcdiStaffRecruitmentExcel> recruitmentList) { private boolean hasExistingWorkHistory(Long recruitmentId) {
List<String> recruitIds = recruitmentList.stream()
.map(CcdiStaffRecruitmentExcel::getRecruitId)
.map(this::trim)
.filter(StringUtils::isNotEmpty)
.toList();
if (recruitIds.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<CcdiStaffRecruitment> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiStaffRecruitment::getRecruitId, recruitIds);
List<CcdiStaffRecruitment> existingRecruitments = recruitmentMapper.selectList(wrapper);
return existingRecruitments.stream()
.map(CcdiStaffRecruitment::getRecruitId)
.collect(Collectors.toSet());
}
private boolean hasExistingWorkHistory(String recruitId) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<CcdiStaffRecruitmentWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, recruitId); wrapper.eq(CcdiStaffRecruitmentWork::getRecruitmentId, recruitmentId);
return recruitmentWorkMapper.selectCount(wrapper) > 0; return recruitmentWorkMapper.selectCount(wrapper) > 0;
} }
@@ -376,22 +362,22 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "职位描述不能为空"); throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "职位描述不能为空");
} }
if (StringUtils.isEmpty(addDTO.getCandName())) { if (StringUtils.isEmpty(addDTO.getCandName())) {
throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员姓名不能为空"); throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "候选人姓名不能为空");
} }
if (StringUtils.isEmpty(addDTO.getCandEdu())) { if (StringUtils.isEmpty(addDTO.getCandEdu())) {
throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员学历不能为空"); throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "学历不能为空");
} }
if (StringUtils.isEmpty(addDTO.getCandId())) { if (StringUtils.isEmpty(addDTO.getCandId())) {
throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "证件号码不能为空"); throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "证件号码不能为空");
} }
if (StringUtils.isEmpty(addDTO.getCandSchool())) { if (StringUtils.isEmpty(addDTO.getCandSchool())) {
throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员毕业院校不能为空"); throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "毕业院校不能为空");
} }
if (StringUtils.isEmpty(addDTO.getCandMajor())) { if (StringUtils.isEmpty(addDTO.getCandMajor())) {
throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员专业不能为空"); throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "专业不能为空");
} }
if (StringUtils.isEmpty(addDTO.getCandGrad())) { if (StringUtils.isEmpty(addDTO.getCandGrad())) {
throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "应聘人员毕业年月不能为空"); throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "毕业年月不能为空");
} }
if (StringUtils.isEmpty(addDTO.getAdmitStatus())) { if (StringUtils.isEmpty(addDTO.getAdmitStatus())) {
throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "录用情况不能为空"); throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "录用情况不能为空");
@@ -414,10 +400,23 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
} }
if (RecruitType.getDescByCode(addDTO.getRecruitType()) == null) { if (RecruitType.getDescByCode(addDTO.getRecruitType()) == null) {
throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘类型只能填写'SOCIAL'或'CAMPUS'"); throw buildValidationException(MAIN_SHEET_NAME, List.of(sheetRowNum), "招聘类型只能填写'SOCIAL/社招'或'CAMPUS/校招'");
} }
} }
private String normalizeRecruitType(String recruitType) {
String value = trim(recruitType);
if (StringUtils.isEmpty(value)) {
return value;
}
for (RecruitType type : RecruitType.values()) {
if (type.getCode().equals(value) || type.getDesc().equals(value)) {
return type.getCode();
}
}
return value;
}
private void validateWorkGroup(List<WorkImportRow> workRows, CcdiStaffRecruitment recruitment) { private void validateWorkGroup(List<WorkImportRow> workRows, CcdiStaffRecruitment recruitment) {
Set<Integer> processedSortOrders = new HashSet<>(); Set<Integer> processedSortOrders = new HashSet<>();
for (WorkImportRow workRow : workRows) { for (WorkImportRow workRow : workRows) {
@@ -451,14 +450,14 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "工作单位不能为空"); throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "工作单位不能为空");
} }
if (StringUtils.isEmpty(trim(excel.getPositionName()))) { if (StringUtils.isEmpty(trim(excel.getPositionName()))) {
throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "岗位不能为空"); throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "岗位名称不能为空");
} }
if (StringUtils.isEmpty(trim(excel.getJobStartMonth()))) { if (StringUtils.isEmpty(trim(excel.getJobStartMonth()))) {
throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "入职年月不能为空"); throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "入职时间不能为空");
} }
validateMonth(excel.getJobStartMonth(), "入职年月", sheetRowNum); validateMonth(excel.getJobStartMonth(), "入职时间", sheetRowNum);
if (StringUtils.isNotEmpty(trim(excel.getJobEndMonth()))) { if (StringUtils.isNotEmpty(trim(excel.getJobEndMonth()))) {
validateMonth(excel.getJobEndMonth(), "离职年月", sheetRowNum); validateMonth(excel.getJobEndMonth(), "离职时间", sheetRowNum);
} }
if (recruitment == null) { if (recruitment == null) {
throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号不存在,请先维护招聘主信息"); throw buildValidationException(WORK_SHEET_NAME, List.of(sheetRowNum), "招聘记录编号不存在,请先维护招聘主信息");
@@ -555,30 +554,36 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
redisTemplate.opsForHash().putAll(key, statusData); redisTemplate.opsForHash().putAll(key, statusData);
} }
private void saveBatch(List<CcdiStaffRecruitment> list, int batchSize) { private List<CcdiStaffRecruitment> selectRecruitmentsByRecruitIds(Set<String> recruitIds) {
for (int i = 0; i < list.size(); i += batchSize) { if (recruitIds == null || recruitIds.isEmpty()) {
int end = Math.min(i + batchSize, list.size()); return Collections.emptyList();
List<CcdiStaffRecruitment> subList = list.subList(i, end);
List<String> recruitIds = subList.stream()
.map(CcdiStaffRecruitment::getRecruitId)
.toList();
if (recruitIds.isEmpty()) {
continue;
}
List<CcdiStaffRecruitment> existingRecords = recruitmentMapper.selectBatchIds(recruitIds);
Set<String> existingIds = existingRecords.stream()
.map(CcdiStaffRecruitment::getRecruitId)
.collect(Collectors.toSet());
List<CcdiStaffRecruitment> toInsert = subList.stream()
.filter(record -> !existingIds.contains(record.getRecruitId()))
.toList();
if (!toInsert.isEmpty()) {
recruitmentMapper.insertBatch(toInsert);
}
} }
LambdaQueryWrapper<CcdiStaffRecruitment> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiStaffRecruitment::getRecruitId, recruitIds);
return recruitmentMapper.selectList(wrapper);
}
private void addRecruitment(Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> map,
CcdiStaffRecruitment recruitment) {
map.computeIfAbsent(buildMatchKey(recruitment), key -> new ArrayList<>()).add(recruitment);
}
private RecruitmentMatchKey buildMatchKey(CcdiStaffRecruitment recruitment) {
return new RecruitmentMatchKey(
trim(recruitment.getRecruitId()),
trim(recruitment.getCandName()),
trim(recruitment.getRecruitName()),
trim(recruitment.getPosName())
);
}
private RecruitmentMatchKey buildMatchKey(CcdiStaffRecruitmentWorkExcel excel) {
return new RecruitmentMatchKey(
trim(excel.getRecruitId()),
trim(excel.getCandName()),
trim(excel.getRecruitName()),
trim(excel.getPosName())
);
} }
private List<MainImportRow> buildMainImportRows(List<CcdiStaffRecruitmentExcel> recruitmentList) { private List<MainImportRow> buildMainImportRows(List<CcdiStaffRecruitmentExcel> recruitmentList) {
@@ -628,10 +633,25 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
private record WorkImportRow(CcdiStaffRecruitmentWorkExcel data, int sheetRowNum) {} private record WorkImportRow(CcdiStaffRecruitmentWorkExcel data, int sheetRowNum) {}
private record MainImportResult(Map<String, CcdiStaffRecruitment> importedRecruitmentMap, int successCount) {} private record MainImportResult(Map<RecruitmentMatchKey, List<CcdiStaffRecruitment>> importedRecruitmentMap,
int successCount) {}
private record FailureMeta(String sheetName, String sheetRowNum) {} private record FailureMeta(String sheetName, String sheetRowNum) {}
private record RecruitmentMatchKey(String recruitId, String candName, String recruitName, String posName) {
private boolean isComplete() {
return StringUtils.isNotEmpty(recruitId)
&& StringUtils.isNotEmpty(candName)
&& StringUtils.isNotEmpty(recruitName)
&& StringUtils.isNotEmpty(posName);
}
private String value() {
return String.join("|", recruitId, candName, recruitName, posName);
}
}
private static class ImportValidationException extends RuntimeException { private static class ImportValidationException extends RuntimeException {
private final String sheetName; private final String sheetName;

View File

@@ -1,6 +1,7 @@
package com.ruoyi.info.collection.service.impl; package com.ruoyi.info.collection.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitment; import com.ruoyi.info.collection.domain.CcdiStaffRecruitment;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork; import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork;
@@ -27,6 +28,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -108,15 +110,15 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
/** /**
* 查询招聘信息详情 * 查询招聘信息详情
* *
* @param recruitId 招聘记录编号 * @param id 主键ID
* @return 招聘信息VO * @return 招聘信息VO
*/ */
@Override @Override
public CcdiStaffRecruitmentVO selectRecruitmentById(String recruitId) { public CcdiStaffRecruitmentVO selectRecruitmentById(Long id) {
CcdiStaffRecruitmentVO vo = recruitmentMapper.selectRecruitmentById(recruitId); CcdiStaffRecruitmentVO vo = recruitmentMapper.selectRecruitmentById(id);
if (vo != null) { if (vo != null) {
vo.setAdmitStatusDesc(AdmitStatus.getDescByCode(vo.getAdmitStatus())); vo.setAdmitStatusDesc(AdmitStatus.getDescByCode(vo.getAdmitStatus()));
vo.setWorkExperienceList(selectWorkExperienceList(recruitId)); vo.setWorkExperienceList(selectWorkExperienceList(vo.getId()));
} }
return vo; return vo;
} }
@@ -130,15 +132,14 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
@Override @Override
@Transactional @Transactional
public int insertRecruitment(CcdiStaffRecruitmentAddDTO addDTO) { public int insertRecruitment(CcdiStaffRecruitmentAddDTO addDTO) {
// 检查招聘记录编号唯一性 String recruitId = trim(addDTO.getRecruitId());
if (recruitmentMapper.selectById(addDTO.getRecruitId()) != null) {
throw new RuntimeException("该招聘记录编号已存在");
}
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
BeanUtils.copyProperties(addDTO, recruitment); BeanUtils.copyProperties(addDTO, recruitment);
int result = recruitmentMapper.insert(recruitment); recruitment.setRecruitId(recruitId);
int result = recruitmentMapper.insert(recruitment);
insertWorkExperienceList(recruitment.getId(), recruitId, addDTO.getRecruitType(), addDTO.getWorkExperienceList());
return result; return result;
} }
@@ -151,9 +152,20 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
@Override @Override
@Transactional @Transactional
public int updateRecruitment(CcdiStaffRecruitmentEditDTO editDTO) { public int updateRecruitment(CcdiStaffRecruitmentEditDTO editDTO) {
CcdiStaffRecruitment existing = recruitmentMapper.selectById(editDTO.getId());
if (existing == null) {
throw new RuntimeException("招聘信息不存在");
}
String recruitId = trim(editDTO.getRecruitId());
editDTO.setRecruitId(recruitId);
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
BeanUtils.copyProperties(editDTO, recruitment); BeanUtils.copyProperties(editDTO, recruitment);
int result = recruitmentMapper.updateById(recruitment); int result = recruitmentMapper.updateById(recruitment);
if (!Objects.equals(existing.getRecruitId(), recruitId)) {
updateWorkRecruitId(editDTO.getId(), recruitId);
}
replaceWorkExperienceList(editDTO); replaceWorkExperienceList(editDTO);
return result; return result;
@@ -162,16 +174,19 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
/** /**
* 批量删除招聘信息 * 批量删除招聘信息
* *
* @param recruitIds 需要删除的招聘记录编号 * @param ids 需要删除的招聘信息ID
* @return 结果 * @return 结果
*/ */
@Override @Override
@Transactional @Transactional
public int deleteRecruitmentByIds(String[] recruitIds) { public int deleteRecruitmentByIds(Long[] ids) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> workWrapper = new LambdaQueryWrapper<>(); List<Long> idList = Arrays.asList(ids);
workWrapper.in(CcdiStaffRecruitmentWork::getRecruitId, List.of(recruitIds)); if (!idList.isEmpty()) {
recruitmentWorkMapper.delete(workWrapper); LambdaQueryWrapper<CcdiStaffRecruitmentWork> workWrapper = new LambdaQueryWrapper<>();
return recruitmentMapper.deleteBatchIds(List.of(recruitIds)); workWrapper.in(CcdiStaffRecruitmentWork::getRecruitmentId, idList);
recruitmentWorkMapper.delete(workWrapper);
}
return recruitmentMapper.deleteBatchIds(idList);
} }
/** /**
@@ -216,9 +231,9 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
return taskId; return taskId;
} }
private List<CcdiStaffRecruitmentWorkVO> selectWorkExperienceList(String recruitId) { private List<CcdiStaffRecruitmentWorkVO> selectWorkExperienceList(Long recruitmentId) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> wrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<CcdiStaffRecruitmentWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, recruitId) wrapper.eq(CcdiStaffRecruitmentWork::getRecruitmentId, recruitmentId)
.orderByAsc(CcdiStaffRecruitmentWork::getSortOrder) .orderByAsc(CcdiStaffRecruitmentWork::getSortOrder)
.orderByDesc(CcdiStaffRecruitmentWork::getId); .orderByDesc(CcdiStaffRecruitmentWork::getId);
List<CcdiStaffRecruitmentWork> workList = recruitmentWorkMapper.selectList(wrapper); List<CcdiStaffRecruitmentWork> workList = recruitmentWorkMapper.selectList(wrapper);
@@ -232,9 +247,20 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
}).toList(); }).toList();
} }
private void updateWorkRecruitId(Long recruitmentId, String newRecruitId) {
LambdaUpdateWrapper<CcdiStaffRecruitmentWork> updateWrapper = new LambdaUpdateWrapper<>();
updateWrapper.eq(CcdiStaffRecruitmentWork::getRecruitmentId, recruitmentId)
.set(CcdiStaffRecruitmentWork::getRecruitId, newRecruitId);
recruitmentWorkMapper.update(null, updateWrapper);
}
private String trim(String value) {
return value == null ? null : value.trim();
}
private void replaceWorkExperienceList(CcdiStaffRecruitmentEditDTO editDTO) { private void replaceWorkExperienceList(CcdiStaffRecruitmentEditDTO editDTO) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> deleteWrapper = new LambdaQueryWrapper<>(); LambdaQueryWrapper<CcdiStaffRecruitmentWork> deleteWrapper = new LambdaQueryWrapper<>();
deleteWrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, editDTO.getRecruitId()); deleteWrapper.eq(CcdiStaffRecruitmentWork::getRecruitmentId, editDTO.getId());
if (!Objects.equals(RecruitType.SOCIAL.getCode(), editDTO.getRecruitType())) { if (!Objects.equals(RecruitType.SOCIAL.getCode(), editDTO.getRecruitType())) {
recruitmentWorkMapper.delete(deleteWrapper); recruitmentWorkMapper.delete(deleteWrapper);
@@ -246,12 +272,28 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
} }
recruitmentWorkMapper.delete(deleteWrapper); recruitmentWorkMapper.delete(deleteWrapper);
List<CcdiStaffRecruitmentWork> workList = buildWorkExperienceEntities(editDTO); List<CcdiStaffRecruitmentWork> workList = buildWorkExperienceEntities(
editDTO.getId(),
editDTO.getRecruitId(),
editDTO.getWorkExperienceList()
);
workList.forEach(recruitmentWorkMapper::insert); workList.forEach(recruitmentWorkMapper::insert);
} }
private List<CcdiStaffRecruitmentWork> buildWorkExperienceEntities(CcdiStaffRecruitmentEditDTO editDTO) { private void insertWorkExperienceList(Long recruitmentId,
List<CcdiStaffRecruitmentWorkEditDTO> workExperienceList = editDTO.getWorkExperienceList(); String recruitId,
String recruitType,
List<CcdiStaffRecruitmentWorkEditDTO> workExperienceList) {
if (!Objects.equals(RecruitType.SOCIAL.getCode(), recruitType)) {
return;
}
List<CcdiStaffRecruitmentWork> workList = buildWorkExperienceEntities(recruitmentId, recruitId, workExperienceList);
workList.forEach(recruitmentWorkMapper::insert);
}
private List<CcdiStaffRecruitmentWork> buildWorkExperienceEntities(Long recruitmentId,
String recruitId,
List<CcdiStaffRecruitmentWorkEditDTO> workExperienceList) {
if (workExperienceList == null || workExperienceList.isEmpty()) { if (workExperienceList == null || workExperienceList.isEmpty()) {
return new ArrayList<>(); return new ArrayList<>();
} }
@@ -264,7 +306,8 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
} }
CcdiStaffRecruitmentWork work = new CcdiStaffRecruitmentWork(); CcdiStaffRecruitmentWork work = new CcdiStaffRecruitmentWork();
BeanUtils.copyProperties(item, work); BeanUtils.copyProperties(item, work);
work.setRecruitId(editDTO.getRecruitId()); work.setRecruitmentId(recruitmentId);
work.setRecruitId(recruitId);
work.setSortOrder(i + 1); work.setSortOrder(i + 1);
entityList.add(work); entityList.add(work);
} }

View File

@@ -0,0 +1,130 @@
package com.ruoyi.info.collection.service.support;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import jakarta.annotation.Resource;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 关联业务实体库自动补全服务。
*/
@Service
public class EnterpriseAutoFillService {
private static final int BATCH_SIZE = 500;
@Resource
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper;
public record EnterpriseFillItem(
String socialCreditCode,
String enterpriseName,
String entSource,
String dataSource,
String userName
) {
}
@Transactional
public void ensureExists(EnterpriseFillItem item) {
ensureExistsBatch(List.of(item));
}
@Transactional
public void ensureExistsBatch(List<EnterpriseFillItem> items) {
if (StringUtils.isEmpty(items)) {
return;
}
Map<String, EnterpriseFillItem> normalizedItems = normalizeItems(items);
if (normalizedItems.isEmpty()) {
return;
}
Set<String> existingCodes = enterpriseBaseInfoMapper.selectBatchIds(new ArrayList<>(normalizedItems.keySet()))
.stream()
.map(CcdiEnterpriseBaseInfo::getSocialCreditCode)
.collect(Collectors.toSet());
List<CcdiEnterpriseBaseInfo> missingEntities = normalizedItems.entrySet().stream()
.filter(entry -> !existingCodes.contains(entry.getKey()))
.map(entry -> buildEntity(entry.getKey(), entry.getValue()))
.toList();
if (missingEntities.isEmpty()) {
return;
}
insertBatchIgnoreDuplicate(missingEntities);
}
private Map<String, EnterpriseFillItem> normalizeItems(List<EnterpriseFillItem> items) {
Map<String, EnterpriseFillItem> normalizedItems = new LinkedHashMap<>();
for (EnterpriseFillItem item : items) {
if (item == null || StringUtils.isEmpty(item.socialCreditCode())) {
continue;
}
String socialCreditCode = item.socialCreditCode().trim();
normalizedItems.putIfAbsent(socialCreditCode, new EnterpriseFillItem(
socialCreditCode,
trimToNull(item.enterpriseName()),
trimToNull(item.entSource()),
trimToNull(item.dataSource()),
trimToNull(item.userName())
));
}
return normalizedItems;
}
private CcdiEnterpriseBaseInfo buildEntity(String socialCreditCode, EnterpriseFillItem item) {
CcdiEnterpriseBaseInfo entity = new CcdiEnterpriseBaseInfo();
entity.setSocialCreditCode(socialCreditCode);
entity.setEnterpriseName(item.enterpriseName());
entity.setEntSource(item.entSource());
entity.setDataSource(item.dataSource());
entity.setRiskLevel(EnterpriseSource.INTERMEDIARY.getCode().equals(item.entSource()) ? "1" : null);
entity.setCreatedBy(item.userName());
entity.setUpdatedBy(item.userName());
return entity;
}
private void insertBatchIgnoreDuplicate(List<CcdiEnterpriseBaseInfo> entities) {
try {
for (int i = 0; i < entities.size(); i += BATCH_SIZE) {
int end = Math.min(i + BATCH_SIZE, entities.size());
enterpriseBaseInfoMapper.insertBatch(entities.subList(i, end));
}
} catch (DuplicateKeyException ex) {
insertOneByOneIgnoreDuplicate(entities);
}
}
private void insertOneByOneIgnoreDuplicate(List<CcdiEnterpriseBaseInfo> entities) {
for (CcdiEnterpriseBaseInfo entity : entities) {
if (enterpriseBaseInfoMapper.selectById(entity.getSocialCreditCode()) != null) {
continue;
}
try {
enterpriseBaseInfoMapper.insert(entity);
} catch (DuplicateKeyException duplicate) {
if (enterpriseBaseInfoMapper.selectById(entity.getSocialCreditCode()) == null) {
throw duplicate;
}
}
}
}
private String trimToNull(String value) {
if (StringUtils.isEmpty(value)) {
return null;
}
return value.trim();
}
}

View File

@@ -2,19 +2,36 @@ package com.ruoyi.info.collection.utils;
import com.alibaba.excel.EasyExcel; import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter; import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.metadata.WriteSheet; import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.handler.WriteHandler; import com.alibaba.excel.write.handler.WriteHandler;
import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy; import com.alibaba.excel.write.style.column.LongestMatchColumnWidthStyleStrategy;
import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.info.collection.handler.DictDropdownWriteHandler; import com.ruoyi.info.collection.handler.DictDropdownWriteHandler;
import com.ruoyi.info.collection.handler.RequiredFieldWriteHandler; import com.ruoyi.info.collection.handler.RequiredFieldWriteHandler;
import com.ruoyi.info.collection.handler.TextFormatWriteHandler; import com.ruoyi.info.collection.handler.TextFormatWriteHandler;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.AccessLevel; import lombok.AccessLevel;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.ss.util.CellRangeAddress;
import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List; import java.util.List;
/** /**
@@ -77,8 +94,10 @@ public class EasyExcelUtil {
* @return 数据列表 * @return 数据列表
*/ */
public static <T> List<T> importExcel(String fileName, Class<T> clazz) { public static <T> List<T> importExcel(String fileName, Class<T> clazz) {
try { try (InputStream inputStream = java.nio.file.Files.newInputStream(java.nio.file.Path.of(fileName))) {
return EasyExcel.read(fileName).head(clazz).sheet().doReadSync(); return importExcel(inputStream, clazz);
} catch (ServiceException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("导入Excel失败", e); throw new RuntimeException("导入Excel失败", e);
} }
@@ -94,7 +113,11 @@ public class EasyExcelUtil {
*/ */
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz) { public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz) {
try { try {
return EasyExcel.read(inputStream).head(clazz).sheet().doReadSync(); byte[] bytes = inputStream.readAllBytes();
validateDictDropdownTemplate(bytes, clazz, null);
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet().doReadSync();
} catch (ServiceException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("导入Excel失败", e); throw new RuntimeException("导入Excel失败", e);
} }
@@ -111,7 +134,11 @@ public class EasyExcelUtil {
*/ */
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz, String sheetName) { public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz, String sheetName) {
try { try {
return EasyExcel.read(inputStream).head(clazz).sheet(sheetName).doReadSync(); byte[] bytes = inputStream.readAllBytes();
validateDictDropdownTemplate(bytes, clazz, sheetName);
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet(sheetName).doReadSync();
} catch (ServiceException e) {
throw e;
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("导入Excel失败", e); throw new RuntimeException("导入Excel失败", e);
} }
@@ -128,9 +155,10 @@ public class EasyExcelUtil {
public static <T> void importTemplateExcel(HttpServletResponse response, Class<T> clazz, String sheetName) { public static <T> void importTemplateExcel(HttpServletResponse response, Class<T> clazz, String sheetName) {
try { try {
setResponseHeader(response, sheetName + "模板"); setResponseHeader(response, sheetName + "模板");
EasyExcel.write(response.getOutputStream(), clazz) templateWriter(response, clazz)
.sheet(sheetName) .sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new TextFormatWriteHandler(clazz))
.doWrite(List.of()); .doWrite(List.of());
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("下载导入模板失败", e); throw new RuntimeException("下载导入模板失败", e);
@@ -151,9 +179,10 @@ public class EasyExcelUtil {
WriteHandler... handlers) { WriteHandler... handlers) {
try { try {
setResponseHeader(response, sheetName + "模板"); setResponseHeader(response, sheetName + "模板");
var writerBuilder = EasyExcel.write(response.getOutputStream(), clazz) var writerBuilder = templateWriter(response, clazz)
.sheet(sheetName) .sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()); .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new TextFormatWriteHandler(clazz));
// 注册所有自定义处理器 // 注册所有自定义处理器
for (WriteHandler handler : handlers) { for (WriteHandler handler : handlers) {
writerBuilder.registerWriteHandler(handler); writerBuilder.registerWriteHandler(handler);
@@ -190,7 +219,7 @@ public class EasyExcelUtil {
public static <T> void importTemplateWithDictDropdown(HttpServletResponse response, Class<T> clazz, String sheetName) { public static <T> void importTemplateWithDictDropdown(HttpServletResponse response, Class<T> clazz, String sheetName) {
try { try {
setResponseHeader(response, sheetName + "模板"); setResponseHeader(response, sheetName + "模板");
EasyExcel.write(response.getOutputStream(), clazz) templateWriter(response, clazz)
.sheet(sheetName) .sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new DictDropdownWriteHandler(clazz)) .registerWriteHandler(new DictDropdownWriteHandler(clazz))
@@ -217,7 +246,7 @@ public class EasyExcelUtil {
String sheetName, String fileName) { String sheetName, String fileName) {
try { try {
setResponseHeader(response, fileName); setResponseHeader(response, fileName);
EasyExcel.write(response.getOutputStream(), clazz) templateWriter(response, clazz)
.sheet(sheetName) .sheet(sheetName)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy()) .registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new DictDropdownWriteHandler(clazz)) .registerWriteHandler(new DictDropdownWriteHandler(clazz))
@@ -250,7 +279,7 @@ public class EasyExcelUtil {
String fileName String fileName
) { ) {
setResponseHeader(response, fileName); setResponseHeader(response, fileName);
try (ExcelWriter writer = EasyExcel.write(response.getOutputStream()).build()) { try (ExcelWriter writer = templateWriter(response).build()) {
writer.write(List.of(), buildTemplateSheet(0, firstClazz, firstSheetName)); writer.write(List.of(), buildTemplateSheet(0, firstClazz, firstSheetName));
writer.write(List.of(), buildTemplateSheet(1, secondClazz, secondSheetName)); writer.write(List.of(), buildTemplateSheet(1, secondClazz, secondSheetName));
} catch (IOException e) { } catch (IOException e) {
@@ -261,7 +290,6 @@ public class EasyExcelUtil {
private static <T> WriteSheet buildTemplateSheet(int sheetNo, Class<T> clazz, String sheetName) { private static <T> WriteSheet buildTemplateSheet(int sheetNo, Class<T> clazz, String sheetName) {
return EasyExcel.writerSheet(sheetNo, sheetName) return EasyExcel.writerSheet(sheetNo, sheetName)
.head(clazz) .head(clazz)
.registerWriteHandler(new LongestMatchColumnWidthStyleStrategy())
.registerWriteHandler(new DictDropdownWriteHandler(clazz)) .registerWriteHandler(new DictDropdownWriteHandler(clazz))
.registerWriteHandler(new TextFormatWriteHandler(clazz)) .registerWriteHandler(new TextFormatWriteHandler(clazz))
.registerWriteHandler(new RequiredFieldWriteHandler(clazz)) .registerWriteHandler(new RequiredFieldWriteHandler(clazz))
@@ -322,4 +350,137 @@ public class EasyExcelUtil {
throw new RuntimeException("导出带字典下拉框的Excel失败", e); throw new RuntimeException("导出带字典下拉框的Excel失败", e);
} }
} }
private static void validateDictDropdownTemplate(byte[] bytes, Class<?> clazz, String sheetName) {
List<DropdownColumn> dropdownColumns = resolveDropdownColumns(clazz);
if (dropdownColumns.isEmpty()) {
return;
}
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) {
Sheet sheet = sheetName == null ? workbook.getSheetAt(0) : workbook.getSheet(sheetName);
if (sheet == null) {
return;
}
int lastDataRowIndex = findLastDataRowIndex(sheet);
if (lastDataRowIndex < 1) {
return;
}
List<String> missingColumnTitles = new ArrayList<>();
for (DropdownColumn column : dropdownColumns) {
if (!isListValidationCovered(sheet, column.index(), lastDataRowIndex)) {
missingColumnTitles.add(column.title());
}
}
if (!missingColumnTitles.isEmpty()) {
throw new ServiceException(sheet.getSheetName() + " Sheet 的 "
+ String.join("", missingColumnTitles)
+ " 列缺少下拉框,请下载最新导入模板填写后重新导入");
}
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导入Excel失败", e);
}
}
private static List<DropdownColumn> resolveDropdownColumns(Class<?> clazz) {
List<DropdownColumn> columns = new ArrayList<>();
Class<?> current = clazz;
while (current != null && current != Object.class) {
for (Field field : current.getDeclaredFields()) {
if (field.getAnnotation(DictDropdown.class) == null) {
continue;
}
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
if (excelProperty == null || excelProperty.index() < 0) {
continue;
}
columns.add(new DropdownColumn(excelProperty.index(), resolveColumnTitle(field, excelProperty)));
}
current = current.getSuperclass();
}
columns.sort(Comparator.comparingInt(DropdownColumn::index));
return columns;
}
private static String resolveColumnTitle(Field field, ExcelProperty excelProperty) {
if (excelProperty.value().length > 0 && excelProperty.value()[0] != null
&& !excelProperty.value()[0].isBlank()) {
return excelProperty.value()[0].replace("*", "");
}
return field.getName();
}
private static int findLastDataRowIndex(Sheet sheet) {
int lastDataRowIndex = -1;
for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (hasData(row)) {
lastDataRowIndex = rowIndex;
}
}
return lastDataRowIndex;
}
private static boolean hasData(Row row) {
if (row == null || row.getLastCellNum() < 0) {
return false;
}
for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
if (cellIndex < 0) {
continue;
}
Cell cell = row.getCell(cellIndex);
if (cell != null && cell.toString() != null && !cell.toString().isBlank()) {
return true;
}
}
return false;
}
private static boolean isListValidationCovered(Sheet sheet, int columnIndex, int lastDataRowIndex) {
boolean[] coveredRows = new boolean[lastDataRowIndex + 1];
for (DataValidation validation : sheet.getDataValidations()) {
DataValidationConstraint constraint = validation.getValidationConstraint();
if (constraint == null || constraint.getValidationType() != DataValidationConstraint.ValidationType.LIST) {
continue;
}
for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) {
if (address.getFirstColumn() > columnIndex || address.getLastColumn() < columnIndex) {
continue;
}
int firstRow = Math.max(1, address.getFirstRow());
int lastRow = Math.min(lastDataRowIndex, address.getLastRow());
for (int rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
coveredRows[rowIndex] = true;
}
}
}
for (int rowIndex = 1; rowIndex <= lastDataRowIndex; rowIndex++) {
if (hasData(sheet.getRow(rowIndex)) && !coveredRows[rowIndex]) {
return false;
}
}
return true;
}
private record DropdownColumn(int index, String title) {}
private static <T> ExcelWriterBuilder templateWriter(HttpServletResponse response, Class<T> clazz)
throws IOException {
// 模板为空且体量小,使用内存工作簿避免 SXSSF 在无字体环境初始化 Fontconfig。
return EasyExcel.write(response.getOutputStream(), clazz).inMemory(Boolean.TRUE);
}
private static ExcelWriterBuilder templateWriter(HttpServletResponse response) throws IOException {
return EasyExcel.write(response.getOutputStream()).inMemory(Boolean.TRUE);
}
} }

View File

@@ -42,7 +42,7 @@
AND e.status = #{query.status} AND e.status = #{query.status}
</if> </if>
</where> </where>
ORDER BY e.create_time DESC ORDER BY e.create_time DESC, e.staff_id DESC
</select> </select>
<!-- 批量插入或更新员工信息只更新非null字段 --> <!-- 批量插入或更新员工信息只更新非null字段 -->

View File

@@ -53,6 +53,9 @@
<if test="query.relationName != null and query.relationName != ''"> <if test="query.relationName != null and query.relationName != ''">
AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%') AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%')
</if> </if>
<if test="query.relationCertNo != null and query.relationCertNo != ''">
AND r.relation_cert_no LIKE CONCAT('%', #{query.relationCertNo}, '%')
</if>
ORDER BY r.create_time DESC ORDER BY r.create_time DESC
</select> </select>

View File

@@ -61,6 +61,9 @@
<if test="query.relationName != null and query.relationName != ''"> <if test="query.relationName != null and query.relationName != ''">
AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%') AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%')
</if> </if>
<if test="query.relationCertNo != null and query.relationCertNo != ''">
AND r.relation_cert_no LIKE CONCAT('%', #{query.relationCertNo}, '%')
</if>
<if test="query.status != null"> <if test="query.status != null">
AND r.status = #{query.status} AND r.status = #{query.status}
</if> </if>
@@ -115,6 +118,9 @@
<if test="query.relationName != null and query.relationName != ''"> <if test="query.relationName != null and query.relationName != ''">
AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%') AND r.relation_name LIKE CONCAT('%', #{query.relationName}, '%')
</if> </if>
<if test="query.relationCertNo != null and query.relationCertNo != ''">
AND r.relation_cert_no LIKE CONCAT('%', #{query.relationCertNo}, '%')
</if>
<if test="query.status != null"> <if test="query.status != null">
AND r.status = #{query.status} AND r.status = #{query.status}
</if> </if>

View File

@@ -6,7 +6,8 @@
<!-- 招聘信息ResultMap --> <!-- 招聘信息ResultMap -->
<resultMap type="com.ruoyi.info.collection.domain.vo.CcdiStaffRecruitmentVO" id="CcdiStaffRecruitmentVOResult"> <resultMap type="com.ruoyi.info.collection.domain.vo.CcdiStaffRecruitmentVO" id="CcdiStaffRecruitmentVOResult">
<id property="recruitId" column="recruit_id"/> <id property="id" column="id"/>
<result property="recruitId" column="recruit_id"/>
<result property="recruitName" column="recruit_name"/> <result property="recruitName" column="recruit_name"/>
<result property="posName" column="pos_name"/> <result property="posName" column="pos_name"/>
<result property="posCategory" column="pos_category"/> <result property="posCategory" column="pos_category"/>
@@ -33,17 +34,17 @@
<!-- 分页查询招聘信息列表 --> <!-- 分页查询招聘信息列表 -->
<select id="selectRecruitmentPage" resultMap="CcdiStaffRecruitmentVOResult"> <select id="selectRecruitmentPage" resultMap="CcdiStaffRecruitmentVOResult">
SELECT SELECT
r.recruit_id, r.recruit_name, r.pos_name, r.pos_category, r.pos_desc, r.id, r.recruit_id, r.recruit_name, r.pos_name, r.pos_category, r.pos_desc,
r.cand_name, r.recruit_type, r.cand_edu, r.cand_id, r.cand_school, r.cand_major, r.cand_grad, r.cand_name, r.recruit_type, r.cand_edu, r.cand_id, r.cand_school, r.cand_major, r.cand_grad,
r.admit_status, COALESCE(w.work_experience_count, 0) AS work_experience_count, r.admit_status, COALESCE(w.work_experience_count, 0) AS work_experience_count,
r.interviewer_name1, r.interviewer_id1, r.interviewer_name2, r.interviewer_id2, r.interviewer_name1, r.interviewer_id1, r.interviewer_name2, r.interviewer_id2,
r.created_by, r.create_time, r.updated_by, r.update_time r.created_by, r.create_time, r.updated_by, r.update_time
FROM ccdi_staff_recruitment r FROM ccdi_staff_recruitment r
LEFT JOIN ( LEFT JOIN (
SELECT recruit_id COLLATE utf8mb4_general_ci AS recruit_id, COUNT(1) AS work_experience_count SELECT recruitment_id, COUNT(1) AS work_experience_count
FROM ccdi_staff_recruitment_work FROM ccdi_staff_recruitment_work
GROUP BY recruit_id COLLATE utf8mb4_general_ci GROUP BY recruitment_id
) w ON w.recruit_id COLLATE utf8mb4_general_ci = r.recruit_id COLLATE utf8mb4_general_ci ) w ON w.recruitment_id = r.id
<where> <where>
<if test="query.recruitName != null and query.recruitName != ''"> <if test="query.recruitName != null and query.recruitName != ''">
AND r.recruit_name LIKE CONCAT('%', #{query.recruitName}, '%') AND r.recruit_name LIKE CONCAT('%', #{query.recruitName}, '%')
@@ -78,16 +79,16 @@
<!-- 查询招聘信息详情 --> <!-- 查询招聘信息详情 -->
<select id="selectRecruitmentById" resultMap="CcdiStaffRecruitmentVOResult"> <select id="selectRecruitmentById" resultMap="CcdiStaffRecruitmentVOResult">
SELECT SELECT
recruit_id, recruit_name, pos_name, pos_category, pos_desc, id, recruit_id, recruit_name, pos_name, pos_category, pos_desc,
cand_name, recruit_type, cand_edu, cand_id, cand_school, cand_major, cand_grad, cand_name, recruit_type, cand_edu, cand_id, cand_school, cand_major, cand_grad,
admit_status, interviewer_name1, interviewer_id1, interviewer_name2, interviewer_id2, admit_status, interviewer_name1, interviewer_id1, interviewer_name2, interviewer_id2,
created_by, create_time, updated_by, update_time created_by, create_time, updated_by, update_time
FROM ccdi_staff_recruitment FROM ccdi_staff_recruitment
WHERE recruit_id = #{recruitId} WHERE id = #{id}
</select> </select>
<!-- 批量插入招聘信息数据 --> <!-- 批量插入招聘信息数据 -->
<insert id="insertBatch"> <insert id="insertBatch" useGeneratedKeys="true" keyProperty="id">
INSERT INTO ccdi_staff_recruitment INSERT INTO ccdi_staff_recruitment
(recruit_id, recruit_name, pos_name, pos_category, pos_desc, (recruit_id, recruit_name, pos_name, pos_category, pos_desc,
cand_name, recruit_type, cand_edu, cand_id, cand_school, cand_major, cand_grad, cand_name, recruit_type, cand_edu, cand_id, cand_school, cand_major, cand_grad,
@@ -124,7 +125,7 @@
interviewer_id2 = #{item.interviewerId2}, interviewer_id2 = #{item.interviewerId2},
updated_by = #{item.updatedBy}, updated_by = #{item.updatedBy},
update_time = NOW() update_time = NOW()
WHERE recruit_id = #{item.recruitId} WHERE id = #{item.id}
</foreach> </foreach>
</update> </update>

View File

@@ -21,4 +21,15 @@ class CcdiBaseStaffMapperTest {
assertTrue(xml.contains("#{item.partyMember}"), xml); assertTrue(xml.contains("#{item.partyMember}"), xml);
} }
} }
@Test
void mapperXml_shouldUseStableOrderForBaseStaffPagination() throws Exception {
try (InputStream inputStream = getClass().getClassLoader()
.getResourceAsStream("mapper/info/collection/CcdiBaseStaffMapper.xml")) {
String xml = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8)
.replaceAll("\\s+", " ");
assertTrue(xml.contains("ORDER BY e.create_time DESC, e.staff_id DESC"), xml);
}
}
} }

View File

@@ -96,6 +96,26 @@ class CcdiAssetInfoImportServiceImplTest {
assertEquals("320101199001010011", captor.getValue().get(0).getPersonId()); assertEquals("320101199001010011", captor.getValue().get(0).getPersonId());
} }
@Test
void importAssetInfoSync_shouldResolveFamilyIdFromCurrentWorkbookRelation() {
CcdiAssetInfoExcel excel = buildExcel("320101199001010033", "股权");
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(assetInfoMapper.selectOwnerCandidatesByRelationCertNos(List.of("320101199001010033")))
.thenReturn(List.of());
service.importAssetInfoSync(
List.of(excel),
"task-current-workbook",
"tester",
Map.of("320101199001010033", Set.of("320101199009090099"))
);
ArgumentCaptor<List<CcdiAssetInfo>> captor = ArgumentCaptor.forClass(List.class);
verify(assetInfoMapper).insertBatch(captor.capture());
assertEquals("320101199009090099", captor.getValue().get(0).getFamilyId());
assertEquals("320101199001010033", captor.getValue().get(0).getPersonId());
}
@Test @Test
void importAssetInfoAsync_shouldFailWhenEmployeeIdCardIsUsedForFamilyAssetImport() { void importAssetInfoAsync_shouldFailWhenEmployeeIdCardIsUsedForFamilyAssetImport() {
CcdiAssetInfoExcel excel = buildExcel("320101199001010011", "房产"); CcdiAssetInfoExcel excel = buildExcel("320101199001010011", "房产");

View File

@@ -19,6 +19,7 @@ import org.springframework.data.redis.core.ValueOperations;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -79,6 +80,26 @@ class CcdiBaseStaffAssetImportServiceImplTest {
assertEquals("320101199001010011", captor.getValue().get(0).getPersonId()); assertEquals("320101199001010011", captor.getValue().get(0).getPersonId());
} }
@Test
void importAssetInfoSync_shouldImportWhenOwnerComesFromCurrentWorkbook() {
CcdiBaseStaffAssetInfoExcel excel = buildExcel("320101199001010033", "存款");
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(assetInfoMapper.selectOwnerCandidatesByBaseStaffIdCards(List.of("320101199001010033")))
.thenReturn(List.of());
service.importAssetInfoSync(
List.of(excel),
"task-current-workbook",
"tester",
Map.of("320101199001010033", Set.of("320101199001010033"))
);
ArgumentCaptor<List<CcdiAssetInfo>> captor = ArgumentCaptor.forClass(List.class);
verify(assetInfoMapper).insertBatch(captor.capture());
assertEquals("320101199001010033", captor.getValue().get(0).getFamilyId());
assertEquals("320101199001010033", captor.getValue().get(0).getPersonId());
}
@Test @Test
void importAssetInfoAsync_shouldFailWhenFamilyCertificateIsUsed() { void importAssetInfoAsync_shouldFailWhenFamilyCertificateIsUsed() {
CcdiBaseStaffAssetInfoExcel excel = buildExcel("320101199201010022", "车辆"); CcdiBaseStaffAssetInfoExcel excel = buildExcel("320101199201010022", "车辆");

View File

@@ -1,14 +1,13 @@
package com.ruoyi.info.collection.service; package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiBizIntermediary; import com.ruoyi.info.collection.domain.CcdiBizIntermediary;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation; import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel; import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO; import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO;
import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper; import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.impl.CcdiIntermediaryEnterpriseRelationImportServiceImpl; import com.ruoyi.info.collection.service.impl.CcdiIntermediaryEnterpriseRelationImportServiceImpl;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
@@ -23,6 +22,7 @@ import java.util.List;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
@@ -42,7 +42,7 @@ class CcdiIntermediaryEnterpriseRelationImportServiceImplTest {
private CcdiBizIntermediaryMapper intermediaryMapper; private CcdiBizIntermediaryMapper intermediaryMapper;
@Mock @Mock
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper; private EnterpriseAutoFillService enterpriseAutoFillService;
@Mock @Mock
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@@ -58,11 +58,11 @@ class CcdiIntermediaryEnterpriseRelationImportServiceImplTest {
CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345"); CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345");
prepareFailureRedisMocks(); prepareFailureRedisMocks();
when(intermediaryMapper.selectList(any())).thenReturn(List.of()); when(intermediaryMapper.selectList(any())).thenReturn(List.of());
when(enterpriseBaseInfoMapper.selectList(any())).thenReturn(List.of(enterprise("91330100MA27X12345")));
service.importAsync(List.of(excel), "task-owner-miss", "tester"); service.importAsync(List.of(excel), "task-owner-miss", "tester");
verify(relationMapper, never()).insertBatch(any()); verify(relationMapper, never()).insertBatch(any());
verify(enterpriseAutoFillService, never()).ensureExistsBatch(any());
IntermediaryEnterpriseRelationImportFailureVO failure = IntermediaryEnterpriseRelationImportFailureVO failure =
firstFailure("import:intermediary-enterprise-relation:task-owner-miss:failures"); firstFailure("import:intermediary-enterprise-relation:task-owner-miss:failures");
assertEquals("320101199001010014", failure.getOwnerPersonId()); assertEquals("320101199001010014", failure.getOwnerPersonId());
@@ -70,20 +70,20 @@ class CcdiIntermediaryEnterpriseRelationImportServiceImplTest {
} }
@Test @Test
void importEnterpriseRelationAsync_shouldFailWhenEnterpriseDoesNotExist() { void importEnterpriseRelationAsync_shouldAutoFillWhenEnterpriseDoesNotExist() {
CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345"); CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345");
prepareFailureRedisMocks(); prepareStatusRedisMock();
when(intermediaryMapper.selectList(any())).thenReturn(List.of(owner("owner-biz", "320101199001010014"))); when(intermediaryMapper.selectList(any())).thenReturn(List.of(owner("owner-biz", "320101199001010014")));
when(enterpriseBaseInfoMapper.selectList(any())).thenReturn(List.of());
when(relationMapper.batchExistsByCombinations(any())).thenReturn(List.of()); when(relationMapper.batchExistsByCombinations(any())).thenReturn(List.of());
service.importAsync(List.of(excel), "task-ent-miss", "tester"); service.importAsync(List.of(excel), "task-ent-miss", "tester");
verify(relationMapper, never()).insertBatch(any()); ArgumentCaptor<List<CcdiIntermediaryEnterpriseRelation>> relationCaptor = ArgumentCaptor.forClass(List.class);
IntermediaryEnterpriseRelationImportFailureVO failure = verify(relationMapper).insertBatch(relationCaptor.capture());
firstFailure("import:intermediary-enterprise-relation:task-ent-miss:failures"); assertEquals(1, relationCaptor.getValue().size());
assertEquals("91330100MA27X12345", failure.getSocialCreditCode()); assertEquals("owner-biz", relationCaptor.getValue().get(0).getIntermediaryBizId());
assertTrue(failure.getErrorMessage().contains("机构表")); assertEquals("91330100MA27X12345", relationCaptor.getValue().get(0).getSocialCreditCode());
assertIntermediaryAutoFill("91330100MA27X12345");
} }
@Test @Test
@@ -96,10 +96,6 @@ class CcdiIntermediaryEnterpriseRelationImportServiceImplTest {
owner("owner-biz-1", "320101199001010014"), owner("owner-biz-1", "320101199001010014"),
owner("owner-biz-2", "320101199003030035") owner("owner-biz-2", "320101199003030035")
)); ));
when(enterpriseBaseInfoMapper.selectList(any())).thenReturn(List.of(
enterprise("91330100MA27X12345"),
enterprise("91330100MA27X12346")
));
when(relationMapper.batchExistsByCombinations(any())).thenReturn(List.of("owner-biz-1|91330100MA27X12345")); when(relationMapper.batchExistsByCombinations(any())).thenReturn(List.of("owner-biz-1|91330100MA27X12345"));
service.importAsync(List.of(duplicateInDb, duplicateInFile1, duplicateInFile2), "task-duplicate", "tester"); service.importAsync(List.of(duplicateInDb, duplicateInFile1, duplicateInFile2), "task-duplicate", "tester");
@@ -108,6 +104,7 @@ class CcdiIntermediaryEnterpriseRelationImportServiceImplTest {
verify(relationMapper).insertBatch(captor.capture()); verify(relationMapper).insertBatch(captor.capture());
assertEquals(1, captor.getValue().size()); assertEquals(1, captor.getValue().size());
assertEquals("owner-biz-2", captor.getValue().get(0).getIntermediaryBizId()); assertEquals("owner-biz-2", captor.getValue().get(0).getIntermediaryBizId());
assertIntermediaryAutoFill("91330100MA27X12346");
IntermediaryEnterpriseRelationImportFailureVO failure = IntermediaryEnterpriseRelationImportFailureVO failure =
firstFailure("import:intermediary-enterprise-relation:task-duplicate:failures"); firstFailure("import:intermediary-enterprise-relation:task-duplicate:failures");
assertTrue(failure.getErrorMessage().contains("重复") || failure.getErrorMessage().contains("已存在")); assertTrue(failure.getErrorMessage().contains("重复") || failure.getErrorMessage().contains("已存在"));
@@ -118,7 +115,6 @@ class CcdiIntermediaryEnterpriseRelationImportServiceImplTest {
CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345"); CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345");
prepareStatusRedisMock(); prepareStatusRedisMock();
when(intermediaryMapper.selectList(any())).thenReturn(List.of(owner("owner-biz", "320101199001010014"))); when(intermediaryMapper.selectList(any())).thenReturn(List.of(owner("owner-biz", "320101199001010014")));
when(enterpriseBaseInfoMapper.selectList(any())).thenReturn(List.of(enterprise("91330100MA27X12345")));
when(relationMapper.batchExistsByCombinations(any())).thenReturn(List.of()); when(relationMapper.batchExistsByCombinations(any())).thenReturn(List.of());
service.importAsync(List.of(excel), "task-success", "tester"); service.importAsync(List.of(excel), "task-success", "tester");
@@ -127,6 +123,7 @@ class CcdiIntermediaryEnterpriseRelationImportServiceImplTest {
verify(relationMapper).insertBatch(captor.capture()); verify(relationMapper).insertBatch(captor.capture());
assertEquals(1, captor.getValue().size()); assertEquals(1, captor.getValue().size());
assertEquals("owner-biz", captor.getValue().get(0).getIntermediaryBizId()); assertEquals("owner-biz", captor.getValue().get(0).getIntermediaryBizId());
assertIntermediaryAutoFill("91330100MA27X12345");
verify(valueOperations, never()).set(any(), any(), any(Long.class), any(TimeUnit.class)); verify(valueOperations, never()).set(any(), any(), any(Long.class), any(TimeUnit.class));
} }
@@ -163,10 +160,15 @@ class CcdiIntermediaryEnterpriseRelationImportServiceImplTest {
return owner; return owner;
} }
private CcdiEnterpriseBaseInfo enterprise(String socialCreditCode) { private void assertIntermediaryAutoFill(String socialCreditCode) {
CcdiEnterpriseBaseInfo enterprise = new CcdiEnterpriseBaseInfo(); ArgumentCaptor<List<EnterpriseAutoFillService.EnterpriseFillItem>> captor = ArgumentCaptor.forClass(List.class);
enterprise.setSocialCreditCode(socialCreditCode); verify(enterpriseAutoFillService).ensureExistsBatch(captor.capture());
enterprise.setEnterpriseName("机构" + socialCreditCode.substring(socialCreditCode.length() - 2)); assertEquals(1, captor.getValue().size());
return enterprise; EnterpriseAutoFillService.EnterpriseFillItem item = captor.getValue().get(0);
assertEquals(socialCreditCode, item.socialCreditCode());
assertNull(item.enterpriseName());
assertEquals("INTERMEDIARY", item.entSource());
assertEquals("IMPORT", item.dataSource());
assertEquals("tester", item.userName());
} }
} }

View File

@@ -2,6 +2,8 @@ package com.ruoyi.info.collection.service;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation; import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation;
import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation; import com.ruoyi.info.collection.domain.CcdiStaffFmyRelation;
import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationAddDTO; import com.ruoyi.info.collection.domain.dto.CcdiStaffEnterpriseRelationAddDTO;
@@ -10,8 +12,10 @@ import com.ruoyi.info.collection.domain.vo.CcdiStaffEnterpriseRelationOptionVO;
import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper; import com.ruoyi.info.collection.mapper.CcdiStaffFmyRelationMapper;
import com.ruoyi.info.collection.service.impl.CcdiStaffEnterpriseRelationServiceImpl; import com.ruoyi.info.collection.service.impl.CcdiStaffEnterpriseRelationServiceImpl;
import com.ruoyi.info.collection.service.support.EnterpriseAutoFillService;
import org.apache.ibatis.builder.MapperBuilderAssistant; import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.apache.ibatis.session.Configuration; import org.apache.ibatis.session.Configuration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
@@ -20,13 +24,17 @@ import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.List; import java.util.List;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -55,8 +63,17 @@ class CcdiStaffEnterpriseRelationServiceImplTest {
@Mock @Mock
private RedisTemplate<String, Object> redisTemplate; private RedisTemplate<String, Object> redisTemplate;
@Mock
private EnterpriseAutoFillService enterpriseAutoFillService;
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test @Test
void insertRelation_shouldAllowValidFamily() { void insertRelation_shouldAllowValidFamily() {
mockLoginUser("tester");
CcdiStaffEnterpriseRelationAddDTO addDTO = buildAddDto(); CcdiStaffEnterpriseRelationAddDTO addDTO = buildAddDto();
CcdiStaffFmyRelation familyRelation = new CcdiStaffFmyRelation(); CcdiStaffFmyRelation familyRelation = new CcdiStaffFmyRelation();
familyRelation.setRelationCertNo(addDTO.getPersonId()); familyRelation.setRelationCertNo(addDTO.getPersonId());
@@ -75,6 +92,13 @@ class CcdiStaffEnterpriseRelationServiceImplTest {
assertEquals(1, captor.getValue().getStatus()); assertEquals(1, captor.getValue().getStatus());
assertEquals("MANUAL", captor.getValue().getDataSource()); assertEquals("MANUAL", captor.getValue().getDataSource());
assertEquals(1, captor.getValue().getIsEmpFamily()); assertEquals(1, captor.getValue().getIsEmpFamily());
verify(enterpriseAutoFillService).ensureExists(argThat(item ->
"91310000123456789A".equals(item.socialCreditCode())
&& "测试企业".equals(item.enterpriseName())
&& "EMP_RELATION".equals(item.entSource())
&& "MANUAL".equals(item.dataSource())
&& "tester".equals(item.userName())
));
} }
@Test @Test
@@ -153,4 +177,13 @@ class CcdiStaffEnterpriseRelationServiceImplTest {
assistant.setCurrentNamespace(namespace); assistant.setCurrentNamespace(namespace);
TableInfoHelper.initTableInfo(assistant, entityClass); TableInfoHelper.initTableInfo(assistant, entityClass);
} }
private void mockLoginUser(String userName) {
SysUser user = new SysUser();
user.setUserName(userName);
LoginUser loginUser = new LoginUser(1L, 1L, user, Set.of());
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(loginUser, null, List.of());
SecurityContextHolder.getContext().setAuthentication(authentication);
}
} }

View File

@@ -1,6 +1,8 @@
package com.ruoyi.info.collection.service; package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitment; import com.ruoyi.info.collection.domain.CcdiStaffRecruitment;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel;
import com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO; import com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO;
import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper; import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper;
@@ -27,6 +29,7 @@ import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -55,7 +58,7 @@ class CcdiStaffRecruitmentImportServiceImplTest {
void shouldFailWholeWorkGroupWhenExistingHistoryExists() { void shouldFailWholeWorkGroupWhenExistingHistoryExists() {
when(redisTemplate.opsForValue()).thenReturn(valueOperations); when(redisTemplate.opsForValue()).thenReturn(valueOperations);
when(redisTemplate.opsForHash()).thenReturn(hashOperations); when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(recruitmentMapper.selectBatchIds(any())).thenReturn(List.of(buildRecruitment("RC001"))); when(recruitmentMapper.selectList(any())).thenReturn(List.of(buildRecruitment("RC001")));
when(recruitmentWorkMapper.selectCount(any())).thenReturn(1L); when(recruitmentWorkMapper.selectCount(any())).thenReturn(1L);
CcdiStaffRecruitmentWorkExcel workRow = new CcdiStaffRecruitmentWorkExcel(); CcdiStaffRecruitmentWorkExcel workRow = new CcdiStaffRecruitmentWorkExcel();
@@ -86,13 +89,81 @@ class CcdiStaffRecruitmentImportServiceImplTest {
assertEquals("招聘记录编号[RC001]已存在历史工作经历,不允许重复导入", failure.getErrorMessage()); assertEquals("招聘记录编号[RC001]已存在历史工作经历,不允许重复导入", failure.getErrorMessage());
} }
@Test
void shouldAllowDuplicateRecruitIdsWhenImportingMainSheet() {
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
when(recruitmentMapper.insert(any(CcdiStaffRecruitment.class))).thenReturn(1);
CcdiStaffRecruitmentExcel first = buildRecruitmentExcel("RC001", "张三");
CcdiStaffRecruitmentExcel second = buildRecruitmentExcel("RC001", "李四");
service.importRecruitmentAsync(List.of(first, second), Collections.emptyList(), "task-2", "admin");
verify(recruitmentMapper, times(2)).insert(any(CcdiStaffRecruitment.class));
verify(valueOperations, never()).set(eq("import:recruitment:task-2:failures"), any(), anyLong(), any());
}
@Test
void shouldAttachWorkToMatchedRecruitmentWhenRecruitIdIsDuplicated() {
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
CcdiStaffRecruitment matched = buildRecruitment(10L, "RC001", "张三");
CcdiStaffRecruitment other = buildRecruitment(11L, "RC001", "李四");
when(recruitmentMapper.selectList(any())).thenReturn(List.of(matched, other));
when(recruitmentWorkMapper.selectCount(any())).thenReturn(0L);
CcdiStaffRecruitmentWorkExcel workRow = buildWorkExcel("RC001", "张三");
service.importRecruitmentAsync(Collections.emptyList(), List.of(workRow), "task-3", "admin");
ArgumentCaptor<CcdiStaffRecruitmentWork> workCaptor = ArgumentCaptor.forClass(CcdiStaffRecruitmentWork.class);
verify(recruitmentWorkMapper).insert(workCaptor.capture());
assertEquals(10L, workCaptor.getValue().getRecruitmentId());
assertEquals("RC001", workCaptor.getValue().getRecruitId());
}
private CcdiStaffRecruitment buildRecruitment(String recruitId) { private CcdiStaffRecruitment buildRecruitment(String recruitId) {
return buildRecruitment(1L, recruitId, "张三");
}
private CcdiStaffRecruitment buildRecruitment(Long id, String recruitId, String candName) {
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
recruitment.setId(id);
recruitment.setRecruitId(recruitId); recruitment.setRecruitId(recruitId);
recruitment.setRecruitType("SOCIAL"); recruitment.setRecruitType("SOCIAL");
recruitment.setCandName("张三"); recruitment.setCandName(candName);
recruitment.setRecruitName("社会招聘项目"); recruitment.setRecruitName("社会招聘项目");
recruitment.setPosName("Java工程师"); recruitment.setPosName("Java工程师");
return recruitment; return recruitment;
} }
private CcdiStaffRecruitmentExcel buildRecruitmentExcel(String recruitId, String candName) {
CcdiStaffRecruitmentExcel excel = new CcdiStaffRecruitmentExcel();
excel.setRecruitId(recruitId);
excel.setRecruitName("社会招聘项目");
excel.setPosName("Java工程师");
excel.setPosCategory("技术类");
excel.setPosDesc("负责系统开发");
excel.setAdmitStatus("录用");
excel.setCandName(candName);
excel.setRecruitType("SOCIAL");
excel.setCandEdu("本科");
excel.setCandId(candName.equals("张三") ? "110105199001010010" : "110105199002020026");
excel.setCandGrad("202110");
excel.setCandSchool("四川大学");
excel.setCandMajor("法学");
return excel;
}
private CcdiStaffRecruitmentWorkExcel buildWorkExcel(String recruitId, String candName) {
CcdiStaffRecruitmentWorkExcel workRow = new CcdiStaffRecruitmentWorkExcel();
workRow.setRecruitId(recruitId);
workRow.setCandName(candName);
workRow.setRecruitName("社会招聘项目");
workRow.setPosName("Java工程师");
workRow.setSortOrder(1);
workRow.setCompanyName("测试科技");
workRow.setPositionName("开发工程师");
workRow.setJobStartMonth("2022-01");
return workRow;
}
} }

View File

@@ -2,12 +2,22 @@ package com.ruoyi.info.collection.utils;
import com.ruoyi.common.core.domain.entity.SysDictData; import com.ruoyi.common.core.domain.entity.SysDictData;
import com.ruoyi.common.utils.DictUtils; import com.ruoyi.common.utils.DictUtils;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel; import com.ruoyi.info.collection.domain.excel.CcdiAccountInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel; import com.ruoyi.info.collection.domain.excel.CcdiAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import com.ruoyi.info.collection.domain.excel.CcdiCustEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiCustFmyRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel; import com.ruoyi.info.collection.domain.excel.CcdiEnterpriseBaseInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel;
import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionExcel;
import com.ruoyi.info.collection.domain.excel.CcdiPurchaseTransactionSupplierExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel; import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffFmyRelationExcel;
import org.apache.poi.ss.usermodel.CellStyle; import org.apache.poi.ss.usermodel.CellStyle;
import org.apache.poi.ss.usermodel.DataValidation; import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.Row; import org.apache.poi.ss.usermodel.Row;
@@ -99,6 +109,63 @@ class EasyExcelUtilTemplateTest {
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) { try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
Sheet sheet = workbook.getSheetAt(0); Sheet sheet = workbook.getSheetAt(0);
assertTrue(hasValidationOnColumn(sheet, 7), "是否党员列应包含下拉校验"); assertTrue(hasValidationOnColumn(sheet, 7), "是否党员列应包含下拉校验");
assertHeaderValue(sheet, 0, "姓名*");
assertHeaderValue(sheet, 1, "员工ID*");
assertHeaderValue(sheet, 2, "所属部门ID*");
assertHeaderValue(sheet, 3, "身份证号*");
assertHeaderValue(sheet, 4, "电话*");
assertHeaderValue(sheet, 7, "是否党员*");
assertHeaderValue(sheet, 8, "状态*");
assertTextColumn(sheet, 3);
assertTextColumn(sheet, 4);
}
}
@Test
void importTemplateWithDictDropdown_shouldKeepBaseStaffDualSheetColumnWidths() throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
try (MockedStatic<DictUtils> mocked = mockStatic(DictUtils.class)) {
mocked.when(() -> DictUtils.getDictCache("ccdi_employee_status"))
.thenReturn(List.of(
buildDictData("在职", "0"),
buildDictData("离职", "1")
));
mocked.when(() -> DictUtils.getDictCache("ccdi_yes_no_flag"))
.thenReturn(List.of(
buildDictData("", "1"),
buildDictData("", "0")
));
mocked.when(() -> DictUtils.getDictCache("ccdi_asset_status"))
.thenReturn(List.of(
buildDictData("正常"),
buildDictData("冻结"),
buildDictData("处置中")
));
EasyExcelUtil.importTemplateWithDictDropdown(
response,
CcdiBaseStaffExcel.class,
"员工信息",
CcdiBaseStaffAssetInfoExcel.class,
"员工资产信息",
"员工信息维护导入模板"
);
}
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
Sheet staffSheet = workbook.getSheet("员工信息");
Sheet assetSheet = workbook.getSheet("员工资产信息");
assertNotNull(staffSheet);
assertNotNull(assetSheet);
assertColumnWidthsAtLeast(staffSheet, new int[] {16, 18, 20, 24, 18, 20, 18, 16, 14});
assertColumnWidthsAtLeast(assetSheet, new int[] {24, 18, 18, 24, 14, 20, 18, 18, 20, 16, 32});
assertHeaderValue(staffSheet, 0, "姓名*");
assertHeaderValue(staffSheet, 8, "状态*");
assertHeaderValue(assetSheet, 0, "员工身份证号*");
assertHeaderValue(assetSheet, 1, "资产大类*");
assertHeaderValue(assetSheet, 10, "备注");
} }
} }
@@ -113,6 +180,11 @@ class EasyExcelUtilTemplateTest {
buildDictData("未录用"), buildDictData("未录用"),
buildDictData("放弃") buildDictData("放弃")
)); ));
mocked.when(() -> DictUtils.getDictCache("ccdi_recruit_type"))
.thenReturn(List.of(
buildDictData("社招", "SOCIAL"),
buildDictData("校招", "CAMPUS")
));
EasyExcelUtil.importTemplateWithDictDropdown( EasyExcelUtil.importTemplateWithDictDropdown(
response, response,
@@ -128,6 +200,8 @@ class EasyExcelUtilTemplateTest {
assertEquals(2, workbook.getNumberOfSheets(), "招聘导入模板应输出双Sheet"); assertEquals(2, workbook.getNumberOfSheets(), "招聘导入模板应输出双Sheet");
assertEquals("招聘信息", workbook.getSheetAt(0).getSheetName()); assertEquals("招聘信息", workbook.getSheetAt(0).getSheetName());
assertEquals("历史工作经历", workbook.getSheetAt(1).getSheetName()); assertEquals("历史工作经历", workbook.getSheetAt(1).getSheetName());
assertTrue(hasValidationOnColumn(workbook.getSheetAt(0), 5), "录用情况列应包含下拉校验");
assertTrue(hasValidationOnColumn(workbook.getSheetAt(0), 7), "招聘类型列应包含下拉校验");
} }
} }
@@ -155,12 +229,119 @@ class EasyExcelUtilTemplateTest {
} }
} }
@Test
void infoImportTemplates_shouldFormatIdentifierAndContactColumnsAsText() throws Exception {
try (MockedStatic<DictUtils> mocked = mockStatic(DictUtils.class)) {
mockCommonDicts(mocked);
assertPlainTemplateTextColumns(CcdiAccountInfoExcel.class, "账户库管理", 1, 3, 7);
assertSingleTemplateTextColumns(CcdiAssetInfoExcel.class, "亲属资产信息", 0);
assertSingleTemplateTextColumns(CcdiBaseStaffAssetInfoExcel.class, "员工资产信息", 0);
assertDualTemplateTextColumns(
CcdiBaseStaffExcel.class,
"员工信息",
new int[] {3, 4},
CcdiBaseStaffAssetInfoExcel.class,
"员工资产信息",
new int[] {0},
"员工信息维护导入模板"
);
assertSingleTemplateTextColumns(CcdiCustEnterpriseRelationExcel.class, "信贷客户实体关联信息", 0, 1);
assertSingleTemplateTextColumns(CcdiCustFmyRelationExcel.class, "信贷客户家庭关系", 0, 6, 7, 8);
assertSingleTemplateTextColumns(CcdiEnterpriseBaseInfoExcel.class, "实体库管理", 0, 10);
assertSingleTemplateTextColumns(CcdiIntermediaryPersonExcel.class, "个人中介信息", 5, 6, 10, 12);
assertSingleTemplateTextColumns(CcdiIntermediaryEntityExcel.class, "实体中介信息", 1, 10);
assertSingleTemplateTextColumns(CcdiIntermediaryEnterpriseRelationExcel.class, "中介实体关联关系信息", 0, 1);
assertDualTemplateTextColumns(
CcdiPurchaseTransactionExcel.class,
"招投标主信息",
new int[] {0, 21, 24},
CcdiPurchaseTransactionSupplierExcel.class,
"供应商信息",
new int[] {0, 2, 4, 5},
"招投标信息维护导入模板"
);
assertSingleTemplateTextColumns(CcdiStaffEnterpriseRelationExcel.class, "员工亲属实体关联", 0, 1);
assertSingleTemplateTextColumns(CcdiStaffFmyRelationExcel.class, "员工亲属关系信息", 0, 6, 7, 8);
assertDualTemplateTextColumns(
CcdiStaffRecruitmentExcel.class,
"招聘信息",
new int[] {0, 9, 14, 16},
CcdiStaffRecruitmentWorkExcel.class,
"历史工作经历",
new int[] {0},
"招聘信息管理导入模板"
);
}
}
private void assertTextColumn(Sheet sheet, int columnIndex) { private void assertTextColumn(Sheet sheet, int columnIndex) {
CellStyle style = sheet.getColumnStyle(columnIndex); CellStyle style = sheet.getColumnStyle(columnIndex);
assertNotNull(style, "文本列应设置默认样式"); assertNotNull(style, "文本列应设置默认样式");
assertEquals("@", style.getDataFormatString(), "证件号列应使用文本格式"); assertEquals("@", style.getDataFormatString(), "证件号列应使用文本格式");
} }
private void assertHeaderValue(Sheet sheet, int columnIndex, String expectedValue) {
assertEquals(expectedValue, sheet.getRow(0).getCell(columnIndex).getStringCellValue());
}
private void assertTextColumns(Sheet sheet, int... columnIndexes) {
for (int columnIndex : columnIndexes) {
assertTextColumn(sheet, columnIndex);
}
}
private void assertPlainTemplateTextColumns(Class<?> clazz, String sheetName, int... columnIndexes) throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
EasyExcelUtil.importTemplateExcel(response, clazz, sheetName);
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
assertTextColumns(workbook.getSheetAt(0), columnIndexes);
}
}
private void assertSingleTemplateTextColumns(Class<?> clazz, String sheetName, int... columnIndexes) throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
EasyExcelUtil.importTemplateWithDictDropdown(response, clazz, sheetName);
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
assertTextColumns(workbook.getSheetAt(0), columnIndexes);
}
}
private <T1, T2> void assertDualTemplateTextColumns(Class<T1> firstClazz,
String firstSheetName,
int[] firstColumnIndexes,
Class<T2> secondClazz,
String secondSheetName,
int[] secondColumnIndexes,
String fileName) throws Exception {
MockHttpServletResponse response = new MockHttpServletResponse();
EasyExcelUtil.importTemplateWithDictDropdown(
response,
firstClazz,
firstSheetName,
secondClazz,
secondSheetName,
fileName
);
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(response.getContentAsByteArray()))) {
assertTextColumns(workbook.getSheet(firstSheetName), firstColumnIndexes);
assertTextColumns(workbook.getSheet(secondSheetName), secondColumnIndexes);
}
}
private void assertColumnWidthsAtLeast(Sheet sheet, int[] expectedWidths) {
for (int columnIndex = 0; columnIndex < expectedWidths.length; columnIndex++) {
int currentColumnIndex = columnIndex;
int expectedWidth = expectedWidths[currentColumnIndex];
int expected = expectedWidth * 256;
assertTrue(sheet.getColumnWidth(currentColumnIndex) >= expected,
() -> sheet.getSheetName() + "" + (currentColumnIndex + 1) + "列宽度应不小于" + expectedWidth);
}
}
private boolean hasValidationOnColumn(Sheet sheet, int columnIndex) { private boolean hasValidationOnColumn(Sheet sheet, int columnIndex) {
for (DataValidation validation : sheet.getDataValidations()) { for (DataValidation validation : sheet.getDataValidations()) {
for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) { for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) {
@@ -172,6 +353,33 @@ class EasyExcelUtilTemplateTest {
return false; return false;
} }
private void mockCommonDicts(MockedStatic<DictUtils> mocked) {
mocked.when(() -> DictUtils.getDictCache("ccdi_asset_status"))
.thenReturn(List.of(buildDictData("正常")));
mocked.when(() -> DictUtils.getDictCache("ccdi_employee_status"))
.thenReturn(List.of(buildDictData("在职", "1")));
mocked.when(() -> DictUtils.getDictCache("ccdi_yes_no_flag"))
.thenReturn(List.of(buildDictData("", "1")));
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("居民身份证")));
mocked.when(() -> DictUtils.getDictCache("ccdi_entity_type"))
.thenReturn(List.of(buildDictData("有限责任公司")));
mocked.when(() -> DictUtils.getDictCache("ccdi_enterprise_nature"))
.thenReturn(List.of(buildDictData("民营企业")));
mocked.when(() -> DictUtils.getDictCache("ccdi_person_type"))
.thenReturn(List.of(buildDictData("中介")));
mocked.when(() -> DictUtils.getDictCache("ccdi_person_sub_type"))
.thenReturn(List.of(buildDictData("本人")));
mocked.when(() -> DictUtils.getDictCache("ccdi_admit_status"))
.thenReturn(List.of(buildDictData("录用")));
mocked.when(() -> DictUtils.getDictCache("ccdi_recruit_type"))
.thenReturn(List.of(buildDictData("社招", "SOCIAL")));
}
private SysDictData buildDictData(String label) { private SysDictData buildDictData(String label) {
return buildDictData(label, label); return buildDictData(label, label);
} }

View File

@@ -15,6 +15,7 @@ import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.File; import java.io.File;
import java.util.Arrays; import java.util.Arrays;
@@ -110,7 +111,15 @@ public class LsfxAnalysisClient {
* 上传文件 * 上传文件
*/ */
public UploadFileResponse uploadFile(Integer groupId, File file) { public UploadFileResponse uploadFile(Integer groupId, File file) {
log.info("【流水分析】上传文件请求: groupId={}, fileName={}", groupId, file.getName()); return uploadFile(groupId, file, file.getName());
}
/**
* 上传文件
*/
public UploadFileResponse uploadFile(Integer groupId, File file, String uploadFileName) {
String multipartFileName = StringUtils.hasText(uploadFileName) ? uploadFileName : file.getName();
log.info("【流水分析】上传文件请求: groupId={}, fileName={}", groupId, multipartFileName);
long startTime = System.currentTimeMillis(); long startTime = System.currentTimeMillis();
try { try {
@@ -118,7 +127,7 @@ public class LsfxAnalysisClient {
Map<String, Object> params = new HashMap<>(); Map<String, Object> params = new HashMap<>();
params.put("groupId", groupId); params.put("groupId", groupId);
params.put("files", file); params.put("files", HttpUtil.namedFileResource(file, multipartFileName));
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = new HashMap<>();
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId); headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);

View File

@@ -10,6 +10,7 @@ import org.springframework.http.*;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
@@ -31,6 +32,24 @@ public class HttpUtil {
@Resource @Resource
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
public static org.springframework.core.io.Resource namedFileResource(File file, String filename) {
return new NamedFileSystemResource(file, filename);
}
private static class NamedFileSystemResource extends FileSystemResource {
private final String filename;
NamedFileSystemResource(File file, String filename) {
super(file);
this.filename = StringUtils.hasText(filename) ? filename : file.getName();
}
@Override
public String getFilename() {
return filename;
}
}
/** /**
* 发送GET请求带查询参数和请求头 * 发送GET请求带查询参数和请求头
* @param url 请求URL * @param url 请求URL
@@ -207,6 +226,8 @@ public class HttpUtil {
if (value instanceof File) { if (value instanceof File) {
File file = (File) value; File file = (File) value;
body.add(key, new FileSystemResource(file)); body.add(key, new FileSystemResource(file));
} else if (value instanceof org.springframework.core.io.Resource) {
body.add(key, value);
} else { } else {
body.add(key, value); body.add(key, value);
} }

View File

@@ -659,7 +659,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
throw new RuntimeException("临时文件不存在: " + tempFilePath); throw new RuntimeException("临时文件不存在: " + tempFilePath);
} }
UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file); UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName());
if (uploadResponse == null || uploadResponse.getData() == null if (uploadResponse == null || uploadResponse.getData() == null
|| uploadResponse.getData().getUploadLogList() == null || uploadResponse.getData().getUploadLogList() == null
|| uploadResponse.getData().getUploadLogList().isEmpty()) { || uploadResponse.getData().getUploadLogList().isEmpty()) {
@@ -673,7 +673,7 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
} }
log.info("【文件上传】文件上传成功: logId={}", logId); log.info("【文件上传】文件上传成功: logId={}", logId);
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId); processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true);
log.info("【文件上传】处理完成: fileName={}", record.getFileName()); log.info("【文件上传】处理完成: fileName={}", record.getFileName());
return true; return true;
@@ -710,6 +710,14 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
Integer lsfxProjectId, Integer lsfxProjectId,
CcdiFileUploadRecord record, CcdiFileUploadRecord record,
Integer logId) { Integer logId) {
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false);
}
private void processRecordAfterLogIdReady(Long projectId,
Integer lsfxProjectId,
CcdiFileUploadRecord record,
Integer logId,
boolean preserveRecordFileName) {
log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId); log.info("【文件上传】步骤3: 更新状态为解析中, logId={}", logId);
record.setLogId(logId); record.setLogId(logId);
record.setFileStatus("parsing"); record.setFileStatus("parsing");
@@ -736,11 +744,13 @@ public class CcdiFileUploadServiceImpl implements ICcdiFileUploadService {
GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0); GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0);
Integer status = logItem.getStatus(); Integer status = logItem.getStatus();
String uploadStatusDesc = logItem.getUploadStatusDesc(); String uploadStatusDesc = logItem.getUploadStatusDesc();
String fileName = StringUtils.hasText(logItem.getUploadFileName()) if (!preserveRecordFileName) {
? logItem.getUploadFileName() String fileName = StringUtils.hasText(logItem.getUploadFileName())
: logItem.getDownloadFileName(); ? logItem.getUploadFileName()
if (StringUtils.hasText(fileName)) { : logItem.getDownloadFileName();
record.setFileName(fileName); if (StringUtils.hasText(fileName)) {
record.setFileName(fileName);
}
} }
if (logItem.getFileSize() != null) { if (logItem.getFileSize() != null) {
record.setFileSize(logItem.getFileSize()); record.setFileSize(logItem.getFileSize());

View File

@@ -14,9 +14,11 @@ import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.file.FileUtils; import com.ruoyi.common.utils.file.FileUtils;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import java.awt.Color; import java.awt.Color;
import java.io.Closeable;
import java.io.File; import java.io.File;
import java.io.IOException; import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.file.Files;
import java.text.DecimalFormat; import java.text.DecimalFormat;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
@@ -32,6 +34,7 @@ import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDType0Font; import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** /**
@@ -43,6 +46,11 @@ public class CcdiProjectOverviewReportPdfExporter {
private static final String CONTENT_TYPE = "application/pdf"; private static final String CONTENT_TYPE = "application/pdf";
private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private static final DateTimeFormatter EXPORT_TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("#,##0.00"); private static final DecimalFormat MONEY_FORMAT = new DecimalFormat("#,##0.00");
private final String pdfFontPath;
public CcdiProjectOverviewReportPdfExporter(@Value("${ccdi.report.pdf-font-path:}") String pdfFontPath) {
this.pdfFontPath = pdfFontPath;
}
public void export(HttpServletResponse response, CcdiProjectOverviewReportVO report) throws IOException { public void export(HttpServletResponse response, CcdiProjectOverviewReportVO report) throws IOException {
response.setContentType(CONTENT_TYPE); response.setContentType(CONTENT_TYPE);
@@ -51,9 +59,8 @@ public class CcdiProjectOverviewReportPdfExporter {
safeFileName(report.getProject().getProjectName()) + "_初核结果报告.pdf" safeFileName(report.getProject().getProjectName()) + "_初核结果报告.pdf"
); );
try (PDDocument document = new PDDocument()) { try (PDDocument document = new PDDocument(); LoadedChineseFont loadedFont = loadChineseFont(document)) {
PDType0Font font = loadChineseFont(document); PdfPageWriter writer = new PdfPageWriter(document, loadedFont.font());
PdfPageWriter writer = new PdfPageWriter(document, font);
writer.newPage(); writer.newPage();
writeCover(writer, report); writeCover(writer, report);
writeUploadSubjects(writer, report.getUploadSubjects()); writeUploadSubjects(writer, report.getUploadSubjects());
@@ -204,44 +211,113 @@ public class CcdiProjectOverviewReportPdfExporter {
); );
} }
private PDType0Font loadChineseFont(PDDocument document) throws IOException { private LoadedChineseFont loadChineseFont(PDDocument document) throws IOException {
List<String> candidates = List.of( if (pdfFontPath == null || pdfFontPath.isBlank()) {
"C:/Windows/Fonts/NotoSansSC-VF.ttf", throw new ServiceException("未配置PDF中文字体路径无法导出PDF报告");
"C:/Windows/Fonts/simhei.ttf",
"C:/Windows/Fonts/simsunb.ttf",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttf",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttf",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"
);
for (String path : candidates) {
File file = new File(path);
if (!file.exists() || !file.isFile()) {
continue;
}
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".ttf")) {
return PDType0Font.load(document, file);
}
if (lowerPath.endsWith(".ttc")) {
PDType0Font font = loadFirstCollectionFont(document, file);
if (font != null) {
return font;
}
}
} }
throw new ServiceException("未找到可用中文字体无法导出PDF报告"); String path = pdfFontPath.trim();
File file = new File(path);
if (!file.exists() || !file.isFile()) {
throw new ServiceException("配置的PDF中文字体路径不可用" + path);
}
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".ttf")) {
return new LoadedChineseFont(PDType0Font.load(document, file), null);
}
if (lowerPath.endsWith(".ttc")) {
LoadedChineseFont loadedFont = loadPreferredCollectionFont(document, file);
if (loadedFont != null) {
return loadedFont;
}
throw new ServiceException("配置的PDF中文字体不支持导出" + path);
}
if (lowerPath.endsWith(".otf")) {
return new LoadedChineseFont(loadOpenTypeFont(document, file), null);
}
throw new ServiceException("配置的PDF中文字体格式不支持" + path);
} }
private PDType0Font loadFirstCollectionFont(PDDocument document, File file) throws IOException { private PDType0Font loadOpenTypeFont(PDDocument document, File file) throws IOException {
try (var inputStream = Files.newInputStream(file.toPath())) {
return PDType0Font.load(document, inputStream, false);
}
}
private LoadedChineseFont loadPreferredCollectionFont(PDDocument document, File file) throws IOException {
LoadedChineseFont font = loadCollectionFontByName(document, file);
return font == null ? loadFirstCollectionFont(document, file) : font;
}
private LoadedChineseFont loadCollectionFontByName(PDDocument document, File file) throws IOException {
TrueTypeCollection collection = new TrueTypeCollection(file);
AtomicReference<PDType0Font> font = new AtomicReference<>(); AtomicReference<PDType0Font> font = new AtomicReference<>();
try (TrueTypeCollection collection = new TrueTypeCollection(file)) { try {
collection.processAllFonts(typeFont -> { collection.processAllFonts(typeFont -> {
if (font.get() == null) { if (font.get() == null && supportsTrueTypeSubset(typeFont)
&& isSimplifiedChineseFont(typeFont.getName())) {
font.set(PDType0Font.load(document, typeFont, true)); font.set(PDType0Font.load(document, typeFont, true));
} }
}); });
if (font.get() != null) {
return new LoadedChineseFont(font.get(), collection);
}
} finally {
if (font.get() == null) {
collection.close();
}
}
return null;
}
private boolean isSimplifiedChineseFont(String fontName) {
if (fontName == null) {
return false;
}
String lowerName = fontName.toLowerCase();
return lowerName.contains("sc")
|| lowerName.contains("hans")
|| lowerName.contains("gb")
|| lowerName.contains("hei")
|| lowerName.contains("song")
|| lowerName.contains("yahei")
|| lowerName.contains("simsun")
|| lowerName.contains("simhei");
}
private LoadedChineseFont loadFirstCollectionFont(PDDocument document, File file) throws IOException {
TrueTypeCollection collection = new TrueTypeCollection(file);
AtomicReference<PDType0Font> font = new AtomicReference<>();
try {
collection.processAllFonts(typeFont -> {
if (font.get() == null && supportsTrueTypeSubset(typeFont)) {
font.set(PDType0Font.load(document, typeFont, true));
}
});
if (font.get() != null) {
return new LoadedChineseFont(font.get(), collection);
}
} finally {
if (font.get() == null) {
collection.close();
}
}
return null;
}
private boolean supportsTrueTypeSubset(org.apache.fontbox.ttf.TrueTypeFont typeFont) throws IOException {
return typeFont.getTableMap().containsKey("glyf");
}
private record LoadedChineseFont(PDType0Font font, Closeable fontSource) implements Closeable {
@Override
public void close() throws IOException {
if (fontSource != null) {
fontSource.close();
}
} }
return font.get();
} }
private List<IndexedUploadSubject> indexedRows(List<CcdiProjectOverviewReportUploadSubjectVO> rows) { private List<IndexedUploadSubject> indexedRows(List<CcdiProjectOverviewReportUploadSubjectVO> rows) {

View File

@@ -1,6 +1,7 @@
package com.ruoyi.ccdi.project.service.impl; package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
import com.ruoyi.ccdi.project.domain.CcdiProject; import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectAbnormalAccountQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectEmployeeCreditNegativeQueryDTO;
@@ -39,12 +40,11 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionPageVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.ModelParamAllVO; import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper; import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewMapper;
import com.ruoyi.ccdi.project.service.ICcdiModelParamService;
import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService; import com.ruoyi.ccdi.project.service.ICcdiProjectOverviewService;
import com.ruoyi.common.exception.ServiceException; import com.ruoyi.common.exception.ServiceException;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
@@ -74,6 +74,9 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
@Resource @Resource
private CcdiProjectMapper projectMapper; private CcdiProjectMapper projectMapper;
@Resource
private CcdiModelParamMapper modelParamMapper;
@Resource @Resource
private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper; private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper;
@@ -89,9 +92,6 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
@Resource @Resource
private CcdiProjectOverviewReportPdfExporter reportPdfExporter; private CcdiProjectOverviewReportPdfExporter reportPdfExporter;
@Resource
private ICcdiModelParamService modelParamService;
@Override @Override
public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) { public CcdiProjectOverviewDashboardVO getDashboard(Long projectId) {
CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId); CcdiProject project = overviewMapper.selectDashboardBaseByProjectId(projectId);
@@ -328,7 +328,7 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
report.setUploadSubjects(defaultList(overviewMapper.selectReportUploadSubjects(projectId)).stream() report.setUploadSubjects(defaultList(overviewMapper.selectReportUploadSubjects(projectId)).stream()
.peek(item -> item.setDataPeriod(formatDataPeriod(item.getMinTrxDate(), item.getMaxTrxDate()))) .peek(item -> item.setDataPeriod(formatDataPeriod(item.getMinTrxDate(), item.getMaxTrxDate())))
.toList()); .toList());
report.setParams(buildReportParams(projectId)); report.setParams(buildReportParams(project));
report.setModelSummaries(defaultList(overviewMapper.selectReportRiskModelSummaries(projectId))); report.setModelSummaries(defaultList(overviewMapper.selectReportRiskModelSummaries(projectId)));
report.setRiskPeople(defaultList(overviewMapper.selectReportRiskPeople(projectId)).stream() report.setRiskPeople(defaultList(overviewMapper.selectReportRiskPeople(projectId)).stream()
.peek(item -> item.setActionLabel(ACTION_LABEL)) .peek(item -> item.setActionLabel(ACTION_LABEL))
@@ -554,21 +554,23 @@ public class CcdiProjectOverviewServiceImpl implements ICcdiProjectOverviewServi
return row; return row;
} }
private List<CcdiProjectOverviewReportParamVO> buildReportParams(Long projectId) { private List<CcdiProjectOverviewReportParamVO> buildReportParams(CcdiProject project) {
ModelParamAllVO response = modelParamService.selectAllParams(projectId); Long effectiveProjectId = "default".equals(project.getConfigType()) ? 0L : project.getProjectId();
return defaultList(response == null ? null : response.getModels()).stream() return defaultList(modelParamMapper.selectByProjectId(effectiveProjectId)).stream()
.flatMap(model -> defaultList(model.getParams()).stream().map(param -> { .map(this::buildReportParamRow)
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
row.setModelName(model.getModelName());
row.setParamName(param.getParamName());
row.setParamValue(param.getParamValue());
row.setParamUnit(param.getParamUnit());
row.setParamDesc(param.getParamDesc());
return row;
}))
.toList(); .toList();
} }
private CcdiProjectOverviewReportParamVO buildReportParamRow(CcdiModelParam param) {
CcdiProjectOverviewReportParamVO row = new CcdiProjectOverviewReportParamVO();
row.setModelName(param.getModelName());
row.setParamName(param.getParamName());
row.setParamValue(param.getParamValue());
row.setParamUnit(param.getParamUnit());
row.setParamDesc(param.getParamDesc());
return row;
}
private String formatDataPeriod(String minTrxDate, String maxTrxDate) { private String formatDataPeriod(String minTrxDate, String maxTrxDate) {
if (minTrxDate == null || minTrxDate.isBlank() || maxTrxDate == null || maxTrxDate.isBlank()) { if (minTrxDate == null || minTrxDate.isBlank() || maxTrxDate == null || maxTrxDate.isBlank()) {
return "-"; return "-";

View File

@@ -1,7 +1,9 @@
package com.ruoyi.ccdi.project.service; package com.ruoyi.ccdi.project.service;
import java.util.Arrays;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
class CcdiProjectOverviewServiceStructureTest { class CcdiProjectOverviewServiceStructureTest {
@@ -35,4 +37,15 @@ class CcdiProjectOverviewServiceStructureTest {
assertNotNull(clazz.getMethod("refreshOverviewEmployeeResults", Long.class, String.class)); assertNotNull(clazz.getMethod("refreshOverviewEmployeeResults", Long.class, String.class));
assertNotNull(clazz.getMethod("refreshProjectRiskCounts", Long.class, String.class)); assertNotNull(clazz.getMethod("refreshProjectRiskCounts", Long.class, String.class));
} }
@Test
void overviewServiceImplShouldNotDependOnModelParamService() throws Exception {
Class<?> clazz = Class.forName(
"com.ruoyi.ccdi.project.service.impl.CcdiProjectOverviewServiceImpl"
);
assertFalse(Arrays.stream(clazz.getDeclaredFields()).anyMatch(field ->
"com.ruoyi.ccdi.project.service.ICcdiModelParamService".equals(field.getType().getName())
));
}
} }

View File

@@ -265,7 +265,8 @@ class CcdiFileUploadServiceImplTest {
AtomicInteger sequence = new AtomicInteger(); AtomicInteger sequence = new AtomicInteger();
captureRecordStatus(events, sequence); captureRecordStatus(events, sequence);
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false)); .thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
@@ -291,7 +292,8 @@ class CcdiFileUploadServiceImplTest {
when(projectMapper.selectById(PROJECT_ID)).thenReturn(project); when(projectMapper.selectById(PROJECT_ID)).thenReturn(project);
when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1); when(bankStatementMapper.countMatchedStaffCountByProjectId(PROJECT_ID)).thenReturn(1);
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false)); .thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
@@ -317,7 +319,8 @@ class CcdiFileUploadServiceImplTest {
@Test @Test
void processFileAsync_shouldCleanupInsertedStatementsWhenFetchFails() throws IOException { void processFileAsync_shouldCleanupInsertedStatementsWhenFetchFails() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false)); .thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
@@ -364,6 +367,77 @@ class CcdiFileUploadServiceImplTest {
); );
} }
@Test
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
.thenReturn(buildEmptyBankStatementResponse());
CcdiFileUploadRecord record = buildRecord();
record.setFileName("原始流水.xlsx");
Path tempFile = createTempFile();
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
verify(lsfxClient).uploadFile(eq(LSFX_PROJECT_ID), argThat(file ->
file.getName().startsWith("upload-") && file.getName().endsWith(".xlsx")
), eq("原始流水.xlsx"));
}
@Test
void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any()))
.thenReturn(buildParsedSuccessStatusResponse("平台返回文件名.xlsx"));
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
.thenReturn(buildEmptyBankStatementResponse());
CcdiFileUploadRecord record = buildRecord();
record.setFileName("原始流水.xlsx");
Path tempFile = createTempFile();
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
"parsed_success".equals(item.getFileStatus())
&& "原始流水.xlsx".equals(item.getFileName()))
);
}
@Test
void processFileAsync_shouldKeepOriginalFileNameWhenParseStatusFails() throws IOException {
GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("平台失败文件名.xlsx");
GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0);
logItem.setStatus(-1);
logItem.setUploadStatusDesc("parse.failed");
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse);
CcdiFileUploadRecord record = buildRecord();
record.setFileName("原始流水.xlsx");
Path tempFile = createTempFile();
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
"parsed_failed".equals(item.getFileStatus())
&& "原始流水.xlsx".equals(item.getFileName()))
);
}
@Test @Test
void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() { void deleteFileUploadRecord_shouldDeletePlatformFileBankStatementsAndMarkDeleted() {
CcdiFileUploadRecord record = buildRecord(); CcdiFileUploadRecord record = buildRecord();
@@ -468,7 +542,8 @@ class CcdiFileUploadServiceImplTest {
AtomicInteger sequence = new AtomicInteger(); AtomicInteger sequence = new AtomicInteger();
captureRecordStatus(events, sequence); captureRecordStatus(events, sequence);
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false)); .thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
@@ -491,7 +566,8 @@ class CcdiFileUploadServiceImplTest {
List<CcdiFileUploadRecord> updates = new ArrayList<>(); List<CcdiFileUploadRecord> updates = new ArrayList<>();
captureUpdatedRecords(updates); captureUpdatedRecords(updates);
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false)); .thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
@@ -512,7 +588,7 @@ class CcdiFileUploadServiceImplTest {
List<CcdiFileUploadRecord> updates = new ArrayList<>(); List<CcdiFileUploadRecord> updates = new ArrayList<>();
captureUpdatedRecords(updates); captureUpdatedRecords(updates);
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())) when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenThrow(new RuntimeException("upload failed:" + "x".repeat(3000))); .thenThrow(new RuntimeException("upload failed:" + "x".repeat(3000)));
CcdiFileUploadRecord record = buildRecord(); CcdiFileUploadRecord record = buildRecord();
@@ -526,7 +602,8 @@ class CcdiFileUploadServiceImplTest {
@Test @Test
void fetchAndSaveBankStatements_shouldTrimLeAccountNoBeforeInsert() throws IOException { void fetchAndSaveBankStatements_shouldTrimLeAccountNoBeforeInsert() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false)); .thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
@@ -597,7 +674,8 @@ class CcdiFileUploadServiceImplTest {
AtomicInteger sequence = new AtomicInteger(); AtomicInteger sequence = new AtomicInteger();
captureRecordStatus(events, sequence); captureRecordStatus(events, sequence);
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse()); when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID))) when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false)); .thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse()); when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());

View File

@@ -12,6 +12,8 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewStatVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskHitTagVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleItemVO;
import com.ruoyi.common.exception.ServiceException;
import java.io.File;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.List; import java.util.List;
@@ -19,13 +21,14 @@ import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpServletResponse;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.assertTrue;
class CcdiProjectOverviewReportPdfExporterTest { class CcdiProjectOverviewReportPdfExporterTest {
@Test @Test
void shouldExportOverviewReportPdf() throws Exception { void shouldExportOverviewReportPdf() throws Exception {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter(); CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter(resolveTestFontPath());
MockHttpServletResponse response = new MockHttpServletResponse(); MockHttpServletResponse response = new MockHttpServletResponse();
exporter.export(response, buildReport()); exporter.export(response, buildReport());
@@ -36,6 +39,41 @@ class CcdiProjectOverviewReportPdfExporterTest {
assertTrue(response.getContentAsByteArray().length > 1000); assertTrue(response.getContentAsByteArray().length > 1000);
} }
@Test
void shouldRejectBlankPdfFontPath() {
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter("");
MockHttpServletResponse response = new MockHttpServletResponse();
ServiceException exception = assertThrows(ServiceException.class, () -> exporter.export(response, buildReport()));
assertEquals("未配置PDF中文字体路径无法导出PDF报告", exception.getMessage());
}
@Test
void shouldRejectUnavailablePdfFontPath() {
String missingPath = "/tmp/ccdi-missing-pdf-font-" + System.nanoTime() + ".ttc";
CcdiProjectOverviewReportPdfExporter exporter = new CcdiProjectOverviewReportPdfExporter(missingPath);
MockHttpServletResponse response = new MockHttpServletResponse();
ServiceException exception = assertThrows(ServiceException.class, () -> exporter.export(response, buildReport()));
assertTrue(exception.getMessage().contains(missingPath));
}
private String resolveTestFontPath() {
List<String> candidates = List.of(
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/STHeiti Light.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/System/Library/Fonts/Supplemental/Songti.ttc",
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc"
);
return candidates.stream()
.filter(path -> new File(path).isFile())
.findFirst()
.orElse(candidates.get(0));
}
private CcdiProjectOverviewReportVO buildReport() { private CcdiProjectOverviewReportVO buildReport() {
CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO(); CcdiProjectOverviewReportVO report = new CcdiProjectOverviewReportVO();
CcdiProject project = new CcdiProject(); CcdiProject project = new CcdiProject();

View File

@@ -1,6 +1,7 @@
package com.ruoyi.ccdi.project.service.impl; package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.ccdi.project.domain.CcdiModelParam;
import com.ruoyi.ccdi.project.domain.CcdiProject; import com.ruoyi.ccdi.project.domain.CcdiProject;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectPersonAnalysisDetailQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO; import com.ruoyi.ccdi.project.domain.dto.CcdiProjectRiskPeopleQueryDTO;
@@ -16,6 +17,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiBankStatementListVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectEmployeeRiskAggregateVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewDashboardVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewEmployeeHitRowVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectOverviewReportVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisDetailVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisBasicInfoVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO;
@@ -27,6 +29,7 @@ import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskModelPeopleVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectRiskPeopleOverviewVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectSuspiciousTransactionItemVO;
import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO; import com.ruoyi.ccdi.project.domain.vo.CcdiProjectTopRiskPeopleVO;
import com.ruoyi.ccdi.project.mapper.CcdiModelParamMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectMapper;
import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper; import com.ruoyi.ccdi.project.mapper.CcdiBankTagResultMapper;
import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper; import com.ruoyi.ccdi.project.mapper.CcdiProjectOverviewEmployeeResultMapper;
@@ -38,6 +41,7 @@ import java.util.List;
import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpServletResponse;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
@@ -65,6 +69,9 @@ class CcdiProjectOverviewServiceImplTest {
@Mock @Mock
private CcdiProjectMapper projectMapper; private CcdiProjectMapper projectMapper;
@Mock
private CcdiModelParamMapper modelParamMapper;
@Mock @Mock
private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper; private CcdiProjectOverviewEmployeeResultMapper overviewEmployeeResultMapper;
@@ -77,6 +84,9 @@ class CcdiProjectOverviewServiceImplTest {
@Mock @Mock
private CcdiProjectRiskDetailWorkbookExporter workbookExporter; private CcdiProjectRiskDetailWorkbookExporter workbookExporter;
@Mock
private CcdiProjectOverviewReportPdfExporter reportPdfExporter;
@Test @Test
void shouldBuildDashboardWithNoRiskCount() { void shouldBuildDashboardWithNoRiskCount() {
CcdiProject project = new CcdiProject(); CcdiProject project = new CcdiProject();
@@ -300,6 +310,37 @@ class CcdiProjectOverviewServiceImplTest {
); );
} }
@Test
void shouldExportOverviewReportParamsFromDefaultProjectConfig() throws Exception {
CcdiProject project = new CcdiProject();
project.setProjectId(40L);
project.setProjectName("测试项目");
project.setConfigType("default");
when(projectMapper.selectById(40L)).thenReturn(project);
CcdiProject dashboardProject = new CcdiProject();
dashboardProject.setProjectId(40L);
dashboardProject.setTargetCount(10);
dashboardProject.setHighRiskCount(1);
dashboardProject.setMediumRiskCount(2);
dashboardProject.setLowRiskCount(3);
when(overviewMapper.selectDashboardBaseByProjectId(40L)).thenReturn(dashboardProject);
when(modelParamMapper.selectByProjectId(0L)).thenReturn(List.of(
buildModelParam("LARGE_TRANSACTION", "大额交易模型", "单笔金额", "1000", "", "单笔金额阈值")
));
MockHttpServletResponse response = new MockHttpServletResponse();
service.exportOverviewReport(response, 40L);
ArgumentCaptor<CcdiProjectOverviewReportVO> captor =
ArgumentCaptor.forClass(CcdiProjectOverviewReportVO.class);
verify(modelParamMapper).selectByProjectId(0L);
verify(reportPdfExporter).export(eq(response), captor.capture());
assertEquals(1, captor.getValue().getParams().size());
assertEquals("大额交易模型", captor.getValue().getParams().getFirst().getModelName());
assertEquals("单笔金额", captor.getValue().getParams().getFirst().getParamName());
}
@Test @Test
void shouldReturnPersonAnalysisDetailWithBasicInfoAndGroupedAbnormalDetail() { void shouldReturnPersonAnalysisDetailWithBasicInfoAndGroupedAbnormalDetail() {
CcdiProject project = new CcdiProject(); CcdiProject project = new CcdiProject();
@@ -539,6 +580,24 @@ class CcdiProjectOverviewServiceImplTest {
return result; return result;
} }
private CcdiModelParam buildModelParam(
String modelCode,
String modelName,
String paramName,
String paramValue,
String paramUnit,
String paramDesc
) {
CcdiModelParam param = new CcdiModelParam();
param.setModelCode(modelCode);
param.setModelName(modelName);
param.setParamName(paramName);
param.setParamValue(paramValue);
param.setParamUnit(paramUnit);
param.setParamDesc(paramDesc);
return param;
}
private CcdiProjectRiskHitTagVO buildHitTag( private CcdiProjectRiskHitTagVO buildHitTag(
String modelCode, String modelCode,
String modelName, String modelName,

504
deploy/ccdi_function.sh Executable file
View File

@@ -0,0 +1,504 @@
#!/bin/sh
set -eu
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname "$0")" && pwd)
# ==================== 生产配置区:按服务器实际路径修改 ====================
# JDK 安装目录。留空时使用服务器已有 JAVA_HOME仍为空时使用 PATH 中的 java。
BACKEND_JAVA_HOME=""
# 后端 Jar 所在目录。生产目录结构为脚本在外层Jar 位于 backend/ruoyi-admin.jar。
APP_HOME="${SCRIPT_DIR}/backend"
# 后端 Jar 文件名。
JAR_NAME="ruoyi-admin.jar"
# Spring Profile。
SPRING_PROFILES_ACTIVE="uat"
# JVM 参数。
JAVA_OPTS="-Duser.timezone=Asia/Shanghai -Xms512m -Xmx1024m -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError"
# 额外应用启动参数,例如:--server.port=8080
APP_ARGS=""
# 停止进程等待秒数。
STOP_WAIT_SECONDS=30
# ==================== 以下为脚本逻辑,一般不需要修改 ====================
if [ "${APP_HOME#/}" = "${APP_HOME}" ]; then
APP_HOME="${SCRIPT_DIR}/${APP_HOME}"
fi
APP_HOME="${APP_HOME%/}"
JAR_PATH="${APP_HOME}/${JAR_NAME}"
FRONTEND_DIR="${SCRIPT_DIR}/frontend"
LOG_DIR="${APP_HOME}/logs"
CONSOLE_LOG="${LOG_DIR}/backend-console.log"
PID_FILE="${LOG_DIR}/backend-java.pid"
APP_MARKER="-Dccdi.backend.prod.home=${APP_HOME}"
JAVA_CMD="java"
RELATIVE_JAR_PATH=""
TIMESTAMP=$(date '+%Y%m%d%H%M%S')
BACKUP_ROOT="${SCRIPT_DIR}/backups"
BACKUP_DIR="${BACKUP_ROOT}/${TIMESTAMP}"
WORK_ROOT="${SCRIPT_DIR}/.deploy-work"
WORK_DIR="${WORK_ROOT}/release-${TIMESTAMP}"
RELEASE_ZIP=""
RELEASE_JAR=""
RELEASE_DIST_ZIP=""
FRONTEND_SOURCE_DIR=""
case "${APP_HOME}" in
"${SCRIPT_DIR}"/*)
RELATIVE_JAR_PATH="${APP_HOME#${SCRIPT_DIR}/}/${JAR_NAME}"
;;
esac
timestamp() {
date '+%Y-%m-%d %H:%M:%S'
}
log_info() {
printf '[%s] %s\n' "$(timestamp)" "$1"
}
log_error() {
printf '[%s] %s\n' "$(timestamp)" "$1" >&2
}
usage() {
cat <<'EOF'
用法: ./ccdi_function.sh [deploy|restart|stop] [上线压缩包路径]
命令:
deploy 备份 backend/ 与 frontend/,部署上线包,然后重启后端并持续输出日志
restart 停止旧后端进程,启动 backend/ruoyi-admin.jar并持续输出日志
stop 停止当前脚本目录对应的 backend/ruoyi-admin.jar 进程
上线包要求:
未传上线压缩包路径时,脚本会自动使用当前脚本目录下唯一的 .zip 文件。
上线压缩包根层必须包含 ruoyi-admin.jar 和 dist.zip。
dist.zip 解压后必须包含 dist/index.html。
生产目录示例:
ccdi_function.sh
backend/
frontend/
ccdi_YYYYMMDD.zip
说明:
restart 和 deploy 启动成功后会持续输出 backend/logs/backend-console.log。
按 Ctrl+C 仅退出日志查看,不会停止已启动的后端进程。
EOF
}
require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
log_error "缺少命令: $1"
exit 1
fi
}
resolve_path() {
input_path="$1"
case "${input_path}" in
/*)
printf '%s\n' "${input_path}"
;;
*)
input_dir=$(dirname "${input_path}")
input_base=$(basename "${input_path}")
printf '%s/%s\n' "$(CDPATH= cd -- "${input_dir}" && pwd)" "${input_base}"
;;
esac
}
resolve_java_cmd() {
configured_java_home="${BACKEND_JAVA_HOME}"
if [ -z "${configured_java_home}" ]; then
configured_java_home="${JAVA_HOME:-}"
fi
if [ -n "${configured_java_home}" ]; then
configured_java_home="${configured_java_home%/}"
if [ ! -x "${configured_java_home}/bin/java" ]; then
log_error "配置的 JAVA_HOME 无效,未找到可执行文件: ${configured_java_home}/bin/java"
exit 1
fi
JAVA_HOME="${configured_java_home}"
export JAVA_HOME
JAVA_CMD="${JAVA_HOME}/bin/java"
else
require_command "java"
JAVA_CMD="java"
fi
log_info "使用 Java 命令: ${JAVA_CMD}"
}
get_process_table() {
if ! ps -ef 2>/dev/null; then
log_error "执行 ps -ef 失败,无法扫描旧进程"
return 1
fi
}
is_backend_process_line() {
process_line="$1"
case "${process_line}" in
*"<defunct>"*)
return 1
;;
*" -jar ${JAR_PATH}"*)
return 0
;;
*"${APP_MARKER}"*)
return 0
;;
esac
if [ -n "${RELATIVE_JAR_PATH}" ]; then
case "${process_line}" in
*" -jar ${RELATIVE_JAR_PATH}"*)
return 0
;;
esac
fi
return 1
}
is_managed_pid() {
check_pid="$1"
if [ -z "${check_pid}" ] || ! kill -0 "${check_pid}" 2>/dev/null; then
return 1
fi
if ! process_table=$(get_process_table); then
return 1
fi
while IFS= read -r process_line; do
set -- ${process_line}
line_pid="${2:-}"
if [ "${line_pid}" = "${check_pid}" ] && is_backend_process_line "${process_line}"; then
return 0
fi
done <<EOF
${process_table}
EOF
return 1
}
collect_pids() {
all_pids=""
unique_pids=""
if ! process_table=$(get_process_table); then
return 1
fi
if [ -f "${PID_FILE}" ]; then
pid_file_value=$(sed -n '1p' "${PID_FILE}" 2>/dev/null || true)
if is_managed_pid "${pid_file_value}"; then
all_pids="${all_pids} ${pid_file_value}"
fi
fi
while IFS= read -r process_line; do
set -- ${process_line}
scan_pid="${2:-}"
case "${scan_pid}" in
''|*[!0-9]*)
continue
;;
esac
if is_backend_process_line "${process_line}"; then
all_pids="${all_pids} ${scan_pid}"
fi
done <<EOF
${process_table}
EOF
for scan_pid in ${all_pids}; do
case " ${unique_pids} " in
*" ${scan_pid} "*)
;;
*)
unique_pids="${unique_pids} ${scan_pid}"
;;
esac
done
printf '%s\n' "${unique_pids}" | xargs 2>/dev/null || true
}
start_backend() {
resolve_java_cmd
if [ ! -f "${JAR_PATH}" ]; then
log_error "未找到后端 Jar: ${JAR_PATH}"
exit 1
fi
if ! running_pids=$(collect_pids); then
log_error "扫描后端进程失败"
exit 1
fi
if [ -n "${running_pids}" ]; then
log_error "检测到后端已在运行: ${running_pids}"
exit 1
fi
mkdir -p "${LOG_DIR}"
printf '\n===== %s start =====\n' "$(timestamp)" >>"${CONSOLE_LOG}"
profile_arg=""
if [ -n "${SPRING_PROFILES_ACTIVE}" ]; then
profile_arg="--spring.profiles.active=${SPRING_PROFILES_ACTIVE}"
fi
log_info "开始启动后端 Jar: ${JAR_PATH}"
nohup "${JAVA_CMD}" "${APP_MARKER}" ${JAVA_OPTS} -jar "${JAR_PATH}" ${profile_arg} ${APP_ARGS} >>"${CONSOLE_LOG}" 2>&1 &
echo $! >"${PID_FILE}"
sleep 3
starter_pid=$(sed -n '1p' "${PID_FILE}" 2>/dev/null || true)
if [ -z "${starter_pid}" ] || ! kill -0 "${starter_pid}" 2>/dev/null; then
log_error "启动命令未保持运行,请检查日志: ${CONSOLE_LOG}"
exit 1
fi
log_info "后端启动完成PID: ${starter_pid}"
}
stop_backend() {
if ! pids=$(collect_pids); then
log_error "扫描后端进程失败"
exit 1
fi
if [ -z "${pids}" ]; then
log_info "未发现运行中的后端进程"
rm -f "${PID_FILE}"
return 0
fi
log_info "准备停止后端进程: ${pids}"
for pid in ${pids}; do
kill -TERM "${pid}" 2>/dev/null || true
done
elapsed=0
remaining_pids="${pids}"
while [ -n "${remaining_pids}" ] && [ "${elapsed}" -lt "${STOP_WAIT_SECONDS}" ]; do
sleep 1
elapsed=$((elapsed + 1))
remaining_pids=""
for pid in ${pids}; do
if kill -0 "${pid}" 2>/dev/null; then
remaining_pids="${remaining_pids} ${pid}"
fi
done
remaining_pids=$(printf '%s\n' "${remaining_pids}" | xargs 2>/dev/null || true)
done
if [ -n "${remaining_pids}" ]; then
log_info "仍有进程未退出,执行强制停止: ${remaining_pids}"
for pid in ${remaining_pids}; do
kill -KILL "${pid}" 2>/dev/null || true
done
fi
rm -f "${PID_FILE}"
log_info "后端停止完成"
}
follow_logs() {
require_command "tail"
mkdir -p "${LOG_DIR}"
touch "${CONSOLE_LOG}"
log_info "持续输出日志中,按 Ctrl+C 仅退出日志查看,不会停止后端进程"
tail -n 200 -F "${CONSOLE_LOG}"
}
restart_action() {
stop_backend
start_backend
follow_logs
}
resolve_release_zip() {
if [ -n "${RELEASE_ZIP}" ]; then
RELEASE_ZIP=$(resolve_path "${RELEASE_ZIP}")
else
mkdir -p "${WORK_ROOT}"
candidate_file="${WORK_ROOT}/zip-candidates-${TIMESTAMP}.txt"
find "${SCRIPT_DIR}" -maxdepth 1 -type f -name '*.zip' ! -name 'dist.zip' | sort >"${candidate_file}"
candidate_count=$(wc -l <"${candidate_file}" | tr -d ' ')
if [ "${candidate_count}" -eq 0 ]; then
log_error "未在脚本目录找到上线压缩包,请传入压缩包路径"
exit 1
fi
if [ "${candidate_count}" -gt 1 ]; then
log_error "脚本目录存在多个上线压缩包,请显式传入压缩包路径"
cat "${candidate_file}" >&2
exit 1
fi
RELEASE_ZIP=$(sed -n '1p' "${candidate_file}")
rm -f "${candidate_file}"
fi
if [ ! -f "${RELEASE_ZIP}" ]; then
log_error "上线压缩包不存在: ${RELEASE_ZIP}"
exit 1
fi
}
assert_deploy_layout() {
if [ ! -d "${APP_HOME}" ]; then
log_error "未找到后端目录: ${APP_HOME}"
exit 1
fi
if [ ! -d "${FRONTEND_DIR}" ]; then
log_error "未找到前端目录: ${FRONTEND_DIR}"
exit 1
fi
}
backup_dir() {
source_dir="$1"
target_dir="$2"
mkdir -p "${target_dir}"
if [ -n "$(find "${source_dir}" -mindepth 1 -maxdepth 1 -print -quit)" ]; then
cp -a "${source_dir}/." "${target_dir}/"
log_info "已备份 ${source_dir}${target_dir}"
else
log_info "目录为空,已创建空备份目录: ${target_dir}"
fi
}
backup_current_files() {
mkdir -p "${BACKUP_DIR}"
backup_dir "${APP_HOME}" "${BACKUP_DIR}/backend"
backup_dir "${FRONTEND_DIR}" "${BACKUP_DIR}/frontend"
}
extract_release_package() {
mkdir -p "${WORK_DIR}/release" "${WORK_DIR}/frontend"
log_info "开始解压上线压缩包: ${RELEASE_ZIP}"
unzip -q "${RELEASE_ZIP}" -d "${WORK_DIR}/release"
RELEASE_JAR="${WORK_DIR}/release/${JAR_NAME}"
RELEASE_DIST_ZIP="${WORK_DIR}/release/dist.zip"
if [ ! -f "${RELEASE_JAR}" ]; then
log_error "上线压缩包根层缺少 ${JAR_NAME}"
exit 1
fi
if [ ! -f "${RELEASE_DIST_ZIP}" ]; then
log_error "上线压缩包根层缺少 dist.zip"
exit 1
fi
unzip -q "${RELEASE_DIST_ZIP}" -d "${WORK_DIR}/frontend"
FRONTEND_SOURCE_DIR="${WORK_DIR}/frontend/dist"
if [ ! -f "${FRONTEND_SOURCE_DIR}/index.html" ]; then
log_error "dist.zip 解压后未找到 dist/index.html"
exit 1
fi
}
deploy_backend() {
target_jar="${APP_HOME}/${JAR_NAME}"
deploying_jar="${APP_HOME}/.${JAR_NAME}.deploying"
cp "${RELEASE_JAR}" "${deploying_jar}"
mv "${deploying_jar}" "${target_jar}"
log_info "后端 Jar 已部署: ${target_jar}"
}
deploy_frontend() {
find "${FRONTEND_DIR}" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
cp -a "${FRONTEND_SOURCE_DIR}/." "${FRONTEND_DIR}/"
log_info "前端文件已部署到: ${FRONTEND_DIR}"
}
cleanup_work_dir() {
rm -rf "${WORK_DIR}"
}
deploy_action() {
RELEASE_ZIP="${1:-}"
require_command "find"
require_command "unzip"
resolve_release_zip
assert_deploy_layout
backup_current_files
trap cleanup_work_dir 0
extract_release_package
deploy_backend
deploy_frontend
cleanup_work_dir
trap - 0
log_info "部署完成,备份目录: ${BACKUP_DIR}"
log_info "开始重启后端并输出日志"
restart_action
}
main() {
action="${1:-help}"
case "${action}" in
deploy)
shift
if [ "$#" -gt 1 ]; then
usage
exit 1
fi
deploy_action "${1:-}"
;;
restart)
shift
if [ "$#" -ne 0 ]; then
usage
exit 1
fi
restart_action
;;
stop)
shift
if [ "$#" -ne 0 ]; then
usage
exit 1
fi
stop_backend
;;
-h|--help|help)
usage
;;
*)
usage
exit 1
;;
esac
}
main "$@"

View File

@@ -2,6 +2,10 @@ FROM eclipse-temurin:21-jre-jammy
WORKDIR /app WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends fontconfig fonts-wqy-microhei \
&& rm -rf /var/lib/apt/lists/*
COPY backend/ruoyi-admin.jar /app/ruoyi-admin.jar COPY backend/ruoyi-admin.jar /app/ruoyi-admin.jar
RUN mkdir -p /app/data/ruoyi /app/logs RUN mkdir -p /app/data/ruoyi /app/logs

View File

@@ -0,0 +1,339 @@
# 员工资产导入与实体库自动补入修复设计
## 1. 背景
本设计用于修复以下后端问题:
1. 【员工信息维护】双 Sheet 导入时,`员工资产信息` Sheet 只能通过数据库已有员工反查归属,不能识别同一个导入文件中刚成功导入的员工。
2. 【员工亲属关系维护】双 Sheet 导入时,`亲属资产信息` Sheet 只能通过数据库已有亲属关系反查归属,不能识别同一个导入文件中刚成功导入的亲属关系。
3. 关联业务自动补入实体库能力在当前主工作区未完整接回,员工亲属、信贷客户、中介、供应商四类业务成功关联后,缺失企业需要统一补入 `ccdi_enterprise_base_info`
本次设计只涉及后端。不调整前端页面结构、上传入口、模板样式和前端轮询字段。
## 2. 目标
- 双 Sheet 导入时,主 Sheet 与资产 Sheet 在后端按业务依赖顺序执行。
- 资产 Sheet 可关联数据库已有主数据,也可关联同一文件中本轮主 Sheet 成功导入的数据。
- 主 Sheet 失败行不能作为资产归属依据。
- 继续返回当前前端已支持的两个任务 ID
- 员工信息维护:`staffTaskId``assetTaskId`
- 员工亲属关系维护:`relationTaskId``assetTaskId`
- 恢复统一 `EnterpriseAutoFillService`,并接入员工亲属、信贷客户、中介、供应商四类业务。
- 实体库自动补入只插入缺失企业,不更新已存在实体。
## 3. 不在本次范围
- 不改前端上传入口、轮询逻辑、失败记录展示和模板下载样式。
- 不合并双 Sheet 导入任务 ID。
- 不改变员工、亲属关系、资产、实体关联现有字段校验规则。
- 不改变实体库手工新增、编辑、导入的既有业务规则。
- 不增加兜底来源、降级来源或兼容性分支。
## 4. 总体架构
本次后端设计分为两条链路。
### 4.1 双 Sheet 导入编排
`/ccdi/baseStaff/importData``/ccdi/staffFmyRelation/importData` 保持原接口、原模板、原返回字段。
Controller 继续负责读取两个 Sheet但不再分别提交两个彼此独立的异步任务。服务层新增提交编排方法
- 员工信息维护提交方法接收 `List<CcdiBaseStaffExcel>``List<CcdiBaseStaffAssetInfoExcel>`
- 员工亲属关系维护提交方法接收 `List<CcdiStaffFmyRelationExcel>``List<CcdiAssetInfoExcel>`
提交方法按实际存在的 Sheet 初始化对应任务状态,并启动一个后台编排任务。编排任务内部按顺序执行:
1. 主 Sheet 校验与插入。
2. 收集本轮主 Sheet 成功上下文。
3. 资产 Sheet 校验与插入。
### 4.2 实体库自动补入
新增或恢复统一内部服务:
`ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java`
该服务是关联业务补入实体库的唯一后端入口。四类业务在业务校验通过、业务记录落库前调用:
- 员工亲属实体关联:`EMP_RELATION`
- 信贷客户实体关联:`CREDIT_CUSTOMER`
- 中介实体关联:`INTERMEDIARY`
- 招投标供应商:`SUPPLIER`
实体库缺失时最小插入;已存在时不更新。
供应商来源需要同步新增后端枚举 `EnterpriseSource.SUPPLIER("SUPPLIER", "供应商")`,并补充 `/ccdi/enum/enterpriseSource` 枚举接口测试。前端继续通过现有枚举接口展示来源,不需要调整页面结构。
## 5. 员工信息维护双 Sheet 导入设计
### 5.1 当前问题
当前 `/ccdi/baseStaff/importData` 分别读取 `员工信息``员工资产信息`,然后分别调用:
- `baseStaffService.importBaseStaff(staffList)`
- `baseStaffAssetImportService.importAssetInfo(assetList)`
两个任务互相独立。员工资产导入只通过数据库 `ccdi_base_staff.id_card` 查找归属,无法稳定看到同一文件中刚导入成功的员工。
### 5.2 设计方案
新增员工信息维护双 Sheet 后端编排能力。
提交阶段:
1. Controller 读取两个 Sheet。
2. 若两个 Sheet 都无数据,仍返回“至少需要一条数据”。
3. 若存在员工 Sheet生成并初始化 `staffTaskId`
4. 若存在员工资产 Sheet生成并初始化 `assetTaskId`
5. 返回当前前端兼容的 `BaseStaffImportSubmitResultVO`
6. 后台启动一个编排任务。
编排阶段:
1. 先执行员工主数据导入。
2. 沿用现有员工必填、身份证号、部门、员工 ID、身份证号重复校验。
3. 批量插入员工成功记录。
4. 收集本轮成功员工的 `idCard`
5. 更新员工任务状态和失败记录。
6. 再执行员工资产导入。
7. 员工资产归属候选来源为:
- 数据库已有 `ccdi_base_staff.id_card`
- 本轮员工 Sheet 成功导入的 `idCard`
8. 员工资产落库继续保持:
- `family_id = 员工身份证号`
- `person_id = 员工身份证号`
9. 员工资产继续保持重复校验:`personId + assetMainType + assetSubType + assetName`
### 5.3 任务状态
- 只填员工 Sheet只返回 `staffTaskId`
- 只填员工资产 Sheet只返回 `assetTaskId`,只按数据库已有员工校验。
- 只填员工资产 Sheet 是正常导入场景,不因 `员工信息` Sheet 为空或未填写而拦截。
- 两个 Sheet 都填:返回 `staffTaskId``assetTaskId`,后台保证员工先处理、资产后处理。
- 员工主 Sheet 部分成功时,员工资产只能使用成功员工上下文。
- 员工主 Sheet 全部失败时,员工资产仍执行,但只能命中数据库已有员工。
## 6. 员工亲属关系维护双 Sheet 导入设计
### 6.1 当前问题
当前 `/ccdi/staffFmyRelation/importData` 分别读取 `员工亲属关系信息``亲属资产信息`,然后分别调用:
- `relationService.importRelation(relationList)`
- `assetInfoImportService.importAssetInfo(assetList)`
两个任务互相独立。亲属资产导入只通过数据库 `ccdi_staff_fmy_relation.relation_cert_no` 查找归属,无法稳定看到同一文件中刚导入成功的亲属关系。
### 6.2 设计方案
新增员工亲属关系维护双 Sheet 后端编排能力。
提交阶段:
1. Controller 读取两个 Sheet。
2. 若两个 Sheet 都无数据,仍返回“至少需要一条数据”。
3. 若存在亲属关系 Sheet生成并初始化 `relationTaskId`
4. 若存在亲属资产 Sheet生成并初始化 `assetTaskId`
5. 返回当前前端兼容的 `StaffFmyRelationImportSubmitResultVO`
6. 后台启动一个编排任务。
编排阶段:
1. 先执行亲属关系导入。
2. 沿用现有亲属关系必填、员工身份证号存在性、关系人证件号、重复组合校验。
3. 批量插入亲属关系成功记录。
4. 收集本轮成功亲属关系映射:
- `relationCertNo` 作为资产 Sheet 的 `personId`
- `personId` 作为资产落库的 `familyId`
5. 更新亲属关系任务状态和失败记录。
6. 再执行亲属资产导入。
7. 亲属资产归属候选来源为:
- 数据库已有员工亲属关系,沿用现有 owner 查询条件
- 本轮亲属关系 Sheet 成功导入的员工亲属关系
8. 亲属资产落库继续保持:
- `family_id = 员工身份证号`
- `person_id = 亲属身份证号`
### 6.3 任务状态
- 只填亲属关系 Sheet只返回 `relationTaskId`
- 只填亲属资产 Sheet只返回 `assetTaskId`,只按数据库已有员工亲属关系校验,并沿用现有 owner 查询条件。
- 只填亲属资产 Sheet 是正常导入场景,不因 `员工亲属关系信息` Sheet 为空或未填写而拦截。
- 两个 Sheet 都填:返回 `relationTaskId``assetTaskId`,后台保证亲属关系先处理、资产后处理。
- 亲属关系主 Sheet 部分成功时,亲属资产只能使用成功亲属关系上下文。
- 亲属关系主 Sheet 全部失败时,亲属资产仍执行,但只能命中数据库已有员工亲属关系。
## 7. 实体库自动补入设计
### 7.1 服务职责
`EnterpriseAutoFillService` 只负责一件事:对关联业务成功记录中的企业,确保实体库存在对应统一社会信用代码。
服务接口:
- `ensureExists(EnterpriseFillItem item)`
- `ensureExistsBatch(List<EnterpriseFillItem> items)`
`EnterpriseFillItem` 字段:
- `socialCreditCode`
- `enterpriseName`
- `entSource`
- `dataSource`
- `userName`
### 7.2 插入规则
1.`socialCreditCode` 去重,同批次同一统一社会信用代码只处理一次。
2. 批量查询 `ccdi_enterprise_base_info` 已存在记录。
3. 已存在实体不更新。
4. 缺失实体最小插入:
- `social_credit_code = 统一社会信用代码`
- `enterprise_name = 企业名称;中介实体关联缺失实体时允许为 NULL`
- `ent_source = 来源`
- `data_source = MANUAL 或 IMPORT`
- `risk_level = 来源规则值`
- `created_by/updated_by = 当前用户`
5. 中介实体关联缺失实体时不要求提供机构名称,补入实体的 `enterprise_name` 可以为 `NULL`
6. 中介来源 `INTERMEDIARY``risk_level = 1`
7. 员工亲属、信贷客户、供应商来源写 `risk_level = NULL`
8. 并发导致主键重复时,按“已存在实体”处理。
9. 其他数据库异常抛出,让当前业务事务失败。
### 7.3 四类业务接入点
员工亲属实体关联:
- 手工新增:校验有效亲属和组合不重复后、插入关联表前补入。
- 导入:只对校验成功并即将插入的记录批量补入。
- 来源:`EMP_RELATION`
- 数据来源:手工新增为 `MANUAL`,导入为 `IMPORT`
- 风险等级:`NULL`
信贷客户实体关联:
- 手工新增:校验通过后、插入关联表前补入。
- 导入:只对校验成功并即将插入的记录批量补入。
- 来源:`CREDIT_CUSTOMER`
- 数据来源:手工新增为 `MANUAL`,导入为 `IMPORT`
- 风险等级:`NULL`
中介实体关联:
- 手工新增:字段校验和重复关系校验通过后,取消或替换“实体库必须已存在”校验,先补入实体库,再插入关联关系。
- 导入:字段校验和重复关系校验通过后,取消或替换“实体库必须已存在”失败条件,只对即将插入的成功记录批量补入。
- 来源:`INTERMEDIARY`
- 数据来源:手工新增为 `MANUAL`,导入为 `IMPORT`
- 风险等级:`1`
招投标供应商:
- 手工新增或保存招投标主信息时,只对 `supplierUscc` 非空且通过现有格式校验的供应商补入。
- 导入:只对成功采购事项中 `supplierUscc` 非空且通过现有格式校验的供应商批量补入,失败采购事项不补入。
- 导入中 `supplierUscc` 为空的供应商保持现有保存规则,但不补入实体库。
- 来源:`SUPPLIER`
- 数据来源:手工新增为 `MANUAL`,导入为 `IMPORT`
- 风险等级:`NULL`
## 8. 错误处理与边界
### 8.1 双 Sheet 导入边界
- 主 Sheet 失败行不能进入资产归属候选。
- 主 Sheet 文件内重复行不能进入资产归属候选。
- 主 Sheet 数据库重复行不能进入资产归属候选。
- 主 Sheet 为空但资产 Sheet 有数据时,资产 Sheet 必须按现有独立资产导入规则正常执行。
- 资产 Sheet 仍按自身失败记录任务记录 `sheetName``rowNum``errorMessage`
- 员工资产找不到员工时继续报“员工资产导入仅支持员工本人证件号”。
- 亲属资产找不到归属时继续报“未找到亲属资产归属员工”。
- 亲属资产命中多个归属时继续报“亲属资产归属员工不唯一”。
- 亲属资产数据库归属查询条件沿用当前实现,不因本次编排新增额外状态过滤。
### 8.2 自动补入边界
- 只处理业务校验成功、即将落库的记录。
- 失败业务行不能产生实体库记录。
- 已存在实体不更新,避免覆盖人工维护数据。
- 不增加默认企业名称、默认来源、默认风险等级等兜底逻辑。
- 不改变实体库导入的严格新增规则。
- 中介实体关联的“实体库必须已存在”校验需要被自动补入替换,否则缺失实体无法进入补入链路。
- 中介实体关联不新增机构名称入参;缺失实体补入时允许 `enterprise_name = NULL`
- 供应商自动补入只处理非空统一社会信用代码,空统一社会信用代码不改变原导入保存规则。
## 9. 测试设计
### 9.1 单元测试
员工信息维护双 Sheet
- 同一模板中新员工和员工资产同时导入,资产引用新员工身份证号,资产成功。
- 员工 Sheet 行校验失败,资产引用该身份证号且数据库不存在,资产失败。
- 员工信息 Sheet 为空或未填写,员工资产 Sheet 引用数据库已有员工时,资产成功导入。
- 只导员工资产,数据库已有员工时成功。
- 只导员工资产,数据库无员工时失败。
- 资产重复命中数据库或当前文件重复时失败。
员工亲属关系维护双 Sheet
- 同一模板中新亲属关系和亲属资产同时导入,资产引用新亲属证件号,资产成功。
- 亲属关系 Sheet 行校验失败,资产引用该亲属证件号且数据库不存在,资产失败。
- 员工亲属关系信息 Sheet 为空或未填写,亲属资产 Sheet 引用数据库已有员工亲属关系时,资产成功导入。
- 只导亲属资产,数据库已有唯一员工亲属关系时成功。
- 只导亲属资产,数据库不存在亲属关系时失败。
- 同一亲属证件号命中多个员工归属时失败。
实体库自动补入:
- 已存在实体不插入、不更新。
- 缺失实体插入最小记录。
- 同批多个成功行引用同一统一社会信用代码,只补入一次。
- 中介来源写 `riskLevel=1`
- 员工亲属、信贷客户、供应商来源写 `riskLevel=null`
- 后端枚举接口返回 `SUPPLIER/供应商`
- 并发重复主键按已存在处理。
四类业务接入:
- 手工新增成功时调用自动补入。
- 导入成功行进入自动补入集合。
- 导入失败行不进入自动补入集合。
### 9.2 接口验证
1. 下载真实 `/ccdi/baseStaff/importTemplate` 模板,构造 `员工信息` + `员工资产信息` 同文件导入,上传 `/ccdi/baseStaff/importData`,轮询两个任务并查询员工、资产落库。
2. 下载真实 `/ccdi/staffFmyRelation/importTemplate` 模板,构造 `员工亲属关系信息` + `亲属资产信息` 同文件导入,上传 `/ccdi/staffFmyRelation/importData`,轮询两个任务并查询亲属关系、资产落库。
3. 分别调用员工亲属、信贷客户、中介、供应商新增或导入接口,验证实体库自动补入来源、数据来源和风险等级。
4. 涉及中文清理 SQL 或验证 SQL 时使用 `bin/mysql_utf8_exec.sh`
### 9.3 页面验证
完成后必须进入真实业务页面验证,不打开 prototype
1. 【员工信息维护】下载模板,填写员工和员工资产,上传后检查任务状态、失败记录、列表和详情资产。
2. 【员工亲属关系维护】下载模板,填写亲属关系和亲属资产,上传后检查任务状态、失败记录、详情资产。
3. 四类实体自动补入完成后,进入【实体库管理】按统一社会信用代码查询。
4. 测试结束后清理本轮新增员工、亲属关系、资产、实体关联和自动补入实体库数据。
5. 测试结束后关闭测试过程中启动的前后端进程。
## 10. 实施顺序
1. 提炼员工主数据导入执行方法,返回成功员工上下文和失败记录。
2. 提炼员工资产导入执行方法,支持额外员工归属上下文。
3. 新增员工信息维护双 Sheet 编排方法并接入 Controller。
4. 提炼亲属关系导入执行方法,返回成功亲属关系上下文和失败记录。
5. 提炼亲属资产导入执行方法,支持额外亲属关系归属上下文。
6. 新增员工亲属关系维护双 Sheet 编排方法并接入 Controller。
7. 恢复或新增 `EnterpriseAutoFillService`
8. 接入员工亲属、信贷客户、中介、供应商四类自动补入。
9. 补充单元测试。
10. 执行接口验证和真实页面验证。
11. 新增实施记录到 `docs/reports/implementation/`
## 11. 风险与控制
- 不能继续由 Controller 分别提交两个独立异步任务,否则资产任务执行顺序仍不确定。
- 不能把主 Sheet 失败行放入资产归属上下文,否则会产生脏资产。
- 不能更新已存在实体库记录,否则会覆盖人工维护的企业名称、风险等级和来源。
- 自动补入必须在业务记录落库前执行,并与业务事务保持一致。
- 四类自动补入必须只处理成功业务行,失败行不能污染实体库。

View File

@@ -0,0 +1,99 @@
# 项目详情专项排查图谱嵌入设计
## 背景
项目详情页的“专项排查”页面当前已有“图谱外链展示”占位卡片,需要替换为真实 iframe 图谱展示能力。图谱分为“资金图谱”和“关系图谱”两个页签,对应 `assets/图谱.txt` 中的两个外部链接。
本次设计只涉及前端页面展示,不新增后端接口,不保存证件号,不调整项目详情主导航。
## 目标
- 在项目详情的“专项排查”页面内嵌入图谱展示区域。
- 图谱区域包含共用证件号输入框和“资金图谱 / 关系图谱”两个页签。
- 用户输入证件号后,前端执行标准化与 md5 加密,并替换图谱链接 `vId``/` 后的 hash 字符串。
- 未输入证件号前不加载 iframe只显示输入提示。
## 页面位置
`ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue` 中,将现有“图谱外链展示”占位卡片替换为独立组件:
- 上方:员工家庭资产负债专项核查
- 中间:图谱展示组件
- 下方:采购、招聘、调动扩展查询
组件建议命名为 `GraphAtlasSection.vue`,放在 `ruoyi-ui/src/views/ccdiProject/components/detail/` 目录下,并由 `SpecialCheck.vue` 引入。
## 交互设计
图谱组件顶部展示一行查询区:
- 证件号输入框
- “查询图谱”按钮
触发方式:
- 输入框回车触发查询
- 点击“查询图谱”按钮触发查询
图谱页签顺序:
1. 资金图谱
2. 关系图谱
未查询状态下iframe 不渲染,展示空状态提示“请输入证件号后查询图谱”。查询成功后,当前页签渲染对应 iframe。切换页签时复用同一个已加密证件号重新生成 iframe 地址。
## 数据处理
证件号处理流程:
1. 对用户输入执行 `trim`,去掉前后空格。
2. 如果标准化后的证件号末尾是小写 `x`,转换为大写 `X`
3. 使用现有 `@/utils/md5` 工具生成 md5。
4. 按当前页签替换图谱链接 `params.vId``/` 后的 hash。
图谱 `vId` 规则:
- 资金图谱:`idno_node/<md5证件号>`
- 关系图谱:`rel_node/<md5证件号>`
链接来源固定为 `assets/图谱.txt` 的两个外部图谱地址。本次按前端常量维护,不引入后端配置化。
## 异常处理
- 证件号为空时,点击按钮或回车提示“请输入证件号”,不加载 iframe。
- 证件号非空时不做身份证格式校验,只执行标准化和 md5避免引入需求外的业务规则。
- iframe 只负责设置 `src`,不读取跨域内容。
- 外部图谱服务的 token、网络或页面错误由 iframe 内部页面展示,当前系统不额外增加兜底跳转或降级逻辑。
- 当前阶段外部图谱页面可能无法正常打开,验收以 iframe 链接拼接正确为准,不以 iframe 内容成功渲染为通过条件。
## 样式要求
- 图谱区域延续专项排查页面现有白底卡片风格。
- iframe 区域设置稳定的最小高度,避免图谱区域过矮。
- 大屏下图谱区域尽量占用可视高度,方便查看节点关系。
- 查询区、页签、iframe 容器之间保持清晰间距,不影响上下两个专项排查区块。
## 测试设计
实现完成后按项目规则进行真实页面验证:
1. 进入真实项目详情页,切换到“专项排查”。
2. 确认图谱模块位于资产负债核查和扩展查询之间。
3. 未输入证件号时点击“查询图谱”,确认提示“请输入证件号”,且页面不加载 iframe。
4. 未输入证件号时在输入框按回车,确认同样提示“请输入证件号”,且页面不加载 iframe。
5. 输入证件号后点击按钮,确认资金图谱 iframe 地址中的 `vId``idno_node/<md5证件号>`
6. 修改证件号后在输入框按回车,确认资金图谱 iframe 地址刷新为新的 `idno_node/<md5证件号>`
7. 切换到关系图谱确认沿用同一个证件号iframe 地址中的 `vId``rel_node/<md5证件号>`
8. 使用带前后空格、末尾小写 `x` 的证件号重复验证,确认按 `trim + X` 标准化后生成 md5。
9. 当前阶段不要求外部图谱内容实际打开成功,以上 iframe `src` 链接正确即可判定图谱拼接链路通过。
10. 测试结束后关闭本轮启动的前端进程。
前端命令执行前必须先在 `ruoyi-ui` 目录执行 `nvm use`
## 非目标
- 不新增后端接口。
- 不将图谱链接写入数据库。
- 不保存用户输入的证件号或 md5 结果。
- 不新增项目详情顶部一级菜单。
- 不为外部图谱服务增加 token 刷新、降级链接或兼容逻辑。

View File

@@ -0,0 +1,128 @@
# 导入模板下拉框结构校验后端设计
## 背景
本次问题来自员工信息维护批量导入文件:
- 文件路径:`/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx`
- 问题表现:`员工信息` Sheet 的 `状态` 列没有 Excel 下拉框,但当前导入链路会继续读取数据并进入业务导入。
用户已确认本次范围为:所有 Excel 导入中,凡是导入对象字段标注了 `@DictDropdown`,上传文件对应 Sheet 的对应列都必须保留模板下拉框;缺失下拉框时导入应立即报错。
## 目标
1. 统一拦截缺少下拉框的数据文件,避免模板结构被破坏后仍被导入。
2. 覆盖所有使用 `EasyExcelUtil.importExcel(...)` 读取、且导入对象含 `@DictDropdown` 字段的导入接口。
3. 双 Sheet 导入按 Sheet 分别校验,任一应有下拉框的列缺失时立即失败。
4. 错误提示明确到 Sheet 和列,便于用户重新下载模板处理。
## 非目标
1. 不改造无 `@DictDropdown` 字段的导入,例如当前未使用字典下拉模板的导入不强行纳入。
2. 不改变现有业务字段校验、重复校验、异步导入、失败记录展示逻辑。
3. 不新增兼容旧模板或降级导入逻辑;缺少下拉框即按模板不合规处理。
## 推荐方案
采用工具层统一校验:
1.`EasyExcelUtil.importExcel(InputStream, Class<T>)``EasyExcelUtil.importExcel(InputStream, Class<T>, String)` 内部先读取上传文件字节。
2. 使用 POI 打开工作簿,根据导入类上的 `@DictDropdown``@ExcelProperty(index)` 解析应校验的列。
3. 校验对应 Sheet 中是否存在覆盖该列数据区的数据验证规则。
4. 校验通过后,再使用 EasyExcel 按现有方式读取数据。
该方案集中在公共导入工具,能随现有导入调用链自然覆盖员工信息、员工资产、亲属关系、招聘、调动、招投标、实体库、中介等使用字典下拉模板的导入。
## 校验规则
### 字段解析
- 扫描导入类及父类字段。
- 仅处理同时具备 `@DictDropdown` 和带明确 `index``@ExcelProperty` 字段。
- 列标题优先取 `@ExcelProperty.value()` 的第一个值。
### Sheet 定位
- 指定 Sheet 名读取时,校验该 Sheet。
- 未指定 Sheet 名读取时,校验第一个 Sheet。
- 若 Sheet 不存在,保持现有读取失败语义,不额外设计兜底。
### 下拉框判断
- 读取 Sheet 的 `DataValidation` 列表。
- 只认可 `DataValidationConstraint.ValidationType.LIST` 类型的数据验证;数字、日期、自定义公式等其他校验类型不能视为模板下拉框。
- 数据区定义为:从第 2 行开始,到本次上传文件中该 Sheet 的最后一行有效数据。
- 对每个实际数据行,目标列单元格都必须被 `LIST` 数据验证区域覆盖;只覆盖表头、只覆盖单个样例行、或只覆盖部分数据行,都视为该列下拉框缺失。
- 校验目标为模板结构是否保留,不判断用户是否逐单元格从下拉框选择。
### 失败行为
任一应有下拉框的列缺失数据验证时,导入立即失败,不进入异步任务,不写 Redis 状态,不产生部分成功。
建议错误文案:
```text
员工信息 Sheet 的 状态 列缺少下拉框,请下载最新导入模板填写后重新导入
```
双 Sheet 场景示例:
- `员工信息` Sheet 的 `是否党员``状态` 缺失下拉框:失败。
- `员工资产信息` Sheet 的 `资产状态` 缺失下拉框:失败。
- 两个 Sheet 都保留下拉框:继续现有导入读取和业务校验。
## 影响范围
后端主要影响:
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`
- 使用 `EasyExcelUtil.importExcel(...)` 的导入 Controller。
前端不需要改动。后端抛出的错误沿现有上传失败链路返回,页面展示现有错误提示即可。
## 测试设计
### 单元测试
新增工具层测试覆盖:
1.`@DictDropdown` 且保留下拉数据验证的模板读取通过。
2. 员工信息 `状态` 列缺少下拉数据验证时报错。
3.`LIST` 类型数据验证不能替代下拉框。
4. 只覆盖部分实际数据行的下拉框应报错。
5. 双 Sheet 中任一 Sheet 的字典下拉列缺失时报错。
6.`@DictDropdown` 字段的导入对象不触发结构校验。
### 样例文件验证
使用用户提供文件验证:
```text
/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx
```
预期:导入员工信息 Sheet 时提示 `状态` 列缺少下拉框。
### 真实页面验证
实现完成后按仓库规则使用 `browser-use` 打开真实员工信息维护页面验证:
1. 下载页面当前真实导入模板。
2. 上传缺少下拉框的批量测试文件,确认页面提示导入失败。
3. 上传保留下拉框的测试文件,确认能进入现有正常导入链路。
4. 测试结束关闭本轮启动的前后端进程。
## 文档与实施记录
实现完成后新增实施记录:
```text
docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md
```
实施记录需包含:
- 修改内容。
- 影响范围。
- 测试命令与真实页面验证结果。
- 用户提供缺下拉框文件的验证结果。

View File

@@ -0,0 +1,730 @@
# Import Dropdown Validation Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 上传 Excel 导入文件时,所有 `@DictDropdown` 字段对应列必须保留模板下拉框;缺失下拉框立即报错并阻止导入。
**Architecture:**`EasyExcelUtil` 的公共读取入口统一增加模板结构校验,先用 POI 检查上传文件中对应 Sheet 的 `LIST` 数据验证覆盖情况,再交给 EasyExcel 执行现有数据读取。业务 Controller、异步导入、Redis 失败记录逻辑保持不变。
**Tech Stack:** Java 21, Spring Boot 3, EasyExcel, Apache POI 4.1.2, JUnit 5, Mockito.
---
## Project Notes
- 当前工作区已有未提交改动,包含 `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`。实施时必须先阅读当前 diff保留已有 `templateWriter(...).inMemory(Boolean.TRUE)` 改动,不要回滚用户或其他任务留下的内容。
- `.DS_Store` 忽略,不纳入任何暂存或提交。
- 本计划只涉及后端;不新增前端代码。
- 实现完成后必须新增实施记录:`docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md`
## File Map
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`
- 统一读取上传文件字节。
- 解析 `@DictDropdown` 字段。
- 使用 POI 校验 `LIST` 数据验证是否覆盖每个实际数据行。
- 校验通过后继续调用 EasyExcel 读取数据。
- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilImportDropdownValidationTest.java`
- 覆盖缺失下拉框、非 LIST 验证、部分行覆盖、无字典字段绕过等工具层规则。
- Create: `docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md`
- 记录本次实现、影响范围、测试命令、用户文件验证和真实页面验证结果。
- Reference only: `docs/plans/backend/2026-04-30-import-dropdown-validation-backend-design.md`
- 已审查通过的设计文档。
## Task 1: Add Failing Utility Tests
**Files:**
- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilImportDropdownValidationTest.java`
- [ ] **Step 1: Create test class skeleton**
Add this file with package/imports and helper methods:
```java
package com.ruoyi.info.collection.utils;
import com.alibaba.excel.annotation.ExcelProperty;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffAssetInfoExcel;
import com.ruoyi.info.collection.domain.excel.CcdiBaseStaffExcel;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.CellType;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.DataValidationHelper;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.util.CellRangeAddressList;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
```
Helper methods:
```java
private byte[] baseStaffWorkbook(boolean partyDropdown, boolean statusDropdown, boolean statusAsList, int statusLastRow) throws Exception {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("员工信息");
Row header = sheet.createRow(0);
String[] headers = {"姓名", "员工ID", "所属部门ID", "身份证号", "电话", "年收入(元/年)", "入职时间", "是否党员", "状态"};
for (int i = 0; i < headers.length; i++) {
header.createCell(i).setCellValue(headers[i]);
}
createBaseStaffRow(sheet, 1, "张三", 9020001L, "33010619850202101X", "0", "1");
createBaseStaffRow(sheet, 2, "李四", 9020002L, "330106198603031022", "1", "1");
if (partyDropdown) {
addListValidation(sheet, 7, 1, 2, "0", "1");
}
if (statusDropdown) {
if (statusAsList) {
addListValidation(sheet, 8, 1, statusLastRow, "0", "1");
} else {
addIntegerValidation(sheet, 8, 1, 2);
}
}
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private byte[] baseStaffDualSheetWorkbookWithMissingAssetStatusDropdown() throws Exception {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet staffSheet = workbook.createSheet("员工信息");
Row staffHeader = staffSheet.createRow(0);
String[] staffHeaders = {"姓名", "员工ID", "所属部门ID", "身份证号", "电话", "年收入(元/年)", "入职时间", "是否党员", "状态"};
for (int i = 0; i < staffHeaders.length; i++) {
staffHeader.createCell(i).setCellValue(staffHeaders[i]);
}
createBaseStaffRow(staffSheet, 1, "张三", 9020001L, "33010619850202101X", "0", "1");
addListValidation(staffSheet, 7, 1, 1, "0", "1");
addListValidation(staffSheet, 8, 1, 1, "0", "1");
Sheet assetSheet = workbook.createSheet("员工资产信息");
Row assetHeader = assetSheet.createRow(0);
String[] assetHeaders = {"员工身份证号*", "资产大类*", "资产小类*", "资产名称*", "产权占比", "购买/评估日期", "资产原值", "当前估值*", "估值截止日期", "资产状态*", "备注"};
for (int i = 0; i < assetHeaders.length; i++) {
assetHeader.createCell(i).setCellValue(assetHeaders[i]);
}
Row assetRow = assetSheet.createRow(1);
assetRow.createCell(0).setCellValue("33010619850202101X");
assetRow.createCell(1).setCellValue("房产");
assetRow.createCell(2).setCellValue("住宅");
assetRow.createCell(3).setCellValue("测试住宅");
assetRow.createCell(7).setCellValue(1000000D);
assetRow.createCell(9).setCellValue("正常");
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private void createBaseStaffRow(Sheet sheet, int rowIndex, String name, long staffId, String idCard, String partyMember, String status) {
Row row = sheet.createRow(rowIndex);
row.createCell(0).setCellValue(name);
row.createCell(1).setCellValue(staffId);
row.createCell(2).setCellValue(103L);
row.createCell(3, CellType.STRING).setCellValue(idCard);
row.createCell(4, CellType.STRING).setCellValue("13370000001");
row.createCell(5).setCellValue(new BigDecimal("180000").doubleValue());
row.createCell(6).setCellValue("2026-04-30");
row.createCell(7, CellType.STRING).setCellValue(partyMember);
row.createCell(8, CellType.STRING).setCellValue(status);
}
private void addListValidation(Sheet sheet, int columnIndex, int firstRow, int lastRow, String... options) {
DataValidationHelper helper = sheet.getDataValidationHelper();
DataValidationConstraint constraint = helper.createExplicitListConstraint(options);
DataValidation validation = helper.createValidation(
constraint,
new CellRangeAddressList(firstRow, lastRow, columnIndex, columnIndex)
);
sheet.addValidationData(validation);
}
private void addIntegerValidation(Sheet sheet, int columnIndex, int firstRow, int lastRow) {
DataValidationHelper helper = sheet.getDataValidationHelper();
DataValidationConstraint constraint = helper.createIntegerConstraint(
DataValidationConstraint.OperatorType.BETWEEN,
"0",
"1"
);
DataValidation validation = helper.createValidation(
constraint,
new CellRangeAddressList(firstRow, lastRow, columnIndex, columnIndex)
);
sheet.addValidationData(validation);
}
private byte[] plainWorkbookWithoutDropdown() throws Exception {
try (Workbook workbook = new XSSFWorkbook();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {
Sheet sheet = workbook.createSheet("普通信息");
Row header = sheet.createRow(0);
header.createCell(0).setCellValue("名称");
Row row = sheet.createRow(1);
row.createCell(0).setCellValue("张三");
workbook.write(outputStream);
return outputStream.toByteArray();
}
}
private static class PlainExcel {
@ExcelProperty(value = "名称", index = 0)
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
```
- [ ] **Step 2: Add tests for accepted and rejected workbooks**
Add these tests:
```java
@Test
void importExcel_shouldPassWhenAllDictDropdownColumnsKeepListValidation() throws Exception {
byte[] bytes = baseStaffWorkbook(true, true, true, 2);
List<CcdiBaseStaffExcel> rows = EasyExcelUtil.importExcel(
new ByteArrayInputStream(bytes),
CcdiBaseStaffExcel.class,
"员工信息"
);
assertEquals(2, rows.size());
}
@Test
void importExcel_shouldFailWhenPartyMemberDropdownIsMissing() throws Exception {
byte[] bytes = baseStaffWorkbook(false, true, true, 2);
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
);
assertTrue(exception.getMessage().contains("是否党员 列缺少下拉框"));
}
@Test
void importExcel_shouldFailWhenStatusDropdownIsMissing() throws Exception {
byte[] bytes = baseStaffWorkbook(true, false, true, 2);
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
);
assertEquals("员工信息 Sheet 的 状态 列缺少下拉框,请下载最新导入模板填写后重新导入", exception.getMessage());
}
@Test
void importExcel_shouldFailWhenValidationIsNotListType() throws Exception {
byte[] bytes = baseStaffWorkbook(true, true, false, 2);
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
);
assertTrue(exception.getMessage().contains("状态 列缺少下拉框"));
}
@Test
void importExcel_shouldFailWhenListValidationDoesNotCoverEveryActualDataRow() throws Exception {
byte[] bytes = baseStaffWorkbook(true, true, true, 1);
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffExcel.class, "员工信息")
);
assertTrue(exception.getMessage().contains("状态 列缺少下拉框"));
}
@Test
void importExcel_shouldFailWhenSecondSheetDropdownIsMissing() throws Exception {
byte[] bytes = baseStaffDualSheetWorkbookWithMissingAssetStatusDropdown();
ServiceException exception = assertThrows(ServiceException.class, () ->
EasyExcelUtil.importExcel(new ByteArrayInputStream(bytes), CcdiBaseStaffAssetInfoExcel.class, "员工资产信息")
);
assertEquals("员工资产信息 Sheet 的 资产状态 列缺少下拉框,请下载最新导入模板填写后重新导入", exception.getMessage());
}
@Test
void importExcel_shouldSkipDropdownStructureValidationWhenClassHasNoDictDropdownFields() throws Exception {
byte[] bytes = plainWorkbookWithoutDropdown();
List<PlainExcel> rows = EasyExcelUtil.importExcel(
new ByteArrayInputStream(bytes),
PlainExcel.class,
"普通信息"
);
assertEquals(1, rows.size());
}
```
- [ ] **Step 3: Run the new tests and confirm they fail**
Run:
```bash
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest test
```
Expected before implementation:
- Tests for missing dropdown, non-LIST validation, and partial coverage fail because no structure validation exists.
- The passing workbook test may pass already.
## Task 2: Implement Dropdown Structure Validation in EasyExcelUtil
**Files:**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java`
- [ ] **Step 1: Add imports**
Add imports needed by the new helper methods:
```java
import com.alibaba.excel.annotation.ExcelProperty;
import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.common.exception.ServiceException;
import org.apache.poi.ss.usermodel.Cell;
import org.apache.poi.ss.usermodel.DataValidation;
import org.apache.poi.ss.usermodel.DataValidationConstraint;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.ss.usermodel.Workbook;
import org.apache.poi.ss.usermodel.WorkbookFactory;
import org.apache.poi.ss.util.CellRangeAddress;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Comparator;
```
Keep existing imports that are still used, including the current `ExcelWriterBuilder` import from the existing working tree.
- [ ] **Step 2: Update importExcel(String fileName, Class<T>)**
Change the file-name overload to use a stream so the same validation path is used:
```java
public static <T> List<T> importExcel(String fileName, Class<T> clazz) {
try (InputStream inputStream = java.nio.file.Files.newInputStream(java.nio.file.Path.of(fileName))) {
return importExcel(inputStream, clazz);
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导入Excel失败", e);
}
}
```
- [ ] **Step 3: Update InputStream import overloads**
Make both stream overloads read bytes once, validate, then pass a fresh `ByteArrayInputStream` to EasyExcel:
```java
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz) {
try {
byte[] bytes = inputStream.readAllBytes();
validateDictDropdownTemplate(bytes, clazz, null);
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet().doReadSync();
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导入Excel失败", e);
}
}
public static <T> List<T> importExcel(java.io.InputStream inputStream, Class<T> clazz, String sheetName) {
try {
byte[] bytes = inputStream.readAllBytes();
validateDictDropdownTemplate(bytes, clazz, sheetName);
return EasyExcel.read(new ByteArrayInputStream(bytes)).head(clazz).sheet(sheetName).doReadSync();
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导入Excel失败", e);
}
}
```
- [ ] **Step 4: Add validation helpers**
Add these private helpers near the bottom of `EasyExcelUtil`, before `templateWriter(...)`:
```java
private static void validateDictDropdownTemplate(byte[] bytes, Class<?> clazz, String sheetName) {
List<DropdownColumn> dropdownColumns = resolveDropdownColumns(clazz);
if (dropdownColumns.isEmpty()) {
return;
}
try (Workbook workbook = WorkbookFactory.create(new ByteArrayInputStream(bytes))) {
Sheet sheet = sheetName == null ? workbook.getSheetAt(0) : workbook.getSheet(sheetName);
if (sheet == null) {
return;
}
int lastDataRowIndex = findLastDataRowIndex(sheet);
if (lastDataRowIndex < 1) {
return;
}
for (DropdownColumn column : dropdownColumns) {
if (!isListValidationCovered(sheet, column.index(), lastDataRowIndex)) {
throw new ServiceException(sheet.getSheetName() + " Sheet 的 " + column.title()
+ " 列缺少下拉框,请下载最新导入模板填写后重新导入");
}
}
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("导入Excel失败", e);
}
}
private static List<DropdownColumn> resolveDropdownColumns(Class<?> clazz) {
List<DropdownColumn> columns = new ArrayList<>();
Class<?> current = clazz;
while (current != null && current != Object.class) {
for (Field field : current.getDeclaredFields()) {
if (field.getAnnotation(DictDropdown.class) == null) {
continue;
}
ExcelProperty excelProperty = field.getAnnotation(ExcelProperty.class);
if (excelProperty == null || excelProperty.index() < 0) {
continue;
}
columns.add(new DropdownColumn(excelProperty.index(), resolveColumnTitle(field, excelProperty)));
}
current = current.getSuperclass();
}
columns.sort(Comparator.comparingInt(DropdownColumn::index));
return columns;
}
private static String resolveColumnTitle(Field field, ExcelProperty excelProperty) {
if (excelProperty.value().length > 0 && excelProperty.value()[0] != null && !excelProperty.value()[0].isBlank()) {
return excelProperty.value()[0].replace("*", "");
}
return field.getName();
}
private static int findLastDataRowIndex(Sheet sheet) {
int lastDataRowIndex = -1;
for (int rowIndex = 1; rowIndex <= sheet.getLastRowNum(); rowIndex++) {
Row row = sheet.getRow(rowIndex);
if (hasData(row)) {
lastDataRowIndex = rowIndex;
}
}
return lastDataRowIndex;
}
private static boolean hasData(Row row) {
if (row == null || row.getLastCellNum() < 0) {
return false;
}
for (int cellIndex = row.getFirstCellNum(); cellIndex < row.getLastCellNum(); cellIndex++) {
if (cellIndex < 0) {
continue;
}
Cell cell = row.getCell(cellIndex);
if (cell != null && cell.toString() != null && !cell.toString().isBlank()) {
return true;
}
}
return false;
}
private static boolean isListValidationCovered(Sheet sheet, int columnIndex, int lastDataRowIndex) {
boolean[] coveredRows = new boolean[lastDataRowIndex + 1];
for (DataValidation validation : sheet.getDataValidations()) {
DataValidationConstraint constraint = validation.getValidationConstraint();
if (constraint == null || constraint.getValidationType() != DataValidationConstraint.ValidationType.LIST) {
continue;
}
for (CellRangeAddress address : validation.getRegions().getCellRangeAddresses()) {
if (address.getFirstColumn() > columnIndex || address.getLastColumn() < columnIndex) {
continue;
}
int firstRow = Math.max(1, address.getFirstRow());
int lastRow = Math.min(lastDataRowIndex, address.getLastRow());
for (int rowIndex = firstRow; rowIndex <= lastRow; rowIndex++) {
coveredRows[rowIndex] = true;
}
}
}
for (int rowIndex = 1; rowIndex <= lastDataRowIndex; rowIndex++) {
if (hasData(sheet.getRow(rowIndex)) && !coveredRows[rowIndex]) {
return false;
}
}
return true;
}
private record DropdownColumn(int index, String title) {}
```
Implementation notes:
- Do not add dictionary-value checks; this task only checks template dropdown structure.
- Do not make Controller-specific changes.
- Do not change existing async import status behavior.
- If `sheet == null`, preserve current EasyExcel failure path rather than inventing a new fallback.
- [ ] **Step 5: Run focused tests**
Run:
```bash
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest test
```
Expected: all tests in the new class pass.
## Task 3: Regression Tests for Existing Template Generation
**Files:**
- Modify only if needed: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilTemplateTest.java`
- [ ] **Step 1: Run existing template tests**
Run:
```bash
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest test
```
Expected: existing template-generation tests pass. This confirms the validation change did not break dropdown generation and preserves the existing `inMemory(Boolean.TRUE)` template writer fix.
- [ ] **Step 2: Run controller tests that mock EasyExcelUtil**
Run:
```bash
mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest test
```
Expected: controller tests pass. This confirms method signatures and import entry points did not change.
## Task 4: Verify User-Provided Broken Workbook
**Files:**
- No committed file changes.
- [ ] **Step 1: Confirm workbook structure manually**
Run:
```bash
python3 - <<'PY'
from openpyxl import load_workbook
path = '/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx'
wb = load_workbook(path)
for ws in wb.worksheets:
print(ws.title, len(ws.data_validations.dataValidation))
PY
```
Expected:
```text
员工信息 0
员工资产信息 0
```
- [ ] **Step 2: Validate through backend code path**
Use the real page upload in Task 6 as the authoritative backend-path validation. The expected page/API error is:
```text
员工信息 Sheet 的 状态 列缺少下拉框,请下载最新导入模板填写后重新导入
```
Do not commit this user-provided workbook or any generated upload files.
## Task 5: Add Implementation Report
**Files:**
- Create: `docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md`
- [ ] **Step 1: Write implementation report**
Create the report with this structure:
```markdown
# 导入模板下拉框结构校验实施记录
## 修改内容
-`EasyExcelUtil.importExcel(...)` 公共入口增加 `@DictDropdown` 列下拉框结构校验。
- 上传文件中对应 Sheet 的对应列必须由 `LIST` 类型数据验证覆盖每个实际数据行。
- 缺失下拉框时导入立即失败,不进入异步导入任务。
## 影响范围
- 影响所有使用 `EasyExcelUtil.importExcel(...)` 且导入对象含 `@DictDropdown` 字段的 Excel 导入。
- 不影响无 `@DictDropdown` 字段的导入。
- 不修改前端页面、业务字段校验、异步导入状态和失败记录逻辑。
## 验证结果
- `mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest test`
- `mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilTemplateTest test`
- `mvn -pl ccdi-info-collection -am -Dtest=CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest test`
## 用户文件验证
- 文件:`/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx`
- 结果:上传后提示 `员工信息 Sheet 的 状态 列缺少下拉框,请下载最新导入模板填写后重新导入`
## 真实页面验证
- 页面:员工信息维护真实业务页面。
- 结果:记录下载真实模板、上传缺下拉文件、上传保留下拉测试文件的页面验证结论。
```
- [ ] **Step 2: Fill actual verification output after tests**
Replace the verification bullets with actual pass/fail results after running commands and browser validation.
## Task 6: Real Page Validation With browser-use
**Files:**
- No source file changes unless verification uncovers a bug.
- Generated test files must stay under ignored output paths such as `output/browser-use/` or `output/spreadsheet/` and must not be committed.
- [ ] **Step 1: Use browser-use skill**
Before browser work, open `/Users/wkc/.codex/plugins/cache/openai-bundled/browser-use/0.1.0-alpha1/skills/browser/SKILL.md` and follow it.
- [ ] **Step 2: Start backend using project script if needed**
If no backend is running or code changes require restart, run:
```bash
sh bin/restart_java_backend.sh
```
Expected: backend available at `http://localhost:62318`.
- [ ] **Step 3: Start frontend with nvm if needed**
If no frontend is running, run:
```bash
cd ruoyi-ui
nvm use
npm run dev
```
Expected: frontend dev server URL printed by Vite/Vue CLI. Keep the process id/session so it can be stopped after testing.
- [ ] **Step 4: Test broken workbook on real page**
In the real employee information maintenance page:
1. Log in through the real app or `/login/test` shortcut if already used by the project.
2. Open employee information maintenance.
3. Upload `/Users/wkc/Desktop/ccdi/ccdi_bulk_20260430/员工信息维护导入模板_批量测试数据.xlsx`.
4. Confirm the page displays the backend error mentioning `状态 列缺少下拉框`.
Expected: no async import task is created for this upload.
- [ ] **Step 5: Test current real template still works**
1. Download the current import template from the real page.
2. Fill a small test workbook while preserving dropdown validations.
3. Upload it and confirm it enters the existing normal import chain.
4. Clean up any successfully imported test data.
Expected: dropdown validation does not block a valid template.
- [ ] **Step 6: Stop test processes**
Stop any backend/frontend process started during this task. Do not stop unrelated user-owned processes.
## Task 7: Final Verification and Commit Hygiene
**Files:**
- Modify: files from previous tasks only.
- [ ] **Step 1: Check worktree and staged state**
Run:
```bash
git status --short
git diff --cached --name-status
```
Expected: no staged unrelated files. `.DS_Store` remains ignored/uncommitted.
- [ ] **Step 2: Run final focused verification**
Run:
```bash
mvn -pl ccdi-info-collection -am -Dtest=EasyExcelUtilImportDropdownValidationTest,EasyExcelUtilTemplateTest,CcdiBaseStaffControllerTest,CcdiStaffFmyRelationControllerTest,CcdiAssetInfoControllerTest,CcdiBaseStaffAssetImportControllerTest test
```
Expected: all selected tests pass.
- [ ] **Step 3: Stage only this task's files**
Stage only these files:
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/utils/EasyExcelUtil.java
git add ccdi-info-collection/src/test/java/com/ruoyi/info/collection/utils/EasyExcelUtilImportDropdownValidationTest.java
git add docs/reports/implementation/2026-04-30-import-dropdown-validation-implementation.md
```
If `EasyExcelUtil.java` still contains unrelated pre-existing edits that should not be committed, use partial staging or stop and ask the user before committing.
- [ ] **Step 4: Review staged diff**
Run:
```bash
git diff --cached --name-status
git diff --cached --stat
```
Expected: only the implementation files and implementation report are staged.
- [ ] **Step 5: Commit if requested**
If the user wants a commit, use a Chinese message:
```bash
git commit -m "新增导入模板下拉框校验"
```
Expected: commit succeeds without `.DS_Store` or unrelated docs/files.

View File

@@ -0,0 +1,671 @@
# Bank Upload Original Filename Implementation Plan
> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Follow the CCDI project rule: unless the user explicitly declares `using-superpowers` for implementation, execute this plan through the ordinary workflow and do not enable subagents by default.
**Goal:** 上传本地流水文件后,页面记录名和转传流水分析平台 multipart 文件名都保持用户初始上传文件名。
**Architecture:** 后端继续使用唯一临时文件名保存文件,避免同名和并发冲突;上传流水分析平台时额外传入原始文件名,并用可覆盖 `Resource#getFilename()` 的资源对象构造 multipart 文件 part。上传记录状态后处理增加“是否保留当前记录文件名”的来源参数本地上传链路保留初始文件名拉取本行信息链路保持现状。
**Tech Stack:** Java 21, Spring Boot 3, RestTemplate, MyBatis Plus, JUnit 5, Mockito, Vue 2 页面真实验证。
---
## Project Notes
- 设计文档:[docs/superpowers/specs/2026-05-06-bank-upload-original-filename-design.md](/Users/wkc/Desktop/ccdi/ccdi/docs/superpowers/specs/2026-05-06-bank-upload-original-filename-design.md)
- 项目路径规则覆盖 `writing-plans` 默认目录:本次只涉及后端源码,因此实施计划放在 `docs/plans/backend/`
- 本次不新增前端源码,不新增数据库字段,不回改历史上传记录。
- `.DS_Store` 忽略,不纳入暂存或提交。
- 完成实现后必须新增实施记录:`docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md`
- 真实页面验证使用 @browser-use:browser 打开实际业务页面,不打开 prototype 页面。
- 如果启动了后端、前端或 `lsfx-mock-server`,测试结束后必须关闭本轮启动的进程。
## File Map
- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java`
- 新增可指定 multipart filename 的 `NamedFileSystemResource`
- `uploadFile` 继续支持 `File` 参数;当参数已经是 `Resource` 时直接加入 multipart body。
- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java`
- 新增 `uploadFile(Integer groupId, File file, String uploadFileName)`
- 现有 `uploadFile(Integer groupId, File file)` 委托到新方法并使用 `file.getName()`
- 项目上传链路使用新方法传入初始上传文件名。
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java`
- 验证 multipart body 中 `files` 资源的 `filename` 可被显式指定。
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java`
- 验证流水分析客户端上传时把指定原始文件名写入 `files` 参数。
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- `processFileAsync` 调用 `lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName())`
- `processRecordAfterLogIdReady` 增加来源控制参数,本地上传不覆盖 `record.fileName`,拉取本行信息保持现有覆盖行为。
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
- 更新现有 `processFileAsync` 上传 stubbing 到三参数签名。
- 新增本地上传使用原始文件名转传、平台返回名不覆盖上传记录、拉取本行信息保持现状的回归测试。
- Create: `docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md`
- 记录修改内容、影响范围、测试命令、真实页面验证和进程清理情况。
## Task 1: Add Failing Tests For Multipart Filename
**Files:**
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java`
- Create: `ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java`
- [ ] **Step 1: Create `HttpUtilTest` with a failing filename assertion**
Add:
```java
package com.ruoyi.lsfx.util;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.ResponseEntity;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class HttpUtilTest {
@Mock
private RestTemplate restTemplate;
@TempDir
Path tempDir;
@Test
void uploadFile_shouldUseExplicitResourceFilename() throws Exception {
HttpUtil httpUtil = new HttpUtil();
ReflectionTestUtils.setField(httpUtil, "restTemplate", restTemplate);
Path tempFile = tempDir.resolve("batch_0_123456.xlsx");
Files.writeString(tempFile, "content");
ArgumentCaptor<HttpEntity> captor = ArgumentCaptor.forClass(HttpEntity.class);
when(restTemplate.postForEntity(eq("http://lsfx/upload"), captor.capture(), eq(String.class)))
.thenReturn(ResponseEntity.ok("ok"));
Map<String, Object> params = new HashMap<>();
params.put("groupId", 200);
params.put("files", HttpUtil.namedFileResource(tempFile.toFile(), "银行流水A.xlsx"));
String result = httpUtil.uploadFile("http://lsfx/upload", params, null, String.class);
assertEquals("ok", result);
MultiValueMap<String, Object> body = (MultiValueMap<String, Object>) captor.getValue().getBody();
Object filePart = body.getFirst("files");
Resource resource = assertInstanceOf(Resource.class, filePart);
assertEquals("银行流水A.xlsx", resource.getFilename());
}
}
```
- [ ] **Step 2: Create `LsfxAnalysisClientTest` with a failing client assertion**
Add:
```java
package com.ruoyi.lsfx.client;
import com.ruoyi.lsfx.constants.LsfxConstants;
import com.ruoyi.lsfx.domain.response.UploadFileResponse;
import com.ruoyi.lsfx.util.HttpUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
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.core.io.Resource;
import org.springframework.test.util.ReflectionTestUtils;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class LsfxAnalysisClientTest {
@Mock
private HttpUtil httpUtil;
@InjectMocks
private LsfxAnalysisClient client;
@TempDir
Path tempDir;
@Test
void uploadFile_shouldPassOriginalFilenameToMultipartResource() throws Exception {
ReflectionTestUtils.setField(client, "baseUrl", "http://lsfx");
ReflectionTestUtils.setField(client, "uploadFileEndpoint", "/upload");
ReflectionTestUtils.setField(client, "clientId", "client-1");
Path tempFile = tempDir.resolve("batch_0_123456.xlsx");
Files.writeString(tempFile, "content");
UploadFileResponse response = new UploadFileResponse();
response.setData(new UploadFileResponse.UploadData());
ArgumentCaptor<Map<String, Object>> paramsCaptor = ArgumentCaptor.forClass(Map.class);
ArgumentCaptor<Map<String, String>> headersCaptor = ArgumentCaptor.forClass(Map.class);
when(httpUtil.uploadFile(eq("http://lsfx/upload"), paramsCaptor.capture(), headersCaptor.capture(), eq(UploadFileResponse.class)))
.thenReturn(response);
client.uploadFile(200, tempFile.toFile(), "银行流水A.xlsx");
assertEquals(200, paramsCaptor.getValue().get("groupId"));
Resource filePart = assertInstanceOf(Resource.class, paramsCaptor.getValue().get("files"));
assertEquals("银行流水A.xlsx", filePart.getFilename());
assertEquals("client-1", headersCaptor.getValue().get(LsfxConstants.HEADER_CLIENT_ID));
}
}
```
- [ ] **Step 3: Run tests and confirm they fail**
Run:
```bash
mvn -pl ccdi-lsfx -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because `HttpUtil.namedFileResource(...)` and `LsfxAnalysisClient.uploadFile(Integer, File, String)` do not exist yet.
## Task 2: Implement Multipart Filename Support
**Files:**
- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java`
- Modify: `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java`
- [ ] **Step 1: Add named file resource to `HttpUtil`**
In `HttpUtil.java`, add only this import:
```java
import org.springframework.util.StringUtils;
```
Do not import `org.springframework.core.io.Resource` in this file because it conflicts with the existing `jakarta.annotation.Resource` annotation import. Use the Spring Resource type by fully qualified name in implementation snippets.
Add this nested class and factory method inside `HttpUtil`:
```java
public static org.springframework.core.io.Resource namedFileResource(File file, String filename) {
return new NamedFileSystemResource(file, filename);
}
private static class NamedFileSystemResource extends FileSystemResource {
private final String filename;
NamedFileSystemResource(File file, String filename) {
super(file);
this.filename = StringUtils.hasText(filename) ? filename : file.getName();
}
@Override
public String getFilename() {
return filename;
}
}
```
- [ ] **Step 2: Let `HttpUtil.uploadFile` accept existing `Resource` values**
Replace the file branch in `uploadFile(...)` with:
```java
if (value instanceof File) {
File file = (File) value;
body.add(key, new FileSystemResource(file));
} else if (value instanceof org.springframework.core.io.Resource) {
body.add(key, value);
} else {
body.add(key, value);
}
```
- [ ] **Step 3: Add explicit filename upload overload to `LsfxAnalysisClient`**
Replace the current upload method body with a delegating overload:
```java
public UploadFileResponse uploadFile(Integer groupId, File file) {
return uploadFile(groupId, file, file.getName());
}
public UploadFileResponse uploadFile(Integer groupId, File file, String uploadFileName) {
String multipartFileName = org.springframework.util.StringUtils.hasText(uploadFileName)
? uploadFileName
: file.getName();
log.info("【流水分析】上传文件请求: groupId={}, fileName={}", groupId, multipartFileName);
long startTime = System.currentTimeMillis();
try {
String url = baseUrl + uploadFileEndpoint;
Map<String, Object> params = new HashMap<>();
params.put("groupId", groupId);
params.put("files", HttpUtil.namedFileResource(file, multipartFileName));
Map<String, String> headers = new HashMap<>();
headers.put(LsfxConstants.HEADER_CLIENT_ID, clientId);
UploadFileResponse response = httpUtil.uploadFile(url, params, headers, UploadFileResponse.class);
long elapsed = System.currentTimeMillis() - startTime;
if (response != null && response.getData() != null) {
log.info("【流水分析】上传文件成功: uploadStatus={}, 耗时={}ms",
response.getData().getUploadStatus(), elapsed);
} else {
log.warn("【流水分析】上传文件响应异常: 耗时={}ms", elapsed);
}
return response;
} catch (LsfxApiException e) {
log.error("【流水分析】上传文件失败: groupId={}, error={}", groupId, e.getMessage(), e);
throw e;
} catch (Exception e) {
log.error("【流水分析】上传文件未知异常: groupId={}", groupId, e);
throw new LsfxApiException("上传文件失败: " + e.getMessage(), e);
}
}
```
- [ ] **Step 4: Run `ccdi-lsfx` tests**
Run:
```bash
mvn -pl ccdi-lsfx -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CreditParseControllerTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 5: Commit lsfx client changes**
Check the worktree, stage only task files, then verify staged scope:
```bash
git status --short
git add ccdi-lsfx/src/main/java/com/ruoyi/lsfx/util/HttpUtil.java \
ccdi-lsfx/src/main/java/com/ruoyi/lsfx/client/LsfxAnalysisClient.java \
ccdi-lsfx/src/test/java/com/ruoyi/lsfx/util/HttpUtilTest.java \
ccdi-lsfx/src/test/java/com/ruoyi/lsfx/client/LsfxAnalysisClientTest.java
git diff --cached --name-status
```
Expected staged files: only the four `ccdi-lsfx` files in this task.
```bash
git commit -m "修复流水分析上传文件名传递"
```
## Task 3: Add Failing Project Upload Service Tests
**Files:**
- Modify: `ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java`
- [ ] **Step 1: Update existing `processFileAsync` stubs to the new signature**
In `CcdiFileUploadServiceImplTest.java`, replace local file upload stubs:
```java
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any())).thenReturn(buildUploadResponse());
```
with:
```java
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
```
Only update tests that exercise `processFileAsync`. Do not change `processPullBankInfoAsync` tests to upload files; that chain uses `fetchInnerFlow`.
- [ ] **Step 2: Add test that local upload calls LSFX with original record filename**
Add:
```java
@Test
void processFileAsync_shouldUploadToLsfxWithOriginalRecordFileName() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), eq("原始流水.xlsx")))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(buildParsedSuccessStatusResponse());
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
.thenReturn(buildEmptyBankStatementResponse());
CcdiFileUploadRecord record = buildRecord();
record.setFileName("原始流水.xlsx");
Path tempFile = createTempFile();
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
verify(lsfxClient).uploadFile(eq(LSFX_PROJECT_ID), argThat(file ->
file.getName().startsWith("upload-") && file.getName().endsWith(".xlsx")
), eq("原始流水.xlsx"));
}
```
- [ ] **Step 3: Add test that platform filename does not overwrite local upload record filename**
Add:
```java
@Test
void processFileAsync_shouldKeepOriginalFileNameWhenStatusReturnsDifferentName() throws IOException {
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any()))
.thenReturn(buildParsedSuccessStatusResponse("平台返回文件名.xlsx"));
when(lsfxClient.getBankStatement(any(GetBankStatementRequest.class)))
.thenReturn(buildEmptyBankStatementResponse());
CcdiFileUploadRecord record = buildRecord();
record.setFileName("原始流水.xlsx");
Path tempFile = createTempFile();
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
"parsed_success".equals(item.getFileStatus())
&& "原始流水.xlsx".equals(item.getFileName()))
);
}
```
- [ ] **Step 4: Keep pull-bank-info regression explicit**
Keep the existing test `processPullBankInfoAsync_shouldUpdateFileSizeFromStatusResponse` asserting:
```java
"XX身份证.xlsx".equals(item.getFileName())
```
This verifies the shared status method still allows platform filename overwrite for the “拉取本行信息” chain.
- [ ] **Step 5: Add failure-state filename regression**
Add:
```java
@Test
void processFileAsync_shouldKeepOriginalFileNameWhenParseStatusFails() throws IOException {
GetFileUploadStatusResponse statusResponse = buildParsedSuccessStatusResponse("平台失败文件名.xlsx");
GetFileUploadStatusResponse.LogItem logItem = statusResponse.getData().getLogs().get(0);
logItem.setStatus(-1);
logItem.setUploadStatusDesc("parse.failed");
when(lsfxClient.uploadFile(eq(LSFX_PROJECT_ID), any(), org.mockito.ArgumentMatchers.anyString()))
.thenReturn(buildUploadResponse());
when(lsfxClient.checkParseStatus(LSFX_PROJECT_ID, String.valueOf(LOG_ID)))
.thenReturn(buildCheckParseStatusResponse(false));
when(lsfxClient.getFileUploadStatus(any())).thenReturn(statusResponse);
CcdiFileUploadRecord record = buildRecord();
record.setFileName("原始流水.xlsx");
Path tempFile = createTempFile();
service.processFileAsync(PROJECT_ID, LSFX_PROJECT_ID, tempFile.toString(), RECORD_ID, "batch-1", record);
verify(recordMapper, org.mockito.Mockito.atLeastOnce()).updateById(
org.mockito.ArgumentMatchers.<CcdiFileUploadRecord>argThat(item ->
"parsed_failed".equals(item.getFileStatus())
&& "原始流水.xlsx".equals(item.getFileName()))
);
}
```
- [ ] **Step 6: Run service tests and confirm expected failure**
Run:
```bash
mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: FAIL because `processFileAsync` still calls the old two-argument upload method and status handling still overwrites `record.fileName`.
## Task 4: Implement Project Upload Filename Isolation
**Files:**
- Modify: `ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java`
- [ ] **Step 1: Use original record filename when forwarding local uploaded files**
In `processFileAsync`, replace:
```java
UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file);
```
with:
```java
UploadFileResponse uploadResponse = lsfxClient.uploadFile(lsfxProjectId, file, record.getFileName());
```
- [ ] **Step 2: Add source control to status post-processing**
Add an overload:
```java
private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId,
CcdiFileUploadRecord record, Integer logId) {
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, false);
}
```
Change the current method signature to:
```java
private void processRecordAfterLogIdReady(Long projectId, Integer lsfxProjectId,
CcdiFileUploadRecord record, Integer logId,
boolean preserveRecordFileName) {
```
Then replace the filename update block with:
```java
if (!preserveRecordFileName) {
String fileName = StringUtils.hasText(logItem.getUploadFileName())
? logItem.getUploadFileName()
: logItem.getDownloadFileName();
if (StringUtils.hasText(fileName)) {
record.setFileName(fileName);
}
}
```
- [ ] **Step 3: Call status post-processing with preservation from local upload**
In `processFileAsync`, replace:
```java
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId);
```
with:
```java
processRecordAfterLogIdReady(projectId, lsfxProjectId, record, logId, true);
```
Do not change `processPullBankInfoAsync`; it should continue to call the four-argument overload and preserve the current pull-bank-info behavior.
- [ ] **Step 4: Run service tests**
Run:
```bash
mvn -pl ccdi-project -am -Dtest=CcdiFileUploadServiceImplTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 5: Commit project upload service changes**
Check the worktree, stage only task files, then verify staged scope:
```bash
git status --short
git add ccdi-project/src/main/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImpl.java \
ccdi-project/src/test/java/com/ruoyi/ccdi/project/service/impl/CcdiFileUploadServiceImplTest.java
git diff --cached --name-status
```
Expected staged files: only `CcdiFileUploadServiceImpl.java` and `CcdiFileUploadServiceImplTest.java`.
```bash
git commit -m "修复本地上传流水记录文件名覆盖"
```
## Task 5: Verification, Real Page Check, And Implementation Record
**Files:**
- Create: `docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md`
- Reference: `ruoyi-admin/src/main/resources/application-dev.yml`
- Reference: `lsfx-mock-server/routers/api.py`
- Reference: `lsfx-mock-server/services/file_service.py`
- [ ] **Step 1: Run focused backend regression tests**
Run:
```bash
mvn -pl ccdi-lsfx,ccdi-project -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest -Dsurefire.failIfNoSpecifiedTests=false test
```
Expected: PASS.
- [ ] **Step 2: Run compile check for affected modules**
Run:
```bash
mvn -pl ccdi-lsfx,ccdi-project -am -DskipTests compile
```
Expected: BUILD SUCCESS.
- [ ] **Step 3: Start verification services**
Use the project backend restart script:
```bash
sh bin/restart_java_backend.sh
```
For the mock service, use local dev defaults:
```bash
cd lsfx-mock-server
python3 main.py --rule-hit-mode subset
```
If the frontend is not already running, start it with the project Node version:
```bash
cd ruoyi-ui
nvm use
npm run dev
```
- [ ] **Step 4: Perform real page upload check with @browser-use:browser**
In the actual business page:
1. Login through the real application.
2. Open project detail -> 上传数据.
3. Upload a test file named with a distinctive original name, for example `原始文件名验证-20260506.xlsx`.
4. Confirm the upload record table displays `原始文件名验证-20260506.xlsx`.
5. Confirm the mock LSFX upload response or service log records the same filename. The mock service receives the filename through FastAPI `UploadFile.filename`, and `FileService.upload_file` writes it into `file_record.file_name`.
Expected: page table filename and mock LSFX received filename both match the original uploaded filename.
- [ ] **Step 5: Clean test data and stop started processes**
Remove the uploaded test record through the existing page delete action if it reached parsed success. If test data was created but cannot be removed from the page, clean only the test row by its unique filename or upload record id after confirming the scope.
Stop only the backend, frontend, or mock-service processes started in Step 3.
- [ ] **Step 6: Write implementation record**
Create `docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md` with:
```markdown
# 上传流水文件原始文件名保持实施记录
## 修改内容
- `ccdi-lsfx` 支持 multipart 文件 part 显式指定 filename。
- `ccdi-project` 本地上传流水文件链路转传流水分析平台时使用初始上传文件名。
- 本地上传链路查询状态后不再使用平台返回文件名覆盖上传记录文件名。
- 拉取本行信息链路保持原有文件名处理行为。
## 影响范围
- 影响项目详情“上传数据”中的本地流水文件上传。
- 不影响历史上传记录。
- 不影响拉取本行信息。
- 不涉及前端源码和数据库结构变更。
## 验证情况
- `mvn -pl ccdi-lsfx,ccdi-project -am -Dtest=HttpUtilTest,LsfxAnalysisClientTest,CcdiFileUploadServiceImplTest,CcdiFileUploadControllerTest -Dsurefire.failIfNoSpecifiedTests=false test`
- `mvn -pl ccdi-lsfx,ccdi-project -am -DskipTests compile`
- 真实页面验证记录页面文件名、mock LSFX 接收 filename、测试数据清理和进程关闭结果。
```
- [ ] **Step 7: Commit final verification record**
Check the worktree, stage only the implementation record, then verify staged scope:
```bash
git status --short
git add docs/reports/implementation/2026-05-06-bank-upload-original-filename-implementation.md
git diff --cached --name-status
```
Expected staged file: only the implementation record.
```bash
git commit -m "文档: 记录上传流水文件名修复验证"
```
## Final Review Checklist
- [ ] 本地上传流水文件转传 LSFX 时 multipart `files` part 的 filename 是初始上传文件名。
- [ ] 本地上传记录 `file_name` 在解析成功或失败后仍是初始上传文件名。
- [ ] 拉取本行信息链路仍按平台返回文件名更新记录,不被本次改动影响。
- [ ] 无数据库结构变更。
- [ ] 无前端源码变更。
- [ ] 实施记录已包含测试、真实页面验证和进程清理结果。
- [ ] 提交前 `git status --short``.DS_Store` 或无关文件。

View File

@@ -0,0 +1,236 @@
# 员工资产导入与员工亲属实体自动补入后端解决方案
## 背景
本方案针对以下两个已验证问题:
1. 【员工信息维护】和【员工亲属关系维护】使用双 Sheet 模板导入时,第二个 Sheet 的资产信息只按数据库已有主数据反查归属,不能识别同一个导入文件中刚导入的员工或亲属关系,导致提示“未找到资产归属员工”等错误。
2. 【员工亲属实体关联】新增或导入关系人与企业关联后,只写入员工亲属实体关联表,没有同步生成实体库数据,导致企业名称和统一社会信用代码不能自动落入【实体库管理】。
本次解决方案只涉及后端,不调整前端页面结构、上传入口和模板样式。现有前端已经能接收员工任务 ID 与资产任务 ID并分别轮询失败记录因此后端需要保持现有返回字段兼容。
## 目标
- 双 Sheet 导入时,主 Sheet 和资产 Sheet 按同一个文件内的业务依赖顺序处理。
- 资产 Sheet 可以关联到数据库已存在的员工或亲属主数据,也可以关联到同一文件中本轮成功导入的员工或亲属主数据。
- 主 Sheet 校验失败的数据不能作为资产归属依据。
- 员工亲属实体关联新增和导入成功后,缺失的企业自动补入实体库。
- 实体库自动补入只插入缺失企业,不更新已存在实体。
## 方案一:员工信息维护双 Sheet 导入
### 当前问题链路
- `/ccdi/baseStaff/importData` 当前分别读取 `员工信息``员工资产信息` 两个 Sheet。
- 有员工数据时调用 `baseStaffService.importBaseStaff(staffList)`,有资产数据时调用 `baseStaffAssetImportService.importAssetInfo(assetList)`
- 两个任务独立异步执行,员工资产任务只通过 `ccdi_base_staff.id_card` 查询数据库已有员工。
- 当模板中员工和资产同时首次导入时,资产任务无法稳定看到本轮员工导入结果。
### 改造策略
保留接口路径、模板名称、返回结构和前端轮询模型,新增后端双 Sheet 编排能力。
1. 在员工导入服务中增加双 Sheet 提交方法:
- 输入:`List<CcdiBaseStaffExcel> staffList``List<CcdiBaseStaffAssetInfoExcel> assetList`
- 输出:`BaseStaffImportSubmitResultVO`
- 有员工 Sheet 时生成 `staffTaskId`
- 有资产 Sheet 时生成 `assetTaskId`
2. 新增一个统一异步编排方法,按顺序执行:
- 初始化员工导入任务状态
- 执行员工主数据校验和批量插入
- 收集本轮员工导入成功的 `idCard`
- 更新员工任务状态和失败记录
- 初始化员工资产导入任务状态
- 执行员工资产导入
- 员工资产归属候选来源为:
- 数据库已有 `ccdi_base_staff.id_card`
- 本轮员工 Sheet 成功导入的 `idCard`
- 更新员工资产任务状态和失败记录
3. 员工资产导入逻辑调整:
- 保留“员工资产只允许员工本人身份证号”的业务规则。
- 保留重复校验规则:`personId + assetMainType + assetSubType + assetName`
- `personId` 既可以命中数据库已有员工,也可以命中本轮成功导入员工。
- 只命中员工 Sheet 失败行、且数据库中也不存在该身份证号时,资产行进入失败记录。
4. `/ccdi/baseStaff/importData` 改为调用新的双 Sheet 提交方法,不再由 Controller 分别提交两个互相独立的异步任务。
### 结果状态
- 只填员工 Sheet只返回 `staffTaskId`,行为保持不变。
- 只填员工资产 Sheet只返回 `assetTaskId`,按数据库已有员工校验。
- 两个 Sheet 都填写:返回 `staffTaskId``assetTaskId`,但后端在同一个编排任务中先处理员工再处理资产。
## 方案二:员工亲属关系维护双 Sheet 导入
### 当前问题链路
- `/ccdi/staffFmyRelation/importData` 当前分别读取 `员工亲属关系信息``亲属资产信息` 两个 Sheet。
- 有亲属关系数据时调用 `relationService.importRelation(relationList)`,有亲属资产数据时调用 `assetInfoImportService.importAssetInfo(assetList)`
- 亲属资产导入只通过 `ccdi_staff_fmy_relation.relation_cert_no` 查询数据库已有亲属关系。
- 当模板中亲属关系和亲属资产同时首次导入时,资产任务无法稳定看到本轮亲属关系导入结果。
### 改造策略
保留接口路径、模板名称、返回结构和前端轮询模型,新增员工亲属关系双 Sheet 编排能力。
1. 在员工亲属关系导入服务中增加双 Sheet 提交方法:
- 输入:`List<CcdiStaffFmyRelationExcel> relationList``List<CcdiAssetInfoExcel> assetList`
- 输出:`StaffFmyRelationImportSubmitResultVO`
- 有亲属关系 Sheet 时生成 `relationTaskId`
- 有亲属资产 Sheet 时生成 `assetTaskId`
2. 新增一个统一异步编排方法,按顺序执行:
- 初始化亲属关系导入任务状态
- 执行亲属关系校验和批量插入
- 收集本轮成功导入且有效的亲属关系映射:
- `relationCertNo` 作为资产 Sheet 的 `personId`
- `personId` 作为资产落库的 `familyId`
- 更新亲属关系任务状态和失败记录
- 初始化亲属资产导入任务状态
- 执行亲属资产导入
- 亲属资产归属候选来源为:
- 数据库已有有效员工亲属关系
- 本轮亲属关系 Sheet 成功导入的有效员工亲属关系
- 更新亲属资产任务状态和失败记录
3. 亲属资产导入逻辑调整:
- 保留 `family_id = 员工身份证号``person_id = 亲属身份证号` 的落库规则。
- `personId` 命中唯一亲属关系时导入成功。
- `personId` 未命中数据库和本轮成功亲属关系时,失败原因为“未找到亲属资产归属员工”。
- `personId` 命中多个员工归属时,失败原因为“亲属资产归属员工不唯一”。
- 只命中亲属关系 Sheet 失败行、且数据库中也不存在有效亲属关系时,资产行进入失败记录。
4. `/ccdi/staffFmyRelation/importData` 改为调用新的双 Sheet 提交方法,不再由 Controller 分别提交两个互相独立的异步任务。
### 结果状态
- 只填亲属关系 Sheet只返回 `relationTaskId`,行为保持不变。
- 只填亲属资产 Sheet只返回 `assetTaskId`,按数据库已有亲属关系校验。
- 两个 Sheet 都填写:返回 `relationTaskId``assetTaskId`,但后端在同一个编排任务中先处理亲属关系再处理亲属资产。
## 方案三:员工亲属实体关联自动补入实体库
### 当前问题链路
- 员工亲属实体关联新增只写入 `ccdi_staff_enterprise_relation`
- 员工亲属实体关联导入成功行也只写入 `ccdi_staff_enterprise_relation`
- 当前源码中没有可复用的实体库自动补入服务实现。
### 改造策略
新增后端内部服务 `EnterpriseAutoFillService`,统一处理缺失企业的最小插入。
1. 新增服务文件:
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/support/EnterpriseAutoFillService.java`
2. 服务方法:
- `ensureExists(EnterpriseFillItem item)`
- `ensureExistsBatch(List<EnterpriseFillItem> items)`
3. `EnterpriseFillItem` 字段:
- `socialCreditCode`
- `enterpriseName`
- `entSource`
- `dataSource`
- `userName`
4. 插入规则:
-`socialCreditCode` 去重。
- 先批量查询 `ccdi_enterprise_base_info` 已存在记录。
- 已存在实体不更新。
- 缺失实体最小插入:
- `social_credit_code = 统一社会信用代码`
- `enterprise_name = 企业名称`
- `ent_source = EMP_RELATION`
- `data_source = MANUAL``IMPORT`
- `risk_level = NULL`
- `create_by/update_by = 当前用户`
- 不引入额外兜底字段,不改变实体库手工新增规则。
5. 接入员工亲属实体关联新增:
-`CcdiStaffEnterpriseRelationServiceImpl#insertRelation` 中,亲属有效性校验和组合查重通过后,写关联表前调用:
- `entSource = EnterpriseSource.EMP_RELATION.getCode()`
- `dataSource = DataSource.MANUAL.getCode()`
6. 接入员工亲属实体关联导入:
-`CcdiStaffEnterpriseRelationImportServiceImpl#importRelationAsync` 中,只对校验成功并即将插入的 `newRecords` 组装补入列表。
- 批量写关联表前调用 `ensureExistsBatch`
- 失败行不进入实体库补入集合。
- `dataSource = DataSource.IMPORT.getCode()`
## 测试方案
### 单元测试
1. 员工信息维护双 Sheet
- 同一模板中员工 Sheet 新增员工,员工资产 Sheet 使用该员工身份证号,资产应导入成功。
- 员工 Sheet 行校验失败,资产 Sheet 使用该失败员工身份证号且数据库不存在,资产应失败。
- 只导资产 Sheet数据库已有员工时资产成功。
- 只导资产 Sheet数据库无员工时资产失败。
- 资产重复命中数据库或当前文件重复时失败。
2. 员工亲属关系维护双 Sheet
- 同一模板中亲属关系 Sheet 新增亲属,亲属资产 Sheet 使用该亲属证件号,资产应导入成功,并落库 `family_id = 员工身份证号`
- 亲属关系 Sheet 行校验失败,亲属资产 Sheet 使用该失败亲属证件号且数据库不存在,资产应失败。
- 只导亲属资产 Sheet数据库已有唯一有效亲属关系时资产成功。
- 只导亲属资产 Sheet数据库不存在亲属关系时资产失败。
- 同一亲属证件号命中多个员工归属时资产失败。
3. 员工亲属实体关联自动补入:
- 手工新增员工亲属实体关联成功时,缺失企业自动插入实体库,来源为 `EMP_RELATION`,数据来源为 `MANUAL`
- 手工新增时实体库已存在该统一社会信用代码,不更新实体库旧记录。
- 导入成功行自动插入实体库,来源为 `EMP_RELATION`,数据来源为 `IMPORT`
- 导入失败行不插入实体库。
- 同一批多个成功行引用同一统一社会信用代码,只补入一次。
### 接口验证
1. 调用 `/ccdi/baseStaff/importTemplate` 下载真实模板,构造:
- `员工信息` 新员工
- `员工资产信息` 引用该员工身份证号
- 上传 `/ccdi/baseStaff/importData`
- 轮询员工任务与资产任务,确认都成功
- 查询 `ccdi_base_staff``ccdi_asset_info`,确认员工和资产落库
2. 调用 `/ccdi/staffFmyRelation/importTemplate` 下载真实模板,构造:
- `员工亲属关系信息` 新亲属
- `亲属资产信息` 引用该亲属证件号
- 上传 `/ccdi/staffFmyRelation/importData`
- 轮询亲属关系任务与亲属资产任务,确认都成功
- 查询 `ccdi_staff_fmy_relation``ccdi_asset_info`,确认亲属关系和资产落库
3. 调用员工亲属实体关联新增接口:
- 新增前确认 `ccdi_enterprise_base_info` 不存在该统一社会信用代码
- 新增关联成功后查询实体库,确认自动生成企业记录
- 校验 `ent_source = EMP_RELATION`
### 页面验证
完成后需要使用真实业务页面验证:
1. 【员工信息维护】页面下载模板,按模板填写员工和员工资产,上传后检查任务状态、失败记录和列表详情。
2. 【员工亲属关系维护】页面下载模板,按模板填写亲属关系和亲属资产,上传后检查任务状态、失败记录和详情资产列表。
3. 【员工亲属实体关联】页面新增关系人与企业关联后,进入【实体库管理】查询该统一社会信用代码,确认企业已自动生成且来源显示为员工关系人。
测试结束后清理本轮新增员工、亲属关系、资产、员工亲属实体关联和自动补入实体库数据,并关闭测试过程中启动的前后端进程。
## 实施顺序
1. 抽取员工导入和亲属关系导入的可复用执行方法,返回成功主数据上下文和失败记录。
2. 新增员工信息维护双 Sheet 后端编排方法,接入 Controller。
3. 新增员工亲属关系维护双 Sheet 后端编排方法,接入 Controller。
4. 新增 `EnterpriseAutoFillService`
5. 员工亲属实体关联新增链路接入实体库自动补入。
6. 员工亲属实体关联导入链路接入实体库自动补入。
7. 补充单元测试。
8. 执行接口验证和真实页面验证。
9. 新增实施记录到 `docs/reports/implementation/`
## 风险点
- 双 Sheet 导入不能继续使用两个互相独立的异步任务,否则仍然存在主数据与资产任务执行顺序不确定的问题。
- 资产归属上下文必须只使用“数据库已有数据”和“本轮主 Sheet 成功数据”,不能把失败主数据作为资产归属。
- 实体库自动补入不能更新已存在企业,避免覆盖人工维护的企业名称、风险等级和来源信息。
- 员工亲属实体关联自动补入必须只处理成功行,失败行不能产生实体库记录。

View File

@@ -1,34 +0,0 @@
# 员工信息维护与招聘信息管理正式化外壳前端实施计划
## 目标
- 基于当前本地最新代码,为 `ccdiBaseStaff``ccdiStaffRecruitment` 试套正式化外壳样式。
- 仅调整查询区、工具条、表格区、分页区与弹窗壳层视觉。
- 不改字段顺序、不改按钮位置、不改功能块结构。
## 范围
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- `ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js`
- `ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js`
## 方案
- 复用现有 `app-container``query-form``mb8`、弹窗 class只补最少样式。
- 给列表区新增最小表格外壳,保证分页和表格归一。
- 通过边框、浅底、留白和表头背景统一正式化视觉。
## 验证
- `node tests/unit/base-staff-formal-shell-layout.test.js`
- `node tests/unit/staff-recruitment-formal-shell-layout.test.js`
- `node tests/unit/employee-asset-maintenance-layout.test.js`
- `node tests/unit/staff-recruitment-import-toolbar.test.js`
## 完成标准
- 两个页面外壳样式统一
- 按钮顺序和功能入口保持不变
- 单测通过
- 浏览器实测通过

View File

@@ -1,65 +0,0 @@
# 2026-04-29 批量正式化外壳样式实施计划
## 目标
- 基于当前本地最新前端代码,批量推进信息维护相关页面的正式化外壳样式。
- 严格保持“只改样式、不改内容和功能”的边界。
- 复用已经在详情弹窗、员工信息维护页、招聘信息管理页验证过的正式化样式骨架。
## 范围
- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
## 实施策略
### 1. 查询区统一正式化
- 保留原有查询字段、排布逻辑和按钮位置。
- 为查询区补统一白色面板、边框、克制圆角和更稳重的标签文字。
- 收紧表单项底部留白,统一输入框、下拉框、日期控件的边框和圆角。
### 2. 工具条统一正式化
- 保留搜索、重置、新增、导入、失败记录入口及其相对位置。
- 统一工具条外层白色承载区。
- 按钮仅调整圆角、边框与视觉重量,不改变语义和行为。
### 3. 表格承载区统一正式化
- 新增或复用 `formal-table-shell` 包裹列表表格与分页区。
- 收紧表头和行高,提升单屏信息密度。
- 主体文本尽量左对齐,保留选择列和操作列居中。
### 4. 弹窗与详情区统一正式化
- 统一弹窗圆角、头部下边线、正文浅底。
- 详情区、导入弹窗、编辑弹窗使用更克制的信息面板样式。
- 不重排现有字段,不新增删减交互块。
## 验证计划
- 复用现有样式契约单测,确保已完成页面没有回退。
- 使用浏览器打开真实业务路由进行验证,禁止使用 prototype 页面替代。
- 核对关键页面是否保持:
- 查询区与工具条仍在原位置
- 新增、导入、失败记录按钮仍按原顺序出现
- 表格列和弹窗内容结构不变
## 风险控制
- 不使用旧 patch 中的结构改法,只借用可复用的正式化视觉参数。
- 每个页面只处理最外层承载和控件外观,不触碰业务字段、接口、校验、按钮逻辑。
- 若真实页面路由可访问,则以真实页面结果为准;若不可访问,保留源码级验证说明。

View File

@@ -1,56 +0,0 @@
# 结果总览项目分析详情正式化外壳前端实施计划
## 目标
- 基于 `output/mockups/project-analysis-formal-soft-preview.html` 的静态预览稿,恢复“项目分析详情”弹窗的正式化、去卡片化外壳样式。
- 本次仅调整详情弹窗整体框架、标题区、左侧人物档案区、右侧主承载区与页签外层视觉。
- 不修改“异常明细”页签内部业务结构、分页、按钮、接口与数据逻辑。
## 范围
- 修改 `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- 修改 `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 修改 `ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js`
- 修改 `ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js`
## 实施方案
### 1. 弹窗外壳正式化
- 将当前偏渐变、大圆角的详情弹窗外壳改为更平直的正式化工作台样式。
- 顶部保留“结果总览 / 项目分析详情”的信息层级,但改成浅边线、弱装饰、明确留白的标题区。
- 调整整体布局间距,让左侧档案区和右侧主区以纵向分隔线形成清晰结构。
### 2. 左侧档案区映射静态稿
- 保留当前姓名、风险等级、工号、部门、所属项目、命中模型数、核心异常标签的数据字段。
- 通过信息头、字段列表、摘要区三段式样式,映射静态稿的人物档案视觉。
- 不新增额外字段、不新增辅助业务区块。
### 3. 右侧主区外层收口
- 保持 `el-tabs`、错误提示、加载逻辑、默认页签逻辑不变。
- 只调整页签外层、内容承载区、主区边界与留白,不进入各 tab 内部重做内容样式。
## 验证计划
### 代码校验
-`ruoyi-ui` 目录执行:
- `node tests/unit/project-analysis-dialog-layout.test.js`
- `node tests/unit/project-analysis-dialog-sidebar.test.js`
- `node tests/unit/project-analysis-dialog-empty-field.test.js`
### 浏览器验证
- 先通过 `nvm use` 确认前端 Node 版本。
- 启动真实前端页面后,使用 `browser-use` 在系统真实页面打开“项目分析详情”弹窗。
- 重点核对:
- 标题区是否为正式化平直样式
- 左侧档案区是否按预览稿形成清晰三段层次
- 右侧主区是否只改外层、不影响“异常明细”内部内容与交互
## 风险控制
- 不改接口、不改 mock 数据、不改异常明细内部组件,避免把外壳样式改动扩大成业务结构调整。
- 单测只更新与外层视觉契约直接相关的断言,避免引入无关回归。

View File

@@ -0,0 +1,572 @@
# 项目详情专项排查图谱嵌入前端实施计划
> **执行说明:** 按当前项目 `AGENTS.md` 规则执行。未得到用户明确授权时默认不开启 subagent如用户明确要求使用 subagent必须按项目规则指定模型与推理强度。Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 在项目详情“专项排查”页面内,将现有图谱占位卡片替换为可输入证件号并生成 iframe 链接的资金图谱/关系图谱展示区。
**Architecture:** 只做前端最短路径改造。新增一个独立 Vue 2 组件负责证件号标准化、md5、tab 状态和 iframe `src` 拼接;`SpecialCheck.vue` 只负责引入组件并替换现有占位卡片。外部图谱当前可能打不开,验收只检查 iframe 链接是否按规则生成。
**Tech Stack:** Vue 2、Element UI、SCSS、`@/utils/md5``browser-use:browser` 实际页面验证。
---
## Scope Check
本计划只覆盖项目详情“专项排查”页内的图谱 iframe 嵌入,不新增后端接口,不保存证件号,不新增项目详情一级菜单,不做身份证格式校验,不增加外部图谱服务兜底或降级逻辑。
## File Structure
- Create: `ruoyi-ui/src/views/ccdiProject/components/detail/GraphAtlasSection.vue`
- 责任:维护共用证件号输入、资金/关系图谱 tab、证件号标准化、md5、iframe 链接生成和空状态展示。
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- 责任:移除现有“图谱外链展示”占位卡片,引入并渲染 `GraphAtlasSection`,并让图谱在资产负债核查空数据状态下仍可展示,清理不再使用的占位样式。
- Create: `docs/reports/implementation/2026-05-07-project-special-check-graph-atlas-frontend-record.md`
- 责任:记录本次前端实现内容、影响范围和验证情况。
- Do not commit: `output/browser-use/` 或其它临时测试文件。
## Test Data
用于链接断言的固定样例:
- 输入:`110101199003074211`
- md5`9b1b5eba4a26c9a68ff1ca06f40bee1b`
- 资金图谱期望 `vId``idno_node/9b1b5eba4a26c9a68ff1ca06f40bee1b`
- 关系图谱期望 `vId``rel_node/9b1b5eba4a26c9a68ff1ca06f40bee1b`
标准化样例:
- 输入:` 33078219900101123x `
- 标准化后:`33078219900101123X`
- md5`233c8519f86a57b1f00ec88a32152ce3`
## Task 1: Establish Current Failure Baseline
**Files:**
- Read: `docs/design/2026-05-07-project-special-check-graph-atlas-design.md`
- Read: `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- [ ] **Step 1: Confirm the spec**
Run:
```bash
sed -n '1,140p' docs/design/2026-05-07-project-special-check-graph-atlas-design.md
```
Expected: spec confirms 专项排查内嵌 iframe、两个 tab、共用证件号、`trim + x 转 X + md5`、只校验 iframe `src`
- [ ] **Step 2: Confirm current placeholder**
Run:
```bash
rg -n "图谱外链展示|graph-placeholder" ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue
```
Expected: current page still has placeholder card and no证件号 input, no graph iframe.
- [ ] **Step 3: Record baseline failure**
Acceptance baseline:
- Current page cannot input证件号 in the graph area.
- Current page cannot produce `idno_node/<md5>` or `rel_node/<md5>` iframe links.
Expected: FAIL relative to the new spec. This is the intended pre-implementation failure.
## Task 2: Create GraphAtlasSection Component
**Files:**
- Create: `ruoyi-ui/src/views/ccdiProject/components/detail/GraphAtlasSection.vue`
- [ ] **Step 1: Create the component with minimal behavior**
Create `GraphAtlasSection.vue` with this implementation:
```vue
<template>
<section class="graph-atlas-card">
<div class="graph-atlas-header">
<div>
<div class="graph-atlas-title">图谱展示</div>
<div class="graph-atlas-subtitle">输入证件号后查看资金图谱和关系图谱</div>
</div>
</div>
<div class="graph-atlas-query">
<el-input
v-model="certNoInput"
class="graph-atlas-input"
clearable
placeholder="请输入证件号"
size="small"
@keyup.enter.native="handleQuery"
/>
<el-button
type="primary"
size="small"
icon="el-icon-search"
@click="handleQuery"
>
查询图谱
</el-button>
</div>
<el-tabs v-model="activeGraphTab" class="graph-atlas-tabs">
<el-tab-pane
v-for="item in graphTabs"
:key="item.name"
:label="item.label"
:name="item.name"
/>
</el-tabs>
<div v-if="!certNoHash" class="graph-atlas-empty">
<el-empty description="请输入证件号后查询图谱" />
</div>
<div v-else class="graph-atlas-frame-wrapper">
<iframe
:key="`${activeGraphTab}-${certNoHash}`"
class="graph-atlas-frame"
:src="currentGraphUrl"
title="专项排查图谱"
/>
</div>
</section>
</template>
<script>
import md5 from "@/utils/md5";
const GRAPH_ATLAS_CONFIG = {
fund: {
label: "资金图谱",
urlTemplate: 'http://64.202.65.112:8082/atlas/refactor/#/home/graph/downloadService?id=ccdi_lanxi_trans&mode=K_EXPAND&type=NORMAL&atlasToken=F4BBA291A285858BAF4526C6EC312388&params={"vId":"idno_node/{hash}"}',
},
relationship: {
label: "关系图谱",
urlTemplate: 'http://64.202.65.112:8082/atlas/refactor/#/home/graph/downloadService?id=lanxitest&mode=K_EXPAND&type=NORMAL&atlasToken=2C914E5E1FBFBC4AD15163E0AB03B800&params={"vId":"rel_node/{hash}"}',
},
};
export default {
name: "GraphAtlasSection",
data() {
return {
certNoInput: "",
certNoHash: "",
activeGraphTab: "fund",
graphTabs: [
{ name: "fund", label: GRAPH_ATLAS_CONFIG.fund.label },
{ name: "relationship", label: GRAPH_ATLAS_CONFIG.relationship.label },
],
};
},
computed: {
currentGraphUrl() {
if (!this.certNoHash) {
return "";
}
const config = GRAPH_ATLAS_CONFIG[this.activeGraphTab];
return config.urlTemplate.replace("{hash}", this.certNoHash);
},
},
methods: {
handleQuery() {
const certNo = this.normalizeCertNo(this.certNoInput);
if (!certNo) {
this.certNoHash = "";
this.$message.warning("请输入证件号");
return;
}
this.certNoHash = md5(certNo);
},
normalizeCertNo(value) {
const certNo = value === null || value === undefined ? "" : String(value).trim();
if (certNo.endsWith("x")) {
return `${certNo.slice(0, -1)}X`;
}
return certNo;
},
},
};
</script>
<style lang="scss" scoped>
.graph-atlas-card {
margin-top: 16px;
padding: 20px;
background: #fff;
border: 1px solid var(--ccdi-border);
border-radius: 14px;
box-shadow: var(--ccdi-shadow);
}
.graph-atlas-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.graph-atlas-title {
font-size: 16px;
font-weight: 600;
color: var(--ccdi-text-primary);
}
.graph-atlas-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--ccdi-text-muted);
}
.graph-atlas-query {
display: flex;
align-items: center;
gap: 10px;
margin-top: 16px;
}
.graph-atlas-input {
width: 320px;
}
.graph-atlas-tabs {
margin-top: 16px;
}
.graph-atlas-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 360px;
border: 1px dashed #d9e3ee;
background: #f8fbfe;
}
.graph-atlas-frame-wrapper {
overflow: hidden;
border: 1px solid #d9e3ee;
background: #fff;
}
.graph-atlas-frame {
display: block;
width: 100%;
height: calc(100vh - 280px);
min-height: 560px;
max-height: 760px;
border: 0;
}
@media (max-width: 768px) {
.graph-atlas-query {
align-items: stretch;
flex-direction: column;
}
.graph-atlas-input {
width: 100%;
}
.graph-atlas-frame {
min-height: 480px;
}
}
</style>
```
- [ ] **Step 2: Check component syntax**
Run:
```bash
cd ruoyi-ui
nvm use
npm run build:prod
```
Expected: this may still fail before wiring if the file is not imported, but it must not report syntax errors in `GraphAtlasSection.vue` once wired in Task 3.
## Task 3: Replace SpecialCheck Placeholder
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- [ ] **Step 1: Import and register the new component**
Modify the script section:
```js
import ExtendedQuerySection from "./ExtendedQuerySection";
import FamilyAssetLiabilitySection from "./FamilyAssetLiabilitySection";
import GraphAtlasSection from "./GraphAtlasSection";
export default {
name: "SpecialCheck",
components: {
ExtendedQuerySection,
FamilyAssetLiabilitySection,
GraphAtlasSection,
},
```
- [ ] **Step 2: Restructure the content area and replace the placeholder block**
The graph must render after the专项排查 data request finishes, even when the family asset/liability list is empty. Replace the current loading/empty/loaded template split with this structure:
```vue
<div v-if="pageState === 'loading'" class="special-check-state">
<div class="state-card">
<el-skeleton animated :rows="6" />
</div>
</div>
<div v-else class="special-check-page">
<div v-if="pageState === 'empty'" class="special-check-state">
<div class="state-card">
<el-empty description="暂无员工家庭资产负债核查数据" />
</div>
</div>
<family-asset-liability-section
v-else
:rows="currentData.rows"
:loading="false"
:project-id="projectId"
:title="sectionTitle"
:subtitle="sectionSubtitle"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
<graph-atlas-section />
</div>
```
Keep the existing `<div v-if="projectId" class="special-check-extended-wrapper">` below this block unchanged, so the final order is: asset/liability loaded table or empty state -> graph atlas -> extended query.
- [ ] **Step 3: Remove obsolete placeholder styles**
Delete these obsolete scoped style blocks from `SpecialCheck.vue`:
```scss
.graph-placeholder-card { ... }
.graph-placeholder-header { ... }
.graph-placeholder-title { ... }
.graph-placeholder-subtitle { ... }
.graph-placeholder-body { ... }
.graph-placeholder-text { ... }
```
Keep `.special-check-extended-wrapper` unchanged so the扩展查询 spacing remains stable.
- [ ] **Step 4: Build the frontend**
Run:
```bash
cd ruoyi-ui
nvm use
npm run build:prod
```
Expected: build completes successfully with no Vue template or module resolution errors.
## Task 4: Browser Verification
**Files:**
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/GraphAtlasSection.vue`
- Verify: `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- Do not commit: `output/browser-use/`
- [ ] **Step 1: Start frontend for real page testing**
Run:
```bash
cd ruoyi-ui
nvm use
npm run dev
```
Expected: dev server starts successfully. Record the local URL. If the default port is occupied, use the URL printed by Vue CLI.
- [ ] **Step 2: Use browser-use on a real project detail page**
Use `browser-use:browser`, not a prototype page.
Navigate to a real project detail route, for example:
```text
http://localhost:<port>/ccdiProject/detail/<projectId>?tab=special
```
Expected:
- “图谱展示” appears in the专项排查 page after the data request finishes.
- If the project has资产负债核查 rows, the graph is below员工家庭资产负债专项核查 and above采购/招聘/调动扩展查询.
- If the project has no资产负债核查 rows, the graph still appears below the existing empty state and above采购/招聘/调动扩展查询.
- [ ] **Step 3: Verify empty input behavior**
Actions:
1. Confirm iframe is absent before any query.
2. Click “查询图谱” with empty input.
3. Press Enter in the empty input.
Expected:
- Page shows “请输入证件号”.
- iframe is still absent.
- [ ] **Step 4: Verify funds graph URL by button**
Actions:
1. Input `110101199003074211`.
2. Click “查询图谱”.
3. Inspect the iframe raw attribute with `document.querySelector(".graph-atlas-frame").getAttribute("src")`. If the tool only exposes normalized `iframe.src`, run `decodeURIComponent(src)` before checking the JSON `params` fragment.
Expected raw iframe `src`, or decoded normalized `src`, contains:
```text
id=ccdi_lanxi_trans
atlasToken=F4BBA291A285858BAF4526C6EC312388
"vId":"idno_node/9b1b5eba4a26c9a68ff1ca06f40bee1b"
```
External graph content may fail to load; this is acceptable. The `src` is the pass condition.
- [ ] **Step 5: Verify funds graph URL by Enter**
Actions:
1. Replace input with ` 33078219900101123x `.
2. Press Enter.
3. Inspect the iframe raw attribute with `document.querySelector(".graph-atlas-frame").getAttribute("src")`. If the tool only exposes normalized `iframe.src`, run `decodeURIComponent(src)` before checking the JSON `params` fragment.
Expected raw iframe `src`, or decoded normalized `src`, contains:
```text
"vId":"idno_node/233c8519f86a57b1f00ec88a32152ce3"
```
This proves `trim + x 转 X + md5`.
- [ ] **Step 6: Verify relationship graph URL**
Actions:
1. Switch to “关系图谱”.
2. Inspect the iframe raw attribute with `document.querySelector(".graph-atlas-frame").getAttribute("src")`. If the tool only exposes normalized `iframe.src`, run `decodeURIComponent(src)` before checking the JSON `params` fragment.
Expected raw iframe `src`, or decoded normalized `src`, contains:
```text
id=lanxitest
atlasToken=2C914E5E1FBFBC4AD15163E0AB03B800
"vId":"rel_node/233c8519f86a57b1f00ec88a32152ce3"
```
External graph content may fail to load; this is acceptable. The `src` is the pass condition.
- [ ] **Step 7: Stop frontend dev process**
Stop the process started in Step 1.
Expected: no test frontend process remains running.
## Task 5: Implementation Record
**Files:**
- Create: `docs/reports/implementation/2026-05-07-project-special-check-graph-atlas-frontend-record.md`
- [ ] **Step 1: Write the implementation report**
Create the report with this structure:
```markdown
# 项目详情专项排查图谱嵌入前端实施记录
## 修改内容
- 新增 `GraphAtlasSection.vue`,实现证件号输入、资金图谱/关系图谱页签、md5 后 iframe 链接拼接。
- 调整 `SpecialCheck.vue`,将原图谱占位卡片替换为图谱展示组件。
## 影响范围
- 仅影响项目详情页“专项排查”中的图谱展示区域。
- 不涉及后端接口、数据库、权限、菜单和项目详情顶部一级导航。
## 验证情况
- `cd ruoyi-ui && nvm use && npm run build:prod`:记录结果。
- 真实项目详情页专项排查浏览器验证:记录项目地址、输入样例、资金图谱 iframe `src`、关系图谱 iframe `src`
- 说明当前外部图谱内容打不开属于预期,验收只检查 iframe 链接正确。
## 遗留事项
- 无。
```
- [ ] **Step 2: Check report path**
Run:
```bash
ls docs/reports/implementation/2026-05-07-project-special-check-graph-atlas-frontend-record.md
```
Expected: file exists under `docs/reports/implementation/`.
## Task 6: Final Git Check
**Files:**
- Stage when ready:
- `ruoyi-ui/src/views/ccdiProject/components/detail/GraphAtlasSection.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue`
- `docs/reports/implementation/2026-05-07-project-special-check-graph-atlas-frontend-record.md`
- [ ] **Step 1: Inspect worktree**
Run:
```bash
git status --short
```
Expected: existing unrelated dirty files may still be present. Do not stage unrelated files such as existing Docker, application config, SQL, PDF exporter, or other report changes unless the user explicitly requests them.
- [ ] **Step 2: Inspect relevant diff**
Run:
```bash
git diff -- ruoyi-ui/src/views/ccdiProject/components/detail/GraphAtlasSection.vue ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue docs/reports/implementation/2026-05-07-project-special-check-graph-atlas-frontend-record.md
```
Expected: diff only contains the graph atlas feature and implementation report.
- [ ] **Step 3: Stage only this task**
Run:
```bash
git add -- ruoyi-ui/src/views/ccdiProject/components/detail/GraphAtlasSection.vue ruoyi-ui/src/views/ccdiProject/components/detail/SpecialCheck.vue docs/reports/implementation/2026-05-07-project-special-check-graph-atlas-frontend-record.md
git diff --cached --name-status
```
Expected: staged files are exactly the three paths above.
- [ ] **Step 4: Commit if requested**
Run only when the user asks to commit:
```bash
git commit -m "接入专项排查图谱展示"
```
Expected: Chinese commit message, no unrelated files included.

View File

@@ -0,0 +1,36 @@
# 关联业务自动补入实体库实施记录
## 修改内容
- 新增 `EnterpriseAutoFillService`,统一处理关联业务按统一社会信用代码补入 `ccdi_enterprise_base_info`
- 员工实体关系、信贷客户实体关系、中介关系、招投标供应商新增与导入成功数据,均在保存关联关系前按缺失实体自动补入实体库。
- 企业名称按“允许为空,有值则入库”的规则处理;供应商场景使用供应商名称补入,其余当前无企业名称来源的场景补入 `NULL`
- 新增企业来源枚举 `SUPPLIER/供应商`,前端实体库企业来源下拉复用后端枚举接口,无需新增前端硬编码。
- 中介来源实体默认风险等级为高风险;中介实体关系不再要求实体库预先存在。
- 新增迁移脚本 `sql/migration/2026-04-26-make-enterprise-name-nullable-for-auto-fill.sql`,将 `ccdi_enterprise_base_info.enterprise_name` 调整为可空。
## 影响范围
- 后端模块:`ccdi-info-collection`
- 相关业务:员工关系实体、信贷客户实体、中介关系、招投标供应商、实体库导入、企业来源枚举
- 数据库表:`ccdi_enterprise_base_info`
## 验证情况
- 后端单元验证已通过:
```bash
mvn -pl ccdi-info-collection -am -Dtest=CcdiEnumControllerTest,EnterpriseAutoFillServiceTest,CcdiStaffEnterpriseRelationServiceImplTest,CcdiStaffEnterpriseRelationImportServiceImplTest,CcdiCustEnterpriseRelationServiceImplTest,CcdiCustEnterpriseRelationImportServiceImplTest,CcdiIntermediaryServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiEnterpriseBaseInfoImportServiceImplTest,CcdiPurchaseTransactionServiceImplTest,CcdiPurchaseTransactionImportServiceImplTest,CcdiPurchaseTransactionFeatureContractTest -Dsurefire.failIfNoSpecifiedTests=false test
```
- 启动当前工作树后端成功,监听 `62318`
- 使用 `nvm use` 切换到 Node `v14.21.3`,启动前端开发服务到 `http://localhost:8080/`
- 使用 browser-use 打开真实业务页面:
- `http://localhost:8080/maintain/enterpriseBaseInfo` 加载正常,企业来源下拉出现“供应商”。
- `http://localhost:8080/maintain/purchaseTransaction` 加载正常,供应商明细相关页面可访问。
- 后端枚举接口 `GET /ccdi/enum/enterpriseSource` 已返回 `{ "value": "SUPPLIER", "label": "供应商" }`
## 未执行事项
- 远程联调库当前 `ccdi_enterprise_base_info.enterprise_name` 仍为 `Null=NO`
- 本次未直接执行远程库结构变更;提权执行迁移脚本被安全审查拦截。真实写入空企业名称的补库验证需先由授权人员执行迁移脚本。

View File

@@ -1,40 +0,0 @@
# 员工信息维护与招聘信息管理正式化外壳实施记录
## 变更日期
- 2026-04-29
## 变更范围
- 前端:`ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- 前端:`ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- 单测:`ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js`
- 单测:`ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js`
## 实施内容
### 1. 页面外壳调整
- 复用 `app-container``query-form``mb8` 等现有结构,只补最少样式。
-`ccdiBaseStaff``ccdiStaffRecruitment` 的查询区增加正式化外壳视觉,包括浅底、边框、留白和输入框边线统一。
- 保持搜索、重置、新增、导入、失败记录等按钮原有顺序不变。
- 为两个页面新增 `formal-table-shell`,将表格和分页收口到同一视觉区域内。
### 2. 弹窗外壳调整
- 复用员工页已有 `employee-edit-dialog``employee-detail-dialog` class只调整弹窗圆角、标题分隔线和弹窗正文背景。
- 复用招聘页现有弹窗结构,只补统一的弹窗标题区和正文背景样式。
- 未改动员工资产、历史工作经历等内部功能块结构。
### 3. 验证情况
- 单测通过:
- `node tests/unit/base-staff-formal-shell-layout.test.js`
- `node tests/unit/staff-recruitment-formal-shell-layout.test.js`
- `node tests/unit/staff-recruitment-import-toolbar.test.js`
- 现有单测异常:
- `node tests/unit/employee-asset-maintenance-layout.test.js`
- 失败原因为当前仓库源码不满足既有字符串断言 `createEmptyAssetRow(defaultPersonId = "")`,与本次外壳样式改动无关。
- 浏览器验证:
- 已使用 `browser-use` 打开 `http://localhost/prototype/staff-recruitment`,确认招聘信息管理页查询区、工具条、表格区已切换为正式化外壳,按钮仍保持原位。
- 尝试打开 `http://localhost/ccdiBaseStaff` 时,当前本地前端路由返回 404 页面,因此未能在浏览器内完成员工信息维护页真实页面验证。

View File

@@ -1,92 +0,0 @@
# 2026-04-29 批量正式化外壳样式实施记录
## 本次实施内容
本轮基于当前本地最新代码,批量将信息维护相关页面收口为统一的正式化外壳样式,继续保持“只改样式、不改内容和功能”的边界。
### 覆盖页面
- 账户库管理:`ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- 征信维护:`ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
- 信贷客户实体关联:`ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- 信贷客户家庭关系:`ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
- 中介库管理:
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- 招投标信息维护:`ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- 员工亲属实体关联:`ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- 员工亲属关系维护:`ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- 员工调动记录:`ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
## 具体调整
### 查询区
- 将筛选区统一收进白色边框面板。
- 统一标签颜色、控件边框、控件圆角和表单项间距。
- 保留全部原始筛选条件和原始布局顺序。
### 工具条
- 为工具条增加统一白色承载面板。
- 按钮圆角统一收敛到约 4px。
- 不调整搜索、重置、新增、导入、失败记录等按钮的位置和语义。
### 表格
- 为列表页统一增加 `formal-table-shell` 外层承载。
- 收紧表头和表体留白,提升单屏显示密度。
- 统一普通列左对齐,操作列和选择列保持居中。
### 弹窗
- 编辑、详情、导入弹窗统一使用更正式的边界和浅底信息面板风格。
- 去掉原有偏演示感的悬浮和装饰感。
- 不改变弹窗中的字段组织和业务交互。
## 修正项
- 批量调整过程中,`ccdiPurchaseTransaction/index.vue` 样式块曾出现一个多余的 `}`,导致前端编译报错。
- 已在本轮内修正,重新通过真实页面检查。
- 批量将 `.mb8` 统一为 `flex` 承载后,`right-toolbar` 的“显示/隐藏 / 刷新”按钮组一度被挤到左侧。
- 已通过为各列表页补充 `.mb8 ::v-deep .top-right-btn { margin-left: auto; }` 恢复原有靠右位置。
## 验证结果
### 单测
- `node ruoyi-ui/tests/unit/base-staff-formal-shell-layout.test.js` 通过
- `node ruoyi-ui/tests/unit/staff-recruitment-formal-shell-layout.test.js` 通过
- `node ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js` 通过
- `node ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js` 通过
### 真实页面浏览器验证
已通过真实业务路由验证以下页面可以打开且关键外壳区域仍保持原有功能结构:
- `http://localhost/maintain/accountInfo`
- `http://localhost/maintain/creditInfo`
- `http://localhost/maintain/intermediary`
- `http://localhost/maintain/purchaseTransaction`
- `http://localhost/maintain/staffTransfer`
- `http://localhost/maintain/staffEnterpriseRelation`
- `http://localhost/maintain/staffFmyRelation`
- `http://localhost/maintain/custEnterpriseRelation`
- `http://localhost/maintain/custFmyRelation`
- `http://localhost/maintain/staffRecruitment`
验证点:
- 页面标题、搜索按钮、新增按钮、导入按钮仍可见
- 查询区与工具条仍位于原位置
- 未发生按钮左右换位
- 表格区与分页区仍按原内容结构展示
## 现有环境问题
- `staffRecruitment` 页面当前仍存在后端返回的字符集排序规则冲突报错:`utf8mb4_0900_ai_ci``utf8mb4_general_ci` 混用。
- 该问题来自现有后端/数据库环境,不是本次样式改动引入的问题。

View File

@@ -1,101 +0,0 @@
# 2026-04-29 正式化样式调整总说明
## 目标边界
- 本轮所有改动都基于当前本地最新代码进行。
- 仅调整页面与弹窗外壳样式,不改变原有内容、字段、按钮语义、交互流程和功能逻辑。
- 不参考 `2026-04-29-dev-ui-style-mixed-stash.patch` 中的结构性和功能性变动。
## 本轮纳入的页面
### 1. 项目分析详情弹窗
- 文件:
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- `ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 调整方向:
- 详情页外壳正式化、去卡片化
- 标题区更平直,人物档案区更规整
- 页签和主区承载更克制
- 不变内容:
- 异常明细、资产分析、征信摘要等业务内容结构不变
- 数据请求、分页、按钮逻辑不变
### 2. 员工信息维护页
- 文件:
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- 调整方向:
- 筛选区收进统一白色区域
- 工具条按钮外观更正式,圆角收小
- 表格与分页统一收进正式信息面板
- 表格更紧凑、阅读更集中
- 编辑/详情弹窗外壳更像正式信息面板
- 不变内容:
- 查询字段、按钮顺序、导入入口、资产信息与党员信息功能不变
### 3. 招聘信息管理页
- 文件:
- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- 调整方向:
- 筛选区、工具条、表格区统一正式化
- 按钮、输入框、下拉框视觉更稳重
- 表格行高与表头高度适当收紧
- 弹窗外壳更克制
- 不变内容:
- 招聘类型、历史工作经历、导入入口、按钮位置和业务流程不变
### 4. 批量推进的信息维护页面
- 文件:
- `ruoyi-ui/src/views/ccdiAccountInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCreditInfo/index.vue`
- `ruoyi-ui/src/views/ccdiCustEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiCustFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/SearchForm.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DataTable.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/DetailDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/EditDialog.vue`
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue`
- `ruoyi-ui/src/views/ccdiStaffEnterpriseRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffFmyRelation/index.vue`
- `ruoyi-ui/src/views/ccdiStaffTransfer/index.vue`
- 调整方向:
- 查询区、工具条、列表区统一成正式化白色信息面板
- 输入框、下拉框、日期控件边框与圆角统一收敛
- 表格与分页通过 `formal-table-shell` 统一承载
- 中介库的搜索、列表、详情、编辑、导入弹窗统一到相同视觉语言
- 各列表维护页的弹窗边界与留白更克制
- 不变内容:
- 按钮顺序、字段结构、导入流程、失败记录入口、详情内容和业务逻辑不变
## 统一视觉原则
- 筛选区更规整:统一白色面板承载,结构清晰
- 按钮更正式:圆角约 4px弱化轻飘感
- 表单控件更稳重:圆角更小,边框更统一
- 表格更紧凑:降低表头和行内容留白,一屏展示更多信息
- 列表阅读性更好:尽量左对齐,减少长字段换行和大片空白
- 视觉装饰收敛:移除不必要的阴影、渐变、悬浮感
- 卡片感减弱:边界、留白、圆角更克制,保留原有内容结构
## 验证说明
- 项目分析详情弹窗已完成真实页面验证
- 员工信息维护页已完成源码与单测级校验
- 招聘信息管理页和批量推进页面已通过真实业务路由验证:
- `http://localhost/maintain/staffRecruitment`
- `http://localhost/maintain/accountInfo`
- `http://localhost/maintain/creditInfo`
- `http://localhost/maintain/intermediary`
- `http://localhost/maintain/purchaseTransaction`
- `http://localhost/maintain/staffTransfer`
- `http://localhost/maintain/staffEnterpriseRelation`
- `http://localhost/maintain/staffFmyRelation`
- `http://localhost/maintain/custEnterpriseRelation`
- `http://localhost/maintain/custFmyRelation`
- 浏览器验证过程中发现并修复了 `ccdiPurchaseTransaction/index.vue` 的样式编译错误
- `staffRecruitment` 页面仍存在现有数据库字符集排序规则冲突报错,该问题不属于本轮样式改动

View File

@@ -1,44 +0,0 @@
# 结果总览项目分析详情正式化外壳实施记录
## 变更日期
- 2026-04-29
## 变更范围
- 前端:`ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisDialog.vue`
- 前端:`ruoyi-ui/src/views/ccdiProject/components/detail/ProjectAnalysisSidebar.vue`
- 单测:`ruoyi-ui/tests/unit/project-analysis-dialog-layout.test.js`
- 单测:`ruoyi-ui/tests/unit/project-analysis-dialog-sidebar.test.js`
## 实施内容
### 1. 弹窗外壳样式正式化
- 调整 `ProjectAnalysisDialog.vue` 的外层壳样式,去掉旧版渐变大圆角卡片视觉。
- 将弹窗主体改为浅灰外层背景 + 白色内容工作台,强化边线、留白和分栏结构。
- 将标题区改成“结果总览 / 项目分析详情”的正式化层级,保留当前命中模型提示,但收口为弱装饰信息块。
- 调整右侧主区与左侧档案区之间的分隔线和间距,只改外壳,不进入各 tab 内部内容结构。
### 2. 左侧人物档案区样式映射
- 调整 `ProjectAnalysisSidebar.vue`,按“人物档案 + 命中模型摘要”两段式结构重排视觉。
- 姓名、风险等级、工号、部门、所属项目继续沿用现有数据字段,不新增业务字段。
- 将风险等级改为细边框状态标识,字段列表改为规整的标签/值双列展示。
- 核心异常标签保留为现有标签数据,仅更新标签外观,不改渲染逻辑。
### 3. 验证情况
- 单测通过:
- `node tests/unit/project-analysis-dialog-layout.test.js`
- `node tests/unit/project-analysis-dialog-sidebar.test.js`
- `node tests/unit/project-analysis-dialog-empty-field.test.js`
- 浏览器实测:
- 使用 `browser-use` 打开本地真实系统 `http://localhost/`
- 进入项目详情页 `http://localhost/ccdiProject/detail/90337?tab=overview`
- 在“结果总览”页点击“查看详情”,确认“项目分析详情”弹窗已应用正式化外壳样式
- 确认左侧人物档案区样式已按预览稿方向收口,右侧“异常明细”内部业务内容未被重做
- 环境记录:
- `ruoyi-ui/.nvmrc` 期望版本为 `14.21.3`
- 当前终端执行 `nvm use` 失败,原因是 `nvm` 未安装到 PowerShell PATH
- 本次前端校验在当前可用 Node `v22.22.0` 下完成,相关单测通过

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