本文档记录会计报销模块(
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 方法 |
| 列 | 列名 | 必填 | 说明 |
|---|---|---|---|
| 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列含"[示例]"标记)或用户实际数据
动态刷新——下载模板时从 cw_reimbursement_type_info 表查询所有末级节点写入。
ImportParams params = new ImportParams();
params.setTitleRows(0); // 第1行就是列头,不跳过
params.setHeadRows(1); // 第1行作为列头匹配,从第2行开始读取数据
踩坑记录:
titleRows=1, headRows=1 → EasyPOI 跳过第1行(真正列头),把第2行(描述行)当列头 → 所有字段 nulltitleRows=0, headRows=1 → 正确。第1行列头匹配 @Excel 注解,第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;
按「是否单独报销」列分组:
是 → 新建一组(一张报销单)同上 → 归入上一组(同一张报销单的追加明细)row.put("originalIndex", i); // EasyPOI 返回列表中的索引(0-based)
row.put("excelRowNum", i + 2); // 对应 Excel 实际行号(1-based,跳过列头行)
组内一致性校验:
逐行数据校验:
| 校验项 | 规则 |
|---|---|
| 报销类型 | 必须是"项目报销"/"报告报销"/"其他报销" |
| 报销类别 | 不能为空;必须在 cw_reimbursement_type_info 中存在且为末级节点 |
| 项目编号 | 项目/报告报销必填;必须在 cw_project_records 中存在 |
| 报告号 | 报告报销必填;必须属于该项目(查 cw_project_report_new_line) |
| 报销人 | 不填默认当前用户;填写则按姓名查 sys_user,重名报错 |
| 报销详情 | 其他报销必填 |
| 金额 | 汇总为空时自动计算 = 非数电发票 + 其他 |
主表 cw_reimbursement_info:
currentUser)getOfficeDTO().getName() 取,兜底走 IOfficeApi.getOfficeById())type=1(暂存)businessFlag=1(会计)IWorkAttachmentApi.genSerialNum() 生成明细表(按 sourceType 分三张表):
matchedUserId/matchedUserName/matchedDeptId/matchedDeptName)reportId(来自 findReportNosByProjectId 的 line_id)和 reportNumberLIGHT_GREEN)+ 红色字体excelRowNum = dataRowIndex + 1(0-based,跳过第 0 行列头)// 优先从 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() 方法不存在。
布局: 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 加的 "错误: " 前缀
}
<!-- 工具栏按钮 -->
<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 弹出是否下载错误文件的确认框,区分"是/否/关闭"三种操作。
importTemplate() // GET, responseType: "blob"
importFile(data) // POST multipart/form-data
downloadImportError(filePath) // GET, responseType: "blob", params: { filePath }
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
用途: 按名称匹配报销类别,确保是末级节点(无子节点)。
SELECT id, project_name FROM cw_project_records
WHERE del_flag = 0 AND project_number = #{projectNumber} LIMIT 1
用途: 按项目编号精确匹配。
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。
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 用于明细表的部门信息。
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)等添加同样的导入功能,需要改动以下位置:
| 改动项 | 说明 |
|---|---|
businessFlag 值 |
writeGroupToDb 中 info.setBusinessFlag("1") → 改为对应值 |
| 编号 bizCode | 确认不同报销类型是否共用编号规则,否则调整 bizCode |
| 权限标识 | cw:reimbursement:info:import → 对应模块的权限 key |
| 前端 API 前缀 | FINANCE_PATH 可能需要改为对应模块的路径 |
| 列表页集成 | 在对应模块的 List.vue 中添加导入按钮和组件引用 |
| 改动项 | 说明 |
|---|---|
| 报销类型枚举 | 如果中审/评估有不同的类型分类,调整 sourceTypeName 的校验和 sourceType 映射 |
| 明细表结构 | 如果中审/评估的明细表字段不同,调整 writeGroupToDb 中的字段赋值 |
| 校验规则 | 根据业务需求调整必填项、匹配逻辑等 |
| 模板列 | 如果字段不同,调整 DTO 的 @Excel 注解和模板 Excel |
| 部门逻辑 | 如果经办人定义不同(不一定是当前登录人),调整主表的 userId/userName/department 赋值逻辑 |
| 项目 | 说明 |
|---|---|
| EasyPOI 解析逻辑 | ImportParams 配置、行过滤、分组逻辑通用 |
| 错误 Excel 生成 | generateErrorExcel 方法通用 |
| 前端 ImportDialog 组件 | 可直接复用或复制后修改文案 |
| 模板下载逻辑 | Controller 的 importTemplate 方法通用(只需换模板文件) |
| 类别列表刷新 | findAllLeafCategories 查的是同一张类别表 |
titleRows 就是几(0-based 跳过行数),headRows 是列头占几行。currentUser.getOfficeDTO().getId() 才是取 office ID 的正确方式,getOfficeId() 不存在。getOfficeDTO().getName() 直接取,兜底走 IOfficeApi.getOfficeById()。Feign 调用可能返回 null。resources/dot/:下载时直接 getResourceAsStream 加载,不要手写 POI 从零生成。[示例] 前缀标记,代码中按此过滤。描述行用"请删除"等关键词过滤。excelRowNum 计算:i + 2(titleRows=0, headRows=1 时,索引 0 对应 Excel 第 2 行)。dataRowIndex + 1(0-based,跳过第 0 行列头)。originalIndex 会占位导致后续行号偏移。"错误: " + message 纯文本,前端需从 err.response.data 取并去掉前缀。:auto-upload="false":手动控制上传时机,点"确定"时才提交。