Prechádzať zdrojové kódy

中审报销 添加批量导入功能

徐滕 2 týždňov pred
rodič
commit
4131e2a541

+ 345 - 0
docs/报销批量导入功能实现文档.md

@@ -0,0 +1,345 @@
+# 报销批量导入功能 — 实现文档
+
+> 本文档记录会计报销模块(`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
+<!-- 工具栏按钮 -->
+<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 方法
+
+```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"`**:手动控制上传时机,点"确定"时才提交。

+ 77 - 0
jeeplus-modules/jeeplus-centrecareful/src/main/java/com/jeeplus/centrecareful/approvalInfo/controller/ZsReimbursementInfoController.java

@@ -27,6 +27,10 @@ import org.springframework.web.bind.annotation.*;
 import org.springframework.http.ResponseEntity;
 import javax.annotation.Resource;
 import javax.servlet.http.HttpServletResponse;
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.OutputStream;
+import java.net.URLEncoder;
 import java.time.LocalDate;
 import java.time.ZoneId;
 import java.util.*;
@@ -567,5 +571,78 @@ public class ZsReimbursementInfoController {
         return currentUserId;
     }
 
+    // ==================== 导入功能接口 ====================
+
+    /**
+     * 下载导入模板
+     * 读取resources/dot/下的模板文件,动态刷新类别列表后下载
+     */
+    @ApiOperation(value = "下载导入模板")
+    @GetMapping("/importTemplate")
+    public void importTemplate(HttpServletResponse response) throws Exception {
+        java.io.InputStream inputStream = this.getClass().getResourceAsStream("/dot/中审报销导入模板.xlsx");
+        org.apache.poi.ss.usermodel.Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(inputStream);
+
+        // 查询sort以'1'开头的末级报销类别
+        List<Map<String, Object>> categories = service.findAllLeafCategories();
+
+        // 刷新「类别列表」Sheet
+        org.apache.poi.ss.usermodel.Sheet categorySheet = workbook.getSheet("类别列表");
+        for (int i = categorySheet.getLastRowNum(); i >= 1; i--) {
+            org.apache.poi.ss.usermodel.Row row = categorySheet.getRow(i);
+            if (row != null) categorySheet.removeRow(row);
+        }
+        for (int i = 0; i < categories.size(); i++) {
+            org.apache.poi.ss.usermodel.Row row = categorySheet.createRow(i + 1);
+            row.createCell(0).setCellValue(categories.get(i).get("name").toString());
+        }
+
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode("中审报销导入模板.xlsx", "UTF-8"));
+        OutputStream os = response.getOutputStream();
+        workbook.write(os);
+        os.flush();
+        os.close();
+        workbook.close();
+        inputStream.close();
+    }
+
+    /**
+     * 导入Excel文件
+     */
+    @ApiOperation(value = "导入报销数据")
+    @ApiLog(value = "中审报销批量导入", type = LogTypeEnum.IMPORT)
+    @PostMapping("/importFile")
+    public ResponseEntity<com.jeeplus.centrecareful.approvalInfo.service.dto.ZsReimbursementImportResult> importFile(@RequestParam("file") org.springframework.web.multipart.MultipartFile file) throws Exception {
+        com.jeeplus.sys.service.dto.UserDTO currentUser = SpringUtil.getBean(com.jeeplus.sys.feign.IUserApi.class)
+                .getByToken(com.jeeplus.common.TokenProvider.getCurrentToken());
+        com.jeeplus.centrecareful.approvalInfo.service.dto.ZsReimbursementImportResult result = service.importReimbursement(file, currentUser);
+        return ResponseEntity.ok(result);
+    }
+
+    /**
+     * 下载导入错误Excel
+     */
+    @ApiOperation(value = "下载导入错误文件")
+    @GetMapping("/downloadImportError")
+    public void downloadImportError(@RequestParam("filePath") String filePath, HttpServletResponse response) throws Exception {
+        File errorFile = new File(filePath);
+        if (!errorFile.exists()) {
+            throw new RuntimeException("错误文件不存在或已过期");
+        }
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode("中审报销导入错误标注.xlsx", "UTF-8"));
+        try (FileInputStream fis = new FileInputStream(errorFile);
+             OutputStream os = response.getOutputStream()) {
+            byte[] buffer = new byte[4096];
+            int len;
+            while ((len = fis.read(buffer)) != -1) {
+                os.write(buffer, 0, len);
+            }
+            os.flush();
+        }
+        errorFile.delete();
+    }
+
 
 }

+ 17 - 0
jeeplus-modules/jeeplus-centrecareful/src/main/java/com/jeeplus/centrecareful/approvalInfo/mapper/ZsReimbursementInfoMapper.java

@@ -96,4 +96,21 @@ public interface ZsReimbursementInfoMapper extends BaseMapper<ZsReimbursementInf
     @InterceptorIgnore(tenantLine = "true")
     List<WorkAttachmentInfo> findReimFiles(@Param("infoId") String infoId,@Param("attachmentFlag") String attachmentFlag);
 
+    // ==================== 导入功能查询SQL ====================
+
+    /**
+     * 根据类别名称查询报销类别(仅末级节点:无子节点的记录,sort以'1'开头)
+     */
+    java.util.Map<String, Object> findCategoryByName(@Param("typeName") String typeName);
+
+    /**
+     * 根据姓名查询用户(受租户拦截器控制,只查当前租户)
+     */
+    List<java.util.Map<String, Object>> findUserByName(@Param("userName") String userName);
+
+    /**
+     * 查询sort以'1'开头的末级报销类别(无子节点的记录)
+     */
+    List<java.util.Map<String, Object>> findAllLeafCategories();
+
 }

+ 41 - 0
jeeplus-modules/jeeplus-centrecareful/src/main/java/com/jeeplus/centrecareful/approvalInfo/mapper/xml/ZsReimbursementInfoMapper.xml

@@ -992,4 +992,45 @@
 			AND attachment_id = #{infoId} and attachment_flag = #{attachmentFlag}
 	</select>
 
+	<!-- ==================== 导入功能查询SQL ==================== -->
+
+	<!-- 根据类别名称查询报销类别(仅末级节点:无子节点,sort以'1'开头) -->
+	<select id="findCategoryByName" resultType="java.util.Map">
+		SELECT t.id, t.name
+		FROM cw_reimbursement_type_info t
+		WHERE t.del_flag = 0
+		  AND t.name = #{typeName}
+		  AND t.sort LIKE '1%'
+		  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>
+
+	<!-- 根据姓名查询用户(受租户拦截器控制,只查当前租户) -->
+	<select id="findUserByName" resultType="java.util.Map">
+		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}
+	</select>
+
+	<!-- 查询sort以'1'开头的末级报销类别(无子节点的记录) -->
+	<select id="findAllLeafCategories" resultType="java.util.Map">
+		SELECT t.id, t.name
+		FROM cw_reimbursement_type_info t
+		WHERE t.del_flag = 0
+		  AND t.sort LIKE '1%'
+		  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
+	</select>
+
 </mapper>

+ 569 - 0
jeeplus-modules/jeeplus-centrecareful/src/main/java/com/jeeplus/centrecareful/approvalInfo/service/ZsReimbursementInfoService.java

@@ -4,6 +4,8 @@ import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.core.date.DateUtil;
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.extra.spring.SpringUtil;
+import cn.afterturn.easypoi.excel.ExcelImportUtil;
+import cn.afterturn.easypoi.excel.entity.ImportParams;
 import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.TypeReference;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -31,12 +33,19 @@ import com.jeeplus.sys.service.dto.OfficeDTO;
 import com.jeeplus.sys.service.dto.RoleDTO;
 import com.jeeplus.sys.service.dto.UserDTO;
 import com.jeeplus.utils.StringUtils;
+import com.jeeplus.sys.feign.IWorkAttachmentApi;
+import org.apache.poi.ss.usermodel.*;
 import org.springframework.beans.BeanUtils;
 import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
 import javax.annotation.Resource;
 import com.baomidou.mybatisplus.*;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileOutputStream;
+import java.io.OutputStream;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
@@ -1712,4 +1721,564 @@ public class ZsReimbursementInfoService {
         String createUserId = zsReimbursementFileSupplementMapper.getReimbursementCreateUserIdByProcessInstanceId(id);
         return createUserId;
     }
+
+    // ==================== 导入功能 ====================
+
+    /**
+     * 查询sort以'1'开头的末级报销类别
+     * @return 类别列表
+     */
+    public List<Map<String, Object>> findAllLeafCategories() {
+        return infoMapper.findAllLeafCategories();
+    }
+
+    /**
+     * 中审-报销批量导入
+     * @param file Excel文件
+     * @param currentUser 当前登录用户
+     * @return 导入结果
+     */
+    public ZsReimbursementImportResult importReimbursement(MultipartFile file, UserDTO currentUser) throws Exception {
+        ZsReimbursementImportResult result = new ZsReimbursementImportResult();
+        List<Map<String, Object>> errorMessages = new ArrayList<>();
+        Map<Integer, String> rowErrorMap = new LinkedHashMap<>();
+
+        String currentUserId = currentUser.getId();
+        String currentUserName = currentUser.getName();
+        String companyId = currentUser.getCompanyDTO().getId();
+        String currentToken = TokenProvider.getCurrentToken();
+
+        // 当前用户部门
+        String currentUserDeptId = "";
+        String currentUserDeptName = "";
+        if (currentUser.getOfficeDTO() != null && StringUtils.isNotBlank(currentUser.getOfficeDTO().getId())) {
+            currentUserDeptId = currentUser.getOfficeDTO().getId();
+            currentUserDeptName = currentUser.getOfficeDTO().getName();
+            if (StringUtils.isBlank(currentUserDeptName)) {
+                OfficeDTO officeDTO = SpringUtil.getBean(IOfficeApi.class).getOfficeById(currentUserDeptId);
+                if (officeDTO != null) {
+                    currentUserDeptName = officeDTO.getName();
+                }
+            }
+        }
+        // 如果仍然没有部门,从数据库查询
+        if (StringUtils.isBlank(currentUserDeptName)) {
+            List<Map<String, Object>> users = infoMapper.findUserByName(currentUserName);
+            if (users != null && !users.isEmpty()) {
+                Map<String, Object> u = users.get(0);
+                currentUserDeptId = u.get("office_id") != null ? u.get("office_id").toString() : "";
+                currentUserDeptName = u.get("office_name") != null ? u.get("office_name").toString() : "";
+            }
+        }
+
+        // 1. 解析Excel
+        ImportParams params = new ImportParams();
+        params.setTitleRows(0);
+        params.setHeadRows(1);
+        List<ZsReimbursementImportDto> rows = ExcelImportUtil.importExcel(file.getInputStream(), ZsReimbursementImportDto.class, params);
+
+        if (rows == null || rows.isEmpty()) {
+            throw new RuntimeException("Excel文件为空或格式不正确");
+        }
+
+        // 2. 分组
+        List<List<Map<String, Object>>> groups = new ArrayList<>();
+        for (int i = 0; i < rows.size(); i++) {
+            ZsReimbursementImportDto dto = rows.get(i);
+            String aloneFlag = safeStr(dto.getAloneFlag());
+            String sourceTypeName = safeStr(dto.getSourceTypeName());
+            String userName = safeStr(dto.getUserName());
+            String typeName = safeStr(dto.getTypeName());
+
+            // 跳过模板描述行
+            if (aloneFlag.contains("请删除") || aloneFlag.contains("填写提示")) {
+                continue;
+            }
+            // 跳过模板示例行
+            if (aloneFlag.contains("[示例]")) {
+                continue;
+            }
+            // 跳过全空行
+            if (StringUtils.isBlank(aloneFlag) && StringUtils.isBlank(sourceTypeName)
+                    && StringUtils.isBlank(userName) && StringUtils.isBlank(typeName)) {
+                continue;
+            }
+            // 只处理合法数据行
+            if (!"是".equals(aloneFlag) && !"同上".equals(aloneFlag)) {
+                continue;
+            }
+
+            Map<String, Object> row = new HashMap<>();
+            row.put("originalIndex", i);
+            row.put("excelRowNum", i + 2);
+            row.put("dto", dto);
+            row.put("sourceTypeName", sourceTypeName);
+            row.put("userName", userName);
+            row.put("typeName", typeName);
+
+            if ("是".equals(aloneFlag) || groups.isEmpty()) {
+                List<Map<String, Object>> group = new ArrayList<>();
+                group.add(row);
+                groups.add(group);
+            } else if ("同上".equals(aloneFlag)) {
+                groups.get(groups.size() - 1).add(row);
+            }
+        }
+
+        if (groups.isEmpty()) {
+            throw new RuntimeException("未找到有效的数据行,请确认已删除模板示例行并填入实际数据");
+        }
+
+        // 3. 逐组校验
+        int successCount = 0, failCount = 0, successDetailCount = 0, failDetailCount = 0;
+
+        for (List<Map<String, Object>> group : groups) {
+            List<String> groupErrors = new ArrayList<>();
+
+            // 3.1 组内一致性校验
+            Map<String, Object> firstRow = group.get(0);
+            String firstSourceTypeName = (String) firstRow.get("sourceTypeName");
+
+            for (int r = 1; r < group.size(); r++) {
+                Map<String, Object> row = group.get(r);
+                int rowNum = (int) row.get("excelRowNum");
+
+                if (!firstSourceTypeName.equals(row.get("sourceTypeName"))) {
+                    String err = "第" + rowNum + "行:同组内报销类型与第一行不一致";
+                    groupErrors.add(err);
+                    rowErrorMap.put((int) row.get("originalIndex"), err);
+                }
+            }
+
+            // 3.2 逐行数据匹配校验
+            for (Map<String, Object> row : group) {
+                ZsReimbursementImportDto dto = (ZsReimbursementImportDto) row.get("dto");
+                int rowNum = (int) row.get("excelRowNum");
+                int originalIndex = (int) row.get("originalIndex");
+                String stn = (String) row.get("sourceTypeName");
+                List<String> rowErrors = new ArrayList<>();
+
+                // 报销类型校验
+                if (!"兴光项目报销".equals(stn) && !"兴光报告报销".equals(stn)
+                        && !"其他报销".equals(stn) && !"中审项目报销".equals(stn)) {
+                    rowErrors.add("第" + rowNum + "行:报销类型必须为兴光项目报销、兴光报告报销、其他报销或中审项目报销");
+                }
+
+                // 报销类别匹配
+                Map<String, Object> category = null;
+                if (StringUtils.isNotBlank((String) row.get("typeName"))) {
+                    category = infoMapper.findCategoryByName((String) row.get("typeName"));
+                    if (category == null) {
+                        rowErrors.add("第" + rowNum + "行:报销类别「" + row.get("typeName") + "」不存在或不是末级节点");
+                    }
+                } else {
+                    rowErrors.add("第" + rowNum + "行:报销类别不能为空");
+                }
+
+                // 报销人匹配
+                String userId = currentUserId;
+                String userNameVal = currentUserName;
+                String deptId = currentUserDeptId;
+                String deptName = currentUserDeptName;
+                if (StringUtils.isNotBlank((String) row.get("userName"))) {
+                    List<Map<String, Object>> users = infoMapper.findUserByName((String) row.get("userName"));
+                    if (users == null || users.isEmpty()) {
+                        rowErrors.add("第" + rowNum + "行:报销人「" + row.get("userName") + "」不存在");
+                    } else if (users.size() > 1) {
+                        rowErrors.add("第" + rowNum + "行:报销人「" + row.get("userName") + "」存在重名,请手动录入后调整");
+                    } else {
+                        Map<String, Object> u = users.get(0);
+                        userId = u.get("id").toString();
+                        userNameVal = u.get("name").toString();
+                        deptId = u.get("office_id") != null ? u.get("office_id").toString() : "";
+                        deptName = u.get("office_name") != null ? u.get("office_name").toString() : "";
+                    }
+                }
+                row.put("matchedUserId", userId);
+                row.put("matchedUserName", userNameVal);
+                row.put("matchedDeptId", deptId);
+                row.put("matchedDeptName", deptName);
+
+                // 报销详情必填
+                if (StringUtils.isBlank(safeStr(dto.getProjectName()))) {
+                    rowErrors.add("第" + rowNum + "行:报销详情不能为空");
+                }
+
+                // 费用处理:汇总 = 非数电发票 + 其他,可手动填写也可自动计算
+                String ordinaryNumber = safeStr(dto.getOrdinaryNumber());
+                String subsidy = safeStr(dto.getSubsidy());
+                String numberCount = safeStr(dto.getNumberCount());
+                if (StringUtils.isBlank(numberCount)) {
+                    numberCount = calcSum(ordinaryNumber, subsidy);
+                }
+                // 至少有一项费用
+                if ("0".equals(numberCount) && StringUtils.isBlank(ordinaryNumber) && StringUtils.isBlank(subsidy)) {
+                    rowErrors.add("第" + rowNum + "行:费用不能全部为空或为0");
+                }
+                row.put("ordinaryNumber", ordinaryNumber);
+                row.put("subsidy", subsidy);
+                row.put("numberCount", numberCount);
+
+                // 张数处理
+                int ordinaryReceiptNumber = dto.getOrdinaryReceiptNumber() != null ? dto.getOrdinaryReceiptNumber() : 0;
+                row.put("ordinaryReceiptNumber", ordinaryReceiptNumber);
+                row.put("receiptNumberCount", ordinaryReceiptNumber);
+
+                // type=8 特殊校验:发票号和立项号至少填写一项
+                if ("中审项目报销".equals(stn)) {
+                    String invoiceNumber = safeStr(dto.getInvoiceNumber());
+                    String projectApprovalNumber = safeStr(dto.getProjectApprovalNumber());
+                    if (StringUtils.isBlank(invoiceNumber) && StringUtils.isBlank(projectApprovalNumber)) {
+                        rowErrors.add("第" + rowNum + "行:中审项目报销的发票号和立项号至少填写一项");
+                    }
+                    row.put("invoiceNumber", invoiceNumber);
+                    row.put("projectApprovalNumber", projectApprovalNumber);
+                }
+
+                // 存储匹配结果
+                row.put("category", category);
+
+                if (!rowErrors.isEmpty()) {
+                    for (String err : rowErrors) {
+                        groupErrors.add(err);
+                    }
+                    if (!rowErrorMap.containsKey(originalIndex)) {
+                        rowErrorMap.put(originalIndex, rowErrors.get(0));
+                    }
+                }
+            }
+
+            // 4. 根据校验结果写入数据库或标记失败
+            if (groupErrors.isEmpty()) {
+                try {
+                    writeGroupToDb(group, companyId, currentToken, currentUserId, currentUserName, currentUserDeptId, currentUserDeptName);
+                    successCount++;
+                    successDetailCount += group.size();
+                } catch (Exception e) {
+                    failCount++;
+                    failDetailCount += group.size();
+                    for (Map<String, Object> r : group) {
+                        int oi = (int) r.get("originalIndex");
+                        if (!rowErrorMap.containsKey(oi)) {
+                            rowErrorMap.put(oi, "第" + r.get("excelRowNum") + "行:写入失败 - " + e.getMessage());
+                        }
+                    }
+                    errorMessages.add(createErrorEntry(
+                            (int) group.get(0).get("excelRowNum"),
+                            "写入失败: " + e.getMessage()));
+                }
+            } else {
+                failCount++;
+                failDetailCount += group.size();
+                for (String err : groupErrors) {
+                    errorMessages.add(createErrorEntry(0, err));
+                }
+            }
+        }
+
+        // 5. 如果有失败,生成错误Excel
+        if (!rowErrorMap.isEmpty()) {
+            String errorPath = generateErrorExcel(file, rowErrorMap);
+            result.setErrorFilePath(errorPath);
+        }
+
+        result.setSuccessCount(successCount);
+        result.setFailCount(failCount);
+        result.setSuccessDetailCount(successDetailCount);
+        result.setFailDetailCount(failDetailCount);
+        result.setErrorMessages(errorMessages);
+        return result;
+    }
+
+    /**
+     * 将校验通过的一组数据写入数据库
+     */
+    private void writeGroupToDb(List<Map<String, Object>> group, String companyId, String currentToken,
+                                String currentUserId, String currentUserName, String currentDeptId, String currentDeptName) throws Exception {
+        String infoId = UUID.randomUUID().toString().replace("-", "");
+        // 生成报销编号(与单条新增使用相同的bizCode)
+        Date targetDate = DateUtil.parseDate("2026-01-01");
+        boolean isAfterTargetDate = DateUtil.date().after(targetDate);
+        String bizCode = isAfterTargetDate ? ZsSaveInfoDto.NEW_BIZ_CODE : ZsSaveInfoDto.BIZ_CODE;
+        String no = SpringUtil.getBean(IWorkAttachmentApi.class)
+                .genSerialNum(companyId, bizCode, currentToken);
+
+        // 取第一行确定sourceType
+        ZsReimbursementImportDto firstDto = (ZsReimbursementImportDto) group.get(0).get("dto");
+        String sourceTypeName = (String) group.get(0).get("sourceTypeName");
+        String sourceType;
+        switch (sourceTypeName) {
+            case "兴光项目报销": sourceType = "1"; break;
+            case "兴光报告报销": sourceType = "3"; break;
+            case "中审项目报销": sourceType = "8"; break;
+            default: sourceType = "4"; break;
+        }
+
+        // 写入主表 zs_reimbursement_info(经办人 = 当前登录人)
+        ZsReimbursementInfo info = new ZsReimbursementInfo();
+        info.setId(infoId);
+        info.setNo(no);
+        info.setUserId(currentUserId);
+        info.setUserName(currentUserName);
+        info.setDepartment(currentDeptId); // 存储部门ID,前端会根据ID查询部门名称
+        info.setReimDate(new Date());
+        info.setSourceType(sourceType);
+        info.setType("1"); // 暂存
+        info.setReimbursementType("0"); // 普通报销
+        info.setCreateById(currentUserId);
+        info.setCreateTime(new Date());
+        info.setUpdateById(currentUserId);
+        info.setUpdateTime(new Date());
+        info.setDelFlag(0);
+        infoMapper.insert(info);
+
+        // 写入明细记录
+        for (Map<String, Object> row : group) {
+            ZsReimbursementImportDto dto = (ZsReimbursementImportDto) row.get("dto");
+            String userId = (String) row.get("matchedUserId");
+            String userName = (String) row.get("matchedUserName");
+            String deptId = (String) row.get("matchedDeptId");
+            String deptName = (String) row.get("matchedDeptName");
+            String ordinaryNumber = (String) row.get("ordinaryNumber");
+            String subsidy = (String) row.get("subsidy");
+            String numberCount = (String) row.get("numberCount");
+            int ordinaryReceiptNumber = (int) row.get("ordinaryReceiptNumber");
+            int receiptNumberCount = (int) row.get("receiptNumberCount");
+            Map<String, Object> category = (Map<String, Object>) row.get("category");
+
+            String detailId = UUID.randomUUID().toString().replace("-", "");
+            String typeId = category != null ? category.get("id").toString() : "";
+            String typeName = category != null ? category.get("name").toString() : "";
+
+            switch (sourceType) {
+                case "1": {
+                    // 兴光项目报销 → zs_reimbursement_detail_info
+                    ZsReimbursementDetailInfo detail = new ZsReimbursementDetailInfo();
+                    detail.setId(detailId);
+                    detail.setInfoId(infoId);
+                    detail.setUserId(userId);
+                    detail.setUserName(userName);
+                    detail.setDeptId(deptId);
+                    detail.setDeptName(deptName);
+                    detail.setTypeId(typeId);
+                    detail.setTypeName(typeName);
+                    detail.setProjectName(safeStr(dto.getProjectName()));
+                    detail.setNumber("0");
+                    detail.setOrdinaryNumber(ordinaryNumber);
+                    detail.setSubsidy(subsidy);
+                    detail.setNumberCount(numberCount);
+                    detail.setReceiptNumber(0);
+                    detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
+                    detail.setReceiptNumberCount(receiptNumberCount);
+                    detail.setContent(safeStr(dto.getContent()));
+                    detail.setDays(dto.getDays());
+                    detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
+                    detail.setReimbursementType("0");
+                    detail.setCreateById(currentUserId);
+                    detail.setCreateTime(new Date());
+                    detail.setUpdateById(currentUserId);
+                    detail.setUpdateTime(new Date());
+                    detail.setDelFlag(0);
+                    detailInfoMapper.insert(detail);
+                    break;
+                }
+                case "3": {
+                    // 兴光报告报销 → zs_reimbursement_detail_info_report
+                    ZsReimbursementDetailInfoReport detail = new ZsReimbursementDetailInfoReport();
+                    detail.setId(detailId);
+                    detail.setInfoId(infoId);
+                    detail.setUserId(userId);
+                    detail.setUserName(userName);
+                    detail.setDeptId(deptId);
+                    detail.setDeptName(deptName);
+                    detail.setTypeId(typeId);
+                    detail.setTypeName(typeName);
+                    detail.setProjectName(safeStr(dto.getProjectName()));
+                    detail.setNumber("0");
+                    detail.setOrdinaryNumber(ordinaryNumber);
+                    detail.setSubsidy(subsidy);
+                    detail.setNumberCount(numberCount);
+                    detail.setReceiptNumber(0);
+                    detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
+                    detail.setReceiptNumberCount(receiptNumberCount);
+                    detail.setContent(safeStr(dto.getContent()));
+                    detail.setDays(dto.getDays());
+                    detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
+                    detail.setReimbursementType("0");
+                    detail.setCreateById(currentUserId);
+                    detail.setCreateTime(new Date());
+                    detail.setUpdateById(currentUserId);
+                    detail.setUpdateTime(new Date());
+                    detail.setDelFlag(0);
+                    zsReimbursementDetailInfoReportMapper.insert(detail);
+                    break;
+                }
+                case "8": {
+                    // 中审项目报销 → zs_reimbursement_detail_info_zs_project
+                    ZsReimbursementDetailInfoProject detail = new ZsReimbursementDetailInfoProject();
+                    detail.setId(detailId);
+                    detail.setInfoId(infoId);
+                    detail.setUserId(userId);
+                    detail.setUserName(userName);
+                    detail.setDeptId(deptId);
+                    detail.setDeptName(deptName);
+                    detail.setTypeId(typeId);
+                    detail.setTypeName(typeName);
+                    detail.setProjectName(safeStr(dto.getProjectName()));
+                    detail.setInvoiceNumber((String) row.get("invoiceNumber"));
+                    detail.setProjectApprovalNumber((String) row.get("projectApprovalNumber"));
+                    detail.setNumber("0");
+                    detail.setOrdinaryNumber(ordinaryNumber);
+                    detail.setSubsidy(subsidy);
+                    detail.setNumberCount(numberCount);
+                    detail.setReceiptNumber(0);
+                    detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
+                    detail.setReceiptNumberCount(receiptNumberCount);
+                    detail.setContent(safeStr(dto.getContent()));
+                    detail.setDays(dto.getDays());
+                    detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
+                    detail.setReimbursementType("0");
+                    detail.setCreateById(currentUserId);
+                    detail.setCreateTime(new Date());
+                    detail.setUpdateById(currentUserId);
+                    detail.setUpdateTime(new Date());
+                    detail.setDelFlag(0);
+                    zsReimbursementDetailInfoProjectMapper.insert(detail);
+                    break;
+                }
+                default: {
+                    // 其他报销 → zs_reimbursement_detail_info_other
+                    ZsReimbursementDetailInfoOther detail = new ZsReimbursementDetailInfoOther();
+                    detail.setId(detailId);
+                    detail.setInfoId(infoId);
+                    detail.setUserId(userId);
+                    detail.setUserName(userName);
+                    detail.setDeptId(deptId);
+                    detail.setDeptName(deptName);
+                    detail.setTypeId(typeId);
+                    detail.setTypeName(typeName);
+                    detail.setProjectName(safeStr(dto.getProjectName()));
+                    detail.setNumber("0");
+                    detail.setOrdinaryNumber(ordinaryNumber);
+                    detail.setSubsidy(subsidy);
+                    detail.setNumberCount(numberCount);
+                    detail.setReceiptNumber(0);
+                    detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
+                    detail.setReceiptNumberCount(receiptNumberCount);
+                    detail.setContent(safeStr(dto.getContent()));
+                    detail.setDays(dto.getDays());
+                    detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
+                    detail.setReimbursementType("0");
+                    detail.setCreateById(currentUserId);
+                    detail.setCreateTime(new Date());
+                    detail.setUpdateById(currentUserId);
+                    detail.setUpdateTime(new Date());
+                    detail.setDelFlag(0);
+                    zsReimbursementDetailInfoOtherMapper.insert(detail);
+                    break;
+                }
+            }
+        }
+    }
+
+    /**
+     * 生成错误Excel文件
+     */
+    private String generateErrorExcel(MultipartFile originalFile, Map<Integer, String> rowErrorMap) throws Exception {
+        Workbook workbook = WorkbookFactory.create(originalFile.getInputStream());
+        Sheet sheet = workbook.getSheetAt(0);
+
+        int errorColIndex = 13; // 13列数据之后 = 第14列(0-based)
+
+        CellStyle errorStyle = workbook.createCellStyle();
+        Font errorFont = workbook.createFont();
+        errorFont.setColor(IndexedColors.RED.getIndex());
+        errorStyle.setFont(errorFont);
+        errorStyle.setFillForegroundColor(IndexedColors.LIGHT_GREEN.getIndex());
+        errorStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+
+        Row headerRow = sheet.getRow(0);
+        if (headerRow != null) {
+            Cell errorHeaderCell = headerRow.createCell(errorColIndex);
+            errorHeaderCell.setCellValue("错误原因");
+            errorHeaderCell.setCellStyle(errorStyle);
+        }
+
+        for (Map.Entry<Integer, String> entry : rowErrorMap.entrySet()) {
+            int dataRowIndex = entry.getKey();
+            int excelRowNum = dataRowIndex + 1;
+            Row dataRow = sheet.getRow(excelRowNum);
+            if (dataRow == null) {
+                dataRow = sheet.createRow(excelRowNum);
+            }
+            for (int c = 0; c <= errorColIndex; c++) {
+                Cell cell = dataRow.getCell(c);
+                if (cell == null) {
+                    cell = dataRow.createCell(c);
+                }
+                cell.setCellStyle(errorStyle);
+            }
+            Cell errorCell = dataRow.getCell(errorColIndex);
+            if (errorCell == null) {
+                errorCell = dataRow.createCell(errorColIndex);
+            }
+            errorCell.setCellValue(entry.getValue());
+        }
+
+        String tempPath = System.getProperty("java.io.tmpdir") + File.separator
+                + "zs_import_error_" + System.currentTimeMillis() + ".xlsx";
+        try (FileOutputStream fos = new FileOutputStream(tempPath)) {
+            workbook.write(fos);
+        }
+        workbook.close();
+        return tempPath;
+    }
+
+    /**
+     * 安全转字符串
+     */
+    private String safeStr(Object obj) {
+        if (obj == null) return "";
+        String s = obj.toString().trim();
+        if (s.endsWith(".0")) {
+            try {
+                Double.parseDouble(s);
+                s = s.substring(0, s.length() - 2);
+            } catch (NumberFormatException ignored) {}
+        }
+        return s;
+    }
+
+    /**
+     * 构建错误条目
+     */
+    private Map<String, Object> createErrorEntry(int row, String message) {
+        Map<String, Object> entry = new HashMap<>();
+        entry.put("row", row);
+        entry.put("message", message);
+        return entry;
+    }
+
+    /**
+     * 计算汇总 = 非数电发票(费用) + 其他(费用)
+     */
+    private String calcSum(String ordinaryNumber, String subsidy) {
+        double ordinary = parseDouble(ordinaryNumber);
+        double subsidyVal = parseDouble(subsidy);
+        double sum = ordinary + subsidyVal;
+        if (sum == 0) return "0";
+        return String.valueOf(sum);
+    }
+
+    /**
+     * 安全解析double
+     */
+    private double parseDouble(String str) {
+        if (StringUtils.isBlank(str)) return 0;
+        try {
+            return Double.parseDouble(str);
+        } catch (NumberFormatException e) {
+            return 0;
+        }
+    }
 }

+ 91 - 0
jeeplus-modules/jeeplus-centrecareful/src/main/java/com/jeeplus/centrecareful/approvalInfo/service/dto/ZsReimbursementImportDto.java

@@ -0,0 +1,91 @@
+package com.jeeplus.centrecareful.approvalInfo.service.dto;
+
+import cn.afterturn.easypoi.excel.annotation.Excel;
+import lombok.Data;
+
+/**
+ * 中审-报销导入Excel行数据映射
+ * @author: CDB
+ * @create: 2026-10-29
+ **/
+@Data
+public class ZsReimbursementImportDto {
+
+    /**
+     * 是否单独报销(是/同上)
+     */
+    @Excel(name = "是否单独报销", orderNum = "1")
+    private String aloneFlag;
+
+    /**
+     * 报销类型(兴光项目报销/兴光报告报销/其他报销/中审项目报销)
+     */
+    @Excel(name = "报销类型", orderNum = "2")
+    private String sourceTypeName;
+
+    /**
+     * 报销人姓名
+     */
+    @Excel(name = "报销人", orderNum = "3")
+    private String userName;
+
+    /**
+     * 报销类别名称
+     */
+    @Excel(name = "报销类别", orderNum = "4")
+    private String typeName;
+
+    /**
+     * 报销详情(纯文本描述)
+     */
+    @Excel(name = "报销详情", orderNum = "5")
+    private String projectName;
+
+    /**
+     * 发票号(仅中审项目报销)
+     */
+    @Excel(name = "发票号", orderNum = "6")
+    private String invoiceNumber;
+
+    /**
+     * 立项号(仅中审项目报销)
+     */
+    @Excel(name = "立项号", orderNum = "7")
+    private String projectApprovalNumber;
+
+    /**
+     * 非数电发票费用
+     */
+    @Excel(name = "非数电发票(费用)", orderNum = "8", type = 10)
+    private String ordinaryNumber;
+
+    /**
+     * 其他费用
+     */
+    @Excel(name = "其他(费用)", orderNum = "9", type = 10)
+    private String subsidy;
+
+    /**
+     * 汇总
+     */
+    @Excel(name = "汇总", orderNum = "10", type = 10)
+    private String numberCount;
+
+    /**
+     * 非数电发票张数
+     */
+    @Excel(name = "非数电发票(张数)", orderNum = "11", type = 10)
+    private Integer ordinaryReceiptNumber;
+
+    /**
+     * 内容
+     */
+    @Excel(name = "内容", orderNum = "12")
+    private String content;
+
+    /**
+     * 出差天数
+     */
+    @Excel(name = "出差天数", orderNum = "13", type = 10)
+    private Integer days;
+}

+ 45 - 0
jeeplus-modules/jeeplus-centrecareful/src/main/java/com/jeeplus/centrecareful/approvalInfo/service/dto/ZsReimbursementImportResult.java

@@ -0,0 +1,45 @@
+package com.jeeplus.centrecareful.approvalInfo.service.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 中审-报销导入结果
+ * @author: CDB
+ * @create: 2026-10-29
+ **/
+@Data
+public class ZsReimbursementImportResult {
+
+    /**
+     * 成功报销单数
+     */
+    private int successCount;
+
+    /**
+     * 失败报销单数
+     */
+    private int failCount;
+
+    /**
+     * 成功明细条数
+     */
+    private int successDetailCount;
+
+    /**
+     * 失败明细条数
+     */
+    private int failDetailCount;
+
+    /**
+     * 错误Excel临时文件路径(失败时返回)
+     */
+    private String errorFilePath;
+
+    /**
+     * 错误明细列表
+     */
+    private List<Map<String, Object>> errorMessages;
+}

BIN
jeeplus-modules/jeeplus-centrecareful/src/main/resources/dot/中审报销导入模板.xlsx


+ 4 - 2
jeeplus-modules/jeeplus-finance/src/main/java/com/jeeplus/finance/reimbursementApproval/approvalInfo/mapper/xml/CwReimbursementInfoMapper.xml

@@ -1029,6 +1029,7 @@ select
 		SELECT t.id, t.name
 		FROM cw_reimbursement_type_info t
 		WHERE t.del_flag = 0
+		  AND t.sort LIKE '1%'
 		  AND t.name = #{typeName}
 		  AND NOT EXISTS (
 			  SELECT 1 FROM cw_reimbursement_type_info child
@@ -1068,16 +1069,17 @@ select
 		  AND a.name = #{userName}
 	</select>
 
-	<!-- 查询所有末级报销类别(无子节点的记录) -->
+	<!-- 查询会计末级报销类别(与前端save选类别逻辑一致:sort以1开头,按sort排序) -->
 	<select id="findAllLeafCategories" resultType="java.util.Map">
 		SELECT t.id, t.name
 		FROM cw_reimbursement_type_info t
 		WHERE t.del_flag = 0
+		  AND t.sort LIKE '1%'
 		  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
+		ORDER BY t.sort ASC
 	</select>
 
 </mapper>