Ver código fonte

会计报销 添加批量导入功能

徐滕 2 semanas atrás
pai
commit
15cf415c37

+ 87 - 1
jeeplus-modules/jeeplus-finance/src/main/java/com/jeeplus/finance/reimbursementApproval/approvalInfo/controller/CwReimbursementInfoController.java

@@ -25,11 +25,16 @@ import com.jeeplus.sys.feign.IDictApi;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
 import org.apache.commons.lang3.StringUtils;
-import org.springframework.web.bind.annotation.*;
 import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
 
 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.*;
@@ -736,4 +741,85 @@ public class CwReimbursementInfoController {
         return currentUserId;
     }
 
+    // ==================== 导入功能接口 ====================
+
+    /**
+     * 下载导入模板
+     * 读取resources/dot/下的模板文件,动态刷新类别列表后下载
+     */
+    @ApiOperation(value = "下载导入模板")
+    @GetMapping("/importTemplate")
+    public void importTemplate(HttpServletResponse response) throws Exception {
+        // 从classpath读取模板文件
+        java.io.InputStream inputStream = this.getClass().getResourceAsStream("/dot/报销导入模板.xlsx");
+        org.apache.poi.ss.usermodel.Workbook workbook = org.apache.poi.ss.usermodel.WorkbookFactory.create(inputStream);
+
+        // 查询所有末级报销类别
+        List<Map<String, Object>> categories = service.findAllLeafCategories();
+
+        // 刷新「类别列表」Sheet
+        org.apache.poi.ss.usermodel.Sheet categorySheet = workbook.getSheet("类别列表");
+        // 清除旧数据(保留第1行表头)
+        for (int i = categorySheet.getLastRowNum(); i >= 1; i--) {
+            org.apache.poi.ss.usermodel.Row row = categorySheet.getRow(i);
+            if (row != null) categorySheet.removeRow(row);
+        }
+        // 写入最新类别数据(从第2行开始,即index=1)
+        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
+        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文件
+     * @param file Excel文件
+     * @return 导入结果
+     */
+    @ApiOperation(value = "导入报销数据")
+    @ApiLog(value = "报销批量导入", type = LogTypeEnum.IMPORT)
+    @PostMapping("/importFile")
+    public ResponseEntity<ReimbursementImportResult> importFile(@RequestParam("file") 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());
+        ReimbursementImportResult result = service.importReimbursement(file, currentUser);
+        return ResponseEntity.ok(result);
+    }
+
+    /**
+     * 下载导入错误Excel
+     * @param filePath 错误文件临时路径
+     */
+    @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();
+    }
+
 }

+ 36 - 0
jeeplus-modules/jeeplus-finance/src/main/java/com/jeeplus/finance/reimbursementApproval/approvalInfo/mapper/CwReimbursementInfoMapper.java

@@ -103,4 +103,40 @@ public interface CwReimbursementInfoMapper extends BaseMapper<CwReimbursementInf
     //根据流程id获取报销创建人id
     String getReimbursementCreateUserIdByProcessInstanceId(@Param("id") String id);
 
+    // ==================== 导入功能查询方法 ====================
+
+    /**
+     * 根据类别名称查询报销类别(仅末级节点)
+     * @param typeName 类别名称
+     * @return Map(id, name) 或 null
+     */
+    java.util.Map<String, Object> findCategoryByName(@Param("typeName") String typeName);
+
+    /**
+     * 根据项目编号查询项目
+     * @param projectNumber 项目编号
+     * @return Map(id, project_name) 或 null
+     */
+    java.util.Map<String, Object> findProjectByNumber(@Param("projectNumber") String projectNumber);
+
+    /**
+     * 根据项目ID查询关联的报告号列表(含行id,用于reportId)
+     * @param projectId 项目ID
+     * @return 列表 Map(line_id, report_no)
+     */
+    List<java.util.Map<String, Object>> findReportNosByProjectId(@Param("projectId") String projectId);
+
+    /**
+     * 根据姓名查询用户(受租户拦截器控制)
+     * @param userName 用户姓名
+     * @return 用户列表 Map(id, name, office_id, office_name)
+     */
+    List<java.util.Map<String, Object>> findUserByName(@Param("userName") String userName);
+
+    /**
+     * 查询所有末级报销类别(无子节点的记录)
+     * @return 类别列表 Map(id, name)
+     */
+    List<java.util.Map<String, Object>> findAllLeafCategories();
+
 }

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

@@ -1022,4 +1022,62 @@ select
 		select create_by_id from cw_reimbursement_info where del_flag = 0 and proc_ins_id = #{id} limit 1
 	</select>
 
+	<!-- ==================== 导入功能查询SQL ==================== -->
+
+	<!-- 根据类别名称查询报销类别(仅末级节点:无子节点的记录) -->
+	<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 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="findProjectByNumber" resultType="java.util.Map">
+		SELECT id, project_name
+		FROM cw_project_records
+		WHERE del_flag = 0
+		  AND project_number = #{projectNumber}
+		LIMIT 1
+	</select>
+
+	<!-- 根据项目ID查询关联的报告号列表(含行id,用于reportId) -->
+	<select id="findReportNosByProjectId" resultType="java.util.Map">
+		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}
+	</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>
+
+	<!-- 查询所有末级报销类别(无子节点的记录) -->
+	<select id="findAllLeafCategories" resultType="java.util.Map">
+		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
+	</select>
+
 </mapper>

+ 624 - 0
jeeplus-modules/jeeplus-finance/src/main/java/com/jeeplus/finance/reimbursementApproval/approvalInfo/service/CwReimbursementInfoService.java

@@ -1,5 +1,7 @@
 package com.jeeplus.finance.reimbursementApproval.approvalInfo.service;
 
+import cn.afterturn.easypoi.excel.ExcelImportUtil;
+import cn.afterturn.easypoi.excel.entity.ImportParams;
 import cn.hutool.core.collection.CollectionUtil;
 import cn.hutool.core.date.DateUtil;
 import cn.hutool.core.util.ObjectUtil;
@@ -37,11 +39,16 @@ import com.jeeplus.sys.service.dto.UserDTO;
 //import com.jeeplus.sys.utils.UserUtils;
 import org.apache.commons.compress.utils.Lists;
 import org.apache.commons.lang3.StringUtils;
+import org.apache.poi.ss.usermodel.*;
+import org.apache.poi.xssf.usermodel.XSSFWorkbook;
 import org.springframework.beans.BeanUtils;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import java.io.File;
+import java.io.FileOutputStream;
 import java.text.SimpleDateFormat;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
@@ -2078,4 +2085,621 @@ public class CwReimbursementInfoService {
         String createUserId = infoMapper.getReimbursementCreateUserIdByProcessInstanceId(id);
         return createUserId;
     }
+
+    // ==================== 导入功能 ====================
+
+    /**
+     * 查询所有末级报销类别
+     * @return 类别列表
+     */
+    public List<Map<String, Object>> findAllLeafCategories() {
+        return infoMapper.findAllLeafCategories();
+    }
+
+    /**
+     * 报销批量导入
+     * @param file Excel文件
+     * @param currentUser 当前登录用户
+     * @return 导入结果
+     */
+    public ReimbursementImportResult importReimbursement(MultipartFile file, UserDTO currentUser) throws Exception {
+        ReimbursementImportResult result = new ReimbursementImportResult();
+        List<Map<String, Object>> errorMessages = new ArrayList<>();
+        // 原始行索引(从0开始) → 错误信息
+        Map<Integer, String> rowErrorMap = new LinkedHashMap<>();
+
+        // 使用传入的当前用户信息
+        String currentUserId = currentUser.getId();
+        String currentUserName = currentUser.getName();
+        String companyId = currentUser.getCompanyDTO().getId();
+        String currentToken = TokenProvider.getCurrentToken();
+
+        // 业务编码(2026年后用101)
+        Date targetDate = DateUtil.parseDate("2026-01-01");
+        String bizCode = DateUtil.date().after(targetDate) ? "101" : "5";
+
+        // 当前用户部门(经办人部门)
+        String currentUserDeptId = "";
+        String currentUserDeptName = "";
+        if (currentUser.getOfficeDTO() != null && StringUtils.isNotBlank(currentUser.getOfficeDTO().getId())) {
+            currentUserDeptId = currentUser.getOfficeDTO().getId();
+            // 优先从DTO直接取名称
+            currentUserDeptName = currentUser.getOfficeDTO().getName();
+            // 兜底:通过API查询
+            if (StringUtils.isBlank(currentUserDeptName)) {
+                OfficeDTO officeDTO = SpringUtil.getBean(IOfficeApi.class).getOfficeById(currentUserDeptId);
+                if (officeDTO != null) {
+                    currentUserDeptName = officeDTO.getName();
+                }
+            }
+        }
+
+        // 1. 解析Excel(titleRows=0不跳过标题, headRows=1用第1行列头匹配, 从第2行开始读数据)
+        ImportParams params = new ImportParams();
+        params.setTitleRows(0); // 第1行就是列头,不跳过
+        params.setHeadRows(1);  // 第1行作为列头匹配,从第2行开始读取数据
+        List<ReimbursementImportDto> rows = ExcelImportUtil.importExcel(file.getInputStream(), ReimbursementImportDto.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++) {
+            ReimbursementImportDto dto = rows.get(i);
+            String aloneFlag = safeStr(dto.getAloneFlag());
+            String sourceTypeName = safeStr(dto.getSourceTypeName());
+            String userName = safeStr(dto.getUserName());
+            String typeName = safeStr(dto.getTypeName());
+
+            // 跳过模板描述行(A列含"请删除"或"填写提示"等提示文字)
+            if (aloneFlag.contains("请删除") || aloneFlag.contains("填写提示")) {
+                continue;
+            }
+            // 跳过模板示例行(A列含[示例]标记)
+            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); // 实际Excel行号(1-based,跳过列头行,索引0对应第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");
+            String firstProjectNumber = safeStr(((ReimbursementImportDto) firstRow.get("dto")).getProjectNumber());
+
+            for (int r = 1; r < group.size(); r++) {
+                Map<String, Object> row = group.get(r);
+                ReimbursementImportDto dto = (ReimbursementImportDto) row.get("dto");
+                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);
+                }
+                if (("项目报销".equals(firstSourceTypeName) || "报告报销".equals(firstSourceTypeName))
+                        && !firstProjectNumber.equals(safeStr(dto.getProjectNumber()))) {
+                    String err = "第" + rowNum + "行:同组内项目编号与第一行不一致";
+                    groupErrors.add(err);
+                    rowErrorMap.put((int) row.get("originalIndex"), err);
+                }
+            }
+
+            // 3.2 逐行数据匹配校验
+            for (Map<String, Object> row : group) {
+                ReimbursementImportDto dto = (ReimbursementImportDto) 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)) {
+                    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 + "行:报销类别不能为空");
+                }
+
+                // 项目编号匹配(项目报销和报告报销必填)
+                Map<String, Object> project = null;
+                if ("项目报销".equals(stn) || "报告报销".equals(stn)) {
+                    if (StringUtils.isNotBlank(safeStr(dto.getProjectNumber()))) {
+                        project = infoMapper.findProjectByNumber(safeStr(dto.getProjectNumber()));
+                        if (project == null) {
+                            rowErrors.add("第" + rowNum + "行:项目编号「" + dto.getProjectNumber() + "」不存在");
+                        }
+                    } else {
+                        rowErrors.add("第" + rowNum + "行:" + stn + "必须填写项目编号");
+                    }
+                }
+
+                // 报告号匹配(仅报告报销)
+                String reportLineId = null;
+                if ("报告报销".equals(stn) && project != null) {
+                    if (StringUtils.isNotBlank(safeStr(dto.getReportNo()))) {
+                        List<Map<String, Object>> reportList = infoMapper.findReportNosByProjectId(project.get("id").toString());
+                        boolean found = false;
+                        for (Map<String, Object> rpt : reportList) {
+                            if (dto.getReportNo().equals(rpt.get("report_no"))) {
+                                reportLineId = rpt.get("line_id").toString();
+                                found = true;
+                                break;
+                            }
+                        }
+                        if (!found) {
+                            rowErrors.add("第" + rowNum + "行:报告号「" + dto.getReportNo() + "」不属于该项目");
+                        }
+                    } 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 ("其他报销".equals(stn) && 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);
+                }
+                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);
+
+                // 存储匹配结果
+                row.put("category", category);
+                row.put("project", project);
+                row.put("reportLineId", reportLineId);
+
+                if (!rowErrors.isEmpty()) {
+                    for (String err : rowErrors) {
+                        groupErrors.add(err);
+                    }
+                    // 只记录第一条错误到Excel标注
+                    if (!rowErrorMap.containsKey(originalIndex)) {
+                        rowErrorMap.put(originalIndex, rowErrors.get(0));
+                    }
+                }
+            }
+
+            // 4. 根据校验结果写入数据库或标记失败
+            if (groupErrors.isEmpty()) {
+                // 校验通过,写入数据库
+                try {
+                    writeGroupToDb(group, bizCode, companyId, currentToken, currentUserId, currentUserName, currentUserDeptName);
+                    successCount++;
+                    successDetailCount += group.size();
+                } catch (Exception e) {
+                    failCount++;
+                    failDetailCount += group.size();
+                    for (Map<String, Object> row : group) {
+                        int originalIndex = (int) row.get("originalIndex");
+                        if (!rowErrorMap.containsKey(originalIndex)) {
+                            rowErrorMap.put(originalIndex, "第" + row.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 bizCode,
+                                String companyId, String currentToken, String currentUserId,
+                                String currentUserName, String currentDeptName) throws Exception {
+        // 生成主记录ID和编号
+        String infoId = UUID.randomUUID().toString().replace("-", "");
+        String no = SpringUtil.getBean(IWorkAttachmentApi.class)
+                .genSerialNum(companyId, bizCode, currentToken);
+
+        // 取第一行确定sourceType
+        ReimbursementImportDto firstDto = (ReimbursementImportDto) 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;
+            default: sourceType = "4"; break;
+        }
+
+        // 写入主表 cw_reimbursement_info(经办人 = 当前登录人)
+        CwReimbursementInfo info = new CwReimbursementInfo();
+        info.setId(infoId);
+        info.setNo(no);
+        info.setUserId(currentUserId);
+        info.setUserName(currentUserName);
+        info.setDepartment(currentDeptName);
+        info.setReimDate(new Date());
+        info.setSourceType(sourceType);
+        info.setType("1"); // 暂存
+        info.setBusinessFlag("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) {
+            ReimbursementImportDto dto = (ReimbursementImportDto) 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");
+            Map<String, Object> project = (Map<String, Object>) row.get("project");
+
+            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": {
+                    // 项目报销 → cw_reimbursement_detail_info
+                    CwReimbursementDetailInfo detail = new CwReimbursementDetailInfo();
+                    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.setProjectId(project != null ? project.get("id").toString() : "");
+                    detail.setProjectName(project != null ? project.get("project_name").toString() : "");
+                    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": {
+                    // 报告报销 → cw_reimbursement_detail_info_report
+                    CwReimbursementDetailInfoReport detail = new CwReimbursementDetailInfoReport();
+                    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.setProjectId(project != null ? project.get("id").toString() : "");
+                    detail.setReportId(row.get("reportLineId") != null ? row.get("reportLineId").toString() : "");
+                    detail.setReportNumber(safeStr(dto.getReportNo()));
+                    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);
+                    cwReimbursementDetailInfoReportMapper.insert(detail);
+                    break;
+                }
+                default: {
+                    // 其他报销 → cw_reimbursement_detail_info_other
+                    CwReimbursementDetailInfoOther detail = new CwReimbursementDetailInfoOther();
+                    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);
+                    cwReimbursementDetailInfoOtherMapper.insert(detail);
+                    break;
+                }
+            }
+        }
+    }
+
+    /**
+     * 生成错误Excel文件
+     * @param originalFile 原始上传文件
+     * @param rowErrorMap 行索引→错误信息
+     * @return 临时文件路径
+     */
+    private String generateErrorExcel(MultipartFile originalFile, Map<Integer, String> rowErrorMap) throws Exception {
+        Workbook workbook = WorkbookFactory.create(originalFile.getInputStream());
+        Sheet sheet = workbook.getSheetAt(0);
+
+        // 错误原因列放在第N列(0-based,13列数据之后 = 第14列即N列)
+        int errorColIndex = 13;
+
+        // 创建错误样式:绿色底色(与模板示例行一致)+ 红色字体
+        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); // 第1行是列头行(0-based)
+        if (headerRow != null) {
+            Cell errorHeaderCell = headerRow.createCell(errorColIndex);
+            errorHeaderCell.setCellValue("错误原因");
+            errorHeaderCell.setCellStyle(errorStyle);
+        }
+
+        // 逐行标注错误(数据从第2行开始,0-based,跳过第0行列头)
+        for (Map.Entry<Integer, String> entry : rowErrorMap.entrySet()) {
+            int dataRowIndex = entry.getKey(); // 数据列表中的索引(0-based)
+            int excelRowNum = dataRowIndex + 1; // 对应Sheet中的行号(0-based,跳过第0行列头)
+            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
+                + "import_error_" + System.currentTimeMillis() + ".xlsx";
+        try (FileOutputStream fos = new FileOutputStream(tempPath)) {
+            workbook.write(fos);
+        }
+        workbook.close();
+        return tempPath;
+    }
+
+    /**
+     * 构建行数据Map
+     */
+    private Map<String, Object> buildRowMap(int index, ReimbursementImportDto dto) {
+        Map<String, Object> row = new HashMap<>();
+        row.put("originalIndex", index);
+        row.put("excelRowNum", index + 2);
+        row.put("dto", dto);
+        row.put("sourceTypeName", safeStr(dto.getSourceTypeName()));
+        row.put("userName", safeStr(dto.getUserName()));
+        row.put("typeName", safeStr(dto.getTypeName()));
+        return row;
+    }
+
+    /**
+     * 判断行是否全空
+     */
+    private boolean isRowEmpty(ReimbursementImportDto dto) {
+        return StringUtils.isBlank(safeStr(dto.getAloneFlag()))
+                && StringUtils.isBlank(safeStr(dto.getSourceTypeName()))
+                && StringUtils.isBlank(safeStr(dto.getUserName()))
+                && StringUtils.isBlank(safeStr(dto.getTypeName()))
+                && StringUtils.isBlank(safeStr(dto.getProjectNumber()))
+                && StringUtils.isBlank(safeStr(dto.getProjectName()))
+                && StringUtils.isBlank(safeStr(dto.getReportNo()))
+                && StringUtils.isBlank(safeStr(dto.getOrdinaryNumber()))
+                && StringUtils.isBlank(safeStr(dto.getSubsidy()))
+                && StringUtils.isBlank(safeStr(dto.getNumberCount()))
+                && dto.getOrdinaryReceiptNumber() == null
+                && StringUtils.isBlank(safeStr(dto.getContent()))
+                && dto.getDays() == null;
+    }
+
+    /**
+     * 安全转字符串
+     */
+    private String safeStr(Object obj) {
+        if (obj == null) return "";
+        String s = obj.toString().trim();
+        // EasyPOI可能将数字读为Double(如 "123.0"),去掉末尾的.0
+        if (s.endsWith(".0")) {
+            try {
+                Double.parseDouble(s);
+                return s.substring(0, s.length() - 2);
+            } catch (NumberFormatException ignored) {}
+        }
+        return s;
+    }
+
+    /**
+     * 计算汇总金额
+     */
+    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;
+        }
+    }
+
+    /**
+     * 创建错误信息条目
+     */
+    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;
+    }
+
+    /**
+     * 列索引转Excel列字母(0→A, 1→B, ...)
+     */
+    private String getColumnLetter(int colIndex) {
+        StringBuilder sb = new StringBuilder();
+        while (colIndex >= 0) {
+            sb.insert(0, (char) ('A' + colIndex % 26));
+            colIndex = colIndex / 26 - 1;
+        }
+        return sb.toString();
+    }
 }