# 招聘信息管理双 Sheet 导入设计文档

## 1. 背景

当前招聘信息管理已经具备以下能力：

- 主信息维护：`ccdi_staff_recruitment`
- 历史工作经历维护：`ccdi_staff_recruitment_work`
- 页面编辑态支持手工维护历史工作经历
- 导入能力仍拆分为两条独立入口：
  - `招聘信息导入`
  - `历史工作经历导入`

对比已经完成改造的招投标信息维护，招聘信息管理当前导入模式存在三个问题：

1. 导入入口与当前主从结构不一致，用户需要自行判断先后顺序
2. 双 Sheet 场景无法在同一个文件中同时提交主信息和工作经历
3. 失败记录无法直接定位失败来源 Sheet 和 Excel 行号

本次需求要求参考招投标信息维护的双 Sheet 模式，将招聘信息管理改造成单入口双 Sheet 导入，并补齐失败定位信息。

## 2. 目标与范围

### 2.1 目标

1. 招聘信息管理页面只保留一个导入按钮
2. 导入模板改为双 Sheet：
   - `招聘信息`
   - `历史工作经历`
3. 后端统一用一个异步任务处理整份文件
4. 支持以下三类导入方式：
   - 只导 `招聘信息` Sheet
   - 只导 `历史工作经历` Sheet
   - 两个 Sheet 同时导入
5. 失败列表必须展示：
   - 失败 Sheet
   - 失败行号
   - 失败原因

### 2.2 已确认业务口径

1. 页面导入入口合并为单按钮
2. `历史工作经历` Sheet 允许独立导入
3. `历史工作经历` 独立导入时，继续按库内已存在的招聘主信息匹配
4. 如果某个招聘记录已经存在历史工作经历，再次导入该招聘记录的工作经历时直接报错，不允许覆盖旧数据
5. 失败展示统一为一个列表，通过 `失败Sheet`、`失败行号`、`失败原因` 定位问题

### 2.3 非目标

1. 不调整招聘信息手工新增、编辑、详情、列表查询主体逻辑
2. 不调整编辑页中手工维护历史工作经历的交互
3. 不新增数据库表，不修改现有表结构
4. 不保留旧的“导入工作经历”按钮或旧接口作为兼容入口
5. 不新增独立任务中心或额外状态体系

## 3. 现状分析

### 3.1 前端现状

招聘信息管理页面当前有两个导入按钮：

1. `导入`
2. `导入工作经历`

两个按钮分别对应不同模板和不同上传接口：

- `/ccdi/staffRecruitment/importTemplate`
- `/ccdi/staffRecruitment/workImportTemplate`
- `/ccdi/staffRecruitment/importData`
- `/ccdi/staffRecruitment/importWorkData`

页面当前仅维护一套失败记录弹窗，但会根据 `currentImportType` 切换展示字段，本质仍然是两条独立导入链路。

### 3.2 后端现状

当前招聘导入服务拆成两条独立任务：

1. `importRecruitment(List<CcdiStaffRecruitmentExcel>)`
2. `importRecruitmentWork(List<CcdiStaffRecruitmentWorkExcel>)`

对应的异步实现也拆成：

1. `importRecruitmentAsync(...)`
2. `importRecruitmentWorkAsync(...)`

其中历史工作经历导入现有语义为：

- 允许独立导入
- 按 `recruitId` 匹配招聘主信息
- 若同一 `recruitId` 任一行失败，则整组不落库
- 导入前会删除该 `recruitId` 旧工作经历，再导入新数据

本次要调整的是最后一条覆盖语义：改为“已有旧记录则失败，不覆盖”。

## 4. 方案对比

### 4.1 方案 A：单入口 + 单任务编排 + 单失败列表

做法：

- 页面只保留一个导入按钮
- 后端一次读取双 Sheet
- 用一个异步任务统一处理主信息和工作经历
- 用一个失败列表展示全部失败记录

优点：

1. 与招投标信息维护双 Sheet 模式一致
2. 支持同文件主从联动导入
3. 失败展示口径最统一
4. 页面交互最简单

缺点：

1. 需要把当前两条招聘导入任务重构为一条统一任务

### 4.2 方案 B：单入口 + 双任务复用 + 前端聚合失败

做法：

- 上传一个双 Sheet 文件
- 后端仍拆成主信息任务和工作经历任务
- 前端再聚合两个任务的失败记录

缺点：

1. 工作经历 Sheet 无法天然引用“本次主 Sheet 刚成功导入的数据”
2. 会引入跨任务依赖和前端拼装逻辑
3. 导入状态与失败记录口径不够干净

### 4.3 方案 C：仅合并模板和按钮，不做真实双 Sheet 联动

做法：

- 页面只有一个导入按钮
- 模板做成双 Sheet
- 但工作经历仍只能引用库内已有招聘主信息

缺点：

1. 无法完成同文件主从联动
2. 不能真正对齐招投标信息维护的双 Sheet 模式
3. 业务理解成本仍然偏高

### 4.4 最终选择

采用方案 A：`单入口 + 单异步任务 + 单失败列表`。

理由：

1. 最符合“参考招投标信息维护双 Sheet 导入”的目标
2. 业务链路闭环最完整
3. 用户侧交互最清晰
4. 后续维护成本最低

## 5. 总体设计

### 5.1 模板设计

统一模板文件名：`招聘信息管理导入模板`

模板包含两个 Sheet：

1. `招聘信息`
2. `历史工作经历`

约束：

1. 两个 Sheet 都允许为空表头，但整份文件至少一个 Sheet 有数据
2. `历史工作经历` Sheet 保持当前字段结构，不新增业务字段
3. `招聘信息` Sheet 保持当前字段结构，不新增业务字段

### 5.2 页面交互设计

页面顶部仅保留一个 `导入` 按钮。

上传弹窗说明文案明确提示：

- 模板包含 `招聘信息`、`历史工作经历` 两个 Sheet
- 支持只填写一个 Sheet，也支持两个 Sheet 同时填写

上传成功后：

1. 只返回一个 `taskId`
2. 前端只启动一套轮询
3. 页面只保留一个“查看导入失败记录”入口

### 5.3 接口设计

保留并调整以下接口：

1. `POST /ccdi/staffRecruitment/importTemplate`
   - 输出双 Sheet 模板
2. `POST /ccdi/staffRecruitment/importData`
   - 一次接收整份双 Sheet 文件
   - 返回统一 `taskId`
3. `GET /ccdi/staffRecruitment/importStatus/{taskId}`
   - 查询统一任务状态
4. `GET /ccdi/staffRecruitment/importFailures/{taskId}`
   - 查询统一失败记录

移除以下独立导入入口：

1. `POST /ccdi/staffRecruitment/workImportTemplate`
2. `POST /ccdi/staffRecruitment/importWorkData`

## 6. 后端处理流程设计

### 6.1 任务编排

`/importData` 收到文件后，一次读取两个 Sheet：

- `招聘信息` -> `List<CcdiStaffRecruitmentExcel>`
- `历史工作经历` -> `List<CcdiStaffRecruitmentWorkExcel>`

若两个 Sheet 都为空，直接返回错误：`至少需要一条数据`。

否则初始化一条统一导入任务，并异步执行以下两个阶段：

1. 阶段一：处理 `招聘信息` Sheet
2. 阶段二：处理 `历史工作经历` Sheet

### 6.2 阶段一：招聘信息 Sheet

沿用现有主信息导入规则：

1. 招聘记录编号不能为空
2. 招聘项目名称、职位名称、职位类别、职位描述、候选人姓名、学历、证件号码、毕业院校、专业、毕业年月、录用情况不能为空
3. 证件号码格式必须合法
4. 毕业年月必须为 `YYYYMM`
5. 录用情况只能填写合法枚举
6. 招聘类型继续按现有逻辑推断
7. 数据库中已存在的 `recruitId` 直接失败
8. 文件内重复 `recruitId` 直接失败

阶段一成功后，建立“本次主 Sheet 成功导入的招聘记录映射”，供阶段二匹配使用。

### 6.3 阶段二：历史工作经历 Sheet

#### 6.3.1 匹配规则

工作经历按 `recruitId` 分组处理。

每组匹配顺序固定为：

1. 先匹配本次 `招聘信息` Sheet 中成功导入的招聘主信息
2. 再匹配数据库中已存在的招聘主信息

若都匹配不到，整组失败。

#### 6.3.2 基础校验

沿用并收敛现有规则：

1. `recruitId` 不能为空
2. 候选人姓名不能为空
3. 招聘项目名称不能为空
4. 职位名称不能为空
5. 排序号不能为空且必须大于 0
6. 工作单位不能为空
7. 岗位不能为空
8. 入职年月不能为空，格式必须为 `YYYY-MM`
9. 离职年月若有值，格式必须为 `YYYY-MM`
10. 候选人姓名、招聘项目名称、职位名称必须与匹配到的招聘主信息一致
11. 匹配到的招聘主信息 `recruitType` 必须为 `SOCIAL`
12. 同一 `recruitId` 下，文件内 `sortOrder` 不能重复

#### 6.3.3 已有工作经历报错规则

若数据库中某个 `recruitId` 已经存在任意历史工作经历记录，则本次再导入该 `recruitId` 的工作经历时：

1. 直接整组失败
2. 不删除旧记录
3. 不做覆盖
4. 不做部分追加

失败原因统一返回明确文案，例如：

`招聘记录编号[xxx]已存在历史工作经历，不允许重复导入`

#### 6.3.4 分组成功与失败策略

按 `recruitId` 维度整组处理：

1. 若该组任意一行失败，则整组不落库
2. 若该组全部通过校验，则整组批量插入

这样可以保证同一招聘记录下的工作经历要么整组成功，要么整组失败，不会出现排序半成功半失败的问题。

### 6.4 失败记录结构

统一失败记录对象新增以下字段：

1. `sheetName`
2. `sheetRowNum`
3. `errorMessage`

并继续保留必要业务定位字段：

1. `recruitId`
2. `recruitName`
3. `posName`
4. `candName`
5. `candId`
6. `companyName`
7. `positionName`

其中：

- 主 Sheet 失败时，`sheetName = 招聘信息`
- 工作经历 Sheet 失败时，`sheetName = 历史工作经历`
- `sheetRowNum` 返回 Excel 实际数据行号

## 7. 前端展示设计

### 7.1 导入入口

页面只保留一个导入按钮，不再保留 `导入工作经历`。

### 7.2 轮询状态

前端本地只维护一条最近导入任务状态：

1. `taskId`
2. `status`
3. `hasFailures`
4. `totalCount`
5. `successCount`
6. `failureCount`

不再按导入类型区分任务状态。

### 7.3 失败弹窗

失败弹窗统一展示一个列表，至少包含以下列：

1. `失败Sheet`
2. `失败行号`
3. `招聘记录编号`
4. `招聘项目名称`
5. `职位名称`
6. `候选人姓名`
7. `工作单位`
8. `失败原因`

展示规则：

1. 主 Sheet 失败时，`工作单位` 为空
2. 工作经历 Sheet 失败时，`工作单位` 有值
3. `失败行号` 展示为 `第X行`

## 8. 影响范围

### 8.1 后端

- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/controller/CcdiStaffRecruitmentController.java`
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentService.java`
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/ICcdiStaffRecruitmentImportService.java`
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentServiceImpl.java`
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/service/impl/CcdiStaffRecruitmentImportServiceImpl.java`
- `ccdi-info-collection/src/main/java/com/ruoyi/info/collection/domain/vo/RecruitmentImportFailureVO.java`

### 8.2 前端

- `ruoyi-ui/src/views/ccdiStaffRecruitment/index.vue`
- `ruoyi-ui/src/api/ccdiStaffRecruitment.js`

### 8.3 不影响范围

1. 招聘详情页展示结构
2. 招聘手工新增、编辑、删除接口
3. 数据库结构与 SQL 迁移
4. 路由、菜单、权限标识

## 9. 验证方案

### 9.1 后端验证

1. `mvn -pl ccdi-info-collection,ruoyi-admin -am -DskipTests compile`

### 9.2 前端验证

1. `source ~/.nvm/nvm.sh && nvm use 14.21.3 >/dev/null && cd ruoyi-ui && npm run build:prod`

### 9.3 页面实测

必须通过真实页面下载模板后再构造测试文件，至少覆盖：

1. 只导 `招聘信息` Sheet 成功
2. 只导 `历史工作经历` Sheet，匹配库内已有主信息成功
3. 双 Sheet 同时导入，工作经历引用本次主 Sheet 成功
4. 工作经历命中已存在旧记录时报错
5. 工作经历命中非社招主信息时报错
6. 文件内工作经历排序号重复时报错
7. 失败弹窗正确展示 `失败Sheet`、`失败行号`、`失败原因`

测试结束后需关闭测试过程中启动的前后端进程。

## 10. 结论

本次采用“单入口 + 双 Sheet 模板 + 单异步任务 + 单失败列表”的最短路径实现：

1. 页面导入入口统一为一个按钮
2. 双 Sheet 模板统一为 `招聘信息` + `历史工作经历`
3. 工作经历继续允许独立导入
4. 工作经历匹配顺序为“本次主 Sheet 成功数据优先，其次数据库已有主信息”
5. 数据库里已存在工作经历时，本次重复导入直接报错，不覆盖旧数据
6. 失败列表统一展示失败 Sheet、失败行号和失败原因
