# 报销批量导入功能 — 实现文档
> 本文档记录会计报销模块(`businessFlag=1`)Excel 批量导入功能的完整实现,供后续中审、评估等其他报销类型改造时参照。
---
## 一、功能概述
用户上传 Excel 文件,系统批量创建报销单(主表 + 明细表),状态为「暂存」(`type=1`),不触发 Flowable 工作流。
**支持的报销类型:**
| 类型名 | sourceType | 明细表 |
|--------|-----------|--------|
| 项目报销 | `1` | `cw_reimbursement_detail_info` |
| 报告报销 | `3` | `cw_reimbursement_detail_info_report` |
| 其他报销 | `4` | `cw_reimbursement_detail_info_other` |
**核心设计原则:** 部分导入——校验通过的组写入 DB,校验失败的组生成带标注的错误 Excel 供下载修改后重新导入。
---
## 二、文件清单
### 后端
| 文件 | 路径 | 说明 |
|------|------|------|
| `ReimbursementImportDto.java` | `service/dto/` | Excel 行 → Java 对象映射(@Excel 注解) |
| `ReimbursementImportResult.java` | `service/dto/` | 导入结果返回 DTO |
| `CwReimbursementInfoService.java` | `service/` | 核心导入逻辑(~300 行) |
| `CwReimbursementInfoController.java` | `controller/` | 3 个接口:下载模板 / 导入 / 下载错误文件 |
| `CwReimbursementInfoMapper.java` | `mapper/` | 5 个导入查询方法 |
| `CwReimbursementInfoMapper.xml` | `mapper/xml/` | 5 条 SQL |
| `报销导入模板.xlsx` | `resources/dot/` | 静态模板文件 |
### 前端
| 文件 | 路径 | 说明 |
|------|------|------|
| `ImportDialog.vue` | `views/cw/reimbursementApproval/info/` | 导入弹窗组件 |
| `InfoList.vue` | `views/cw/reimbursementApproval/info/` | 列表页(添加导入按钮 + 组件引用) |
| `ReimbursementApprovalService.js` | `api/cw/reimbursementApproval/` | 3 个 API 方法 |
---
## 三、Excel 模板结构
### Sheet1:导入数据(13 列)
| 列 | 列名 | 必填 | 说明 |
|----|------|------|------|
| A | 是否单独报销 | 是 | `是` = 独立成单,`同上` = 归入上一行同组 |
| B | 报销类型 | 是 | 下拉:项目报销 / 报告报销 / 其他报销 |
| C | 报销人 | 否 | 不填默认导入操作人本人 |
| D | 报销类别 | 是 | 下拉,引用 Sheet2 类别列表(仅末级节点) |
| E | 项目编号 | 条件 | 项目报销和报告报销必填 |
| F | 报告号 | 条件 | 仅报告报销必填,需与项目编号关联 |
| G | 报销详情 | 条件 | 仅其他报销必填 |
| H | 非数电发票(费用) | 否 | 数字 |
| I | 其他(费用) | 否 | 数字 |
| J | 汇总 | 否 | 可手动填或系统自动计算 |
| K | 非数电发票(张数) | 否 | 整数 |
| L | 内容 | 否 | 文字说明 |
| M | 出差天数 | 否 | 数字 |
**行结构:**
```
Row 1 (index 0): 列头行(蓝色背景)
Row 2 (index 1): 描述/提示行(黄色/浅蓝背景)
Row 3+ (index 2+): 示例行(绿色背景,A列含"[示例]"标记)或用户实际数据
```
### Sheet2:类别列表
动态刷新——下载模板时从 `cw_reimbursement_type_info` 表查询所有末级节点写入。
---
## 四、后端实现详解
### 4.1 ImportParams 配置(关键!)
```java
ImportParams params = new ImportParams();
params.setTitleRows(0); // 第1行就是列头,不跳过
params.setHeadRows(1); // 第1行作为列头匹配,从第2行开始读取数据
```
**踩坑记录:**
- `titleRows=1, headRows=1` → EasyPOI 跳过第1行(真正列头),把第2行(描述行)当列头 → 所有字段 null
- `titleRows=0, headRows=1` → 正确。第1行列头匹配 @Excel 注解,第2行起读数据
- EasyPOI 遇到 numeric 列中有文字时,内部 catch NumberFormatException 并设 null,不会中断导入
### 4.2 行过滤逻辑(三重过滤)
```java
// 1. 跳过描述行
if (aloneFlag.contains("请删除") || aloneFlag.contains("填写提示")) continue;
// 2. 跳过示例行
if (aloneFlag.contains("[示例]")) continue;
// 3. 跳过全空行
if (StringUtils.isBlank(aloneFlag) && StringUtils.isBlank(sourceTypeName)
&& StringUtils.isBlank(userName) && StringUtils.isBlank(typeName)) continue;
// 4. 只处理合法值
if (!"是".equals(aloneFlag) && !"同上".equals(aloneFlag)) continue;
```
### 4.3 分组逻辑
按「是否单独报销」列分组:
- `是` → 新建一组(一张报销单)
- `同上` → 归入上一组(同一张报销单的追加明细)
### 4.4 行号计算
```java
row.put("originalIndex", i); // EasyPOI 返回列表中的索引(0-based)
row.put("excelRowNum", i + 2); // 对应 Excel 实际行号(1-based,跳过列头行)
```
### 4.5 校验流程(逐组 → 逐行)
**组内一致性校验:**
- 同组内报销类型必须与第一行一致
- 同组内项目编号必须与第一行一致(项目报销和报告报销)
**逐行数据校验:**
| 校验项 | 规则 |
|--------|------|
| 报销类型 | 必须是"项目报销"/"报告报销"/"其他报销" |
| 报销类别 | 不能为空;必须在 `cw_reimbursement_type_info` 中存在且为末级节点 |
| 项目编号 | 项目/报告报销必填;必须在 `cw_project_records` 中存在 |
| 报告号 | 报告报销必填;必须属于该项目(查 `cw_project_report_new_line`) |
| 报销人 | 不填默认当前用户;填写则按姓名查 `sys_user`,重名报错 |
| 报销详情 | 其他报销必填 |
| 金额 | 汇总为空时自动计算 = 非数电发票 + 其他 |
### 4.6 写入数据库
**主表 `cw_reimbursement_info`:**
- 经办人 = 当前登录人(`currentUser`)
- 部门 = 当前登录人的部门(优先从 `getOfficeDTO().getName()` 取,兜底走 `IOfficeApi.getOfficeById()`)
- 状态 = `type=1`(暂存)
- 业务标识 = `businessFlag=1`(会计)
- 编号通过 `IWorkAttachmentApi.genSerialNum()` 生成
**明细表(按 sourceType 分三张表):**
- 报销人信息用匹配到的用户数据(`matchedUserId`/`matchedUserName`/`matchedDeptId`/`matchedDeptName`)
- 类别 ID/名称从匹配结果取
- 项目 ID/名称从匹配结果取
- 报告报销额外设置 `reportId`(来自 `findReportNosByProjectId` 的 `line_id`)和 `reportNumber`
### 4.7 错误 Excel 生成
- 基于原始上传文件复制,在第 14 列(N 列)追加"错误原因"列
- 错误行整行标绿色底色(`LIGHT_GREEN`)+ 红色字体
- 行号映射:`excelRowNum = dataRowIndex + 1`(0-based,跳过第 0 行列头)
- 临时文件路径返回给前端供下载,下载后删除
### 4.8 部门查询兜底逻辑
```java
// 优先从 DTO 直接取(Feign 返回的 UserDTO 通常已含 office 信息)
currentUserDeptName = currentUser.getOfficeDTO().getName();
// 兜底:通过 API 查询
if (StringUtils.isBlank(currentUserDeptName)) {
OfficeDTO officeDTO = SpringUtil.getBean(IOfficeApi.class).getOfficeById(currentUserDeptId);
if (officeDTO != null) {
currentUserDeptName = officeDTO.getName();
}
}
```
**注意:** `currentUser.getOfficeDTO().getId()` 才是取 office ID 的正确方式,`getOfficeId()` 方法不存在。
---
## 五、前端实现详解
### 5.1 ImportDialog.vue 组件
**布局:** el-dialog (width=1000px) + el-form + el-upload,底部三按钮(下载模板 / 关闭 / 确定)
**关键交互:**
- `init()` → 打开弹窗,重置表单
- `downloadTpl()` → 调用 `importTemplate` API,`this.$utils.downloadExcel()` 下载
- `doSubmit()` → FormData 上传文件,全屏 loading 遮罩
**结果处理三种情况:**
| 场景 | 行为 |
|------|------|
| 全部成功 | `success` 提示 + 关闭弹窗 + `emit('refreshDataList')` |
| 部分成功 | `warning` 提示 + `emit('import-error')` + `emit('refreshDataList')` + 关闭 |
| 全部失败 | `warning` 提示 + `emit('import-error')` + 关闭(不刷新列表) |
| 服务异常 | `error` 提示(从 `err.response.data` 取服务器错误信息) |
**错误信息提取:**
```javascript
let errMsg = (err.response && err.response.data) ? err.response.data : (err.message || '服务异常')
if (typeof errMsg === 'string' && errMsg.startsWith('错误: ')) {
errMsg = errMsg.substring(4) // 去掉 ExceptionTranslator 加的 "错误: " 前缀
}
```
### 5.2 InfoList.vue 集成
```html
导入
```
`handleImportError` 用 `this.$confirm` 弹出是否下载错误文件的确认框,区分"是/否/关闭"三种操作。
### 5.3 API 方法
```javascript
importTemplate() // GET, responseType: "blob"
importFile(data) // POST multipart/form-data
downloadImportError(filePath) // GET, responseType: "blob", params: { filePath }
```
---
## 六、SQL 查询清单
### 6.1 findCategoryByName
```sql
SELECT t.id, t.name FROM cw_reimbursement_type_info t
WHERE t.del_flag = 0 AND t.name = #{typeName}
AND NOT EXISTS (SELECT 1 FROM cw_reimbursement_type_info child WHERE child.del_flag = 0 AND child.parent_id = t.id)
LIMIT 1
```
**用途:** 按名称匹配报销类别,确保是末级节点(无子节点)。
### 6.2 findProjectByNumber
```sql
SELECT id, project_name FROM cw_project_records
WHERE del_flag = 0 AND project_number = #{projectNumber} LIMIT 1
```
**用途:** 按项目编号精确匹配。
### 6.3 findReportNosByProjectId
```sql
SELECT nl.id AS line_id, nl.report_no
FROM cw_project_report_new_line nl
LEFT JOIN cw_project_report r ON nl.report_id = r.id
WHERE nl.del_flag = 0 AND r.del_flag = 0 AND r.project_id = #{projectId}
```
**用途:** 查项目下所有报告号,返回 `line_id`(用于明细表的 `reportId`)和 `report_no`。
### 6.4 findUserByName
```sql
SELECT a.id, a.name, a.office_id, b.name AS office_name
FROM sys_user a LEFT JOIN sys_office b ON a.office_id = b.id
WHERE a.del_flag = 0 AND a.name = #{userName}
```
**用途:** 按姓名查用户,受租户拦截器控制只查当前租户。返回 `office_id` 和 `office_name` 用于明细表的部门信息。
### 6.5 findAllLeafCategories
```sql
SELECT t.id, t.name FROM cw_reimbursement_type_info t
WHERE t.del_flag = 0
AND NOT EXISTS (SELECT 1 FROM cw_reimbursement_type_info child WHERE child.del_flag = 0 AND child.parent_id = t.id)
ORDER BY t.name
```
**用途:** 下载模板时刷新 Sheet2 类别列表。
---
## 七、移植到其他报销类型的改造清单
如果要为中审(`businessFlag=2`)、评估(`businessFlag=3`)等添加同样的导入功能,需要改动以下位置:
### 7.1 必须改
| 改动项 | 说明 |
|--------|------|
| `businessFlag` 值 | `writeGroupToDb` 中 `info.setBusinessFlag("1")` → 改为对应值 |
| 编号 bizCode | 确认不同报销类型是否共用编号规则,否则调整 `bizCode` |
| 权限标识 | `cw:reimbursement:info:import` → 对应模块的权限 key |
| 前端 API 前缀 | `FINANCE_PATH` 可能需要改为对应模块的路径 |
| 列表页集成 | 在对应模块的 List.vue 中添加导入按钮和组件引用 |
### 7.2 可能需要改
| 改动项 | 说明 |
|--------|------|
| 报销类型枚举 | 如果中审/评估有不同的类型分类,调整 `sourceTypeName` 的校验和 `sourceType` 映射 |
| 明细表结构 | 如果中审/评估的明细表字段不同,调整 `writeGroupToDb` 中的字段赋值 |
| 校验规则 | 根据业务需求调整必填项、匹配逻辑等 |
| 模板列 | 如果字段不同,调整 DTO 的 @Excel 注解和模板 Excel |
| 部门逻辑 | 如果经办人定义不同(不一定是当前登录人),调整主表的 userId/userName/department 赋值逻辑 |
### 7.3 不需要改
| 项目 | 说明 |
|------|------|
| EasyPOI 解析逻辑 | `ImportParams` 配置、行过滤、分组逻辑通用 |
| 错误 Excel 生成 | `generateErrorExcel` 方法通用 |
| 前端 ImportDialog 组件 | 可直接复用或复制后修改文案 |
| 模板下载逻辑 | Controller 的 `importTemplate` 方法通用(只需换模板文件) |
| 类别列表刷新 | `findAllLeafCategories` 查的是同一张类别表 |
---
## 八、踩坑记录 & 注意事项
### 8.1 EasyPOI 相关
1. **titleRows/headRows 配置**:必须根据模板实际结构设置。列头在第几行,`titleRows` 就是几(0-based 跳过行数),`headRows` 是列头占几行。
2. **@Excel 的 name 必须与列头文字完全一致**(包括括号、空格),否则字段为 null。
3. **Numeric 列遇到文字**:EasyPOI 内部 catch NumberFormatException 并设 null,不会抛异常中断,但会在日志中打印 ERROR。
4. **orderNum 是 String 类型**,表示列序号(从 "0" 或 "1" 开始均可,EasyPOI 按数值排序)。
### 8.2 用户/部门相关
5. **`currentUser.getOfficeDTO().getId()`** 才是取 office ID 的正确方式,`getOfficeId()` 不存在。
6. **部门名称获取**:优先从 `getOfficeDTO().getName()` 直接取,兜底走 `IOfficeApi.getOfficeById()`。Feign 调用可能返回 null。
7. **经办人 vs 报销人**:经办人 = 当前登录人(主表的 userId/userName/department),报销人 = Excel 中填写的人(明细表的 userId/userName/deptId/deptName),两者不冲突。
### 8.3 模板相关
8. **模板文件放 `resources/dot/`**:下载时直接 `getResourceAsStream` 加载,不要手写 POI 从零生成。
9. **类别列表动态刷新**:下载模板时清空 Sheet2 旧数据,写入最新末级类别。
10. **示例行标记**:用 `[示例]` 前缀标记,代码中按此过滤。描述行用"请删除"等关键词过滤。
### 8.4 行号映射
11. **`excelRowNum` 计算**:`i + 2`(titleRows=0, headRows=1 时,索引 0 对应 Excel 第 2 行)。
12. **错误 Excel 行号**:`dataRowIndex + 1`(0-based,跳过第 0 行列头)。
13. **描述行过滤**:必须在分组循环最前面过滤,否则其 `originalIndex` 会占位导致后续行号偏移。
### 8.5 前端相关
14. **错误信息提取**:ExceptionTranslator 返回 `"错误: " + message` 纯文本,前端需从 `err.response.data` 取并去掉前缀。
15. **刷新时机**:全部成功和部分成功都要刷新列表,全部失败不刷新(无数据写入)。
16. **el-upload 的 `:auto-upload="false"`**:手动控制上传时机,点"确定"时才提交。