浏览代码

Merge remote-tracking branch 'origin/master'

Alex 2 天之前
父节点
当前提交
3fde0ce3ed

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

@@ -91,20 +91,22 @@ 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,不会中断导入
+- 金额/张数/天数列 **不指定 type=10、按文本接收**:type=10 时 EasyPOI 对文字单元格 catch NumberFormatException 后静默置 null,无法区分"没填"和"填错";文本接收后由 Service 逐列校验并报错
+- **数值单元格的文本形态**(`CellValueService.readNumericCell`):整数值单元格返回 `Integer`(`3` → `"3"`,不会变成 `"3.0"`),非整返回 `Double`(`100.50` → `"100.5"`);超过 int 范围的大数走 `Double` 分支,可能给出科学计数法,由 `normalizeNumber` 展开
+- **自动汇总不能用 double 相加**:`String.valueOf(100.10 + 200.20)` = `"300.29999999999995"`,会原样落库并显示在列表页。`calcSum` 必须用 `BigDecimal.valueOf().add()` 再 `stripTrailingZeros().toPlainString()`
 
-### 4.2 行过滤逻辑(三重过滤)
+### 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;
+// 3. 跳过全空行(isRowEmpty 全列判空)
+if (isRowEmpty(dto)) continue;
+// 4. A列非「是/同上」时:除A列外无业务数据的装饰行(分隔线等)跳过;
+//    带业务数据的行标注错误「「是否单独报销」只能填「是」或「同上」,本行未导入」,防止静默丢行
+if (!"是".equals(aloneFlag) && !"同上".equals(aloneFlag)) { ...标错/跳过... }
 ```
 
 ### 4.3 分组逻辑
@@ -123,8 +125,8 @@ row.put("excelRowNum", i + 2);     // 对应 Excel 实际行号(1-based,跳
 ### 4.5 校验流程(逐组 → 逐行)
 
 **组内一致性校验:**
-- 同组内报销类型必须与第一行一致
-- 同组内项目编号必须与第一行一致(项目报销和报告报销)
+- 同组内报销类型必须与第一行一致(sourceType 为主表单据级字段)
+- 项目编号**不做**组内一致性校验(对齐手工表单:每行明细可独立挂不同项目)
 
 **逐行数据校验:**
 
@@ -133,10 +135,11 @@ row.put("excelRowNum", i + 2);     // 对应 Excel 实际行号(1-based,跳
 | 报销类型 | 必须是"项目报销"/"报告报销"/"其他报销" |
 | 报销类别 | 不能为空;必须在 `cw_reimbursement_type_info` 中存在且为末级节点 |
 | 项目编号 | 项目/报告报销必填;必须在 `cw_project_records` 中存在 |
-| 报告号 | 报告报销必填;必须属于该项目(查 `cw_project_report_new_line`) |
+| 报告号 | 报告报销必填;必须属于**本行**项目(查 `cw_project_report_new_line`,两侧去首尾空格后比对) |
 | 报销人 | 不填默认当前用户;填写则按姓名查 `sys_user`,重名报错 |
 | 报销详情 | 其他报销必填 |
-| 金额 | 汇总为空时自动计算 = 非数电发票 + 其他 |
+| 金额列(H/I/J) | 文本接收;非空必须是数字("必须为数字"报错,不再静默按0);填写值归一化为最短十进制文本("50.0"→"50"、"100.50"→"100.5"、科学计数法转普通十进制);汇总为空时自动计算 = 非数电发票 + 其他(`calcSum` 用 BigDecimal 相加,避免浮点尾巴) |
+| 张数(K)/出差天数(M) | 文本接收;非空必须是非负整数,否则报错 |
 
 ### 4.6 写入数据库
 
@@ -317,7 +320,7 @@ ORDER BY t.name
 
 1. **titleRows/headRows 配置**:必须根据模板实际结构设置。列头在第几行,`titleRows` 就是几(0-based 跳过行数),`headRows` 是列头占几行。
 2. **@Excel 的 name 必须与列头文字完全一致**(包括括号、空格),否则字段为 null。
-3. **Numeric 列遇到文字**:EasyPOI 内部 catch NumberFormatException 并设 null,不会抛异常中断,但会在日志中打印 ERROR。
+3. **Numeric 列遇到文字**:不要给数字列配 `type=10`(EasyPOI 会静默置 null,"没填"和"填错"分不开)。按文本接收,Service 里校验后明确报错。
 4. **orderNum 是 String 类型**,表示列序号(从 "0" 或 "1" 开始均可,EasyPOI 按数值排序)。
 
 ### 8.2 用户/部门相关

+ 9 - 7
jeeplus-modules/jeeplus-assess/src/main/java/com/jeeplus/assess/workClientInfo/service/WorkClientService.java

@@ -36,7 +36,6 @@ import org.springframework.transaction.annotation.Transactional;
 
 import javax.annotation.Resource;
 import java.util.*;
-import java.util.stream.Collectors;
 
 @Slf4j
 @Service
@@ -321,12 +320,13 @@ public class WorkClientService {
         }
         //附件信息处理
         List<WorkAttachmentInfo> workAttachments = workClientInfosDto.getWorkAttachments();
-        List<String> collect = workAttachments.stream().distinct().filter(item -> {
-            if (StringUtils.isNotBlank(item.getId())) {
-                return true;
+        List<String> collect = new ArrayList<>();
+        for (WorkAttachmentInfo item : workAttachments) {
+            String attachmentId = item.getId();
+            if (StringUtils.isNotBlank(attachmentId) && !collect.contains(attachmentId)) {
+                collect.add(attachmentId);
             }
-            return false;
-        }).map(WorkAttachmentInfo::getId).collect(Collectors.toList());
+        }
         if (CollectionUtil.isNotEmpty(workAttachments)) {
             if(CollectionUtil.isNotEmpty(collect)){
                 SpringUtil.getBean ( IWorkAttachmentApi.class ).deleteByAttachmentIdNotInIds(workClientInfo.getId(),collect);
@@ -334,7 +334,9 @@ public class WorkClientService {
 //                        .eq(WorkAttachmentInfo::getAttachmentId,workClientInfo.getId())
 //                        .notIn(WorkAttachmentInfo::getId,collect));
             }
-            workAttachments.stream().forEach(i -> i.setAttachmentId(workClientInfo.getId()));
+            for (WorkAttachmentInfo item : workAttachments) {
+                item.setAttachmentId(workClientInfo.getId());
+            }
             Map<String,String> map = new HashMap<>();
             map.put("workAttachments",JSON.toJSONString(workAttachments));
             map.put("currentToken", TokenProvider.getCurrentToken());

+ 131 - 17
jeeplus-modules/jeeplus-centrecareful/src/main/java/com/jeeplus/centrecareful/approvalInfo/service/ZsReimbursementInfoService.java

@@ -46,6 +46,7 @@ import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
 import java.io.OutputStream;
+import java.math.BigDecimal;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
 import java.util.*;
@@ -1781,7 +1782,8 @@ public class ZsReimbursementInfoService {
             throw new RuntimeException("Excel文件为空或格式不正确");
         }
 
-        // 2. 分组
+        // 2. 分组:遍历所有行,按「是否单独报销」分组,跳过示例行和无效行
+        int successCount = 0, failCount = 0, successDetailCount = 0, failDetailCount = 0;
         List<List<Map<String, Object>>> groups = new ArrayList<>();
         for (int i = 0; i < rows.size(); i++) {
             ZsReimbursementImportDto dto = rows.get(i);
@@ -1790,21 +1792,40 @@ public class ZsReimbursementInfoService {
             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)) {
+            if (isRowEmpty(dto)) {
                 continue;
             }
-            // 只处理合法数据行
+            // 只处理「是否单独报销」为"是"或"同上"的合法数据行
             if (!"是".equals(aloneFlag) && !"同上".equals(aloneFlag)) {
+                // 仅A列有内容的装饰行(分隔线等)跳过;带业务数据但A列无效的行标注错误,防止静默丢行
+                boolean hasBusinessData = StringUtils.isNotBlank(sourceTypeName) || StringUtils.isNotBlank(userName)
+                        || StringUtils.isNotBlank(typeName)
+                        || StringUtils.isNotBlank(safeStr(dto.getProjectName()))
+                        || StringUtils.isNotBlank(safeStr(dto.getInvoiceNumber()))
+                        || StringUtils.isNotBlank(safeStr(dto.getProjectApprovalNumber()))
+                        || StringUtils.isNotBlank(safeStr(dto.getOrdinaryNumber()))
+                        || StringUtils.isNotBlank(safeStr(dto.getSubsidy()))
+                        || StringUtils.isNotBlank(safeStr(dto.getNumberCount()))
+                        || StringUtils.isNotBlank(safeStr(dto.getOrdinaryReceiptNumber()))
+                        || StringUtils.isNotBlank(safeStr(dto.getContent()))
+                        || StringUtils.isNotBlank(safeStr(dto.getDays()));
+                if (!hasBusinessData) {
+                    continue;
+                }
+                String err = "第" + (i + 2) + "行:「是否单独报销」只能填「是」或「同上」,本行未导入";
+                rowErrorMap.put(i, err);
+                errorMessages.add(createErrorEntry(i + 2, err));
+                failCount++;
+                failDetailCount++;
                 continue;
             }
 
@@ -1826,11 +1847,20 @@ public class ZsReimbursementInfoService {
         }
 
         if (groups.isEmpty()) {
+            // 所有数据行都在分组阶段被标错(如A列全填了无效值):仍生成标注文件按"全部失败"返回,避免异常吞掉错误明细
+            if (!rowErrorMap.isEmpty()) {
+                result.setErrorFilePath(generateErrorExcel(file, rowErrorMap));
+                result.setSuccessCount(0);
+                result.setFailCount(failCount);
+                result.setSuccessDetailCount(0);
+                result.setFailDetailCount(failDetailCount);
+                result.setErrorMessages(errorMessages);
+                return result;
+            }
             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<>();
@@ -1905,9 +1935,23 @@ public class ZsReimbursementInfoService {
                 }
 
                 // 费用处理:汇总 = 非数电发票 + 其他,可手动填写也可自动计算
+                // 数字列填了文字明确报错,不再静默按0处理
                 String ordinaryNumber = safeStr(dto.getOrdinaryNumber());
                 String subsidy = safeStr(dto.getSubsidy());
                 String numberCount = safeStr(dto.getNumberCount());
+                if (StringUtils.isNotBlank(ordinaryNumber) && tryParseDouble(ordinaryNumber) == null) {
+                    rowErrors.add("第" + rowNum + "行:非数电发票(费用)必须为数字");
+                }
+                if (StringUtils.isNotBlank(subsidy) && tryParseDouble(subsidy) == null) {
+                    rowErrors.add("第" + rowNum + "行:其他(费用)必须为数字");
+                }
+                if (StringUtils.isNotBlank(numberCount) && tryParseDouble(numberCount) == null) {
+                    rowErrors.add("第" + rowNum + "行:汇总必须为数字");
+                }
+                // 数字单元格按文本读取可能出现 "50.0" / 科学计数法,统一归一化为普通十进制字符串再落库
+                ordinaryNumber = normalizeNumber(ordinaryNumber);
+                subsidy = normalizeNumber(subsidy);
+                numberCount = normalizeNumber(numberCount);
                 if (StringUtils.isBlank(numberCount)) {
                     numberCount = calcSum(ordinaryNumber, subsidy);
                 }
@@ -1920,10 +1964,30 @@ public class ZsReimbursementInfoService {
                 row.put("numberCount", numberCount);
 
                 // 张数处理
-                int ordinaryReceiptNumber = dto.getOrdinaryReceiptNumber() != null ? dto.getOrdinaryReceiptNumber() : 0;
+                int ordinaryReceiptNumber = 0;
+                String receiptNumberStr = safeStr(dto.getOrdinaryReceiptNumber());
+                if (StringUtils.isNotBlank(receiptNumberStr)) {
+                    Integer parsedCount = tryParseNonNegativeInt(receiptNumberStr);
+                    if (parsedCount == null) {
+                        rowErrors.add("第" + rowNum + "行:非数电发票(张数)必须为非负整数");
+                    } else {
+                        ordinaryReceiptNumber = parsedCount;
+                    }
+                }
                 row.put("ordinaryReceiptNumber", ordinaryReceiptNumber);
                 row.put("receiptNumberCount", ordinaryReceiptNumber);
 
+                // 出差天数处理
+                Integer daysVal = null;
+                String daysStr = safeStr(dto.getDays());
+                if (StringUtils.isNotBlank(daysStr)) {
+                    daysVal = tryParseNonNegativeInt(daysStr);
+                    if (daysVal == null) {
+                        rowErrors.add("第" + rowNum + "行:出差天数必须为非负整数");
+                    }
+                }
+                row.put("days", daysVal);
+
                 // type=8 特殊校验:发票号和立项号至少填写一项
                 if ("中审项目报销".equals(stn)) {
                     String invoiceNumber = safeStr(dto.getInvoiceNumber());
@@ -2071,7 +2135,7 @@ public class ZsReimbursementInfoService {
                     detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
                     detail.setReceiptNumberCount(receiptNumberCount);
                     detail.setContent(safeStr(dto.getContent()));
-                    detail.setDays(dto.getDays());
+                    detail.setDays((Integer) row.get("days"));
                     detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
                     detail.setReimbursementType("0");
                     detail.setCreateById(currentUserId);
@@ -2102,7 +2166,7 @@ public class ZsReimbursementInfoService {
                     detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
                     detail.setReceiptNumberCount(receiptNumberCount);
                     detail.setContent(safeStr(dto.getContent()));
-                    detail.setDays(dto.getDays());
+                    detail.setDays((Integer) row.get("days"));
                     detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
                     detail.setReimbursementType("0");
                     detail.setCreateById(currentUserId);
@@ -2135,7 +2199,7 @@ public class ZsReimbursementInfoService {
                     detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
                     detail.setReceiptNumberCount(receiptNumberCount);
                     detail.setContent(safeStr(dto.getContent()));
-                    detail.setDays(dto.getDays());
+                    detail.setDays((Integer) row.get("days"));
                     detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
                     detail.setReimbursementType("0");
                     detail.setCreateById(currentUserId);
@@ -2166,7 +2230,7 @@ public class ZsReimbursementInfoService {
                     detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
                     detail.setReceiptNumberCount(receiptNumberCount);
                     detail.setContent(safeStr(dto.getContent()));
-                    detail.setDays(dto.getDays());
+                    detail.setDays((Integer) row.get("days"));
                     detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
                     detail.setReimbursementType("0");
                     detail.setCreateById(currentUserId);
@@ -2263,11 +2327,11 @@ public class ZsReimbursementInfoService {
      * 计算汇总 = 非数电发票(费用) + 其他(费用)
      */
     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);
+        // 用 BigDecimal 相加,避免 double 求和出现 "300.29999999999995" 这类浮点尾巴
+        BigDecimal sum = BigDecimal.valueOf(parseDouble(ordinaryNumber))
+                .add(BigDecimal.valueOf(parseDouble(subsidy)));
+        if (sum.signum() == 0) return "0";
+        return sum.stripTrailingZeros().toPlainString();
     }
 
     /**
@@ -2281,4 +2345,54 @@ public class ZsReimbursementInfoService {
             return 0;
         }
     }
+
+    /**
+     * 判断整行是否为空行(所有列都没有内容)
+     */
+    private boolean isRowEmpty(ZsReimbursementImportDto 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.getProjectName()))
+                && StringUtils.isBlank(safeStr(dto.getInvoiceNumber()))
+                && StringUtils.isBlank(safeStr(dto.getProjectApprovalNumber()))
+                && StringUtils.isBlank(safeStr(dto.getOrdinaryNumber()))
+                && StringUtils.isBlank(safeStr(dto.getSubsidy()))
+                && StringUtils.isBlank(safeStr(dto.getNumberCount()))
+                && StringUtils.isBlank(safeStr(dto.getOrdinaryReceiptNumber()))
+                && StringUtils.isBlank(safeStr(dto.getContent()))
+                && StringUtils.isBlank(safeStr(dto.getDays()));
+    }
+
+    /**
+     * 尝试解析double,非法返回null(用于区分"没填"和"填了非数字")
+     */
+    private Double tryParseDouble(String str) {
+        if (StringUtils.isBlank(str)) return null;
+        try {
+            return Double.parseDouble(str);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    /**
+     * 尝试解析非负整数,非法返回null(限制位数防溢出,用于张数/天数校验)
+     */
+    private Integer tryParseNonNegativeInt(String str) {
+        if (StringUtils.isBlank(str) || !str.matches("\\d{1,9}")) return null;
+        return Integer.parseInt(str);
+    }
+
+    /**
+     * 金额字符串归一化:"50.0"、"1.0E11" 等统一转为普通十进制文本;非法或空值原样返回
+     */
+    private String normalizeNumber(String str) {
+        Double d = tryParseDouble(str);
+        if (d == null) {
+            return str;
+        }
+        return BigDecimal.valueOf(d).stripTrailingZeros().toPlainString();
+    }
 }

+ 12 - 11
jeeplus-modules/jeeplus-centrecareful/src/main/java/com/jeeplus/centrecareful/approvalInfo/service/dto/ZsReimbursementImportDto.java

@@ -55,27 +55,28 @@ public class ZsReimbursementImportDto {
 
     /**
      * 非数电发票费用
+     * 按文本接收(不加 type=10),由 Service 校验并归一化,避免填文字时被 EasyPoi 静默转成 null
      */
-    @Excel(name = "非数电发票(费用)", orderNum = "8", type = 10)
+    @Excel(name = "非数电发票(费用)", orderNum = "8")
     private String ordinaryNumber;
 
     /**
-     * 其他费用
+     * 其他费用(按文本接收,同上)
      */
-    @Excel(name = "其他(费用)", orderNum = "9", type = 10)
+    @Excel(name = "其他(费用)", orderNum = "9")
     private String subsidy;
 
     /**
-     * 汇总
+     * 汇总(按文本接收,同上)
      */
-    @Excel(name = "汇总", orderNum = "10", type = 10)
+    @Excel(name = "汇总", orderNum = "10")
     private String numberCount;
 
     /**
-     * 非数电发票张数
+     * 非数电发票张数(按文本接收,同上)
      */
-    @Excel(name = "非数电发票(张数)", orderNum = "11", type = 10)
-    private Integer ordinaryReceiptNumber;
+    @Excel(name = "非数电发票(张数)", orderNum = "11")
+    private String ordinaryReceiptNumber;
 
     /**
      * 内容
@@ -84,8 +85,8 @@ public class ZsReimbursementImportDto {
     private String content;
 
     /**
-     * 出差天数
+     * 出差天数(按文本接收,同上)
      */
-    @Excel(name = "出差天数", orderNum = "13", type = 10)
-    private Integer days;
+    @Excel(name = "出差天数", orderNum = "13")
+    private String days;
 }

+ 8 - 3
jeeplus-modules/jeeplus-finance/src/main/java/com/jeeplus/finance/reimbursementApproval/approvalInfo/REQUIREMENTS.md

@@ -100,6 +100,7 @@
 - 每组生成**一张**报销单(`cw_reimbursement_info`)
 - 组内每行生成**一条**明细记录
 - 全空行(所有列均为空)自动跳过,不参与分组
+- 除 A 列外其余列全空(如分隔线)自动跳过;A 列填了「是/同上」以外的值且本行有业务数据 → 标注错误「「是否单独报销」只能填「是」或「同上」,本行未导入」,不再静默丢行
 
 ### 4.2 同组一致性校验
 
@@ -108,7 +109,8 @@
 | 校验项 | 说明 |
 |---|---|
 | 报销类型 | 组内所有行的报销类型必须相同 |
-| 项目编号 | 项目报销和报告报销的组内,项目编号必须相同 |
+
+> 项目编号**不做**组内一致性校验(对齐手工表单:每行明细可独立挂不同项目;报告报销按行校验"报告号属于本行项目编号")。
 
 ### 4.3 数据匹配逻辑
 
@@ -257,7 +259,10 @@ WHERE a.del_flag = 0 AND b.project_id = #{projectId}
 | 报告号不匹配 | 「第X行:报告号「XXX」不属于项目「YYY」」 |
 | 报销人不存在的 | 「第X行:报销人「XXX」不存在」 |
 | 报销人重名 | 「第X行:报销人「XXX」存在重名,请手动录入后调整」 |
-| 同组不一致 | 「第X行:同组内报销类型/项目编号与组内第一行不一致」 |
+| 同组类型不一致 | 「第X行:同组内报销类型与第一行不一致」 |
+| A列填写无效 | 「第X行:「是否单独报销」只能填「是」或「同上」,本行未导入」 |
+| 金额列非数字 | 「第X行:非数电发票(费用)/其他(费用)/汇总必须为数字」 |
+| 张数/天数非整数 | 「第X行:非数电发票(张数)/出差天数必须为非负整数」 |
 | 报告报销缺项目编号 | 「第X行:报告报销必须填写项目编号」 |
 | 项目报销缺项目编号 | 「第X行:项目报销必须填写项目编号」 |
 | 其他报销缺报销详情 | 「第X行:其他报销必须填写报销详情」 |
@@ -465,4 +470,4 @@ downloadImportError(filePath) {
 7. 空行 → 自动跳过,不报错
 8. 导入成功后 → 列表页刷新,显示新导入的暂存单据
 9. 暂存单据 → 用户打开后可正常编辑、上传发票、提交审批
-10. 同组一致性 → 组内报销类型、项目编号不一致时正确报错
+10. 同组一致性 → 组内报销类型不一致时正确报错(项目编号允许同组不同项目)

+ 117 - 29
jeeplus-modules/jeeplus-finance/src/main/java/com/jeeplus/finance/reimbursementApproval/approvalInfo/service/CwReimbursementInfoService.java

@@ -49,6 +49,7 @@ import org.springframework.web.multipart.MultipartFile;
 import javax.annotation.Resource;
 import java.io.File;
 import java.io.FileOutputStream;
+import java.math.BigDecimal;
 import java.text.SimpleDateFormat;
 import java.time.LocalDate;
 import java.time.format.DateTimeFormatter;
@@ -2145,6 +2146,7 @@ public class CwReimbursementInfoService {
         }
 
         // 2. 分组:遍历所有行,按「是否单独报销」分组,跳过示例行和无效行
+        int successCount = 0, failCount = 0, successDetailCount = 0, failDetailCount = 0;
         List<List<Map<String, Object>>> groups = new ArrayList<>();
         for (int i = 0; i < rows.size(); i++) {
             ReimbursementImportDto dto = rows.get(i);
@@ -2161,13 +2163,32 @@ public class CwReimbursementInfoService {
             if (aloneFlag.contains("[示例]")) {
                 continue;
             }
-            // 跳过全空行(包括分隔线、提示行等)
-            if (StringUtils.isBlank(aloneFlag) && StringUtils.isBlank(sourceTypeName)
-                    && StringUtils.isBlank(userName) && StringUtils.isBlank(typeName)) {
+            // 跳过全空行
+            if (isRowEmpty(dto)) {
                 continue;
             }
             // 只处理「是否单独报销」为"是"或"同上"的合法数据行
             if (!"是".equals(aloneFlag) && !"同上".equals(aloneFlag)) {
+                // 仅A列有内容的装饰行(分隔线等)跳过;带业务数据但A列无效的行标注错误,防止静默丢行
+                boolean hasBusinessData = StringUtils.isNotBlank(sourceTypeName) || StringUtils.isNotBlank(userName)
+                        || StringUtils.isNotBlank(typeName)
+                        || StringUtils.isNotBlank(safeStr(dto.getProjectNumber()))
+                        || StringUtils.isNotBlank(safeStr(dto.getReportNo()))
+                        || StringUtils.isNotBlank(safeStr(dto.getProjectName()))
+                        || StringUtils.isNotBlank(safeStr(dto.getOrdinaryNumber()))
+                        || StringUtils.isNotBlank(safeStr(dto.getSubsidy()))
+                        || StringUtils.isNotBlank(safeStr(dto.getNumberCount()))
+                        || StringUtils.isNotBlank(safeStr(dto.getOrdinaryReceiptNumber()))
+                        || StringUtils.isNotBlank(safeStr(dto.getContent()))
+                        || StringUtils.isNotBlank(safeStr(dto.getDays()));
+                if (!hasBusinessData) {
+                    continue;
+                }
+                String err = "第" + (i + 2) + "行:「是否单独报销」只能填「是」或「同上」,本行未导入";
+                rowErrorMap.put(i, err);
+                errorMessages.add(createErrorEntry(i + 2, err));
+                failCount++;
+                failDetailCount++;
                 continue;
             }
 
@@ -2192,23 +2213,31 @@ public class CwReimbursementInfoService {
         }
 
         if (groups.isEmpty()) {
+            // 所有数据行都在分组阶段被标错(如A列全填了无效值):仍生成标注文件按"全部失败"返回,避免异常吞掉错误明细
+            if (!rowErrorMap.isEmpty()) {
+                result.setErrorFilePath(generateErrorExcel(file, rowErrorMap));
+                result.setSuccessCount(0);
+                result.setFailCount(failCount);
+                result.setSuccessDetailCount(0);
+                result.setFailDetailCount(failDetailCount);
+                result.setErrorMessages(errorMessages);
+                return result;
+            }
             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 组内一致性校验(与第一行比较)
+            // 3.1 组内一致性校验:报销类型必须与第一行一致(主表 sourceType 为单据级字段);
+            // 项目编号不再要求同组一致(对齐手工表单:每行明细可挂不同项目,报告报销逐行校验报告号属于本行项目)
             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"))) {
@@ -2216,12 +2245,6 @@ public class CwReimbursementInfoService {
                     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 逐行数据匹配校验
@@ -2261,21 +2284,22 @@ public class CwReimbursementInfoService {
                     }
                 }
 
-                // 报告号匹配(仅报告报销)
+                // 报告号匹配(仅报告报销;两侧去首尾空格后比对,避免单元格尾随空格误报"不属于该项目")
                 String reportLineId = null;
                 if ("报告报销".equals(stn) && project != null) {
-                    if (StringUtils.isNotBlank(safeStr(dto.getReportNo()))) {
+                    String reportNo = safeStr(dto.getReportNo());
+                    if (StringUtils.isNotBlank(reportNo)) {
                         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"))) {
+                            if (reportNo.equals(safeStr(rpt.get("report_no")))) {
                                 reportLineId = rpt.get("line_id").toString();
                                 found = true;
                                 break;
                             }
                         }
                         if (!found) {
-                            rowErrors.add("第" + rowNum + "行:报告号「" + dto.getReportNo() + "」不属于该项目");
+                            rowErrors.add("第" + rowNum + "行:报告号「" + reportNo + "」不属于该项目");
                         }
                     } else {
                         rowErrors.add("第" + rowNum + "行:报告报销必须填写报告号");
@@ -2311,10 +2335,23 @@ public class CwReimbursementInfoService {
                     rowErrors.add("第" + rowNum + "行:其他报销必须填写报销详情");
                 }
 
-                // 金额处理
+                // 金额处理(数字列填了文字明确报错,不再静默按0处理)
                 String ordinaryNumber = safeStr(dto.getOrdinaryNumber());
                 String subsidy = safeStr(dto.getSubsidy());
                 String numberCount = safeStr(dto.getNumberCount());
+                if (StringUtils.isNotBlank(ordinaryNumber) && tryParseDouble(ordinaryNumber) == null) {
+                    rowErrors.add("第" + rowNum + "行:非数电发票(费用)必须为数字");
+                }
+                if (StringUtils.isNotBlank(subsidy) && tryParseDouble(subsidy) == null) {
+                    rowErrors.add("第" + rowNum + "行:其他(费用)必须为数字");
+                }
+                if (StringUtils.isNotBlank(numberCount) && tryParseDouble(numberCount) == null) {
+                    rowErrors.add("第" + rowNum + "行:汇总必须为数字");
+                }
+                // 数字单元格按文本读取可能出现 "50.0" / 科学计数法,统一归一化为普通十进制字符串再落库
+                ordinaryNumber = normalizeNumber(ordinaryNumber);
+                subsidy = normalizeNumber(subsidy);
+                numberCount = normalizeNumber(numberCount);
                 if (StringUtils.isBlank(numberCount)) {
                     numberCount = calcSum(ordinaryNumber, subsidy);
                 }
@@ -2323,10 +2360,30 @@ public class CwReimbursementInfoService {
                 row.put("numberCount", numberCount);
 
                 // 张数处理
-                int ordinaryReceiptNumber = dto.getOrdinaryReceiptNumber() != null ? dto.getOrdinaryReceiptNumber() : 0;
+                int ordinaryReceiptNumber = 0;
+                String receiptNumberStr = safeStr(dto.getOrdinaryReceiptNumber());
+                if (StringUtils.isNotBlank(receiptNumberStr)) {
+                    Integer parsedCount = tryParseNonNegativeInt(receiptNumberStr);
+                    if (parsedCount == null) {
+                        rowErrors.add("第" + rowNum + "行:非数电发票(张数)必须为非负整数");
+                    } else {
+                        ordinaryReceiptNumber = parsedCount;
+                    }
+                }
                 row.put("ordinaryReceiptNumber", ordinaryReceiptNumber);
                 row.put("receiptNumberCount", ordinaryReceiptNumber);
 
+                // 出差天数处理
+                Integer daysVal = null;
+                String daysStr = safeStr(dto.getDays());
+                if (StringUtils.isNotBlank(daysStr)) {
+                    daysVal = tryParseNonNegativeInt(daysStr);
+                    if (daysVal == null) {
+                        rowErrors.add("第" + rowNum + "行:出差天数必须为非负整数");
+                    }
+                }
+                row.put("days", daysVal);
+
                 // 存储匹配结果
                 row.put("category", category);
                 row.put("project", project);
@@ -2467,7 +2524,7 @@ public class CwReimbursementInfoService {
                     detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
                     detail.setReceiptNumberCount(receiptNumberCount);
                     detail.setContent(safeStr(dto.getContent()));
-                    detail.setDays(dto.getDays());
+                    detail.setDays((Integer) row.get("days"));
                     detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
                     detail.setReimbursementType("0");
                     detail.setCreateById(currentUserId);
@@ -2500,7 +2557,7 @@ public class CwReimbursementInfoService {
                     detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
                     detail.setReceiptNumberCount(receiptNumberCount);
                     detail.setContent(safeStr(dto.getContent()));
-                    detail.setDays(dto.getDays());
+                    detail.setDays((Integer) row.get("days"));
                     detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
                     detail.setReimbursementType("0");
                     detail.setCreateById(currentUserId);
@@ -2531,7 +2588,7 @@ public class CwReimbursementInfoService {
                     detail.setOrdinaryReceiptNumber(ordinaryReceiptNumber);
                     detail.setReceiptNumberCount(receiptNumberCount);
                     detail.setContent(safeStr(dto.getContent()));
-                    detail.setDays(dto.getDays());
+                    detail.setDays((Integer) row.get("days"));
                     detail.setUniqueId(UUID.randomUUID().toString().replace("-", ""));
                     detail.setReimbursementType("0");
                     detail.setCreateById(currentUserId);
@@ -2637,9 +2694,9 @@ public class CwReimbursementInfoService {
                 && StringUtils.isBlank(safeStr(dto.getOrdinaryNumber()))
                 && StringUtils.isBlank(safeStr(dto.getSubsidy()))
                 && StringUtils.isBlank(safeStr(dto.getNumberCount()))
-                && dto.getOrdinaryReceiptNumber() == null
+                && StringUtils.isBlank(safeStr(dto.getOrdinaryReceiptNumber()))
                 && StringUtils.isBlank(safeStr(dto.getContent()))
-                && dto.getDays() == null;
+                && StringUtils.isBlank(safeStr(dto.getDays()));
     }
 
     /**
@@ -2662,11 +2719,11 @@ public class CwReimbursementInfoService {
      * 计算汇总金额
      */
     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);
+        // 用 BigDecimal 相加,避免 double 求和出现 "300.29999999999995" 这类浮点尾巴
+        BigDecimal sum = BigDecimal.valueOf(parseDouble(ordinaryNumber))
+                .add(BigDecimal.valueOf(parseDouble(subsidy)));
+        if (sum.signum() == 0) return "0";
+        return sum.stripTrailingZeros().toPlainString();
     }
 
     /**
@@ -2682,6 +2739,37 @@ public class CwReimbursementInfoService {
     }
 
     /**
+     * 尝试解析金额,非法返回null(用于导入数字列校验)
+     */
+    private Double tryParseDouble(String str) {
+        if (StringUtils.isBlank(str)) return null;
+        try {
+            return Double.parseDouble(str);
+        } catch (NumberFormatException e) {
+            return null;
+        }
+    }
+
+    /**
+     * 尝试解析非负整数,非法返回null(限制位数防溢出,用于张数/天数校验)
+     */
+    private Integer tryParseNonNegativeInt(String str) {
+        if (StringUtils.isBlank(str) || !str.matches("\\d{1,9}")) return null;
+        return Integer.parseInt(str);
+    }
+
+    /**
+     * 金额字符串归一化:"50.0"、"1.0E11" 等统一转为普通十进制文本;非法或空值原样返回
+     */
+    private String normalizeNumber(String str) {
+        Double d = tryParseDouble(str);
+        if (d == null) {
+            return str;
+        }
+        return BigDecimal.valueOf(d).stripTrailingZeros().toPlainString();
+    }
+
+    /**
      * 创建错误信息条目
      */
     private Map<String, Object> createErrorEntry(int row, String message) {

+ 13 - 12
jeeplus-modules/jeeplus-finance/src/main/java/com/jeeplus/finance/reimbursementApproval/approvalInfo/service/dto/ReimbursementImportDto.java

@@ -54,28 +54,29 @@ public class ReimbursementImportDto {
     private String projectName;
 
     /**
-     * 非数电发票费用
+     * 非数电发票费用(数字,@Excel 不指定 type=10,按文本接收后由 Service 校验为数字,
+     * 避免 EasyPOI 对文字单元格静默置 null 导致无法区分"没填"和"填错")
      */
-    @Excel(name = "非数电发票(费用)", orderNum = "8", type = 10)
+    @Excel(name = "非数电发票(费用)", orderNum = "8")
     private String ordinaryNumber;
 
     /**
-     * 其他费用
+     * 其他费用(同上,文本接收后校验)
      */
-    @Excel(name = "其他(费用)", orderNum = "9", type = 10)
+    @Excel(name = "其他(费用)", orderNum = "9")
     private String subsidy;
 
     /**
-     * 汇总
+     * 汇总(同上,文本接收后校验)
      */
-    @Excel(name = "汇总", orderNum = "10", type = 10)
+    @Excel(name = "汇总", orderNum = "10")
     private String numberCount;
 
     /**
-     * 非数电发票张数
+     * 非数电发票张数(文本接收后校验为整数)
      */
-    @Excel(name = "非数电发票(张数)", orderNum = "11", type = 10)
-    private Integer ordinaryReceiptNumber;
+    @Excel(name = "非数电发票(张数)", orderNum = "11")
+    private String ordinaryReceiptNumber;
 
     /**
      * 内容
@@ -84,8 +85,8 @@ public class ReimbursementImportDto {
     private String content;
 
     /**
-     * 出差天数
+     * 出差天数(文本接收后校验为整数)
      */
-    @Excel(name = "出差天数", orderNum = "13", type = 10)
-    private Integer days;
+    @Excel(name = "出差天数", orderNum = "13")
+    private String days;
 }

二进制
jeeplus-modules/jeeplus-finance/src/main/resources/dot/报销导入模板.xlsx


+ 1 - 0
jeeplus-modules/jeeplus-public-modules/src/main/java/com/jeeplus/pubmodules/oss/service/OssService.java

@@ -106,6 +106,7 @@ public class OssService extends ServiceImpl<OssServiceMapper, WorkAttachment> {
                 lambdaQueryWrapper.eq(WorkAttachment::getAttachmentId, workAttachment.getAttachmentId());*/
                 WorkAttachmentInfo workAttachmentInfo = new WorkAttachmentInfo();
                 workAttachmentInfo.setUrl(workAttachment.getUrl());
+                workAttachmentInfo.setAttachmentName(workAttachment.getAttachmentName());
                 workAttachmentInfo.setAttachmentId(workAttachment.getAttachmentId());
                 List<WorkAttachmentInfo> list = ossServiceMapper.getByAttachmentIdAndUrlAndAttachmentFlag(workAttachmentInfo);
                 if (CollectionUtil.isNotEmpty(list)) {