# 报销批量导入功能 — 实现文档 > 本文档记录会计报销模块(`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"`**:手动控制上传时机,点"确定"时才提交。