Compare commits

11 Commits

101 changed files with 6898 additions and 510 deletions

4
.gitignore vendored
View File

@@ -87,4 +87,6 @@ ruoyi-ui/vue.config.js
.pytest_cache/
tests/
tests/
tongweb_62318.properties

View File

@@ -15,21 +15,58 @@
---
## 协作约定
## 高优先级规则
- 使用简体中文进行思考和对话
- Git 提交说明使用中文
- Git 提交前必须检查暂存区,仅允许包含本次任务相关文件
- 若暂存区存在无关文件,必须先移出暂存或与用户确认,禁止顺带提交
- 根据设计文档产出实施计划时,默认输出两份文档:
- 后端实施计划放 `docs/plans/backend/`
- 前端实施计划放 `docs/plans/frontend/`
- Git 提交说明必须使用中文
- 忽略 `.DS_Store` 文件,不将其视为本次任务需要处理或提交的有效变更
- 仅当用户明确声明调用 `using-superpowers` 时才允许启用;未明确声明时按普通流程直接处理需求
- Git 提交前必须检查暂存区,仅允许包含本次任务相关文件;若存在无关文件,必须先移出暂存或与用户确认
- 每一次改动都需要留下实施文档,记录修改内容、影响范围与验证情况
- 功能设计同时涉及前端和后端改动时,必须分别输出后端与前端两份实施计划;若仅涉及单侧,则只输出对应实施计划
- 新增或修改设计文档、实施计划、实施记录前,必须先确认保存路径是否正确
- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本
- 测试结束后,自动关闭测试过程中启动的前后端进程
- 重启后端时,必须优先使用 `bin/restart_java_backend.sh`
---
## 协作约定
### 基础协作
- 前端开发直接在当前分支进行,不需要额外创建 git worktree
- 给出方案时,必须保持最短路径实现,不允许提供兼容性、补丁性或过度设计的方案
- 不允许自行扩展出用户需求之外的兜底、降级或变体方案,避免业务逻辑偏移
- 输出方案前必须完成全链路逻辑校验,确保方案逻辑正确、链路闭环
### Git 与变更管理
- Git 提交前必须检查暂存区,仅保留本次任务相关文件
- 若暂存区存在无关文件,必须先移出暂存或与用户确认,禁止顺带提交
- `.DS_Store` 默认忽略,不纳入任务变更范围
### 文档产出
- 若需求来自设计文档,默认同时沉淀后端与前端两份实施计划
- 功能设计同时涉及前端和后端改动时,实施计划分别放在 `docs/plans/backend/``docs/plans/frontend/`
- 功能修改只涉及前端或只涉及后端时,只输出对应的实施计划
- 非前后端架构项目不强制拆分两份实施计划
- 每一次改动都需要留下实施文档,实施记录优先放在 `docs/reports/implementation/`
- 每次新增或修改设计文档、实施计划、实施记录前,都要先确认保存路径是否正确
### 测试与运行
- 测试结束后,自动关闭测试过程中启动的前后端进程
- 重启后端时,必须优先使用 `bin/restart_java_backend.sh`,不要直接手工执行 `java -jar` 替代正式重启流程
- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本
### 数据库与编码
- 遇到 MCP 数据库操作时,使用项目配置文件中的数据库连接信息
- 执行包含中文内容的 MySQL SQL 脚本或数据库导入时,禁止直接手写 `mysql -e` 或普通重定向执行;必须优先使用 `bin/mysql_utf8_exec.sh <sql-file>`,确保会话字符集为 `utf8mb4`,避免导入或写入乱码
- 数据库字符集与排序规则统一要求:所有业务表、系统表新增或修改时,必须显式使用 `utf8mb4` 字符集与 `utf8mb4_general_ci` 排序规则;禁止引入 `utf8mb4_0900_ai_ci``utf8mb4_unicode_ci` 或其他混用排序规则
- 执行包含中文内容的 MySQL SQL 脚本或数据库导入时,禁止直接手写 `mysql -e` 或普通重定向执行;必须优先使用 `bin/mysql_utf8_exec.sh <sql-file>`,确保会话字符集为 `utf8mb4`
- 所有业务表、系统表新增或修改时,必须显式使用 `utf8mb4` 字符集与 `utf8mb4_general_ci` 排序规则
- 禁止引入 `utf8mb4_0900_ai_ci``utf8mb4_unicode_ci` 或其他混用排序规则
- 银行流水打标相关规则与参数编码需要统一使用全大写;新增或修改 `rule_code``indicator_code``param_code` 时,禁止混用大小写风格
---
@@ -63,6 +100,9 @@ mvn clean package -DskipTests
```bash
cd ruoyi-ui
# 使用 nvm 切换到项目所需 Node 版本
nvm use
# 安装依赖
npm install --registry=https://registry.npmmirror.com
@@ -166,8 +206,10 @@ return AjaxResult.success(result);
- 非业务字段如 `create_by``create_time` 由后端自动维护
- 前端表单不要暴露通用审计字段
- 新增菜单、字典、初始化数据时,同步补充 SQL 脚本
- 执行数据库脚本或导入数据库前,需确认客户端会话字符集为 `utf8mb4`;涉及中文插入、更新、导入时默认使用 `bin/mysql_utf8_exec.sh`
- 所有系统表和业务表的表级、字符字段级排序规则统一为 `utf8mb4_general_ci`;新增建表 SQL、字段追加 SQL、表结构修复 SQL 必须显式声明,避免因默认排序规则漂移导致联表或条件查询报错
- 执行数据库脚本或导入数据库前,需确认客户端会话字符集为 `utf8mb4`
- 涉及中文插入、更新、导入时默认使用 `bin/mysql_utf8_exec.sh`
- 所有系统表和业务表的表级、字符字段级排序规则统一为 `utf8mb4_general_ci`
- 新增建表 SQL、字段追加 SQL、表结构修复 SQL 必须显式声明字符集与排序规则,避免因默认排序规则漂移导致联表或条件查询报错
### 前端规范
@@ -228,15 +270,10 @@ ccdi/
### 主要业务代码分布
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/`
-`controller``domain``mapper``service``annotation``validation` 等目录
- `ccdi-project/src/main/java/com/ruoyi/ccdi/project/`
-`config``controller``domain``mapper``service`
- `ccdi-lsfx/src/main/java/com/ruoyi/lsfx/`
-`client``config``constants``controller``domain/request``domain/response`
- `ruoyi-ui/src/views/`
- 当前包含 `ccdi``ccdiBaseStaff``ccdiProject``ccdiPurchaseTransaction``ccdiIntermediary`、亲属关系、员工调动、招聘等业务页面
- `ruoyi-ui/src/api/ccdi/`
- 放置纪检初核业务 API 封装
### 添加新后端模块时
@@ -297,6 +334,9 @@ ccdi/
- 只有历史资料或外部原始材料才放入 `assets/`
- 如果移动了文档,需同步修正文档内引用路径
- 若需求来自设计文档,默认同时沉淀后端与前端两份实施计划
- 功能设计同时涉及前端和后端改动时,必须分别输出后端与前端两份实施计划;若仅涉及前端或仅涉及后端,则只输出对应实施计划;非前后端架构项目不强制拆分双文档
- 每一次改动都需要留下实施文档,记录本次修改内容、影响范围与验证情况,实施记录优先放在 `docs/reports/implementation/`
- 每次新增或修改设计文档、实施计划、实施记录前,都要先确认保存路径是否正确
---
@@ -307,3 +347,5 @@ ccdi/
- `docker/backend``docker/frontend``docker/mock` 分别对应三类运行时镜像
- `sql/migration/` 用于增量迁移脚本,新增修复脚本优先按日期或功能命名
- 启动前后端或 Mock 服务做验证后,结束测试时要主动停止进程,避免残留占用端口
- 前端相关安装、构建、调试、测试命令执行前,必须先通过 `nvm` 切换并确认 Node 版本

View File

@@ -2,10 +2,10 @@ package com.ruoyi.info.collection.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.dto.*;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEntityExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel;
import com.ruoyi.info.collection.domain.vo.*;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEntityImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryService;
import com.ruoyi.info.collection.utils.EasyExcelUtil;
@@ -46,7 +46,7 @@ public class CcdiIntermediaryController extends BaseController {
private ICcdiIntermediaryPersonImportService personImportService;
@Resource
private ICcdiIntermediaryEntityImportService entityImportService;
private ICcdiIntermediaryEnterpriseRelationImportService enterpriseRelationImportService;
/**
* 查询中介列表
@@ -277,10 +277,10 @@ public class CcdiIntermediaryController extends BaseController {
/**
* 下载实体中介导入模板
*/
@Operation(summary = "下载实体中介导入模板")
@PostMapping("/importEntityTemplate")
public void importEntityTemplate(HttpServletResponse response) {
EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiIntermediaryEntityExcel.class, "实体中介信息");
@Operation(summary = "下载中介实体关联关系导入模板")
@PostMapping("/importEnterpriseRelationTemplate")
public void importEnterpriseRelationTemplate(HttpServletResponse response) {
EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiIntermediaryEnterpriseRelationExcel.class, "中介实体关联关系信息");
}
/**
@@ -313,20 +313,19 @@ public class CcdiIntermediaryController extends BaseController {
/**
* 导入实体中介数据(异步)
*/
@Operation(summary = "导入实体中介数据")
@Operation(summary = "导入中介实体关联关系数据")
@PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')")
@Log(title = "实体中介", businessType = BusinessType.IMPORT)
@PostMapping("/importEntityData")
public AjaxResult importEntityData(MultipartFile file) throws Exception {
List<CcdiIntermediaryEntityExcel> list = EasyExcelUtil.importExcel(
file.getInputStream(), CcdiIntermediaryEntityExcel.class);
@Log(title = "中介实体关联关系", businessType = BusinessType.IMPORT)
@PostMapping("/importEnterpriseRelationData")
public AjaxResult importEnterpriseRelationData(MultipartFile file) throws Exception {
List<CcdiIntermediaryEnterpriseRelationExcel> list = EasyExcelUtil.importExcel(
file.getInputStream(), CcdiIntermediaryEnterpriseRelationExcel.class);
if (list == null || list.isEmpty()) {
return error("至少需要一条数据");
}
// 提交异步任务
String taskId = intermediaryService.importIntermediaryEntity(list);
String taskId = intermediaryService.importIntermediaryEnterpriseRelation(list);
// 立即返回,不等待后台任务完成
ImportResultVO result = new ImportResultVO();
@@ -383,12 +382,12 @@ public class CcdiIntermediaryController extends BaseController {
/**
* 查询实体中介导入状态
*/
@Operation(summary = "查询实体中介导入状态")
@Operation(summary = "查询中介实体关联关系导入状态")
@PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')")
@GetMapping("/importEntityStatus/{taskId}")
public AjaxResult getEntityImportStatus(@PathVariable String taskId) {
@GetMapping("/importEnterpriseRelationStatus/{taskId}")
public AjaxResult getEnterpriseRelationImportStatus(@PathVariable String taskId) {
try {
ImportStatusVO status = entityImportService.getImportStatus(taskId);
ImportStatusVO status = enterpriseRelationImportService.getImportStatus(taskId);
return success(status);
} catch (Exception e) {
return error(e.getMessage());
@@ -396,18 +395,18 @@ public class CcdiIntermediaryController extends BaseController {
}
/**
* 查询实体中介导入失败记录
* 查询中介实体关联关系导入失败记录
*/
@Operation(summary = "查询实体中介导入失败记录")
@Operation(summary = "查询中介实体关联关系导入失败记录")
@PreAuthorize("@ss.hasPermi('ccdi:intermediary:import')")
@GetMapping("/importEntityFailures/{taskId}")
public TableDataInfo getEntityImportFailures(
@GetMapping("/importEnterpriseRelationFailures/{taskId}")
public TableDataInfo getEnterpriseRelationImportFailures(
@PathVariable String taskId,
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize) {
List<IntermediaryEntityImportFailureVO> failures =
entityImportService.getImportFailures(taskId);
List<IntermediaryEnterpriseRelationImportFailureVO> failures =
enterpriseRelationImportService.getImportFailures(taskId);
// 手动分页
int fromIndex = (pageNum - 1) * pageSize;
@@ -418,7 +417,7 @@ public class CcdiIntermediaryController extends BaseController {
return getDataTable(new ArrayList<>(), failures.size());
}
List<IntermediaryEntityImportFailureVO> pageData = failures.subList(fromIndex, toIndex);
List<IntermediaryEnterpriseRelationImportFailureVO> pageData = failures.subList(fromIndex, toIndex);
return getDataTable(pageData, failures.size());
}

View File

@@ -5,6 +5,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel;
import com.ruoyi.info.collection.domain.vo.CcdiStaffRecruitmentVO;
import com.ruoyi.info.collection.domain.vo.ImportResultVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
@@ -128,6 +129,15 @@ public class CcdiStaffRecruitmentController extends BaseController {
EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiStaffRecruitmentExcel.class, "员工招聘信息");
}
/**
* 下载历史工作经历导入模板
*/
@Operation(summary = "下载历史工作经历导入模板")
@PostMapping("/workImportTemplate")
public void workImportTemplate(HttpServletResponse response) {
EasyExcelUtil.importTemplateWithDictDropdown(response, CcdiStaffRecruitmentWorkExcel.class, "历史工作经历");
}
/**
* 异步导入招聘信息
*/
@@ -155,6 +165,31 @@ public class CcdiStaffRecruitmentController extends BaseController {
return AjaxResult.success("导入任务已提交,正在后台处理", result);
}
/**
* 异步导入历史工作经历
*/
@Operation(summary = "异步导入历史工作经历")
@Parameter(name = "file", description = "导入文件", required = true)
@PreAuthorize("@ss.hasPermi('ccdi:staffRecruitment:import')")
@Log(title = "员工招聘历史工作经历", businessType = BusinessType.IMPORT)
@PostMapping("/importWorkData")
public AjaxResult importWorkData(@Parameter(description = "导入文件") MultipartFile file) throws Exception {
List<CcdiStaffRecruitmentWorkExcel> list = EasyExcelUtil.importExcel(file.getInputStream(), CcdiStaffRecruitmentWorkExcel.class);
if (list == null || list.isEmpty()) {
return error("至少需要一条数据");
}
String taskId = recruitmentService.importRecruitmentWork(list);
ImportResultVO result = new ImportResultVO();
result.setTaskId(taskId);
result.setStatus("PROCESSING");
result.setMessage("历史工作经历导入任务已提交,正在后台处理");
return AjaxResult.success("历史工作经历导入任务已提交,正在后台处理", result);
}
/**
* 查询导入状态
*/

View File

@@ -63,7 +63,7 @@ public class CcdiBizIntermediary implements Serializable {
/** 职位 */
private String position;
/** 关联人员ID */
/** 关联中介本人证件号码 */
private String relatedNumId;
/** 数据来源MANUAL:手动录入, SYSTEM:系统同步, IMPORT:批量导入, API:接口获取 */

View File

@@ -22,7 +22,7 @@ public class CcdiStaffRecruitment implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 招聘项目编号 */
/** 招聘记录编号 */
@TableId(type = IdType.INPUT)
private String recruitId;
@@ -41,6 +41,9 @@ public class CcdiStaffRecruitment implements Serializable {
/** 应聘人员姓名 */
private String candName;
/** 招聘类型SOCIAL-社招CAMPUS-校招 */
private String recruitType;
/** 应聘人员学历 */
private String candEdu;

View File

@@ -0,0 +1,76 @@
package com.ruoyi.info.collection.domain;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 招聘记录历史工作经历对象 ccdi_staff_recruitment_work
*
* @author ruoyi
* @date 2026-04-15
*/
@Data
@TableName("ccdi_staff_recruitment_work")
public class CcdiStaffRecruitmentWork implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 关联招聘记录编号 */
private String recruitId;
/** 排序号 */
private Integer sortOrder;
/** 工作单位 */
private String companyName;
/** 所属部门 */
private String departmentName;
/** 岗位名称 */
private String positionName;
/** 入职年月 */
private String jobStartMonth;
/** 离职年月 */
private String jobEndMonth;
/** 离职原因 */
private String departureReason;
/** 主要工作内容 */
private String workContent;
/** 备注 */
private String remark;
/** 创建人 */
@TableField(fill = FieldFill.INSERT)
private String createdBy;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/** 更新人 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updatedBy;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}

View File

@@ -67,8 +67,8 @@ public class CcdiIntermediaryPersonAddDTO implements Serializable {
@Size(max = 100, message = "职位长度不能超过100个字符")
private String position;
@Schema(description = "关联人员ID")
@Size(max = 50, message = "关联人员ID长度不能超过50个字符")
@Schema(description = "关联中介本人证件号码")
@Size(max = 50, message = "关联中介本人证件号码长度不能超过50个字符")
private String relatedNumId;
@Schema(description = "关联关系")

View File

@@ -70,8 +70,8 @@ public class CcdiIntermediaryPersonEditDTO implements Serializable {
@Size(max = 100, message = "职位长度不能超过100个字符")
private String position;
@Schema(description = "关联人员ID")
@Size(max = 50, message = "关联人员ID长度不能超过50个字符")
@Schema(description = "关联中介本人证件号码")
@Size(max = 50, message = "关联中介本人证件号码长度不能超过50个字符")
private String relatedNumId;
@Schema(description = "关联关系")

View File

@@ -2,6 +2,7 @@ package com.ruoyi.info.collection.domain.dto;
import com.ruoyi.info.collection.annotation.EnumValid;
import com.ruoyi.info.collection.enums.AdmitStatus;
import com.ruoyi.info.collection.enums.RecruitType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
@@ -22,9 +23,9 @@ public class CcdiStaffRecruitmentAddDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 招聘项目编号 */
@NotBlank(message = "招聘项目编号不能为空")
@Size(max = 32, message = "招聘项目编号长度不能超过32个字符")
/** 招聘记录编号 */
@NotBlank(message = "招聘记录编号不能为空")
@Size(max = 32, message = "招聘记录编号长度不能超过32个字符")
private String recruitId;
/** 招聘项目名称 */
@@ -51,6 +52,11 @@ public class CcdiStaffRecruitmentAddDTO implements Serializable {
@Size(max = 20, message = "应聘人员姓名长度不能超过20个字符")
private String candName;
/** 招聘类型 */
@NotBlank(message = "招聘类型不能为空")
@EnumValid(enumClass = RecruitType.class, message = "招聘类型状态值不合法")
private String recruitType;
/** 应聘人员学历 */
@NotBlank(message = "应聘人员学历不能为空")
@Size(max = 20, message = "应聘人员学历长度不能超过20个字符")

View File

@@ -2,6 +2,7 @@ package com.ruoyi.info.collection.domain.dto;
import com.ruoyi.info.collection.annotation.EnumValid;
import com.ruoyi.info.collection.enums.AdmitStatus;
import com.ruoyi.info.collection.enums.RecruitType;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
@@ -23,8 +24,8 @@ public class CcdiStaffRecruitmentEditDTO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 招聘项目编号 */
@NotNull(message = "招聘项目编号不能为空")
/** 招聘记录编号 */
@NotNull(message = "招聘记录编号不能为空")
private String recruitId;
/** 招聘项目名称 */
@@ -46,6 +47,10 @@ public class CcdiStaffRecruitmentEditDTO implements Serializable {
@Size(max = 20, message = "应聘人员姓名长度不能超过20个字符")
private String candName;
/** 招聘类型 */
@EnumValid(enumClass = RecruitType.class, message = "招聘类型状态值不合法")
private String recruitType;
/** 应聘人员学历 */
@Size(max = 20, message = "应聘人员学历长度不能超过20个字符")
private String candEdu;

View File

@@ -26,6 +26,9 @@ public class CcdiStaffRecruitmentQueryDTO implements Serializable {
/** 候选人姓名(模糊查询) */
private String candName;
/** 招聘类型(精确查询) */
private String recruitType;
/** 证件号码(精确查询) */
private String candId;

View File

@@ -0,0 +1,38 @@
package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 中介实体关联关系导入对象
*/
@Data
public class CcdiIntermediaryEnterpriseRelationExcel implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 中介本人证件号码 */
@ExcelProperty(value = "中介本人证件号码*", index = 0)
@ColumnWidth(24)
private String ownerPersonId;
/** 统一社会信用代码 */
@ExcelProperty(value = "统一社会信用代码*", index = 1)
@ColumnWidth(24)
private String socialCreditCode;
/** 关联人职务 */
@ExcelProperty(value = "关联人职务", index = 2)
@ColumnWidth(20)
private String relationPersonPost;
/** 备注 */
@ExcelProperty(value = "备注", index = 3)
@ColumnWidth(30)
private String remark;
}

View File

@@ -34,6 +34,7 @@ public class CcdiIntermediaryPersonExcel implements Serializable {
/** 人员子类型 */
@ExcelProperty(value = "人员子类型", index = 2)
@ColumnWidth(15)
@DictDropdown(dictType = "ccdi_person_sub_type")
private String personSubType;
/** 性别 */
@@ -83,19 +84,13 @@ public class CcdiIntermediaryPersonExcel implements Serializable {
@ColumnWidth(15)
private String position;
/** 关联人员ID */
@ExcelProperty(value = "关联人员ID", index = 12)
@ColumnWidth(15)
/** 关联中介本人证件号码 */
@ExcelProperty(value = "关联中介本人证件号码", index = 12)
@ColumnWidth(24)
private String relatedNumId;
/** 关系类型 */
@ExcelProperty(value = "关系类型", index = 13)
@ColumnWidth(15)
@DictDropdown(dictType = "ccdi_relation_type")
private String relationType;
/** 备注 */
@ExcelProperty(value = "备注", index = 14)
@ExcelProperty(value = "备注", index = 13)
@ColumnWidth(30)
private String remark;
}

View File

@@ -0,0 +1,95 @@
package com.ruoyi.info.collection.domain.excel;
import com.alibaba.excel.annotation.ExcelProperty;
import com.alibaba.excel.annotation.write.style.ColumnWidth;
import com.ruoyi.common.annotation.Required;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 招聘记录历史工作经历Excel导入对象
*
* @author ruoyi
* @date 2026-04-20
*/
@Data
public class CcdiStaffRecruitmentWorkExcel implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 招聘记录编号 */
@ExcelProperty(value = "招聘记录编号", index = 0)
@ColumnWidth(20)
@Required
private String recruitId;
/** 候选人姓名 */
@ExcelProperty(value = "候选人姓名", index = 1)
@ColumnWidth(15)
@Required
private String candName;
/** 招聘项目名称 */
@ExcelProperty(value = "招聘项目名称", index = 2)
@ColumnWidth(25)
@Required
private String recruitName;
/** 职位名称 */
@ExcelProperty(value = "职位名称", index = 3)
@ColumnWidth(20)
@Required
private String posName;
/** 排序号 */
@ExcelProperty(value = "排序号", index = 4)
@ColumnWidth(10)
@Required
private Integer sortOrder;
/** 工作单位 */
@ExcelProperty(value = "工作单位", index = 5)
@ColumnWidth(25)
@Required
private String companyName;
/** 所属部门 */
@ExcelProperty(value = "所属部门", index = 6)
@ColumnWidth(18)
private String departmentName;
/** 岗位 */
@ExcelProperty(value = "岗位", index = 7)
@ColumnWidth(20)
@Required
private String positionName;
/** 入职年月 */
@ExcelProperty(value = "入职年月", index = 8)
@ColumnWidth(12)
@Required
private String jobStartMonth;
/** 离职年月 */
@ExcelProperty(value = "离职年月", index = 9)
@ColumnWidth(12)
private String jobEndMonth;
/** 离职原因 */
@ExcelProperty(value = "离职原因", index = 10)
@ColumnWidth(30)
private String departureReason;
/** 工作内容 */
@ExcelProperty(value = "工作内容", index = 11)
@ColumnWidth(35)
private String workContent;
/** 备注 */
@ExcelProperty(value = "备注", index = 12)
@ColumnWidth(25)
private String remark;
}

View File

@@ -63,7 +63,7 @@ public class CcdiIntermediaryPersonDetailVO implements Serializable {
@Schema(description = "职位")
private String position;
@Schema(description = "关联人员ID")
@Schema(description = "关联中介本人证件号码")
private String relatedNumId;
@Schema(description = "关联关系")

View File

@@ -21,7 +21,7 @@ public class CcdiIntermediaryRelativeVO implements Serializable {
@Schema(description = "人员ID")
private String bizId;
@Schema(description = "所属中介ID")
@Schema(description = "关联中介本人证件号码")
private String relatedNumId;
@Schema(description = "姓名")

View File

@@ -5,6 +5,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
import java.util.List;
/**
* 员工招聘信息VO
@@ -18,7 +19,7 @@ public class CcdiStaffRecruitmentVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 招聘项目编号 */
/** 招聘记录编号 */
private String recruitId;
/** 招聘项目名称 */
@@ -36,6 +37,9 @@ public class CcdiStaffRecruitmentVO implements Serializable {
/** 应聘人员姓名 */
private String candName;
/** 招聘类型 */
private String recruitType;
/** 应聘人员学历 */
private String candEdu;
@@ -57,6 +61,12 @@ public class CcdiStaffRecruitmentVO implements Serializable {
/** 录用情况描述 */
private String admitStatusDesc;
/** 历史工作经历条数 */
private Long workExperienceCount;
/** 历史工作经历列表 */
private List<CcdiStaffRecruitmentWorkVO> workExperienceList;
/** 面试官1姓名 */
private String interviewerName1;

View File

@@ -0,0 +1,46 @@
package com.ruoyi.info.collection.domain.vo;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 招聘记录历史工作经历VO
*
* @author ruoyi
* @date 2026-04-15
*/
@Data
public class CcdiStaffRecruitmentWorkVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 排序号 */
private Integer sortOrder;
/** 工作单位 */
private String companyName;
/** 所属部门 */
private String departmentName;
/** 岗位名称 */
private String positionName;
/** 入职年月 */
private String jobStartMonth;
/** 离职年月 */
private String jobEndMonth;
/** 离职原因 */
private String departureReason;
/** 主要工作内容 */
private String workContent;
/** 备注 */
private String remark;
}

View File

@@ -0,0 +1,33 @@
package com.ruoyi.info.collection.domain.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
/**
* 中介实体关联关系导入失败记录
*/
@Data
@Schema(description = "中介实体关联关系导入失败记录")
public class IntermediaryEnterpriseRelationImportFailureVO implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
@Schema(description = "中介本人证件号码")
private String ownerPersonId;
@Schema(description = "统一社会信用代码")
private String socialCreditCode;
@Schema(description = "关联人职务")
private String relationPersonPost;
@Schema(description = "备注")
private String remark;
@Schema(description = "错误信息")
private String errorMessage;
}

View File

@@ -22,21 +22,45 @@ public class IntermediaryPersonImportFailureVO implements Serializable {
@Schema(description = "姓名")
private String name;
@Schema(description = "证件号码")
private String personId;
@Schema(description = "人员类型")
private String personType;
@Schema(description = "人员子类型")
private String personSubType;
@Schema(description = "性别")
private String gender;
@Schema(description = "证件类型")
private String idType;
@Schema(description = "证件号码")
private String personId;
@Schema(description = "手机号码")
private String mobile;
@Schema(description = "微信号")
private String wechatNo;
@Schema(description = "联系地址")
private String contactAddress;
@Schema(description = "所在公司")
private String company;
@Schema(description = "企业统一信用码")
private String socialCreditCode;
@Schema(description = "职位")
private String position;
@Schema(description = "关联中介本人证件号码")
private String relatedNumId;
@Schema(description = "备注")
private String remark;
@Schema(description = "错误信息")
private String errorMessage;
}

View File

@@ -19,6 +19,9 @@ public class RecruitmentImportFailureVO {
@Schema(description = "招聘项目名称")
private String recruitName;
@Schema(description = "职位名称")
private String posName;
@Schema(description = "应聘人员姓名")
private String candName;
@@ -28,6 +31,12 @@ public class RecruitmentImportFailureVO {
@Schema(description = "录用情况")
private String admitStatus;
@Schema(description = "工作单位")
private String companyName;
@Schema(description = "岗位")
private String positionName;
@Schema(description = "错误信息")
private String errorMessage;
}

View File

@@ -0,0 +1,49 @@
package com.ruoyi.info.collection.enums;
import com.ruoyi.common.utils.StringUtils;
/**
* 招聘类型枚举
*
* @author ruoyi
*/
public enum RecruitType {
/** 社招 */
SOCIAL("SOCIAL", "社招"),
/** 校招 */
CAMPUS("CAMPUS", "校招");
private final String code;
private final String desc;
RecruitType(String code, String desc) {
this.code = code;
this.desc = desc;
}
public String getCode() {
return code;
}
public String getDesc() {
return desc;
}
public static String getDescByCode(String code) {
for (RecruitType type : values()) {
if (type.code.equals(code)) {
return type.desc;
}
}
return null;
}
public static String inferCode(String recruitName) {
if (StringUtils.isNotEmpty(recruitName) && recruitName.contains("校园")) {
return CAMPUS.code;
}
return SOCIAL.code;
}
}

View File

@@ -14,10 +14,14 @@ import java.util.List;
@Mapper
public interface CcdiIntermediaryEnterpriseRelationMapper extends BaseMapper<CcdiIntermediaryEnterpriseRelation> {
int insertBatch(@Param("list") List<CcdiIntermediaryEnterpriseRelation> list);
List<CcdiIntermediaryEnterpriseRelationVO> selectByIntermediaryBizId(@Param("bizId") String bizId);
CcdiIntermediaryEnterpriseRelationVO selectDetailById(@Param("id") Long id);
boolean existsByIntermediaryBizIdAndSocialCreditCode(@Param("bizId") String bizId,
@Param("socialCreditCode") String socialCreditCode);
List<String> batchExistsByCombinations(@Param("combinations") List<String> combinations);
}

View File

@@ -0,0 +1,13 @@
package com.ruoyi.info.collection.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork;
/**
* 招聘记录历史工作经历 数据层
*
* @author ruoyi
* @date 2026-04-15
*/
public interface CcdiStaffRecruitmentWorkMapper extends BaseMapper<CcdiStaffRecruitmentWork> {
}

View File

@@ -0,0 +1,38 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO;
import java.util.List;
/**
* 中介实体关联关系异步导入服务接口
*/
public interface ICcdiIntermediaryEnterpriseRelationImportService {
/**
* 异步导入中介实体关联关系
*
* @param excelList Excel数据
* @param taskId 任务ID
* @param userName 当前用户名
*/
void importAsync(List<CcdiIntermediaryEnterpriseRelationExcel> excelList, String taskId, String userName);
/**
* 查询导入状态
*
* @param taskId 任务ID
* @return 导入状态
*/
ImportStatusVO getImportStatus(String taskId);
/**
* 查询导入失败记录
*
* @param taskId 任务ID
* @return 失败记录
*/
List<IntermediaryEnterpriseRelationImportFailureVO> getImportFailures(String taskId);
}

View File

@@ -7,7 +7,7 @@ import com.ruoyi.info.collection.domain.vo.IntermediaryPersonImportFailureVO;
import java.util.List;
/**
* 个人中介异步导入Service接口
* 中介信息异步导入Service接口
*
* @author ruoyi
* @date 2026-02-06
@@ -15,7 +15,7 @@ import java.util.List;
public interface ICcdiIntermediaryPersonImportService {
/**
* 异步导入个人中介数据
* 异步导入中介信息
*
* @param excelList Excel数据列表
* @param taskId 任务ID

View File

@@ -2,6 +2,7 @@ package com.ruoyi.info.collection.service;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.dto.*;
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.vo.CcdiIntermediaryEnterpriseRelationVO;
@@ -168,7 +169,7 @@ public interface ICcdiIntermediaryService {
int deleteIntermediaryByIds(String[] ids);
/**
* 校验人员ID唯一性
* 校验中介本人证件号码唯一性
*
* @param personId 人员ID
* @param bizId 排除的人员ID
@@ -193,6 +194,14 @@ public interface ICcdiIntermediaryService {
*/
String importIntermediaryPerson(List<CcdiIntermediaryPersonExcel> list);
/**
* 导入中介实体关联关系
*
* @param list Excel实体列表
* @return 任务ID
*/
String importIntermediaryEnterpriseRelation(List<CcdiIntermediaryEnterpriseRelationExcel> list);
/**
* 导入实体中介数据
*

View File

@@ -1,6 +1,7 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO;
@@ -25,6 +26,17 @@ public interface ICcdiStaffRecruitmentImportService {
String taskId,
String userName);
/**
* 异步导入招聘记录历史工作经历数据
*
* @param excelList Excel数据列表
* @param taskId 任务ID
* @param userName 用户名
*/
void importRecruitmentWorkAsync(List<CcdiStaffRecruitmentWorkExcel> excelList,
String taskId,
String userName);
/**
* 查询导入状态
*

View File

@@ -5,6 +5,7 @@ import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel;
import com.ruoyi.info.collection.domain.vo.CcdiStaffRecruitmentVO;
import java.util.List;
@@ -81,4 +82,12 @@ public interface ICcdiStaffRecruitmentService {
* @return 结果
*/
String importRecruitment(List<CcdiStaffRecruitmentExcel> excelList);
/**
* 导入招聘记录历史工作经历数据(异步)
*
* @param excelList Excel实体列表
* @return 任务ID
*/
String importRecruitmentWork(List<CcdiStaffRecruitmentWorkExcel> excelList);
}

View File

@@ -4,7 +4,10 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.info.collection.domain.CcdiCustEnterpriseRelation;
import com.ruoyi.info.collection.domain.CcdiEnterpriseBaseInfo;
import com.ruoyi.info.collection.domain.CcdiIntermediaryEnterpriseRelation;
import com.ruoyi.info.collection.domain.CcdiStaffEnterpriseRelation;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiEnterpriseBaseInfoQueryDTO;
@@ -14,6 +17,9 @@ import com.ruoyi.info.collection.enums.DataSource;
import com.ruoyi.info.collection.enums.EnterpriseRiskLevel;
import com.ruoyi.info.collection.enums.EnterpriseSource;
import com.ruoyi.info.collection.mapper.CcdiEnterpriseBaseInfoMapper;
import com.ruoyi.info.collection.mapper.CcdiCustEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffEnterpriseRelationMapper;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoImportService;
import com.ruoyi.info.collection.service.ICcdiEnterpriseBaseInfoService;
import jakarta.annotation.Resource;
@@ -25,6 +31,7 @@ import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.StringJoiner;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
@@ -40,6 +47,15 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
@Resource
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper;
@Resource
private CcdiStaffEnterpriseRelationMapper staffEnterpriseRelationMapper;
@Resource
private CcdiCustEnterpriseRelationMapper custEnterpriseRelationMapper;
@Resource
private CcdiIntermediaryEnterpriseRelationMapper intermediaryEnterpriseRelationMapper;
@Resource
private ICcdiEnterpriseBaseInfoImportService enterpriseBaseInfoImportService;
@@ -96,6 +112,9 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
if (socialCreditCodes == null || socialCreditCodes.length == 0) {
return 0;
}
for (String socialCreditCode : socialCreditCodes) {
validateDeleteRelations(socialCreditCode);
}
return enterpriseBaseInfoMapper.deleteBatchIds(List.of(socialCreditCodes));
}
@@ -179,4 +198,23 @@ public class CcdiEnterpriseBaseInfoServiceImpl implements ICcdiEnterpriseBaseInf
}
return false;
}
private void validateDeleteRelations(String socialCreditCode) {
StringJoiner relationTypes = new StringJoiner("");
if (staffEnterpriseRelationMapper.selectCount(new LambdaQueryWrapper<CcdiStaffEnterpriseRelation>()
.eq(CcdiStaffEnterpriseRelation::getSocialCreditCode, socialCreditCode)) > 0) {
relationTypes.add("员工");
}
if (custEnterpriseRelationMapper.selectCount(new LambdaQueryWrapper<CcdiCustEnterpriseRelation>()
.eq(CcdiCustEnterpriseRelation::getSocialCreditCode, socialCreditCode)) > 0) {
relationTypes.add("信贷客户");
}
if (intermediaryEnterpriseRelationMapper.selectCount(new LambdaQueryWrapper<CcdiIntermediaryEnterpriseRelation>()
.eq(CcdiIntermediaryEnterpriseRelation::getSocialCreditCode, socialCreditCode)) > 0) {
relationTypes.add("中介");
}
if (relationTypes.length() > 0) {
throw new RuntimeException("统一社会信用代码[" + socialCreditCode + "]已关联" + relationTypes + ",删除失败");
}
}
}

View File

@@ -0,0 +1,266 @@
package com.ruoyi.info.collection.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
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.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.ImportResult;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO;
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.service.ICcdiIntermediaryEnterpriseRelationImportService;
import com.ruoyi.info.collection.utils.ImportLogUtils;
import com.ruoyi.common.utils.IdCardUtil;
import com.ruoyi.common.utils.StringUtils;
import jakarta.annotation.Resource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.BeanUtils;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 中介实体关联关系异步导入实现
*/
@Service
@EnableAsync
public class CcdiIntermediaryEnterpriseRelationImportServiceImpl implements ICcdiIntermediaryEnterpriseRelationImportService {
private static final Logger log = LoggerFactory.getLogger(CcdiIntermediaryEnterpriseRelationImportServiceImpl.class);
private static final String STATUS_KEY_PREFIX = "import:intermediary-enterprise-relation:";
@Resource
private CcdiIntermediaryEnterpriseRelationMapper relationMapper;
@Resource
private CcdiBizIntermediaryMapper intermediaryMapper;
@Resource
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@Override
@Async
@Transactional(rollbackFor = Exception.class)
public void importAsync(List<CcdiIntermediaryEnterpriseRelationExcel> excelList, String taskId, String userName) {
long startTime = System.currentTimeMillis();
ImportLogUtils.logImportStart(log, taskId, "中介实体关联关系", excelList.size(), userName);
Map<String, String> ownerBizIdByPersonId = getOwnerBizIdByPersonId(excelList);
Set<String> existingEnterpriseCodes = getExistingEnterpriseCodes(excelList);
Set<String> existingCombinations = getExistingRelationCombinations(ownerBizIdByPersonId, excelList);
List<CcdiIntermediaryEnterpriseRelation> successRecords = new ArrayList<>();
List<IntermediaryEnterpriseRelationImportFailureVO> failures = new ArrayList<>();
Set<String> processedCombinations = new HashSet<>();
for (int i = 0; i < excelList.size(); i++) {
CcdiIntermediaryEnterpriseRelationExcel excel = excelList.get(i);
try {
validateExcel(excel);
String ownerBizId = ownerBizIdByPersonId.get(excel.getOwnerPersonId());
if (StringUtils.isEmpty(ownerBizId)) {
throw new RuntimeException("中介本人不存在,请先导入或维护中介本人信息");
}
if (!existingEnterpriseCodes.contains(excel.getSocialCreditCode())) {
throw new RuntimeException("统一社会信用代码不存在于系统机构表");
}
String combination = ownerBizId + "|" + excel.getSocialCreditCode();
if (existingCombinations.contains(combination)) {
throw new RuntimeException("中介实体关联关系已存在,请勿重复导入");
}
if (!processedCombinations.add(combination)) {
throw new RuntimeException("同一中介本人与统一社会信用代码组合在导入文件中重复");
}
CcdiIntermediaryEnterpriseRelation relation = new CcdiIntermediaryEnterpriseRelation();
BeanUtils.copyProperties(excel, relation);
relation.setIntermediaryBizId(ownerBizId);
relation.setCreatedBy(userName);
relation.setUpdatedBy(userName);
successRecords.add(relation);
} catch (Exception e) {
failures.add(createFailureVO(excel, e.getMessage()));
ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(),
String.format("中介本人证件号码=%s, 统一社会信用代码=%s", excel.getOwnerPersonId(), excel.getSocialCreditCode()));
}
}
if (!successRecords.isEmpty()) {
saveBatch(successRecords, 500);
}
if (!failures.isEmpty()) {
redisTemplate.opsForValue().set(failureKey(taskId), failures, 7, TimeUnit.DAYS);
}
ImportResult result = new ImportResult();
result.setTotalCount(excelList.size());
result.setSuccessCount(successRecords.size());
result.setFailureCount(failures.size());
updateImportStatus(taskId, result);
long duration = System.currentTimeMillis() - startTime;
ImportLogUtils.logImportComplete(log, taskId, "中介实体关联关系",
excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration);
}
@Override
public ImportStatusVO getImportStatus(String taskId) {
String key = statusKey(taskId);
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
throw new RuntimeException("任务不存在或已过期");
}
Map<Object, Object> statusMap = redisTemplate.opsForHash().entries(key);
ImportStatusVO statusVO = new ImportStatusVO();
statusVO.setTaskId((String) statusMap.get("taskId"));
statusVO.setStatus((String) statusMap.get("status"));
statusVO.setTotalCount((Integer) statusMap.get("totalCount"));
statusVO.setSuccessCount((Integer) statusMap.get("successCount"));
statusVO.setFailureCount((Integer) statusMap.get("failureCount"));
statusVO.setProgress((Integer) statusMap.get("progress"));
statusVO.setStartTime((Long) statusMap.get("startTime"));
statusVO.setEndTime((Long) statusMap.get("endTime"));
statusVO.setMessage((String) statusMap.get("message"));
return statusVO;
}
@Override
public List<IntermediaryEnterpriseRelationImportFailureVO> getImportFailures(String taskId) {
Object failuresObj = redisTemplate.opsForValue().get(failureKey(taskId));
if (failuresObj == null) {
return Collections.emptyList();
}
return JSON.parseArray(JSON.toJSONString(failuresObj), IntermediaryEnterpriseRelationImportFailureVO.class);
}
private Map<String, String> getOwnerBizIdByPersonId(List<CcdiIntermediaryEnterpriseRelationExcel> excelList) {
List<String> ownerPersonIds = excelList.stream()
.map(CcdiIntermediaryEnterpriseRelationExcel::getOwnerPersonId)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (ownerPersonIds.isEmpty()) {
return Collections.emptyMap();
}
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiBizIntermediary::getPersonSubType, "本人")
.in(CcdiBizIntermediary::getPersonId, ownerPersonIds);
return intermediaryMapper.selectList(wrapper).stream()
.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,
List<CcdiIntermediaryEnterpriseRelationExcel> excelList) {
List<String> combinations = excelList.stream()
.map(excel -> {
String ownerBizId = ownerBizIdByPersonId.get(excel.getOwnerPersonId());
if (StringUtils.isEmpty(ownerBizId) || StringUtils.isEmpty(excel.getSocialCreditCode())) {
return null;
}
return ownerBizId + "|" + excel.getSocialCreditCode();
})
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (combinations.isEmpty()) {
return Collections.emptySet();
}
return new HashSet<>(relationMapper.batchExistsByCombinations(combinations));
}
private void validateExcel(CcdiIntermediaryEnterpriseRelationExcel excel) {
if (StringUtils.isEmpty(excel.getOwnerPersonId())) {
throw new RuntimeException("中介本人证件号码不能为空");
}
if (StringUtils.isEmpty(excel.getSocialCreditCode())) {
throw new RuntimeException("统一社会信用代码不能为空");
}
String ownerPersonIdError = IdCardUtil.getErrorMessage(excel.getOwnerPersonId());
if (ownerPersonIdError != null) {
throw new RuntimeException("中介本人证件号码" + ownerPersonIdError);
}
if (StringUtils.isNotEmpty(excel.getRelationPersonPost()) && excel.getRelationPersonPost().length() > 100) {
throw new RuntimeException("关联人职务长度不能超过100个字符");
}
if (StringUtils.isNotEmpty(excel.getRemark()) && excel.getRemark().length() > 500) {
throw new RuntimeException("备注长度不能超过500个字符");
}
}
private IntermediaryEnterpriseRelationImportFailureVO createFailureVO(CcdiIntermediaryEnterpriseRelationExcel excel,
String errorMessage) {
IntermediaryEnterpriseRelationImportFailureVO failure = new IntermediaryEnterpriseRelationImportFailureVO();
BeanUtils.copyProperties(excel, failure);
failure.setErrorMessage(errorMessage);
return failure;
}
private void saveBatch(List<CcdiIntermediaryEnterpriseRelation> list, int batchSize) {
for (int i = 0; i < list.size(); i += batchSize) {
int end = Math.min(i + batchSize, list.size());
relationMapper.insertBatch(list.subList(i, end));
}
}
private void updateImportStatus(String taskId, ImportResult result) {
Map<String, Object> statusData = new HashMap<>();
statusData.put("status", result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS");
statusData.put("successCount", result.getSuccessCount());
statusData.put("failureCount", result.getFailureCount());
statusData.put("progress", 100);
statusData.put("endTime", System.currentTimeMillis());
statusData.put("message", result.getFailureCount() == 0
? "全部成功!共导入" + result.getTotalCount() + "条数据"
: "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "");
redisTemplate.opsForHash().putAll(statusKey(taskId), statusData);
}
private String statusKey(String taskId) {
return STATUS_KEY_PREFIX + taskId;
}
private String failureKey(String taskId) {
return statusKey(taskId) + ":failures";
}
}

View File

@@ -22,15 +22,18 @@ import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 个人中介异步导入Service实现
*
* @author ruoyi
* @date 2026-02-06
* 中介信息异步导入实现
*/
@Service
@EnableAsync
@@ -38,6 +41,8 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar
private static final Logger log = LoggerFactory.getLogger(CcdiIntermediaryPersonImportServiceImpl.class);
private static final String STATUS_KEY_PREFIX = "import:intermediary:";
@Resource
private CcdiBizIntermediaryMapper intermediaryMapper;
@@ -47,110 +52,104 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar
@Override
@Async
@Transactional(rollbackFor = Exception.class)
public void importPersonAsync(List<CcdiIntermediaryPersonExcel> excelList,
String taskId,
String userName) {
public void importPersonAsync(List<CcdiIntermediaryPersonExcel> excelList, String taskId, String userName) {
long startTime = System.currentTimeMillis();
ImportLogUtils.logImportStart(log, taskId, "中介信息", excelList.size(), userName);
// 记录导入开始
ImportLogUtils.logImportStart(log, taskId, "个人中介", excelList.size(), userName);
List<CcdiBizIntermediary> newRecords = new ArrayList<>();
List<CcdiIntermediaryPersonExcel> ownerRows = new ArrayList<>();
List<CcdiIntermediaryPersonExcel> relativeRows = new ArrayList<>();
List<IntermediaryPersonImportFailureVO> failures = new ArrayList<>();
// 批量查询已存在的证件号
ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的证件号", excelList.size());
Set<String> existingPersonIds = getExistingPersonIds(excelList);
ImportLogUtils.logBatchQueryComplete(log, taskId, "证件号", existingPersonIds.size());
// 用于检测Excel内部的重复ID
Set<String> excelProcessedIds = new HashSet<>();
// 分类数据
for (int i = 0; i < excelList.size(); i++) {
CcdiIntermediaryPersonExcel excel = excelList.get(i);
try {
// 验证数据
validatePersonData(excel, existingPersonIds);
CcdiBizIntermediary intermediary = new CcdiBizIntermediary();
BeanUtils.copyProperties(excel, intermediary);
// 设置数据来源和审计字段
intermediary.setDataSource("IMPORT");
intermediary.setCreatedBy(userName);
intermediary.setUpdatedBy(userName);
if (existingPersonIds.contains(excel.getPersonId())) {
// 证件号码在数据库中已存在,直接报错
throw new RuntimeException(String.format("证件号码[%s]已存在,请勿重复导入", excel.getPersonId()));
} else if (excelProcessedIds.contains(excel.getPersonId())) {
// 证件号码在Excel文件内部重复
throw new RuntimeException(String.format("证件号码[%s]在导入文件中重复,已跳过此条记录", excel.getPersonId()));
validateCommonRow(excel);
if (isOwnerRow(excel)) {
validateOwnerRow(excel);
ownerRows.add(excel);
} else {
newRecords.add(intermediary);
excelProcessedIds.add(excel.getPersonId()); // 标记为已处理
validateRelativeRow(excel);
relativeRows.add(excel);
}
// 记录进度
ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(),
newRecords.size(), failures.size());
} catch (Exception e) {
failures.add(createFailureVO(excel, e.getMessage()));
// 记录验证失败日志
String keyData = String.format("姓名=%s, 证件号码=%s",
excel.getName(), excel.getPersonId());
ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData);
ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(),
String.format("姓名=%s, 证件号码=%s", excel.getName(), excel.getPersonId()));
}
}
// 批量插入新数据
if (!newRecords.isEmpty()) {
ImportLogUtils.logBatchOperationStart(log, taskId, "插入",
(newRecords.size() + 499) / 500, 500);
saveBatch(newRecords, 500);
}
Set<String> existingOwnerPersonIds = getExistingOwnerPersonIds(ownerRows);
Set<String> existingOwnerRefs = getExistingOwnerRefs(relativeRows);
Set<String> existingRelativeCombinations = getExistingRelativeCombinations(relativeRows);
// 保存失败记录到Redis
if (!failures.isEmpty()) {
List<CcdiBizIntermediary> successRecords = new ArrayList<>();
Set<String> importedOwnerPersonIds = new HashSet<>();
for (CcdiIntermediaryPersonExcel ownerExcel : ownerRows) {
try {
String failuresKey = "import:intermediary:" + taskId + ":failures";
redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS);
ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size());
String ownerPersonId = ownerExcel.getPersonId();
if (existingOwnerPersonIds.contains(ownerPersonId)) {
throw new RuntimeException(String.format("中介本人证件号码[%s]已存在,请勿重复导入", ownerPersonId));
}
if (!importedOwnerPersonIds.add(ownerPersonId)) {
throw new RuntimeException(String.format("中介本人证件号码[%s]在导入文件中重复", ownerPersonId));
}
successRecords.add(buildRecord(ownerExcel, userName, null));
} catch (Exception e) {
ImportLogUtils.logRedisError(log, taskId, "保存失败记录", e);
failures.add(createFailureVO(ownerExcel, e.getMessage()));
}
}
Set<String> validOwnerRefs = new HashSet<>(existingOwnerRefs);
validOwnerRefs.addAll(importedOwnerPersonIds);
Set<String> processedRelativeCombinations = new HashSet<>();
for (CcdiIntermediaryPersonExcel relativeExcel : relativeRows) {
try {
String ownerPersonId = relativeExcel.getRelatedNumId();
String combination = ownerPersonId + "|" + relativeExcel.getPersonId();
if (!validOwnerRefs.contains(ownerPersonId)) {
throw new RuntimeException(String.format("关联中介本人证件号码[%s]不存在", ownerPersonId));
}
if (existingRelativeCombinations.contains(combination)) {
throw new RuntimeException(String.format("同一中介本人名下证件号码[%s]的亲属已存在,请勿重复导入", relativeExcel.getPersonId()));
}
if (!processedRelativeCombinations.add(combination)) {
throw new RuntimeException(String.format("同一中介本人名下证件号码[%s]的亲属在导入文件中重复", relativeExcel.getPersonId()));
}
successRecords.add(buildRecord(relativeExcel, userName, ownerPersonId));
} catch (Exception e) {
failures.add(createFailureVO(relativeExcel, e.getMessage()));
}
}
if (!successRecords.isEmpty()) {
saveBatch(successRecords, 500);
}
if (!failures.isEmpty()) {
redisTemplate.opsForValue().set(failureKey(taskId), failures, 7, TimeUnit.DAYS);
}
ImportResult result = new ImportResult();
result.setTotalCount(excelList.size());
result.setSuccessCount(newRecords.size());
result.setSuccessCount(successRecords.size());
result.setFailureCount(failures.size());
updateImportStatus(taskId, result);
// 更新最终状态
String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS";
updateImportStatus(taskId, finalStatus, result);
// 记录导入完成
long duration = System.currentTimeMillis() - startTime;
ImportLogUtils.logImportComplete(log, taskId, "个人中介",
excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration);
ImportLogUtils.logImportComplete(log, taskId, "中介信息",
excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration);
}
@Override
public ImportStatusVO getImportStatus(String taskId) {
String key = "import:intermediary:" + taskId;
Boolean hasKey = redisTemplate.hasKey(key);
if (Boolean.FALSE.equals(hasKey)) {
String key = statusKey(taskId);
if (Boolean.FALSE.equals(redisTemplate.hasKey(key))) {
throw new RuntimeException("任务不存在或已过期");
}
Map<Object, Object> statusMap = redisTemplate.opsForHash().entries(key);
ImportStatusVO statusVO = new ImportStatusVO();
statusVO.setTaskId((String) statusMap.get("taskId"));
statusVO.setStatus((String) statusMap.get("status"));
@@ -161,83 +160,120 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar
statusVO.setStartTime((Long) statusMap.get("startTime"));
statusVO.setEndTime((Long) statusMap.get("endTime"));
statusVO.setMessage((String) statusMap.get("message"));
return statusVO;
}
@Override
public List<IntermediaryPersonImportFailureVO> getImportFailures(String taskId) {
String key = "import:intermediary:" + taskId + ":failures";
Object failuresObj = redisTemplate.opsForValue().get(key);
Object failuresObj = redisTemplate.opsForValue().get(failureKey(taskId));
if (failuresObj == null) {
return Collections.emptyList();
}
return JSON.parseArray(JSON.toJSONString(failuresObj), IntermediaryPersonImportFailureVO.class);
}
/**
* 批量查询已存在的证件号
*/
private Set<String> getExistingPersonIds(List<CcdiIntermediaryPersonExcel> excelList) {
List<String> personIds = excelList.stream()
.map(CcdiIntermediaryPersonExcel::getPersonId)
.filter(StringUtils::isNotEmpty)
.collect(Collectors.toList());
private boolean isOwnerRow(CcdiIntermediaryPersonExcel excel) {
return "本人".equals(excel.getPersonSubType());
}
if (personIds.isEmpty()) {
private void validateCommonRow(CcdiIntermediaryPersonExcel excel) {
if (StringUtils.isEmpty(excel.getName())) {
throw new RuntimeException("姓名不能为空");
}
if (StringUtils.isEmpty(excel.getPersonSubType())) {
throw new RuntimeException("人员子类型不能为空");
}
if (StringUtils.isEmpty(excel.getPersonId())) {
throw new RuntimeException("证件号码不能为空");
}
String idCardError = IdCardUtil.getErrorMessage(excel.getPersonId());
if (idCardError != null) {
throw new RuntimeException("证件号码" + idCardError);
}
}
private void validateOwnerRow(CcdiIntermediaryPersonExcel excel) {
if (StringUtils.isNotEmpty(excel.getRelatedNumId())) {
throw new RuntimeException("本人行关联中介本人证件号码必须为空");
}
}
private void validateRelativeRow(CcdiIntermediaryPersonExcel excel) {
if (StringUtils.isEmpty(excel.getRelatedNumId())) {
throw new RuntimeException("亲属行必须填写关联中介本人证件号码");
}
}
private Set<String> getExistingOwnerPersonIds(List<CcdiIntermediaryPersonExcel> ownerRows) {
List<String> ownerPersonIds = ownerRows.stream()
.map(CcdiIntermediaryPersonExcel::getPersonId)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (ownerPersonIds.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiBizIntermediary::getPersonId, personIds);
List<CcdiBizIntermediary> existingIntermediaries = intermediaryMapper.selectList(wrapper);
return existingIntermediaries.stream()
.map(CcdiBizIntermediary::getPersonId)
.collect(Collectors.toSet());
wrapper.eq(CcdiBizIntermediary::getPersonSubType, "本人")
.in(CcdiBizIntermediary::getPersonId, ownerPersonIds);
return intermediaryMapper.selectList(wrapper).stream()
.map(CcdiBizIntermediary::getPersonId)
.collect(Collectors.toSet());
}
/**
* 批量保存(使用ON DUPLICATE KEY UPDATE)
*/
private int saveBatchWithUpsert(List<CcdiBizIntermediary> list, int batchSize) {
int totalCount = 0;
for (int i = 0; i < list.size(); i += batchSize) {
int end = Math.min(i + batchSize, list.size());
List<CcdiBizIntermediary> subList = list.subList(i, end);
int count = intermediaryMapper.importPersonBatch(subList);
totalCount += count;
}
return totalCount;
}
/**
* 从数据库获取已存在的证件号
*/
private Set<String> getExistingPersonIdsFromDb(List<CcdiBizIntermediary> records) {
List<String> personIds = records.stream()
.map(CcdiBizIntermediary::getPersonId)
.filter(StringUtils::isNotEmpty)
.collect(Collectors.toList());
if (personIds.isEmpty()) {
private Set<String> getExistingOwnerRefs(List<CcdiIntermediaryPersonExcel> relativeRows) {
List<String> ownerRefs = relativeRows.stream()
.map(CcdiIntermediaryPersonExcel::getRelatedNumId)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (ownerRefs.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.in(CcdiBizIntermediary::getPersonId, personIds);
List<CcdiBizIntermediary> existing = intermediaryMapper.selectList(wrapper);
return existing.stream()
.map(CcdiBizIntermediary::getPersonId)
.collect(Collectors.toSet());
wrapper.eq(CcdiBizIntermediary::getPersonSubType, "本人")
.in(CcdiBizIntermediary::getPersonId, ownerRefs);
return intermediaryMapper.selectList(wrapper).stream()
.map(CcdiBizIntermediary::getPersonId)
.collect(Collectors.toSet());
}
private Set<String> getExistingRelativeCombinations(List<CcdiIntermediaryPersonExcel> relativeRows) {
List<String> ownerRefs = relativeRows.stream()
.map(CcdiIntermediaryPersonExcel::getRelatedNumId)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
List<String> relativePersonIds = relativeRows.stream()
.map(CcdiIntermediaryPersonExcel::getPersonId)
.filter(StringUtils::isNotEmpty)
.distinct()
.collect(Collectors.toList());
if (ownerRefs.isEmpty() || relativePersonIds.isEmpty()) {
return Collections.emptySet();
}
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.ne(CcdiBizIntermediary::getPersonSubType, "本人")
.in(CcdiBizIntermediary::getRelatedNumId, ownerRefs)
.in(CcdiBizIntermediary::getPersonId, relativePersonIds);
return intermediaryMapper.selectList(wrapper).stream()
.map(item -> item.getRelatedNumId() + "|" + item.getPersonId())
.collect(Collectors.toSet());
}
private CcdiBizIntermediary buildRecord(CcdiIntermediaryPersonExcel excel, String userName, String ownerPersonId) {
CcdiBizIntermediary intermediary = new CcdiBizIntermediary();
BeanUtils.copyProperties(excel, intermediary);
intermediary.setRelatedNumId(ownerPersonId);
intermediary.setDataSource("IMPORT");
intermediary.setCreatedBy(userName);
intermediary.setUpdatedBy(userName);
return intermediary;
}
/**
* 创建失败记录VO
*/
private IntermediaryPersonImportFailureVO createFailureVO(CcdiIntermediaryPersonExcel excel, String errorMsg) {
IntermediaryPersonImportFailureVO failure = new IntermediaryPersonImportFailureVO();
BeanUtils.copyProperties(excel, failure);
@@ -245,73 +281,31 @@ public class CcdiIntermediaryPersonImportServiceImpl implements ICcdiIntermediar
return failure;
}
/**
* 创建失败记录VO(重载方法)
*/
private IntermediaryPersonImportFailureVO createFailureVO(CcdiBizIntermediary record, String errorMsg) {
CcdiIntermediaryPersonExcel excel = new CcdiIntermediaryPersonExcel();
BeanUtils.copyProperties(record, excel);
return createFailureVO(excel, errorMsg);
}
/**
* 批量保存
*/
private int saveBatch(List<CcdiBizIntermediary> list, int batchSize) {
// 使用真正的批量插入,分批次执行以提高性能
int totalCount = 0;
private void saveBatch(List<CcdiBizIntermediary> list, int batchSize) {
for (int i = 0; i < list.size(); i += batchSize) {
int end = Math.min(i + batchSize, list.size());
List<CcdiBizIntermediary> subList = list.subList(i, end);
int count = intermediaryMapper.insertBatch(subList);
totalCount += count;
intermediaryMapper.insertBatch(list.subList(i, end));
}
return totalCount;
}
/**
* 更新导入状态
*/
private void updateImportStatus(String taskId, String status, ImportResult result) {
String key = "import:intermediary:" + taskId;
private void updateImportStatus(String taskId, ImportResult result) {
Map<String, Object> statusData = new HashMap<>();
statusData.put("status", status);
statusData.put("status", result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS");
statusData.put("successCount", result.getSuccessCount());
statusData.put("failureCount", result.getFailureCount());
statusData.put("progress", 100);
statusData.put("endTime", System.currentTimeMillis());
if ("SUCCESS".equals(status)) {
statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "数据");
} else {
statusData.put("message", "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "");
}
redisTemplate.opsForHash().putAll(key, statusData);
statusData.put("message", result.getFailureCount() == 0
? "全部成功!共导入" + result.getTotalCount() + "条数据"
: "成功" + result.getSuccessCount() + "条,失败" + result.getFailureCount() + "");
redisTemplate.opsForHash().putAll(statusKey(taskId), statusData);
}
/**
* 验证个人中介数据
*
* @param excel Excel数据
* @param existingPersonIds 已存在的证件号集合
*/
private void validatePersonData(CcdiIntermediaryPersonExcel excel,
Set<String> existingPersonIds) {
// 验证必填字段:姓名
if (StringUtils.isEmpty(excel.getName())) {
throw new RuntimeException("姓名不能为空");
}
private String statusKey(String taskId) {
return STATUS_KEY_PREFIX + taskId;
}
// 验证必填字段:证件号码
if (StringUtils.isEmpty(excel.getPersonId())) {
throw new RuntimeException("证件号码不能为空");
}
// 验证证件号码格式
String idCardError = IdCardUtil.getErrorMessage(excel.getPersonId());
if (idCardError != null) {
throw new RuntimeException("证件号码" + idCardError);
}
private String failureKey(String taskId) {
return statusKey(taskId) + ":failures";
}
}

View File

@@ -6,6 +6,7 @@ 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.dto.*;
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.vo.CcdiIntermediaryEnterpriseRelationVO;
@@ -17,6 +18,7 @@ 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.CcdiIntermediaryMapper;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEntityImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryService;
@@ -61,6 +63,9 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
@Resource
private ICcdiIntermediaryEntityImportService entityImportService;
@Resource
private ICcdiIntermediaryEnterpriseRelationImportService enterpriseRelationImportService;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@@ -101,8 +106,9 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
@Override
public List<CcdiIntermediaryRelativeVO> selectIntermediaryRelativeList(String bizId) {
CcdiBizIntermediary owner = requireIntermediaryPerson(bizId);
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiBizIntermediary::getRelatedNumId, bizId)
wrapper.eq(CcdiBizIntermediary::getRelatedNumId, owner.getPersonId())
.ne(CcdiBizIntermediary::getPersonSubType, "本人")
.orderByDesc(CcdiBizIntermediary::getCreateTime);
return bizIntermediaryMapper.selectList(wrapper).stream().map(this::buildRelativeVo).toList();
@@ -187,8 +193,9 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
BeanUtils.copyProperties(editDTO, person);
person.setPersonSubType("本人");
person.setRelatedNumId(null);
return bizIntermediaryMapper.updateById(person);
int updated = bizIntermediaryMapper.updateById(person);
syncRelativeOwnerPersonId(existing.getPersonId(), editDTO.getPersonId());
return updated;
}
@Override
@@ -196,13 +203,13 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
public int insertIntermediaryRelative(String bizId, CcdiIntermediaryRelativeAddDTO addDTO) {
CcdiBizIntermediary owner = requireIntermediaryPerson(bizId);
validateRelativePersonSubType(addDTO.getPersonSubType());
if (!checkPersonIdUnique(addDTO.getPersonId(), null)) {
throw new RuntimeException("证件号已存在");
if (!checkRelativePersonUnique(owner.getPersonId(), addDTO.getPersonId(), null)) {
throw new RuntimeException("中介本人下已存在相同证件号亲属");
}
CcdiBizIntermediary relative = new CcdiBizIntermediary();
BeanUtils.copyProperties(addDTO, relative);
relative.setRelatedNumId(owner.getBizId());
relative.setRelatedNumId(owner.getPersonId());
relative.setDataSource("MANUAL");
return bizIntermediaryMapper.insert(relative);
}
@@ -216,8 +223,8 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
}
validateRelativePersonSubType(editDTO.getPersonSubType());
if (StringUtils.isNotEmpty(editDTO.getPersonId())
&& !checkPersonIdUnique(editDTO.getPersonId(), editDTO.getBizId())) {
throw new RuntimeException("证件号已存在");
&& !checkRelativePersonUnique(existing.getRelatedNumId(), editDTO.getPersonId(), editDTO.getBizId())) {
throw new RuntimeException("中介本人下已存在相同证件号亲属");
}
CcdiBizIntermediary relative = new CcdiBizIntermediary();
@@ -334,7 +341,8 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
if (intermediary != null) {
if (isIntermediaryPerson(intermediary)) {
bizIntermediaryMapper.delete(new LambdaQueryWrapper<CcdiBizIntermediary>()
.eq(CcdiBizIntermediary::getRelatedNumId, id));
.eq(CcdiBizIntermediary::getRelatedNumId, intermediary.getPersonId())
.ne(CcdiBizIntermediary::getPersonSubType, "本人"));
enterpriseRelationMapper.delete(new LambdaQueryWrapper<CcdiIntermediaryEnterpriseRelation>()
.eq(CcdiIntermediaryEnterpriseRelation::getIntermediaryBizId, id));
}
@@ -359,7 +367,8 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
@Override
public boolean checkPersonIdUnique(String personId, String bizId) {
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiBizIntermediary::getPersonId, personId);
wrapper.eq(CcdiBizIntermediary::getPersonId, personId)
.eq(CcdiBizIntermediary::getPersonSubType, "本人");
if (StringUtils.isNotEmpty(bizId)) {
wrapper.ne(CcdiBizIntermediary::getBizId, bizId);
}
@@ -419,6 +428,31 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
return taskId;
}
@Override
@Transactional
public String importIntermediaryEnterpriseRelation(List<CcdiIntermediaryEnterpriseRelationExcel> list) {
String taskId = UUID.randomUUID().toString();
long startTime = System.currentTimeMillis();
String statusKey = "import:intermediary-enterprise-relation:" + taskId;
Map<String, Object> statusData = new HashMap<>();
statusData.put("taskId", taskId);
statusData.put("status", "PROCESSING");
statusData.put("totalCount", list.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);
String userName = SecurityUtils.getUsername();
enterpriseRelationImportService.importAsync(list, taskId, userName);
return taskId;
}
/**
* 导入实体中介数据(异步)
*
@@ -473,6 +507,17 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
}
}
private boolean checkRelativePersonUnique(String ownerPersonId, String personId, String excludeBizId) {
LambdaQueryWrapper<CcdiBizIntermediary> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiBizIntermediary::getRelatedNumId, ownerPersonId)
.eq(CcdiBizIntermediary::getPersonId, personId)
.ne(CcdiBizIntermediary::getPersonSubType, "本人");
if (StringUtils.isNotEmpty(excludeBizId)) {
wrapper.ne(CcdiBizIntermediary::getBizId, excludeBizId);
}
return bizIntermediaryMapper.selectCount(wrapper) == 0;
}
private void validateEnterpriseRelation(String bizId, String socialCreditCode, Long excludeId) {
requireIntermediaryPerson(bizId);
if (enterpriseBaseInfoMapper.selectById(socialCreditCode) == null) {
@@ -490,6 +535,20 @@ public class CcdiIntermediaryServiceImpl implements ICcdiIntermediaryService {
}
}
private void syncRelativeOwnerPersonId(String oldOwnerPersonId, String newOwnerPersonId) {
if (StringUtils.isEmpty(oldOwnerPersonId)
|| StringUtils.isEmpty(newOwnerPersonId)
|| oldOwnerPersonId.equals(newOwnerPersonId)) {
return;
}
CcdiBizIntermediary relative = new CcdiBizIntermediary();
relative.setRelatedNumId(newOwnerPersonId);
bizIntermediaryMapper.update(relative, new LambdaQueryWrapper<CcdiBizIntermediary>()
.eq(CcdiBizIntermediary::getRelatedNumId, oldOwnerPersonId)
.ne(CcdiBizIntermediary::getPersonSubType, "本人"));
}
private CcdiIntermediaryRelativeVO buildRelativeVo(CcdiBizIntermediary relative) {
CcdiIntermediaryRelativeVO vo = new CcdiIntermediaryRelativeVO();
BeanUtils.copyProperties(relative, vo);

View File

@@ -3,13 +3,17 @@ package com.ruoyi.info.collection.service.impl;
import com.alibaba.fastjson2.JSON;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitment;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork;
import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentAddDTO;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel;
import com.ruoyi.info.collection.domain.vo.ImportResult;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.RecruitmentImportFailureVO;
import com.ruoyi.info.collection.enums.AdmitStatus;
import com.ruoyi.info.collection.enums.RecruitType;
import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentWorkMapper;
import com.ruoyi.info.collection.service.ICcdiStaffRecruitmentImportService;
import com.ruoyi.info.collection.utils.ImportLogUtils;
import com.ruoyi.common.utils.IdCardUtil;
@@ -43,6 +47,9 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
@Resource
private CcdiStaffRecruitmentMapper recruitmentMapper;
@Resource
private CcdiStaffRecruitmentWorkMapper recruitmentWorkMapper;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@@ -60,10 +67,10 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
List<CcdiStaffRecruitment> newRecords = new ArrayList<>();
List<RecruitmentImportFailureVO> failures = new ArrayList<>();
// 批量查询已存在的招聘项目编号
ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的招聘项目编号", excelList.size());
// 批量查询已存在的招聘记录编号
ImportLogUtils.logBatchQueryStart(log, taskId, "已存在的招聘记录编号", excelList.size());
Set<String> existingRecruitIds = getExistingRecruitIds(excelList);
ImportLogUtils.logBatchQueryComplete(log, taskId, "招聘项目编号", existingRecruitIds.size());
ImportLogUtils.logBatchQueryComplete(log, taskId, "招聘记录编号", existingRecruitIds.size());
// 用于检测Excel内部的重复ID
Set<String> excelProcessedIds = new HashSet<>();
@@ -76,19 +83,21 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
// 转换为AddDTO进行验证
CcdiStaffRecruitmentAddDTO addDTO = new CcdiStaffRecruitmentAddDTO();
BeanUtils.copyProperties(excel, addDTO);
addDTO.setRecruitType(RecruitType.inferCode(addDTO.getRecruitName()));
// 验证数据
validateRecruitmentData(addDTO, existingRecruitIds);
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
BeanUtils.copyProperties(excel, recruitment);
recruitment.setRecruitType(addDTO.getRecruitType());
if (existingRecruitIds.contains(excel.getRecruitId())) {
// 招聘项目编号在数据库中已存在,直接报错
throw new RuntimeException(String.format("招聘项目编号[%s]已存在,请勿重复导入", excel.getRecruitId()));
// 招聘记录编号在数据库中已存在,直接报错
throw new RuntimeException(String.format("招聘记录编号[%s]已存在,请勿重复导入", excel.getRecruitId()));
} else if (excelProcessedIds.contains(excel.getRecruitId())) {
// 招聘项目编号在Excel文件内部重复
throw new RuntimeException(String.format("招聘项目编号[%s]在导入文件中重复,已跳过此条记录", excel.getRecruitId()));
// 招聘记录编号在Excel文件内部重复
throw new RuntimeException(String.format("招聘记录编号[%s]在导入文件中重复,已跳过此条记录", excel.getRecruitId()));
} else {
recruitment.setCreatedBy(userName);
recruitment.setUpdatedBy(userName);
@@ -107,7 +116,7 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
failures.add(failure);
// 记录验证失败日志
String keyData = String.format("招聘项目编号=%s, 项目名称=%s, 应聘人员=%s",
String keyData = String.format("招聘记录编号=%s, 项目名称=%s, 应聘人员=%s",
excel.getRecruitId(), excel.getRecruitName(), excel.getCandName());
ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData);
}
@@ -142,7 +151,85 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
// 记录导入完成
long duration = System.currentTimeMillis() - startTime;
ImportLogUtils.logImportComplete(log, taskId, "招聘信息",
ImportLogUtils.logImportComplete(log, taskId, "招聘信息",
excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration);
}
@Override
@Async
@Transactional
public void importRecruitmentWorkAsync(List<CcdiStaffRecruitmentWorkExcel> excelList,
String taskId,
String userName) {
long startTime = System.currentTimeMillis();
ImportLogUtils.logImportStart(log, taskId, "招聘历史工作经历", excelList.size(), userName);
List<RecruitmentImportFailureVO> failures = new ArrayList<>();
List<CcdiStaffRecruitmentWork> validRecords = new ArrayList<>();
Set<String> failedRecruitIds = new HashSet<>();
Set<String> processedRecruitSortKeys = new HashSet<>();
Map<String, CcdiStaffRecruitment> recruitmentMap = getRecruitmentMap(excelList);
for (int i = 0; i < excelList.size(); i++) {
CcdiStaffRecruitmentWorkExcel excel = excelList.get(i);
try {
CcdiStaffRecruitment recruitment = recruitmentMap.get(trim(excel.getRecruitId()));
validateRecruitmentWorkData(excel, recruitment, processedRecruitSortKeys);
CcdiStaffRecruitmentWork work = new CcdiStaffRecruitmentWork();
BeanUtils.copyProperties(excel, work);
work.setRecruitId(trim(excel.getRecruitId()));
work.setCreatedBy(userName);
work.setUpdatedBy(userName);
validRecords.add(work);
ImportLogUtils.logProgress(log, taskId, i + 1, excelList.size(),
validRecords.size(), failures.size());
} catch (Exception e) {
failedRecruitIds.add(trim(excel.getRecruitId()));
failures.add(buildWorkFailure(excel, e.getMessage()));
String keyData = String.format("招聘记录编号=%s, 候选人=%s, 工作单位=%s",
excel.getRecruitId(), excel.getCandName(), excel.getCompanyName());
ImportLogUtils.logValidationError(log, taskId, i + 1, e.getMessage(), keyData);
}
}
List<CcdiStaffRecruitmentWork> importRecords = validRecords.stream()
.filter(work -> !failedRecruitIds.contains(work.getRecruitId()))
.toList();
appendSkippedFailures(validRecords, failedRecruitIds, failures);
if (!importRecords.isEmpty()) {
Set<String> importRecruitIds = importRecords.stream()
.map(CcdiStaffRecruitmentWork::getRecruitId)
.collect(Collectors.toSet());
LambdaQueryWrapper<CcdiStaffRecruitmentWork> deleteWrapper = new LambdaQueryWrapper<>();
deleteWrapper.in(CcdiStaffRecruitmentWork::getRecruitId, importRecruitIds);
recruitmentWorkMapper.delete(deleteWrapper);
importRecords.forEach(recruitmentWorkMapper::insert);
}
if (!failures.isEmpty()) {
try {
String failuresKey = "import:recruitment:" + taskId + ":failures";
redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS);
ImportLogUtils.logRedisOperation(log, taskId, "保存失败记录", failures.size());
} catch (Exception e) {
ImportLogUtils.logRedisError(log, taskId, "保存失败记录", e);
}
}
ImportResult result = new ImportResult();
result.setTotalCount(excelList.size());
result.setSuccessCount(importRecords.size());
result.setFailureCount(failures.size());
String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS";
updateImportStatus(taskId, finalStatus, result);
long duration = System.currentTimeMillis() - startTime;
ImportLogUtils.logImportComplete(log, taskId, "招聘历史工作经历",
excelList.size(), result.getSuccessCount(), result.getFailureCount(), duration);
}
@@ -184,7 +271,7 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
}
/**
* 批量查询已存在的招聘项目编号
* 批量查询已存在的招聘记录编号
*/
private Set<String> getExistingRecruitIds(List<CcdiStaffRecruitmentExcel> excelList) {
List<String> recruitIds = excelList.stream()
@@ -212,7 +299,7 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
Set<String> existingRecruitIds) {
// 验证必填字段
if (StringUtils.isEmpty(addDTO.getRecruitId())) {
throw new RuntimeException("招聘项目编号不能为空");
throw new RuntimeException("招聘记录编号不能为空");
}
if (StringUtils.isEmpty(addDTO.getRecruitName())) {
throw new RuntimeException("招聘项目名称不能为空");
@@ -247,6 +334,9 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
if (StringUtils.isEmpty(addDTO.getAdmitStatus())) {
throw new RuntimeException("录用情况不能为空");
}
if (StringUtils.isEmpty(addDTO.getRecruitType())) {
throw new RuntimeException("招聘类型不能为空");
}
// 验证证件号码格式
String idCardError = IdCardUtil.getErrorMessage(addDTO.getCandId());
@@ -263,6 +353,115 @@ public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitm
if (AdmitStatus.getDescByCode(addDTO.getAdmitStatus()) == null) {
throw new RuntimeException("录用情况只能填写'录用'、'未录用'或'放弃'");
}
if (RecruitType.getDescByCode(addDTO.getRecruitType()) == null) {
throw new RuntimeException("招聘类型只能填写'SOCIAL'或'CAMPUS'");
}
}
private Map<String, CcdiStaffRecruitment> getRecruitmentMap(List<CcdiStaffRecruitmentWorkExcel> excelList) {
List<String> recruitIds = excelList.stream()
.map(CcdiStaffRecruitmentWorkExcel::getRecruitId)
.map(this::trim)
.filter(StringUtils::isNotEmpty)
.distinct()
.toList();
if (recruitIds.isEmpty()) {
return Collections.emptyMap();
}
List<CcdiStaffRecruitment> recruitments = recruitmentMapper.selectBatchIds(recruitIds);
return recruitments.stream()
.collect(Collectors.toMap(CcdiStaffRecruitment::getRecruitId, item -> item));
}
private void validateRecruitmentWorkData(CcdiStaffRecruitmentWorkExcel excel,
CcdiStaffRecruitment recruitment,
Set<String> processedRecruitSortKeys) {
if (StringUtils.isEmpty(trim(excel.getRecruitId()))) {
throw new RuntimeException("招聘记录编号不能为空");
}
if (StringUtils.isEmpty(trim(excel.getCandName()))) {
throw new RuntimeException("候选人姓名不能为空");
}
if (StringUtils.isEmpty(trim(excel.getRecruitName()))) {
throw new RuntimeException("招聘项目名称不能为空");
}
if (StringUtils.isEmpty(trim(excel.getPosName()))) {
throw new RuntimeException("职位名称不能为空");
}
if (excel.getSortOrder() == null || excel.getSortOrder() <= 0) {
throw new RuntimeException("排序号不能为空且必须大于0");
}
if (StringUtils.isEmpty(trim(excel.getCompanyName()))) {
throw new RuntimeException("工作单位不能为空");
}
if (StringUtils.isEmpty(trim(excel.getPositionName()))) {
throw new RuntimeException("岗位不能为空");
}
if (StringUtils.isEmpty(trim(excel.getJobStartMonth()))) {
throw new RuntimeException("入职年月不能为空");
}
validateMonth(excel.getJobStartMonth(), "入职年月");
if (StringUtils.isNotEmpty(trim(excel.getJobEndMonth()))) {
validateMonth(excel.getJobEndMonth(), "离职年月");
}
if (recruitment == null) {
throw new RuntimeException("招聘记录编号不存在,请先维护招聘主信息");
}
if (!"SOCIAL".equals(recruitment.getRecruitType())) {
throw new RuntimeException("该招聘记录不是社招,不允许导入历史工作经历");
}
if (!sameText(excel.getCandName(), recruitment.getCandName())) {
throw new RuntimeException("招聘记录编号与候选人姓名不匹配");
}
if (!sameText(excel.getRecruitName(), recruitment.getRecruitName())) {
throw new RuntimeException("招聘记录编号与招聘项目名称不匹配");
}
if (!sameText(excel.getPosName(), recruitment.getPosName())) {
throw new RuntimeException("招聘记录编号与职位名称不匹配");
}
String duplicateKey = trim(excel.getRecruitId()) + "#" + excel.getSortOrder();
if (!processedRecruitSortKeys.add(duplicateKey)) {
throw new RuntimeException("同一招聘记录编号下排序号重复");
}
}
private void validateMonth(String value, String fieldName) {
String month = trim(value);
if (!month.matches("^((19|20)\\d{2})-(0[1-9]|1[0-2])$")) {
throw new RuntimeException(fieldName + "格式不正确应为YYYY-MM");
}
}
private boolean sameText(String first, String second) {
return Objects.equals(trim(first), trim(second));
}
private String trim(String value) {
return value == null ? null : value.trim();
}
private RecruitmentImportFailureVO buildWorkFailure(CcdiStaffRecruitmentWorkExcel excel, String errorMessage) {
RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO();
BeanUtils.copyProperties(excel, failure);
failure.setErrorMessage(errorMessage);
return failure;
}
private void appendSkippedFailures(List<CcdiStaffRecruitmentWork> validRecords,
Set<String> failedRecruitIds,
List<RecruitmentImportFailureVO> failures) {
Set<String> appendedRecruitIds = new HashSet<>();
for (CcdiStaffRecruitmentWork work : validRecords) {
if (failedRecruitIds.contains(work.getRecruitId()) && appendedRecruitIds.add(work.getRecruitId())) {
RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO();
failure.setRecruitId(work.getRecruitId());
failure.setCompanyName(work.getCompanyName());
failure.setPositionName(work.getPositionName());
failure.setErrorMessage("同一招聘记录编号存在失败行,已跳过该编号下全部工作经历,避免覆盖旧数据");
failures.add(failure);
}
}
}
/**

View File

@@ -1,13 +1,18 @@
package com.ruoyi.info.collection.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitment;
import com.ruoyi.info.collection.domain.CcdiStaffRecruitmentWork;
import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentAddDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentEditDTO;
import com.ruoyi.info.collection.domain.dto.CcdiStaffRecruitmentQueryDTO;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentExcel;
import com.ruoyi.info.collection.domain.excel.CcdiStaffRecruitmentWorkExcel;
import com.ruoyi.info.collection.domain.vo.CcdiStaffRecruitmentVO;
import com.ruoyi.info.collection.domain.vo.CcdiStaffRecruitmentWorkVO;
import com.ruoyi.info.collection.enums.AdmitStatus;
import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentWorkMapper;
import com.ruoyi.info.collection.mapper.CcdiStaffRecruitmentMapper;
import com.ruoyi.info.collection.service.ICcdiStaffRecruitmentImportService;
import com.ruoyi.info.collection.service.ICcdiStaffRecruitmentService;
@@ -19,6 +24,7 @@ import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -37,6 +43,9 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
@Resource
private CcdiStaffRecruitmentMapper recruitmentMapper;
@Resource
private CcdiStaffRecruitmentWorkMapper recruitmentWorkMapper;
@Resource
private ICcdiStaffRecruitmentImportService recruitmentImportService;
@@ -96,7 +105,7 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
/**
* 查询招聘信息详情
*
* @param recruitId 招聘项目编号
* @param recruitId 招聘记录编号
* @return 招聘信息VO
*/
@Override
@@ -104,6 +113,7 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
CcdiStaffRecruitmentVO vo = recruitmentMapper.selectRecruitmentById(recruitId);
if (vo != null) {
vo.setAdmitStatusDesc(AdmitStatus.getDescByCode(vo.getAdmitStatus()));
vo.setWorkExperienceList(selectWorkExperienceList(recruitId));
}
return vo;
}
@@ -117,9 +127,9 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
@Override
@Transactional
public int insertRecruitment(CcdiStaffRecruitmentAddDTO addDTO) {
// 检查招聘项目编号唯一性
// 检查招聘记录编号唯一性
if (recruitmentMapper.selectById(addDTO.getRecruitId()) != null) {
throw new RuntimeException("该招聘项目编号已存在");
throw new RuntimeException("该招聘记录编号已存在");
}
CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment();
@@ -148,12 +158,15 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
/**
* 批量删除招聘信息
*
* @param recruitIds 需要删除的招聘项目编号
* @param recruitIds 需要删除的招聘记录编号
* @return 结果
*/
@Override
@Transactional
public int deleteRecruitmentByIds(String[] recruitIds) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> workWrapper = new LambdaQueryWrapper<>();
workWrapper.in(CcdiStaffRecruitmentWork::getRecruitId, List.of(recruitIds));
recruitmentWorkMapper.delete(workWrapper);
return recruitmentMapper.deleteBatchIds(List.of(recruitIds));
}
@@ -197,4 +210,56 @@ public class CcdiStaffRecruitmentServiceImpl implements ICcdiStaffRecruitmentSer
return taskId;
}
/**
* 导入招聘记录历史工作经历数据(异步)
*
* @param excelList Excel实体列表
* @return 任务ID
*/
@Override
@Transactional
public String importRecruitmentWork(List<CcdiStaffRecruitmentWorkExcel> excelList) {
if (StringUtils.isNull(excelList) || excelList.isEmpty()) {
throw new RuntimeException("至少需要一条数据");
}
String taskId = UUID.randomUUID().toString();
long startTime = System.currentTimeMillis();
String userName = SecurityUtils.getUsername();
String statusKey = "import:recruitment:" + 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);
recruitmentImportService.importRecruitmentWorkAsync(excelList, taskId, userName);
return taskId;
}
private List<CcdiStaffRecruitmentWorkVO> selectWorkExperienceList(String recruitId) {
LambdaQueryWrapper<CcdiStaffRecruitmentWork> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(CcdiStaffRecruitmentWork::getRecruitId, recruitId)
.orderByAsc(CcdiStaffRecruitmentWork::getSortOrder)
.orderByDesc(CcdiStaffRecruitmentWork::getId);
List<CcdiStaffRecruitmentWork> workList = recruitmentWorkMapper.selectList(wrapper);
if (workList == null || workList.isEmpty()) {
return new ArrayList<>();
}
return workList.stream().map(work -> {
CcdiStaffRecruitmentWorkVO vo = new CcdiStaffRecruitmentWorkVO();
BeanUtils.copyProperties(work, vo);
return vo;
}).toList();
}
}

View File

@@ -4,6 +4,19 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ruoyi.info.collection.mapper.CcdiIntermediaryEnterpriseRelationMapper">
<insert id="insertBatch" parameterType="java.util.List">
INSERT INTO ccdi_intermediary_enterprise_relation (
intermediary_biz_id, social_credit_code, relation_person_post, remark,
created_by, updated_by, create_time, update_time
) VALUES
<foreach collection="list" item="item" separator=",">
(
#{item.intermediaryBizId}, #{item.socialCreditCode}, #{item.relationPersonPost}, #{item.remark},
#{item.createdBy}, #{item.updatedBy}, NOW(), NOW()
)
</foreach>
</insert>
<resultMap id="CcdiIntermediaryEnterpriseRelationVOResult"
type="com.ruoyi.info.collection.domain.vo.CcdiIntermediaryEnterpriseRelationVO">
<id property="id" column="id"/>
@@ -63,4 +76,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
AND social_credit_code = #{socialCreditCode}
</select>
<select id="batchExistsByCombinations" resultType="string">
SELECT CONCAT(intermediary_biz_id, '|', social_credit_code)
FROM ccdi_intermediary_enterprise_relation
WHERE CONCAT(intermediary_biz_id, '|', social_credit_code) IN
<foreach collection="combinations" item="item" open="(" separator="," close=")">
#{item}
</foreach>
</select>
</mapper>

View File

@@ -32,7 +32,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
child.create_time
FROM ccdi_biz_intermediary child
INNER JOIN ccdi_biz_intermediary parent
ON child.related_num_id COLLATE utf8mb4_general_ci = parent.biz_id COLLATE utf8mb4_general_ci
ON child.related_num_id COLLATE utf8mb4_general_ci = parent.person_id COLLATE utf8mb4_general_ci
AND parent.person_sub_type COLLATE utf8mb4_general_ci = '本人' COLLATE utf8mb4_general_ci
WHERE child.person_sub_type IS NOT NULL
AND child.person_sub_type COLLATE utf8mb4_general_ci != '本人' COLLATE utf8mb4_general_ci

View File

@@ -12,12 +12,14 @@
<result property="posCategory" column="pos_category"/>
<result property="posDesc" column="pos_desc"/>
<result property="candName" column="cand_name"/>
<result property="recruitType" column="recruit_type"/>
<result property="candEdu" column="cand_edu"/>
<result property="candId" column="cand_id"/>
<result property="candSchool" column="cand_school"/>
<result property="candMajor" column="cand_major"/>
<result property="candGrad" column="cand_grad"/>
<result property="admitStatus" column="admit_status"/>
<result property="workExperienceCount" column="work_experience_count"/>
<result property="interviewerName1" column="interviewer_name1"/>
<result property="interviewerId1" column="interviewer_id1"/>
<result property="interviewerName2" column="interviewer_name2"/>
@@ -31,44 +33,53 @@
<!-- 分页查询招聘信息列表 -->
<select id="selectRecruitmentPage" resultMap="CcdiStaffRecruitmentVOResult">
SELECT
recruit_id, recruit_name, pos_name, pos_category, pos_desc,
cand_name, cand_edu, cand_id, cand_school, cand_major, cand_grad,
admit_status, interviewer_name1, interviewer_id1, interviewer_name2, interviewer_id2,
created_by, create_time, updated_by, update_time
FROM ccdi_staff_recruitment
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.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.created_by, r.create_time, r.updated_by, r.update_time
FROM ccdi_staff_recruitment r
LEFT JOIN (
SELECT recruit_id, COUNT(1) AS work_experience_count
FROM ccdi_staff_recruitment_work
GROUP BY recruit_id
) w ON w.recruit_id = r.recruit_id
<where>
<if test="query.recruitName != null and query.recruitName != ''">
AND recruit_name LIKE CONCAT('%', #{query.recruitName}, '%')
AND r.recruit_name LIKE CONCAT('%', #{query.recruitName}, '%')
</if>
<if test="query.posName != null and query.posName != ''">
AND pos_name LIKE CONCAT('%', #{query.posName}, '%')
AND r.pos_name LIKE CONCAT('%', #{query.posName}, '%')
</if>
<if test="query.candName != null and query.candName != ''">
AND cand_name LIKE CONCAT('%', #{query.candName}, '%')
AND r.cand_name LIKE CONCAT('%', #{query.candName}, '%')
</if>
<if test="query.recruitType != null and query.recruitType != ''">
AND r.recruit_type = #{query.recruitType}
</if>
<if test="query.candId != null and query.candId != ''">
AND cand_id = #{query.candId}
AND r.cand_id = #{query.candId}
</if>
<if test="query.admitStatus != null and query.admitStatus != ''">
AND admit_status = #{query.admitStatus}
AND r.admit_status = #{query.admitStatus}
</if>
<if test="query.interviewerName != null and query.interviewerName != ''">
AND (interviewer_name1 LIKE CONCAT('%', #{query.interviewerName}, '%')
OR interviewer_name2 LIKE CONCAT('%', #{query.interviewerName}, '%'))
AND (r.interviewer_name1 LIKE CONCAT('%', #{query.interviewerName}, '%')
OR r.interviewer_name2 LIKE CONCAT('%', #{query.interviewerName}, '%'))
</if>
<if test="query.interviewerId != null and query.interviewerId != ''">
AND (interviewer_id1 = #{query.interviewerId}
OR interviewer_id2 = #{query.interviewerId})
AND (r.interviewer_id1 = #{query.interviewerId}
OR r.interviewer_id2 = #{query.interviewerId})
</if>
</where>
ORDER BY create_time DESC
ORDER BY r.create_time DESC
</select>
<!-- 查询招聘信息详情 -->
<select id="selectRecruitmentById" resultMap="CcdiStaffRecruitmentVOResult">
SELECT
recruit_id, recruit_name, pos_name, pos_category, pos_desc,
cand_name, 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,
created_by, create_time, updated_by, update_time
FROM ccdi_staff_recruitment
@@ -79,13 +90,13 @@
<insert id="insertBatch">
INSERT INTO ccdi_staff_recruitment
(recruit_id, recruit_name, pos_name, pos_category, pos_desc,
cand_name, 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,
created_by, create_time, updated_by, update_time)
VALUES
<foreach collection="list" item="item" separator=",">
(#{item.recruitId}, #{item.recruitName}, #{item.posName}, #{item.posCategory}, #{item.posDesc},
#{item.candName}, #{item.candEdu}, #{item.candId}, #{item.candSchool}, #{item.candMajor}, #{item.candGrad},
#{item.candName}, #{item.recruitType}, #{item.candEdu}, #{item.candId}, #{item.candSchool}, #{item.candMajor}, #{item.candGrad},
#{item.admitStatus}, #{item.interviewerName1}, #{item.interviewerId1}, #{item.interviewerName2}, #{item.interviewerId2},
#{item.createdBy}, NOW(), #{item.updatedBy}, NOW())
</foreach>
@@ -100,6 +111,7 @@
pos_category = #{item.posCategory},
pos_desc = #{item.posDesc},
cand_name = #{item.candName},
recruit_type = #{item.recruitType},
cand_edu = #{item.candEdu},
cand_id = #{item.candId},
cand_school = #{item.candSchool},

View File

@@ -0,0 +1,172 @@
package com.ruoyi.info.collection.controller;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel;
import com.ruoyi.info.collection.domain.vo.ImportResultVO;
import com.ruoyi.info.collection.domain.vo.ImportStatusVO;
import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO;
import com.ruoyi.info.collection.domain.vo.IntermediaryPersonImportFailureVO;
import com.ruoyi.info.collection.service.ICcdiIntermediaryEnterpriseRelationImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryPersonImportService;
import com.ruoyi.info.collection.service.ICcdiIntermediaryService;
import com.ruoyi.info.collection.utils.EasyExcelUtil;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.mock.web.MockMultipartFile;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mockStatic;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiIntermediaryControllerTest {
@InjectMocks
private CcdiIntermediaryController controller;
@Mock
private ICcdiIntermediaryService intermediaryService;
@Mock
private ICcdiIntermediaryPersonImportService personImportService;
@Mock
private ICcdiIntermediaryEnterpriseRelationImportService enterpriseRelationImportService;
@Test
void importPersonData_shouldReturnWarnWhenExcelHasNoRows() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"intermediary-empty.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"empty".getBytes(StandardCharsets.UTF_8)
);
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiIntermediaryPersonExcel.class)))
.thenReturn(List.of());
AjaxResult result = controller.importPersonData(file);
assertEquals(HttpStatus.ERROR, result.get(AjaxResult.CODE_TAG));
assertEquals("至少需要一条数据", result.get(AjaxResult.MSG_TAG));
}
}
@Test
void importPersonData_shouldReturnSuccessWhenTaskCreated() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"intermediary-person.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"person".getBytes(StandardCharsets.UTF_8)
);
CcdiIntermediaryPersonExcel excel = new CcdiIntermediaryPersonExcel();
excel.setPersonId("320101199001010011");
when(intermediaryService.importIntermediaryPerson(List.of(excel))).thenReturn("task-person");
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiIntermediaryPersonExcel.class)))
.thenReturn(List.of(excel));
AjaxResult result = controller.importPersonData(file);
assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
ImportResultVO data = (ImportResultVO) result.get(AjaxResult.DATA_TAG);
assertEquals("task-person", data.getTaskId());
}
}
@Test
void importEnterpriseRelationData_shouldReturnSuccessWhenTaskCreated() throws Exception {
MockMultipartFile file = new MockMultipartFile(
"file",
"intermediary-relation.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"relation".getBytes(StandardCharsets.UTF_8)
);
CcdiIntermediaryEnterpriseRelationExcel excel = new CcdiIntermediaryEnterpriseRelationExcel();
excel.setOwnerPersonId("320101199001010011");
excel.setSocialCreditCode("91330100MA27X12345");
when(intermediaryService.importIntermediaryEnterpriseRelation(List.of(excel))).thenReturn("task-relation");
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
mocked.when(() -> EasyExcelUtil.importExcel(any(InputStream.class), eq(CcdiIntermediaryEnterpriseRelationExcel.class)))
.thenReturn(List.of(excel));
AjaxResult result = controller.importEnterpriseRelationData(file);
assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
ImportResultVO data = (ImportResultVO) result.get(AjaxResult.DATA_TAG);
assertEquals("task-relation", data.getTaskId());
}
}
@Test
void getEnterpriseRelationImportStatus_shouldDelegateToRelationImportService() {
ImportStatusVO statusVO = new ImportStatusVO();
statusVO.setTaskId("task-status");
when(enterpriseRelationImportService.getImportStatus("task-status")).thenReturn(statusVO);
AjaxResult result = controller.getEnterpriseRelationImportStatus("task-status");
assertEquals(HttpStatus.SUCCESS, result.get(AjaxResult.CODE_TAG));
assertEquals(statusVO, result.get(AjaxResult.DATA_TAG));
}
@Test
void getEnterpriseRelationImportFailures_shouldReturnPagedRows() {
IntermediaryEnterpriseRelationImportFailureVO failure1 = new IntermediaryEnterpriseRelationImportFailureVO();
failure1.setOwnerPersonId("A1");
IntermediaryEnterpriseRelationImportFailureVO failure2 = new IntermediaryEnterpriseRelationImportFailureVO();
failure2.setOwnerPersonId("A2");
when(enterpriseRelationImportService.getImportFailures("task-failures")).thenReturn(List.of(failure1, failure2));
TableDataInfo result = controller.getEnterpriseRelationImportFailures("task-failures", 2, 1);
assertEquals(2, result.getTotal());
assertEquals(1, result.getRows().size());
assertEquals("A2", ((IntermediaryEnterpriseRelationImportFailureVO) result.getRows().get(0)).getOwnerPersonId());
}
@Test
void getPersonImportFailures_shouldReturnPagedRows() {
IntermediaryPersonImportFailureVO failure1 = new IntermediaryPersonImportFailureVO();
failure1.setPersonId("A1");
IntermediaryPersonImportFailureVO failure2 = new IntermediaryPersonImportFailureVO();
failure2.setPersonId("A2");
when(personImportService.getImportFailures("task-person-failures")).thenReturn(List.of(failure1, failure2));
TableDataInfo result = controller.getPersonImportFailures("task-person-failures", 2, 1);
assertEquals(2, result.getTotal());
assertEquals(1, result.getRows().size());
assertEquals("A2", ((IntermediaryPersonImportFailureVO) result.getRows().get(0)).getPersonId());
}
@Test
void importEnterpriseRelationTemplate_shouldUseRelationTemplateName() {
try (MockedStatic<EasyExcelUtil> mocked = mockStatic(EasyExcelUtil.class)) {
controller.importEnterpriseRelationTemplate(null);
mocked.verify(() -> EasyExcelUtil.importTemplateWithDictDropdown(
null,
CcdiIntermediaryEnterpriseRelationExcel.class,
"中介实体关联关系信息"
));
}
}
}

View File

@@ -0,0 +1,172 @@
package com.ruoyi.info.collection.service;
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.excel.CcdiIntermediaryEnterpriseRelationExcel;
import com.ruoyi.info.collection.domain.vo.IntermediaryEnterpriseRelationImportFailureVO;
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.service.impl.CcdiIntermediaryEnterpriseRelationImportServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiIntermediaryEnterpriseRelationImportServiceImplTest {
@InjectMocks
private CcdiIntermediaryEnterpriseRelationImportServiceImpl service;
@Mock
private CcdiIntermediaryEnterpriseRelationMapper relationMapper;
@Mock
private CcdiBizIntermediaryMapper intermediaryMapper;
@Mock
private CcdiEnterpriseBaseInfoMapper enterpriseBaseInfoMapper;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private HashOperations<String, Object, Object> hashOperations;
@Mock
private ValueOperations<String, Object> valueOperations;
@Test
void importEnterpriseRelationAsync_shouldFailWhenOwnerPersonIdDoesNotExist() {
CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345");
prepareFailureRedisMocks();
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");
verify(relationMapper, never()).insertBatch(any());
IntermediaryEnterpriseRelationImportFailureVO failure =
firstFailure("import:intermediary-enterprise-relation:task-owner-miss:failures");
assertEquals("320101199001010014", failure.getOwnerPersonId());
assertTrue(failure.getErrorMessage().contains("中介本人不存在"));
}
@Test
void importEnterpriseRelationAsync_shouldFailWhenEnterpriseDoesNotExist() {
CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345");
prepareFailureRedisMocks();
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());
service.importAsync(List.of(excel), "task-ent-miss", "tester");
verify(relationMapper, never()).insertBatch(any());
IntermediaryEnterpriseRelationImportFailureVO failure =
firstFailure("import:intermediary-enterprise-relation:task-ent-miss:failures");
assertEquals("91330100MA27X12345", failure.getSocialCreditCode());
assertTrue(failure.getErrorMessage().contains("机构表"));
}
@Test
void importEnterpriseRelationAsync_shouldRejectFileDuplicateAndDbDuplicate() {
CcdiIntermediaryEnterpriseRelationExcel duplicateInDb = buildExcel("320101199001010014", "91330100MA27X12345");
CcdiIntermediaryEnterpriseRelationExcel duplicateInFile1 = buildExcel("320101199003030035", "91330100MA27X12346");
CcdiIntermediaryEnterpriseRelationExcel duplicateInFile2 = buildExcel("320101199003030035", "91330100MA27X12346");
prepareFailureRedisMocks();
when(intermediaryMapper.selectList(any())).thenReturn(List.of(
owner("owner-biz-1", "320101199001010014"),
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"));
service.importAsync(List.of(duplicateInDb, duplicateInFile1, duplicateInFile2), "task-duplicate", "tester");
ArgumentCaptor<List<CcdiIntermediaryEnterpriseRelation>> captor = ArgumentCaptor.forClass(List.class);
verify(relationMapper).insertBatch(captor.capture());
assertEquals(1, captor.getValue().size());
assertEquals("owner-biz-2", captor.getValue().get(0).getIntermediaryBizId());
IntermediaryEnterpriseRelationImportFailureVO failure =
firstFailure("import:intermediary-enterprise-relation:task-duplicate:failures");
assertTrue(failure.getErrorMessage().contains("重复") || failure.getErrorMessage().contains("已存在"));
}
@Test
void importEnterpriseRelationAsync_shouldImportSuccessWhenOwnerAndEnterpriseExist() {
CcdiIntermediaryEnterpriseRelationExcel excel = buildExcel("320101199001010014", "91330100MA27X12345");
prepareStatusRedisMock();
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());
service.importAsync(List.of(excel), "task-success", "tester");
ArgumentCaptor<List<CcdiIntermediaryEnterpriseRelation>> captor = ArgumentCaptor.forClass(List.class);
verify(relationMapper).insertBatch(captor.capture());
assertEquals(1, captor.getValue().size());
assertEquals("owner-biz", captor.getValue().get(0).getIntermediaryBizId());
verify(valueOperations, never()).set(any(), any(), any(Long.class), any(TimeUnit.class));
}
private void prepareStatusRedisMock() {
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
}
private void prepareFailureRedisMocks() {
prepareStatusRedisMock();
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
}
private IntermediaryEnterpriseRelationImportFailureVO firstFailure(String key) {
ArgumentCaptor<Object> failureCaptor = ArgumentCaptor.forClass(Object.class);
verify(valueOperations).set(org.mockito.ArgumentMatchers.eq(key), failureCaptor.capture(),
org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(TimeUnit.DAYS));
return (IntermediaryEnterpriseRelationImportFailureVO) ((List<?>) failureCaptor.getValue()).get(0);
}
private CcdiIntermediaryEnterpriseRelationExcel buildExcel(String ownerPersonId, String socialCreditCode) {
CcdiIntermediaryEnterpriseRelationExcel excel = new CcdiIntermediaryEnterpriseRelationExcel();
excel.setOwnerPersonId(ownerPersonId);
excel.setSocialCreditCode(socialCreditCode);
excel.setRelationPersonPost("董事");
excel.setRemark("备注");
return excel;
}
private CcdiBizIntermediary owner(String bizId, String personId) {
CcdiBizIntermediary owner = new CcdiBizIntermediary();
owner.setBizId(bizId);
owner.setPersonId(personId);
owner.setPersonSubType("本人");
return owner;
}
private CcdiEnterpriseBaseInfo enterprise(String socialCreditCode) {
CcdiEnterpriseBaseInfo enterprise = new CcdiEnterpriseBaseInfo();
enterprise.setSocialCreditCode(socialCreditCode);
enterprise.setEnterpriseName("机构" + socialCreditCode.substring(socialCreditCode.length() - 2));
return enterprise;
}
}

View File

@@ -0,0 +1,176 @@
package com.ruoyi.info.collection.service;
import com.ruoyi.common.annotation.DictDropdown;
import com.ruoyi.info.collection.domain.CcdiBizIntermediary;
import com.ruoyi.info.collection.domain.excel.CcdiIntermediaryPersonExcel;
import com.ruoyi.info.collection.domain.vo.IntermediaryPersonImportFailureVO;
import com.ruoyi.info.collection.mapper.CcdiBizIntermediaryMapper;
import com.ruoyi.info.collection.service.impl.CcdiIntermediaryPersonImportServiceImpl;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import java.lang.reflect.Field;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CcdiIntermediaryPersonImportServiceImplTest {
@InjectMocks
private CcdiIntermediaryPersonImportServiceImpl service;
@Mock
private CcdiBizIntermediaryMapper intermediaryMapper;
@Mock
private RedisTemplate<String, Object> redisTemplate;
@Mock
private HashOperations<String, Object, Object> hashOperations;
@Mock
private ValueOperations<String, Object> valueOperations;
@Test
void intermediaryPersonExcel_shouldUsePersonSubTypeDropdownAndDropRelationType() throws Exception {
Field personSubTypeField = CcdiIntermediaryPersonExcel.class.getDeclaredField("personSubType");
DictDropdown dictDropdown = personSubTypeField.getAnnotation(DictDropdown.class);
assertNotNull(dictDropdown);
assertEquals("ccdi_person_sub_type", dictDropdown.dictType());
assertThrows(NoSuchFieldException.class, () -> CcdiIntermediaryPersonExcel.class.getDeclaredField("relationType"));
assertThrows(NoSuchFieldException.class, () -> IntermediaryPersonImportFailureVO.class.getDeclaredField("relationType"));
}
@Test
void importPersonAsync_shouldFailWhenOwnerRowContainsOwnerPersonIdReference() {
CcdiIntermediaryPersonExcel owner = buildOwnerExcel("320101199001010014");
owner.setRelatedNumId("320101199105050053");
prepareFailureRedisMocks();
service.importPersonAsync(List.of(owner), "task-owner", "tester");
verify(intermediaryMapper, never()).insertBatch(any());
IntermediaryPersonImportFailureVO failure = firstFailure("import:intermediary:task-owner:failures");
assertEquals("320101199105050053", failure.getRelatedNumId());
assertTrue(failure.getErrorMessage().contains("本人"));
}
@Test
void importPersonAsync_shouldFailWhenRelativeRowMissesOwnerPersonId() {
CcdiIntermediaryPersonExcel relative = buildRelativeExcel("320101199201010027", "配偶", null);
prepareFailureRedisMocks();
service.importPersonAsync(List.of(relative), "task-relative", "tester");
verify(intermediaryMapper, never()).insertBatch(any());
IntermediaryPersonImportFailureVO failure = firstFailure("import:intermediary:task-relative:failures");
assertEquals("320101199201010027", failure.getPersonId());
assertTrue(failure.getErrorMessage().contains("关联中介本人证件号码"));
}
@Test
void importPersonAsync_shouldAllowRelativeReferencingSuccessfulOwnerInSameFile() {
CcdiIntermediaryPersonExcel owner = buildOwnerExcel("320101199001010014");
CcdiIntermediaryPersonExcel relative = buildRelativeExcel("320101199201010027", "配偶", "320101199001010014");
prepareStatusRedisMock();
when(intermediaryMapper.selectList(any())).thenReturn(List.of());
service.importPersonAsync(List.of(owner, relative), "task-mixed", "tester");
ArgumentCaptor<List<CcdiBizIntermediary>> captor = ArgumentCaptor.forClass(List.class);
verify(intermediaryMapper).insertBatch(captor.capture());
assertEquals(2, captor.getValue().size());
assertEquals("本人", captor.getValue().get(0).getPersonSubType());
assertEquals("320101199001010014", captor.getValue().get(1).getRelatedNumId());
verify(valueOperations, never()).set(any(), any(), any(Long.class), any(TimeUnit.class));
}
@Test
void importPersonAsync_shouldAllowSameRelativePersonIdUnderDifferentOwners() {
CcdiIntermediaryPersonExcel owner1 = buildOwnerExcel("320101199001010014");
CcdiIntermediaryPersonExcel owner2 = buildOwnerExcel("320101199003030035");
CcdiIntermediaryPersonExcel relative1 = buildRelativeExcel("320101199201010027", "配偶", "320101199001010014");
CcdiIntermediaryPersonExcel relative2 = buildRelativeExcel("320101199201010027", "配偶", "320101199003030035");
prepareStatusRedisMock();
when(intermediaryMapper.selectList(any())).thenReturn(List.of());
service.importPersonAsync(List.of(owner1, owner2, relative1, relative2), "task-owner-scope", "tester");
ArgumentCaptor<List<CcdiBizIntermediary>> captor = ArgumentCaptor.forClass(List.class);
verify(intermediaryMapper).insertBatch(captor.capture());
assertEquals(4, captor.getValue().size());
verify(valueOperations, never()).set(any(), any(), any(Long.class), any(TimeUnit.class));
}
@Test
void importPersonAsync_shouldRejectDuplicateRelativeUnderSameOwner() {
CcdiIntermediaryPersonExcel owner = buildOwnerExcel("320101199001010014");
CcdiIntermediaryPersonExcel relative1 = buildRelativeExcel("320101199201010027", "配偶", "320101199001010014");
CcdiIntermediaryPersonExcel relative2 = buildRelativeExcel("320101199201010027", "配偶", "320101199001010014");
prepareFailureRedisMocks();
when(intermediaryMapper.selectList(any())).thenReturn(List.of());
service.importPersonAsync(List.of(owner, relative1, relative2), "task-duplicate", "tester");
ArgumentCaptor<List<CcdiBizIntermediary>> captor = ArgumentCaptor.forClass(List.class);
verify(intermediaryMapper).insertBatch(captor.capture());
assertEquals(2, captor.getValue().size());
IntermediaryPersonImportFailureVO failure = firstFailure("import:intermediary:task-duplicate:failures");
assertEquals("320101199201010027", failure.getPersonId());
assertTrue(failure.getErrorMessage().contains("重复"));
}
private void prepareStatusRedisMock() {
when(redisTemplate.opsForHash()).thenReturn(hashOperations);
}
private void prepareFailureRedisMocks() {
prepareStatusRedisMock();
when(redisTemplate.opsForValue()).thenReturn(valueOperations);
}
private IntermediaryPersonImportFailureVO firstFailure(String key) {
ArgumentCaptor<Object> failureCaptor = ArgumentCaptor.forClass(Object.class);
verify(valueOperations).set(org.mockito.ArgumentMatchers.eq(key), failureCaptor.capture(),
org.mockito.ArgumentMatchers.eq(7L), org.mockito.ArgumentMatchers.eq(TimeUnit.DAYS));
return (IntermediaryPersonImportFailureVO) ((List<?>) failureCaptor.getValue()).get(0);
}
private CcdiIntermediaryPersonExcel buildOwnerExcel(String personId) {
CcdiIntermediaryPersonExcel excel = new CcdiIntermediaryPersonExcel();
excel.setName("中介本人" + personId.substring(personId.length() - 2));
excel.setPersonType("中介");
excel.setPersonSubType("本人");
excel.setIdType("身份证");
excel.setPersonId(personId);
return excel;
}
private CcdiIntermediaryPersonExcel buildRelativeExcel(String personId, String personSubType, String ownerPersonId) {
CcdiIntermediaryPersonExcel excel = new CcdiIntermediaryPersonExcel();
excel.setName("中介亲属" + personId.substring(personId.length() - 2));
excel.setPersonType("中介");
excel.setPersonSubType(personSubType);
excel.setIdType("身份证");
excel.setPersonId(personId);
excel.setRelatedNumId(ownerPersonId);
return excel;
}
}

View File

@@ -0,0 +1,69 @@
package com.ruoyi.ccdi.project.controller;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceSaveDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiEvidenceVO;
import com.ruoyi.ccdi.project.service.ICcdiEvidenceService;
import com.ruoyi.common.core.controller.BaseController;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.SecurityUtils;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 项目证据Controller
*
* @author ruoyi
*/
@RestController
@RequestMapping("/ccdi/evidence")
@Tag(name = "项目证据")
public class CcdiEvidenceController extends BaseController {
@Resource
private ICcdiEvidenceService evidenceService;
/**
* 保存证据
*/
@PostMapping
@Operation(summary = "保存证据")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult saveEvidence(@Validated @RequestBody CcdiEvidenceSaveDTO dto) {
CcdiEvidenceVO vo = evidenceService.saveEvidence(dto, SecurityUtils.getUsername());
return AjaxResult.success("证据入库成功", vo);
}
/**
* 查询项目证据列表
*/
@GetMapping("/list")
@Operation(summary = "查询项目证据列表")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult listEvidence(CcdiEvidenceQueryDTO queryDTO) {
List<CcdiEvidenceVO> list = evidenceService.listEvidence(queryDTO);
return AjaxResult.success(list);
}
/**
* 查询证据详情
*/
@GetMapping("/{evidenceId}")
@Operation(summary = "查询证据详情")
@PreAuthorize("@ss.hasPermi('ccdi:project:query')")
public AjaxResult getEvidence(@PathVariable Long evidenceId) {
CcdiEvidenceVO vo = evidenceService.getEvidence(evidenceId);
return AjaxResult.success(vo);
}
}

View File

@@ -0,0 +1,21 @@
package com.ruoyi.ccdi.project.domain.dto;
import lombok.Data;
/**
* 项目证据查询入参
*
* @author ruoyi
*/
@Data
public class CcdiEvidenceQueryDTO {
/** 项目ID */
private Long projectId;
/** 证据类型FLOW/MODEL/ASSET */
private String evidenceType;
/** 关键词:姓名、标题、摘要、证据编号 */
private String keyword;
}

View File

@@ -0,0 +1,54 @@
package com.ruoyi.ccdi.project.domain.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 保存项目证据入参
*
* @author ruoyi
*/
@Data
public class CcdiEvidenceSaveDTO {
/** 项目ID */
@NotNull(message = "项目ID不能为空")
private Long projectId;
/** 证据类型FLOW/MODEL/ASSET */
@NotBlank(message = "证据类型不能为空")
private String evidenceType;
/** 关联人员姓名 */
@NotBlank(message = "关联人员不能为空")
private String relatedPersonName;
/** 关联人员标识 */
private String relatedPersonId;
/** 证据标题 */
@NotBlank(message = "证据标题不能为空")
private String evidenceTitle;
/** 证据摘要 */
@NotBlank(message = "证据摘要不能为空")
private String evidenceSummary;
/** 来源类型 */
@NotBlank(message = "来源类型不能为空")
private String sourceType;
/** 来源记录ID */
private String sourceRecordId;
/** 来源页面名称 */
private String sourcePage;
/** 证据快照JSON */
private String snapshotJson;
/** 确认理由/备注 */
@NotBlank(message = "确认理由/备注不能为空")
private String confirmReason;
}

View File

@@ -0,0 +1,84 @@
package com.ruoyi.ccdi.project.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
import java.util.Date;
/**
* 项目证据对象 ccdi_evidence
*
* @author ruoyi
*/
@Data
@TableName("ccdi_evidence")
public class CcdiEvidence implements Serializable {
@Serial
private static final long serialVersionUID = 1L;
/** 证据ID */
@TableId(type = IdType.AUTO)
private Long evidenceId;
/** 项目ID */
private Long projectId;
/** 证据类型FLOW/MODEL/ASSET */
private String evidenceType;
/** 关联人员姓名 */
private String relatedPersonName;
/** 关联人员标识,优先存身份证号或员工号 */
private String relatedPersonId;
/** 证据标题 */
private String evidenceTitle;
/** 证据摘要 */
private String evidenceSummary;
/** 来源类型BANK_STATEMENT/MODEL_DETAIL/ASSET_DETAIL */
private String sourceType;
/** 来源记录ID */
private String sourceRecordId;
/** 来源页面名称 */
private String sourcePage;
/** 证据快照JSON */
private String snapshotJson;
/** 确认理由/备注 */
private String confirmReason;
/** 确认人 */
private String confirmBy;
/** 确认时间 */
private Date confirmTime;
/** 创建者 */
@TableField(fill = FieldFill.INSERT)
private String createBy;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private Date createTime;
/** 更新者 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private String updateBy;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private Date updateTime;
}

View File

@@ -0,0 +1,56 @@
package com.ruoyi.ccdi.project.domain.vo;
import lombok.Data;
import java.util.Date;
/**
* 项目证据返回对象
*
* @author ruoyi
*/
@Data
public class CcdiEvidenceVO {
/** 证据ID */
private Long evidenceId;
/** 项目ID */
private Long projectId;
/** 证据类型FLOW/MODEL/ASSET */
private String evidenceType;
/** 关联人员姓名 */
private String relatedPersonName;
/** 关联人员标识 */
private String relatedPersonId;
/** 证据标题 */
private String evidenceTitle;
/** 证据摘要 */
private String evidenceSummary;
/** 来源类型 */
private String sourceType;
/** 来源记录ID */
private String sourceRecordId;
/** 来源页面名称 */
private String sourcePage;
/** 证据快照JSON */
private String snapshotJson;
/** 确认理由/备注 */
private String confirmReason;
/** 确认人 */
private String confirmBy;
/** 确认时间 */
private Date confirmTime;
}

View File

@@ -10,6 +10,8 @@ import lombok.Data;
@Data
public class CcdiProjectPersonAnalysisObjectRecordVO {
private String modelCode;
private String title;
private String subtitle;

View File

@@ -0,0 +1,12 @@
package com.ruoyi.ccdi.project.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.ruoyi.ccdi.project.domain.entity.CcdiEvidence;
/**
* 项目证据Mapper接口
*
* @author ruoyi
*/
public interface CcdiEvidenceMapper extends BaseMapper<CcdiEvidence> {
}

View File

@@ -0,0 +1,40 @@
package com.ruoyi.ccdi.project.service;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceSaveDTO;
import com.ruoyi.ccdi.project.domain.vo.CcdiEvidenceVO;
import java.util.List;
/**
* 项目证据Service接口
*
* @author ruoyi
*/
public interface ICcdiEvidenceService {
/**
* 保存证据
*
* @param dto 保存入参
* @param operator 操作人
* @return 证据
*/
CcdiEvidenceVO saveEvidence(CcdiEvidenceSaveDTO dto, String operator);
/**
* 查询项目证据列表
*
* @param queryDTO 查询入参
* @return 证据列表
*/
List<CcdiEvidenceVO> listEvidence(CcdiEvidenceQueryDTO queryDTO);
/**
* 查询证据详情
*
* @param evidenceId 证据ID
* @return 证据
*/
CcdiEvidenceVO getEvidence(Long evidenceId);
}

View File

@@ -0,0 +1,84 @@
package com.ruoyi.ccdi.project.service.impl;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceQueryDTO;
import com.ruoyi.ccdi.project.domain.dto.CcdiEvidenceSaveDTO;
import com.ruoyi.ccdi.project.domain.entity.CcdiEvidence;
import com.ruoyi.ccdi.project.domain.vo.CcdiEvidenceVO;
import com.ruoyi.ccdi.project.mapper.CcdiEvidenceMapper;
import com.ruoyi.ccdi.project.service.ICcdiEvidenceService;
import com.ruoyi.common.exception.ServiceException;
import jakarta.annotation.Resource;
import org.springframework.beans.BeanUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.List;
/**
* 项目证据Service实现类
*
* @author ruoyi
*/
@Service
public class CcdiEvidenceServiceImpl implements ICcdiEvidenceService {
@Resource
private CcdiEvidenceMapper evidenceMapper;
@Override
@Transactional(rollbackFor = Exception.class)
public CcdiEvidenceVO saveEvidence(CcdiEvidenceSaveDTO dto, String operator) {
CcdiEvidence evidence = new CcdiEvidence();
BeanUtils.copyProperties(dto, evidence);
evidence.setConfirmBy(operator);
evidence.setConfirmTime(new Date());
evidenceMapper.insert(evidence);
return toVO(evidence);
}
@Override
public List<CcdiEvidenceVO> listEvidence(CcdiEvidenceQueryDTO queryDTO) {
if (queryDTO.getProjectId() == null) {
throw new ServiceException("项目ID不能为空");
}
LambdaQueryWrapper<CcdiEvidence> wrapper = new LambdaQueryWrapper<CcdiEvidence>()
.eq(CcdiEvidence::getProjectId, queryDTO.getProjectId())
.orderByDesc(CcdiEvidence::getConfirmTime)
.orderByDesc(CcdiEvidence::getEvidenceId);
if (StringUtils.hasText(queryDTO.getEvidenceType())) {
wrapper.eq(CcdiEvidence::getEvidenceType, queryDTO.getEvidenceType());
}
if (StringUtils.hasText(queryDTO.getKeyword())) {
String keyword = queryDTO.getKeyword().trim();
wrapper.and(item -> item
.like(CcdiEvidence::getRelatedPersonName, keyword)
.or()
.like(CcdiEvidence::getRelatedPersonId, keyword)
.or()
.like(CcdiEvidence::getEvidenceTitle, keyword)
.or()
.like(CcdiEvidence::getEvidenceSummary, keyword)
);
}
return evidenceMapper.selectList(wrapper).stream().map(this::toVO).toList();
}
@Override
public CcdiEvidenceVO getEvidence(Long evidenceId) {
CcdiEvidence evidence = evidenceMapper.selectById(evidenceId);
if (evidence == null) {
throw new ServiceException("证据不存在");
}
return toVO(evidence);
}
private CcdiEvidenceVO toVO(CcdiEvidence evidence) {
CcdiEvidenceVO vo = new CcdiEvidenceVO();
BeanUtils.copyProperties(evidence, vo);
return vo;
}
}

View File

@@ -836,6 +836,7 @@
<select id="selectPersonAnalysisObjectRows" resultType="com.ruoyi.ccdi.project.domain.vo.CcdiProjectPersonAnalysisObjectRecordVO">
select
max(tr.model_code) as modelCode,
coalesce(max(staff.name), max(relation.relation_name), max(tr.object_key), max(tr.object_type)) as title,
max(case
when tr.object_type = 'STAFF_ID_CARD' then '员工对象'

View File

@@ -0,0 +1,573 @@
# 中介库导入改造设计文档
**模块**: 中介库管理
**日期**: 2026-04-20
**作者**: Codex
**状态**: 已实现SQL 已执行,历史脏数据待清洗)
## 一、背景
当前中介库模块已经完成“中介本人 / 中介亲属 / 中介关联机构”三类记录的统一展示与维护,但导入能力仍停留在旧语义:
1. 页面导入入口与当前主从维护模式不一致
2. `related_num_id` 仍按“关联中介本人 `biz_id`”理解
3. 中介人员导入与中介实体关系导入边界不清晰
4. 导入交互未完全对齐系统内“员工数据导入”的标准模式
本次需求要求在中介库管理中补齐导入能力,并统一中介模块内 `relatedNumId` 的业务语义。
## 二、目标
本次设计目标如下:
1. 中介库顶部保留 2 个导入按钮:
- 导入中介信息
- 导入中介实体关联关系
2. “导入中介信息”统一处理中介本人和中介亲属,依据 `personSubType` 判断身份
3. “导入中介实体关联关系”单独写入 `ccdi_intermediary_enterprise_relation`
4. `ccdi_biz_intermediary.related_num_id` 在整个中介模块内统一改为保存“关联中介本人证件号码”
5. 全部导入采用纯新增模式,不支持覆盖更新
6. 导入交互、异步任务状态、失败记录展示与“员工数据导入”保持一致
7. 对历史数据执行一次性迁移,不保留双语义兼容逻辑
8. 中介模块现有外部接口继续保持 `bizId` 作为主资源标识,不改成证件号码路由
9. `relationType` 在中介模块中废弃,不再参与导入模板、校验与维护流程
## 三、范围
### 3.1 本次范围
- 中介库导入入口改造
- 中介信息导入与中介实体关联关系导入设计
- `related_num_id` 语义切换
- 中介模块内受影响代码调整
- 历史数据迁移设计
- 失败记录、去重、异步任务状态设计
- 设计文档、后端实施计划、前端实施计划沉淀
### 3.2 不在本次范围
- 不新增独立的“中介亲属关系导入”按钮
- 不新增机构主档导入能力
- 不支持导入覆盖更新
- 不做“同时兼容旧 `biz_id` 与新证件号码语义”的补丁方案
- 不抽象通用关系平台
## 四、现状分析
### 4.1 中介模块现状
当前中介库页面位于:
- `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
后端核心位于:
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java`
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java`
现有中介模块特点:
1. `ccdi_biz_intermediary` 同时承载中介本人和中介亲属
2. `ccdi_intermediary_enterprise_relation` 维护中介与机构关系
3. 中介统一列表通过联合查询展示本人、亲属、机构关系三类记录
4. 中介亲属当前通过 `related_num_id = 本人 biz_id` 建立归属关系
### 4.2 导入能力现状
当前仓库内已存在:
1. 个人中介导入异步链路
2. 实体中介导入异步链路
3. 员工数据导入标准交互
其中存在的偏差:
1. 现有中介导入仍沿用“个人 / 机构”旧语义
2. 顶部入口与当前业务结构不一致
3. 个人导入没有支持“本人 + 亲属”混合导入
4. 中介实体关联关系没有对应独立导入能力
5. `related_num_id` 新旧语义未统一
### 4.3 参考实现
本次导入交互和异步链路主要参考:
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiBaseStaffController.java`
本次关系导入与失败记录模式主要参考:
- 员工实体关系导入
- 员工亲属关系导入
## 五、方案对比
### 5.1 方案 A两个导入按钮人员与机构关系分开导入
做法:
1. 顶部仅保留“导入中介信息”和“导入中介实体关联关系”
2. 中介本人和中介亲属共用一个人员导入模板
3. 中介实体关联关系单独导入关系表
4. `related_num_id` 整模块统一改为“关联中介本人证件号码”
5. 执行一次性历史数据迁移
优点:
1. 与当前业务边界一致
2. 符合最短路径原则
3. 导入入口数量最少,业务理解成本低
4. 不引入补丁兼容逻辑
缺点:
1. 需要同步改造中介模块内既有主从关系代码
2. 需要补历史数据迁移脚本
### 5.2 方案 B三个导入按钮亲属关系独立导入
问题:
1. 与用户最终确认的交互不一致
2. 与“本人 / 亲属字段完全一致”的业务口径不一致
3. 增加额外入口和模板维护成本
### 5.3 方案 C保留 `related_num_id` 旧语义,仅导入层转换
问题:
1. 形成代码与导入口径分裂
2. 后续维护成本高
3. 属于兼容型补丁方案,不符合本次约束
### 5.4 结论
采用 **方案 A两个导入按钮人员与机构关系分开导入并整体切换 `related_num_id` 语义**
## 六、总体设计
### 6.1 设计原则
1. 保持中介模块现有主从结构,不新增平行模块
2. 所有“亲属归属中介本人”的关系统一按证件号码表达
3. 导入全部为纯新增,失败逐条返回
4. 文件内去重与库内去重同时存在
5. 不引入补丁兼容逻辑
6. 现有外部接口对前端继续保持 `bizId` 契约,内部再转换为本人证件号码处理
### 6.2 顶部入口设计
中介库列表页顶部工具栏最终包含:
1. 新增
2. 导入中介信息
3. 导入中介实体关联关系
4. 查看中介信息导入失败记录
5. 查看中介实体关联关系导入失败记录
说明:
1. 不再保留独立“中介亲属关系导入”按钮
2. 两类导入任务各自维护状态和失败记录
## 七、数据模型与语义调整
### 7.1 `ccdi_biz_intermediary` 使用方式
`ccdi_biz_intermediary` 继续承载中介本人与中介亲属:
1. 中介本人
- `person_sub_type = 本人`
- `related_num_id = null`
2. 中介亲属
- `person_sub_type != 本人`
- `related_num_id = 关联中介本人证件号码`
唯一性口径调整为:
1. 中介本人 `person_id` 全表唯一
2. 中介亲属允许相同 `person_id` 关联多个不同中介本人
3. 同一中介本人名下的亲属以 `related_num_id + person_id` 唯一
### 7.2 `related_num_id` 语义切换
本次统一调整为:
- **旧语义**:关联中介本人 `biz_id`
- **新语义**:关联中介本人 `person_id`
影响原则:
1. 手工新增亲属时写入本人证件号码
2. 查询亲属列表时按本人证件号码关联
3. 首页联合查询亲属记录时按本人证件号码关联
4. 删除本人时按本人证件号码删除其亲属
5. 导入亲属时按本人证件号码回填 `related_num_id`
6. 外部接口继续传本人 `bizId`,服务层内部先查本人证件号码后再执行业务逻辑
### 7.3 外部接口契约
本次仅调整中介模块内部关联语义,不调整现有外部接口的路径参数契约:
1. 查询中介详情继续使用 `bizId`
2. 查询亲属列表继续使用 `bizId`
3. 新增中介亲属继续使用 `bizId`
4. 查询中介关联机构列表继续使用 `bizId`
服务层处理方式统一为:
1. 先根据外部传入的 `bizId` 查询中介本人
2. 再使用本人 `personId` 处理亲属链路
3. 机构关系链路继续使用本人 `bizId`
### 7.4 `ccdi_intermediary_enterprise_relation`
中介实体关联关系继续存于:
- `ccdi_intermediary_enterprise_relation`
该表语义保持不变:
1. `intermediary_biz_id` 仍保存中介本人 `biz_id`
2. 导入时先通过“中介本人证件号码”定位本人,再回填 `intermediary_biz_id`
3. 不在该链路中修改关系表结构
## 八、导入能力设计
### 8.1 导入中介信息
#### 8.1.1 目标
统一导入中介本人与中介亲属,落表:
- `ccdi_biz_intermediary`
#### 8.1.2 接口
建议保留或调整为:
1. `POST /ccdi/intermediary/importPersonTemplate`
2. `POST /ccdi/intermediary/importPersonData`
3. `GET /ccdi/intermediary/importPersonStatus/{taskId}`
4. `GET /ccdi/intermediary/importPersonFailures/{taskId}`
#### 8.1.3 模板字段
模板字段如下:
1. 姓名
2. 人员类型
3. 人员子类型
4. 性别
5. 证件类型
6. 证件号码
7. 手机号码
8. 微信号
9. 联系地址
10. 所在公司
11. 企业统一信用码
12. 职位
13. 关联中介本人证件号码
14. 备注
说明:
1. `personSubType` 在导入模板中必须使用下拉框,不允许自由文本输入
2. `personSubType` 下拉来源使用系统字典 `ccdi_person_sub_type`
3. 模板生成方式与系统现有 Excel 字典下拉保持一致
4. `personSubType = 本人` 时,`relatedNumId` 必须为空
5. `personSubType != 本人` 时,`relatedNumId` 必须填写“关联中介本人证件号码”
6. `relationType` 字段在本次导入中废弃,不出现在模板中,也不参与导入校验与落库
#### 8.1.4 导入处理规则
导入分两阶段处理:
第一阶段:处理中介本人
1. `personSubType = 本人` 时,`relatedNumId` 必须为空
2. 提取所有 `personSubType = 本人` 的行
3. 校验必填、证件号格式、文件内重复、库内重复
4. 导入成功后建立“本人证件号集合”
第二阶段:处理中介亲属
1. 提取所有 `personSubType != 本人` 的行
2. 校验 `relatedNumId` 必填
3. 校验 `relatedNumId` 对应的中介本人是否存在于:
- 数据库已存在记录
- 本次第一阶段已导入成功的本人记录
4. 允许相同亲属证件号码关联多个不同中介本人
5. 校验同一中介本人名下的亲属关系唯一性
6. 落库时 `related_num_id = 本人证件号码`
#### 8.1.5 去重逻辑
双层去重:
1. 对中介本人记录,文件内按 `personId` 去重
2. 对中介本人记录,数据库内按 `personId` 去重
3. 对中介亲属记录,文件内按 `relatedNumId + personId` 去重
4. 对中介亲属记录,数据库内按 `relatedNumId + personId` 去重
失败判定口径:
1. 中介本人证件号在同一文件内重复,记失败
2. 中介本人证件号在数据库中已存在,记失败
3. 同一文件内相同“本人证件号 + 亲属证件号”重复,记失败
4. 数据库中相同“本人证件号 + 亲属证件号”已存在,记失败
5. 相同亲属证件号若关联的是不同中介本人,允许导入成功
### 8.2 导入中介实体关联关系
#### 8.2.1 目标
导入中介与机构的关系,落表:
- `ccdi_intermediary_enterprise_relation`
#### 8.2.2 接口
新增独立接口:
1. `POST /ccdi/intermediary/importEnterpriseRelationTemplate`
2. `POST /ccdi/intermediary/importEnterpriseRelationData`
3. `GET /ccdi/intermediary/importEnterpriseRelationStatus/{taskId}`
4. `GET /ccdi/intermediary/importEnterpriseRelationFailures/{taskId}`
#### 8.2.3 模板字段
模板字段如下:
1. 中介本人证件号码
2. 统一社会信用代码
3. 关联角色/职务
4. 备注
#### 8.2.4 导入处理规则
1. 根据“中介本人证件号码”查找 `person_sub_type = 本人` 的中介本人
2. 根据统一社会信用代码校验机构已存在于 `ccdi_enterprise_base_info`
3. 将本人 `biz_id` 回填为 `intermediary_biz_id`
4. 写入 `ccdi_intermediary_enterprise_relation`
#### 8.2.5 去重逻辑
双层去重:
1. 文件内按 `中介本人证件号码 + 统一社会信用代码` 去重
2. 数据库内按 `intermediary_biz_id + social_credit_code` 去重
失败判定口径:
1. 中介本人证件号码不存在,记失败
2. 统一社会信用代码不存在于系统机构表,记失败
3. 文件内重复关系,记失败
4. 数据库内已存在关系,记失败
## 九、异步任务与失败记录设计
### 9.1 异步链路
两类导入都采用与员工数据导入一致的异步模式:
1. Controller 解析 Excel
2. 主 Service 生成 `taskId`
3. Redis 初始化状态为 `PROCESSING`
4. 异步 ImportService 后台处理
5. 前端轮询任务状态
6. 完成后展示结果并支持查看失败记录
### 9.2 Redis Key 设计
中介信息导入:
1. `import:intermediary-person:{taskId}`
2. `import:intermediary-person:{taskId}:failures`
中介实体关联关系导入:
1. `import:intermediary-enterprise-relation:{taskId}`
2. `import:intermediary-enterprise-relation:{taskId}:failures`
### 9.3 状态字段
状态字段统一为:
1. `taskId`
2. `status`
3. `totalCount`
4. `successCount`
5. `failureCount`
6. `progress`
7. `startTime`
8. `endTime`
9. `message`
### 9.4 失败记录
失败记录按模板字段原样返回,并增加:
- `errorMessage`
前端可直接复用员工数据导入的失败记录弹窗模式。
## 十、`relatedNumId` 语义切换影响清单
### 10.1 直接受影响代码
1. `CcdiIntermediaryServiceImpl`
- 亲属新增、修改、查询、删除、级联删除
2. `CcdiIntermediaryMapper.xml`
- 统一列表中亲属联表条件
3. `CcdiIntermediaryPersonImportServiceImpl`
- 本人/亲属混合导入逻辑
4. `CcdiIntermediaryController`
- 导入接口重组
5. `CcdiBizIntermediary`
- 字段语义注释
6. `CcdiIntermediaryPersonDetailVO`
7. `CcdiIntermediaryRelativeVO`
8. `CcdiIntermediaryPersonAddDTO`
9. `CcdiIntermediaryPersonEditDTO`
10. `CcdiIntermediaryRelativeAddDTO`
11. `CcdiIntermediaryRelativeEditDTO`
12. `CcdiIntermediaryPersonExcel`
### 10.2 前端受影响代码
1. `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
2. `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
3. `ruoyi-ui/src/api/ccdiIntermediary.js`
4. 失败记录弹窗相关状态管理
5. 导入模板说明文案
6. 现有依赖 `bizId` 的亲属维护入口保持不变
### 10.3 基本不受影响代码
1. `CcdiIntermediaryEnterpriseRelationMapper.xml`
- 仍通过 `intermediary_biz_id -> biz_id` 关联
2. 账户模块中 `INTERMEDIARY` 所属人校验逻辑
- 当前仅以中介证件号作为 ownerId不依赖 `relatedNumId`
### 10.4 文档与资料
以下文档需要同步修正语义:
1. 旧中介库设计文档
2. 中介 API 文档
3. 数据库字段说明
4. 相关实施计划与测试文档
## 十一、历史数据迁移设计
### 11.1 迁移目标
将历史数据中 `related_num_id = 中介本人 biz_id` 的记录一次性迁移为:
- `related_num_id = 中介本人 person_id`
### 11.2 迁移前校验
迁移前必须检查:
1. `related_num_id` 有值,但找不到对应本人 `biz_id`
2. 找到本人后,本人 `person_id` 为空
3. 同一历史关系迁移后是否会与现有新语义数据冲突
4. 现有亲属记录在迁移后是否出现同一中介本人名下 `related_num_id + person_id` 冲突
### 11.3 迁移原则
1. 按用户要求,迁移先于功能改造执行
2. 先清洗异常数据,再执行正式迁移
3. 迁移完成后,全部代码统一按新语义运行
4. 不保留同时兼容 `biz_id` 与证件号码的查询逻辑
5. 为避免旧逻辑读取失败,迁移、代码发布、回归验证必须放在同一实施窗口内连续完成
## 十二、异常处理口径
### 12.1 导入中介信息
常见失败原因包括:
1. 证件号码不能为空
2. 人员子类型不能为空
3. `personSubType = 本人``relatedNumId` 非空
4. `personSubType != 本人``relatedNumId` 为空
5. 关联中介本人证件号码不存在
6. 中介本人证件号在导入文件内重复
7. 中介本人证件号在数据库中已存在
8. 导入文件内亲属关系重复
9. 数据库中亲属关系已存在
### 12.2 导入中介实体关联关系
常见失败原因包括:
1. 中介本人证件号码不能为空
2. 统一社会信用代码不能为空
3. 中介本人不存在
4. 统一社会信用代码不存在于系统机构表
5. 导入文件内关系重复
6. 数据库中关系已存在
## 十三、测试边界
### 13.1 后端测试
至少覆盖:
1. 历史数据迁移后亲属列表查询正确
2. 手工新增亲属后 `related_num_id` 保存本人证件号码
3. 删除中介本人时级联删除其亲属和关联机构关系
4. 本人和亲属混合导入成功
5. 亲属引用“本次文件内先导入成功的本人”成功
6. 相同亲属证件号关联不同中介本人成功
7. 文件内重复、库内重复、无效关联均能正确记失败
8. 中介实体关联关系导入成功与失败路径正确
### 13.2 前端测试
至少覆盖:
1. 顶部两个导入按钮显示正确
2. 模板下载地址正确
3. 上传后异步轮询正常
4. 完成后结果提示正确
5. 失败记录弹窗、分页、清除历史记录可用
6. 页面刷新后可恢复最近一次导入任务状态
## 十四、实施建议
建议实施顺序如下:
1. 先完成 `related_num_id` 历史数据迁移脚本与迁移校验 SQL
2. 在同一实施窗口内先执行迁移
3. 紧接着发布中介模块语义切换代码,完成亲属查询、手工维护、删除级联改造
4. 再接入两类导入后端接口与异步处理
5. 最后改造前端入口、弹窗、失败记录与状态恢复
## 十五、设计结论
本次中介库导入改造最终确认如下:
1. 顶部仅保留 2 个导入按钮
2. 中介本人和中介亲属合并为“导入中介信息”
3. 中介实体关联关系单独导入,落关系表
4. `related_num_id` 整个中介模块统一改为“关联中介本人证件号码”
5. 所有导入采用纯新增模式
6. 中介本人按证件号全局唯一,亲属允许关联多个不同中介本人
7. 去重采用“文件内去重 + 库内去重”
8. 外部接口继续保持 `bizId` 契约
9. `relationType` 废弃,`personSubType` 使用字典下拉
10. 通过一次性历史数据迁移完成语义统一
该方案满足当前需求边界,且符合最短路径实现原则,不引入兼容性补丁方案。
## 十六、实施回写
- 2026-04-20 已完成中介模块 `related_num_id` 语义切换,手工新增亲属、统一列表查询、本人证件号变更同步和级联删除逻辑均按“关联中介本人证件号码”改造。
- 2026-04-20 已完成“导入中介信息”和“导入中介实体关联关系”两条异步导入链路,并同步完成前端双按钮、双任务状态、双失败记录模式改造。
- 2026-04-20 已完成后端目标测试回归、信息采集模块编译、前端静态单测和 `build:prod` 构建验证。
- 2026-04-20 已执行 `bin/mysql_utf8_exec.sh sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql``bin/mysql_utf8_exec.sh sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql`
- 迁移后核查结果:`legacy_biz_id_reference = 0`,说明旧 `biz_id` 语义残留已清零;`post_migration_missing_parent = 1025``owner_person_id_empty_after_migration = 754`,说明历史数据中仍存在无法关联到本人证件号的脏数据,需后续专项清洗。

View File

@@ -0,0 +1,461 @@
# 中介库导入改造后端实施计划
> **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.
> **执行约束:** 仓库约定不开 subagent执行时使用 `superpowers:executing-plans`。
**Goal:** 完成中介库后端导入改造,统一 `related_num_id` 为“关联中介本人证件号码”,新增“导入中介信息”和“导入中介实体关联关系”两条异步导入链路,并保证手工维护、统一查询和级联删除与新语义一致。
**Architecture:** 后端保持 `CcdiIntermediaryController + CcdiIntermediaryServiceImpl + ccdi_biz_intermediary/ccdi_intermediary_enterprise_relation` 的现有主线,不新增平行模块。外部 API 继续保持 `bizId` 契约,服务层内部把亲属链路统一转换为“本人证件号码”处理;导入链路对齐员工数据导入,拆分为“中介信息导入”和“中介实体关联关系导入”两套独立任务与失败记录。
**Tech Stack:** Java 21, Spring Boot 3, MyBatis Plus, MySQL, Redis, EasyExcel, JUnit 5, Maven, Markdown
---
## 文件结构与职责
**设计与计划基线**
- `docs/design/2026-04-20-intermediary-import-refactor-design.md`
当前需求和边界的唯一设计基线,实施不得偏离其中已确认口径。
**SQL 与迁移**
- Create: `sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql`
负责把历史 `related_num_id = 本人 biz_id` 迁移为 `related_num_id = 本人 person_id`
- Create: `sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql`
负责补齐或校正 `ccdi_person_sub_type` 字典项,供导入模板下拉使用。
**中介主链路**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java`
调整 `relatedNumId` 字段注释语义。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java`
暴露新的校验与导入能力方法。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java`
完成亲属主从语义切换、手工维护逻辑修正、关系唯一性判断和级联删除。
- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml`
把统一列表中的亲属关联条件从 `parent.biz_id` 改为 `parent.person_id`
**导入链路**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java`
重组导入接口,保留中介信息导入,新增实体关联关系导入。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java`
`personSubType` 使用字典下拉,移除废弃的 `relationType` 列,重命名 `relatedNumId` 模板文案。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java`
对齐新模板字段,移除废弃字段。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java`
调整中介信息导入接口定义。
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java`
重写为“本人阶段 + 亲属阶段”的混合导入实现。
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryEnterpriseRelationExcel.java`
中介实体关联关系导入模板实体。
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryEnterpriseRelationImportFailureVO.java`
实体关联关系失败记录 VO。
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryEnterpriseRelationImportService.java`
实体关联关系导入服务接口。
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java`
实体关联关系异步导入实现。
**既有 DTO / VO / Mapper 补充**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonAddDTO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryPersonEditDTO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeAddDTO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeEditDTO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryPersonDetailVO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/CcdiIntermediaryRelativeVO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryEnterpriseRelationMapper.java`
- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml`
**测试**
- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryServiceImplTest.java`
- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryMapperTest.java`
- Modify: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java`
- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryPersonImportServiceImplTest.java`
- Create: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java`
## 实施任务
### Task 1: 编写迁移脚本并锁定实施窗口
**Files:**
- Create: `sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql`
- Create: `sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql`
- Reference: `sql/migration/2026-04-17-fix-intermediary-person-sub-type-dict.sql`
- Reference: `sql/dpc_intermediary_dict_data_20260129.sql`
- [ ] **Step 1: 写迁移前校验 SQL**
在迁移脚本头部先写校验查询,明确输出:
```sql
-- 1. 找不到对应本人 biz_id 的亲属
-- 2. 本人 person_id 为空的记录
-- 3. 迁移后同一中介本人下 related_num_id + person_id 冲突的记录
```
- [ ] **Step 2: 写正式迁移 SQL**
将旧语义统一改为新语义:
```sql
UPDATE ccdi_biz_intermediary child
JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.biz_id
SET child.related_num_id = parent.person_id
WHERE child.person_sub_type <> '本人';
```
- [ ] **Step 3: 写 `ccdi_person_sub_type` 字典修正脚本**
至少补齐:
```sql
/ / / / /
```
Expected: 模板下拉和后端校验使用同一字典源。
- [ ] **Step 4: 用 UTF-8 脚本执行 SQL 验证**
Run:
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql
bin/mysql_utf8_exec.sh sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql
```
Expected: SQL 正常执行,无乱码,无唯一性冲突。
- [ ] **Step 5: 提交迁移脚本**
```bash
git add sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql
git commit -m "新增中介导入迁移脚本"
```
### Task 2: 切换中介模块内部主从语义
**Files:**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java`
- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeAddDTO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeEditDTO.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryServiceImplTest.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryMapperTest.java`
- [ ] **Step 1: 先写失败测试,覆盖亲属新语义**
补测试场景:
```java
// 1. 手工新增亲属时 related_num_id 写本人 person_id
// 2. 查询亲属列表按 parent.person_id 关联
// 3. 删除本人时按本人 person_id 级联删除亲属
// 4. 相同亲属 person_id 挂到不同本人时允许成功
```
- [ ] **Step 2: 调整服务层内部转换逻辑**
关键改造点:
```java
CcdiBizIntermediary owner = requireIntermediaryPerson(bizId);
relative.setRelatedNumId(owner.getPersonId());
```
以及:
```java
wrapper.eq(CcdiBizIntermediary::getRelatedNumId, owner.getPersonId())
```
- [ ] **Step 3: 收敛唯一性校验**
新增两个明确规则:
```java
// 本人personId 全表唯一
// 亲属:同一 relatedNumId + personId 唯一
```
Expected: 不再把亲属 `personId` 误当成全表唯一。
- [ ] **Step 4: 修改联合查询 SQL**
把亲属联表条件改成:
```xml
ON child.related_num_id = parent.person_id
```
Expected: 统一列表在迁移后仍能查出亲属记录。
- [ ] **Step 5: 运行服务层与 Mapper 测试**
Run:
```bash
mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest test
```
Expected: 目标测试通过,新的主从语义断言成立。
- [ ] **Step 6: 提交语义切换代码**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/CcdiBizIntermediary.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryService.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryMapper.xml ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeAddDTO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/dto/CcdiIntermediaryRelativeEditDTO.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryServiceImplTest.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/mapper/CcdiIntermediaryMapperTest.java
git commit -m "调整中介亲属关联语义"
```
### Task 3: 重构中介信息导入为“本人 + 亲属”混合导入
**Files:**
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryPersonImportServiceImplTest.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java`
- [ ] **Step 1: 先写导入失败测试**
覆盖:
```java
// 1. personSubType 使用字典下拉字段
// 2. personSubType=本人 且 relatedNumId 非空 -> 失败
// 3. personSubType!=本人 且 relatedNumId 为空 -> 失败
// 4. 亲属引用本次导入前面成功的本人 -> 成功
// 5. 同一亲属挂不同本人 -> 成功
// 6. 同一本人名下重复亲属 -> 失败
```
- [ ] **Step 2: 调整 Excel 模板实体**
最小改动:
```java
@DictDropdown(dictType = "ccdi_person_sub_type")
private String personSubType;
@ExcelProperty("关联中介本人证件号码")
private String relatedNumId;
```
同时删除:
```java
private String relationType;
```
- [ ] **Step 3: 按“两阶段”重写导入实现**
核心流程:
```java
List<CcdiIntermediaryPersonExcel> owners = ...
List<CcdiIntermediaryPersonExcel> relatives = ...
// owners: personId 全表唯一
// relatives: relatedNumId + personId 唯一
```
Expected: 中介本人和亲属在一个文件中可混合导入。
- [ ] **Step 4: 统一失败记录字段**
`IntermediaryPersonImportFailureVO` 只保留当前模板字段,不再暴露废弃的 `relationType`
- [ ] **Step 5: 调整 Controller 返回文案与模板下载**
Expected: 下载模板时 `personSubType` 下拉来源于 `ccdi_person_sub_type`,返回的失败记录字段与模板一致。
- [ ] **Step 6: 运行导入链路测试**
Run:
```bash
mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryControllerTest test
```
Expected: 混合导入与失败口径通过。
- [ ] **Step 7: 提交中介信息导入改造**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryPersonExcel.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryPersonImportFailureVO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryPersonImportService.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryPersonImportServiceImpl.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryPersonImportServiceImplTest.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java
git commit -m "改造中介信息导入链路"
```
### Task 4: 新增中介实体关联关系导入链路
**Files:**
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryEnterpriseRelationExcel.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryEnterpriseRelationImportFailureVO.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryEnterpriseRelationImportService.java`
- Create: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java`
- Modify: `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java`
- Modify: `ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java`
- Test: `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java`
- [ ] **Step 1: 先写关系导入测试**
覆盖:
```java
// 1. 中介本人证件号码不存在 -> 失败
// 2. socialCreditCode 不存在于机构表 -> 失败
// 3. 同一文件内相同 personId + socialCreditCode -> 失败
// 4. 数据库内已存在相同 intermediary_biz_id + socialCredit_code -> 失败
// 5. 正常导入成功并写入关系表
```
- [ ] **Step 2: 新建 Excel 与失败记录对象**
字段固定为:
```java
ownerPersonId
socialCreditCode
relationPersonPost
remark
```
- [ ] **Step 3: 新建异步导入服务**
服务逻辑:
```java
// 证件号码 -> 查本人 bizId
// 社会信用代码 -> 校验机构存在
// intermediaryBizId + socialCreditCode 唯一
```
- [ ] **Step 4: 补 Controller 接口**
新增:
```java
/importEnterpriseRelationTemplate
/importEnterpriseRelationData
/importEnterpriseRelationStatus/{taskId}
/importEnterpriseRelationFailures/{taskId}
```
- [ ] **Step 5: 运行关系导入测试**
Run:
```bash
mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiIntermediaryControllerTest test
```
Expected: 实体关联关系导入链路完整通过。
- [ ] **Step 6: 提交关系导入代码**
```bash
git add ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/excel/CcdiIntermediaryEnterpriseRelationExcel.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/IntermediaryEnterpriseRelationImportFailureVO.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiIntermediaryEnterpriseRelationImportService.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryEnterpriseRelationImportServiceImpl.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiIntermediaryController.java ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiIntermediaryServiceImpl.java ccdi-info-collection/src/main/resources/mapper/info/collection/CcdiIntermediaryEnterpriseRelationMapper.xml ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiIntermediaryEnterpriseRelationImportServiceImplTest.java ccdi-info-collection/src/test/java/com/ruoyi/info/collection/controller/CcdiIntermediaryControllerTest.java
git commit -m "新增中介实体关联关系导入"
```
### Task 5: 做完整回归验证并更新文档
**Files:**
- Modify: `docs/design/2026-04-20-intermediary-import-refactor-design.md`
- Modify: `docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md`
- Reference: `bin/mysql_utf8_exec.sh`
- [ ] **Step 1: 运行后端完整相关测试集合**
Run:
```bash
mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test
```
Expected: 全部 PASS。
- [ ] **Step 2: 编译信息采集模块,确认没有遗漏编译错误**
Run:
```bash
mvn -pl ccdi-info-collection -am clean compile
```
Expected: `BUILD SUCCESS`
- [ ] **Step 3: 记录 SQL 执行与测试结果**
在计划文档底部补:
```markdown
- 实际执行的 SQL 脚本
- 实际执行的 Maven 命令
- PASS / FAIL 结果
```
- [ ] **Step 4: 提交文档回写**
```bash
git add docs/design/2026-04-20-intermediary-import-refactor-design.md docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md
git commit -m "补充中介导入后端实施记录"
```
## 验证命令
```bash
bin/mysql_utf8_exec.sh sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql
bin/mysql_utf8_exec.sh sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql
mvn -pl ccdi-info-collection -am -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test
mvn -pl ccdi-info-collection -am clean compile
```
## 完成标准
- `related_num_id` 已按“关联中介本人证件号码”完成迁移和代码切换
- 手工新增亲属、查询亲属、删除本人级联删除全部与新语义一致
- 中介信息导入支持本人和亲属混合导入
- 同一亲属证件号允许关联多个不同中介本人
- 中介实体关联关系导入独立可用,且只写关系表
- `relationType` 已从导入链路中移除
- `personSubType` 模板下拉已切换为 `ccdi_person_sub_type`
- 后端测试与编译验证通过
## 执行结果
- SQL 脚本:
`sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql`
`sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql`
结果:已执行 `bin/mysql_utf8_exec.sh`
- SQL 核查:
`post_migration_missing_parent = 1025`
`legacy_biz_id_reference = 0`
`owner_person_id_empty_after_migration = 754`
结果:可迁移数据已切换完成,旧 `biz_id` 语义残留清零,但历史脏数据仍需后续清洗。
- Maven 命令:
`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest test`
结果PASS
- Maven 命令:
`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest,CcdiIntermediaryControllerTest test`
结果PASS
- Maven 命令:
`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test`
结果PASS
- Maven 命令:
`mvn -pl ccdi-info-collection -am clean compile`
结果PASSBUILD SUCCESS

View File

@@ -0,0 +1,34 @@
# 实体库删除关联校验后端实施计划
## 目标
在实体库管理删除接口中增加删除前校验,确保待删除实体与中介、员工、信贷客户不存在关联关系;一旦存在任一关联,当前删除操作直接失败并返回明确提示。
## 涉及文件
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiEnterpriseBaseInfoServiceImpl.java`
- `ccdi-info-collection/src/test/java/com/ruoyi/info/collection/service/CcdiEnterpriseBaseInfoServiceImplTest.java`
## 实施步骤
1.`CcdiEnterpriseBaseInfoServiceImpl` 的批量删除入口增加逐条删除前校验。
2. 使用现有三张关系表进行计数判断:
`ccdi_staff_enterprise_relation`
`ccdi_cust_enterprise_relation`
`ccdi_intermediary_enterprise_relation`
3. 若任一关系存在,拼装“员工/信贷客户/中介”中文提示并抛出运行时异常,阻断整批删除。
4. 保持所有待删实体均通过校验后,再执行原有批量删除。
5. 补充服务层单元测试,覆盖无关联可删除、单一关联拦截、多关联拦截三类场景。
## 验证命令
```bash
mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiEnterpriseBaseInfoServiceImplTest test
```
## 完成标准
- 实体库删除前会校验员工、信贷客户、中介三类关联
- 存在关联时接口返回明确失败原因,不执行删除
- 无关联时保留原有批量删除行为
- 定向单元测试通过

View File

@@ -0,0 +1,53 @@
# Redis 断连自动重连修复实施计划
## 1. 背景
后端当前通过 Spring Boot 自动配置的 Lettuce `RedisConnectionFactory` 提供 Redis 连接。现场反馈 Redis 短暂断连或重启后,后端不会恢复可用连接,后续 Redis 访问持续失败。
## 2. 问题定位
- 项目未自定义 `LettuceConnectionFactory`,使用的是 Spring Boot 默认装配。
- 默认共享连接在未开启连接校验时,存在继续复用失效连接的风险。
- 当前 `ruoyi-framework` 仅配置了 `RedisTemplate` 序列化,未对 Lettuce 连接工厂做任何重连相关定制。
## 3. 实施方案
### 3.1 最短路径修复
`ruoyi-framework` 的 Redis 配置中增加一个 `BeanPostProcessor`,对 Spring Boot 自动创建的 `LettuceConnectionFactory` 统一开启 `validateConnection`
### 3.2 预期效果
- Redis 恢复可用后,连接工厂在获取共享连接时会先校验连接有效性。
- 避免继续复用已经失效的 Lettuce 共享连接。
- 不调整现有 `RedisTemplate`、业务缓存调用方式和 YAML 配置结构。
## 4. 代码改动点
- `ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java`
- 增加 Lettuce 连接工厂后处理器。
- 在 Bean 初始化阶段统一开启 `validateConnection`
- `ruoyi-framework/src/test/java/com/ruoyi/framework/config/RedisConfigTest.java`
- 新增回归测试,校验 Lettuce 连接工厂会被强制打开连接校验。
- `ruoyi-framework/pom.xml`
- 增加 `spring-boot-starter-test` 测试依赖。
## 5. 验证计划
执行以下命令验证:
```bash
mvn -pl ruoyi-framework -am test -Dtest=RedisConfigTest -Dsurefire.failIfNoSpecifiedTests=false
```
验证通过标准:
- `RedisConfigTest` 通过。
- `ruoyi-framework` 模块构建成功。
- 不影响 `ruoyi-common``ruoyi-system` 作为依赖模块的测试阶段执行。
## 6. 影响范围
- 影响模块:`ruoyi-framework`
- 影响对象:所有通过 Spring 容器注入并复用 `RedisConnectionFactory` / `RedisTemplate` 的后端 Redis 调用
- 不涉及数据库结构、前端页面、接口入参与返回结构变更

View File

@@ -0,0 +1,413 @@
# 中介库导入改造前端实施计划
> **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.
> **执行约束:** 仓库约定不开 subagent执行时使用 `superpowers:executing-plans`;前端 Node 版本必须通过 `nvm` 切换到仓库已验证版本。
**Goal:** 将中介库前端导入入口改造为“导入中介信息 + 导入中介实体关联关系”两按钮模式,去掉旧的“个人/机构”切换导入,打通异步轮询、失败记录查看、历史任务恢复,并保持现有手工维护链路继续使用 `bizId` 契约。
**Architecture:** 保留 `ruoyi-ui/src/views/ccdiIntermediary/` 作为唯一页面入口,不新增平行页面。导入弹窗继续复用现有 `ImportDialog.vue` 外壳,但改为由父页面通过 `scene`/`importType` 驱动,而不是弹窗内部单选切换;`index.vue` 负责管理两类导入任务状态、失败记录和按钮展示,交互整体对齐员工数据导入页面。
**Tech Stack:** Vue 2, Element UI, JavaScript, npm, nvm, Markdown
---
## 文件结构与职责
**设计与计划基线**
- `docs/design/2026-04-20-intermediary-import-refactor-design.md`
已确认的导入边界、字段口径和前端交互基线。
**前端源码**
- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
负责顶部按钮、两类导入弹窗打开逻辑、异步任务状态恢复、失败记录弹窗和表格刷新。
- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
从“内部切换 person/entity”改为“父组件传入导入场景”支持中介信息导入和实体关联关系导入。
- Modify: `ruoyi-ui/src/api/ccdiIntermediary.js`
补两类导入的模板下载、上传、状态查询、失败记录 API。
**测试**
- Create: `ruoyi-ui/tests/unit/intermediary-import-toolbar.test.js`
- Create: `ruoyi-ui/tests/unit/intermediary-import-dialog.test.js`
- Create: `ruoyi-ui/tests/unit/intermediary-import-state.test.js`
- Create: `ruoyi-ui/tests/unit/intermediary-import-api.test.js`
- Modify: `ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js`
**依赖参考**
- `ruoyi-ui/src/views/ccdiBaseStaff/index.vue`
参考顶部导入按钮、轮询、失败记录、历史任务恢复。
- `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
继续作为导入弹窗壳子,不重复造一个全新组件。
- `ruoyi-ui/tests/unit/employee-asset-import-ui.test.js`
参考导入类单测结构和断言风格。
## 实施任务
### Task 1: 改造 API 文件为两类导入链路
**Files:**
- Modify: `ruoyi-ui/src/api/ccdiIntermediary.js`
- Test: `ruoyi-ui/tests/unit/intermediary-import-api.test.js`
- [ ] **Step 1: 先写失败测试,固定 API 方法名与路径**
覆盖:
```js
importPersonTemplate
importPersonData
getPersonImportStatus
getPersonImportFailures
importEnterpriseRelationTemplate
importEnterpriseRelationData
getEnterpriseRelationImportStatus
getEnterpriseRelationImportFailures
```
- [ ] **Step 2: 保留中介信息导入 API**
中介信息导入继续使用:
```js
/ccdi/intermediary/importPersonTemplate
/ccdi/intermediary/importPersonData
/ccdi/intermediary/importPersonStatus/{taskId}
/ccdi/intermediary/importPersonFailures/{taskId}
```
- [ ] **Step 3: 新增实体关联关系导入 API**
新增:
```js
/ccdi/intermediary/importEnterpriseRelationTemplate
/ccdi/intermediary/importEnterpriseRelationData
/ccdi/intermediary/importEnterpriseRelationStatus/{taskId}
/ccdi/intermediary/importEnterpriseRelationFailures/{taskId}
```
- [ ] **Step 4: 删除前端对旧 `importEntity*` 导入接口的依赖**
Expected: 页面不再调用旧“机构中介导入”接口。
- [ ] **Step 5: 运行 API 测试**
Run:
```bash
cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui
node tests/unit/intermediary-import-api.test.js
```
Expected: PASS方法名和 URL 与设计文档一致。
- [ ] **Step 6: 提交 API 调整**
```bash
git add ruoyi-ui/src/api/ccdiIntermediary.js ruoyi-ui/tests/unit/intermediary-import-api.test.js
git commit -m "调整中介导入前端接口"
```
### Task 2: 将导入弹窗改为场景驱动
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- Test: `ruoyi-ui/tests/unit/intermediary-import-dialog.test.js`
- [ ] **Step 1: 先写弹窗行为测试**
覆盖:
```js
// 1. 不再渲染“个人中介/机构中介”单选
// 2. scene=person 时调用中介信息导入地址
// 3. scene=enterpriseRelation 时调用实体关联关系导入地址
// 4. 模板文案随场景切换
```
- [ ] **Step 2: 把内部单选切换改成父组件传参**
新增 props
```js
scene: {
type: String,
default: 'person'
}
```
Expected: 弹窗自身不再维护 `formData.importType`
- [ ] **Step 3: 根据 `scene` 计算上传地址和模板地址**
```js
scene === 'person'
scene === 'enterpriseRelation'
```
- [ ] **Step 4: 按场景返回不同提示文案**
中介信息导入提示需要包含:
```text
personSubType 为字典下拉;
本人行 relatedNumId 为空;
亲属行 relatedNumId 填关联中介本人证件号码。
```
实体关联关系导入提示需要包含:
```text
只导入中介与机构关系;
统一社会信用代码必须已存在于系统机构表。
```
- [ ] **Step 5: 运行弹窗测试**
Run:
```bash
cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui
node tests/unit/intermediary-import-dialog.test.js
```
Expected: PASS弹窗只根据外部场景工作。
- [ ] **Step 6: 提交弹窗改造**
```bash
git add ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue ruoyi-ui/tests/unit/intermediary-import-dialog.test.js
git commit -m "改造中介导入弹窗场景切换"
```
### Task 3: 改造页面顶部按钮与任务状态
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- Test: `ruoyi-ui/tests/unit/intermediary-import-toolbar.test.js`
- Test: `ruoyi-ui/tests/unit/intermediary-import-state.test.js`
- [ ] **Step 1: 先写按钮与状态测试**
覆盖:
```js
// 1. 顶部有两个导入按钮
// 2. 按钮文案正确
// 3. 两类失败记录按钮独立显示
// 4. localStorage 使用两套独立 key
```
- [ ] **Step 2: 在工具栏增加两个导入按钮**
按钮文案固定为:
```vue
导入中介信息
导入中介实体关联关系
```
- [ ] **Step 3: 在页面中维护两套独立任务状态**
建议状态结构:
```js
personImportTask
enterpriseRelationImportTask
```
以及两套本地缓存 key
```js
ccdi_intermediary_person_import_task
ccdi_intermediary_enterprise_relation_import_task
```
- [ ] **Step 4: 保持页面原有手工维护链路不变**
Expected: 打开详情、新增亲属、查询关系列表等既有逻辑继续走 `bizId`,不因导入改造改变页面其它交互。
- [ ] **Step 5: 运行按钮与状态测试**
Run:
```bash
cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui
node tests/unit/intermediary-import-toolbar.test.js
node tests/unit/intermediary-import-state.test.js
```
Expected: PASS按钮与任务缓存隔离正确。
- [ ] **Step 6: 提交页面状态改造**
```bash
git add ruoyi-ui/src/views/ccdiIntermediary/index.vue ruoyi-ui/tests/unit/intermediary-import-toolbar.test.js ruoyi-ui/tests/unit/intermediary-import-state.test.js
git commit -m "调整中介导入页面入口状态"
```
### Task 4: 接入失败记录弹窗与完成态刷新
**Files:**
- Modify: `ruoyi-ui/src/views/ccdiIntermediary/index.vue`
- Modify: `ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue`
- Modify: `ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js`
- [ ] **Step 1: 先写失败记录行为断言**
在现有中介页面测试里补充:
```js
// 1. 导入完成且 failureCount > 0 时显示对应失败记录按钮
// 2. 查看失败记录时调用对应接口
// 3. 清除历史记录后按钮消失
```
- [ ] **Step 2: 在 `index.vue` 中落两套失败记录弹窗状态**
建议状态:
```js
personFailureDialogVisible
enterpriseRelationFailureDialogVisible
personFailureList
enterpriseRelationFailureList
```
- [ ] **Step 3: 对齐员工导入的完成态逻辑**
Expected:
```js
导入完成 -> 刷新列表 -> 刷新当前详情(如有) -> 更新失败按钮状态
```
- [ ] **Step 4: 对齐员工导入的历史记录恢复逻辑**
页面刷新后如果任务仍在缓存中:
```js
恢复失败记录按钮
恢复上次导入摘要
必要时继续轮询
```
- [ ] **Step 5: 运行页面行为测试**
Run:
```bash
cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui
node tests/unit/intermediary-person-edit-ui.test.js
```
Expected: PASS没有把导入状态逻辑打坏现有详情维护链路。
- [ ] **Step 6: 提交失败记录与刷新逻辑**
```bash
git add ruoyi-ui/src/views/ccdiIntermediary/index.vue ruoyi-ui/src/views/ccdiIntermediary/components/ImportDialog.vue ruoyi-ui/tests/unit/intermediary-person-edit-ui.test.js
git commit -m "补齐中介导入失败记录交互"
```
### Task 5: 做前端验证并回写计划结果
**Files:**
- Modify: `docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md`
- [ ] **Step 1: 使用 nvm 切换到仓库已验证版本**
Run:
```bash
cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui
source ~/.nvm/nvm.sh && nvm use 14.21.3
```
Expected: 输出 `Now using node v14.21.3`
- [ ] **Step 2: 运行前端相关单测脚本**
Run:
```bash
cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui
node tests/unit/intermediary-import-api.test.js
node tests/unit/intermediary-import-dialog.test.js
node tests/unit/intermediary-import-toolbar.test.js
node tests/unit/intermediary-import-state.test.js
node tests/unit/intermediary-person-edit-ui.test.js
```
Expected: 全部 PASS。
- [ ] **Step 3: 执行生产构建验证**
Run:
```bash
cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui
source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod
```
Expected: `BUILD SUCCESS`,没有新增语法或模块解析错误。
- [ ] **Step 4: 回写执行结果**
在计划文档末尾补:
```markdown
- 实际执行的 node / build 命令
- 各测试脚本 PASS / FAIL 结果
- 构建结果
```
- [ ] **Step 5: 提交验证结果**
```bash
git add docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md
git commit -m "补充中介导入前端实施记录"
```
## 验证命令
```bash
cd /Users/wkc/Desktop/ccdi/ccdi/ruoyi-ui
source ~/.nvm/nvm.sh && nvm use 14.21.3
node tests/unit/intermediary-import-api.test.js
node tests/unit/intermediary-import-dialog.test.js
node tests/unit/intermediary-import-toolbar.test.js
node tests/unit/intermediary-import-state.test.js
node tests/unit/intermediary-person-edit-ui.test.js
source ~/.nvm/nvm.sh && nvm use 14.21.3 && npm run build:prod
```
## 完成标准
- 顶部只有“导入中介信息”和“导入中介实体关联关系”两个按钮
- 导入弹窗不再内部切换“个人/机构”,而是由页面场景驱动
- 两类导入的模板下载、上传、轮询、失败记录全部独立
- 现有手工维护链路继续保持 `bizId` 契约
- 页面支持恢复两类最近一次导入任务状态
- 导入提示文案已经明确 `personSubType` 字典下拉、`relationType` 废弃、`relatedNumId` 新语义
- 已使用 `nvm use 14.21.3` 完成前端测试脚本和生产构建验证
## 执行结果
- Node 命令:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3`
结果PASSNow using node v14.21.3
- Node 命令:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && node tests/unit/intermediary-import-api.test.js && node tests/unit/intermediary-import-dialog.test.js && node tests/unit/intermediary-import-toolbar.test.js && node tests/unit/intermediary-import-state.test.js && node tests/unit/intermediary-person-edit-ui.test.js`
结果PASS
- Node 命令:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && npm run build:prod`
结果PASS构建成功仅保留原有 bundle size warning

View File

@@ -0,0 +1,19 @@
# AGENTS 全局规则同步实施记录
## 本次改动
- 更新根目录 `AGENTS.md` 的协作约定,补充 `.DS_Store` 忽略要求,以及 `using-superpowers` 只能在用户明确声明时启用的限制
- 更新根目录 `AGENTS.md` 的前端命令与开发测试提醒,明确前端相关命令执行前必须先通过 `nvm` 切换并确认 Node 版本
- 更新根目录 `AGENTS.md` 的文档维护要求,补充“每次改动均需留下实施文档”“前后端联动设计需拆分前后端实施计划”“写文档前先确认保存路径正确”等规则
- 在根目录 `AGENTS.md` 新增“方案规范”章节,约束方案输出必须走最短实现路径,不得擅自扩展兼容、补丁、兜底或降级方案,并要求完成全链路逻辑校验
## 影响范围
- 影响文件仅限仓库根目录 `AGENTS.md`
- 本次为项目协作规范更新,不涉及业务代码、数据库脚本、前端页面或后端接口变更
## 验证说明
- 已确认实施记录保存路径为 `docs/reports/implementation/`
- 已人工核对新增规则与本次提供的全局 `AGENTS.md` 要求一致,且未覆盖或破坏仓库原有约束
- 本次仅修改文档,无需运行代码测试

View File

@@ -0,0 +1,19 @@
# AGENTS 文档结构优化实施记录
## 本次改动
- 重构根目录 `AGENTS.md` 的章节结构,新增“高优先级规则”章节,将高频且强约束的协作规则前置
- 将原有分散在多个章节中的规则重新归类到“基础协作”“Git 与变更管理”“文档产出”“测试与运行”“数据库与编码”等小节
- 保留原有核心约束不变,仅优化文档层次、阅读顺序与检索效率
- 精简重复表达,例如将前后端实施计划拆分、实施记录留痕、`nvm` 使用、测试后关闭进程等规则统一收口到更明确的章节
## 影响范围
- 影响文件仅限仓库根目录 `AGENTS.md`
- 本次为项目协作规范文档优化,不涉及业务代码、数据库脚本、前端页面或后端接口变更
## 验证说明
- 已确认实施记录保存路径位于 `docs/reports/implementation/`
- 已人工检查优化后的 `AGENTS.md`,确认原有关键规则仍然保留,且文档结构更清晰
- 本次仅修改文档,无需运行代码测试

View File

@@ -0,0 +1,44 @@
# 中介库导入改造实施记录
## 基本信息
- 日期2026-04-20
- 范围:中介库后端导入改造 + 前端导入入口与状态改造
- 关联设计:`docs/design/2026-04-20-intermediary-import-refactor-design.md`
- 关联计划:
`docs/plans/backend/2026-04-20-intermediary-import-backend-implementation.md`
`docs/plans/frontend/2026-04-20-intermediary-import-frontend-implementation.md`
## 实施内容
- 后端完成 `related_num_id` 语义切换,统一为“关联中介本人证件号码”,并补齐本人证件号变更同步、亲属唯一性收敛、统一列表联表条件切换。
- 后端完成“导入中介信息”链路重构,支持本人与亲属混合导入、同文件内引用先成功导入的本人、同亲属证件号挂到不同本人。
- 后端新增“导入中介实体关联关系”链路,按“本人证件号码 -> 本人 bizId -> 关系表”写入,并支持文件内去重、库内去重、失败记录回看。
- 前端完成中介导入入口改造,页面顶部改为“导入中介信息”“导入中介实体关联关系”两个按钮,导入弹窗改为 `scene` 驱动。
- 前端完成两类导入任务状态、本地缓存键、失败记录弹窗、历史任务恢复和完成态刷新逻辑,保留现有详情维护、亲属维护、关联机构维护的 `bizId` 契约。
## 验证结果
- 后端测试:
`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiIntermediaryServiceImplTest,CcdiIntermediaryMapperTest,CcdiIntermediaryControllerTest,CcdiIntermediaryPersonImportServiceImplTest,CcdiIntermediaryEnterpriseRelationImportServiceImplTest test`
结果PASS
- 后端编译:
`mvn -pl ccdi-info-collection -am clean compile`
结果PASS
- 前端静态测试:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && node tests/unit/intermediary-import-api.test.js && node tests/unit/intermediary-import-dialog.test.js && node tests/unit/intermediary-import-toolbar.test.js && node tests/unit/intermediary-import-state.test.js && node tests/unit/intermediary-person-edit-ui.test.js`
结果PASS
- 前端构建:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && npm run build:prod`
结果PASS仅有原有 bundle size warning
## SQL 执行结果
- 已执行:
`bin/mysql_utf8_exec.sh sql/migration/2026-04-20-fix-ccdi-person-sub-type-dict.sql`
`bin/mysql_utf8_exec.sh sql/migration/2026-04-20-migrate-intermediary-related-num-id-to-person-id.sql`
- 迁移后核查:
`post_migration_missing_parent = 1025`
`legacy_biz_id_reference = 0`
`owner_person_id_empty_after_migration = 754`
- 结论:可迁移数据已经完成语义切换,旧 `biz_id` 语义残留已清零;历史中仍有缺失本人映射或 `related_num_id` 为空的脏数据,需要后续专项清洗。

View File

@@ -0,0 +1,75 @@
# 实体库管理导入浏览器测试记录
## 测试目标
- 在真实页面中下载“实体库管理”导入模板
- 基于下载模板生成覆盖全部后端显式校验分支的测试数据
- 通过页面上传测试文件并核对导入结果、失败记录与列表落库情况
## 测试环境
- 测试日期2026-04-21
- 前端地址:`http://127.0.0.1:8080`
- 后端地址:`http://127.0.0.1:62318`
- 登录账号:`admin`
- 登录方式:浏览器页面登录
## 测试文件
- 页面下载模板:`/Users/wkc/Desktop/ccdi/ccdi/.playwright-cli/实体库管理模板-1776753846277.xlsx`
- 生成测试文件:`/Users/wkc/Desktop/ccdi/ccdi/output/spreadsheet/enterprise-base-info-import-browser-test.xlsx`
## 测试步骤
1. 登录系统后进入“信息维护 -> 实体库管理”页面。
2. 打开导入弹窗并点击“下载模板”。
3. 基于下载模板填写 11 条测试数据。
4. 在导入弹窗上传测试文件并点击“确定”。
5. 等待异步导入完成。
6. 核对列表新增数据、失败记录条数与失败原因。
## 测试数据覆盖范围
- 成功导入完整数据 1 条
- 成功导入最简数据 1 条
- 与库内已存在统一社会信用代码重复 1 条
- 文件内统一社会信用代码重复 1 条
- 企业名称为空 1 条
- 统一社会信用代码为空 1 条
- 统一社会信用代码格式错误 1 条
- 经营状态为空 1 条
- 风险等级非法 1 条
- 企业来源非法 1 条
- 数据来源非法 1 条
## 页面验证结果
- 模板下载成功。
- 上传请求成功发送到 `/dev-api/ccdi/enterpriseBaseInfo/importData`
- 导入任务状态写入浏览器本地存储:
- `status=PARTIAL_SUCCESS`
- `totalCount=11`
- `successCount=2`
- `failureCount=9`
- 列表总数由 `2001` 增加到 `2003`,与成功导入 2 条一致。
- 新增记录已出现在列表顶部:
- `浏览器测试实体企业A / 992604210000000001`
- `浏览器测试实体企业B / 992604210000000002`
- “查看导入失败记录”弹窗成功展示 9 条失败数据。
## 失败原因核对
- `111333432959145585`:统一社会信用代码已存在
- `992604210000000002`:统一社会信用代码在导入文件中重复
- `992604210000000003`:企业名称不能为空
- 空统一社会信用代码:统一社会信用代码不能为空
- `ABC123`:统一社会信用代码格式不正确
- `992604210000000004`:经营状态不能为空
- `992604210000000005`:风险等级不在允许范围内
- `992604210000000006`:企业来源不在允许范围内
- `992604210000000007`:数据来源不在允许范围内
## 结论
- 实体库管理导入功能在真实浏览器场景下可正常完成模板下载、文件上传、异步导入、成功入库和失败记录展示。
- 本次基于页面和后端实际行为验证,后端当前显式校验分支均已命中且返回结果符合预期。

View File

@@ -0,0 +1,22 @@
# 实体库删除关联校验实施记录
## 基本信息
- 日期2026-04-21
- 范围:实体库管理后端删除校验
- 关联计划:`docs/plans/backend/2026-04-21-enterprise-delete-relation-check-backend-implementation.md`
## 实施内容
-`CcdiEnterpriseBaseInfoServiceImpl``deleteEnterpriseBaseInfoByIds` 中增加删除前校验逻辑。
- 删除前分别查询员工实体关联、信贷客户实体关联、中介关联机构三张关系表,只要任一表存在当前统一社会信用代码的关联记录,即终止删除。
- 失败提示按实际命中的关联类型拼装中文文案,例如“已关联员工”“已关联员工、信贷客户、中介”,便于页面直接回显原因。
- 保持“先全部校验、后统一删除”的处理方式,避免批量删除时出现部分删除成功、部分失败的不一致情况。
- 补充 `CcdiEnterpriseBaseInfoServiceImplTest`,覆盖删除成功、员工关联拦截、多关联拦截场景。
## 验证结果
- 执行命令:
`mvn -pl ccdi-info-collection -am -Dsurefire.failIfNoSpecifiedTests=false -Dtest=CcdiEnterpriseBaseInfoServiceImplTest test`
- 结果PASS
- 备注Maven 构建过程中仍存在项目原有 `ccdi-lsfx` 重复依赖声明 warning本次改动未触碰该问题不影响本次测试通过。

View File

@@ -0,0 +1,76 @@
# 中介库管理导入功能浏览器测试实施记录
## 本次操作
- 使用真实浏览器进入 `中介库管理` 页面。
- 分别下载两类导入模板:
- 中介和亲属信息导入模板
- 中介实体关联关系导入模板
- 基于下载模板生成浏览器回传测试文件,并在页面上传验证。
## 测试文件
- `output/spreadsheet/intermediary_person_import_browser_phase1.xlsx`
- `output/spreadsheet/intermediary_person_import_browser_phase2_existing_db_cases.xlsx`
- `output/spreadsheet/intermediary_enterprise_relation_import_browser_phase1.xlsx`
- `output/spreadsheet/intermediary_enterprise_relation_import_browser_phase2_db_duplicate.xlsx`
## 验证结果
### 中介和亲属信息导入
- 第一轮导入结果:总数 `13`,成功 `4`,失败 `9`
- 第二轮导入结果:总数 `2`,成功 `0`,失败 `2`
- 成功数据已在页面列表可见:
- `自动化中介本人A`
- `自动化中介A配偶`
- `文件内重复本人1`
- `文件内重复亲属1`
- 页面失败记录已确认命中:
- 本人行关联字段错误
- 亲属缺少关联本人
- 姓名为空
- 人员子类型为空
- 证件号非法
- 文件内本人重复
- 关联本人不存在
- 文件内亲属重复
- 库内本人重复
- 库内亲属重复
### 中介实体关联关系导入
- 第一轮导入结果:总数 `11`,成功 `3`,失败 `8`
- 第二轮导入结果:总数 `1`,成功 `0`,失败 `1`
- 成功数据已在页面列表可见:
- `成都市资产企业 / 自动化中介本人A / 董事`
- `上海市资产企业 / 自动化中介本人A / 监事`
- `杭州市不动产合伙企业 / 自动化中介本人A / 法人`
- 页面失败记录已确认命中:
- 中介本人为空
- 中介本人证件号非法
- 中介本人不存在
- 统一社会信用代码为空
- 统一社会信用代码不存在
- 关联人职务超长
- 备注超长
- 文件内关系重复
- 库内关系重复
## 影响范围
- 页面:`ruoyi-ui/src/views/ccdiIntermediary/`
- 后端接口:
- `/ccdi/intermediary/importPersonTemplate`
- `/ccdi/intermediary/importPersonData`
- `/ccdi/intermediary/importPersonStatus/{taskId}`
- `/ccdi/intermediary/importPersonFailures/{taskId}`
- `/ccdi/intermediary/importEnterpriseRelationTemplate`
- `/ccdi/intermediary/importEnterpriseRelationData`
- `/ccdi/intermediary/importEnterpriseRelationStatus/{taskId}`
- `/ccdi/intermediary/importEnterpriseRelationFailures/{taskId}`
## 说明
- `docs/tests/records/2026-04-21-intermediary-import-browser-test-record.md` 也已生成更完整测试记录,但该目录受当前仓库 `.gitignore` 规则影响,不进入版本管理。
- 测试结束后,已关闭本次启动的前端开发进程和 Playwright 浏览器;后端沿用原有 `62318` 进程,未做重启或停止。

View File

@@ -0,0 +1,19 @@
# 中介导入文案调整实施记录
## 基本信息
- 日期2026-04-21
- 范围:中介库前端导入入口文案调整
## 修改内容
- 将页面主按钮文案从“导入中介信息”改为“导入中介和亲属信息”。
- 将失败记录入口和失败记录弹窗标题同步改为“中介和亲属信息”表述。
- 将导入弹窗下载模板名称同步改为“中介和亲属信息导入模板”。
- 同步更新前端静态测试断言,确保页面文案与测试基线一致。
## 验证结果
- 已执行:
`source ~/.nvm/nvm.sh && cd ruoyi-ui && nvm use 14.21.3 >/dev/null && node tests/unit/intermediary-import-toolbar.test.js && node tests/unit/intermediary-import-dialog.test.js && node tests/unit/intermediary-import-state.test.js && node tests/unit/intermediary-person-edit-ui.test.js`
- 结果PASS

View File

@@ -0,0 +1,41 @@
# Redis 断连自动重连修复实施记录
## 1. 本次修改内容
-`ruoyi-framework` 的 Redis 配置中新增 Lettuce 连接工厂后处理器。
- 对 Spring Boot 自动装配的 `LettuceConnectionFactory` 统一开启 `validateConnection`
- 新增回归测试,校验连接工厂初始化时已开启连接校验。
-`ruoyi-framework` 补充测试依赖。
## 2. 修改原因
后端 Redis 连接在发生断连后,存在持续复用失效连接的风险,导致 Redis 恢复后应用侧仍无法正常访问缓存。此次修复通过在连接工厂层开启连接校验,缩短恢复路径,避免业务代码层面持续拿到不可用连接。
## 3. 实际变更文件
- `ruoyi-framework/src/main/java/com/ruoyi/framework/config/RedisConfig.java`
- `ruoyi-framework/src/test/java/com/ruoyi/framework/config/RedisConfigTest.java`
- `ruoyi-framework/pom.xml`
## 4. 验证结果
已执行:
```bash
mvn -pl ruoyi-framework -am test -Dtest=RedisConfigTest -Dsurefire.failIfNoSpecifiedTests=false
```
执行结果:
- `BUILD SUCCESS`
- `RedisConfigTest` 通过
## 5. 影响范围
- 后端 Redis 连接恢复行为
- 所有依赖 `RedisTemplate``RedisCache` 的缓存、登录态、验证码、限流等功能
## 6. 备注
- 本次未改动 Redis 地址、密码、库索引、连接池大小等运行参数。
- 本次未引入新的 Redis 客户端,仍保持现有 Spring Data Redis + Lettuce 方案。

View File

@@ -0,0 +1,44 @@
# 证据库最小改造验证清单
## 验证范围
- 流水证据:流水详情中小号「加入证据库」按钮、确认弹窗、保存入库。
- 模型证据:模型详情异常对象卡片中小号「加入证据库」按钮、确认弹窗、保存入库。
- 资产证据:资产详情中小号「加入证据库」按钮、确认弹窗、保存入库。
- 证据线索:项目详情右上角小号「证据线索」入口、右侧抽屉列表、搜索框基础展示。
## 版式约束
- 不新增独立证据库页面。
- 不改变原详情页主体字段、表格列、字号体系和业务阅读顺序。
- 「加入证据库」只能作为低频辅助按钮出现,使用 mini 尺寸、弱边框、弱背景。
- 「证据线索」只能作为轻入口和右侧抽屉,不遮挡或重排项目详情主体内容。
## 功能验证
- 项目详情页能正常打开,顶部「证据线索」按钮可打开抽屉。
- 抽屉无证据时展示空状态,有证据时展示编号、类型、关联人员、摘要、来源、确认人、备注。
- 模型详情点击「加入证据库」后,弹窗自动带出证据类型、关联人员、证据摘要。
- 流水详情点击「加入证据库」后,弹窗自动带出流水证据摘要,`source_record_id` 使用 `md5(本方账号+本方名称+对方账号+对方名称+交易时间+金额+摘要)`
- 资产详情点击「加入证据库」后,弹窗自动带出资产证据摘要。
- 模型证据 `source_record_id` 使用 `md5(人员身份证+模型编码)`,缺少人员身份证或模型编码时不允许入库。
- 资产证据 `source_record_id` 使用 `md5(人员身份证+资产字段)`,当前资产负债聚合口径的资产字段为家庭总收入、家庭总负债、家庭总资产、风险等级编码。
- 确认理由为空时不能提交。
- 填写确认理由后可以提交,提交成功后自动打开或刷新证据线索抽屉。
- 保存后的证据落库到 `ccdi_evidence`
## 技术验证
- 后端 `ccdi-project` 编译通过。
- 前端 `npm run build:prod` 通过。
- 数据库表 `ccdi_evidence` 存在。
- 流水证据 `source_record_id` 不依赖 `statementId/bankStatementId`,应为 32 位 MD5 指纹。
- 模型证据、资产证据的 `source_record_id` 均不拼接项目 ID项目归属仅存 `project_id` 字段。
- 页面控制台不出现由本次改造引入的明显错误。
- 不提交或误动无关文件。
## 本期不做
- 证据卡片「查看详情」真实跳转原记录。
- 跨项目引用/复用 UI。
- 重复证据拦截。

View File

@@ -0,0 +1,64 @@
# 员工招聘功能自验收清单
验收日期2026-04-20
## 验收范围
本次自验收覆盖员工招聘页面与接口联动所需的前后端能力,包括招聘类型、候选人历史工作经历、工作经历单独导入、详情/编辑页展示顺序、面试官字段展示一致性,以及基于现有招聘数据补充联调样例数据。
## 前端页面
- [x] 查询条件保持原有结构,仅新增“招聘类型”筛选项。
- [x] 顶部操作区包含“新增”“导入”“导入工作经历”“导出”。
- [x] 列表列按最新口径展示:招聘记录编号、招聘项目名称、职位名称、候选人姓名、录用情况、学历 / 毕业学校、招聘类型、历史工作经历、操作。
- [x] 列表“操作”列包含“详情”“编辑”“删除”按钮。
- [x] 招聘项目名称列已加宽,长名称不再只显示为“办结”一类截断残片。
- [x] “学历 / 毕业学校”在列表合并展示,详情/编辑中仍保留学历、毕业院校、毕业年月、专业等候选人基础字段。
- [x] 详情页模块顺序为:招聘岗位信息、录用情况、候选人情况、候选人历史工作经历、面试官信息。
- [x] 编辑页模块顺序与详情页保持一致:招聘岗位信息、录用情况、候选人情况、面试官信息。
- [x] 详情页“面试官信息”统一按四个字段展示面试官1姓名、面试官1工号、面试官2姓名、面试官2工号。
- [x] 详情页不再展示重复的“社招工作经历摘要”,只保留“候选人历史工作经历”。
- [x] 工作经历导入使用独立入口、独立模板、独立上传接口。
## 后端接口与数据结构
- [x] 主表 `ccdi_staff_recruitment` 保留原有创建/更新人员字段命名,不改动既有审计字段口径。
- [x] 主表新增 `recruit_type`,用于区分社招、校招。
- [x] 历史工作经历使用独立表 `ccdi_staff_recruitment_work`,不把工作经历摘要字段放入主表。
- [x] 列表查询聚合返回历史工作经历段数,避免前端列表加载完整经历明细。
- [x] 详情查询返回完整历史工作经历列表。
- [x] 删除招聘记录时同步删除对应历史工作经历。
- [x] 工作经历导入以招聘记录编号为唯一匹配依据。
- [x] 工作经历导入时,候选人姓名、招聘项目名称、职位名称仅用于人工核对和导入校验。
- [x] 工作经历导入时,三个辅助字段与主表不一致则禁止导入。
- [x] 工作经历导入只允许社招记录导入,校招记录禁止导入。
- [x] 同一个招聘记录编号在工作经历导入文件中任意一行失败时,该招聘记录编号下本次所有工作经历均不覆盖入库。
## 数据库与联调样例数据
- [x] 已补充数据库迁移脚本:`sql/migration/2026-04-15-add-staff-recruitment-social-work-summary.sql`
- [x] 已补充现有数据联调样例脚本:`sql/migration/2026-04-20-seed-staff-recruitment-work-existing-data.sql`
- [x] 样例脚本不改动已有招聘项目名称、职位名称、候选人姓名、录用情况、面试官等原始业务信息。
- [x] 样例脚本只在招聘类型为空时补充 `recruit_type`,并生成带标记的历史工作经历样例。
- [x] 数据库验证结果:`SOCIAL = 4646``CAMPUS = 1355`
- [x] 数据库验证结果:已生成历史工作经历样例 `25` 条,覆盖社招招聘记录 `20` 条。
## 构建与验证
- [x] 后端编译通过:`mvn -pl ccdi-info-collection -am compile -DskipTests`
- [x] 前端生产构建通过:`npm run build:prod`
- [x] 前端构建仅存在体积提示类 warning未出现编译错误。
- [x] 前端预览截图已生成,覆盖列表、工作经历导入、详情面试官展示。
- [x] 验证过程中启动的前端预览进程已停止,未保留 8088 端口监听。
## 预览截图
- 列表页:`C:\Users\20696\codex-preview\staff-recruitment-work-import-list.png`
- 工作经历导入弹窗:`C:\Users\20696\codex-preview\staff-recruitment-work-import-dialog.png`
- 详情页面试官四字段展示:`C:\Users\20696\codex-preview\staff-recruitment-detail-interviewer-separated.png`
## 注意事项
- 当前机器无法通过 `bin/mysql_utf8_exec.sh` 调用 MySQL 客户端执行中文 SQL实际数据库脚本执行采用本地 Maven 缓存中的 MySQL JDBC 驱动,并显式设置 `utf8mb4` 会话字符集。
- 列表默认第一页如果主要是校招记录,“历史工作经历”可能显示为 `-`;筛选“社招”后可看到已补充的工作经历段数。
- 仓库中存在与本次招聘功能无关的未跟踪 `docx` 文件,本次未处理、未纳入验收范围。

View File

@@ -0,0 +1,51 @@
# 证据库最小改造验证记录
## 验证时间
2026-04-21
## 验证环境
- 前端:`http://localhost:62319`
- 后端:`http://localhost:62318`
- 项目:`test`
- 项目 ID`90337`
## 验证结果
| 验证项 | 结果 | 说明 |
| --- | --- | --- |
| 后端编译 | 通过 | `mvn -pl ccdi-project -am compile -DskipTests` 成功 |
| 前端构建 | 通过 | `npm run build:prod` 成功,仅存在原有包体积 warning |
| 数据库表 | 通过 | `ccdi_evidence` 已存在 |
| 模型证据入库 | 通过 | 模型详情小号「加入证据库」可打开弹窗并保存,生成 `EV-001` |
| 流水证据入库 | 通过 | 流水详情小号「加入证据库」可打开弹窗并保存,当前代码已改为使用 32 位 MD5 指纹作为 `source_record_id` |
| 资产证据入库 | 通过 | 资产详情小号「加入证据库」可打开弹窗并保存,已验证旧规则测试数据 `EV-003` 与新指纹规则测试数据 `EV-004` |
| 证据线索抽屉 | 通过 | 抽屉展示三类证据,包含编号、类型、关联人员、摘要、来源、确认人、备注 |
| 前端控制台 | 通过 | 验证后未发现 error/warn |
| 模型/资产来源指纹更新 | 通过 | 已重启后端并通过 MCP 页面验证:模型证据、资产证据均可打开确认弹窗,本次未确认入库,避免新增测试数据 |
| 证据抽屉跳转入口 | 通过 | 本期不做原记录跳转,已移除抽屉卡片中的「查看流水详情」「查看模型详情」「查看资产详情」按钮 |
## 落库核对
项目 `90337` 当前证据数:
| 类型 | 数量 |
| --- | ---: |
| FLOW | 1 |
| MODEL | 1 |
| ASSET | 2 |
| 合计 | 4 |
## 注意事项
- 本次验证产生了测试证据数据,如正式交付前需要干净环境,可按项目 ID 清理。
- 历史已保存的测试证据可能保留旧来源标识,新保存的流水、模型、资产证据会按当前规则生成 MD5 指纹。
- 当前代码已将模型证据来源标识改为 `md5(人员身份证+模型编码)`,资产证据来源标识改为 `md5(人员身份证+资产字段)`,均不拼接项目 ID。
- 为让模型详情前端拿到模型编码,后端仅补充返回 `modelCode` 字段,不涉及表结构和接口路径变更。
## 后续边界
- 证据卡片「查看详情」本期不做真实跳转,当前抽屉不展示跳转按钮;后续如要定位原记录,可基于 `source_type``source_record_id``snapshot_json` 增加跳转逻辑。
- 跨项目引用/复用 UI 本期不做;当前 `source_record_id` 已按不拼接项目 ID 的规则生成,后续具备按同一来源指纹做跨项目比对的基础。
- 重复证据拦截本期不做;当前允许同一项目内重复确认,后续可按 `project_id + evidence_type + source_type + source_record_id` 增加唯一性提示或软拦截。

View File

@@ -89,11 +89,11 @@ spring:
# 地址
host: 116.62.17.81
# 端口默认为6379
port: 6379
port: 56379
# 数据库索引
database: 0
database: 9
# 密码
password: Kfcx@1234
password: N0f3d12c4a927eee1+
# 连接超时时间
timeout: 10s
lettuce:

View File

@@ -59,6 +59,13 @@
<artifactId>ruoyi-system</artifactId>
</dependency>
<!-- 测试依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
</project>

View File

@@ -2,9 +2,11 @@ package com.ruoyi.framework.config;
import org.springframework.cache.annotation.CachingConfigurerSupport;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@@ -19,6 +21,23 @@ import org.springframework.data.redis.serializer.StringRedisSerializer;
@EnableCaching
public class RedisConfig extends CachingConfigurerSupport
{
@Bean
public static BeanPostProcessor lettuceConnectionFactoryBeanPostProcessor()
{
return new BeanPostProcessor()
{
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName)
{
if (bean instanceof LettuceConnectionFactory lettuceConnectionFactory)
{
lettuceConnectionFactory.setValidateConnection(true);
}
return bean;
}
};
}
@Bean
@SuppressWarnings(value = { "unchecked", "rawtypes" })
public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)

View File

@@ -0,0 +1,27 @@
import request from '@/utils/request'
// 保存证据
export function saveEvidence(data) {
return request({
url: '/ccdi/evidence',
method: 'post',
data
})
}
// 查询项目证据列表
export function listEvidence(params) {
return request({
url: '/ccdi/evidence/list',
method: 'get',
params
})
}
// 查询证据详情
export function getEvidence(evidenceId) {
return request({
url: '/ccdi/evidence/' + evidenceId,
method: 'get'
})
}

View File

@@ -178,14 +178,6 @@ export function importPersonTemplate() {
})
}
// 下载机构中介导入模板
export function importEntityTemplate() {
return request({
url: '/ccdi/intermediary/importEntityTemplate',
method: 'post'
})
}
// 导入个人中介黑名单
export function importPersonData(data, updateSupport) {
return request({
@@ -195,12 +187,11 @@ export function importPersonData(data, updateSupport) {
})
}
// 导入机构中介黑名单
export function importEntityData(data, updateSupport) {
// 下载中介实体关联关系导入模板
export function importEnterpriseRelationTemplate() {
return request({
url: '/ccdi/intermediary/importEntityData?updateSupport=' + updateSupport,
method: 'post',
data: data
url: '/ccdi/intermediary/importEnterpriseRelationTemplate',
method: 'post'
})
}
@@ -221,18 +212,27 @@ export function getPersonImportFailures(taskId, pageNum, pageSize) {
})
}
// 查询实体中介导入状态
export function getEntityImportStatus(taskId) {
// 导入中介实体关联关系
export function importEnterpriseRelationData(data, updateSupport) {
return request({
url: `/ccdi/intermediary/importEntityStatus/${taskId}`,
url: '/ccdi/intermediary/importEnterpriseRelationData?updateSupport=' + updateSupport,
method: 'post',
data: data
})
}
// 查询中介实体关联关系导入状态
export function getEnterpriseRelationImportStatus(taskId) {
return request({
url: `/ccdi/intermediary/importEnterpriseRelationStatus/${taskId}`,
method: 'get'
})
}
// 查询实体中介导入失败记录
export function getEntityImportFailures(taskId, pageNum, pageSize) {
// 查询中介实体关联关系导入失败记录
export function getEnterpriseRelationImportFailures(taskId, pageNum, pageSize) {
return request({
url: `/ccdi/intermediary/importEntityFailures/${taskId}`,
url: `/ccdi/intermediary/importEnterpriseRelationFailures/${taskId}`,
method: 'get',
params: { pageNum, pageSize }
})

View File

@@ -9,7 +9,7 @@ import { isRelogin } from '@/utils/request'
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register', '/prototype/account-library']
const whiteList = ['/login', '/register', '/prototype/account-library', '/prototype/staff-recruitment']
const isWhiteList = (path) => {
return whiteList.some(pattern => isPathMatch(pattern, path))

View File

@@ -85,6 +85,13 @@ export const constantRoutes = [
hidden: true,
meta: { title: '账户库管理原型', noCache: true }
},
{
path: 'prototype/staff-recruitment',
component: () => import('@/views/ccdiStaffRecruitment/index'),
name: 'StaffRecruitmentPrototype',
hidden: true,
meta: { title: '招聘信息预览', noCache: true }
},
{
path: 'ccdiAccountInfo',
component: () => import('@/views/ccdiAccountInfo/index'),

View File

@@ -0,0 +1,78 @@
import md5 from "@/utils/md5";
export const FLOW_EVIDENCE_FINGERPRINT_RULE =
"md5(leAccountNo+leAccountName+customerAccountNo+customerAccountName+trxDate+displayAmount+userMemo)";
export const MODEL_EVIDENCE_FINGERPRINT_RULE = "md5(personIdCard+modelCode)";
export const ASSET_EVIDENCE_FINGERPRINT_RULE =
"md5(staffIdCard+totalIncome+totalDebt+totalAsset+riskLevelCode)";
function normalizeFingerprintValue(value) {
if (value === null || value === undefined) {
return "";
}
return String(value).trim();
}
function resolveCounterpartyName(detail) {
return detail.customerAccountName || detail.customerName || detail.counterpartyName || "";
}
export function buildFlowEvidenceFingerprintSource(detail = {}) {
return [
detail.leAccountNo,
detail.leAccountName,
detail.customerAccountNo,
resolveCounterpartyName(detail),
detail.trxDate,
detail.displayAmount,
detail.userMemo,
]
.map(normalizeFingerprintValue)
.join("");
}
export function buildFlowEvidenceFingerprint(detail = {}) {
const source = buildFlowEvidenceFingerprintSource(detail);
return source ? md5(source) : "";
}
export function buildFlowEvidenceSnapshot(detail = {}) {
const evidenceFingerprint = buildFlowEvidenceFingerprint(detail);
return {
...detail,
evidenceFingerprint,
evidenceFingerprintRule: FLOW_EVIDENCE_FINGERPRINT_RULE,
};
}
export function buildModelEvidenceFingerprint(personIdCard, modelCode) {
const idCard = normalizeFingerprintValue(personIdCard);
const code = normalizeFingerprintValue(modelCode);
return idCard && code ? md5(idCard + code) : "";
}
export function buildAssetEvidenceFingerprint(row = {}) {
const idCard = normalizeFingerprintValue(row.staffIdCard);
const assetSource = [
row.totalIncome,
row.totalDebt,
row.totalAsset,
row.riskLevelCode,
]
.map(normalizeFingerprintValue)
.join("");
return idCard && assetSource ? md5(idCard + assetSource) : "";
}
export function buildAssetEvidenceSnapshot(row = {}, detail = {}, summary = {}) {
const evidenceFingerprint = buildAssetEvidenceFingerprint(row);
return {
row,
detail,
summary,
evidenceFingerprint,
evidenceFingerprintRule: ASSET_EVIDENCE_FINGERPRINT_RULE,
};
}

161
ruoyi-ui/src/utils/md5.js Normal file
View File

@@ -0,0 +1,161 @@
function safeAdd(x, y) {
const lsw = (x & 0xffff) + (y & 0xffff);
const msw = (x >> 16) + (y >> 16) + (lsw >> 16);
return (msw << 16) | (lsw & 0xffff);
}
function rotateLeft(num, cnt) {
return (num << cnt) | (num >>> (32 - cnt));
}
function cmn(q, a, b, x, s, t) {
return safeAdd(rotateLeft(safeAdd(safeAdd(a, q), safeAdd(x, t)), s), b);
}
function ff(a, b, c, d, x, s, t) {
return cmn((b & c) | (~b & d), a, b, x, s, t);
}
function gg(a, b, c, d, x, s, t) {
return cmn((b & d) | (c & ~d), a, b, x, s, t);
}
function hh(a, b, c, d, x, s, t) {
return cmn(b ^ c ^ d, a, b, x, s, t);
}
function ii(a, b, c, d, x, s, t) {
return cmn(c ^ (b | ~d), a, b, x, s, t);
}
function wordsToRaw(input) {
let output = "";
for (let i = 0; i < input.length * 32; i += 8) {
output += String.fromCharCode((input[i >> 5] >>> (i % 32)) & 0xff);
}
return output;
}
function rawToWords(input) {
const output = [];
output[(input.length >> 2) - 1] = undefined;
for (let i = 0; i < output.length; i++) {
output[i] = 0;
}
for (let i = 0; i < input.length * 8; i += 8) {
output[i >> 5] |= (input.charCodeAt(i / 8) & 0xff) << (i % 32);
}
return output;
}
function calculate(words, len) {
words[len >> 5] |= 0x80 << (len % 32);
words[(((len + 64) >>> 9) << 4) + 14] = len;
let a = 1732584193;
let b = -271733879;
let c = -1732584194;
let d = 271733878;
for (let i = 0; i < words.length; i += 16) {
const olda = a;
const oldb = b;
const oldc = c;
const oldd = d;
a = ff(a, b, c, d, words[i], 7, -680876936);
d = ff(d, a, b, c, words[i + 1], 12, -389564586);
c = ff(c, d, a, b, words[i + 2], 17, 606105819);
b = ff(b, c, d, a, words[i + 3], 22, -1044525330);
a = ff(a, b, c, d, words[i + 4], 7, -176418897);
d = ff(d, a, b, c, words[i + 5], 12, 1200080426);
c = ff(c, d, a, b, words[i + 6], 17, -1473231341);
b = ff(b, c, d, a, words[i + 7], 22, -45705983);
a = ff(a, b, c, d, words[i + 8], 7, 1770035416);
d = ff(d, a, b, c, words[i + 9], 12, -1958414417);
c = ff(c, d, a, b, words[i + 10], 17, -42063);
b = ff(b, c, d, a, words[i + 11], 22, -1990404162);
a = ff(a, b, c, d, words[i + 12], 7, 1804603682);
d = ff(d, a, b, c, words[i + 13], 12, -40341101);
c = ff(c, d, a, b, words[i + 14], 17, -1502002290);
b = ff(b, c, d, a, words[i + 15], 22, 1236535329);
a = gg(a, b, c, d, words[i + 1], 5, -165796510);
d = gg(d, a, b, c, words[i + 6], 9, -1069501632);
c = gg(c, d, a, b, words[i + 11], 14, 643717713);
b = gg(b, c, d, a, words[i], 20, -373897302);
a = gg(a, b, c, d, words[i + 5], 5, -701558691);
d = gg(d, a, b, c, words[i + 10], 9, 38016083);
c = gg(c, d, a, b, words[i + 15], 14, -660478335);
b = gg(b, c, d, a, words[i + 4], 20, -405537848);
a = gg(a, b, c, d, words[i + 9], 5, 568446438);
d = gg(d, a, b, c, words[i + 14], 9, -1019803690);
c = gg(c, d, a, b, words[i + 3], 14, -187363961);
b = gg(b, c, d, a, words[i + 8], 20, 1163531501);
a = gg(a, b, c, d, words[i + 13], 5, -1444681467);
d = gg(d, a, b, c, words[i + 2], 9, -51403784);
c = gg(c, d, a, b, words[i + 7], 14, 1735328473);
b = gg(b, c, d, a, words[i + 12], 20, -1926607734);
a = hh(a, b, c, d, words[i + 5], 4, -378558);
d = hh(d, a, b, c, words[i + 8], 11, -2022574463);
c = hh(c, d, a, b, words[i + 11], 16, 1839030562);
b = hh(b, c, d, a, words[i + 14], 23, -35309556);
a = hh(a, b, c, d, words[i + 1], 4, -1530992060);
d = hh(d, a, b, c, words[i + 4], 11, 1272893353);
c = hh(c, d, a, b, words[i + 7], 16, -155497632);
b = hh(b, c, d, a, words[i + 10], 23, -1094730640);
a = hh(a, b, c, d, words[i + 13], 4, 681279174);
d = hh(d, a, b, c, words[i], 11, -358537222);
c = hh(c, d, a, b, words[i + 3], 16, -722521979);
b = hh(b, c, d, a, words[i + 6], 23, 76029189);
a = hh(a, b, c, d, words[i + 9], 4, -640364487);
d = hh(d, a, b, c, words[i + 12], 11, -421815835);
c = hh(c, d, a, b, words[i + 15], 16, 530742520);
b = hh(b, c, d, a, words[i + 2], 23, -995338651);
a = ii(a, b, c, d, words[i], 6, -198630844);
d = ii(d, a, b, c, words[i + 7], 10, 1126891415);
c = ii(c, d, a, b, words[i + 14], 15, -1416354905);
b = ii(b, c, d, a, words[i + 5], 21, -57434055);
a = ii(a, b, c, d, words[i + 12], 6, 1700485571);
d = ii(d, a, b, c, words[i + 3], 10, -1894986606);
c = ii(c, d, a, b, words[i + 10], 15, -1051523);
b = ii(b, c, d, a, words[i + 1], 21, -2054922799);
a = ii(a, b, c, d, words[i + 8], 6, 1873313359);
d = ii(d, a, b, c, words[i + 15], 10, -30611744);
c = ii(c, d, a, b, words[i + 6], 15, -1560198380);
b = ii(b, c, d, a, words[i + 13], 21, 1309151649);
a = ii(a, b, c, d, words[i + 4], 6, -145523070);
d = ii(d, a, b, c, words[i + 11], 10, -1120210379);
c = ii(c, d, a, b, words[i + 2], 15, 718787259);
b = ii(b, c, d, a, words[i + 9], 21, -343485551);
a = safeAdd(a, olda);
b = safeAdd(b, oldb);
c = safeAdd(c, oldc);
d = safeAdd(d, oldd);
}
return [a, b, c, d];
}
function rawToHex(input) {
const hex = "0123456789abcdef";
let output = "";
for (let i = 0; i < input.length; i++) {
const x = input.charCodeAt(i);
output += hex.charAt((x >>> 4) & 0x0f) + hex.charAt(x & 0x0f);
}
return output;
}
function toUtf8Raw(input) {
return unescape(encodeURIComponent(input));
}
export default function md5(input) {
const value = input === null || input === undefined ? "" : String(input);
const raw = toUtf8Raw(value);
return rawToHex(wordsToRaw(calculate(rawToWords(raw), raw.length * 8)));
}

View File

@@ -8,6 +8,7 @@
<el-descriptions-item label="姓名">{{ detailData.name || '-' }}</el-descriptions-item>
<el-descriptions-item label="证件号">{{ detailData.personId || '-' }}</el-descriptions-item>
<el-descriptions-item label="人员类型">{{ detailData.personType || '-' }}</el-descriptions-item>
<el-descriptions-item label="中介子类型">{{ detailData.personSubType || detailData.relationType || '-' }}</el-descriptions-item>
<el-descriptions-item label="性别">{{ formatGender(detailData.gender) }}</el-descriptions-item>
<el-descriptions-item label="证件类型">{{ detailData.idType || '-' }}</el-descriptions-item>
<el-descriptions-item label="职位">{{ detailData.position || '-' }}</el-descriptions-item>

View File

@@ -26,6 +26,27 @@
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<!-- v-model="form.personSubType" -->
<el-form-item label="中介子类型">
<el-select
v-model="localForm.personSubType"
placeholder="请选择中介子类型"
clearable
style="width: 100%"
@change="handlePersonSubTypeChange"
>
<el-option
v-for="item in personSubTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row>
<el-col :span="12">
<el-form-item label="证件类型">
<el-select v-model="localForm.idType" placeholder="请选择证件类型" clearable style="width: 100%">
@@ -131,6 +152,10 @@ export default {
certTypeOptions: {
type: Array,
default: () => []
},
personSubTypeOptions: {
type: Array,
default: () => []
}
},
data() {
@@ -162,6 +187,12 @@ export default {
}
},
methods: {
handlePersonSubTypeChange(value) {
const typeMappings = [
{ label: '个人', value: '本人' }
];
return typeMappings.find(item => item.value === value) || null;
},
handleSubmit() {
this.$refs.formRef.validate(valid => {
if (valid) {

View File

@@ -1,6 +1,5 @@
<template>
<div>
<!-- 导入对话框 -->
<el-dialog
:title="title"
:visible.sync="visible"
@@ -13,22 +12,18 @@
:close-on-press-escape="false"
custom-class="import-dialog-wrapper"
>
<!-- 全屏Loading遮罩层 -->
<div v-show="isUploading" class="import-loading-overlay">
<i class="el-icon-loading"></i>
<p>正在导入中,请稍候...</p>
</div>
<el-form :model="formData" label-position="top" size="medium">
<!-- 导入类型 -->
<el-form-item label="导入类型">
<el-radio-group v-model="formData.importType" @change="handleImportTypeChange" style="width: 100%">
<el-radio label="person" border>个人中介</el-radio>
<el-radio label="entity" border>机构中介</el-radio>
</el-radio-group>
<el-form label-position="top" size="medium">
<el-form-item label="导入说明">
<div class="scene-tips">
<p v-for="item in sceneTips" :key="item">{{ item }}</p>
</div>
</el-form-item>
<!-- 文件上传 -->
<el-form-item label="选择文件">
<el-upload
ref="upload"
@@ -53,7 +48,6 @@
</el-upload>
</el-form-item>
<!-- 下载模板 -->
<el-form-item>
<el-link type="primary" :underline="false" @click="handleDownloadTemplate">
<i class="el-icon-download"></i>
@@ -62,7 +56,6 @@
</el-form-item>
</el-form>
<!-- 底部按钮 -->
<div slot="footer" class="dialog-footer">
<el-button
type="primary"
@@ -79,7 +72,6 @@
</div>
</el-dialog>
<!-- 导入结果对话框 -->
<import-result-dialog
:visible.sync="importResultVisible"
:content="importResultContent"
@@ -90,10 +82,16 @@
</template>
<script>
import {getToken} from "@/utils/auth";
import {getEntityImportStatus, getPersonImportStatus} from "@/api/ccdiIntermediary";
import { getToken } from "@/utils/auth";
import {
getEnterpriseRelationImportStatus,
getPersonImportStatus
} from "@/api/ccdiIntermediary";
import ImportResultDialog from "@/components/ImportResultDialog.vue";
const PERSON_SCENE = "person";
const ENTERPRISE_RELATION_SCENE = "enterpriseRelation";
export default {
name: "ImportDialog",
components: { ImportResultDialog },
@@ -105,32 +103,55 @@ export default {
title: {
type: String,
default: "数据导入"
},
scene: {
type: String,
default: PERSON_SCENE
}
},
data() {
return {
formData: {
importType: "person"
},
headers: { Authorization: "Bearer " + getToken() },
isUploading: false,
isFileSelected: false,
// 导入结果弹窗
importResultVisible: false,
importResultContent: "",
// 轮询状态
pollingTimer: null,
currentTaskId: null
};
},
computed: {
sceneConfig() {
if (this.scene === ENTERPRISE_RELATION_SCENE) {
return {
uploadPath: "/ccdi/intermediary/importEnterpriseRelationData",
templatePath: "ccdi/intermediary/importEnterpriseRelationTemplate",
templateName: "中介实体关联关系导入模板",
statusApi: getEnterpriseRelationImportStatus,
tips: [
"只导入中介与机构关系;",
"统一社会信用代码必须已存在于系统机构表。"
]
};
}
return {
uploadPath: "/ccdi/intermediary/importPersonData",
templatePath: "ccdi/intermediary/importPersonTemplate",
templateName: "中介和亲属信息导入模板",
statusApi: getPersonImportStatus,
tips: [
"personSubType 为字典下拉;",
"本人行 relatedNumId 为空;",
"亲属行 relatedNumId 填关联中介本人证件号码。"
]
};
},
uploadUrl() {
const baseUrl = process.env.VUE_APP_BASE_API;
if (this.formData.importType === 'person') {
return `${baseUrl}/ccdi/intermediary/importPersonData`;
} else {
return `${baseUrl}/ccdi/intermediary/importEntityData`;
}
return `${baseUrl}${this.sceneConfig.uploadPath}`;
},
sceneTips() {
return this.sceneConfig.tips;
}
},
methods: {
@@ -145,21 +166,14 @@ export default {
this.$emit("close");
},
handleCancel() {
// 通过 $emit 通知父组件更新 visible 状态,而不是直接修改 prop
this.$emit('update:visible', false);
},
handleImportTypeChange() {
if (this.$refs.upload) {
this.$refs.upload.clearFiles();
}
this.isFileSelected = false;
this.$emit("update:visible", false);
},
handleDownloadTemplate() {
if (this.formData.importType === 'person') {
this.download('ccdi/intermediary/importPersonTemplate', {}, `个人中介黑名单模板_${new Date().getTime()}.xlsx`);
} else {
this.download('ccdi/intermediary/importEntityTemplate', {}, `机构中介黑名单模板_${new Date().getTime()}.xlsx`);
}
this.download(
this.sceneConfig.templatePath,
{},
`${this.sceneConfig.templateName}_${new Date().getTime()}.xlsx`
);
},
handleFileUploadProgress() {
this.isUploading = true;
@@ -177,28 +191,30 @@ export default {
const taskId = response.data.taskId;
this.currentTaskId = taskId;
// 显示通知
this.$notify({
title: '导入任务已提交',
message: '正在后台处理中,处理完成后将通知您',
type: 'info',
title: "导入任务已提交",
message: "正在后台处理中,处理完成后将通知您",
type: "info",
duration: 3000
});
// 关闭对话框 - 使用$emit更新父组件的visible
this.$emit('update:visible', false);
this.$refs.upload.clearFiles();
this.$emit("task-created", {
scene: this.scene,
taskId,
status: "PROCESSING"
});
this.$emit("update:visible", false);
this.$emit("success", { scene: this.scene, taskId });
// 通知父组件刷新列表
this.$emit("success");
if (this.$refs.upload) {
this.$refs.upload.clearFiles();
}
// 开始轮询
this.startImportStatusPolling(taskId);
} else {
this.$modal.msgError(response.msg || '导入失败');
this.$modal.msgError(response.msg || "导入失败");
}
},
// 导入结果弹窗关闭
handleImportResultClose() {
this.importResultVisible = false;
this.importResultContent = "";
@@ -206,77 +222,65 @@ export default {
handleFileError() {
this.isUploading = false;
this.$modal.msgError("导入失败,请检查文件格式是否正确");
this.$refs.upload.clearFiles();
if (this.$refs.upload) {
this.$refs.upload.clearFiles();
}
},
handleSubmit() {
// 触发清除历史记录事件
this.$emit('clear-import-history', this.formData.importType);
// 提交文件上传
this.$emit("clear-import-history", this.scene);
this.$refs.upload.submit();
},
/** 开始轮询导入状态 */
startImportStatusPolling(taskId) {
let pollCount = 0;
const maxPolls = 150; // 最多5分钟
const maxPolls = 150;
this.pollingTimer = setInterval(async () => {
try {
pollCount++;
if (pollCount > maxPolls) {
clearInterval(this.pollingTimer);
this.$modal.msgWarning('导入任务处理超时,请联系管理员');
this.$modal.msgWarning("导入任务处理超时,请联系管理员");
return;
}
// 根据导入类型调用不同的API
const apiMethod = this.formData.importType === 'person'
? getPersonImportStatus
: getEntityImportStatus;
const response = await apiMethod(taskId);
if (response.data && response.data.status !== 'PROCESSING') {
const response = await this.sceneConfig.statusApi(taskId);
if (response.data && response.data.status !== "PROCESSING") {
clearInterval(this.pollingTimer);
this.handleImportComplete(response.data);
}
} catch (error) {
clearInterval(this.pollingTimer);
this.$modal.msgError('查询导入状态失败: ' + error.message);
this.$modal.msgError("查询导入状态失败: " + error.message);
}
}, 2000); // 每2秒轮询一次
}, 2000);
},
/** 处理导入完成 */
handleImportComplete(statusResult) {
if (statusResult.status === 'SUCCESS') {
if (statusResult.status === "SUCCESS") {
this.$notify({
title: '导入完成',
title: "导入完成",
message: `全部成功!共导入${statusResult.totalCount}条数据`,
type: 'success',
type: "success",
duration: 5000
});
} else if (statusResult.failureCount > 0) {
this.$notify({
title: '导入完成',
title: "导入完成",
message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}`,
type: 'warning',
type: "warning",
duration: 5000
});
}
// 通知父组件更新失败记录状态
this.$emit("import-complete", {
scene: this.scene,
taskId: statusResult.taskId,
hasFailures: statusResult.failureCount > 0,
importType: this.formData.importType,
totalCount: statusResult.totalCount,
successCount: statusResult.successCount,
failureCount: statusResult.failureCount
});
}
},
/** 组件销毁时清除定时器 */
beforeDestroy() {
if (this.pollingTimer) {
clearInterval(this.pollingTimer);
@@ -292,35 +296,6 @@ export default {
margin-bottom: 22px;
}
.el-radio-group {
display: flex;
.el-radio {
flex: 1;
text-align: center;
margin-right: 0;
&:first-child {
.el-radio__label {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
}
&:last-child {
.el-radio__label {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
}
&.is-bordered {
padding: 10px 0;
height: auto;
}
}
}
.el-upload {
width: 100%;
@@ -355,6 +330,18 @@ export default {
}
}
.scene-tips {
padding: 12px 14px;
background: #f5f7fa;
border-radius: 4px;
color: #606266;
line-height: 1.7;
p {
margin: 0;
}
}
.dialog-footer {
text-align: center;
padding: 5px 0 0;

View File

@@ -17,6 +17,44 @@
v-hasPermi="['ccdi:intermediary:add']"
>新增</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-upload2"
size="mini"
@click="handleOpenPersonImport"
v-hasPermi="['ccdi:intermediary:import']"
>导入中介和亲属信息</el-button>
</el-col>
<el-col :span="1.5">
<el-button
type="success"
plain
icon="el-icon-connection"
size="mini"
@click="handleOpenEnterpriseRelationImport"
v-hasPermi="['ccdi:intermediary:import']"
>导入中介实体关联关系</el-button>
</el-col>
<el-col :span="1.5" v-if="personImportTask && personImportTask.failureCount > 0">
<el-button
type="warning"
plain
icon="el-icon-warning"
size="mini"
@click="viewPersonImportFailures"
>查看中介和亲属信息导入失败记录</el-button>
</el-col>
<el-col :span="1.5" v-if="enterpriseRelationImportTask && enterpriseRelationImportTask.failureCount > 0">
<el-button
type="warning"
plain
icon="el-icon-warning-outline"
size="mini"
@click="viewEnterpriseRelationImportFailures"
>查看中介实体关联关系导入失败记录</el-button>
</el-col>
<right-toolbar :showSearch.sync="showSearch" @queryTable="getList" />
</el-row>
@@ -36,6 +74,7 @@
:visible.sync="personDialogVisible"
:title="personDialogTitle"
:form="personForm"
:person-sub-type-options="relationTypeOptions"
:indiv-type-options="indivTypeOptions"
:gender-options="genderOptions"
:cert-type-options="certTypeOptions"
@@ -80,6 +119,87 @@
@edit-enterprise-relation="handleEditEnterpriseRelationFromDetail"
@delete-enterprise-relation="handleDelete"
/>
<import-dialog
:visible.sync="importDialogVisible"
:title="importDialogTitle"
:scene="importScene"
@task-created="handleImportTaskCreated"
@success="handleImportDialogSuccess"
@import-complete="handleImportComplete"
@clear-import-history="handleClearImportHistory"
/>
<el-dialog
title="中介和亲属信息导入失败记录"
:visible.sync="personFailureDialogVisible"
width="1200px"
append-to-body
>
<el-alert
v-if="personLastImportInfo"
:title="personLastImportInfo"
type="info"
:closable="false"
style="margin-bottom: 15px"
/>
<el-table :data="personFailureList" v-loading="personFailureLoading">
<el-table-column label="姓名" prop="name" align="center" />
<el-table-column label="人员子类型" prop="personSubType" align="center" />
<el-table-column label="证件号码" prop="personId" align="center" min-width="180" />
<el-table-column label="关联中介本人证件号码" prop="relatedNumId" align="center" min-width="180" />
<el-table-column label="失败原因" prop="errorMessage" align="center" min-width="220" :show-overflow-tooltip="true" />
</el-table>
<pagination
v-show="personFailureTotal > 0"
:total="personFailureTotal"
:page.sync="personFailureQueryParams.pageNum"
:limit.sync="personFailureQueryParams.pageSize"
@pagination="getPersonFailureList"
/>
<div slot="footer" class="dialog-footer">
<el-button @click="personFailureDialogVisible = false">关闭</el-button>
<el-button type="danger" plain @click="clearPersonImportHistory">清除历史记录</el-button>
</div>
</el-dialog>
<el-dialog
title="中介实体关联关系导入失败记录"
:visible.sync="enterpriseRelationFailureDialogVisible"
width="1200px"
append-to-body
>
<el-alert
v-if="enterpriseRelationLastImportInfo"
:title="enterpriseRelationLastImportInfo"
type="info"
:closable="false"
style="margin-bottom: 15px"
/>
<el-table :data="enterpriseRelationFailureList" v-loading="enterpriseRelationFailureLoading">
<el-table-column label="中介本人证件号码" prop="ownerPersonId" align="center" min-width="180" />
<el-table-column label="统一社会信用代码" prop="socialCreditCode" align="center" min-width="180" />
<el-table-column label="关联人职务" prop="relationPersonPost" align="center" />
<el-table-column label="失败原因" prop="errorMessage" align="center" min-width="220" :show-overflow-tooltip="true" />
</el-table>
<pagination
v-show="enterpriseRelationFailureTotal > 0"
:total="enterpriseRelationFailureTotal"
:page.sync="enterpriseRelationFailureQueryParams.pageNum"
:limit.sync="enterpriseRelationFailureQueryParams.pageSize"
@pagination="getEnterpriseRelationFailureList"
/>
<div slot="footer" class="dialog-footer">
<el-button @click="enterpriseRelationFailureDialogVisible = false">关闭</el-button>
<el-button type="danger" plain @click="clearEnterpriseRelationImportHistory">清除历史记录</el-button>
</div>
</el-dialog>
</div>
</template>
@@ -91,8 +211,12 @@ import {
delIntermediary,
delIntermediaryEnterpriseRelation,
delIntermediaryRelative,
getEnterpriseRelationImportFailures,
getEnterpriseRelationImportStatus,
getIntermediaryEnterpriseRelation,
getIntermediaryRelative,
getPersonImportFailures,
getPersonImportStatus,
getPersonIntermediary,
listIntermediary,
listIntermediaryEnterpriseRelations,
@@ -113,6 +237,10 @@ import EditDialog from "./components/EditDialog";
import DetailDialog from "./components/DetailDialog";
import RelativeEditDialog from "./components/RelativeEditDialog";
import EnterpriseRelationEditDialog from "./components/EnterpriseRelationEditDialog";
import ImportDialog from "./components/ImportDialog";
const PERSON_SCENE = "person";
const ENTERPRISE_RELATION_SCENE = "enterpriseRelation";
export default {
name: "Intermediary",
@@ -122,7 +250,8 @@ export default {
EditDialog,
DetailDialog,
RelativeEditDialog,
EnterpriseRelationEditDialog
EnterpriseRelationEditDialog,
ImportDialog
},
data() {
return {
@@ -159,7 +288,32 @@ export default {
relativeList: [],
enterpriseRelationList: [],
currentIntermediaryId: null,
currentOwnerName: ""
currentOwnerName: "",
importDialogVisible: false,
importDialogTitle: "",
importScene: PERSON_SCENE,
personImportTask: null,
enterpriseRelationImportTask: null,
personImportPollingTimer: null,
enterpriseRelationImportPollingTimer: null,
personFailureDialogVisible: false,
personFailureList: [],
personFailureLoading: false,
personFailureTotal: 0,
personFailureQueryParams: {
pageNum: 1,
pageSize: 10
},
personLastImportInfo: "",
enterpriseRelationFailureDialogVisible: false,
enterpriseRelationFailureList: [],
enterpriseRelationFailureLoading: false,
enterpriseRelationFailureTotal: 0,
enterpriseRelationFailureQueryParams: {
pageNum: 1,
pageSize: 10
},
enterpriseRelationLastImportInfo: ""
};
},
created() {
@@ -168,6 +322,11 @@ export default {
this.resetEnterpriseRelationForm();
this.getList();
this.loadEnumOptions();
this.restoreImportTasks();
},
beforeDestroy() {
this.clearImportPolling(PERSON_SCENE);
this.clearImportPolling(ENTERPRISE_RELATION_SCENE);
},
methods: {
loadEnumOptions() {
@@ -252,6 +411,206 @@ export default {
this.personDialogTitle = "新增中介本人";
this.personDialogVisible = true;
},
handleOpenPersonImport() {
this.importScene = PERSON_SCENE;
this.importDialogTitle = "导入中介和亲属信息";
this.importDialogVisible = true;
},
handleOpenEnterpriseRelationImport() {
this.importScene = ENTERPRISE_RELATION_SCENE;
this.importDialogTitle = "导入中介实体关联关系";
this.importDialogVisible = true;
},
handleImportDialogSuccess() {
// 导入任务创建后由任务轮询负责状态更新
},
handleImportTaskCreated(payload) {
const task = {
...payload,
failureCount: 0,
successCount: 0,
totalCount: 0,
saveTime: Date.now()
};
this.setImportTask(payload.scene, task);
this.saveImportTaskToStorage(payload.scene, task);
},
handleImportComplete(payload) {
const task = {
...payload,
status: payload.hasFailures ? "PARTIAL_SUCCESS" : "SUCCESS",
saveTime: Date.now()
};
this.setImportTask(payload.scene, task);
this.saveImportTaskToStorage(payload.scene, task);
if (payload.scene === PERSON_SCENE) {
this.personLastImportInfo = this.buildImportSummary(task);
} else {
this.enterpriseRelationLastImportInfo = this.buildImportSummary(task);
}
this.getList();
this.refreshCurrentDetail();
},
handleClearImportHistory(scene) {
this.clearImportTaskFromStorage(scene);
this.setImportTask(scene, null);
if (scene === PERSON_SCENE) {
this.personFailureList = [];
this.personFailureTotal = 0;
this.personLastImportInfo = "";
} else {
this.enterpriseRelationFailureList = [];
this.enterpriseRelationFailureTotal = 0;
this.enterpriseRelationLastImportInfo = "";
}
},
setImportTask(scene, task) {
if (scene === PERSON_SCENE) {
this.personImportTask = task;
return;
}
this.enterpriseRelationImportTask = task;
},
getImportTask(scene) {
return scene === PERSON_SCENE ? this.personImportTask : this.enterpriseRelationImportTask;
},
getImportStorageKey(scene) {
if (scene === PERSON_SCENE) {
return "ccdi_intermediary_person_import_task";
}
return "ccdi_intermediary_enterprise_relation_import_task";
},
saveImportTaskToStorage(scene, task) {
try {
localStorage.setItem(this.getImportStorageKey(scene), JSON.stringify(task));
} catch (error) {
console.error("保存中介导入任务状态失败:", error);
}
},
getImportTaskFromStorage(scene) {
try {
const raw = localStorage.getItem(this.getImportStorageKey(scene));
return raw ? JSON.parse(raw) : null;
} catch (error) {
console.error("读取中介导入任务状态失败:", error);
return null;
}
},
clearImportTaskFromStorage(scene) {
try {
localStorage.removeItem(this.getImportStorageKey(scene));
} catch (error) {
console.error("清除中介导入任务状态失败:", error);
}
this.clearImportPolling(scene);
},
restoreImportTasks() {
[PERSON_SCENE, ENTERPRISE_RELATION_SCENE].forEach((scene) => {
const task = this.getImportTaskFromStorage(scene);
if (!task) {
return;
}
this.setImportTask(scene, task);
if (scene === PERSON_SCENE) {
this.personLastImportInfo = this.buildImportSummary(task);
} else {
this.enterpriseRelationLastImportInfo = this.buildImportSummary(task);
}
if (task.status === "PROCESSING") {
this.resumeImportPolling(scene, task.taskId);
}
});
},
resumeImportPolling(scene, taskId) {
this.clearImportPolling(scene);
const timerKey = scene === PERSON_SCENE ? "personImportPollingTimer" : "enterpriseRelationImportPollingTimer";
const getStatus = scene === PERSON_SCENE ? getPersonImportStatus : getEnterpriseRelationImportStatus;
let pollCount = 0;
this[timerKey] = setInterval(async () => {
pollCount++;
if (pollCount > 150) {
this.clearImportPolling(scene);
return;
}
const response = await getStatus(taskId);
if (response.data && response.data.status !== "PROCESSING") {
this.clearImportPolling(scene);
this.handleImportComplete({
scene,
taskId: response.data.taskId,
hasFailures: response.data.failureCount > 0,
totalCount: response.data.totalCount,
successCount: response.data.successCount,
failureCount: response.data.failureCount
});
}
});
},
clearImportPolling(scene) {
const timerKey = scene === PERSON_SCENE ? "personImportPollingTimer" : "enterpriseRelationImportPollingTimer";
if (this[timerKey]) {
clearInterval(this[timerKey]);
this[timerKey] = null;
}
},
buildImportSummary(task) {
if (!task) {
return "";
}
return `任务ID: ${task.taskId},总数 ${task.totalCount || 0} 条,成功 ${task.successCount || 0} 条,失败 ${task.failureCount || 0}`;
},
viewPersonImportFailures() {
this.personFailureDialogVisible = true;
this.personFailureQueryParams.pageNum = 1;
this.personLastImportInfo = this.buildImportSummary(this.personImportTask);
this.getPersonFailureList();
},
getPersonFailureList() {
if (!this.personImportTask) {
return;
}
this.personFailureLoading = true;
getPersonImportFailures(
this.personImportTask.taskId,
this.personFailureQueryParams.pageNum,
this.personFailureQueryParams.pageSize
).then((response) => {
this.personFailureList = response.rows || [];
this.personFailureTotal = response.total || 0;
}).finally(() => {
this.personFailureLoading = false;
});
},
clearPersonImportHistory() {
this.personFailureDialogVisible = false;
this.handleClearImportHistory(PERSON_SCENE);
},
viewEnterpriseRelationImportFailures() {
this.enterpriseRelationFailureDialogVisible = true;
this.enterpriseRelationFailureQueryParams.pageNum = 1;
this.enterpriseRelationLastImportInfo = this.buildImportSummary(this.enterpriseRelationImportTask);
this.getEnterpriseRelationFailureList();
},
getEnterpriseRelationFailureList() {
if (!this.enterpriseRelationImportTask) {
return;
}
this.enterpriseRelationFailureLoading = true;
getEnterpriseRelationImportFailures(
this.enterpriseRelationImportTask.taskId,
this.enterpriseRelationFailureQueryParams.pageNum,
this.enterpriseRelationFailureQueryParams.pageSize
).then((response) => {
this.enterpriseRelationFailureList = response.rows || [];
this.enterpriseRelationFailureTotal = response.total || 0;
}).finally(() => {
this.enterpriseRelationFailureLoading = false;
});
},
clearEnterpriseRelationImportHistory() {
this.enterpriseRelationFailureDialogVisible = false;
this.handleClearImportHistory(ENTERPRISE_RELATION_SCENE);
},
handleDetail(row) {
if (row.recordType === "INTERMEDIARY") {
this.openIntermediaryDetail(row.recordId);

View File

@@ -283,10 +283,23 @@
:visible.sync="detailVisible"
append-to-body
custom-class="detail-dialog"
title="流水详情"
width="980px"
@close="closeDetailDialog"
>
<template slot="title">
<div class="detail-dialog-title">
<span>流水详情</span>
<el-button
class="evidence-corner-btn"
size="mini"
plain
:disabled="detailLoading || !buildFlowEvidenceFingerprint(detailData)"
@click="handleAddEvidence"
>
加入证据库
</el-button>
</div>
</template>
<div v-loading="detailLoading" class="detail-dialog-body">
<div class="detail-overview-grid">
<div class="detail-field">
@@ -394,6 +407,7 @@ import {
getBankStatementOptions,
getBankStatementDetail,
} from "@/api/ccdiProjectBankStatement";
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
const TAB_MAP = {
all: "all",
@@ -518,6 +532,7 @@ export default {
},
},
methods: {
buildFlowEvidenceFingerprint,
async getList() {
this.syncProjectId();
if (!this.queryParams.projectId) {
@@ -638,6 +653,29 @@ export default {
this.detailLoading = false;
this.detailData = createEmptyDetailData();
},
handleAddEvidence() {
const detail = this.detailData || {};
const sourceRecordId = buildFlowEvidenceFingerprint(detail);
const amountText = this.formatSignedAmount(detail.displayAmount);
const counterparty = this.formatCounterpartyName(detail);
const hitTagText = Array.isArray(detail.hitTags) && detail.hitTags.length
? `,命中${detail.hitTags.map((tag) => tag.ruleName).filter(Boolean).join("、")}标签`
: "";
this.$emit("evidence-confirm", {
evidenceType: "FLOW",
relatedPersonName: this.resolveFlowRelatedPerson(detail),
relatedPersonId: detail.cretNo || "",
evidenceTitle: `${this.resolveFlowRelatedPerson(detail)} / ${this.formatField(detail.leAccountNo)}`,
evidenceSummary: `${this.formatField(detail.trxDate)}${this.resolveFlowRelatedPerson(detail)}账户与${counterparty}发生交易,金额${amountText}${hitTagText}`,
sourceType: "BANK_STATEMENT",
sourceRecordId,
sourcePage: "流水详情",
snapshotJson: JSON.stringify(buildFlowEvidenceSnapshot(detail)),
});
},
resolveFlowRelatedPerson(detail) {
return this.formatField(detail.leAccountName) === "-" ? "关联人员" : this.formatField(detail.leAccountName);
},
handleExport() {
if (this.total === 0) {
return;
@@ -751,6 +789,26 @@ export default {
gap: 12px;
}
.detail-dialog-title {
display: inline-flex;
align-items: center;
gap: 10px;
}
.evidence-corner-btn {
padding: 4px 9px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: #2474e8;
border-color: #9fc3ff;
background: #edf5ff;
}
}
.shell-sidebar,
.shell-main {
border: 1px solid #ebeef5;

View File

@@ -0,0 +1,136 @@
<template>
<el-dialog
:visible.sync="dialogVisible"
append-to-body
title="确认为证据"
width="560px"
:close-on-click-modal="false"
@close="handleClose"
>
<el-form label-position="top" class="evidence-confirm-form">
<el-form-item label="证据类型">
<el-input :value="typeLabel" readonly />
</el-form-item>
<el-form-item label="关联人员">
<el-input :value="payload.relatedPersonName || '-'" readonly />
</el-form-item>
<el-form-item label="证据摘要">
<el-input
:value="payload.evidenceSummary || '-'"
readonly
type="textarea"
:rows="3"
/>
</el-form-item>
<el-form-item label="确认理由/备注" required>
<el-input
v-model.trim="confirmReason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请填写为什么将该详情确认为证据"
/>
</el-form-item>
</el-form>
<div slot="footer">
<el-button @click="handleClose">取消</el-button>
<el-button type="primary" :loading="submitting" @click="handleSubmit">
确认入库
</el-button>
</div>
</el-dialog>
</template>
<script>
import { saveEvidence } from "@/api/ccdiEvidence";
const TYPE_LABEL_MAP = {
FLOW: "流水证据",
MODEL: "模型证据",
ASSET: "资产证据",
};
export default {
name: "EvidenceConfirmDialog",
props: {
visible: {
type: Boolean,
default: false,
},
payload: {
type: Object,
default: () => ({}),
},
},
data() {
return {
confirmReason: "",
submitting: false,
};
},
computed: {
dialogVisible: {
get() {
return this.visible;
},
set(value) {
if (!value) {
this.handleClose();
}
},
},
typeLabel() {
return TYPE_LABEL_MAP[this.payload.evidenceType] || this.payload.evidenceType || "-";
},
},
watch: {
visible(value) {
if (value) {
this.confirmReason = "";
}
},
},
methods: {
handleClose() {
if (this.submitting) {
return;
}
this.$emit("update:visible", false);
},
async handleSubmit() {
if (!this.confirmReason) {
this.$message.warning("请填写确认理由/备注");
return;
}
this.submitting = true;
try {
const data = {
...this.payload,
confirmReason: this.confirmReason,
};
const response = await saveEvidence(data);
this.$message.success("证据入库成功");
this.$emit("saved", response.data);
this.$emit("update:visible", false);
} catch (error) {
this.$message.error("证据入库失败");
console.error("证据入库失败", error);
} finally {
this.submitting = false;
}
},
},
};
</script>
<style lang="scss" scoped>
.evidence-confirm-form {
:deep(.el-form-item__label) {
padding-bottom: 6px;
font-weight: 600;
color: #606266;
}
}
</style>

View File

@@ -0,0 +1,180 @@
<template>
<el-drawer
:visible.sync="drawerVisible"
append-to-body
title="证据线索"
size="420px"
custom-class="evidence-drawer"
@open="loadEvidence"
>
<div class="evidence-drawer-body">
<el-input
v-model.trim="keyword"
clearable
size="small"
prefix-icon="el-icon-search"
placeholder="搜索姓名、账号、证据编号"
@keyup.enter.native="loadEvidence"
@clear="loadEvidence"
>
<el-button slot="append" @click="loadEvidence">查询</el-button>
</el-input>
<div class="evidence-list" v-loading="loading">
<el-empty
v-if="!loading && evidenceList.length === 0"
:image-size="80"
description="暂无证据线索"
/>
<article
v-for="item in evidenceList"
:key="item.evidenceId"
class="evidence-card"
>
<div class="evidence-card__header">
<span class="evidence-code">EV-{{ formatEvidenceId(item.evidenceId) }} {{ formatType(item.evidenceType) }}</span>
<el-tag size="mini" type="danger" effect="plain">确认可疑</el-tag>
</div>
<div class="evidence-title">{{ item.evidenceTitle }}</div>
<div class="evidence-summary">{{ item.evidenceSummary }}</div>
<div class="evidence-meta">
来源{{ item.sourcePage || formatSource(item.sourceType) }}确认人{{ item.confirmBy || "-" }}
</div>
<div v-if="item.confirmReason" class="evidence-meta">
备注{{ item.confirmReason }}
</div>
</article>
</div>
</div>
</el-drawer>
</template>
<script>
import { listEvidence } from "@/api/ccdiEvidence";
const TYPE_LABEL_MAP = {
FLOW: "流水证据",
MODEL: "模型证据",
ASSET: "资产证据",
};
const SOURCE_LABEL_MAP = {
BANK_STATEMENT: "流水详情",
MODEL_DETAIL: "模型详情",
ASSET_DETAIL: "资产详情",
};
export default {
name: "EvidenceDrawer",
props: {
visible: {
type: Boolean,
default: false,
},
projectId: {
type: [String, Number],
default: null,
},
},
data() {
return {
keyword: "",
loading: false,
evidenceList: [],
};
},
computed: {
drawerVisible: {
get() {
return this.visible;
},
set(value) {
this.$emit("update:visible", value);
},
},
},
methods: {
async loadEvidence() {
if (!this.projectId) {
this.evidenceList = [];
return;
}
this.loading = true;
try {
const response = await listEvidence({
projectId: this.projectId,
keyword: this.keyword,
});
this.evidenceList = response.data || [];
} catch (error) {
this.evidenceList = [];
this.$message.error("加载证据线索失败");
console.error("加载证据线索失败", error);
} finally {
this.loading = false;
}
},
formatEvidenceId(value) {
return String(value || "").padStart(3, "0");
},
formatType(type) {
return TYPE_LABEL_MAP[type] || type || "证据";
},
formatSource(sourceType) {
return SOURCE_LABEL_MAP[sourceType] || sourceType || "-";
},
},
};
</script>
<style lang="scss" scoped>
.evidence-drawer-body {
padding: 0 16px 16px;
}
.evidence-list {
margin-top: 14px;
}
.evidence-card {
padding: 14px;
margin-bottom: 12px;
border: 1px solid #e5eaf2;
border-radius: 10px;
background: #fff;
}
.evidence-card__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.evidence-code {
color: #2474e8;
font-size: 13px;
font-weight: 700;
}
.evidence-title {
margin-top: 8px;
font-size: 15px;
font-weight: 700;
color: #1f2937;
}
.evidence-summary {
margin-top: 8px;
font-size: 13px;
line-height: 1.6;
color: #475467;
}
.evidence-meta {
margin-top: 8px;
font-size: 12px;
color: #8a96a8;
line-height: 1.5;
}
</style>

View File

@@ -21,10 +21,24 @@
</template>
<el-table-column type="expand" width="1">
<template slot-scope="scope">
<family-asset-liability-detail
:detail="detailCache[scope.row.staffIdCard]"
:loading="Boolean(detailLoadingMap[scope.row.staffIdCard])"
/>
<div class="family-detail-wrapper">
<div class="family-detail-toolbar">
<span class="family-detail-title">资产详情</span>
<el-button
class="evidence-corner-btn"
size="mini"
plain
:disabled="Boolean(detailLoadingMap[scope.row.staffIdCard]) || !buildAssetEvidenceFingerprint(scope.row)"
@click="handleAddEvidence(scope.row)"
>
加入证据库
</el-button>
</div>
<family-asset-liability-detail
:detail="detailCache[scope.row.staffIdCard]"
:loading="Boolean(detailLoadingMap[scope.row.staffIdCard])"
/>
</div>
</template>
</el-table-column>
<el-table-column type="index" label="序号" width="60" />
@@ -67,6 +81,7 @@
<script>
import { getFamilyAssetLiabilityDetail } from "@/api/ccdi/projectSpecialCheck";
import { buildAssetEvidenceFingerprint, buildAssetEvidenceSnapshot } from "@/utils/ccdiEvidence";
import FamilyAssetLiabilityDetail from "./FamilyAssetLiabilityDetail";
export default {
@@ -114,6 +129,7 @@ export default {
},
},
methods: {
buildAssetEvidenceFingerprint,
resolveRiskTagType(riskLevelCode) {
const riskTagTypeMap = {
NORMAL: "success",
@@ -164,6 +180,30 @@ export default {
this.detailCache = {};
this.detailLoadingMap = {};
},
handleAddEvidence(row) {
if (!row || !row.staffIdCard) {
return;
}
const sourceRecordId = buildAssetEvidenceFingerprint(row);
if (!sourceRecordId) {
this.$message.warning("缺少人员身份证或资产字段,暂不能加入证据库");
return;
}
const detail = this.detailCache[row.staffIdCard] || {};
const summary = detail.summary || {};
const evidenceSummary = `${row.staffName}家庭资产负债核查:家庭总年收入${this.formatAmount(row.totalIncome)},家庭总负债${this.formatAmount(row.totalDebt)},家庭总资产${this.formatAmount(row.totalAsset)},风险情况${row.riskLevelName || "-" }`;
this.$emit("evidence-confirm", {
evidenceType: "ASSET",
relatedPersonName: row.staffName || "关联人员",
relatedPersonId: row.staffIdCard || "",
evidenceTitle: `${row.staffName || "关联人员"} / 家庭资产负债核查`,
evidenceSummary,
sourceType: "ASSET_DETAIL",
sourceRecordId,
sourcePage: "资产详情",
snapshotJson: JSON.stringify(buildAssetEvidenceSnapshot(row, detail, summary)),
});
},
},
};
</script>
@@ -204,6 +244,39 @@ export default {
overflow: hidden;
}
.family-detail-wrapper {
padding: 12px 0;
}
.family-detail-toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
margin-bottom: 10px;
}
.family-detail-title {
margin-right: auto;
color: #64748b;
font-size: 13px;
font-weight: 600;
}
.evidence-corner-btn {
padding: 4px 9px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: #2474e8;
border-color: #9fc3ff;
background: #edf5ff;
}
}
:deep(.family-table th) {
background: #f8fafc;
color: #64748b;

View File

@@ -33,7 +33,10 @@
@selection-change="handleRiskModelSelectionChange"
@view-project-analysis="handleRiskModelProjectAnalysis"
/>
<risk-detail-section :section-data="currentData.riskDetails" />
<risk-detail-section
:section-data="currentData.riskDetails"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</div>
<project-analysis-dialog
:visible.sync="projectAnalysisDialogVisible"
@@ -43,6 +46,7 @@
:model-summary="projectAnalysisModelSummary"
:project-name="projectInfo.projectName"
@close="handleProjectAnalysisDialogClose"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</div>
</template>

View File

@@ -75,15 +75,28 @@
<div v-else-if='group.groupType === "OBJECT"' class="object-card-grid">
<article
v-for="(item, index) in group.records || []"
:key="`${item.title || index}-object`"
:key="`${resolveGroupKey(group, groupIndex)}-${item.title || 'object'}-${index}`"
class="object-card"
>
<div class="object-card__title">{{ item.title || "-" }}</div>
<div class="object-card__subtitle">{{ item.subtitle || "-" }}</div>
<div class="object-card__header">
<div>
<div class="object-card__title">{{ item.title || "-" }}</div>
<div class="object-card__subtitle">{{ item.subtitle || "-" }}</div>
</div>
<el-button
class="evidence-corner-btn"
size="mini"
plain
:disabled="!buildModelEvidenceFingerprint(resolvePersonIdCard(), item.modelCode)"
@click="handleAddModelEvidence(item, group)"
>
加入证据库
</el-button>
</div>
<div v-if="item.riskTags && item.riskTags.length" class="tag-list">
<el-tag
v-for="(tag, tagIndex) in item.riskTags"
:key="`${item.title || index}-risk-${tagIndex}`"
:key="`${resolveGroupKey(group, groupIndex)}-${item.title || index}-risk-${tagIndex}`"
size="mini"
effect="plain"
>
@@ -97,7 +110,7 @@
<p class="object-card__summary">{{ item.summary || "-" }}</p>
<div
v-for="(field, fieldIndex) in item.extraFields || []"
:key="`${item.title || index}-field-${fieldIndex}`"
:key="`${resolveGroupKey(group, groupIndex)}-${item.title || index}-field-${fieldIndex}`"
class="summary-row"
>
<span class="summary-row__label">{{ field.label }}</span>
@@ -113,6 +126,8 @@
</template>
<script>
import { buildModelEvidenceFingerprint, MODEL_EVIDENCE_FINGERPRINT_RULE } from "@/utils/ccdiEvidence";
export default {
name: "ProjectAnalysisAbnormalTab",
props: {
@@ -122,6 +137,14 @@ export default {
groups: [],
}),
},
person: {
type: Object,
default: () => ({}),
},
projectId: {
type: [String, Number],
default: null,
},
},
data() {
return {
@@ -150,6 +173,7 @@ export default {
},
},
methods: {
buildModelEvidenceFingerprint,
resolveGroupKey(group, index = 0) {
return group.groupCode || group.groupName || `BANK_STATEMENT_${index}`;
},
@@ -170,6 +194,48 @@ export default {
const groupKey = this.resolveGroupKey(group);
this.$set(this.statementPageMap, groupKey, page);
},
handleAddModelEvidence(item, group) {
const safeItem = item || {};
const safeGroup = group || {};
const relatedPersonName = this.resolveRelatedPersonName(safeItem);
const personIdCard = this.resolvePersonIdCard();
const sourceRecordId = buildModelEvidenceFingerprint(personIdCard, safeItem.modelCode);
if (!sourceRecordId) {
this.$message.warning("缺少人员身份证或模型编码,暂不能加入证据库");
return;
}
const riskTags = Array.isArray(safeItem.riskTags) ? safeItem.riskTags.join("、") : "";
const reason = safeItem.reasonDetail || safeItem.summary || "-";
const payload = {
evidenceType: "MODEL",
relatedPersonName,
relatedPersonId: personIdCard,
evidenceTitle: `${relatedPersonName} / ${safeItem.title || safeGroup.groupName || "模型异常"}`,
evidenceSummary: `${safeItem.title || safeGroup.groupName || "模型异常"}${reason}`,
sourceType: "MODEL_DETAIL",
sourceRecordId,
sourcePage: "模型详情",
snapshotJson: JSON.stringify({
group: safeGroup,
item: safeItem,
person: this.person,
riskTags,
evidenceFingerprint: sourceRecordId,
evidenceFingerprintRule: MODEL_EVIDENCE_FINGERPRINT_RULE,
}),
};
this.$emit("evidence-confirm", payload);
this.$root.$emit("ccdi-evidence-confirm", payload);
},
resolvePersonIdCard() {
return (this.person && (this.person.idNo || this.person.staffIdCard)) || "";
},
resolveRelatedPersonName(item) {
if (this.person && (this.person.name || this.person.staffName)) {
return this.person.name || this.person.staffName;
}
return item.title || "关联人员";
},
},
};
</script>
@@ -252,12 +318,35 @@ export default {
background: #f8fafc;
}
.object-card__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.object-card__title {
font-size: 15px;
font-weight: 600;
color: #0f172a;
}
.evidence-corner-btn {
flex: 0 0 auto;
padding: 4px 9px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: #2474e8;
border-color: #9fc3ff;
background: #edf5ff;
}
}
.object-card__subtitle {
margin-top: 6px;
font-size: 12px;

View File

@@ -46,7 +46,12 @@
</el-alert>
<el-tabs v-model="activeTab" class="project-analysis-tabs" stretch>
<el-tab-pane label="异常明细" name="abnormalDetail">
<project-analysis-abnormal-tab :detail-data="dialogData.abnormalDetail" />
<project-analysis-abnormal-tab
:detail-data="dialogData.abnormalDetail"
:person="person"
:project-id="projectId"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
</el-tab-pane>
<el-tab-pane label="资产分析" name="assetAnalysis">
<project-analysis-placeholder-tab :tab-data="getTabData('assetAnalysis')" />

View File

@@ -220,10 +220,23 @@
:visible.sync="detailVisible"
append-to-body
custom-class="detail-dialog"
title="流水详情"
width="980px"
@close="closeDetailDialog"
>
<template slot="title">
<div class="detail-dialog-title">
<span>流水详情</span>
<el-button
class="evidence-corner-btn"
size="mini"
plain
:disabled="detailLoading || !buildFlowEvidenceFingerprint(detailData)"
@click="handleAddEvidence"
>
加入证据库
</el-button>
</div>
</template>
<div v-loading="detailLoading" class="detail-dialog-body">
<div class="detail-overview-grid">
<div class="detail-field">
@@ -332,6 +345,7 @@ import {
getOverviewSuspiciousTransactions,
} from "@/api/ccdi/projectOverview";
import { getBankStatementDetail } from "@/api/ccdiProjectBankStatement";
import { buildFlowEvidenceFingerprint, buildFlowEvidenceSnapshot } from "@/utils/ccdiEvidence";
const SUSPICIOUS_TYPE_OPTIONS = [
{ value: "ALL", label: "全部可疑人员类型" },
@@ -452,6 +466,7 @@ export default {
},
},
methods: {
buildFlowEvidenceFingerprint,
async handleSuspiciousTypeChange(command) {
this.currentSuspiciousType = command;
this.suspiciousPageNum = 1;
@@ -646,6 +661,31 @@ export default {
this.detailLoading = false;
this.detailData = createEmptyDetailData();
},
handleAddEvidence() {
const detail = this.detailData || {};
const sourceRecordId = buildFlowEvidenceFingerprint(detail);
const amountText = this.formatSignedAmount(detail.displayAmount);
const counterparty = this.formatCounterpartyName(detail);
const relatedPersonName = this.resolveFlowRelatedPerson(detail);
const hitTagText = Array.isArray(detail.hitTags) && detail.hitTags.length
? `,命中${detail.hitTags.map((tag) => tag.ruleName).filter(Boolean).join("、")}标签`
: "";
this.$emit("evidence-confirm", {
evidenceType: "FLOW",
relatedPersonName,
relatedPersonId: detail.cretNo || "",
evidenceTitle: `${relatedPersonName} / ${this.formatField(detail.leAccountNo)}`,
evidenceSummary: `${this.formatField(detail.trxDate)}${relatedPersonName}账户与${counterparty}发生交易,金额${amountText}${hitTagText}`,
sourceType: "BANK_STATEMENT",
sourceRecordId,
sourcePage: "流水详情",
snapshotJson: JSON.stringify(buildFlowEvidenceSnapshot(detail)),
});
},
resolveFlowRelatedPerson(detail) {
const value = this.formatField(detail.leAccountName);
return value === "-" ? "关联人员" : value;
},
handleRiskDetailExport() {
if (!this.projectId) {
return;
@@ -1020,6 +1060,26 @@ export default {
gap: 12px;
}
.detail-dialog-title {
display: inline-flex;
align-items: center;
gap: 10px;
}
.evidence-corner-btn {
padding: 4px 9px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: #2474e8;
border-color: #9fc3ff;
background: #edf5ff;
}
}
:deep(.detail-dialog) {
border-radius: 8px;

View File

@@ -19,6 +19,7 @@
:project-id="projectId"
:title="sectionTitle"
:subtitle="sectionSubtitle"
@evidence-confirm="$emit('evidence-confirm', $event)"
/>
<section class="graph-placeholder-card">

View File

@@ -33,6 +33,15 @@
</div>
</div>
<div class="header-right">
<el-button
class="evidence-entry-btn"
size="mini"
plain
icon="el-icon-collection-tag"
@click="evidenceDrawerVisible = true"
>
证据线索
</el-button>
<el-menu
:default-active="activeTab"
mode="horizontal"
@@ -59,6 +68,18 @@
@name-selected="handleNameSelected"
@generate-report="handleGenerateReport"
@fetch-bank-info="handleFetchBankInfo"
@evidence-confirm="handleEvidenceConfirm"
/>
<evidence-confirm-dialog
:visible.sync="evidenceConfirmVisible"
:payload="evidencePayload"
@saved="handleEvidenceSaved"
/>
<evidence-drawer
ref="evidenceDrawer"
:visible.sync="evidenceDrawerVisible"
:project-id="projectId"
/>
</div>
</template>
@@ -69,6 +90,8 @@ import ParamConfig from "./components/detail/ParamConfig";
import PreliminaryCheck from "./components/detail/PreliminaryCheck";
import SpecialCheck from "./components/detail/SpecialCheck";
import DetailQuery from "./components/detail/DetailQuery";
import EvidenceConfirmDialog from "./components/detail/EvidenceConfirmDialog";
import EvidenceDrawer from "./components/detail/EvidenceDrawer";
import { getProject } from "@/api/ccdiProject";
export default {
@@ -79,6 +102,8 @@ export default {
PreliminaryCheck,
SpecialCheck,
DetailQuery,
EvidenceConfirmDialog,
EvidenceDrawer,
},
data() {
return {
@@ -102,6 +127,9 @@ export default {
warningThreshold: 60,
projectStatus: "0",
},
evidenceConfirmVisible: false,
evidenceDrawerVisible: false,
evidencePayload: {},
projectStatusPollingTimer: null,
projectStatusPollingInterval: 1000,
projectStatusPollingLoading: false,
@@ -139,8 +167,10 @@ export default {
// 初始化页面数据
this.initActiveTabFromRoute();
this.initPageData();
this.$root.$on("ccdi-evidence-confirm", this.handleEvidenceConfirm);
},
beforeDestroy() {
this.$root.$off("ccdi-evidence-confirm", this.handleEvidenceConfirm);
this.stopProjectStatusPolling();
},
methods: {
@@ -400,6 +430,21 @@ export default {
handleRefreshProject() {
this.initPageData();
},
handleEvidenceConfirm(payload) {
this.evidencePayload = {
projectId: this.projectId,
...(payload || {}),
};
this.evidenceConfirmVisible = true;
},
handleEvidenceSaved() {
this.evidenceDrawerVisible = true;
this.$nextTick(() => {
if (this.$refs.evidenceDrawer) {
this.$refs.evidenceDrawer.loadEvidence();
}
});
},
/** 导出报告 */
handleExport() {
console.log("导出报告");
@@ -496,6 +541,21 @@ export default {
.header-right {
display: flex;
align-items: center;
gap: 10px;
.evidence-entry-btn {
padding: 6px 10px;
font-size: 12px;
color: #5b7fb8;
border-color: #d6e4f7;
background: #f8fbff;
&:hover {
color: var(--ccdi-primary);
border-color: #9fc3ff;
background: #edf5ff;
}
}
.nav-menu {
// 移除默认背景色和边框

View File

@@ -44,6 +44,16 @@
<el-option label="放弃" value="放弃" />
</el-select>
</el-form-item>
<el-form-item label="招聘类型" prop="recruitType">
<el-select v-model="queryParams.recruitType" placeholder="请选择招聘类型" clearable style="width: 240px">
<el-option
v-for="item in recruitTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
@@ -53,6 +63,15 @@
<el-row :gutter="10" class="mb8">
<el-col :span="1.5">
<el-button
v-if="isPreviewMode()"
type="primary"
plain
icon="el-icon-plus"
size="mini"
@click="handleAdd"
>新增</el-button>
<el-button
v-else
type="primary"
plain
icon="el-icon-plus"
@@ -63,6 +82,15 @@
</el-col>
<el-col :span="1.5">
<el-button
v-if="isPreviewMode()"
type="success"
plain
icon="el-icon-upload2"
size="mini"
@click="handleImport"
>导入</el-button>
<el-button
v-else
type="success"
plain
icon="el-icon-upload2"
@@ -73,6 +101,34 @@
</el-col>
<el-col :span="1.5">
<el-button
v-if="isPreviewMode()"
type="info"
plain
icon="el-icon-upload"
size="mini"
@click="handleWorkImport"
>导入工作经历</el-button>
<el-button
v-else
type="info"
plain
icon="el-icon-upload"
size="mini"
@click="handleWorkImport"
v-hasPermi="['ccdi:staffRecruitment:import']"
>导入工作经历</el-button>
</el-col>
<el-col :span="1.5">
<el-button
v-if="isPreviewMode()"
type="warning"
plain
icon="el-icon-download"
size="mini"
@click="handleExport"
>导出</el-button>
<el-button
v-else
type="warning"
plain
icon="el-icon-download"
@@ -100,13 +156,10 @@
<el-table v-loading="loading" :data="recruitmentList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="招聘项目编号" align="center" prop="recruitId" width="150" :show-overflow-tooltip="true"/>
<el-table-column label="招聘项目名称" align="center" prop="recruitName" :show-overflow-tooltip="true"/>
<el-table-column label="招聘记录编号" align="center" prop="recruitId" width="150" :show-overflow-tooltip="true"/>
<el-table-column label="招聘项目名称" align="center" prop="recruitName" min-width="220" :show-overflow-tooltip="true"/>
<el-table-column label="职位名称" align="center" prop="posName" :show-overflow-tooltip="true"/>
<el-table-column label="候选人姓名" align="center" prop="candName" width="120"/>
<el-table-column label="证件号码" align="center" prop="candId" width="180"/>
<el-table-column label="毕业院校" align="center" prop="candSchool" :show-overflow-tooltip="true"/>
<el-table-column label="专业" align="center" prop="candMajor" :show-overflow-tooltip="true"/>
<el-table-column label="录用情况" align="center" prop="admitStatus" width="100">
<template slot-scope="scope">
<el-tag v-if="scope.row.admitStatus === '录用'" type="success" size="small">录用</el-tag>
@@ -114,14 +167,34 @@
<el-tag v-else type="warning" size="small">放弃</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" align="center" prop="createTime" width="180">
<el-table-column label="学历 / 毕业学校" align="center" min-width="180">
<template slot-scope="scope">
<span>{{ parseTime(scope.row.createTime) }}</span>
<span>{{ formatEducationSchool(scope.row) }}</span>
</template>
</el-table-column>
<el-table-column label="招聘类型" align="center" prop="recruitType" width="100">
<template slot-scope="scope">
<el-tag :type="scope.row.recruitType === 'SOCIAL' ? 'success' : 'info'" size="small">
{{ formatRecruitType(scope.row.recruitType) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="历史工作经历" align="center" prop="workExperienceCount" width="120">
<template slot-scope="scope">
{{ formatWorkExperienceCount(scope.row) }}
</template>
</el-table-column>
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="200">
<template slot-scope="scope">
<el-button
v-if="isPreviewMode()"
size="mini"
type="text"
icon="el-icon-view"
@click="handleDetail(scope.row)"
>详情</el-button>
<el-button
v-else
size="mini"
type="text"
icon="el-icon-view"
@@ -129,6 +202,14 @@
v-hasPermi="['ccdi:staffRecruitment:query']"
>详情</el-button>
<el-button
v-if="isPreviewMode()"
size="mini"
type="text"
icon="el-icon-edit"
@click="handleUpdate(scope.row)"
>编辑</el-button>
<el-button
v-else
size="mini"
type="text"
icon="el-icon-edit"
@@ -136,6 +217,14 @@
v-hasPermi="['ccdi:staffRecruitment:edit']"
>编辑</el-button>
<el-button
v-if="isPreviewMode()"
size="mini"
type="text"
icon="el-icon-delete"
@click="handleDelete(scope.row)"
>删除</el-button>
<el-button
v-else
size="mini"
type="text"
icon="el-icon-delete"
@@ -157,11 +246,11 @@
<!-- 添加或修改对话框 -->
<el-dialog :title="title" :visible.sync="open" width="900px" append-to-body>
<el-form ref="form" :model="form" :rules="rules" label-width="120px">
<el-divider content-position="left">招聘项目信息</el-divider>
<el-divider content-position="left">招聘岗位信息</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="招聘项目编号" prop="recruitId">
<el-input v-model="form.recruitId" placeholder="请输入招聘项目编号" maxlength="32" :disabled="!isAdd" />
<el-form-item label="招聘记录编号" prop="recruitId">
<el-input v-model="form.recruitId" placeholder="请输入招聘记录编号" maxlength="32" :disabled="!isAdd" />
</el-form-item>
</el-col>
<el-col :span="12">
@@ -171,7 +260,6 @@
</el-col>
</el-row>
<el-divider content-position="left">职位信息</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="职位名称" prop="posName">
@@ -188,18 +276,43 @@
<el-input v-model="form.posDesc" type="textarea" :rows="3" placeholder="请输入职位描述" />
</el-form-item>
<el-divider content-position="left">候选人信息</el-divider>
<el-divider content-position="left">录用情况</el-divider>
<el-form-item label="录用情况" prop="admitStatus">
<el-radio-group v-model="form.admitStatus">
<el-radio label="录用">录用</el-radio>
<el-radio label="未录用">未录用</el-radio>
<el-radio label="放弃">放弃</el-radio>
</el-radio-group>
</el-form-item>
<el-divider content-position="left">候选人情况</el-divider>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="候选人姓名" prop="candName">
<el-input v-model="form.candName" placeholder="请输入候选人姓名" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="招聘类型" prop="recruitType">
<el-radio-group v-model="form.recruitType">
<el-radio
v-for="item in recruitTypeOptions"
:key="item.value"
:label="item.value"
>
{{ item.label }}
</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
<el-form-item label="学历" prop="candEdu">
<el-input v-model="form.candEdu" placeholder="请输入学历" maxlength="20" />
</el-form-item>
</el-col>
<el-col :span="12"></el-col>
</el-row>
<el-row :gutter="16">
<el-col :span="12">
@@ -226,15 +339,6 @@
</el-col>
</el-row>
<el-divider content-position="left">录用信息</el-divider>
<el-form-item label="录用情况" prop="admitStatus">
<el-radio-group v-model="form.admitStatus">
<el-radio label="录用">录用</el-radio>
<el-radio label="未录用">未录用</el-radio>
<el-radio label="放弃">放弃</el-radio>
</el-radio-group>
</el-form-item>
<el-divider content-position="left">面试官信息</el-divider>
<el-row :gutter="16">
<el-col :span="12">
@@ -270,30 +374,16 @@
<!-- 详情对话框 -->
<el-dialog title="招聘信息详情" :visible.sync="detailOpen" width="900px" append-to-body>
<div class="detail-container">
<el-divider content-position="left">招聘项目信息</el-divider>
<el-divider content-position="left">招聘岗位信息</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="招聘项目编号">{{ recruitmentDetail.recruitId || '-' }}</el-descriptions-item>
<el-descriptions-item label="招聘记录编号">{{ recruitmentDetail.recruitId || '-' }}</el-descriptions-item>
<el-descriptions-item label="招聘项目名称">{{ recruitmentDetail.recruitName || '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">职位信息</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="职位名称">{{ recruitmentDetail.posName || '-' }}</el-descriptions-item>
<el-descriptions-item label="职位类别">{{ recruitmentDetail.posCategory || '-' }}</el-descriptions-item>
<el-descriptions-item label="职位描述" :span="2">{{ recruitmentDetail.posDesc || '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">候选人信息</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="候选人姓名">{{ recruitmentDetail.candName || '-' }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ recruitmentDetail.candEdu || '-' }}</el-descriptions-item>
<el-descriptions-item label="证件号码">{{ recruitmentDetail.candId || '-' }}</el-descriptions-item>
<el-descriptions-item label="毕业年月">{{ recruitmentDetail.candGrad || '-' }}</el-descriptions-item>
<el-descriptions-item label="毕业院校">{{ recruitmentDetail.candSchool || '-' }}</el-descriptions-item>
<el-descriptions-item label="专业">{{ recruitmentDetail.candMajor || '-' }}</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">录用信息</el-divider>
<el-divider content-position="left">录用情况</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="录用情况">
<el-tag v-if="recruitmentDetail.admitStatus === '录用'" type="success" size="small">录用</el-tag>
@@ -302,18 +392,48 @@
</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">候选人情况</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="候选人姓名">{{ recruitmentDetail.candName || '-' }}</el-descriptions-item>
<el-descriptions-item label="招聘类型">{{ formatRecruitType(recruitmentDetail.recruitType) }}</el-descriptions-item>
<el-descriptions-item label="学历">{{ recruitmentDetail.candEdu || '-' }}</el-descriptions-item>
<el-descriptions-item label="证件号码">{{ recruitmentDetail.candId || '-' }}</el-descriptions-item>
<el-descriptions-item label="毕业年月">{{ recruitmentDetail.candGrad || '-' }}</el-descriptions-item>
<el-descriptions-item label="毕业院校">{{ recruitmentDetail.candSchool || '-' }}</el-descriptions-item>
<el-descriptions-item label="专业">{{ recruitmentDetail.candMajor || '-' }}</el-descriptions-item>
</el-descriptions>
<template v-if="isSocialRecruitment(recruitmentDetail)">
<el-divider content-position="left">候选人历史工作经历</el-divider>
<el-table
v-if="recruitmentDetail.workExperienceList && recruitmentDetail.workExperienceList.length"
:data="recruitmentDetail.workExperienceList"
border
>
<el-table-column label="序号" align="center" prop="sortOrder" width="80" />
<el-table-column label="工作单位" align="center" prop="companyName" min-width="180" :show-overflow-tooltip="true" />
<el-table-column label="岗位" align="center" prop="positionName" min-width="140" :show-overflow-tooltip="true" />
<el-table-column label="任职时间" align="center" min-width="160">
<template slot-scope="scope">
{{ getEmploymentPeriod(scope.row) }}
</template>
</el-table-column>
<el-table-column label="离职原因" align="center" prop="departureReason" min-width="220" :show-overflow-tooltip="true" />
</el-table>
<el-empty
v-else
description="暂无历史工作经历"
:image-size="72"
class="work-experience-empty"
/>
</template>
<el-divider content-position="left">面试官信息</el-divider>
<el-descriptions :column="2" border>
<el-descriptions-item label="面试官1">{{ recruitmentDetail.interviewerName1 || '-' }} ({{ recruitmentDetail.interviewerId1 || '-' }})</el-descriptions-item>
<el-descriptions-item label="面试官2">{{ recruitmentDetail.interviewerName2 || '-' }} ({{ recruitmentDetail.interviewerId2 || '-' }})</el-descriptions-item>
<el-descriptions-item label="创建时间">
{{ recruitmentDetail.createTime ? parseTime(recruitmentDetail.createTime) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="创建人">{{ recruitmentDetail.createdBy || '-' }}</el-descriptions-item>
<el-descriptions-item label="更新时间">
{{ recruitmentDetail.updateTime ? parseTime(recruitmentDetail.updateTime) : '-' }}
</el-descriptions-item>
<el-descriptions-item label="更新人">{{ recruitmentDetail.updatedBy || '-' }}</el-descriptions-item>
<el-descriptions-item label="面试官1姓名">{{ recruitmentDetail.interviewerName1 || '-' }}</el-descriptions-item>
<el-descriptions-item label="面试官1工号">{{ recruitmentDetail.interviewerId1 || '-' }}</el-descriptions-item>
<el-descriptions-item label="面试官2姓名">{{ recruitmentDetail.interviewerName2 || '-' }}</el-descriptions-item>
<el-descriptions-item label="面试官2工号">{{ recruitmentDetail.interviewerId2 || '-' }}</el-descriptions-item>
</el-descriptions>
</div>
<div slot="footer" class="dialog-footer">
@@ -341,7 +461,7 @@
<el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline;" @click="importTemplate">下载模板</el-link>
</div>
<div class="el-upload__tip" slot="tip">
<span>仅允许导入"xls""xlsx"格式文件</span>
<span>{{ upload.tip }}</span>
</div>
</el-upload>
<div slot="footer" class="dialog-footer">
@@ -374,11 +494,33 @@
/>
<el-table :data="failureList" v-loading="failureLoading">
<el-table-column label="招聘项目编号" prop="recruitId" align="center" width="150" />
<el-table-column label="招聘记录编号" prop="recruitId" align="center" width="150" />
<el-table-column label="招聘项目名称" prop="recruitName" align="center" :show-overflow-tooltip="true"/>
<el-table-column label="职位名称" prop="posName" align="center" :show-overflow-tooltip="true"/>
<el-table-column label="候选人姓名" prop="candName" align="center" width="120"/>
<el-table-column label="证件号码" prop="candId" align="center" width="180"/>
<el-table-column
v-if="currentImportType !== 'work'"
label="证件号码"
prop="candId"
align="center"
width="180"
/>
<el-table-column
v-if="currentImportType === 'work'"
label="工作单位"
prop="companyName"
align="center"
min-width="180"
:show-overflow-tooltip="true"
/>
<el-table-column
v-if="currentImportType === 'work'"
label="岗位"
prop="positionName"
align="center"
min-width="140"
:show-overflow-tooltip="true"
/>
<el-table-column label="失败原因" prop="errorMessage" align="center" min-width="200"
:show-overflow-tooltip="true" />
</el-table>
@@ -416,6 +558,109 @@ import ImportResultDialog from "@/components/ImportResultDialog.vue";
const idCardPattern = /^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/;
// 毕业年月校验正则 (YYYYMM)
const gradPattern = /^((19|20)\d{2})(0[1-9]|1[0-2])$/;
const previewRecruitmentList = [
{
recruitId: "RC2025001205",
recruitName: "2024年社会招聘-技术部",
posName: "Java开发工程师",
posCategory: "技术研发",
posDesc: "负责核心业务系统设计、开发与维护。",
candName: "杨丽思思",
recruitType: "SOCIAL",
candEdu: "本科",
candId: "330101199403150021",
candSchool: "四川大学",
candMajor: "法学",
candGrad: "202110",
admitStatus: "录用",
workExperienceCount: 2,
interviewerName1: "陈志远",
interviewerId1: "I0001",
interviewerName2: "王晨",
interviewerId2: "I0002",
createdBy: "admin",
updatedBy: "admin",
createTime: "2026-04-15 09:00:00",
updateTime: "2026-04-15 09:20:00",
workExperienceList: [
{
sortOrder: 1,
companyName: "杭州数联科技有限公司",
positionName: "Java开发工程师",
jobStartMonth: "2022-07",
jobEndMonth: "2024-12",
departureReason: "个人职业发展需要,期望参与更大规模系统建设"
},
{
sortOrder: 2,
companyName: "成都云启信息技术有限公司",
positionName: "初级开发工程师",
jobStartMonth: "2021-07",
jobEndMonth: "2022-06",
departureReason: "项目阶段结束后选择新的发展机会"
}
]
},
{
recruitId: "RC2025001206",
recruitName: "2024年社会招聘-技术部",
posName: "数据分析师",
posCategory: "数据分析",
posDesc: "负责经营分析、指标体系建设与专题分析。",
candName: "罗军晓东",
recruitType: "SOCIAL",
candEdu: "本科",
candId: "420106199603120018",
candSchool: "华中科技大学",
candMajor: "软件工程",
candGrad: "202003",
admitStatus: "录用",
workExperienceCount: 1,
interviewerName1: "李倩",
interviewerId1: "I0003",
interviewerName2: "周腾",
interviewerId2: "I0004",
createdBy: "admin",
updatedBy: "admin",
createTime: "2026-04-15 09:05:00",
updateTime: "2026-04-15 09:25:00",
workExperienceList: [
{
sortOrder: 1,
companyName: "上海明策数据服务有限公司",
positionName: "数据分析师",
jobStartMonth: "2021-03",
jobEndMonth: "2025-01",
departureReason: "期望转向更深入的数据建模分析工作"
}
]
},
{
recruitId: "RC2025001003",
recruitName: "2024年春季校园招聘",
posName: "Java开发工程师",
posCategory: "技术研发",
posDesc: "参与项目需求开发与系统维护。",
candName: "黄伟梓萱",
recruitType: "CAMPUS",
candEdu: "本科",
candId: "440105200001018888",
candSchool: "中山大学",
candMajor: "建筑学",
candGrad: "202108",
admitStatus: "录用",
workExperienceCount: 0,
interviewerName1: "陈志远",
interviewerId1: "I0001",
interviewerName2: "王晨",
interviewerId2: "I0002",
createdBy: "admin",
updatedBy: "admin",
createTime: "2026-04-15 09:10:00",
updateTime: "2026-04-15 09:30:00",
workExperienceList: []
}
];
export default {
name: "StaffRecruitment",
@@ -436,6 +681,10 @@ export default {
total: 0,
// 招聘信息表格数据
recruitmentList: [],
recruitTypeOptions: [
{ value: "SOCIAL", label: "社招" },
{ value: "CAMPUS", label: "校招" }
],
// 弹出层标题
title: "",
// 是否显示弹出层
@@ -455,6 +704,7 @@ export default {
candName: null,
candId: null,
admitStatus: null,
recruitType: null,
interviewerName: null,
interviewerId: null
},
@@ -463,8 +713,8 @@ export default {
// 表单校验
rules: {
recruitId: [
{ required: true, message: "招聘项目编号不能为空", trigger: "blur" },
{ max: 32, message: "招聘项目编号长度不能超过32个字符", trigger: "blur" }
{ required: true, message: "招聘记录编号不能为空", trigger: "blur" },
{ max: 32, message: "招聘记录编号长度不能超过32个字符", trigger: "blur" }
],
recruitName: [
{ required: true, message: "招聘项目名称不能为空", trigger: "blur" },
@@ -485,6 +735,9 @@ export default {
{ required: true, message: "候选人姓名不能为空", trigger: "blur" },
{ max: 20, message: "候选人姓名长度不能超过20个字符", trigger: "blur" }
],
recruitType: [
{ required: true, message: "招聘类型不能为空", trigger: "change" }
],
candEdu: [
{ required: true, message: "学历不能为空", trigger: "blur" },
{ max: 20, message: "学历长度不能超过20个字符", trigger: "blur" }
@@ -515,6 +768,10 @@ export default {
open: false,
// 弹出层标题
title: "",
// 导入类型
importType: "recruitment",
// 弹窗提示
tip: "仅允许导入\"xls\"或\"xlsx\"格式文件。",
// 是否禁用上传
isUploading: false,
// 设置上传的请求头部
@@ -531,6 +788,8 @@ export default {
showFailureButton: false,
// 当前导入任务ID
currentTaskId: null,
// 当前导入类型
currentImportType: "recruitment",
// 失败记录对话框
failureDialogVisible: false,
failureList: [],
@@ -549,12 +808,16 @@ export default {
lastImportInfo() {
const savedTask = this.getImportTaskFromStorage();
if (savedTask && savedTask.totalCount) {
return `导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}`;
return `导入类型: ${this.getImportTypeLabel(savedTask.importType || 'recruitment')} | 导入时间: ${this.parseTime(savedTask.saveTime)} | 总数: ${savedTask.totalCount}条 | 成功: ${savedTask.successCount}条 | 失败: ${savedTask.failureCount}`;
}
return '';
}
},
created() {
if (this.isPreviewMode()) {
this.loadPreviewPage();
return;
}
this.getList();
this.restoreImportState(); // 恢复导入状态
},
@@ -568,6 +831,10 @@ export default {
methods: {
/** 查询招聘信息列表 */
getList() {
if (this.isPreviewMode()) {
this.loadPreviewList();
return;
}
this.loading = true;
listStaffRecruitment(this.queryParams).then(response => {
this.recruitmentList = response.rows;
@@ -589,12 +856,15 @@ export default {
posCategory: null,
posDesc: null,
candName: null,
recruitType: "SOCIAL",
candEdu: null,
candId: null,
candSchool: null,
candMajor: null,
candGrad: null,
admitStatus: "录用",
workExperienceCount: 0,
workExperienceList: [],
interviewerName1: null,
interviewerId1: null,
interviewerName2: null,
@@ -629,8 +899,26 @@ export default {
handleUpdate(row) {
this.reset();
const recruitId = row.recruitId || this.ids[0];
if (this.isPreviewMode()) {
const target = this.findPreviewRecruitment(recruitId);
if (target) {
this.form = {
...this.form,
...target,
workExperienceList: this.normalizeWorkExperienceList(target.workExperienceList)
};
}
this.open = true;
this.title = "修改招聘信息";
this.isAdd = false;
return;
}
getStaffRecruitment(recruitId).then(response => {
this.form = response.data;
this.form = {
...this.form,
...response.data,
workExperienceList: this.normalizeWorkExperienceList(response.data && response.data.workExperienceList)
};
this.open = true;
this.title = "修改招聘信息";
this.isAdd = false;
@@ -639,15 +927,114 @@ export default {
/** 详情按钮操作 */
handleDetail(row) {
const recruitId = row.recruitId;
if (this.isPreviewMode()) {
const target = this.findPreviewRecruitment(recruitId);
if (target) {
this.recruitmentDetail = {
...target,
workExperienceList: this.normalizeWorkExperienceList(target.workExperienceList)
};
this.detailOpen = true;
}
return;
}
getStaffRecruitment(recruitId).then(response => {
this.recruitmentDetail = response.data;
this.recruitmentDetail = {
...response.data,
workExperienceList: this.normalizeWorkExperienceList(response.data && response.data.workExperienceList)
};
this.detailOpen = true;
});
},
/** 招聘类型格式化 */
formatRecruitType(value) {
const matched = this.recruitTypeOptions.find(item => item.value === value);
return matched ? matched.label : "-";
},
/** 学历与毕业学校格式化 */
formatEducationSchool(row) {
if (!row) {
return "-";
}
const edu = row.candEdu || "-";
const school = row.candSchool || "-";
return `${edu} / ${school}`;
},
/** 历史工作经历展示 */
formatWorkExperienceCount(row) {
if (!row || row.recruitType !== "SOCIAL") {
return "-";
}
const count = Number(row.workExperienceCount || 0);
return `${count}`;
},
/** 是否为社招 */
isSocialRecruitment(row) {
return row && row.recruitType === "SOCIAL";
},
/** 任职时间展示 */
getEmploymentPeriod(row) {
if (!row) {
return "-";
}
const start = row.jobStartMonth || "-";
const end = row.jobEndMonth || "至今";
return `${start} ~ ${end}`;
},
/** 工作经历列表归一化 */
normalizeWorkExperienceList(list) {
if (!Array.isArray(list)) {
return [];
}
return list.slice().sort((a, b) => {
const first = Number(a.sortOrder || 0);
const second = Number(b.sortOrder || 0);
return first - second;
});
},
/** 是否为预览模式 */
isPreviewMode() {
return this.$route && this.$route.query && this.$route.query.preview === "1";
},
/** 加载预览页面 */
loadPreviewPage() {
this.loadPreviewList();
const mode = this.$route.query.mode;
const recruitId = this.$route.query.recruitId || "RC2025001205";
if (mode === "detail") {
this.handleDetail({ recruitId });
} else if (mode === "edit") {
this.handleUpdate({ recruitId });
} else if (mode === "workImport") {
this.handleWorkImport();
} else if (mode === "import") {
this.handleImport();
} else if (mode === "add") {
this.handleAdd();
}
},
/** 加载预览列表 */
loadPreviewList() {
this.loading = false;
this.recruitmentList = previewRecruitmentList.map(item => ({
...item,
workExperienceList: this.normalizeWorkExperienceList(item.workExperienceList)
}));
this.total = this.recruitmentList.length;
},
/** 查找预览记录 */
findPreviewRecruitment(recruitId) {
return previewRecruitmentList.find(item => item.recruitId === recruitId) || null;
},
/** 提交按钮 */
submitForm() {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.isPreviewMode()) {
this.$modal.msgSuccess(this.isAdd ? "预览模式:新增成功" : "预览模式:修改成功");
this.open = false;
return;
}
if (this.isAdd) {
addStaffRecruitment(this.form).then(response => {
this.$modal.msgSuccess("新增成功");
@@ -667,6 +1054,10 @@ export default {
/** 删除按钮操作 */
handleDelete(row) {
const recruitIds = row.recruitId || this.ids;
if (this.isPreviewMode()) {
this.$modal.msgSuccess(`预览模式:已模拟删除 ${recruitIds}`);
return;
}
this.$modal.confirm('是否确认删除招聘信息编号为"' + recruitIds + '"的数据项?').then(function() {
return delStaffRecruitment(recruitIds);
}).then(() => {
@@ -682,11 +1073,36 @@ export default {
},
/** 导入按钮操作 */
handleImport() {
this.upload.title = "招聘信息数据导入";
this.openImportDialog("recruitment");
},
/** 导入工作经历按钮操作 */
handleWorkImport() {
this.openImportDialog("work");
},
/** 打开导入弹窗 */
openImportDialog(importType) {
const isWorkImport = importType === "work";
this.upload.importType = importType;
this.currentImportType = importType;
this.upload.title = isWorkImport ? "历史工作经历数据导入" : "招聘信息数据导入";
this.upload.url = process.env.VUE_APP_BASE_API + (isWorkImport
? "/ccdi/staffRecruitment/importWorkData"
: "/ccdi/staffRecruitment/importData");
this.upload.tip = isWorkImport
? "仅允许导入\"xls\"或\"xlsx\"格式文件;招聘记录编号用于匹配,姓名/项目/职位用于校验。"
: "仅允许导入\"xls\"或\"xlsx\"格式文件。";
if (this.isPreviewMode()) {
this.upload.open = true;
return;
}
this.upload.open = true;
},
/** 下载模板操作 */
importTemplate() {
if (this.upload.importType === "work") {
this.download('ccdi/staffRecruitment/workImportTemplate', {}, `历史工作经历导入模板_${new Date().getTime()}.xlsx`);
return;
}
this.download('ccdi/staffRecruitment/importTemplate', {}, `招聘信息导入模板_${new Date().getTime()}.xlsx`);
},
// 文件上传中处理
@@ -722,17 +1138,19 @@ export default {
taskId: taskId,
status: 'PROCESSING',
timestamp: Date.now(),
hasFailures: false
hasFailures: false,
importType: this.upload.importType
});
// 重置状态
this.showFailureButton = false;
this.currentTaskId = taskId;
this.currentImportType = this.upload.importType;
// 显示后台处理提示
this.$notify({
title: '导入任务已提交',
message: '正在后台处理中,处理完成后将通知您',
message: `${this.getImportTypeLabel(this.upload.importType)}正在后台处理中,处理完成后将通知您`,
type: 'info',
duration: 3000
});
@@ -780,14 +1198,15 @@ export default {
hasFailures: statusResult.failureCount > 0,
totalCount: statusResult.totalCount,
successCount: statusResult.successCount,
failureCount: statusResult.failureCount
failureCount: statusResult.failureCount,
importType: this.currentImportType
});
if (statusResult.status === 'SUCCESS') {
// 全部成功
this.$notify({
title: '导入完成',
message: `全部成功!共导入${statusResult.totalCount}条数据`,
message: `${this.getImportTypeLabel(this.currentImportType)}全部成功!共导入${statusResult.totalCount}条数据`,
type: 'success',
duration: 5000
});
@@ -797,7 +1216,7 @@ export default {
// 部分失败
this.$notify({
title: '导入完成',
message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}`,
message: `${this.getImportTypeLabel(this.currentImportType)}成功${statusResult.successCount}条,失败${statusResult.failureCount}`,
type: 'warning',
duration: 5000
});
@@ -866,6 +1285,7 @@ export default {
// 如果有失败记录,恢复按钮显示
if (savedTask.hasFailures && savedTask.taskId) {
this.currentTaskId = savedTask.taskId;
this.currentImportType = savedTask.importType || "recruitment";
this.showFailureButton = true;
}
},
@@ -875,7 +1295,7 @@ export default {
if (savedTask && savedTask.saveTime) {
const date = new Date(savedTask.saveTime);
const timeStr = this.parseTime(date, '{y}-{m}-{d} {h}:{i}');
return `上次导入: ${timeStr}`;
return `上次${this.getImportTypeLabel(savedTask.importType || 'recruitment')}: ${timeStr}`;
}
return '';
},
@@ -954,12 +1374,21 @@ export default {
},
// 提交上传文件
submitFileForm() {
if (this.isPreviewMode()) {
this.$modal.msgSuccess(`预览模式:已模拟提交${this.getImportTypeLabel(this.upload.importType)}`);
this.upload.open = false;
return;
}
this.$refs.upload.submit();
},
// 关闭导入对话框
handleImportDialogClose() {
this.upload.isUploading = false;
this.$refs.upload.clearFiles();
},
/** 导入类型展示 */
getImportTypeLabel(importType) {
return importType === "work" ? "历史工作经历导入" : "招聘信息导入";
}
}
};
@@ -973,4 +1402,8 @@ export default {
.el-divider {
margin: 16px 0;
}
.work-experience-empty {
padding: 24px 0 8px;
}
</style>

View File

@@ -0,0 +1,45 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const apiPath = path.resolve(
__dirname,
"../../src/api/ccdiIntermediary.js"
);
const source = fs.readFileSync(apiPath, "utf8");
[
"export function importPersonTemplate()",
"export function importPersonData(data, updateSupport)",
"export function getPersonImportStatus(taskId)",
"export function getPersonImportFailures(taskId, pageNum, pageSize)",
"export function importEnterpriseRelationTemplate()",
"export function importEnterpriseRelationData(data, updateSupport)",
"export function getEnterpriseRelationImportStatus(taskId)",
"export function getEnterpriseRelationImportFailures(taskId, pageNum, pageSize)",
"/ccdi/intermediary/importPersonTemplate",
"/ccdi/intermediary/importPersonData",
"/ccdi/intermediary/importPersonStatus/",
"/ccdi/intermediary/importPersonFailures/",
"/ccdi/intermediary/importEnterpriseRelationTemplate",
"/ccdi/intermediary/importEnterpriseRelationData",
"/ccdi/intermediary/importEnterpriseRelationStatus/",
"/ccdi/intermediary/importEnterpriseRelationFailures/",
].forEach((token) => {
assert(source.includes(token), `中介导入 API 缺少关键方法或路径: ${token}`);
});
[
"export function importEntityTemplate()",
"export function importEntityData(data, updateSupport)",
"export function getEntityImportStatus(taskId)",
"export function getEntityImportFailures(taskId, pageNum, pageSize)",
"/ccdi/intermediary/importEntityTemplate",
"/ccdi/intermediary/importEntityData",
"/ccdi/intermediary/importEntityStatus/",
"/ccdi/intermediary/importEntityFailures/",
].forEach((token) => {
assert(!source.includes(token), `中介导入 API 不应继续保留旧机构导入接口: ${token}`);
});
console.log("intermediary-import-api test passed");

View File

@@ -0,0 +1,40 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const componentPath = path.resolve(
__dirname,
"../../src/views/ccdiIntermediary/components/ImportDialog.vue"
);
const source = fs.readFileSync(componentPath, "utf8");
[
"scene: {",
"default: PERSON_SCENE",
"this.scene === ENTERPRISE_RELATION_SCENE",
"/ccdi/intermediary/importPersonData",
"/ccdi/intermediary/importEnterpriseRelationData",
"ccdi/intermediary/importPersonTemplate",
"ccdi/intermediary/importEnterpriseRelationTemplate",
"getPersonImportStatus",
"getEnterpriseRelationImportStatus",
"personSubType 为字典下拉;",
"本人行 relatedNumId 为空;",
"亲属行 relatedNumId 填关联中介本人证件号码。",
"只导入中介与机构关系;",
"统一社会信用代码必须已存在于系统机构表。"
].forEach((token) => {
assert(source.includes(token), `中介导入弹窗缺少场景驱动能力: ${token}`);
});
[
'label="导入类型"',
"个人中介",
"机构中介",
"formData.importType",
"handleImportTypeChange"
].forEach((token) => {
assert(!source.includes(token), `中介导入弹窗不应继续保留内部类型切换: ${token}`);
});
console.log("intermediary-import-dialog test passed");

View File

@@ -0,0 +1,28 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const pagePath = path.resolve(
__dirname,
"../../src/views/ccdiIntermediary/index.vue"
);
const source = fs.readFileSync(pagePath, "utf8");
[
"ccdi_intermediary_person_import_task",
"ccdi_intermediary_enterprise_relation_import_task",
"personFailureDialogVisible",
"enterpriseRelationFailureDialogVisible",
"personFailureList",
"enterpriseRelationFailureList",
"getPersonImportFailures",
"getEnterpriseRelationImportFailures",
"getPersonImportStatus",
"getEnterpriseRelationImportStatus",
"refreshCurrentDetail()",
"restoreImportTasks()"
].forEach((token) => {
assert(source.includes(token), `中介导入页面缺少任务恢复或失败记录状态: ${token}`);
});
console.log("intermediary-import-state test passed");

View File

@@ -0,0 +1,22 @@
const assert = require("assert");
const fs = require("fs");
const path = require("path");
const pagePath = path.resolve(
__dirname,
"../../src/views/ccdiIntermediary/index.vue"
);
const source = fs.readFileSync(pagePath, "utf8");
[
"导入中介和亲属信息",
"导入中介实体关联关系",
"查看中介和亲属信息导入失败记录",
"查看中介实体关联关系导入失败记录",
"personImportTask",
"enterpriseRelationImportTask"
].forEach((token) => {
assert(source.includes(token), `中介导入页面缺少按钮或任务状态: ${token}`);
});
console.log("intermediary-import-toolbar test passed");

View File

@@ -10,9 +10,14 @@ const detailDialogPath = path.resolve(
__dirname,
"../../src/views/ccdiIntermediary/components/DetailDialog.vue"
);
const pagePath = path.resolve(
__dirname,
"../../src/views/ccdiIntermediary/index.vue"
);
const editDialogSource = fs.readFileSync(editDialogPath, "utf8");
const detailDialogSource = fs.readFileSync(detailDialogPath, "utf8");
const pageSource = fs.readFileSync(pagePath, "utf8");
[
'label="中介子类型"',
@@ -52,4 +57,19 @@ assert(
"个人中介详情不应继续展示单独的关系类型字段"
);
[
"personFailureDialogVisible",
"enterpriseRelationFailureDialogVisible",
"viewPersonImportFailures",
"viewEnterpriseRelationImportFailures",
"clearPersonImportHistory",
"clearEnterpriseRelationImportHistory",
"refreshCurrentDetail()",
].forEach((token) => {
assert(
pageSource.includes(token),
`中介页面缺少导入失败记录或详情刷新逻辑: ${token}`
);
});
console.log("intermediary-person-edit-ui test passed");

View File

@@ -0,0 +1,44 @@
-- 员工招聘:招聘类型与候选人历史工作经历结构变更
-- 说明:
-- 1. 本脚本对应招聘功能正式表结构变更,需要进入代码仓库作为后续部署、测试和合并依据。
-- 2. 当前联调数据库已于 2026-04-20 手工/脚本执行过等价结构变更:
-- - ccdi_staff_recruitment 已存在 recruit_type 字段;
-- - ccdi_staff_recruitment_work 子表已创建。
-- 3. 后续环境执行前请先确认字段和表是否已存在;若已存在,不要重复执行 ALTER ADD COLUMN避免重复字段报错。
-- 4. 本脚本只负责结构与招聘类型基础回填,不包含演示/联调造数。
ALTER TABLE `ccdi_staff_recruitment`
ADD COLUMN `recruit_type` VARCHAR(20) NULL COMMENT '招聘类型SOCIAL-社招CAMPUS-校招' AFTER `cand_grad`;
UPDATE `ccdi_staff_recruitment`
SET `recruit_type` = CASE
WHEN `recruit_name` LIKE '%校园%' THEN 'CAMPUS'
ELSE 'SOCIAL'
END
WHERE `recruit_type` IS NULL
OR `recruit_type` = '';
ALTER TABLE `ccdi_staff_recruitment`
MODIFY COLUMN `recruit_type` VARCHAR(20) NOT NULL COMMENT '招聘类型SOCIAL-社招CAMPUS-校招';
CREATE TABLE IF NOT EXISTS `ccdi_staff_recruitment_work`
(
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`recruit_id` VARCHAR(32) NOT NULL COMMENT '关联招聘记录编号',
`sort_order` INT NOT NULL DEFAULT 1 COMMENT '排序号1 表示最近一段经历',
`company_name` VARCHAR(200) NOT NULL COMMENT '工作单位',
`department_name` VARCHAR(100) DEFAULT NULL COMMENT '所属部门',
`position_name` VARCHAR(100) DEFAULT NULL COMMENT '岗位名称',
`job_start_month` VARCHAR(7) NOT NULL COMMENT '入职年月,格式 YYYY-MM',
`job_end_month` VARCHAR(7) DEFAULT NULL COMMENT '离职年月,格式 YYYY-MM',
`departure_reason` VARCHAR(500) DEFAULT NULL COMMENT '离职原因',
`work_content` VARCHAR(1000) DEFAULT NULL COMMENT '主要工作内容',
`remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
`created_by` VARCHAR(20) DEFAULT NULL COMMENT '创建人',
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_by` VARCHAR(20) DEFAULT NULL COMMENT '更新人',
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_recruit_id` (`recruit_id`),
KEY `idx_recruit_id_sort_order` (`recruit_id`, `sort_order`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='招聘信息历史工作经历表';

View File

@@ -0,0 +1,153 @@
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
SET character_set_client = utf8mb4;
SET character_set_connection = utf8mb4;
SET character_set_results = utf8mb4;
INSERT INTO sys_dict_type (dict_name, dict_type, status, create_by, create_time, remark)
SELECT '人员子类型', 'ccdi_person_sub_type', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_type
WHERE dict_type = 'ccdi_person_sub_type'
);
UPDATE sys_dict_type
SET dict_name = '人员子类型',
status = '0',
remark = '中介黑名单-人员子类型',
update_by = 'admin',
update_time = NOW()
WHERE dict_type = 'ccdi_person_sub_type';
UPDATE sys_dict_data
SET dict_sort = 1,
dict_label = '本人',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '本人';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 1, '本人', '本人', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '本人'
);
UPDATE sys_dict_data
SET dict_sort = 2,
dict_label = '配偶',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '配偶';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 2, '配偶', '配偶', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '配偶'
);
UPDATE sys_dict_data
SET dict_sort = 3,
dict_label = '子女',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '子女';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 3, '子女', '子女', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '子女'
);
UPDATE sys_dict_data
SET dict_sort = 4,
dict_label = '父母',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '父母';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 4, '父母', '父母', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '父母'
);
UPDATE sys_dict_data
SET dict_sort = 5,
dict_label = '兄弟姐妹',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '兄弟姐妹';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 5, '兄弟姐妹', '兄弟姐妹', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '兄弟姐妹'
);
UPDATE sys_dict_data
SET dict_sort = 6,
dict_label = '其他',
css_class = '',
list_class = 'default',
is_default = 'N',
status = '0',
update_by = 'admin',
update_time = NOW(),
remark = '中介黑名单-人员子类型'
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '其他';
INSERT INTO sys_dict_data (dict_sort, dict_label, dict_value, dict_type, css_class, list_class, is_default, status, create_by, create_time, remark)
SELECT 6, '其他', '其他', 'ccdi_person_sub_type', '', 'default', 'N', '0', 'admin', NOW(), '中介黑名单-人员子类型'
WHERE NOT EXISTS (
SELECT 1
FROM sys_dict_data
WHERE dict_type = 'ccdi_person_sub_type'
AND dict_value = '其他'
);

View File

@@ -0,0 +1,73 @@
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
SET character_set_client = utf8mb4;
SET character_set_connection = utf8mb4;
SET character_set_results = utf8mb4;
-- 迁移前检查 1找不到对应本人 biz_id 的亲属记录
SELECT
child.biz_id AS child_biz_id,
child.name AS child_name,
child.person_id AS child_person_id,
child.related_num_id AS legacy_owner_biz_id
FROM ccdi_biz_intermediary child
LEFT JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.biz_id
AND parent.person_sub_type = '本人'
WHERE child.person_sub_type <> '本人'
AND (child.related_num_id IS NULL OR parent.biz_id IS NULL);
-- 迁移前检查 2本人 person_id 为空的记录
SELECT
child.biz_id AS child_biz_id,
child.name AS child_name,
child.person_id AS child_person_id,
child.related_num_id AS legacy_owner_biz_id,
parent.biz_id AS parent_biz_id,
parent.name AS parent_name,
parent.person_id AS parent_person_id
FROM ccdi_biz_intermediary child
JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.biz_id
AND parent.person_sub_type = '本人'
WHERE child.person_sub_type <> '本人'
AND (parent.person_id IS NULL OR TRIM(parent.person_id) = '');
-- 迁移前检查 3迁移后同一中介本人下 related_num_id + person_id 冲突的记录
SELECT
parent.person_id AS owner_person_id,
child.person_id AS relative_person_id,
COUNT(*) AS conflict_count,
GROUP_CONCAT(child.biz_id ORDER BY child.biz_id SEPARATOR ',') AS child_biz_ids
FROM ccdi_biz_intermediary child
JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.biz_id
AND parent.person_sub_type = '本人'
WHERE child.person_sub_type <> '本人'
AND parent.person_id IS NOT NULL
AND TRIM(parent.person_id) <> ''
GROUP BY parent.person_id, child.person_id
HAVING COUNT(*) > 1;
START TRANSACTION;
UPDATE ccdi_biz_intermediary child
JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.biz_id
SET child.related_num_id = parent.person_id
WHERE child.person_sub_type <> '本人';
COMMIT;
-- 迁移后检查:亲属记录应统一按本人证件号码关联
SELECT
child.biz_id AS child_biz_id,
child.related_num_id AS owner_person_id,
parent.biz_id AS parent_biz_id,
parent.person_id AS parent_person_id
FROM ccdi_biz_intermediary child
LEFT JOIN ccdi_biz_intermediary parent
ON child.related_num_id = parent.person_id
AND parent.person_sub_type = '本人'
WHERE child.person_sub_type <> '本人'
AND (child.related_num_id IS NULL OR parent.biz_id IS NULL);

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