diff --git a/doc/plans/2026-02-06-ccdi_purchase_transaction.md b/doc/plans/2026-02-06-ccdi_purchase_transaction.md new file mode 100644 index 0000000..ecc40f7 --- /dev/null +++ b/doc/plans/2026-02-06-ccdi_purchase_transaction.md @@ -0,0 +1,1210 @@ +# 员工采购交易信息管理功能实施计划 + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**目标:** 构建完整的员工采购交易信息管理模块,支持CRUD操作、分页查询、异步导入导出、批量删除等功能 + +**架构:** 基于若依框架前后端分离架构,后端使用MyBatis Plus + EasyExcel + Redis实现异步导入,前端使用Vue 2 + Element UI + +**技术栈:** +- 后端: Spring Boot 3.5.8, MyBatis Plus 3.5.10, EasyExcel, Redis +- 前端: Vue 2.6.12, Element UI 2.15.14, Axios +- 数据库: MySQL 8.2.0 + +--- + +## 前置条件 + +### 参考文档 +- 员工招聘信息模块: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/**/CcdiStaffRecruitment*` +- 员工异步导入实现: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeImportServiceImpl.java` +- 数据库表定义: `doc/docs/ccdi_purchase_transaction.csv` + +### 数据库表结构 +```sql +CREATE TABLE `ccdi_purchase_transaction` ( + `purchase_id` VARCHAR(32) NOT NULL COMMENT '采购事项ID', + `purchase_category` VARCHAR(50) NOT NULL COMMENT '采购类别', + `project_name` VARCHAR(200) DEFAULT NULL COMMENT '项目名称', + `subject_name` VARCHAR(200) NOT NULL COMMENT '标的物名称', + `subject_desc` TEXT COMMENT '标的物描述', + `purchase_qty` DECIMAL(12,4) NOT NULL DEFAULT 1 COMMENT '采购数量', + `budget_amount` DECIMAL(18,2) NOT NULL COMMENT '预算金额', + `bid_amount` DECIMAL(18,2) DEFAULT NULL COMMENT '中标金额', + `actual_amount` DECIMAL(18,2) DEFAULT NULL COMMENT '实际采购金额', + `contract_amount` DECIMAL(18,2) DEFAULT NULL COMMENT '合同金额', + `settlement_amount` DECIMAL(18,2) DEFAULT NULL COMMENT '结算金额', + `purchase_method` VARCHAR(50) NOT NULL COMMENT '采购方式', + `supplier_name` VARCHAR(200) DEFAULT NULL COMMENT '中标供应商名称', + `contact_person` VARCHAR(50) DEFAULT NULL COMMENT '供应商联系人', + `contact_phone` VARCHAR(20) DEFAULT NULL COMMENT '供应商联系电话', + `supplier_uscc` VARCHAR(18) DEFAULT NULL COMMENT '供应商统一信用代码', + `supplier_bank_account` VARCHAR(50) DEFAULT NULL COMMENT '供应商银行账户', + `apply_date` DATE NOT NULL COMMENT '采购申请日期', + `plan_approve_date` DATE DEFAULT NULL COMMENT '采购计划批准日期', + `announce_date` DATE DEFAULT NULL COMMENT '采购公告发布日期', + `bid_open_date` DATE DEFAULT NULL COMMENT '开标日期', + `contract_sign_date` DATE DEFAULT NULL COMMENT '合同签订日期', + `expected_delivery_date` DATE DEFAULT NULL COMMENT '预计交货日期', + `actual_delivery_date` DATE DEFAULT NULL COMMENT '实际交货日期', + `acceptance_date` DATE DEFAULT NULL COMMENT '验收日期', + `settlement_date` DATE DEFAULT NULL COMMENT '结算日期', + `applicant_id` VARCHAR(7) NOT NULL COMMENT '申请人工号', + `applicant_name` VARCHAR(50) NOT NULL COMMENT '申请人姓名', + `apply_department` VARCHAR(100) NOT NULL COMMENT '申请部门', + `purchase_leader_id` VARCHAR(7) DEFAULT NULL COMMENT '采购负责人工号', + `purchase_leader_name` VARCHAR(50) DEFAULT NULL COMMENT '采购负责人姓名', + `purchase_department` VARCHAR(100) DEFAULT NULL COMMENT '采购部门', + `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `created_by` VARCHAR(50) NOT NULL COMMENT '创建人', + `updated_by` VARCHAR(50) DEFAULT NULL COMMENT '更新人', + PRIMARY KEY (`purchase_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='员工采购交易信息表'; +``` + +--- + +## Task 1: 创建数据库表 + +**Files:** +- Modify: 执行SQL创建表 + +**Step 1: 连接数据库并创建表** + +使用MCP连接MySQL数据库: +```bash +# 使用 mcp__mysql__connect_db 连接到数据库 +# 然后执行上面的CREATE TABLE语句 +``` + +**Step 2: 验证表创建** + +```sql +SHOW CREATE TABLE ccdi_purchase_transaction; +``` + +Expected: 表结构正确创建 + +**Step 3: Commit** + +```bash +git add sql/ +git commit -m "feat: 添加员工采购交易信息表" +``` + +--- + +## Task 2: 创建实体类 + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/CcdiPurchaseTransaction.java` + +**Step 1: 创建实体类** + +```java +package com.ruoyi.ccdi.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 lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 员工采购交易信息对象 ccdi_purchase_transaction + * + * @author ruoyi + * @date 2026-02-06 + */ +@Data +public class CcdiPurchaseTransaction implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 采购事项ID */ + @TableId(type = IdType.INPUT) + private String purchaseId; + + /** 采购类别 */ + private String purchaseCategory; + + /** 项目名称 */ + private String projectName; + + /** 标的物名称 */ + private String subjectName; + + /** 标的物描述 */ + private String subjectDesc; + + /** 采购数量 */ + private BigDecimal purchaseQty; + + /** 预算金额 */ + private BigDecimal budgetAmount; + + /** 中标金额 */ + private BigDecimal bidAmount; + + /** 实际采购金额 */ + private BigDecimal actualAmount; + + /** 合同金额 */ + private BigDecimal contractAmount; + + /** 结算金额 */ + private BigDecimal settlementAmount; + + /** 采购方式 */ + private String purchaseMethod; + + /** 中标供应商名称 */ + private String supplierName; + + /** 供应商联系人 */ + private String contactPerson; + + /** 供应商联系电话 */ + private String contactPhone; + + /** 供应商统一信用代码 */ + private String supplierUscc; + + /** 供应商银行账户 */ + private String supplierBankAccount; + + /** 采购申请日期 */ + private LocalDate applyDate; + + /** 采购计划批准日期 */ + private LocalDate planApproveDate; + + /** 采购公告发布日期 */ + private LocalDate announceDate; + + /** 开标日期 */ + private LocalDate bidOpenDate; + + /** 合同签订日期 */ + private LocalDate contractSignDate; + + /** 预计交货日期 */ + private LocalDate expectedDeliveryDate; + + /** 实际交货日期 */ + private LocalDate actualDeliveryDate; + + /** 验收日期 */ + private LocalDate acceptanceDate; + + /** 结算日期 */ + private LocalDate settlementDate; + + /** 申请人工号 */ + private String applicantId; + + /** 申请人姓名 */ + private String applicantName; + + /** 申请部门 */ + private String applyDepartment; + + /** 采购负责人工号 */ + private String purchaseLeaderId; + + /** 采购负责人姓名 */ + private String purchaseLeaderName; + + /** 采购部门 */ + private String purchaseDepartment; + + /** 创建时间 */ + @TableField(fill = FieldFill.INSERT) + private LocalDateTime createTime; + + /** 更新时间 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private LocalDateTime updateTime; + + /** 创建人 */ + @TableField(fill = FieldFill.INSERT) + private String createdBy; + + /** 更新人 */ + @TableField(fill = FieldFill.INSERT_UPDATE) + private String updatedBy; +} +``` + +**Step 2: 验证编译** + +```bash +cd ruoyi-ccdi +mvn compile -pl . -am +``` + +Expected: 编译成功,无错误 + +**Step 3: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/CcdiPurchaseTransaction.java +git commit -m "feat: 添加采购交易信息实体类" +``` + +--- + +## Task 3: 创建查询DTO + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/dto/CcdiPurchaseTransactionQueryDTO.java` + +**Step 1: 创建查询DTO** + +```java +package com.ruoyi.ccdi.domain.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.time.LocalDate; + +/** + * 采购交易信息查询DTO + * + * @author ruoyi + * @date 2026-02-06 + */ +@Data +@Schema(description = "采购交易信息查询条件") +public class CcdiPurchaseTransactionQueryDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + /** 项目名称 */ + @Schema(description = "项目名称") + private String projectName; + + /** 标的物名称 */ + @Schema(description = "标的物名称") + private String subjectName; + + /** 申请人姓名 */ + @Schema(description = "申请人姓名") + private String applicantName; + + /** 申请人工号 */ + @Schema(description = "申请人工号") + private String applicantId; + + /** 申请日期-开始 */ + @Schema(description = "申请日期-开始") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate applyDateStart; + + /** 申请日期-结束 */ + @Schema(description = "申请日期-结束") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate applyDateEnd; +} +``` + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/dto/CcdiPurchaseTransactionQueryDTO.java +git commit -m "feat: 添加采购交易查询DTO" +``` + +--- + +## Task 4: 创建新增DTO + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/dto/CcdiPurchaseTransactionAddDTO.java` + +**Step 1: 创建新增DTO(包含验证注解)** + +```java +package com.ruoyi.ccdi.domain.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; + +/** + * 采购交易信息新增DTO + * + * @author ruoyi + * @date 2026-02-06 + */ +@Data +@Schema(description = "采购交易信息新增") +public class CcdiPurchaseTransactionAddDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "采购事项ID") + @NotBlank(message = "采购事项ID不能为空") + @Size(max = 32, message = "采购事项ID长度不能超过32个字符") + private String purchaseId; + + @Schema(description = "采购类别") + @NotBlank(message = "采购类别不能为空") + @Size(max = 50, message = "采购类别长度不能超过50个字符") + private String purchaseCategory; + + @Schema(description = "项目名称") + @Size(max = 200, message = "项目名称长度不能超过200个字符") + private String projectName; + + @Schema(description = "标的物名称") + @NotBlank(message = "标的物名称不能为空") + @Size(max = 200, message = "标的物名称长度不能超过200个字符") + private String subjectName; + + @Schema(description = "标的物描述") + private String subjectDesc; + + @Schema(description = "采购数量") + @NotNull(message = "采购数量不能为空") + @DecimalMin(value = "0.0001", message = "采购数量必须大于0") + private BigDecimal purchaseQty; + + @Schema(description = "预算金额") + @NotNull(message = "预算金额不能为空") + @DecimalMin(value = "0.01", message = "预算金额必须大于0") + private BigDecimal budgetAmount; + + @Schema(description = "中标金额") + @DecimalMin(value = "0", message = "中标金额不能为负数") + private BigDecimal bidAmount; + + @Schema(description = "实际采购金额") + @DecimalMin(value = "0", message = "实际采购金额不能为负数") + private BigDecimal actualAmount; + + @Schema(description = "合同金额") + @DecimalMin(value = "0", message = "合同金额不能为负数") + private BigDecimal contractAmount; + + @Schema(description = "结算金额") + @DecimalMin(value = "0", message = "结算金额不能为负数") + private BigDecimal settlementAmount; + + @Schema(description = "采购方式") + @NotBlank(message = "采购方式不能为空") + @Size(max = 50, message = "采购方式长度不能超过50个字符") + private String purchaseMethod; + + @Schema(description = "中标供应商名称") + @Size(max = 200, message = "中标供应商名称长度不能超过200个字符") + private String supplierName; + + @Schema(description = "供应商联系人") + @Size(max = 50, message = "供应商联系人长度不能超过50个字符") + private String contactPerson; + + @Schema(description = "供应商联系电话") + @Size(max = 20, message = "供应商联系电话长度不能超过20个字符") + private String contactPhone; + + @Schema(description = "供应商统一信用代码") + @Size(max = 18, message = "供应商统一信用代码长度不能超过18个字符") + private String supplierUscc; + + @Schema(description = "供应商银行账户") + @Size(max = 50, message = "供应商银行账户长度不能超过50个字符") + private String supplierBankAccount; + + @Schema(description = "采购申请日期") + @NotNull(message = "采购申请日期不能为空") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate applyDate; + + @Schema(description = "采购计划批准日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate planApproveDate; + + @Schema(description = "采购公告发布日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate announceDate; + + @Schema(description = "开标日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate bidOpenDate; + + @Schema(description = "合同签订日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate contractSignDate; + + @Schema(description = "预计交货日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate expectedDeliveryDate; + + @Schema(description = "实际交货日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate actualDeliveryDate; + + @Schema(description = "验收日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate acceptanceDate; + + @Schema(description = "结算日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate settlementDate; + + @Schema(description = "申请人工号") + @NotBlank(message = "申请人工号不能为空") + @Pattern(regexp = "^\\d{7}$", message = "申请人工号必须为7位数字") + private String applicantId; + + @Schema(description = "申请人姓名") + @NotBlank(message = "申请人姓名不能为空") + @Size(max = 50, message = "申请人姓名长度不能超过50个字符") + private String applicantName; + + @Schema(description = "申请部门") + @NotBlank(message = "申请部门不能为空") + @Size(max = 100, message = "申请部门长度不能超过100个字符") + private String applyDepartment; + + @Schema(description = "采购负责人工号") + @Pattern(regexp = "^\\d{7}$", message = "采购负责人工号必须为7位数字") + private String purchaseLeaderId; + + @Schema(description = "采购负责人姓名") + @Size(max = 50, message = "采购负责人姓名长度不能超过50个字符") + private String purchaseLeaderName; + + @Schema(description = "采购部门") + @Size(max = 100, message = "采购部门长度不能超过100个字符") + private String purchaseDepartment; +} +``` + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/dto/CcdiPurchaseTransactionAddDTO.java +git commit -m "feat: 添加采购交易新增DTO" +``` + +--- + +## Task 5: 创建编辑DTO + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/dto/CcdiPurchaseTransactionEditDTO.java` + +**Step 1: 创建编辑DTO** + +复制AddDTO的内容,修改类名和类注释,所有验证注解保持不变。 + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/dto/CcdiPurchaseTransactionEditDTO.java +git commit -m "feat: 添加采购交易编辑DTO" +``` + +--- + +## Task 6: 创建VO类 + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/CcdiPurchaseTransactionVO.java` + +**Step 1: 创建VO类** + +```java +package com.ruoyi.ccdi.domain.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** + * 采购交易信息VO + * + * @author ruoyi + * @date 2026-02-06 + */ +@Data +@Schema(description = "采购交易信息") +public class CcdiPurchaseTransactionVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "采购事项ID") + private String purchaseId; + + @Schema(description = "采购类别") + private String purchaseCategory; + + @Schema(description = "项目名称") + private String projectName; + + @Schema(description = "标的物名称") + private String subjectName; + + @Schema(description = "标的物描述") + private String subjectDesc; + + @Schema(description = "采购数量") + private BigDecimal purchaseQty; + + @Schema(description = "预算金额") + private BigDecimal budgetAmount; + + @Schema(description = "中标金额") + private BigDecimal bidAmount; + + @Schema(description = "实际采购金额") + private BigDecimal actualAmount; + + @Schema(description = "合同金额") + private BigDecimal contractAmount; + + @Schema(description = "结算金额") + private BigDecimal settlementAmount; + + @Schema(description = "采购方式") + private String purchaseMethod; + + @Schema(description = "中标供应商名称") + private String supplierName; + + @Schema(description = "供应商联系人") + private String contactPerson; + + @Schema(description = "供应商联系电话") + private String contactPhone; + + @Schema(description = "供应商统一信用代码") + private String supplierUscc; + + @Schema(description = "供应商银行账户") + private String supplierBankAccount; + + @Schema(description = "采购申请日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate applyDate; + + @Schema(description = "采购计划批准日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate planApproveDate; + + @Schema(description = "采购公告发布日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate announceDate; + + @Schema(description = "开标日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate bidOpenDate; + + @Schema(description = "合同签订日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate contractSignDate; + + @Schema(description = "预计交货日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate expectedDeliveryDate; + + @Schema(description = "实际交货日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate actualDeliveryDate; + + @Schema(description = "验收日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate acceptanceDate; + + @Schema(description = "结算日期") + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate settlementDate; + + @Schema(description = "申请人工号") + private String applicantId; + + @Schema(description = "申请人姓名") + private String applicantName; + + @Schema(description = "申请部门") + private String applyDepartment; + + @Schema(description = "采购负责人工号") + private String purchaseLeaderId; + + @Schema(description = "采购负责人姓名") + private String purchaseLeaderName; + + @Schema(description = "采购部门") + private String purchaseDepartment; + + @Schema(description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @Schema(description = "更新时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; + + @Schema(description = "创建人") + private String createdBy; + + @Schema(description = "更新人") + private String updatedBy; +} +``` + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/CcdiPurchaseTransactionVO.java +git commit -m "feat: 添加采购交易VO类" +``` + +--- + +## Task 7: 创建Excel导入导出类 + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/excel/CcdiPurchaseTransactionExcel.java` + +**Step 1: 创建Excel类** + +参考 `CcdiStaffRecruitmentExcel.java`,为36个字段添加 `@ExcelProperty`、`@ColumnWidth`、`@Required` 注解。必填字段:purchaseId, purchaseCategory, subjectName, purchaseQty, budgetAmount, purchaseMethod, applyDate, applicantId, applicantName, applyDepartment。 + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/excel/CcdiPurchaseTransactionExcel.java +git commit -m "feat: 添加采购交易Excel类" +``` + +--- + +## Task 8: 创建Mapper接口 + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/mapper/CcdiPurchaseTransactionMapper.java` + +**Step 1: 创建Mapper接口** + +```java +package com.ruoyi.ccdi.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.ruoyi.ccdi.domain.CcdiPurchaseTransaction; +import com.ruoyi.ccdi.domain.dto.CcdiPurchaseTransactionQueryDTO; +import com.ruoyi.ccdi.domain.vo.CcdiPurchaseTransactionVO; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 采购交易信息 数据层 + * + * @author ruoyi + * @date 2026-02-06 + */ +public interface CcdiPurchaseTransactionMapper extends BaseMapper { + + /** + * 分页查询采购交易列表 + */ + Page selectTransactionPage(@Param("page") Page page, + @Param("query") CcdiPurchaseTransactionQueryDTO queryDTO); + + /** + * 查询采购交易详情 + */ + CcdiPurchaseTransactionVO selectTransactionById(@Param("purchaseId") String purchaseId); + + /** + * 批量插入 + */ + int insertBatch(@Param("list") List list); + + /** + * 批量更新(先删除再插入) + */ + int insertOrUpdateBatch(@Param("list") List list); +} +``` + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/mapper/CcdiPurchaseTransactionMapper.java +git commit -m "feat: 添加采购交易Mapper接口" +``` + +--- + +## Task 9: 创建Mapper XML文件 + +**Files:** +- Create: `ruoyi-ccdi/src/main/resources/mapper/ccdi/CcdiPurchaseTransactionMapper.xml` + +**Step 1: 创建XML映射文件** + +参考 `CcdiStaffRecruitmentMapper.xml`,编写SQL映射: +- selectTransactionPage: 分页查询,支持项目名称、标的物名称、申请人、日期范围查询 +- selectTransactionById: 根据ID查询详情 +- insertBatch: 批量插入 +- insertOrUpdateBatch: 批量更新(先删除再插入) + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/resources/mapper/ccdi/CcdiPurchaseTransactionMapper.xml +git commit -m "feat: 添加采购交易Mapper XML" +``` + +--- + +## Task 10: 创建Service接口 + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiPurchaseTransactionService.java` + +**Step 1: 创建Service接口** + +参考 `ICcdiStaffRecruitmentService.java`,定义以下方法: +- selectTransactionList +- selectTransactionPage +- selectTransactionListForExport +- selectTransactionById +- insertTransaction +- updateTransaction +- deleteTransactionByIds +- importTransaction (返回taskId) + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiPurchaseTransactionService.java +git commit -m "feat: 添加采购交易Service接口" +``` + +--- + +## Task 11: 创建异步导入Service接口 + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiPurchaseTransactionImportService.java` + +**Step 1: 创建异步导入Service接口** + +参考 `ICcdiEmployeeImportService.java`,定义方法: +- importTransactionAsync +- getImportStatus +- getImportFailures + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiPurchaseTransactionImportService.java +git commit -m "feat: 添加采购交易异步导入Service接口" +``` + +--- + +## Task 12: 创建Service实现类 + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiPurchaseTransactionServiceImpl.java` + +**Step 1: 创建Service实现** + +参考 `CcdiStaffRecruitmentServiceImpl.java`,实现所有CRUD方法和importTransaction方法(初始化Redis状态,调用异步服务)。 + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiPurchaseTransactionServiceImpl.java +git commit -m "feat: 添加采购交易Service实现" +``` + +--- + +## Task 13: 创建异步导入Service实现类 + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiPurchaseTransactionImportServiceImpl.java` + +**Step 1: 创建异步导入实现** + +参考 `CcdiEmployeeImportServiceImpl.java`,实现: +- @Async + @Transactional 注解 +- importTransactionAsync方法(接收userName参数) +- 数据分类(新记录/更新记录/失败记录) +- 批量插入和更新 +- Redis状态管理 +- 数据验证逻辑 + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiPurchaseTransactionImportServiceImpl.java +git commit -m "feat: 添加采购交易异步导入Service实现" +``` + +--- + +## Task 14: 创建Controller控制器 + +**Files:** +- Create: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiPurchaseTransactionController.java` + +**Step 1: 创建Controller** + +参考 `CcdiStaffRecruitmentController.java` 和 `CcdiEmployeeController.java`,实现接口: +- GET /list - 分页查询 +- GET /{purchaseId} - 查询详情 +- POST / - 新增 +- PUT / - 修改 +- DELETE /{purchaseIds} - 删除 +- POST /export - 导出 +- POST /importTemplate - 下载模板 +- POST /importData - 异步导入 +- GET /importStatus/{taskId} - 查询导入状态 +- GET /importFailures/{taskId} - 查询失败记录 + +添加完整的Swagger注解。 + +**Step 2: Commit** + +```bash +git add ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiPurchaseTransactionController.java +git commit -m "feat: 添加采购交易Controller" +``` + +--- + +## Task 15: 创建前端API文件 + +**Files:** +- Create: `ruoyi-ui/src/api/ccdiPurchaseTransaction.js` + +**Step 1: 创建API定义** + +```javascript +import request from '@/utils/request' + +// 查询采购交易列表 +export function listTransaction(query) { + return request({ + url: '/ccdi/purchaseTransaction/list', + method: 'get', + params: query + }) +} + +// 查询采购交易详情 +export function getTransaction(purchaseId) { + return request({ + url: '/ccdi/purchaseTransaction/' + purchaseId, + method: 'get' + }) +} + +// 新增采购交易 +export function addTransaction(data) { + return request({ + url: '/ccdi/purchaseTransaction', + method: 'post', + data: data + }) +} + +// 修改采购交易 +export function updateTransaction(data) { + return request({ + url: '/ccdi/purchaseTransaction', + method: 'put', + data: data + }) +} + +// 删除采购交易 +export function delTransaction(purchaseIds) { + return request({ + url: '/ccdi/purchaseTransaction/' + purchaseIds, + method: 'delete' + }) +} + +// 导出采购交易 +export function exportTransaction(query) { + return request({ + url: '/ccdi/purchaseTransaction/export', + method: 'post', + params: query + }) +} + +// 下载导入模板 +export function importTemplate() { + return request({ + url: '/ccdi/purchaseTransaction/importTemplate', + method: 'post' + }) +} + +// 导入采购交易 +export function importData(file, updateSupport) { + const formData = new FormData() + formData.append('file', file) + formData.append('updateSupport', updateSupport) + return request({ + url: '/ccdi/purchaseTransaction/importData', + method: 'post', + data: formData + }) +} + +// 查询导入状态 +export function getImportStatus(taskId) { + return request({ + url: '/ccdi/purchaseTransaction/importStatus/' + taskId, + method: 'get' + }) +} + +// 查询导入失败记录 +export function getImportFailures(taskId, pageNum, pageSize) { + return request({ + url: '/ccdi/purchaseTransaction/importFailures/' + taskId, + method: 'get', + params: { pageNum, pageSize } + }) +} +``` + +**Step 2: Commit** + +```bash +git add ruoyi-ui/src/api/ccdiPurchaseTransaction.js +git commit -m "feat: 添加采购交易前端API" +``` + +--- + +## Task 16: 创建前端页面组件 + +**Files:** +- Create: `ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue` + +**Step 1: 创建页面组件** + +参考 `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`,实现: +- 查询表单(项目名称、标的物名称、申请人、日期范围) +- 列表表格(13个字段 + 操作列) +- 新增/编辑对话框 +- 导入对话框(支持异步导入状态轮询) +- 删除确认 + +**Step 2: Commit** + +```bash +git add ruoyi-ui/src/views/ccdiPurchaseTransaction/index.vue +git commit -m "feat: 添加采购交易管理页面" +``` + +--- + +## Task 17: 配置菜单和权限 + +**Files:** +- Modify: 数据库菜单表 + +**Step 1: 添加菜单数据** + +执行SQL: +```sql +-- 添加采购交易管理菜单 +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark) +VALUES +('采购交易管理', (SELECT menu_id FROM sys_menu WHERE menu_name='CCDI管理' AND parent_id=0), 5, 'purchaseTransaction', 'ccdiPurchaseTransaction/index', 1, 0, 'C', '0', '0', 'ccdi:purchaseTransaction:list', 'shopping', 'admin', NOW(), '', NULL, '采购交易信息管理菜单'); + +-- 获取刚插入的菜单ID +SET @menu_id = LAST_INSERT_ID(); + +-- 添加按钮权限 +INSERT INTO sys_menu (menu_name, parent_id, order_num, path, component, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, remark) VALUES +('采购交易查询', @menu_id, 1, '', '', 1, 0, 'F', '0', '0', 'ccdi:purchaseTransaction:query', '#', 'admin', NOW(), ''), +('采购交易新增', @menu_id, 2, '', '', 1, 0, 'F', '0', '0', 'ccdi:purchaseTransaction:add', '#', 'admin', NOW(), ''), +('采购交易修改', @menu_id, 3, '', '', 1, 0, 'F', '0', '0', 'ccdi:purchaseTransaction:edit', '#', 'admin', NOW(), ''), +('采购交易删除', @menu_id, 4, '', '', 1, 0, 'F', '0', '0', 'ccdi:purchaseTransaction:remove', '#', 'admin', NOW(), ''), +('采购交易导出', @menu_id, 5, '', '', 1, 0, 'F', '0', '0', 'ccdi:purchaseTransaction:export', '#', 'admin', NOW(), ''), +('采购交易导入', @menu_id, 6, '', '', 1, 0, 'F', '0', '0', 'ccdi:purchaseTransaction:import', '#', 'admin', NOW(), ''); +``` + +**Step 2: Commit** + +```bash +git add sql/ +git commit -m "feat: 配置采购交易管理菜单和权限" +``` + +--- + +## Task 18: 生成测试脚本 + +**Files:** +- Create: `test/test_purchase_transaction_api.ps1` + +**Step 1: 创建测试脚本** + +参考 `test/test_employee_api.ps1`,编写完整的测试脚本: +- 登录获取token +- 测试分页查询 +- 测试新增 +- 测试查询详情 +- 测试修改 +- 测试删除 +- 测试导入 +- 测试导出 + +**Step 2: Commit** + +```bash +git add test/test_purchase_transaction_api.ps1 +git commit -m "test: 添加采购交易API测试脚本" +``` + +--- + +## Task 19: 运行测试并生成报告 + +**Step 1: 运行测试脚本** + +```powershell +cd test +./test_purchase_transaction_api.ps1 > test_report_$(Get-Date -Format 'yyyyMMdd_HHmmss').txt +``` + +**Step 2: 验证所有接口正常** + +Expected: 所有测试用例通过 + +**Step 3: 保存测试报告** + +将测试报告保存到 `doc/test-data/purchase_transaction/` 目录。 + +**Step 4: Commit** + +```bash +git add doc/test-data/purchase_transaction/ +git commit -m "test: 添加采购交易测试报告" +``` + +--- + +## Task 20: 生成API文档 + +**Files:** +- Create: `doc/api/ccdi_purchase_transaction_api.md` + +**Step 1: 导出Swagger API文档** + +访问 http://localhost:8080/swagger-ui/index.html,导出采购交易管理的API文档。 + +**Step 2: 整理为Markdown格式** + +整理所有接口的请求参数、响应格式、示例。 + +**Step 3: Commit** + +```bash +git add doc/api/ccdi_purchase_transaction_api.md +git commit -m "docs: 添加采购交易API文档" +``` + +--- + +## Task 21: 最终验证和清理 + +**Step 1: 完整功能测试** + +1. 启动后端服务 +2. 启动前端服务 +3. 登录系统 +4. 导航到采购交易管理菜单 +5. 测试所有功能: + - 列表查询 + - 新增记录 + - 编辑记录 + - 删除记录 + - 批量删除 + - 导入功能(含异步状态轮询) + - 导出功能 + +**Step 2: 代码审查** + +检查: +- 所有DTO验证注解完整 +- 所有Swagger注解完整 +- 异常处理完善 +- 日志记录完整 +- 审计字段正确填充 + +**Step 3: 性能测试** + +测试批量导入1000条数据的性能。 + +**Step 4: 最终Commit** + +```bash +git add . +git commit -m "feat: 完成采购交易信息管理功能开发" +``` + +--- + +## 实施注意事项 + +1. **必填字段**: purchaseId, purchaseCategory, subjectName, purchaseQty, budgetAmount, purchaseMethod, applyDate, applicantId, applicantName, applyDepartment +2. **异步导入**: 使用@Async + @Transactional + Redis +3. **批量操作**: 每批500条记录 +4. **更新策略**: 先批量删除,再批量插入 +5. **数据验证**: Jakarta Validation + 自定义业务验证 +6. **审计字段**: 使用@TableField(fill = FieldFill.INSERT/INSERT_UPDATE) +7. **错误处理**: 只返回失败记录,不返回成功记录 +8. **导入轮询**: 每2秒轮询一次状态 + +## 参考文件 + +- 员工招聘信息模块: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/**/CcdiStaffRecruitment*` +- 员工异步导入: `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiEmployeeImportServiceImpl.java` +- 前端页面: `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` +- 测试脚本: `test/test_employee_api.ps1` diff --git a/doc/plans/2026-02-06-recruitment-async-import-design.md b/doc/plans/2026-02-06-recruitment-async-import-design.md new file mode 100644 index 0000000..b2da827 --- /dev/null +++ b/doc/plans/2026-02-06-recruitment-async-import-design.md @@ -0,0 +1,846 @@ +# 招聘信息异步导入功能设计文档 + +**创建日期:** 2026-02-06 +**设计目标:** 将招聘信息管理的文件导入功能改造为异步实现,完全复用员工信息异步导入的架构模式 +**数据量预期:** 小批量(通常<500条) + +--- + +## 一、架构概述 + +### 1.1 核心架构 + +招聘信息异步导入完全复用员工信息异步导入的架构模式: + +- **异步处理层**: 使用Spring `@Async`注解,通过现有的`importExecutor`线程池执行异步任务 +- **状态存储层**: 使用Redis Hash存储导入状态,Key格式为`import:recruitment:{taskId}`,TTL为7天 +- **失败记录层**: 使用Redis String存储失败记录,Key格式为`import:recruitment:{taskId}:failures` +- **API层**: 提供三个接口 - 导入接口(返回taskId)、状态查询接口、失败记录查询接口 + +### 1.2 数据流程 + +``` +前端上传Excel + ↓ +Controller解析并立即返回taskId + ↓ +异步服务在后台处理: + 1. 数据验证 + 2. 分类(新增/更新) + 3. 批量操作 + 4. 保存结果到Redis + ↓ +前端每2秒轮询状态 + ↓ +状态变为SUCCESS/PARTIAL_SUCCESS/FAILED + ↓ +如有失败,显示"查看失败记录"按钮 +``` + +### 1.3 Redis Key设计 + +- **状态Key**: `import:recruitment:{taskId}` (Hash结构) +- **失败记录Key**: `import:recruitment:{taskId}:failures` (String结构,存储JSON数组) +- **TTL**: 7天 + +### 1.4 状态枚举 + +| 状态值 | 说明 | 前端行为 | +|--------|------|----------| +| PROCESSING | 处理中 | 继续轮询 | +| SUCCESS | 全部成功 | 显示成功通知,刷新列表 | +| PARTIAL_SUCCESS | 部分成功 | 显示警告通知,显示失败按钮 | +| FAILED | 全部失败 | 显示错误通知,显示失败按钮 | + +--- + +## 二、组件设计 + +### 2.1 VO类设计 + +#### 2.1.1 ImportResultVO (复用员工导入) + +```java +@Data +@Schema(description = "导入结果") +public class ImportResultVO { + @Schema(description = "任务ID") + private String taskId; + + @Schema(description = "状态: PROCESSING-处理中, SUCCESS-成功, PARTIAL_SUCCESS-部分成功, FAILED-失败") + private String status; + + @Schema(description = "消息") + private String message; +} +``` + +#### 2.1.2 ImportStatusVO (复用员工导入) + +```java +@Data +@Schema(description = "导入状态") +public class ImportStatusVO { + @Schema(description = "任务ID") + private String taskId; + + @Schema(description = "状态") + private String status; + + @Schema(description = "总记录数") + private Integer totalCount; + + @Schema(description = "成功数") + private Integer successCount; + + @Schema(description = "失败数") + private Integer failureCount; + + @Schema(description = "进度百分比") + private Integer progress; + + @Schema(description = "开始时间戳") + private Long startTime; + + @Schema(description = "结束时间戳") + private Long endTime; + + @Schema(description = "状态消息") + private String message; +} +``` + +#### 2.1.3 RecruitmentImportFailureVO (新建,适配招聘信息) + +```java +@Data +@Schema(description = "招聘信息导入失败记录") +public class RecruitmentImportFailureVO { + + @Schema(description = "招聘项目编号") + private String recruitId; + + @Schema(description = "招聘项目名称") + private String recruitName; + + @Schema(description = "应聘人员姓名") + private String candName; + + @Schema(description = "证件号码") + private String candId; + + @Schema(description = "录用情况") + private String admitStatus; + + @Schema(description = "错误信息") + private String errorMessage; +} +``` + +### 2.2 Service层设计 + +#### 2.2.1 接口定义 + +```java +public interface ICcdiStaffRecruitmentImportService { + + /** + * 异步导入招聘信息数据 + * + * @param excelList Excel数据列表 + * @param isUpdateSupport 是否更新已存在的数据 + * @param taskId 任务ID + */ + void importRecruitmentAsync(List excelList, + Boolean isUpdateSupport, + String taskId); + + /** + * 查询导入状态 + * + * @param taskId 任务ID + * @return 导入状态信息 + */ + ImportStatusVO getImportStatus(String taskId); + + /** + * 获取导入失败记录 + * + * @param taskId 任务ID + * @return 失败记录列表 + */ + List getImportFailures(String taskId); +} +``` + +#### 2.2.2 实现类核心逻辑 + +**类注解:** +```java +@Service +@EnableAsync +public class CcdiStaffRecruitmentImportServiceImpl + implements ICcdiStaffRecruitmentImportService { + + @Resource + private CcdiStaffRecruitmentMapper recruitmentMapper; + + @Resource + private RedisTemplate redisTemplate; +} +``` + +**异步导入方法:** +```java +@Override +@Async +public void importRecruitmentAsync(List excelList, + Boolean isUpdateSupport, + String taskId) { + List newRecords = new ArrayList<>(); + List updateRecords = new ArrayList<>(); + List failures = new ArrayList<>(); + + // 1. 批量查询已存在的招聘项目编号 + Set existingRecruitIds = getExistingRecruitIds(excelList); + + // 2. 分类数据 + for (CcdiStaffRecruitmentExcel excel : excelList) { + try { + // 验证数据 + validateRecruitmentData(excel, isUpdateSupport, existingRecruitIds); + + CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); + BeanUtils.copyProperties(excel, recruitment); + + if (existingRecruitIds.contains(excel.getRecruitId())) { + if (isUpdateSupport) { + updateRecords.add(recruitment); + } else { + throw new RuntimeException("该招聘项目编号已存在"); + } + } else { + newRecords.add(recruitment); + } + } catch (Exception e) { + RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setErrorMessage(e.getMessage()); + failures.add(failure); + } + } + + // 3. 批量插入新数据 + if (!newRecords.isEmpty()) { + recruitmentMapper.insertBatch(newRecords); + } + + // 4. 批量更新已有数据 + if (!updateRecords.isEmpty() && isUpdateSupport) { + recruitmentMapper.updateBatch(updateRecords); + } + + // 5. 保存失败记录到Redis + if (!failures.isEmpty()) { + String failuresKey = "import:recruitment:" + taskId + ":failures"; + redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); + } + + // 6. 更新最终状态 + ImportResult result = new ImportResult(); + result.setTotalCount(excelList.size()); + result.setSuccessCount(newRecords.size() + updateRecords.size()); + result.setFailureCount(failures.size()); + + String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; + updateImportStatus(taskId, finalStatus, result); +} +``` + +### 2.3 Controller层设计 + +#### 2.3.1 修改导入接口 + +```java +@PostMapping("/importData") +public AjaxResult importData(MultipartFile file, boolean updateSupport) throws Exception { + List list = EasyExcelUtil.importExcel( + file.getInputStream(), + CcdiStaffRecruitmentExcel.class + ); + + if (list == null || list.isEmpty()) { + return error("至少需要一条数据"); + } + + // 生成任务ID + String taskId = UUID.randomUUID().toString(); + + // 提交异步任务 + importAsyncService.importRecruitmentAsync(list, updateSupport, taskId); + + // 立即返回,不等待后台任务完成 + ImportResultVO result = new ImportResultVO(); + result.setTaskId(taskId); + result.setStatus("PROCESSING"); + result.setMessage("导入任务已提交,正在后台处理"); + + return AjaxResult.success("导入任务已提交,正在后台处理", result); +} +``` + +#### 2.3.2 新增状态查询接口 + +```java +@GetMapping("/importStatus/{taskId}") +public AjaxResult getImportStatus(@PathVariable String taskId) { + try { + ImportStatusVO status = importAsyncService.getImportStatus(taskId); + return success(status); + } catch (Exception e) { + return error(e.getMessage()); + } +} +``` + +#### 2.3.3 新增失败记录查询接口 + +```java +@GetMapping("/importFailures/{taskId}") +public TableDataInfo getImportFailures( + @PathVariable String taskId, + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize) { + + List failures = + importAsyncService.getImportFailures(taskId); + + // 手动分页 + int fromIndex = (pageNum - 1) * pageSize; + int toIndex = Math.min(fromIndex + pageSize, failures.size()); + + List pageData = failures.subList(fromIndex, toIndex); + + return getDataTable(pageData, failures.size()); +} +``` + +--- + +## 三、数据验证与错误处理 + +### 3.1 数据验证规则 + +#### 3.1.1 必填字段验证 + +- 招聘项目编号 (`recruitId`) +- 招聘项目名称 (`recruitName`) +- 职位名称 (`posName`) +- 职位类别 (`posCategory`) +- 职位描述 (`posDesc`) +- 应聘人员姓名 (`candName`) +- 应聘人员学历 (`candEdu`) +- 证件号码 (`candId`) +- 应聘人员毕业院校 (`candSchool`) +- 应聘人员专业 (`candMajor`) +- 应聘人员毕业年月 (`candGrad`) +- 录用情况 (`admitStatus`) + +#### 3.1.2 格式验证 + +```java +// 证件号码格式验证 +String idCardError = IdCardUtil.getErrorMessage(excel.getCandId()); +if (idCardError != null) { + throw new RuntimeException("证件号码" + idCardError); +} + +// 毕业年月格式验证(YYYYMM) +if (!excel.getCandGrad().matches("^((19|20)\\d{2})(0[1-9]|1[0-2])$")) { + throw new RuntimeException("毕业年月格式不正确,应为YYYYMM"); +} + +// 录用情况验证 +if (AdmitStatus.getDescByCode(excel.getAdmitStatus()) == null) { + throw new RuntimeException("录用情况只能填写'录用'、'未录用'或'放弃'"); +} +``` + +#### 3.1.3 唯一性验证 + +```java +// 批量查询已存在的招聘项目编号 +private Set getExistingRecruitIds(List excelList) { + List recruitIds = excelList.stream() + .map(CcdiStaffRecruitmentExcel::getRecruitId) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toList()); + + if (recruitIds.isEmpty()) { + return Collections.emptySet(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(CcdiStaffRecruitment::getRecruitId, recruitIds); + List existingRecruitments = + recruitmentMapper.selectList(wrapper); + + return existingRecruitments.stream() + .map(CcdiStaffRecruitment::getRecruitId) + .collect(Collectors.toSet()); +} +``` + +### 3.2 错误处理流程 + +#### 3.2.1 单条数据错误 + +```java +try { + // 验证和处理数据 +} catch (Exception e) { + RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setErrorMessage(e.getMessage()); + failures.add(failure); + // 继续处理下一条数据 +} +``` + +#### 3.2.2 状态更新逻辑 + +```java +private void updateImportStatus(String taskId, String status, ImportResult result) { + String key = "import:recruitment:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("status", status); + statusData.put("successCount", result.getSuccessCount()); + statusData.put("failureCount", result.getFailureCount()); + statusData.put("progress", 100); + statusData.put("endTime", System.currentTimeMillis()); + + if ("SUCCESS".equals(status)) { + statusData.put("message", "全部成功!共导入" + result.getTotalCount() + "条数据"); + } else { + statusData.put("message", "成功" + result.getSuccessCount() + + "条,失败" + result.getFailureCount() + "条"); + } + + redisTemplate.opsForHash().putAll(key, statusData); +} +``` + +--- + +## 四、前端实现 + +### 4.1 API定义 + +在 `ruoyi-ui/src/api/ccdiStaffRecruitment.js` 中添加: + +```javascript +// 查询导入状态 +export function getImportStatus(taskId) { + return request({ + url: '/ccdi/staffRecruitment/importStatus/' + taskId, + method: 'get' + }) +} + +// 查询导入失败记录 +export function getImportFailures(taskId, pageNum, pageSize) { + return request({ + url: '/ccdi/staffRecruitment/importFailures/' + taskId, + method: 'get', + params: { pageNum, pageSize } + }) +} +``` + +### 4.2 Vue组件修改 + +在 `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` 中修改: + +#### 4.2.1 data属性 + +```javascript +data() { + return { + // ...现有data + pollingTimer: null, + showFailureButton: false, + currentTaskId: null, + failureDialogVisible: false, + failureList: [], + failureLoading: false, + failureTotal: 0, + failureQueryParams: { + pageNum: 1, + pageSize: 10 + } + } +} +``` + +#### 4.2.2 handleFileSuccess方法 + +```javascript +handleFileSuccess(response, file, fileList) { + this.upload.isUploading = false; + this.upload.open = false; + + if (response.code === 200) { + const taskId = response.data.taskId; + + // 显示后台处理提示 + this.$notify({ + title: '导入任务已提交', + message: '正在后台处理中,处理完成后将通知您', + type: 'info', + duration: 3000 + }); + + // 开始轮询检查状态 + this.startImportStatusPolling(taskId); + } else { + this.$modal.msgError(response.msg); + } +} +``` + +#### 4.2.3 轮询方法 + +```javascript +methods: { + startImportStatusPolling(taskId) { + this.pollingTimer = setInterval(async () => { + try { + const response = await getImportStatus(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); + } + }, 2000); // 每2秒轮询一次 + }, + + handleImportComplete(statusResult) { + if (statusResult.status === 'SUCCESS') { + this.$notify({ + title: '导入完成', + message: `全部成功!共导入${statusResult.totalCount}条数据`, + type: 'success', + duration: 5000 + }); + this.getList(); + } else if (statusResult.failureCount > 0) { + this.$notify({ + title: '导入完成', + message: `成功${statusResult.successCount}条,失败${statusResult.failureCount}条`, + type: 'warning', + duration: 5000 + }); + + // 显示查看失败记录按钮 + this.showFailureButton = true; + this.currentTaskId = statusResult.taskId; + + // 刷新列表 + this.getList(); + } + } +} +``` + +#### 4.2.4 生命周期销毁钩子 + +```javascript +beforeDestroy() { + // 组件销毁时清除定时器 + if (this.pollingTimer) { + clearInterval(this.pollingTimer); + this.pollingTimer = null; + } +} +``` + +#### 4.2.5 失败记录对话框 + +**模板部分:** +```vue + + + 查看导入失败记录 + + + + + + + + + + + + + + + + + +``` + +**方法部分:** +```javascript +methods: { + viewImportFailures() { + this.failureDialogVisible = true; + this.getFailureList(); + }, + + getFailureList() { + this.failureLoading = true; + getImportFailures( + this.currentTaskId, + this.failureQueryParams.pageNum, + this.failureQueryParams.pageSize + ).then(response => { + this.failureList = response.rows; + this.failureTotal = response.total; + this.failureLoading = false; + }).catch(error => { + this.failureLoading = false; + this.$modal.msgError('查询失败记录失败: ' + error.message); + }); + } +} +``` + +--- + +## 五、测试计划 + +### 5.1 功能测试 + +| 测试项 | 测试内容 | 预期结果 | +|--------|---------|---------| +| 正常导入 | 导入100-500条有效数据 | 全部成功,状态为SUCCESS | +| 重复导入-不更新 | recruitId已存在,updateSupport=false | 导入失败,提示"该招聘项目编号已存在" | +| 重复导入-更新 | recruitId已存在,updateSupport=true | 更新已有数据,状态为SUCCESS | +| 部分错误 | 混合有效数据和无效数据 | 部分成功,状态为PARTIAL_SUCCESS | +| 状态查询 | 调用getImportStatus接口 | 返回正确状态和进度 | +| 失败记录查询 | 调用getImportFailures接口 | 返回失败记录列表,支持分页 | +| 前端轮询 | 导入后观察轮询行为 | 每2秒查询一次,完成后停止 | +| 完成通知 | 导入完成后观察通知 | 显示正确的成功/警告通知 | +| 失败记录UI | 点击"查看失败记录"按钮 | 显示对话框,正确展示失败数据 | + +### 5.2 性能测试 + +| 测试项 | 测试数据量 | 性能要求 | +|--------|-----------|---------| +| 导入接口响应时间 | 任意 | < 500ms(立即返回taskId) | +| 数据处理时间 | 500条 | < 5秒 | +| 数据处理时间 | 1000条 | < 10秒 | +| Redis存储 | 任意 | 数据正确存储,TTL为7天 | +| 前端轮询 | 任意 | 不阻塞UI,不影响用户操作 | + +### 5.3 异常测试 + +| 测试项 | 测试内容 | 预期结果 | +|--------|---------|---------| +| 空文件 | 上传空Excel文件 | 返回错误提示"至少需要一条数据" | +| 格式错误 | 上传非Excel文件 | 解析失败,返回错误提示 | +| 不存在的taskId | 查询导入状态时传入随机UUID | 返回错误提示"任务不存在或已过期" | +| 并发导入 | 同时上传3个Excel文件 | 生成3个不同的taskId,各自独立处理,互不影响 | +| 网络中断 | 导入过程中断开网络 | 异步任务继续执行,恢复后可查询状态 | + +### 5.4 数据验证测试 + +| 测试项 | 测试内容 | 预期结果 | +|--------|---------|---------| +| 必填字段缺失 | 缺少recruitId、candName等必填字段 | 记录到失败列表,提示具体字段不能为空 | +| 证件号格式错误 | 填写错误的身份证号 | 记录到失败列表,提示证件号码格式错误 | +| 毕业年月格式错误 | 填写非YYYYMM格式 | 记录到失败列表,提示毕业年月格式不正确 | +| 录用情况无效 | 填写"录用"、"未录用"、"放弃"之外的值 | 记录到失败列表,提示录用情况只能填写指定值 | + +--- + +## 六、实施步骤 + +### 6.1 后端实施步骤 + +#### 步骤1: 创建VO类 + +**文件:** +- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/RecruitmentImportFailureVO.java` + +**操作:** +- 创建`RecruitmentImportFailureVO`类 +- 添加招聘信息相关字段 +- 复用`ImportResultVO`和`ImportStatusVO` + +#### 步骤2: 创建Service接口 + +**文件:** +- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiStaffRecruitmentImportService.java` + +**操作:** +- 创建Service接口 +- 定义三个方法:异步导入、查询状态、查询失败记录 + +#### 步骤3: 实现Service + +**文件:** +- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiStaffRecruitmentImportServiceImpl.java` + +**操作:** +- 实现`ICcdiStaffRecruitmentImportService`接口 +- 添加`@EnableAsync`注解 +- 注入`CcdiStaffRecruitmentMapper`和`RedisTemplate` +- 实现异步导入逻辑 +- 实现状态查询逻辑 +- 实现失败记录查询逻辑 + +#### 步骤4: 修改Controller + +**文件:** +- `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiStaffRecruitmentController.java` + +**操作:** +- 注入`ICcdiStaffRecruitmentImportService` +- 修改`importData()`方法:调用异步服务,返回taskId +- 添加`getImportStatus()`方法 +- 添加`getImportFailures()`方法 +- 添加Swagger注解 + +### 6.2 前端实施步骤 + +#### 步骤5: 修改API定义 + +**文件:** +- `ruoyi-ui/src/api/ccdiStaffRecruitment.js` + +**操作:** +- 添加`getImportStatus()`方法 +- 添加`getImportFailures()`方法 + +#### 步骤6: 修改Vue组件 + +**文件:** +- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` + +**操作:** +- 添加data属性(pollingTimer、showFailureButton等) +- 修改`handleFileSuccess()`方法 +- 添加`startImportStatusPolling()`方法 +- 添加`handleImportComplete()`方法 +- 添加`viewImportFailures()`方法 +- 添加`getFailureList()`方法 +- 添加`beforeDestroy()`生命周期钩子 +- 添加"查看失败记录"按钮 +- 添加失败记录对话框 + +### 6.3 测试与文档 + +#### 步骤7: 生成测试脚本 + +**文件:** +- `test/test_recruitment_import.py` + +**操作:** +- 编写测试脚本 +- 包含:登录、导入、状态查询、失败记录查询等测试用例 + +#### 步骤8: 手动测试 + +**操作:** +- 启动后端服务 +- 启动前端服务 +- 执行完整功能测试 +- 记录测试结果 + +#### 步骤9: 更新API文档 + +**文件:** +- `doc/api/ccdi_staff_recruitment_api.md` + +**操作:** +- 添加导入相关接口文档 +- 包含:请求参数、响应示例、错误码说明 + +#### 步骤10: 代码提交 + +**操作:** +```bash +git add . +git commit -m "feat: 实现招聘信息异步导入功能" +``` + +--- + +## 七、文件清单 + +### 7.1 新增文件 + +| 文件路径 | 说明 | +|---------|------| +| `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/RecruitmentImportFailureVO.java` | 招聘信息导入失败记录VO | +| `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiStaffRecruitmentImportService.java` | 招聘信息异步导入Service接口 | +| `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiStaffRecruitmentImportServiceImpl.java` | 招聘信息异步导入Service实现 | +| `test/test_recruitment_import.py` | 测试脚本 | + +### 7.2 修改文件 + +| 文件路径 | 修改内容 | +|---------|---------| +| `ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/controller/CcdiStaffRecruitmentController.java` | 修改导入接口,添加状态查询和失败记录查询接口 | +| `ruoyi-ui/src/api/ccdiStaffRecruitment.js` | 添加导入状态和失败记录查询API | +| `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue` | 添加轮询逻辑和失败记录UI | +| `doc/api/ccdi_staff_recruitment_api.md` | 更新API文档 | + +### 7.3 复用组件 + +| 组件 | 说明 | +|------|------| +| `ImportResultVO` | 导入结果VO(复用员工导入) | +| `ImportStatusVO` | 导入状态VO(复用员工导入) | +| `AsyncConfig` | 异步配置(复用员工导入) | +| `importExecutor` | 导入任务线程池(复用员工导入) | + +--- + +## 八、参考文档 + +- 员工信息异步导入实施计划: `doc/plans/2026-02-06-employee-async-import.md` +- 员工信息异步导入设计文档: `doc/plans/2026-02-06-employee-async-import-design.md` +- 员工信息导入API文档: `doc/api/ccdi-employee-import-api.md` + +--- + +**设计版本:** 1.0 +**创建日期:** 2026-02-06 +**设计人员:** Claude +**审核状态:** 待审核 diff --git a/doc/scripts/generate_recruitment_test_data.py b/doc/scripts/generate_recruitment_test_data.py new file mode 100644 index 0000000..d0edcf2 --- /dev/null +++ b/doc/scripts/generate_recruitment_test_data.py @@ -0,0 +1,271 @@ +""" +招聘信息测试数据生成器 +生成符合校验规则的招聘信息测试数据并保存到Excel文件 +""" + +import random +import string +from datetime import datetime, timedelta +from openpyxl import Workbook +from openpyxl.styles import Font, Alignment, PatternFill + +# 数据配置 +RECRUIT_COUNT = 2000 # 生成数据条数 + +# 招聘项目名称列表 +RECRUIT_NAMES = [ + "2025春季校园招聘", "2025秋季校园招聘", "2025社会招聘", "2025技术专项招聘", + "2025管培生招聘", "2025实习生招聘", "2025高端人才引进", "2025春季研发岗招聘", + "2025夏季校园招聘", "2025冬季校园招聘", "2025春季销售岗招聘", "2025秋季市场岗招聘", + "2025春季运营岗招聘", "2025秋季产品岗招聘", "2025春季客服岗招聘", "2025秋季人事岗招聘" +] + +# 职位名称列表 +POSITION_NAMES = [ + "Java开发工程师", "Python开发工程师", "前端开发工程师", "后端开发工程师", + "全栈工程师", "算法工程师", "数据分析师", "产品经理", + "UI设计师", "测试工程师", "运维工程师", "架构师", + "软件工程师", "系统分析师", "数据库管理员", "网络工程师", + "移动端开发工程师", "嵌入式开发工程师", "大数据工程师", "人工智能工程师" +] + +# 职位类别 +POSITION_CATEGORIES = [ + "技术类", "产品类", "设计类", "运营类", + "市场类", "销售类", "客服类", "人事类", + "财务类", "行政类", "管理类", "研发类" +] + +# 职位描述模板 +POSITION_DESCS = [ + "负责公司核心业务系统的设计和开发,要求熟悉相关技术栈,具备良好的编码规范和团队协作能力。", + "参与产品需求分析和技术方案设计,负责模块开发和维护,优化系统性能,保障系统稳定性。", + "负责系统架构设计和技术选型,解决技术难题,指导团队成员开发,推动技术创新。", + "负责数据采集、清洗、分析和可视化,为业务决策提供数据支持,优化业务流程。", + "负责产品规划、需求分析和产品设计,协调研发、测试、运营等团队,推动产品落地。", + "负责用户界面设计和用户体验优化,与产品经理和开发团队协作,确保设计还原度。", + "负责系统测试和质量保障,编写测试用例,执行测试,跟踪缺陷,保障产品质量。", + "负责系统运维和监控,保障系统稳定运行,优化系统性能,处理故障和应急响应。" +] + +# 常见姓氏和名字 +SURNAMES = ["王", "李", "张", "刘", "陈", "杨", "黄", "赵", "周", "吴", "徐", "孙", "马", "朱", "胡", "郭", "何", "高", "林", "罗"] +GIVEN_NAMES = ["伟", "芳", "娜", "敏", "静", "丽", "强", "磊", "军", "洋", "勇", "艳", "杰", "娟", "涛", "明", "超", "秀英", "华", "英"] + +# 学历列表 +EDUCATIONS = ["本科", "硕士", "博士", "大专", "高中"] + +# 毕业院校列表 +UNIVERSITIES = [ + "清华大学", "北京大学", "复旦大学", "上海交通大学", "浙江大学", "中国科学技术大学", + "南京大学", "中山大学", "华中科技大学", "哈尔滨工业大学", "西安交通大学", "北京理工大学", + "中国人民大学", "北京航空航天大学", "同济大学", "南开大学", "天津大学", "东南大学", + "武汉大学", "厦门大学", "山东大学", "四川大学", "吉林大学", "中南大学", + "华南理工大学", "西北工业大学", "华东师范大学", "北京师范大学", "重庆大学" +] + +# 专业列表 +MAJORS = [ + "计算机科学与技术", "软件工程", "人工智能", "数据科学与大数据技术", "物联网工程", + "电子信息工程", "通信工程", "自动化", "电气工程及其自动化", "机械工程", + "材料科学与工程", "化学工程与工艺", "生物工程", "环境工程", "土木工程", + "数学与应用数学", "统计学", "物理学", "化学", "生物学", + "工商管理", "市场营销", "会计学", "金融学", "国际经济与贸易", + "人力资源管理", "公共事业管理", "行政管理", "法学", "汉语言文学", + "英语", "日语", "新闻传播学", "广告学", "艺术设计" +] + +# 录用状态 +ADMIT_STATUSES = ["录用", "未录用", "放弃"] + +# 面试官姓名和工号 +INTERVIEWERS = [ + ("张伟", "INT001"), ("李芳", "INT002"), ("王磊", "INT003"), ("刘娜", "INT004"), + ("陈军", "INT005"), ("杨静", "INT006"), ("黄勇", "INT007"), ("赵丽", "INT008"), + ("周涛", "INT009"), ("吴明", "INT010"), ("徐超", "INT011"), ("孙杰", "INT012"), + ("马娟", "INT013"), ("朱华", "INT014"), ("胡英", "INT015"), ("郭强", "INT016") +] + + +def generate_chinese_name(): + """生成中文姓名""" + surname = random.choice(SURNAMES) + # 50%概率双字名,50%概率单字名 + if random.random() > 0.5: + given_name = random.choice(GIVEN_NAMES) + random.choice(GIVEN_NAMES) + else: + given_name = random.choice(GIVEN_NAMES) + return surname + given_name + + +def generate_id_number(): + """生成18位身份证号码""" + # 地区码(前6位) + area_code = f"{random.randint(110000, 659001):06d}" + + # 出生日期(8位) - 生成1990-2005年的出生日期 + birth_year = random.randint(1990, 2005) + birth_month = f"{random.randint(1, 12):02d}" + birth_day = f"{random.randint(1, 28):02d}" + birth_date = f"{birth_year}{birth_month}{birth_day}" + + # 顺序码(3位) + sequence_code = f"{random.randint(1, 999):03d}" + + # 前17位 + id_17 = area_code + birth_date + sequence_code + + # 计算校验码(最后1位) + weights = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2] + check_codes = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'] + + total = sum(int(id_17[i]) * weights[i] for i in range(17)) + check_code = check_codes[total % 11] + + return id_17 + check_code + + +def generate_graduation_date(): + """生成毕业年月(YYYYMM格式)""" + # 生成2020-2030年之间的毕业年月 + year = random.randint(2020, 2030) + month = f"{random.randint(1, 12):02d}" + return f"{year}{month}" + + +def generate_recruitment_data(start_index): + """生成招聘测试数据""" + data = [] + + for i in range(start_index, start_index + RECRUIT_COUNT): + # 生成招聘项目编号 + recruit_id = f"REC{datetime.now().strftime('%Y%m%d')}{i:06d}" + + # 选择面试官(50%概率有两个面试官,50%概率只有一个) + if random.random() > 0.5: + interviewer1_name, interviewer1_id = random.choice(INTERVIEWERS) + interviewer2_name, interviewer2_id = random.choice(INTERVIEWERS) + else: + interviewer1_name, interviewer1_id = random.choice(INTERVIEWERS) + interviewer2_name = "" + interviewer2_id = "" + + row_data = [ + recruit_id, # 招聘项目编号 + random.choice(RECRUIT_NAMES), # 招聘项目名称 + random.choice(POSITION_NAMES), # 职位名称 + random.choice(POSITION_CATEGORIES), # 职位类别 + random.choice(POSITION_DESCS), # 职位描述 + generate_chinese_name(), # 应聘人员姓名 + random.choice(EDUCATIONS), # 应聘人员学历 + generate_id_number(), # 应聘人员证件号码 + random.choice(UNIVERSITIES), # 应聘人员毕业院校 + random.choice(MAJORS), # 应聘人员专业 + generate_graduation_date(), # 应聘人员毕业年月 + random.choice(ADMIT_STATUSES), # 录用情况 + interviewer1_name, # 面试官1姓名 + interviewer1_id, # 面试官1工号 + interviewer2_name, # 面试官2姓名 + interviewer2_id # 面试官2工号 + ] + + data.append(row_data) + + return data + + +def create_excel(data, filename): + """创建Excel文件""" + wb = Workbook() + ws = wb.active + ws.title = "招聘信息" + + # 表头 + headers = [ + "招聘项目编号", "招聘项目名称", "职位名称", "职位类别", "职位描述", + "应聘人员姓名", "应聘人员学历", "应聘人员证件号码", "应聘人员毕业院校", + "应聘人员专业", "应聘人员毕业年月", "录用情况", + "面试官1姓名", "面试官1工号", "面试官2姓名", "面试官2工号" + ] + + # 写入表头 + ws.append(headers) + + # 设置表头样式 + header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid") + header_font = Font(bold=True, color="FFFFFF") + + for col_num, header in enumerate(headers, 1): + cell = ws.cell(row=1, column=col_num) + cell.fill = header_fill + cell.font = header_font + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + + # 写入数据 + for row_data in data: + ws.append(row_data) + + # 设置列宽 + column_widths = [20, 20, 20, 15, 30, 15, 15, 20, 20, 15, 15, 10, 15, 15, 15, 15] + for col_num, width in enumerate(column_widths, 1): + ws.column_dimensions[chr(64 + col_num)].width = width + + # 设置所有单元格居中对齐 + for row in ws.iter_rows(min_row=1, max_row=ws.max_row, min_col=1, max_col=ws.max_column): + for cell in row: + cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) + + # 保存文件 + wb.save(filename) + print(f"✓ 已生成文件: {filename}") + print(f" 数据行数: {len(data)}") + + +def main(): + """主函数""" + print("=" * 70) + print("招聘信息测试数据生成器") + print("=" * 70) + + # 检查是否安装了openpyxl + try: + import openpyxl + except ImportError: + print("✗ 未安装openpyxl库,正在安装...") + import subprocess + subprocess.check_call(["pip", "install", "openpyxl"]) + print("✓ openpyxl库安装成功") + + print(f"\n配置信息:") + print(f" - 生成数据量: {RECRUIT_COUNT} 条/文件") + print(f" - 生成文件数: 2 个") + print(f" - 总数据量: {RECRUIT_COUNT * 2} 条") + + print(f"\n开始生成数据...") + + # 生成第一个文件 + print(f"\n正在生成第1个文件...") + data1 = generate_recruitment_data(1) + filename1 = "doc/test-data/recruitment/recruitment_test_data_2000_1.xlsx" + create_excel(data1, filename1) + + # 生成第二个文件 + print(f"\n正在生成第2个文件...") + data2 = generate_recruitment_data(RECRUIT_COUNT + 1) + filename2 = "doc/test-data/recruitment/recruitment_test_data_2000_2.xlsx" + create_excel(data2, filename2) + + print("\n" + "=" * 70) + print("✓ 所有文件生成完成!") + print("=" * 70) + print(f"\n生成的文件:") + print(f" 1. {filename1}") + print(f" 2. {filename2}") + print(f"\n数据统计:") + print(f" - 总数据量: {RECRUIT_COUNT * 2} 条") + print(f" - 文件1: {len(data1)} 条") + print(f" - 文件2: {len(data2)} 条") + + +if __name__ == "__main__": + main() diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/RecruitmentImportFailureVO.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/RecruitmentImportFailureVO.java new file mode 100644 index 0000000..a1cb6a6 --- /dev/null +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/domain/vo/RecruitmentImportFailureVO.java @@ -0,0 +1,33 @@ +package com.ruoyi.ccdi.domain.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 招聘信息导入失败记录VO + * + * @author ruoyi + * @date 2026-02-06 + */ +@Data +@Schema(description = "招聘信息导入失败记录") +public class RecruitmentImportFailureVO { + + @Schema(description = "招聘项目编号") + private String recruitId; + + @Schema(description = "招聘项目名称") + private String recruitName; + + @Schema(description = "应聘人员姓名") + private String candName; + + @Schema(description = "证件号码") + private String candId; + + @Schema(description = "录用情况") + private String admitStatus; + + @Schema(description = "错误信息") + private String errorMessage; +} diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiStaffRecruitmentImportService.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiStaffRecruitmentImportService.java new file mode 100644 index 0000000..ecb19a4 --- /dev/null +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/ICcdiStaffRecruitmentImportService.java @@ -0,0 +1,44 @@ +package com.ruoyi.ccdi.service; + +import com.ruoyi.ccdi.domain.excel.CcdiStaffRecruitmentExcel; +import com.ruoyi.ccdi.domain.vo.ImportStatusVO; +import com.ruoyi.ccdi.domain.vo.RecruitmentImportFailureVO; + +import java.util.List; + +/** + * 招聘信息异步导入Service + * + * @author ruoyi + * @date 2026-02-06 + */ +public interface ICcdiStaffRecruitmentImportService { + + /** + * 异步导入招聘信息数据 + * + * @param excelList Excel数据列表 + * @param isUpdateSupport 是否更新已存在的数据 + * @param taskId 任务ID + */ + void importRecruitmentAsync(List excelList, + Boolean isUpdateSupport, + String taskId, + String userName); + + /** + * 查询导入状态 + * + * @param taskId 任务ID + * @return 导入状态信息 + */ + ImportStatusVO getImportStatus(String taskId); + + /** + * 获取导入失败记录 + * + * @param taskId 任务ID + * @return 失败记录列表 + */ + List getImportFailures(String taskId); +} diff --git a/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiStaffRecruitmentImportServiceImpl.java b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiStaffRecruitmentImportServiceImpl.java new file mode 100644 index 0000000..734efb8 --- /dev/null +++ b/ruoyi-ccdi/src/main/java/com/ruoyi/ccdi/service/impl/CcdiStaffRecruitmentImportServiceImpl.java @@ -0,0 +1,269 @@ +package com.ruoyi.ccdi.service.impl; + +import com.alibaba.fastjson2.JSON; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.ruoyi.ccdi.domain.CcdiStaffRecruitment; +import com.ruoyi.ccdi.domain.excel.CcdiStaffRecruitmentExcel; +import com.ruoyi.ccdi.domain.vo.ImportResult; +import com.ruoyi.ccdi.domain.vo.ImportStatusVO; +import com.ruoyi.ccdi.domain.vo.RecruitmentImportFailureVO; +import com.ruoyi.ccdi.enums.AdmitStatus; +import com.ruoyi.ccdi.mapper.CcdiStaffRecruitmentMapper; +import com.ruoyi.ccdi.service.ICcdiStaffRecruitmentImportService; +import com.ruoyi.common.utils.IdCardUtil; +import com.ruoyi.common.utils.StringUtils; +import jakarta.annotation.Resource; +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 java.util.*; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +/** + * 招聘信息异步导入Service实现 + * + * @author ruoyi + * @date 2026-02-06 + */ +@Service +@EnableAsync +public class CcdiStaffRecruitmentImportServiceImpl implements ICcdiStaffRecruitmentImportService { + + @Resource + private CcdiStaffRecruitmentMapper recruitmentMapper; + + @Resource + private RedisTemplate redisTemplate; + + @Override + @Async + public void importRecruitmentAsync(List excelList, + Boolean isUpdateSupport, + String taskId, + String userName) { + long startTime = System.currentTimeMillis(); + + // 初始化Redis状态 + String statusKey = "import:recruitment:" + taskId; + Map 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); + + List newRecords = new ArrayList<>(); + List updateRecords = new ArrayList<>(); + List failures = new ArrayList<>(); + + // 批量查询已存在的招聘项目编号 + Set existingRecruitIds = getExistingRecruitIds(excelList); + + // 分类数据 + for (CcdiStaffRecruitmentExcel excel : excelList) { + try { + // 验证数据 + validateRecruitmentData(excel, isUpdateSupport, existingRecruitIds); + + CcdiStaffRecruitment recruitment = new CcdiStaffRecruitment(); + BeanUtils.copyProperties(excel, recruitment); + + if (existingRecruitIds.contains(excel.getRecruitId())) { + if (isUpdateSupport) { + recruitment.setUpdatedBy(userName); + updateRecords.add(recruitment); + } else { + throw new RuntimeException("该招聘项目编号已存在"); + } + } else { + recruitment.setCreatedBy(userName); + recruitment.setUpdatedBy(userName); + newRecords.add(recruitment); + } + + } catch (Exception e) { + RecruitmentImportFailureVO failure = new RecruitmentImportFailureVO(); + BeanUtils.copyProperties(excel, failure); + failure.setErrorMessage(e.getMessage()); + failures.add(failure); + } + } + + // 批量插入新数据 + if (!newRecords.isEmpty()) { + recruitmentMapper.insertBatch(newRecords); + } + + // 批量更新已有数据 + if (!updateRecords.isEmpty() && isUpdateSupport) { + recruitmentMapper.updateBatch(updateRecords); + } + + // 保存失败记录到Redis + if (!failures.isEmpty()) { + String failuresKey = "import:recruitment:" + taskId + ":failures"; + redisTemplate.opsForValue().set(failuresKey, failures, 7, TimeUnit.DAYS); + } + + // 更新最终状态 + ImportResult result = new ImportResult(); + result.setTotalCount(excelList.size()); + result.setSuccessCount(newRecords.size() + updateRecords.size()); + result.setFailureCount(failures.size()); + + String finalStatus = result.getFailureCount() == 0 ? "SUCCESS" : "PARTIAL_SUCCESS"; + updateImportStatus(taskId, finalStatus, result); + } + + @Override + public ImportStatusVO getImportStatus(String taskId) { + String key = "import:recruitment:" + taskId; + Boolean hasKey = redisTemplate.hasKey(key); + + if (Boolean.FALSE.equals(hasKey)) { + throw new RuntimeException("任务不存在或已过期"); + } + + Map 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 getImportFailures(String taskId) { + String key = "import:recruitment:" + taskId + ":failures"; + Object failuresObj = redisTemplate.opsForValue().get(key); + + if (failuresObj == null) { + return Collections.emptyList(); + } + + return JSON.parseArray(JSON.toJSONString(failuresObj), RecruitmentImportFailureVO.class); + } + + /** + * 批量查询已存在的招聘项目编号 + */ + private Set getExistingRecruitIds(List excelList) { + List recruitIds = excelList.stream() + .map(CcdiStaffRecruitmentExcel::getRecruitId) + .filter(StringUtils::isNotEmpty) + .collect(Collectors.toList()); + + if (recruitIds.isEmpty()) { + return Collections.emptySet(); + } + + LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + wrapper.in(CcdiStaffRecruitment::getRecruitId, recruitIds); + List existingRecruitments = recruitmentMapper.selectList(wrapper); + + return existingRecruitments.stream() + .map(CcdiStaffRecruitment::getRecruitId) + .collect(Collectors.toSet()); + } + + /** + * 验证招聘信息数据 + */ + private void validateRecruitmentData(CcdiStaffRecruitmentExcel excel, + Boolean isUpdateSupport, + Set existingRecruitIds) { + // 验证必填字段 + if (StringUtils.isEmpty(excel.getRecruitId())) { + throw new RuntimeException("招聘项目编号不能为空"); + } + if (StringUtils.isEmpty(excel.getRecruitName())) { + throw new RuntimeException("招聘项目名称不能为空"); + } + if (StringUtils.isEmpty(excel.getPosName())) { + throw new RuntimeException("职位名称不能为空"); + } + if (StringUtils.isEmpty(excel.getPosCategory())) { + throw new RuntimeException("职位类别不能为空"); + } + if (StringUtils.isEmpty(excel.getPosDesc())) { + throw new RuntimeException("职位描述不能为空"); + } + if (StringUtils.isEmpty(excel.getCandName())) { + throw new RuntimeException("应聘人员姓名不能为空"); + } + if (StringUtils.isEmpty(excel.getCandEdu())) { + throw new RuntimeException("应聘人员学历不能为空"); + } + if (StringUtils.isEmpty(excel.getCandId())) { + throw new RuntimeException("证件号码不能为空"); + } + if (StringUtils.isEmpty(excel.getCandSchool())) { + throw new RuntimeException("应聘人员毕业院校不能为空"); + } + if (StringUtils.isEmpty(excel.getCandMajor())) { + throw new RuntimeException("应聘人员专业不能为空"); + } + if (StringUtils.isEmpty(excel.getCandGrad())) { + throw new RuntimeException("应聘人员毕业年月不能为空"); + } + if (StringUtils.isEmpty(excel.getAdmitStatus())) { + throw new RuntimeException("录用情况不能为空"); + } + + // 验证证件号码格式 + String idCardError = IdCardUtil.getErrorMessage(excel.getCandId()); + if (idCardError != null) { + throw new RuntimeException("证件号码" + idCardError); + } + + // 验证毕业年月格式(YYYYMM) + if (!excel.getCandGrad().matches("^((19|20)\\d{2})(0[1-9]|1[0-2])$")) { + throw new RuntimeException("毕业年月格式不正确,应为YYYYMM"); + } + + // 验证录用状态 + if (AdmitStatus.getDescByCode(excel.getAdmitStatus()) == null) { + throw new RuntimeException("录用情况只能填写'录用'、'未录用'或'放弃'"); + } + } + + /** + * 更新导入状态 + */ + private void updateImportStatus(String taskId, String status, ImportResult result) { + String key = "import:recruitment:" + taskId; + Map statusData = new HashMap<>(); + statusData.put("status", status); + statusData.put("totalCount", result.getTotalCount()); + 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); + } +}