|
|
@@ -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) {
|