报销批量导入功能实现文档.md 14 KB

报销批量导入功能 — 实现文档

本文档记录会计报销模块(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 配置(关键!)

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 行过滤逻辑(三重过滤)

// 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 行号计算

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 部门查询兜底逻辑

// 优先从 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 取服务器错误信息)

错误信息提取:

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 集成

<!-- 工具栏按钮 -->
<el-button v-if="hasPermission('cw:reimbursement:info:import')" type="info" icon="el-icon-upload2"
    @click="openImportDialog()">导入</el-button>

<!-- 组件引用 -->
<ImportDialog ref="importDialog" @refreshDataList="refreshList" @import-error="handleImportError"></ImportDialog>

handleImportError 用 this.$confirm 弹出是否下载错误文件的确认框,区分"是/否/关闭"三种操作。

5.3 API 方法

importTemplate()    // GET, responseType: "blob"
importFile(data)    // POST multipart/form-data
downloadImportError(filePath)  // GET, responseType: "blob", params: { filePath }

六、SQL 查询清单

6.1 findCategoryByName

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

SELECT id, project_name FROM cw_project_records
WHERE del_flag = 0 AND project_number = #{projectNumber} LIMIT 1

用途: 按项目编号精确匹配。

6.3 findReportNosByProjectId

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

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

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 用户/部门相关

  1. currentUser.getOfficeDTO().getId() 才是取 office ID 的正确方式,getOfficeId() 不存在。
  2. 部门名称获取:优先从 getOfficeDTO().getName() 直接取,兜底走 IOfficeApi.getOfficeById()。Feign 调用可能返回 null。
  3. 经办人 vs 报销人:经办人 = 当前登录人(主表的 userId/userName/department),报销人 = Excel 中填写的人(明细表的 userId/userName/deptId/deptName),两者不冲突。

8.3 模板相关

  1. 模板文件放 resources/dot/:下载时直接 getResourceAsStream 加载,不要手写 POI 从零生成。
  2. 类别列表动态刷新:下载模板时清空 Sheet2 旧数据,写入最新末级类别。
  3. 示例行标记:用 [示例] 前缀标记,代码中按此过滤。描述行用"请删除"等关键词过滤。

8.4 行号映射

  1. excelRowNum 计算:i + 2(titleRows=0, headRows=1 时,索引 0 对应 Excel 第 2 行)。
  2. 错误 Excel 行号:dataRowIndex + 1(0-based,跳过第 0 行列头)。
  3. 描述行过滤:必须在分组循环最前面过滤,否则其 originalIndex 会占位导致后续行号偏移。

8.5 前端相关

  1. 错误信息提取:ExceptionTranslator 返回 "错误: " + message 纯文本,前端需从 err.response.data 取并去掉前缀。
  2. 刷新时机:全部成功和部分成功都要刷新列表,全部失败不刷新(无数据写入)。
  3. el-upload 的 :auto-upload="false":手动控制上传时机,点"确定"时才提交。