> rows;
+
+ @Schema(description = "分批追加")
+ private Boolean append;
+
+ @Schema(description = "列表筛选:项目名称")
+ private String projectNameLike;
+
+ @Schema(description = "列表筛选:创建人")
+ private Long createUser;
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementImportVoidRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementImportVoidRequest.java
new file mode 100644
index 00000000..bb330c14
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/SettlementImportVoidRequest.java
@@ -0,0 +1,51 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+
+/**
+ * 导入结算单作废请求
+ *
+ * @author Chill
+ */
+@Data
+@Schema(description = "导入结算单作废请求")
+public class SettlementImportVoidRequest implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "批次主键")
+ private Long id;
+
+ @Schema(description = "作废原因")
+ private String reason;
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java
new file mode 100644
index 00000000..88a3bcd4
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/SettlementImportBatch.java
@@ -0,0 +1,115 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.entity;
+
+import com.baomidou.mybatisplus.annotation.TableName;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springblade.core.tenant.mp.TenantEntity;
+
+import java.io.Serial;
+
+/**
+ * 导入结算单批次
+ *
+ * @author Chill
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@TableName("blade_settlement_import_batch")
+@Schema(description = "导入结算单批次")
+public class SettlementImportBatch extends TenantEntity {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "批次号")
+ private String batchNo;
+
+ @Schema(description = "项目ID")
+ private Long projectId;
+
+ @Schema(description = "项目名称")
+ private String projectName;
+
+ @Schema(description = "结算类型 receivable/payable")
+ private String settlementType;
+
+ @Schema(description = "结算单类型 formal/pre")
+ private String settlementBillType;
+
+ @Schema(description = "客户合同ID")
+ private Long customerContractId;
+
+ @Schema(description = "客户合同名称")
+ private String customerContractName;
+
+ @Schema(description = "客户名称")
+ private String customerName;
+
+ @Schema(description = "承运商合同ID")
+ private Long carrierContractId;
+
+ @Schema(description = "承运商合同名称")
+ private String carrierContractName;
+
+ @Schema(description = "承运商名称")
+ private String carrierName;
+
+ @Schema(description = "运单模式 new")
+ private String waybillMode;
+
+ @Schema(description = "导入状态 draft/completed/voided")
+ private String importStatus;
+
+ @Schema(description = "运单数量")
+ private Integer waybillCount;
+
+ @Schema(description = "明细数量")
+ private Integer detailCount;
+
+ @Schema(description = "正式结算单ID")
+ private Long formalSettlementId;
+
+ @Schema(description = "正式结算单号")
+ private String formalSettlementNo;
+
+ @Schema(description = "预结算单ID")
+ private Long preSettlementId;
+
+ @Schema(description = "预结算单号")
+ private String preSettlementNo;
+
+ @Schema(description = "动态费用列名JSON")
+ private String feeItemNamesJson;
+
+ @Schema(description = "备注")
+ private String remark;
+
+ @Schema(description = "关联运单导入批次ID")
+ private Long waybillImportBatchId;
+}
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java
index d79f1502..a3157095 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java
@@ -249,6 +249,9 @@ public class Waybill extends TenantEntity {
@Schema(description = "导入批次ID")
private Long importBatchId;
+ @Schema(description = "导入结算单批次ID")
+ private Long settlementImportBatchId;
+
@Schema(description = "关联单号")
private String relationNo;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java
new file mode 100644
index 00000000..3f856a62
--- /dev/null
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/SettlementImportBatchVO.java
@@ -0,0 +1,69 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.pojo.vo;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.springblade.transport.pojo.entity.SettlementImportBatch;
+
+import java.io.Serial;
+import java.util.List;
+
+/**
+ * 导入结算单批次视图
+ *
+ * @author Chill
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "导入结算单批次视图")
+public class SettlementImportBatchVO extends SettlementImportBatch {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @Schema(description = "创建人姓名")
+ private String createUserName;
+
+ @Schema(description = "更新人姓名")
+ private String updateUserName;
+
+ @Schema(description = "导入状态显示名")
+ private String importStatusName;
+
+ @Schema(description = "结算类型显示名")
+ private String settlementTypeName;
+
+ @Schema(description = "结算单类型显示名")
+ private String settlementBillTypeName;
+
+ @Schema(description = "运单模式显示名")
+ private String waybillModeName;
+
+ @Schema(description = "动态费用列名")
+ private List feeItemNames;
+}
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.java b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.java
index e4b98971..940bb5bb 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.java
@@ -34,6 +34,26 @@ import java.util.List;
*/
public interface MeasurementUnitMapper extends BaseMapper {
+ /**
+ * 按编码查询计量单位(包含逻辑删除记录,用于新增恢复)。
+ */
+ MeasurementUnit selectByUnitCodeIncludingDeleted(@Param("unitCode") String unitCode);
+
+ /**
+ * 按名称查询计量单位(包含逻辑删除记录,用于新增恢复)。
+ */
+ MeasurementUnit selectByUnitNameIncludingDeleted(@Param("unitName") String unitName);
+
+ /**
+ * 恢复逻辑删除计量单位。
+ */
+ int restoreById(@Param("id") Long id);
+
+ /**
+ * 释放已软删除记录占用的单位名称,避免唯一索引冲突。
+ */
+ int releaseUnitName(@Param("id") Long id, @Param("unitName") String unitName);
+
/**
* 自定义分页
*
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.xml b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.xml
index 10fbb903..4f0bd97d 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.xml
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/mapper/MeasurementUnitMapper.xml
@@ -19,6 +19,34 @@
+
+ SELECT *
+ FROM blade_measurement_unit
+ WHERE unit_code = #{unitCode}
+ LIMIT 1
+
+
+
+ SELECT *
+ FROM blade_measurement_unit
+ WHERE unit_name = #{unitName}
+ LIMIT 1
+
+
+
+ UPDATE blade_measurement_unit
+ SET is_deleted = 0
+ WHERE id = #{id}
+ AND is_deleted = 1
+
+
+
+ UPDATE blade_measurement_unit
+ SET unit_name = #{unitName}
+ WHERE id = #{id}
+ AND is_deleted = 1
+
+
SELECT
mmu.id,
diff --git a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java
index 4c43c474..ab0d639a 100644
--- a/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java
+++ b/blade-service/blade-system/src/main/java/org/springblade/system/service/impl/MeasurementUnitServiceImpl.java
@@ -62,10 +62,42 @@ public class MeasurementUnitServiceImpl extends BaseServiceImpl
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.controller;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.AllArgsConstructor;
+import org.springblade.core.boot.ctrl.BladeController;
+import org.springblade.core.mp.support.Condition;
+import org.springblade.core.mp.support.Query;
+import org.springblade.core.secure.annotation.PreAuth;
+import org.springblade.core.tool.api.R;
+import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
+import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
+import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
+import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
+import org.springblade.transport.service.ISettlementImportBatchService;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RequestPart;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 导入结算单控制器
+ *
+ * @author Chill
+ */
+@RestController
+@AllArgsConstructor
+@PreAuth(menu = "settlement_import")
+@RequestMapping("settlement-import")
+@Tag(name = "导入结算单", description = "导入结算单管理")
+public class SettlementImportController extends BladeController {
+
+ private final ISettlementImportBatchService settlementImportBatchService;
+
+ @GetMapping("/options")
+ @ApiOperationSupport(order = 1)
+ @Operation(summary = "筛选项与表单选项")
+ public R>>> options() {
+ return R.data(settlementImportBatchService.options());
+ }
+
+ @GetMapping("/next-code")
+ @ApiOperationSupport(order = 2)
+ @Operation(summary = "下一个批次号")
+ public R nextCode() {
+ return R.data(settlementImportBatchService.nextBatchNo());
+ }
+
+ @GetMapping("/list")
+ @ApiOperationSupport(order = 3)
+ @Operation(summary = "导入结算单批次分页")
+ public R> list(SettlementImportBatchRequest request, Query query) {
+ return R.data(settlementImportBatchService.page(Condition.getPage(query), request));
+ }
+
+ @GetMapping("/detail")
+ @ApiOperationSupport(order = 4)
+ @Operation(summary = "导入结算单批次详情")
+ public R detail(@RequestParam Long id) {
+ return R.data(settlementImportBatchService.detail(id));
+ }
+
+ @GetMapping("/export-template")
+ @ApiOperationSupport(order = 5)
+ @Operation(summary = "导出导入模板")
+ public void exportTemplate(HttpServletResponse response) {
+ settlementImportBatchService.exportTemplate(response);
+ }
+
+ @PostMapping("/parse")
+ @ApiOperationSupport(order = 6)
+ @Operation(summary = "解析导入 Excel")
+ public R> parse(@RequestPart("file") MultipartFile file) {
+ return R.data(settlementImportBatchService.parse(file));
+ }
+
+ @PostMapping("/validate")
+ @ApiOperationSupport(order = 7)
+ @Operation(summary = "校验导入数据")
+ public R validate(@RequestBody SettlementImportBatchRequest request) {
+ return R.data(settlementImportBatchService.validate(request));
+ }
+
+ @PostMapping("/export-failures")
+ @ApiOperationSupport(order = 8)
+ @Operation(summary = "导出失败明细")
+ public void exportFailures(@RequestBody SettlementImportBatchRequest request, HttpServletResponse response) {
+ settlementImportBatchService.exportFailures(request, null, response);
+ }
+
+ @PostMapping("/draft")
+ @ApiOperationSupport(order = 9)
+ @Operation(summary = "保存草稿")
+ public R draft(@RequestBody SettlementImportBatchRequest request) {
+ return R.data(settlementImportBatchService.saveDraft(request));
+ }
+
+ @PostMapping("/confirm")
+ @ApiOperationSupport(order = 10)
+ @Operation(summary = "确认导入结算单")
+ public R confirm(@RequestBody SettlementImportBatchRequest request) {
+ return R.data(settlementImportBatchService.confirm(request));
+ }
+
+ @PostMapping("/void")
+ @ApiOperationSupport(order = 11)
+ @Operation(summary = "作废导入结算单批次")
+ public R voidBatch(@RequestBody SettlementImportVoidRequest request) {
+ settlementImportBatchService.voidBatch(request);
+ return R.success("作废成功");
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java
new file mode 100644
index 00000000..be771e3c
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportFeeReader.java
@@ -0,0 +1,379 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.excel;
+
+import org.apache.poi.ss.usermodel.Cell;
+import org.apache.poi.ss.usermodel.DataFormatter;
+import org.apache.poi.ss.usermodel.Row;
+import org.apache.poi.ss.usermodel.Sheet;
+import org.apache.poi.ss.usermodel.Workbook;
+import org.apache.poi.ss.usermodel.WorkbookFactory;
+import org.springblade.common.excel.ImportCellReadException;
+import org.springblade.core.log.exception.ServiceException;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.InputStream;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+
+/**
+ * 导入结算单 Excel 读取器(Apache POI)。
+ *
+ * 定位「*运输费」或「运输费」列,其后直到「结算金额」为动态费用列。
+ *
+ * @author Chill
+ */
+public final class SettlementImportFeeReader {
+
+ private static final DataFormatter DATA_FORMATTER = new DataFormatter(Locale.CHINA);
+
+ private SettlementImportFeeReader() {
+ }
+
+ /**
+ * 解析结果
+ */
+ public static final class ParseResult {
+ private final List> rows;
+ private final List feeItemNames;
+ private final List warnings;
+
+ public ParseResult(List> rows, List feeItemNames, List warnings) {
+ this.rows = rows;
+ this.feeItemNames = feeItemNames;
+ this.warnings = warnings;
+ }
+
+ public List> getRows() {
+ return rows;
+ }
+
+ public List getFeeItemNames() {
+ return feeItemNames;
+ }
+
+ public List getWarnings() {
+ return warnings;
+ }
+ }
+
+ public static ParseResult read(MultipartFile file) {
+ if (file == null || file.isEmpty()) {
+ throw new ServiceException("上传文件不能为空");
+ }
+ try (InputStream inputStream = file.getInputStream(); Workbook workbook = WorkbookFactory.create(inputStream)) {
+ Sheet sheet = resolveDataSheet(workbook);
+ Row headerRow = sheet.getRow(sheet.getFirstRowNum());
+ if (headerRow == null) {
+ throw new ServiceException("未识别到表头,请使用标准导入结算单模板");
+ }
+ Map headers = readHeaderMap(headerRow);
+ int transportFeeColumn = findTransportFeeColumn(headers);
+ if (transportFeeColumn < 0) {
+ throw new ServiceException("未找到「*运输费」或「运输费」列");
+ }
+ int settlementAmountColumn = findColumnExact(headers, "结算金额");
+ List feeColumns = new ArrayList<>();
+ List feeItemNames = new ArrayList<>();
+ int endColumn = settlementAmountColumn > transportFeeColumn
+ ? settlementAmountColumn
+ : headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1;
+ for (int column = transportFeeColumn + 1; column < endColumn; column++) {
+ String name = headers.get(column);
+ if (name == null || name.isBlank() || "结算金额".equals(name)) {
+ continue;
+ }
+ feeColumns.add(column);
+ feeItemNames.add(name);
+ }
+
+ List> rows = new ArrayList<>();
+ int firstDataRow = sheet.getFirstRowNum() + 1;
+ int lastRow = sheet.getLastRowNum();
+ for (int rowIndex = firstDataRow; rowIndex <= lastRow; rowIndex++) {
+ Row row = sheet.getRow(rowIndex);
+ if (row == null) {
+ continue;
+ }
+ Map mapped = mapRow(headers, feeColumns, row, rowIndex);
+ if (mapped != null) {
+ rows.add(mapped);
+ }
+ }
+ return new ParseResult(rows, feeItemNames, List.of());
+ } catch (ServiceException exception) {
+ throw exception;
+ } catch (Exception exception) {
+ throw new ServiceException("读取导入结算单 Excel 失败:" + exception.getMessage());
+ }
+ }
+
+ /** 优先取含「*运输费/运输费」表头的工作表,否则取第一个非空表。 */
+ private static Sheet resolveDataSheet(Workbook workbook) {
+ Sheet fallback = null;
+ for (int index = 0; index < workbook.getNumberOfSheets(); index++) {
+ Sheet sheet = workbook.getSheetAt(index);
+ if (sheet == null) {
+ continue;
+ }
+ Row headerRow = sheet.getRow(sheet.getFirstRowNum());
+ if (headerRow == null) {
+ continue;
+ }
+ Map headers = readHeaderMap(headerRow);
+ if (findTransportFeeColumn(headers) >= 0) {
+ return sheet;
+ }
+ if (fallback == null && !headers.isEmpty()) {
+ fallback = sheet;
+ }
+ }
+ if (fallback != null) {
+ return fallback;
+ }
+ throw new ServiceException("Excel 中未找到有效工作表");
+ }
+
+ private static Map readHeaderMap(Row headerRow) {
+ Map headers = new LinkedHashMap<>();
+ short lastCell = headerRow.getLastCellNum();
+ for (int column = 0; column < lastCell; column++) {
+ String value = normalizeHeader(cellString(headerRow.getCell(column)));
+ if (value != null && !value.isBlank()) {
+ headers.put(column, value);
+ }
+ }
+ return headers;
+ }
+
+ /** 去掉 BOM/不可见空白,全角星号转半角,便于匹配「*运输费」。 */
+ private static String normalizeHeader(String raw) {
+ if (raw == null) {
+ return null;
+ }
+ String text = raw.replace("\uFEFF", "")
+ .replace('\u00A0', ' ')
+ .replace('*', '*')
+ .trim();
+ return text.isEmpty() ? null : text;
+ }
+
+ private static Map mapRow(Map headers, List feeColumns,
+ Row row, int excelRowIndex) {
+ Map mapped = new LinkedHashMap<>();
+ Map feeItems = new LinkedHashMap<>();
+ for (Map.Entry entry : headers.entrySet()) {
+ int column = entry.getKey();
+ String trimmedHeader = entry.getValue();
+ String cell = cellString(row.getCell(column));
+ if (feeColumns.contains(column)) {
+ feeItems.put(trimmedHeader, decimal(cell, trimmedHeader, excelRowIndex));
+ continue;
+ }
+ String field = FIXED_HEADER_MAP.get(trimmedHeader);
+ if (field == null) {
+ continue;
+ }
+ if ("transportFee".equals(field) || "unitPrice".equals(field)
+ || "quantity".equals(field) || "mileage".equals(field)
+ || "settlementAmount".equals(field)) {
+ mapped.put(field, decimal(cell, trimmedHeader, excelRowIndex));
+ } else if ("serialNumber".equals(field)) {
+ mapped.put(field, intValue(cell));
+ } else {
+ mapped.put(field, blankToNull(cell));
+ }
+ mapped.put(trimmedHeader, mapped.get(field));
+ }
+
+ boolean hasBusinessValue = mapped.values().stream().anyMatch(value -> {
+ if (value == null) {
+ return false;
+ }
+ if (value instanceof String text) {
+ return !text.isBlank();
+ }
+ return true;
+ }) || feeItems.values().stream().anyMatch(amount -> amount != null && amount.compareTo(BigDecimal.ZERO) != 0);
+ if (!hasBusinessValue) {
+ return null;
+ }
+
+ BigDecimal transportFee = toDecimal(mapped.get("transportFee"));
+ BigDecimal feeItemTotal = feeItems.values().stream()
+ .reduce(BigDecimal.ZERO, BigDecimal::add)
+ .setScale(2, RoundingMode.HALF_UP);
+ BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
+ mapped.put("settlementAmount", settlementAmount);
+ mapped.put("结算金额", settlementAmount);
+ mapped.put("feeItems", feeItems);
+ mapped.put("freight", transportFee);
+ mapped.put("运费", transportFee);
+ mapped.put("*运输费", transportFee);
+ mapped.put("运输费", transportFee);
+ mapped.put("otherFeeTotal", feeItemTotal);
+ mapped.put("其他费用合计", feeItemTotal);
+ mapped.put("freightTotal", settlementAmount);
+ mapped.put("运费合计", settlementAmount);
+ if (mapped.get("unitPrice") != null) {
+ mapped.put("单价", mapped.get("unitPrice"));
+ mapped.put("运输单价", mapped.get("unitPrice"));
+ }
+ return mapped;
+ }
+
+ private static int findTransportFeeColumn(Map headers) {
+ int starred = findColumnExact(headers, "*运输费");
+ if (starred >= 0) {
+ return starred;
+ }
+ return findColumnExact(headers, "运输费");
+ }
+
+ private static int findColumnExact(Map headers, String header) {
+ return headers.entrySet().stream()
+ .filter(entry -> header.equals(entry.getValue()))
+ .mapToInt(Map.Entry::getKey)
+ .findFirst()
+ .orElse(-1);
+ }
+
+ private static String cellString(Cell cell) {
+ if (cell == null) {
+ return null;
+ }
+ String text = DATA_FORMATTER.formatCellValue(cell);
+ if (text == null) {
+ return null;
+ }
+ text = text.trim();
+ return text.isEmpty() ? null : text;
+ }
+
+ private static BigDecimal decimal(String cell, String columnName, Integer excelRowIndex) {
+ if (cell == null || cell.isBlank()) {
+ return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+ }
+ String normalized = cell.trim().replace(",", "");
+ try {
+ return new BigDecimal(normalized).setScale(2, RoundingMode.HALF_UP);
+ } catch (NumberFormatException exception) {
+ throw new ImportCellReadException(excelRowIndex, columnName, cell.trim());
+ }
+ }
+
+ private static Integer intValue(String cell) {
+ if (cell == null || cell.isBlank()) {
+ return null;
+ }
+ try {
+ return new BigDecimal(cell.trim().replace(",", "")).intValue();
+ } catch (NumberFormatException exception) {
+ return null;
+ }
+ }
+
+ private static String blankToNull(String cell) {
+ if (cell == null) {
+ return null;
+ }
+ String text = cell.trim();
+ return text.isEmpty() ? null : text;
+ }
+
+ private static BigDecimal toDecimal(Object value) {
+ if (value == null) {
+ return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+ }
+ if (value instanceof BigDecimal bigDecimal) {
+ return bigDecimal;
+ }
+ try {
+ return new BigDecimal(String.valueOf(value).trim()).setScale(2, RoundingMode.HALF_UP);
+ } catch (NumberFormatException exception) {
+ return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+ }
+ }
+
+ private static final Map FIXED_HEADER_MAP = buildFixedHeaderMap();
+
+ private static Map buildFixedHeaderMap() {
+ Map map = new LinkedHashMap<>();
+ map.put("序号", "serialNumber");
+ map.put("原始单号", "originalNo");
+ map.put("配载标识号", "loadingIdentifier");
+ map.put("*车牌号/船班号/船次号/班次号", "vehicleNo");
+ map.put("车牌号/船班号/船次号/班次号", "vehicleNo");
+ map.put("*车牌号/航班号/船号/班列号", "vehicleNo");
+ map.put("车牌号/航班号/船号/班列号", "vehicleNo");
+ map.put("*运输方式", "transportType");
+ map.put("运输方式", "transportType");
+ map.put("运输类型", "transportType");
+ map.put("*运输类型", "transportType");
+ map.put("司机/船长姓名", "driverName");
+ map.put("司机/船长手机号", "driverPhone");
+ map.put("*发货地址", "departureAddress");
+ map.put("发货地址", "departureAddress");
+ map.put("发货联系人", "departureContact");
+ map.put("发货联系人电话", "departurePhone");
+ map.put("*到货地址", "arrivalAddress");
+ map.put("到货地址", "arrivalAddress");
+ map.put("收货联系人", "arrivalContact");
+ map.put("收货联系人电话", "arrivalPhone");
+ map.put("*货物名称", "cargoName");
+ map.put("货物名称", "cargoName");
+ map.put("*货物类型", "cargoType");
+ map.put("货物类型", "cargoType");
+ map.put("包装", "packageType");
+ map.put("*数量", "quantity");
+ map.put("数量", "quantity");
+ map.put("*数量单位", "quantityUnit");
+ map.put("数量单位", "quantityUnit");
+ map.put("规格", "specification");
+ map.put("型号", "model");
+ map.put("里程(km)", "mileage");
+ map.put("里程", "mileage");
+ map.put("运输单价", "unitPrice");
+ map.put("单价", "unitPrice");
+ map.put("*运输费", "transportFee");
+ map.put("运输费", "transportFee");
+ map.put("结算金额", "settlementAmount");
+ map.put("*实际发货时间", "actualStartDate");
+ map.put("实际发货时间", "actualStartDate");
+ map.put("*实际完成时间", "actualEndDate");
+ map.put("实际完成时间", "actualEndDate");
+ map.put("预计发货时间", "planStartDate");
+ map.put("预计完成时间", "planEndDate");
+ map.put("备注", "remark");
+ map.put("同一运单标识号", "waybillIdentifier");
+ return map;
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java
new file mode 100644
index 00000000..a060b15e
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/excel/SettlementImportTemplateExcel.java
@@ -0,0 +1,121 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.excel;
+
+import cn.idev.excel.annotation.ExcelIgnore;
+import cn.idev.excel.annotation.ExcelProperty;
+import cn.idev.excel.annotation.write.style.ColumnWidth;
+import cn.idev.excel.annotation.write.style.ContentRowHeight;
+import cn.idev.excel.annotation.write.style.HeadRowHeight;
+import lombok.Data;
+
+import java.io.Serial;
+import java.io.Serializable;
+import java.math.BigDecimal;
+
+/**
+ * 导入结算单 Excel 模板(固定列 + 运输单价 + *运输费 + 示例费用项)
+ *
+ * @author Chill
+ */
+@Data
+@ColumnWidth(22)
+@HeadRowHeight(28)
+@ContentRowHeight(18)
+public class SettlementImportTemplateExcel implements Serializable {
+
+ @Serial
+ private static final long serialVersionUID = 1L;
+
+ @ExcelProperty("序号")
+ private Integer serialNumber;
+ @ExcelProperty("原始单号")
+ private String originalNo;
+ @ExcelProperty("配载标识号")
+ private String loadingIdentifier;
+ @ExcelProperty("*车牌号/航班号/船号/班列号")
+ private String vehicleNo;
+ @ExcelProperty("*运输方式")
+ private String transportType;
+ @ExcelProperty("司机/船长姓名")
+ private String driverName;
+ @ExcelProperty("司机/船长手机号")
+ private String driverPhone;
+ @ExcelProperty("*发货地址")
+ private String departureAddress;
+ @ExcelProperty("发货联系人")
+ private String departureContact;
+ @ExcelProperty("发货联系人电话")
+ private String departurePhone;
+ @ExcelProperty("*到货地址")
+ private String arrivalAddress;
+ @ExcelProperty("收货联系人")
+ private String arrivalContact;
+ @ExcelProperty("收货联系人电话")
+ private String arrivalPhone;
+ @ExcelProperty("*货物名称")
+ private String cargoName;
+ @ExcelProperty("*货物类型")
+ private String cargoType;
+ @ExcelProperty("包装")
+ private String packageType;
+ @ExcelProperty("*数量")
+ private BigDecimal quantity;
+ @ExcelProperty("*数量单位")
+ private String quantityUnit;
+ @ExcelProperty("规格")
+ private String specification;
+ @ExcelProperty("型号")
+ private String model;
+ @ExcelProperty("里程(km)")
+ private BigDecimal mileage;
+ @ExcelProperty("运输单价")
+ private BigDecimal unitPrice;
+ @ExcelProperty("*运输费")
+ private BigDecimal transportFee;
+ @ExcelProperty("装卸费")
+ private BigDecimal loadingFee;
+ @ExcelProperty("过路费")
+ private BigDecimal tollFee;
+ @ExcelProperty("结算金额")
+ private BigDecimal settlementAmount;
+ @ExcelProperty("*实际发货时间")
+ private String actualStartDate;
+ @ExcelProperty("*实际完成时间")
+ private String actualEndDate;
+ @ExcelProperty("预计发货时间")
+ private String planStartDate;
+ @ExcelProperty("预计完成时间")
+ private String planEndDate;
+ @ExcelProperty("备注")
+ private String remark;
+ @ExcelProperty("同一运单标识号")
+ private String waybillIdentifier;
+
+ /** 导入失败原因(不导出到模板,仅用于失败明细) */
+ @ExcelIgnore
+ private String errorMessage;
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml
index 31f15b31..e6d9996c 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/CustomerArchiveMapper.xml
@@ -101,8 +101,7 @@
approved_time,
kingdee_status,
kingdee_fail_reason,
- kingdee_sync_time
- approved_time,
+ kingdee_sync_time,
(
SELECT s.review_status
FROM blade_customer_credit_score s
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementImportBatchMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementImportBatchMapper.java
new file mode 100644
index 00000000..15156a16
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/SettlementImportBatchMapper.java
@@ -0,0 +1,50 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+import org.apache.ibatis.annotations.Select;
+import org.springblade.transport.pojo.entity.SettlementImportBatch;
+
+/**
+ * 导入结算单批次 Mapper
+ *
+ * @author Chill
+ */
+@Mapper
+public interface SettlementImportBatchMapper extends BaseMapper {
+
+ /**
+ * 查询指定租户、指定日期前缀下已使用的最大批次流水号。
+ * 不过滤逻辑删除数据,避免唯一索引仍占用历史编号时重复生成。
+ */
+ @Select("SELECT COALESCE(MAX(CAST(SUBSTRING(batch_no, CHAR_LENGTH(#{prefix}) + 1) AS UNSIGNED)), 0) "
+ + "FROM blade_settlement_import_batch WHERE tenant_id = #{tenantId} "
+ + "AND batch_no LIKE CONCAT(#{prefix}, '%')")
+ Long selectMaxDailySequence(@Param("tenantId") String tenantId, @Param("prefix") String prefix);
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java
index 196df17c..82592347 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IReceivablePayableDetailService.java
@@ -101,4 +101,19 @@ public interface IReceivablePayableDetailService extends BaseService
+ * 历史运单若已有系统生成明细,不覆盖原明细,另建结算导入明细;运单已进入结算则拒绝重复导入。
+ * feeRows 每项含 waybillId、transportFee、unitPrice、feeItems(Map)、remark。
+ *
+ *
+ * @param settlementType 结算类型 receivable/payable
+ * @param contractId 结算合同ID
+ * @param feeRows 费用行
+ * @return 明细主键列表(新建或刷新后的结算导入明细)
+ */
+ List createDetailsFromSettlementImport(String settlementType, Long contractId,
+ List> feeRows);
}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java
new file mode 100644
index 00000000..f045a015
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/ISettlementImportBatchService.java
@@ -0,0 +1,69 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springblade.core.mp.base.BaseService;
+import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
+import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
+import org.springblade.transport.pojo.entity.SettlementImportBatch;
+import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
+import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 导入结算单批次服务
+ *
+ * @author Chill
+ */
+public interface ISettlementImportBatchService extends BaseService {
+
+ Map>> options();
+
+ String nextBatchNo();
+
+ IPage page(IPage page, SettlementImportBatchRequest request);
+
+ SettlementImportBatchVO detail(Long id);
+
+ void exportTemplate(HttpServletResponse response);
+
+ Map parse(MultipartFile file);
+
+ WaybillImportValidateResultVO validate(SettlementImportBatchRequest request);
+
+ void exportFailures(SettlementImportBatchRequest request, MultipartFile file, HttpServletResponse response);
+
+ SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request);
+
+ SettlementImportBatchVO confirm(SettlementImportBatchRequest request);
+
+ void voidBatch(SettlementImportVoidRequest request);
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
index 4c6cd10f..1ec56fd9 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ContractManageServiceImpl.java
@@ -437,9 +437,11 @@ public class ContractManageServiceImpl extends BaseServiceImpl createDetailsFromSettlementImport(String settlementType, Long contractId,
+ List> feeRows) {
+ if (Func.isEmpty(settlementType) || Func.isEmpty(contractId)) {
+ throw new ServiceException("结算类型与合同不能为空");
+ }
+ if (Func.isEmpty(feeRows)) {
+ return List.of();
+ }
+ ContractManage contract = contractManageService.getById(contractId);
+ if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) {
+ throw new ServiceException("合同不存在");
+ }
+ String targetSettlementType = settlementType(settlementType);
+ Long currentUserId = AuthUtil.getUserId();
+ Date generateTime = new Date();
+ List createdIds = new ArrayList<>();
+ for (Map feeRow : feeRows) {
+ if (feeRow == null) {
+ continue;
+ }
+ Long waybillId = toLong(feeRow.get("waybillId"));
+ if (waybillId == null) {
+ continue;
+ }
+ Waybill waybill = waybillService.getById(waybillId);
+ if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
+ throw new ServiceException("运单不存在:" + waybillId);
+ }
+ assertWaybillNotSettledForImport(waybill, targetSettlementType);
+
+ BigDecimal transportFee = money(decimal(feeRow.get("transportFee")));
+ BigDecimal unitPrice = money(decimal(feeRow.get("unitPrice")));
+ Map feeItems = normalizeFeeItems(toFeeItemMap(feeRow.get("feeItems")));
+ BigDecimal feeItemTotal = feeItems.values().stream().map(this::money).reduce(BigDecimal.ZERO, BigDecimal::add);
+ BigDecimal afterAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
+ ReceivablePayableCargoFee cargoFee = buildSettlementImportCargoFee(
+ waybill, transportFee, unitPrice, feeItems, afterAmount, stringValue(feeRow.get("remark")));
+
+ ReceivablePayableDetail importDetail = getOne(Wrappers.lambdaQuery()
+ .eq(ReceivablePayableDetail::getWaybillId, waybillId)
+ .eq(ReceivablePayableDetail::getSettlementType, targetSettlementType)
+ .eq(ReceivablePayableDetail::getSourceType, SOURCE_SETTLEMENT_IMPORT)
+ .eq(ReceivablePayableDetail::getIsDeleted, 0)
+ .orderByDesc(ReceivablePayableDetail::getCreateTime)
+ .last("limit 1"), false);
+ if (importDetail != null) {
+ refreshSettlementImportDetail(importDetail, waybill, contract, targetSettlementType,
+ cargoFee, unitPrice, stringValue(feeRow.get("remark")), currentUserId, generateTime);
+ createdIds.add(importDetail.getId());
+ continue;
+ }
+
+ ReceivablePayableDetail detail = buildDetail(waybill, contract, targetSettlementType,
+ List.of(cargoFee), unitPrice);
+ detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
+ detail.setSettlementStatus("pending");
+ if (Func.isNotEmpty(stringValue(feeRow.get("remark")))) {
+ detail.setRemark(stringValue(feeRow.get("remark")));
+ }
+ saveAutomaticDetail(detail, List.of(cargoFee), false, currentUserId, generateTime);
+ createdIds.add(detail.getId());
+ }
+ return createdIds;
+ }
+
+ /** 运单在该结算类型下已进入预/正式结算时,禁止再导入。 */
+ private void assertWaybillNotSettledForImport(Waybill waybill, String settlementType) {
+ List details = list(Wrappers.lambdaQuery()
+ .eq(ReceivablePayableDetail::getWaybillId, waybill.getId())
+ .eq(ReceivablePayableDetail::getSettlementType, settlementType)
+ .eq(ReceivablePayableDetail::getIsDeleted, 0));
+ for (ReceivablePayableDetail detail : details) {
+ String settlementStatus = Func.toStr(detail.getSettlementStatus(), "pending");
+ if (Func.isNotEmpty(detail.getFormalSettlementNo())
+ || Func.isNotEmpty(detail.getPreSettlementNo())
+ || List.of("pre_settled", "formal_settled", "closed").contains(settlementStatus)) {
+ throw new ServiceException("运单" + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId()))
+ + "已进入结算,无法重复导入");
+ }
+ }
+ }
+
+ private ReceivablePayableCargoFee buildSettlementImportCargoFee(Waybill waybill, BigDecimal transportFee,
+ BigDecimal unitPrice, Map feeItems,
+ BigDecimal afterAmount, String remark) {
+ ReceivablePayableCargoFee cargoFee = new ReceivablePayableCargoFee();
+ cargoFee.setWaybillId(waybill.getId());
+ cargoFee.setLineNo("0001");
+ cargoFee.setDataSource(FEE_SOURCE_MANUAL);
+ cargoFee.setCargoName(waybill.getCargoName());
+ cargoFee.setCargoType(waybill.getCargoType());
+ cargoFee.setSpecification(waybill.getSpecification());
+ cargoFee.setModel(waybill.getModel());
+ cargoFee.setBillingFactor("按重量");
+ cargoFee.setBillingType("固定单价");
+ cargoFee.setTransportQuantity(money(waybill.getQuantity()));
+ cargoFee.setQuantityUnit(waybill.getQuantityUnit());
+ cargoFee.setPriceUnit(waybill.getPriceUnit());
+ cargoFee.setUnitPrice(unitPrice);
+ cargoFee.setMileage(normalizeGeneratedMileage(waybill.getMileage()));
+ cargoFee.setFreightAmount(transportFee);
+ cargoFee.setFeeItemsJson(JsonUtil.toJson(feeItems));
+ cargoFee.setOriginalAmount(afterAmount);
+ cargoFee.setAdjustAmount(BigDecimal.ZERO.setScale(2));
+ cargoFee.setAfterAmount(afterAmount);
+ cargoFee.setRemark(remark);
+ return cargoFee;
+ }
+
+ /** 刷新已有结算导入明细:费用项目与金额始终覆盖为本次导入数据。 */
+ private void refreshSettlementImportDetail(ReceivablePayableDetail detail, Waybill waybill, ContractManage contract,
+ String settlementType, ReceivablePayableCargoFee cargoFee,
+ BigDecimal unitPrice, String remark, Long currentUserId, Date generateTime) {
+ ReceivablePayableDetail rebuilt = buildDetail(waybill, contract, settlementType, List.of(cargoFee), unitPrice);
+ detail.setFreightAmount(rebuilt.getFreightAmount());
+ detail.setOtherFeeAmount(rebuilt.getOtherFeeAmount());
+ detail.setTotalAmount(rebuilt.getTotalAmount());
+ detail.setUnitPrice(rebuilt.getUnitPrice());
+ detail.setCurrency(rebuilt.getCurrency());
+ detail.setFeeItemsJson(rebuilt.getFeeItemsJson());
+ detail.setContractId(rebuilt.getContractId());
+ detail.setContractNo(rebuilt.getContractNo());
+ detail.setContractName(rebuilt.getContractName());
+ detail.setCustomerName(rebuilt.getCustomerName());
+ detail.setSourceType(SOURCE_SETTLEMENT_IMPORT);
+ detail.setSettlementStatus("pending");
+ if (Func.isNotEmpty(remark)) {
+ detail.setRemark(remark);
+ }
+ updateById(detail);
+ cargoFeeMapper.delete(Wrappers.lambdaQuery()
+ .eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
+ ReceivablePayableCargoFee copy = BeanUtil.copyProperties(cargoFee, ReceivablePayableCargoFee.class);
+ copy.setId(null);
+ copy.setDetailId(detail.getId());
+ fillGeneratedAuditFields(copy, currentUserId, generateTime);
+ cargoFeeMapper.insert(copy);
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map toFeeItemMap(Object value) {
+ Map result = new LinkedHashMap<>();
+ if (!(value instanceof Map, ?> raw)) {
+ return result;
+ }
+ raw.forEach((key, amount) -> {
+ if (key == null) {
+ return;
+ }
+ result.put(String.valueOf(key), decimal(amount));
+ });
+ return result;
+ }
+
+ private Long toLong(Object value) {
+ if (value == null || "".equals(value)) {
+ return null;
+ }
+ if (value instanceof Number number) {
+ return number.longValue();
+ }
+ try {
+ return Long.valueOf(String.valueOf(value).trim());
+ } catch (NumberFormatException exception) {
+ return null;
+ }
+ }
+
+ private String stringValue(Object value) {
+ if (value == null) {
+ return null;
+ }
+ String text = String.valueOf(value).trim();
+ return text.isEmpty() ? null : text;
+ }
+
@Override
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
rollbackFor = Exception.class)
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java
new file mode 100644
index 00000000..34ae5a9b
--- /dev/null
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/SettlementImportBatchServiceImpl.java
@@ -0,0 +1,1144 @@
+/**
+ * BladeX Commercial License Agreement
+ * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
+ *
+ * Use of this software is governed by the Commercial License Agreement
+ * obtained after purchasing a license from BladeX.
+ *
+ * 1. This software is for development use only under a valid license
+ * from BladeX.
+ *
+ * 2. Redistribution of this software's source code to any third party
+ * without a commercial license is strictly prohibited.
+ *
+ * 3. Licensees may copyright their own code but cannot use segments
+ * from this software for such purposes. Copyright of this software
+ * remains with BladeX.
+ *
+ * Using this software signifies agreement to this License, and the software
+ * must not be used for illegal purposes.
+ *
+ * THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
+ * not liable for any claims arising from secondary or illegal development.
+ *
+ * Author: Chill Zhuang (bladejava@qq.com)
+ */
+package org.springblade.transport.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springblade.common.excel.ImportFailureExcelUtil;
+import org.springblade.core.excel.util.ExcelUtil;
+import org.springblade.core.log.exception.ServiceException;
+import org.springblade.core.mp.base.BaseServiceImpl;
+import org.springblade.core.secure.utils.AuthUtil;
+import org.springblade.core.tool.api.R;
+import org.springblade.core.tool.jackson.JsonUtil;
+import org.springblade.core.tool.utils.BeanUtil;
+import org.springblade.core.tool.utils.DateUtil;
+import org.springblade.core.tool.utils.Func;
+import org.springblade.system.cache.UserCache;
+import org.springblade.system.feign.ISysClient;
+import org.springblade.system.pojo.entity.FeeItem;
+import org.springblade.transport.constant.TransportBusiConstant;
+import org.springblade.transport.excel.SettlementImportFeeReader;
+import org.springblade.transport.excel.SettlementImportTemplateExcel;
+import org.springblade.transport.mapper.SettlementImportBatchMapper;
+import org.springblade.transport.pojo.dto.FormalSettlementSaveRequest;
+import org.springblade.transport.pojo.dto.FormalSettlementStatusRequest;
+import org.springblade.transport.pojo.dto.PreSettlementSaveRequest;
+import org.springblade.transport.pojo.dto.PreSettlementStatusRequest;
+import org.springblade.transport.pojo.dto.SettlementImportBatchRequest;
+import org.springblade.transport.pojo.dto.SettlementImportVoidRequest;
+import org.springblade.transport.pojo.dto.WaybillImportBatchRequest;
+import org.springblade.transport.pojo.entity.ContractManage;
+import org.springblade.transport.pojo.entity.FormalSettlement;
+import org.springblade.transport.pojo.entity.PreSettlement;
+import org.springblade.transport.pojo.entity.ProjectApply;
+import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
+import org.springblade.transport.pojo.entity.SettlementImportBatch;
+import org.springblade.transport.pojo.entity.Waybill;
+import org.springblade.transport.pojo.vo.SettlementImportBatchVO;
+import org.springblade.transport.pojo.vo.WaybillImportBatchVO;
+import org.springblade.transport.pojo.vo.WaybillImportRowErrorVO;
+import org.springblade.transport.pojo.vo.WaybillImportValidateResultVO;
+import org.springblade.transport.service.IContractManageService;
+import org.springblade.transport.service.IFormalSettlementService;
+import org.springblade.transport.service.IPreSettlementService;
+import org.springblade.transport.service.IProjectApplyService;
+import org.springblade.transport.service.IReceivablePayableDetailService;
+import org.springblade.transport.service.ISettlementImportBatchService;
+import org.springblade.transport.service.IWaybillImportBatchService;
+import org.springblade.transport.service.IWaybillService;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+/**
+ * 导入结算单批次服务实现
+ *
+ * @author Chill
+ */
+@Service
+@RequiredArgsConstructor
+public class SettlementImportBatchServiceImpl
+ extends BaseServiceImpl
+ implements ISettlementImportBatchService {
+
+ private static final String STATUS_DRAFT = "draft";
+ private static final String STATUS_COMPLETED = "completed";
+ private static final String STATUS_VOIDED = "voided";
+ private static final String WAYBILL_MODE_NEW = "new";
+ private static final String WAYBILL_MODE_HISTORY = "history";
+ private static final String TYPE_RECEIVABLE = "receivable";
+ private static final String TYPE_PAYABLE = "payable";
+ private static final String BILL_TYPE_FORMAL = "formal";
+ private static final String BILL_TYPE_PRE = "pre";
+
+ private final IProjectApplyService projectApplyService;
+ private final IContractManageService contractManageService;
+ private final IWaybillImportBatchService waybillImportBatchService;
+ private final IWaybillService waybillService;
+ private final IReceivablePayableDetailService receivablePayableDetailService;
+ private final IFormalSettlementService formalSettlementService;
+ private final IPreSettlementService preSettlementService;
+ private final ISysClient sysClient;
+
+ @Override
+ public Map>> options() {
+ Map>> options = new LinkedHashMap<>();
+ options.put("projects", projectApplyService.list().stream()
+ .filter(project -> List.of("approved", "change_approved").contains(project.getApprovalStatus()))
+ .map(project -> option(project.getId(), "projectName", project.getProjectName(),
+ "customerName", project.getCustomerNames()))
+ .toList());
+ List contracts = contractManageService.list(Wrappers.lambdaQuery()
+ .eq(ContractManage::getIsDeleted, 0)
+ .in(ContractManage::getApprovalStatus, "approved", "change_approved")
+ .and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
+ .or().ne(ContractManage::getContractStage, "terminated")));
+ options.put("customerContracts", contracts.stream()
+ .filter(contract -> "客户合同".equals(contract.getContractCategory()))
+ .map(contract -> option(contract.getId(), "contractName", contract.getContractName(),
+ "projectId", contract.getProjectId(), "partyA", contract.getPartyA(), "partyB", contract.getPartyB()))
+ .toList());
+ options.put("carrierContracts", contracts.stream()
+ .filter(contract -> "承运商合同".equals(contract.getContractCategory()))
+ .map(contract -> option(contract.getId(), "contractName", contract.getContractName(),
+ "projectId", contract.getProjectId(), "partyA", contract.getPartyA(), "partyB", contract.getPartyB()))
+ .toList());
+ options.put("batchNos", list(Wrappers.lambdaQuery()
+ .select(SettlementImportBatch::getBatchNo)
+ .eq(SettlementImportBatch::getIsDeleted, 0)
+ .isNotNull(SettlementImportBatch::getBatchNo)
+ .orderByDesc(SettlementImportBatch::getCreateTime)).stream()
+ .map(SettlementImportBatch::getBatchNo)
+ .filter(Func::isNotEmpty)
+ .distinct()
+ .map(batchNo -> {
+ Map item = new LinkedHashMap<>();
+ item.put("id", batchNo);
+ item.put("name", batchNo);
+ return item;
+ })
+ .toList());
+ Set creatorIds = new LinkedHashSet<>();
+ list(Wrappers.lambdaQuery()
+ .select(SettlementImportBatch::getCreateUser)
+ .eq(SettlementImportBatch::getIsDeleted, 0)
+ .isNotNull(SettlementImportBatch::getCreateUser))
+ .stream()
+ .map(SettlementImportBatch::getCreateUser)
+ .filter(Objects::nonNull)
+ .forEach(creatorIds::add);
+ options.put("creators", creatorIds.stream()
+ .map(userId -> option(userId, "name", UserCache.getUserRealName(userId)))
+ .toList());
+ return options;
+ }
+
+ @Override
+ public String nextBatchNo() {
+ return nextCode();
+ }
+
+ @Override
+ public IPage page(IPage page, SettlementImportBatchRequest request) {
+ LambdaQueryWrapper wrapper = Wrappers.lambdaQuery()
+ .eq(SettlementImportBatch::getIsDeleted, 0)
+ .like(Func.isNotEmpty(request.getBatchNo()), SettlementImportBatch::getBatchNo, request.getBatchNo())
+ .eq(Func.isNotEmpty(request.getSettlementType()), SettlementImportBatch::getSettlementType, request.getSettlementType())
+ .like(Func.isNotEmpty(request.getProjectName()), SettlementImportBatch::getProjectName, request.getProjectName())
+ .like(Func.isNotEmpty(request.getCustomerName()), SettlementImportBatch::getCustomerName, request.getCustomerName())
+ .like(Func.isNotEmpty(request.getCarrierName()), SettlementImportBatch::getCarrierName, request.getCarrierName())
+ .eq(request.getCreateUser() != null, SettlementImportBatch::getCreateUser, request.getCreateUser())
+ .orderByDesc(SettlementImportBatch::getCreateTime);
+ IPage entityPage = page(page, wrapper);
+ List records = entityPage.getRecords().stream().map(this::toVO).toList();
+ Page resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
+ resultPage.setRecords(records);
+ return resultPage;
+ }
+
+ @Override
+ public SettlementImportBatchVO detail(Long id) {
+ SettlementImportBatch batch = getById(id);
+ if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) {
+ throw new ServiceException("导入结算单批次不存在");
+ }
+ return toVO(batch);
+ }
+
+ @Override
+ public void exportTemplate(HttpServletResponse response) {
+ List sample = new ArrayList<>();
+ ExcelUtil.export(response, "导入结算单模板", "导入结算单模板", sample, SettlementImportTemplateExcel.class);
+ }
+
+ @Override
+ public Map parse(MultipartFile file) {
+ if (file == null || file.isEmpty()) {
+ throw new ServiceException("上传文件不能为空");
+ }
+ SettlementImportFeeReader.ParseResult result = SettlementImportFeeReader.read(file);
+ Map data = new LinkedHashMap<>();
+ data.put("rows", result.getRows());
+ data.put("feeItemNames", result.getFeeItemNames());
+ data.put("warnings", result.getWarnings());
+ return data;
+ }
+
+ @Override
+ public WaybillImportValidateResultVO validate(SettlementImportBatchRequest request) {
+ prepareSettlementRows(request);
+ String waybillMode = resolveWaybillMode(request);
+ List errors = new ArrayList<>();
+ if (WAYBILL_MODE_HISTORY.equals(waybillMode)) {
+ mergeErrors(errors, validateHistoryWaybillMatches(request));
+ } else {
+ WaybillImportValidateResultVO waybillResult = waybillImportBatchService.validate(toWaybillRequest(request));
+ if (waybillResult.getErrors() != null) {
+ errors.addAll(waybillResult.getErrors());
+ }
+ }
+ Map settlementErrors = validateSettlementExtras(request);
+ mergeErrors(errors, settlementErrors);
+ WaybillImportValidateResultVO result = new WaybillImportValidateResultVO();
+ result.setErrors(errors);
+ result.setSuccess(errors.isEmpty());
+ return result;
+ }
+
+ @Override
+ public void exportFailures(SettlementImportBatchRequest request, MultipartFile file, HttpServletResponse response) {
+ if (Func.isEmpty(request.getRows())) {
+ throw new ServiceException("请至少提供一条失败明细");
+ }
+ List failureList = new ArrayList<>();
+ for (Map row : request.getRows()) {
+ SettlementImportTemplateExcel excel = mapToExcel(row);
+ excel.setErrorMessage(stringValue(row, "errorMessage", "导入失败原因", "失败原因"));
+ failureList.add(excel);
+ }
+ String fileName = "导入结算单失败明细" + DateUtil.time();
+ if (file != null && !file.isEmpty()) {
+ ImportFailureExcelUtil.exportOriginalWithFailures(response, fileName, "导入失败明细",
+ file, failureList, SettlementImportTemplateExcel.class);
+ return;
+ }
+ ImportFailureExcelUtil.export(response, fileName, "导入失败明细", failureList, SettlementImportTemplateExcel.class);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public SettlementImportBatchVO saveDraft(SettlementImportBatchRequest request) {
+ SettlementImportBatch batch = buildDraftBatch(request);
+ if (Func.isNotEmpty(request.getId())) {
+ SettlementImportBatch oldBatch = getById(request.getId());
+ if (oldBatch == null || Objects.equals(oldBatch.getIsDeleted(), 1)) {
+ throw new ServiceException("导入结算单批次不存在");
+ }
+ if (!STATUS_DRAFT.equals(oldBatch.getImportStatus())) {
+ throw new ServiceException("仅草稿状态的批次允许编辑");
+ }
+ batch.setId(oldBatch.getId());
+ batch.setBatchNo(oldBatch.getBatchNo());
+ }
+ batch.setWaybillCount(0);
+ batch.setDetailCount(0);
+ saveOrUpdate(batch);
+ return toVO(batch);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public SettlementImportBatchVO confirm(SettlementImportBatchRequest request) {
+ validateForm(request);
+ String waybillMode = resolveWaybillMode(request);
+ prepareSettlementRows(request);
+ WaybillImportValidateResultVO validateResult = validate(request);
+ if (!Boolean.TRUE.equals(validateResult.getSuccess())) {
+ String firstError = validateResult.getErrors().isEmpty()
+ ? "校验失败" : validateResult.getErrors().get(0).getErrorMessage();
+ Integer firstIndex = validateResult.getErrors().isEmpty()
+ ? 0 : validateResult.getErrors().get(0).getRowIndex();
+ throw new ServiceException("第" + (firstIndex + 1) + "行校验失败:" + firstError);
+ }
+
+ SettlementImportBatch batch = buildBatch(request, STATUS_COMPLETED);
+ if (Func.isNotEmpty(request.getId())) {
+ SettlementImportBatch oldBatch = getById(request.getId());
+ if (oldBatch == null || Objects.equals(oldBatch.getIsDeleted(), 1)) {
+ throw new ServiceException("导入结算单批次不存在");
+ }
+ if (!STATUS_DRAFT.equals(oldBatch.getImportStatus())) {
+ throw new ServiceException("仅草稿状态的批次允许确认导入");
+ }
+ batch.setId(oldBatch.getId());
+ batch.setBatchNo(oldBatch.getBatchNo());
+ }
+ saveOrUpdate(batch);
+
+ List waybills;
+ Long waybillImportBatchId = null;
+ if (WAYBILL_MODE_HISTORY.equals(waybillMode)) {
+ // 历史运单:匹配已有运单(不新建),再按导入费用生成应收应付明细与预/正式结算单
+ waybills = matchHistoryWaybills(request);
+ if (Func.isEmpty(waybills)) {
+ throw new ServiceException("未匹配到系统已有运单");
+ }
+ List matchedIds = waybills.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
+ waybillService.update(Wrappers.lambdaUpdate()
+ .in(Waybill::getId, matchedIds)
+ .eq(Waybill::getIsDeleted, 0)
+ .set(Waybill::getSettlementImportBatchId, batch.getId()));
+ waybills.forEach(waybill -> waybill.setSettlementImportBatchId(batch.getId()));
+ } else {
+ // 新运单:同步生成运单,再按导入费用生成应收应付明细与预/正式结算单
+ WaybillImportBatchRequest waybillRequest = toWaybillRequest(request);
+ waybillRequest.setCustomerName(batch.getCustomerName());
+ waybillRequest.setContractName(batch.getCustomerContractName());
+ waybillRequest.setCarrierName(batch.getCarrierName());
+ waybillRequest.setStatus(STATUS_COMPLETED);
+ waybillRequest.setImportType("settlement");
+ waybillRequest.setAppend(Boolean.FALSE);
+ WaybillImportBatchVO waybillBatch = waybillImportBatchService.confirm(waybillRequest);
+ waybillImportBatchId = waybillBatch.getId();
+ waybills = waybillService.list(Wrappers.lambdaQuery()
+ .eq(Waybill::getImportBatchId, waybillBatch.getId())
+ .eq(Waybill::getIsDeleted, 0)
+ .orderByAsc(Waybill::getCreateTime)
+ .orderByAsc(Waybill::getId));
+ if (Func.isNotEmpty(waybills)) {
+ waybillService.update(Wrappers.lambdaUpdate()
+ .eq(Waybill::getImportBatchId, waybillBatch.getId())
+ .eq(Waybill::getIsDeleted, 0)
+ .set(Waybill::getSettlementImportBatchId, batch.getId()));
+ waybills.forEach(waybill -> waybill.setSettlementImportBatchId(batch.getId()));
+ }
+ }
+
+ // 明细与结算单费用项目/金额一律取自导入行(运输费 + 动态费用列),不取运单原系统计价
+ List> feeRows = buildFeeRows(request.getRows(), waybills);
+ Long settlementContractId = TYPE_PAYABLE.equals(request.getSettlementType())
+ ? request.getCarrierContractId() : request.getCustomerContractId();
+ List detailIds = receivablePayableDetailService.createDetailsFromSettlementImport(
+ request.getSettlementType(), settlementContractId, feeRows);
+ String settlementBillType = resolveSettlementBillType(request);
+ if (Func.isEmpty(detailIds)) {
+ throw new ServiceException("未生成应收应付明细,无法创建"
+ + (BILL_TYPE_PRE.equals(settlementBillType) ? "预结算单" : "正式结算单"));
+ }
+
+ batch.setWaybillImportBatchId(waybillImportBatchId);
+ batch.setWaybillCount(waybills.size());
+ batch.setDetailCount(detailIds.size());
+ batch.setSettlementBillType(settlementBillType);
+ if (BILL_TYPE_PRE.equals(settlementBillType)) {
+ PreSettlementSaveRequest preRequest = new PreSettlementSaveRequest();
+ preRequest.setContractId(settlementContractId);
+ preRequest.setSettlementType(request.getSettlementType());
+ preRequest.setSourceDetailIds(detailIds);
+ preRequest.setRemark(limit(request.getRemark(), 200));
+ Long preSettlementId = preSettlementService.saveDraft(preRequest);
+ PreSettlement preSettlement = preSettlementService.getById(preSettlementId);
+ batch.setPreSettlementId(preSettlementId);
+ batch.setPreSettlementNo(preSettlement == null ? null : preSettlement.getPreSettlementNo());
+ batch.setFormalSettlementId(null);
+ batch.setFormalSettlementNo(null);
+ } else {
+ FormalSettlementSaveRequest formalRequest = new FormalSettlementSaveRequest();
+ formalRequest.setContractId(settlementContractId);
+ formalRequest.setSettlementType(request.getSettlementType());
+ formalRequest.setSourceDetailIds(detailIds);
+ formalRequest.setRemark(limit(request.getRemark(), 200));
+ Long formalSettlementId = formalSettlementService.saveDraft(formalRequest);
+ FormalSettlement formalSettlement = formalSettlementService.getById(formalSettlementId);
+ batch.setFormalSettlementId(formalSettlementId);
+ batch.setFormalSettlementNo(formalSettlement == null ? null : formalSettlement.getFormalSettlementNo());
+ batch.setPreSettlementId(null);
+ batch.setPreSettlementNo(null);
+ }
+ batch.setImportStatus(STATUS_COMPLETED);
+ batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request)));
+ updateById(batch);
+ return toVO(batch);
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void voidBatch(SettlementImportVoidRequest request) {
+ if (request == null || request.getId() == null) {
+ throw new ServiceException("请选择需要作废的批次");
+ }
+ SettlementImportBatch batch = getById(request.getId());
+ if (batch == null || Objects.equals(batch.getIsDeleted(), 1)) {
+ throw new ServiceException("导入结算单批次不存在");
+ }
+ if (STATUS_VOIDED.equals(batch.getImportStatus())) {
+ throw new ServiceException("该批次已作废");
+ }
+
+ boolean draftSettlement = false;
+ if (batch.getFormalSettlementId() != null) {
+ FormalSettlement settlement = formalSettlementService.getById(batch.getFormalSettlementId());
+ if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) {
+ if (TransportBusiConstant.DRAFT.equals(settlement.getApprovalStatus())
+ || TransportBusiConstant.RETURNED.equals(settlement.getApprovalStatus())) {
+ formalSettlementService.removeDraft(settlement.getId());
+ draftSettlement = true;
+ } else if (TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())
+ && !"synced".equals(settlement.getKingdeeSyncStatus())) {
+ FormalSettlementStatusRequest voidRequest = new FormalSettlementStatusRequest();
+ voidRequest.setId(settlement.getId());
+ voidRequest.setReason(Func.isEmpty(request.getReason()) ? "导入结算单批次作废" : request.getReason());
+ formalSettlementService.voidBill(voidRequest);
+ } else {
+ throw new ServiceException("当前正式结算单状态不允许作废");
+ }
+ }
+ }
+ if (batch.getPreSettlementId() != null) {
+ PreSettlement settlement = preSettlementService.getById(batch.getPreSettlementId());
+ if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) {
+ if (TransportBusiConstant.DRAFT.equals(settlement.getApprovalStatus())) {
+ preSettlementService.removeDraft(settlement.getId());
+ draftSettlement = true;
+ } else if (TransportBusiConstant.APPROVED.equals(settlement.getApprovalStatus())) {
+ PreSettlementStatusRequest voidRequest = new PreSettlementStatusRequest();
+ voidRequest.setId(settlement.getId());
+ voidRequest.setReason(Func.isEmpty(request.getReason()) ? "导入结算单批次作废" : request.getReason());
+ preSettlementService.voidBill(voidRequest);
+ } else {
+ throw new ServiceException("当前预结算单状态不允许作废");
+ }
+ }
+ }
+
+ List waybills = listBatchWaybills(batch);
+ List waybillIds = waybills.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
+ boolean historyMode = WAYBILL_MODE_HISTORY.equals(batch.getWaybillMode());
+ if (draftSettlement && Func.isNotEmpty(waybillIds)) {
+ LambdaQueryWrapper detailWrapper = Wrappers.lambdaQuery()
+ .in(ReceivablePayableDetail::getWaybillId, waybillIds)
+ .eq(ReceivablePayableDetail::getIsDeleted, 0);
+ if (historyMode) {
+ // 历史运单仅清理本批次结算导入产生的明细,保留运单原有费用明细
+ detailWrapper.eq(ReceivablePayableDetail::getSourceType, "结算导入");
+ }
+ List details = receivablePayableDetailService.list(detailWrapper);
+ if (Func.isNotEmpty(details)) {
+ receivablePayableDetailService.deleteLogic(details.stream().map(ReceivablePayableDetail::getId).toList());
+ }
+ }
+ if (Func.isNotEmpty(waybillIds)) {
+ if (historyMode) {
+ // 历史运单不可删除,仅解除与导入批次的关联
+ waybillService.update(Wrappers.lambdaUpdate()
+ .in(Waybill::getId, waybillIds)
+ .eq(Waybill::getSettlementImportBatchId, batch.getId())
+ .set(Waybill::getSettlementImportBatchId, null));
+ } else {
+ waybillService.deleteLogic(waybillIds);
+ }
+ }
+ batch.setImportStatus(STATUS_VOIDED);
+ batch.setRemark(mergeRemark(batch.getRemark(), request.getReason()));
+ updateById(batch);
+ }
+
+ private List listBatchWaybills(SettlementImportBatch batch) {
+ LambdaQueryWrapper wrapper = Wrappers.lambdaQuery().eq(Waybill::getIsDeleted, 0);
+ if (batch.getId() != null && batch.getWaybillImportBatchId() != null) {
+ wrapper.and(query -> query.eq(Waybill::getSettlementImportBatchId, batch.getId())
+ .or().eq(Waybill::getImportBatchId, batch.getWaybillImportBatchId()));
+ } else if (batch.getId() != null) {
+ wrapper.eq(Waybill::getSettlementImportBatchId, batch.getId());
+ } else if (batch.getWaybillImportBatchId() != null) {
+ wrapper.eq(Waybill::getImportBatchId, batch.getWaybillImportBatchId());
+ } else {
+ return List.of();
+ }
+ return waybillService.list(wrapper);
+ }
+
+ private void validateForm(SettlementImportBatchRequest request) {
+ if (request == null) {
+ throw new ServiceException("请求不能为空");
+ }
+ if (Func.isEmpty(request.getProjectId())) {
+ throw new ServiceException("请选择项目");
+ }
+ if (Func.isEmpty(request.getSettlementType())
+ || (!TYPE_RECEIVABLE.equals(request.getSettlementType()) && !TYPE_PAYABLE.equals(request.getSettlementType()))) {
+ throw new ServiceException("结算类型仅支持 receivable 或 payable");
+ }
+ resolveSettlementBillType(request);
+ resolveWaybillMode(request);
+ if (Func.isEmpty(request.getCustomerContractId())) {
+ throw new ServiceException("请选择客户合同");
+ }
+ if (Func.isEmpty(request.getCarrierContractId())) {
+ throw new ServiceException("请选择承运商合同");
+ }
+ if (Func.isEmpty(request.getRows())) {
+ throw new ServiceException("请上传至少一条运单明细");
+ }
+ }
+
+ /** 运单类型:new=新运单,history=历史运单。 */
+ private String resolveWaybillMode(SettlementImportBatchRequest request) {
+ String waybillMode = Func.toStr(request == null ? null : request.getWaybillMode(), WAYBILL_MODE_NEW);
+ if (!WAYBILL_MODE_NEW.equals(waybillMode) && !WAYBILL_MODE_HISTORY.equals(waybillMode)) {
+ throw new ServiceException("运单类型仅支持新运单或历史运单");
+ }
+ if (request != null) {
+ request.setWaybillMode(waybillMode);
+ }
+ return waybillMode;
+ }
+
+ /**
+ * 历史运单匹配校验。
+ *
+ * 规则:项目 + 车号 + 运输完成日期 + 客户 + 承运商 + 起运地 + 目的地 + 货物名称;
+ * 命中 1 条自动绑定;命中 ≥2 条需手工指定运单号;已填写运单号时按运单号精确匹配。
+ */
+ private Map validateHistoryWaybillMatches(SettlementImportBatchRequest request) {
+ Map errorMap = new LinkedHashMap<>();
+ List> rows = request.getRows();
+ Set matchedWaybillIds = new HashSet<>();
+ for (Map row : primaryRows(rows)) {
+ int index = rows.indexOf(row);
+ if (index < 0) {
+ continue;
+ }
+ try {
+ Waybill waybill = matchHistoryWaybill(request, row);
+ row.put("waybillNo", waybill.getWaybillNo());
+ row.put("matchedWaybillId", waybill.getId());
+ if (!matchedWaybillIds.add(waybill.getId())) {
+ errorMap.put(index, "匹配到的运单与其他行重复:"
+ + Func.toStr(waybill.getWaybillNo(), String.valueOf(waybill.getId())));
+ }
+ } catch (ServiceException exception) {
+ errorMap.put(index, exception.getMessage());
+ }
+ }
+ return errorMap;
+ }
+
+ private List matchHistoryWaybills(SettlementImportBatchRequest request) {
+ List waybills = new ArrayList<>();
+ for (Map row : primaryRows(request.getRows())) {
+ Waybill waybill = matchHistoryWaybill(request, row);
+ row.put("waybillNo", waybill.getWaybillNo());
+ row.put("matchedWaybillId", waybill.getId());
+ waybills.add(waybill);
+ }
+ return waybills;
+ }
+
+ /**
+ * 历史运单自动匹配:优先手工运单号,否则按业务维度唯一匹配。
+ */
+ private Waybill matchHistoryWaybill(SettlementImportBatchRequest request, Map row) {
+ String manualWaybillNo = stringValue(row, "waybillNo", "运单号", "matchedWaybillNo");
+ if (Func.isNotEmpty(manualWaybillNo)) {
+ return matchHistoryWaybillByNo(request, manualWaybillNo);
+ }
+ String vehicleNo = stringValue(row, "vehicleNo", "*车牌号/航班号/船号/班列号",
+ "车牌号/航班号/船号/班列号", "*车牌号/船班号/船次号/班次号", "车牌号/船班号/船次号/班次号");
+ String actualEndDateText = stringValue(row, "actualEndDate", "*实际完成时间", "实际完成时间", "endDate", "结束时间");
+ String departureAddress = stringValue(row, "departureAddress", "*发货地址", "发货地址");
+ String arrivalAddress = stringValue(row, "arrivalAddress", "*到货地址", "到货地址");
+ String cargoName = stringValue(row, "cargoName", "*货物名称", "货物名称");
+ if (Func.isEmpty(vehicleNo) || Func.isEmpty(actualEndDateText) || Func.isEmpty(departureAddress)
+ || Func.isEmpty(arrivalAddress) || Func.isEmpty(cargoName)) {
+ throw new ServiceException("历史运单需填写车号、运输完成日期、起运地、目的地、货物名称以匹配系统已有运单");
+ }
+ LocalDate endDate = org.springblade.common.excel.LenientDateParser.parseDateLenientlyOrNull(actualEndDateText);
+ if (endDate == null) {
+ throw new ServiceException("运输完成日期格式不正确:" + actualEndDateText);
+ }
+ LambdaQueryWrapper wrapper = Wrappers.lambdaQuery()
+ .eq(Waybill::getIsDeleted, 0)
+ .eq(Waybill::getProjectId, request.getProjectId())
+ .eq(Waybill::getContractId, request.getCustomerContractId())
+ .eq(Waybill::getCarrierContractId, request.getCarrierContractId())
+ .eq(Waybill::getVehicleNo, vehicleNo)
+ .eq(Waybill::getEndDate, endDate)
+ .eq(Waybill::getDepartureAddress, departureAddress)
+ .eq(Waybill::getArrivalAddress, arrivalAddress)
+ .eq(Waybill::getCargoName, cargoName)
+ .orderByDesc(Waybill::getCreateTime)
+ .last("limit 3");
+ List matches = waybillService.list(wrapper);
+ if (Func.isEmpty(matches)) {
+ throw new ServiceException("未匹配到系统已有运单(项目+车号+运输完成日期+客户+承运商+起运地+目的地+货物名称)");
+ }
+ if (matches.size() > 1) {
+ throw new ServiceException("匹配到多条运单,无法自动识别,请手工指定运单号");
+ }
+ return matches.get(0);
+ }
+
+ /** 手工指定运单号时,在当前项目/客户/承运商范围内精确匹配。 */
+ private Waybill matchHistoryWaybillByNo(SettlementImportBatchRequest request, String waybillNo) {
+ List matches = waybillService.list(Wrappers.lambdaQuery()
+ .eq(Waybill::getIsDeleted, 0)
+ .eq(Waybill::getProjectId, request.getProjectId())
+ .eq(Waybill::getContractId, request.getCustomerContractId())
+ .eq(Waybill::getCarrierContractId, request.getCarrierContractId())
+ .eq(Waybill::getWaybillNo, waybillNo)
+ .orderByDesc(Waybill::getCreateTime)
+ .last("limit 2"));
+ if (Func.isEmpty(matches)) {
+ throw new ServiceException("未匹配到运单号「" + waybillNo + "」对应的系统已有运单");
+ }
+ if (matches.size() > 1) {
+ throw new ServiceException("运单号「" + waybillNo + "」匹配到多条运单,请核对后重试");
+ }
+ return matches.get(0);
+ }
+
+ private void prepareSettlementRows(SettlementImportBatchRequest request) {
+ if (request == null || Func.isEmpty(request.getRows())) {
+ return;
+ }
+ List feeItemNames = resolveFeeItemNames(request);
+ for (Map row : request.getRows()) {
+ if (row == null) {
+ continue;
+ }
+ BigDecimal transportFee = decimalValue(row, "transportFee", "*运输费", "运输费", "freight", "运费");
+ if (transportFee == null) {
+ transportFee = BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
+ }
+ Map feeItems = extractFeeItems(row, feeItemNames);
+ BigDecimal feeItemTotal = feeItems.values().stream()
+ .reduce(BigDecimal.ZERO, BigDecimal::add).setScale(2, RoundingMode.HALF_UP);
+ BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
+ row.put("transportFee", transportFee);
+ row.put("feeItems", feeItems);
+ row.put("settlementAmount", settlementAmount);
+ row.put("freight", transportFee);
+ row.put("运费", transportFee);
+ row.put("otherFeeTotal", feeItemTotal);
+ row.put("其他费用合计", feeItemTotal);
+ row.put("freightTotal", settlementAmount);
+ row.put("运费合计", settlementAmount);
+ BigDecimal unitPrice = decimalValue(row, "unitPrice", "运输单价", "单价");
+ if (unitPrice != null) {
+ row.put("unitPrice", unitPrice);
+ row.put("单价", unitPrice);
+ row.put("运输单价", unitPrice);
+ }
+ }
+ request.setFeeItemNames(feeItemNames);
+ }
+
+ @SuppressWarnings("unchecked")
+ private Map extractFeeItems(Map row, List feeItemNames) {
+ Map feeItems = new LinkedHashMap<>();
+ Object existing = row.get("feeItems");
+ if (existing instanceof Map, ?> raw) {
+ raw.forEach((key, value) -> {
+ if (key != null) {
+ feeItems.put(String.valueOf(key), money(decimal(value)));
+ }
+ });
+ }
+ if (Func.isNotEmpty(feeItemNames)) {
+ for (String name : feeItemNames) {
+ if (Func.isEmpty(name) || feeItems.containsKey(name)) {
+ continue;
+ }
+ BigDecimal amount = decimalValue(row, name);
+ if (amount != null) {
+ feeItems.put(name, money(amount));
+ }
+ }
+ }
+ return feeItems;
+ }
+
+ private Map validateSettlementExtras(SettlementImportBatchRequest request) {
+ Map errorMap = new LinkedHashMap<>();
+ Set feeItemCatalog = loadFeeItemNames();
+ List feeItemNames = resolveFeeItemNames(request);
+ List> rows = request.getRows();
+ for (int index = 0; index < rows.size(); index++) {
+ List errors = new ArrayList<>();
+ Map row = rows.get(index);
+ BigDecimal transportFee = decimalValue(row, "transportFee", "*运输费", "运输费", "freight", "运费");
+ if (transportFee == null) {
+ errors.add("*运输费不能为空");
+ } else if (transportFee.compareTo(BigDecimal.ZERO) < 0) {
+ errors.add("*运输费必须大于等于0");
+ }
+ Map feeItems = extractFeeItems(row, feeItemNames);
+ for (String name : feeItems.keySet()) {
+ if (!feeItemCatalog.contains(name)) {
+ errors.add("费用项「" + name + "」未在费用项主数据中匹配");
+ }
+ }
+ for (String name : feeItemNames) {
+ if (Func.isNotEmpty(name) && !feeItemCatalog.contains(name)) {
+ errors.add("费用项「" + name + "」未在费用项主数据中匹配");
+ }
+ }
+ if (!errors.isEmpty()) {
+ errorMap.put(index, String.join("; ", errors));
+ }
+ }
+ return errorMap;
+ }
+
+ private Set loadFeeItemNames() {
+ R> response = sysClient.getFeeItems();
+ if (response == null || !response.isSuccess() || response.getData() == null) {
+ throw new ServiceException("费用项基础档案读取失败,请稍后重试");
+ }
+ return response.getData().stream()
+ .map(FeeItem::getName)
+ .filter(Func::isNotEmpty)
+ .collect(Collectors.toCollection(HashSet::new));
+ }
+
+ private List resolveFeeItemNames(SettlementImportBatchRequest request) {
+ if (request.getFeeItemNames() != null) {
+ return request.getFeeItemNames().stream().filter(Func::isNotEmpty).distinct().toList();
+ }
+ Set names = new LinkedHashSet<>();
+ if (Func.isNotEmpty(request.getRows())) {
+ for (Map row : request.getRows()) {
+ Object feeItems = row.get("feeItems");
+ if (feeItems instanceof Map, ?> raw) {
+ raw.keySet().stream().filter(Objects::nonNull).map(String::valueOf).forEach(names::add);
+ }
+ }
+ }
+ return new ArrayList<>(names);
+ }
+
+ private List> buildFeeRows(List> rows, List waybills) {
+ List> primaryRows = primaryRows(rows);
+ List> feeRows = new ArrayList<>();
+ int size = Math.min(primaryRows.size(), waybills.size());
+ for (int index = 0; index < size; index++) {
+ Map row = primaryRows.get(index);
+ Waybill waybill = waybills.get(index);
+ Map feeRow = new LinkedHashMap<>();
+ feeRow.put("waybillId", waybill.getId());
+ feeRow.put("transportFee", decimalValue(row, "transportFee", "*运输费", "运输费", "freight", "运费"));
+ feeRow.put("unitPrice", decimalValue(row, "unitPrice", "运输单价", "单价"));
+ feeRow.put("feeItems", extractFeeItems(row, resolveFeeItemNamesFromRow(row)));
+ feeRow.put("remark", stringValue(row, "remark", "备注"));
+ feeRows.add(feeRow);
+ }
+ return feeRows;
+ }
+
+ @SuppressWarnings("unchecked")
+ private List resolveFeeItemNamesFromRow(Map row) {
+ Object feeItems = row.get("feeItems");
+ if (!(feeItems instanceof Map, ?> raw)) {
+ return List.of();
+ }
+ return raw.keySet().stream().filter(Objects::nonNull).map(String::valueOf).toList();
+ }
+
+ /** 与运单导入一致:同一运单标识号仅保留首行作为主行。 */
+ private List> primaryRows(List> rows) {
+ List> primary = new ArrayList<>();
+ Set seenIdentifiers = new HashSet<>();
+ for (Map row : rows) {
+ String identifier = stringValue(row, "waybillIdentifier", "同一运单标识号");
+ if (Func.isNotEmpty(identifier)) {
+ if (seenIdentifiers.contains(identifier)) {
+ continue;
+ }
+ seenIdentifiers.add(identifier);
+ }
+ primary.add(row);
+ }
+ return primary;
+ }
+
+ private WaybillImportBatchRequest toWaybillRequest(SettlementImportBatchRequest request) {
+ WaybillImportBatchRequest waybillRequest = new WaybillImportBatchRequest();
+ waybillRequest.setProjectId(request.getProjectId());
+ waybillRequest.setProjectName(request.getProjectName());
+ waybillRequest.setCustomerName(request.getCustomerName());
+ waybillRequest.setContractId(request.getCustomerContractId());
+ waybillRequest.setContractName(request.getCustomerContractName());
+ waybillRequest.setCarrierContractId(request.getCarrierContractId());
+ waybillRequest.setCarrierName(request.getCarrierName());
+ waybillRequest.setCarrierType("承运商");
+ waybillRequest.setImportType("settlement");
+ waybillRequest.setStatus(STATUS_COMPLETED);
+ waybillRequest.setRemark(request.getRemark());
+ waybillRequest.setRows(request.getRows());
+ waybillRequest.setAppend(request.getAppend());
+ return waybillRequest;
+ }
+
+ private SettlementImportBatch buildDraftBatch(SettlementImportBatchRequest request) {
+ SettlementImportBatch batch = new SettlementImportBatch();
+ batch.setBatchNo(Func.isEmpty(request.getId()) ? nextCode() : request.getBatchNo());
+ batch.setSettlementType(Func.isEmpty(request.getSettlementType()) ? TYPE_RECEIVABLE : request.getSettlementType());
+ batch.setSettlementBillType(resolveSettlementBillType(request));
+ batch.setWaybillMode(resolveWaybillMode(request));
+ batch.setImportStatus(STATUS_DRAFT);
+ batch.setRemark(limit(request.getRemark(), 200));
+ batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request)));
+ fillProjectAndContracts(batch, request, false);
+ return batch;
+ }
+
+ private SettlementImportBatch buildBatch(SettlementImportBatchRequest request, String importStatus) {
+ SettlementImportBatch batch = new SettlementImportBatch();
+ batch.setBatchNo(Func.isEmpty(request.getId()) ? nextCode() : request.getBatchNo());
+ batch.setSettlementType(request.getSettlementType());
+ batch.setSettlementBillType(resolveSettlementBillType(request));
+ batch.setWaybillMode(resolveWaybillMode(request));
+ batch.setImportStatus(importStatus);
+ batch.setRemark(limit(request.getRemark(), 200));
+ batch.setFeeItemNamesJson(JsonUtil.toJson(resolveFeeItemNames(request)));
+ batch.setWaybillCount(0);
+ batch.setDetailCount(0);
+ fillProjectAndContracts(batch, request, true);
+ return batch;
+ }
+
+ /** 应收固定正式结算;应付可选 formal/pre,缺省 formal。 */
+ private String resolveSettlementBillType(SettlementImportBatchRequest request) {
+ String settlementType = request == null ? null : request.getSettlementType();
+ String billType = request == null ? null : request.getSettlementBillType();
+ if (!TYPE_PAYABLE.equals(settlementType)) {
+ return BILL_TYPE_FORMAL;
+ }
+ if (Func.isEmpty(billType)) {
+ return BILL_TYPE_FORMAL;
+ }
+ if (!BILL_TYPE_FORMAL.equals(billType) && !BILL_TYPE_PRE.equals(billType)) {
+ throw new ServiceException("结算单类型仅支持 formal 或 pre");
+ }
+ return billType;
+ }
+
+ private void fillProjectAndContracts(SettlementImportBatch batch, SettlementImportBatchRequest request, boolean required) {
+ if (Func.isEmpty(request.getProjectId())) {
+ if (required) {
+ throw new ServiceException("请选择项目");
+ }
+ return;
+ }
+ ProjectApply project = projectApplyService.getById(request.getProjectId());
+ if (project == null || !List.of("approved", "change_approved").contains(project.getApprovalStatus())) {
+ throw new ServiceException("仅允许选择审核通过或变更审核通过的项目");
+ }
+ batch.setProjectId(project.getId());
+ batch.setProjectName(project.getProjectName());
+ if (Func.isNotEmpty(request.getCustomerContractId()) || required) {
+ ContractManage customerContract = requireContract(request.getCustomerContractId(), "客户合同");
+ if (!"客户合同".equals(customerContract.getContractCategory())) {
+ throw new ServiceException("客户合同类别不正确");
+ }
+ batch.setCustomerContractId(customerContract.getId());
+ batch.setCustomerContractName(customerContract.getContractName());
+ batch.setCustomerName(Func.isEmpty(request.getCustomerName())
+ ? customerContract.getPartyA() : request.getCustomerName());
+ }
+ if (Func.isNotEmpty(request.getCarrierContractId()) || required) {
+ ContractManage carrierContract = requireContract(request.getCarrierContractId(), "承运商合同");
+ if (!"承运商合同".equals(carrierContract.getContractCategory())) {
+ throw new ServiceException("承运商合同类别不正确");
+ }
+ batch.setCarrierContractId(carrierContract.getId());
+ batch.setCarrierContractName(carrierContract.getContractName());
+ batch.setCarrierName(Func.isEmpty(request.getCarrierName())
+ ? carrierContract.getPartyB() : request.getCarrierName());
+ }
+ if (Func.isEmpty(batch.getCustomerName()) && Func.isNotEmpty(request.getCustomerName())) {
+ batch.setCustomerName(request.getCustomerName());
+ }
+ if (Func.isEmpty(batch.getCarrierName()) && Func.isNotEmpty(request.getCarrierName())) {
+ batch.setCarrierName(request.getCarrierName());
+ }
+ }
+
+ private ContractManage requireContract(Long contractId, String label) {
+ if (Func.isEmpty(contractId)) {
+ throw new ServiceException("请选择" + label);
+ }
+ ContractManage contract = contractManageService.getById(contractId);
+ if (contract == null || Objects.equals(contract.getIsDeleted(), 1)) {
+ throw new ServiceException(label + "不存在");
+ }
+ if (!List.of("approved", "change_approved").contains(contract.getApprovalStatus())
+ || "terminated".equals(contract.getContractStage())) {
+ throw new ServiceException(label + "未审核完成或已终止");
+ }
+ return contract;
+ }
+
+ private synchronized String nextCode() {
+ String prefix = "DJS" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE);
+ Long maxSequence = baseMapper.selectMaxDailySequence(AuthUtil.getTenantId(), prefix);
+ return prefix + String.format("%04d", (maxSequence == null ? 0 : maxSequence) + 1);
+ }
+
+ private SettlementImportBatchVO toVO(SettlementImportBatch batch) {
+ SettlementImportBatchVO vo = BeanUtil.copyProperties(batch, SettlementImportBatchVO.class);
+ if (vo == null) {
+ throw new ServiceException("导入结算单批次数据转换失败");
+ }
+ vo.setCreateUserName(UserCache.getUserRealName(batch.getCreateUser()));
+ vo.setUpdateUserName(UserCache.getUserRealName(batch.getUpdateUser()));
+ vo.setImportStatusName(importStatusName(batch.getImportStatus()));
+ vo.setSettlementTypeName(settlementTypeName(batch.getSettlementType()));
+ vo.setSettlementBillTypeName(settlementBillTypeName(batch.getSettlementBillType()));
+ vo.setWaybillModeName(waybillModeName(batch.getWaybillMode()));
+ if (Func.isNotEmpty(batch.getFeeItemNamesJson())) {
+ vo.setFeeItemNames(JsonUtil.parseArray(batch.getFeeItemNamesJson(), String.class));
+ }
+ return vo;
+ }
+
+ private String importStatusName(String status) {
+ if (STATUS_DRAFT.equals(status)) {
+ return "草稿";
+ }
+ if (STATUS_COMPLETED.equals(status)) {
+ return "已完成";
+ }
+ if (STATUS_VOIDED.equals(status)) {
+ return "已作废";
+ }
+ return Func.toStr(status);
+ }
+
+ private String settlementTypeName(String settlementType) {
+ if (TYPE_RECEIVABLE.equals(settlementType)) {
+ return "应收";
+ }
+ if (TYPE_PAYABLE.equals(settlementType)) {
+ return "应付";
+ }
+ return Func.toStr(settlementType);
+ }
+
+ private String settlementBillTypeName(String settlementBillType) {
+ if (BILL_TYPE_PRE.equals(settlementBillType)) {
+ return "预结算单";
+ }
+ return "正式结算单";
+ }
+
+ private String waybillModeName(String waybillMode) {
+ if (WAYBILL_MODE_HISTORY.equals(waybillMode)) {
+ return "历史运单";
+ }
+ return "新运单";
+ }
+
+ private void mergeErrors(List errors, Map settlementErrors) {
+ Map byIndex = new LinkedHashMap<>();
+ for (WaybillImportRowErrorVO error : errors) {
+ byIndex.put(error.getRowIndex(), error);
+ }
+ for (Map.Entry entry : settlementErrors.entrySet()) {
+ WaybillImportRowErrorVO existing = byIndex.get(entry.getKey());
+ if (existing == null) {
+ WaybillImportRowErrorVO error = new WaybillImportRowErrorVO();
+ error.setRowIndex(entry.getKey());
+ error.setErrorMessage(entry.getValue());
+ errors.add(error);
+ } else {
+ existing.setErrorMessage(existing.getErrorMessage() + "; " + entry.getValue());
+ }
+ }
+ }
+
+ @SuppressWarnings("unchecked")
+ private SettlementImportTemplateExcel mapToExcel(Map row) {
+ SettlementImportTemplateExcel excel = new SettlementImportTemplateExcel();
+ excel.setSerialNumber(intValue(row.get("serialNumber")));
+ excel.setOriginalNo(stringValue(row, "originalNo", "原始单号"));
+ excel.setLoadingIdentifier(stringValue(row, "loadingIdentifier", "配载标识号"));
+ excel.setVehicleNo(stringValue(row, "vehicleNo", "*车牌号/船班号/船次号/班次号"));
+ excel.setTransportType(stringValue(row, "transportType", "*运输方式", "运输方式"));
+ excel.setDriverName(stringValue(row, "driverName", "司机/船长姓名"));
+ excel.setDriverPhone(stringValue(row, "driverPhone", "司机/船长手机号"));
+ excel.setDepartureAddress(stringValue(row, "departureAddress", "*发货地址", "发货地址"));
+ excel.setDepartureContact(stringValue(row, "departureContact", "发货联系人"));
+ excel.setDeparturePhone(stringValue(row, "departurePhone", "发货联系人电话"));
+ excel.setArrivalAddress(stringValue(row, "arrivalAddress", "*到货地址", "到货地址"));
+ excel.setArrivalContact(stringValue(row, "arrivalContact", "收货联系人"));
+ excel.setArrivalPhone(stringValue(row, "arrivalPhone", "收货联系人电话"));
+ excel.setCargoName(stringValue(row, "cargoName", "*货物名称", "货物名称"));
+ excel.setCargoType(stringValue(row, "cargoType", "*货物类型", "货物类型"));
+ excel.setPackageType(stringValue(row, "packageType", "包装"));
+ excel.setQuantity(decimalValue(row, "quantity", "*数量", "数量"));
+ excel.setQuantityUnit(stringValue(row, "quantityUnit", "*数量单位", "数量单位"));
+ excel.setSpecification(stringValue(row, "specification", "规格"));
+ excel.setModel(stringValue(row, "model", "型号"));
+ excel.setMileage(decimalValue(row, "mileage", "里程(km)", "里程"));
+ excel.setUnitPrice(decimalValue(row, "unitPrice", "运输单价", "单价"));
+ excel.setTransportFee(decimalValue(row, "transportFee", "*运输费", "运输费"));
+ Map feeItems = extractFeeItems(row, List.of("装卸费", "过路费"));
+ excel.setLoadingFee(feeItems.get("装卸费"));
+ excel.setTollFee(feeItems.get("过路费"));
+ excel.setSettlementAmount(decimalValue(row, "settlementAmount", "结算金额"));
+ excel.setActualStartDate(stringValue(row, "actualStartDate", "*实际发货时间", "实际发货时间"));
+ excel.setActualEndDate(stringValue(row, "actualEndDate", "*实际完成时间", "实际完成时间"));
+ excel.setPlanStartDate(stringValue(row, "planStartDate", "预计发货时间"));
+ excel.setPlanEndDate(stringValue(row, "planEndDate", "预计完成时间"));
+ excel.setRemark(stringValue(row, "remark", "备注"));
+ excel.setWaybillIdentifier(stringValue(row, "waybillIdentifier", "同一运单标识号"));
+ return excel;
+ }
+
+ private Map option(Object id, String nameKey, Object nameValue) {
+ return option(id, nameKey, nameValue, null, null);
+ }
+
+ private Map option(Object id, String nameKey, Object nameValue, String extraKey, Object extraValue) {
+ return option(id, nameKey, nameValue, extraKey, extraValue, null, null, null, null);
+ }
+
+ private Map option(Object id, String nameKey, Object nameValue,
+ String extraKey, Object extraValue,
+ String thirdKey, Object thirdValue,
+ String fourthKey, Object fourthValue) {
+ Map item = new LinkedHashMap<>();
+ item.put("id", id);
+ item.put(nameKey, nameValue);
+ item.put("name", nameValue);
+ if (Func.isNotEmpty(extraKey)) {
+ item.put(extraKey, extraValue);
+ }
+ if (Func.isNotEmpty(thirdKey)) {
+ item.put(thirdKey, thirdValue);
+ }
+ if (Func.isNotEmpty(fourthKey)) {
+ item.put(fourthKey, fourthValue);
+ }
+ return item;
+ }
+
+ private String stringValue(Map row, String field, String... aliases) {
+ Object value = row.get(field);
+ if (value != null && !String.valueOf(value).trim().isEmpty()) {
+ return String.valueOf(value).trim();
+ }
+ for (String alias : aliases) {
+ Object aliasValue = row.get(alias);
+ if (aliasValue != null && !String.valueOf(aliasValue).trim().isEmpty()) {
+ return String.valueOf(aliasValue).trim();
+ }
+ }
+ return null;
+ }
+
+ private BigDecimal decimalValue(Map row, String field, String... aliases) {
+ String value = stringValue(row, field, aliases);
+ if (Func.isEmpty(value) || "-1".equals(value)) {
+ return null;
+ }
+ try {
+ return new BigDecimal(value).setScale(2, RoundingMode.HALF_UP);
+ } catch (NumberFormatException exception) {
+ return null;
+ }
+ }
+
+ private BigDecimal decimal(Object value) {
+ if (value == null || "".equals(value)) {
+ return BigDecimal.ZERO;
+ }
+ if (value instanceof BigDecimal bigDecimal) {
+ return bigDecimal;
+ }
+ try {
+ return new BigDecimal(String.valueOf(value).trim()).setScale(2, RoundingMode.HALF_UP);
+ } catch (Exception exception) {
+ return BigDecimal.ZERO;
+ }
+ }
+
+ private BigDecimal money(BigDecimal value) {
+ return value == null ? BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP)
+ : value.setScale(2, RoundingMode.HALF_UP);
+ }
+
+ private Integer intValue(Object value) {
+ if (value == null) {
+ return null;
+ }
+ if (value instanceof Number number) {
+ return number.intValue();
+ }
+ try {
+ return new BigDecimal(String.valueOf(value).trim()).intValue();
+ } catch (NumberFormatException exception) {
+ return null;
+ }
+ }
+
+ private String limit(String value, int max) {
+ if (value == null) {
+ return null;
+ }
+ String text = value.trim();
+ return text.length() <= max ? text : text.substring(0, max);
+ }
+
+ private String mergeRemark(String original, String reason) {
+ if (Func.isEmpty(reason)) {
+ return original;
+ }
+ String merged = Func.isEmpty(original) ? "作废原因:" + reason : original + ";作废原因:" + reason;
+ return limit(merged, 200);
+ }
+}
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java
index c850d180..fe4a5160 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java
@@ -1211,15 +1211,14 @@ public class WaybillServiceImpl extends BaseServiceImpl
@Override
public List exportWaybill(WaybillVO waybill, String ids) {
- TransportBusinessSupport.validateAllDept(waybill.getAllDept(), "运单管理");
List idList = Func.toLongList(ids);
LambdaQueryWrapper queryWrapper;
if (Func.isNotEmpty(idList)) {
queryWrapper = Wrappers.lambdaQuery().eq(Waybill::getIsDeleted, 0);
- if (!Objects.equals(waybill.getAllDept(), 1)) {
- queryWrapper.eq(Waybill::getDeptId, TransportBusinessSupport.currentDeptId("运单管理"));
- } else if (Func.isNotEmpty(waybill.getDeptId())) {
- queryWrapper.eq(Waybill::getDeptId, waybill.getDeptId());
+ List deptIdList = TransportBusinessSupport.resolveDeptFilterIds(
+ waybill.getAllDept(), waybill.getDeptId(), "运单管理");
+ if (Func.isNotEmpty(deptIdList)) {
+ queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0));
}
queryWrapper.in(Waybill::getId, idList).orderByDesc(Waybill::getCreateTime);
} else {
@@ -1604,12 +1603,12 @@ public class WaybillServiceImpl extends BaseServiceImpl
}
private LambdaQueryWrapper buildQuery(WaybillVO waybill) {
- TransportBusinessSupport.validateAllDept(waybill.getAllDept(), "运单管理");
LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(Waybill::getIsDeleted, 0);
- if (!Objects.equals(waybill.getAllDept(), 1)) {
- queryWrapper.eq(Waybill::getDeptId, TransportBusinessSupport.currentDeptId("运单管理"));
- } else if (Func.isNotEmpty(waybill.getDeptId())) {
- queryWrapper.eq(Waybill::getDeptId, waybill.getDeptId());
+ // 与常用线路等模块一致:超管默认不按组织过滤;普通用户 allDept!=1 时强制当前组织
+ List deptIdList = TransportBusinessSupport.resolveDeptFilterIds(
+ waybill.getAllDept(), waybill.getDeptId(), "运单管理");
+ if (Func.isNotEmpty(deptIdList)) {
+ queryWrapper.eq(Waybill::getDeptId, deptIdList.get(0));
}
if (Func.isNotEmpty(waybill.getWaybillNo())) {
queryWrapper.eq(Waybill::getWaybillNo, waybill.getWaybillNo());
diff --git a/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql b/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql
index 1850aa03..2f9c3a36 100644
--- a/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql
+++ b/doc/sql/transport/blade_customer_archive_kingdee_20260917.sql
@@ -9,8 +9,16 @@ ALTER TABLE `blade_customer_archive`
CREATE INDEX idx_kingdee_status ON blade_customer_archive (kingdee_status);
--- 列表「同步金蝶」按钮权限(审批通过后推送失败的重推入口;parent_id 通过菜单 code 关联,避免写死 id)
+-- 列表「同步金蝶」按钮权限(审批通过且未推送/推送失败时可点;parent_id 通过菜单 code 关联)
INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted)
-SELECT 2075431800000000001, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 1, 2, 0, 1, '', '', 0
+SELECT 2077600000000000011, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 10, 2, 0, 1, NULL, '', 0
FROM blade_menu m
-WHERE m.code = 'customer_archive' AND m.is_deleted = 0;
+WHERE m.code = 'customer_archive' AND m.is_deleted = 0
+LIMIT 1
+ON DUPLICATE KEY UPDATE
+ parent_id = VALUES(parent_id),
+ name = VALUES(name),
+ alias = VALUES(alias),
+ sort = VALUES(sort),
+ category = VALUES(category),
+ is_deleted = 0;
diff --git a/doc/sql/transport/blade_customer_archive_sync_menu_fix_20261015.sql b/doc/sql/transport/blade_customer_archive_sync_menu_fix_20261015.sql
new file mode 100644
index 00000000..b80c088a
--- /dev/null
+++ b/doc/sql/transport/blade_customer_archive_sync_menu_fix_20261015.sql
@@ -0,0 +1,22 @@
+-- 修复「同步金蝶」按钮菜单:按 code 幂等补齐/纠正,避免权限勾了但不生效
+-- 执行后需:角色重新勾选「同步金蝶」→ 用户重新登录
+
+INSERT INTO blade_menu (id, parent_id, code, name, alias, path, source, sort, category, action, is_open, component, remark, is_deleted)
+SELECT 2077600000000000011, m.id, 'customer_archive_sync', '同步金蝶', 'customer_archive_sync', '', '', 10, 2, 0, 1, NULL, '', 0
+FROM blade_menu m
+WHERE m.code = 'customer_archive' AND m.is_deleted = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM blade_menu x WHERE x.code = 'customer_archive_sync' AND x.is_deleted = 0
+ )
+LIMIT 1;
+
+UPDATE blade_menu child
+INNER JOIN blade_menu parent ON parent.code = 'customer_archive' AND parent.is_deleted = 0
+SET
+ child.parent_id = parent.id,
+ child.name = '同步金蝶',
+ child.alias = 'customer_archive_sync',
+ child.category = 2,
+ child.sort = 10,
+ child.is_deleted = 0
+WHERE child.code = 'customer_archive_sync';
diff --git a/doc/sql/transport/blade_settlement_import_batch_20261015.sql b/doc/sql/transport/blade_settlement_import_batch_20261015.sql
new file mode 100644
index 00000000..6578be14
--- /dev/null
+++ b/doc/sql/transport/blade_settlement_import_batch_20261015.sql
@@ -0,0 +1,60 @@
+-- 导入结算单批次表 + 菜单
+CREATE TABLE IF NOT EXISTS `blade_settlement_import_batch` (
+ `id` bigint NOT NULL COMMENT '主键',
+ `tenant_id` varchar(12) NOT NULL DEFAULT '000000' COMMENT '租户ID',
+ `batch_no` varchar(64) NOT NULL COMMENT '批次号 DJS+yyyyMMdd+序号',
+ `project_id` bigint DEFAULT NULL COMMENT '项目ID',
+ `project_name` varchar(200) DEFAULT NULL COMMENT '项目名称',
+ `settlement_type` varchar(30) NOT NULL COMMENT 'receivable/payable',
+ `settlement_bill_type` varchar(20) NOT NULL DEFAULT 'formal' COMMENT '结算单类型 formal/pre',
+ `customer_contract_id` bigint DEFAULT NULL COMMENT '客户合同ID',
+ `customer_contract_name` varchar(200) DEFAULT NULL COMMENT '客户合同名称',
+ `customer_name` varchar(200) DEFAULT NULL COMMENT '客户名称',
+ `carrier_contract_id` bigint DEFAULT NULL COMMENT '承运商合同ID',
+ `carrier_contract_name` varchar(200) DEFAULT NULL COMMENT '承运商合同名称',
+ `carrier_name` varchar(200) DEFAULT NULL COMMENT '承运商名称',
+ `waybill_mode` varchar(20) DEFAULT 'new' COMMENT '运单模式 new/history',
+ `import_status` varchar(32) NOT NULL COMMENT 'draft/completed/voided',
+ `waybill_count` int DEFAULT 0 COMMENT '运单数量',
+ `detail_count` int DEFAULT 0 COMMENT '明细数量',
+ `formal_settlement_id` bigint DEFAULT NULL COMMENT '正式结算单ID',
+ `formal_settlement_no` varchar(100) DEFAULT NULL COMMENT '正式结算单号',
+ `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID',
+ `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号',
+ `fee_item_names_json` longtext COMMENT '动态费用列名JSON数组',
+ `remark` varchar(200) DEFAULT NULL COMMENT '备注',
+ `waybill_import_batch_id` bigint DEFAULT NULL COMMENT '关联底层运单导入批次,可空',
+ `create_user` bigint DEFAULT NULL COMMENT '创建人',
+ `create_dept` bigint DEFAULT NULL COMMENT '创建部门',
+ `create_time` datetime DEFAULT NULL COMMENT '创建时间',
+ `update_user` bigint DEFAULT NULL COMMENT '更新人',
+ `update_time` datetime DEFAULT NULL COMMENT '更新时间',
+ `status` int DEFAULT 1 COMMENT '业务状态',
+ `is_deleted` int DEFAULT 0 COMMENT '是否已删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_settlement_import_batch_no` (`tenant_id`,`batch_no`),
+ KEY `idx_settlement_import_project` (`project_id`),
+ KEY `idx_settlement_import_formal` (`formal_settlement_id`),
+ KEY `idx_settlement_import_pre` (`pre_settlement_id`),
+ KEY `idx_settlement_import_waybill_batch` (`waybill_import_batch_id`),
+ KEY `idx_settlement_import_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='导入结算单批次';
+
+-- 已建表环境增量字段(列已存在可忽略报错)
+ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `settlement_bill_type` varchar(20) NOT NULL DEFAULT 'formal' COMMENT '结算单类型 formal/pre' AFTER `settlement_type`;
+ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_id` bigint DEFAULT NULL COMMENT '预结算单ID' AFTER `formal_settlement_no`;
+ALTER TABLE `blade_settlement_import_batch` ADD COLUMN `pre_settlement_no` varchar(100) DEFAULT NULL COMMENT '预结算单号' AFTER `pre_settlement_id`;
+ALTER TABLE `blade_settlement_import_batch` ADD KEY `idx_settlement_import_pre` (`pre_settlement_id`);
+
+-- 运单关联导入结算批次(可选,若列已存在可忽略报错)
+ALTER TABLE `blade_waybill` ADD COLUMN `settlement_import_batch_id` bigint DEFAULT NULL COMMENT '导入结算单批次ID';
+ALTER TABLE `blade_waybill` ADD KEY `idx_waybill_settlement_import_batch_id` (`settlement_import_batch_id`);
+
+INSERT INTO `blade_menu` (`id`,`parent_id`,`code`,`name`,`alias`,`path`,`source`,`sort`,`category`,`action`,`is_open`,`component`,`remark`,`is_deleted`) VALUES
+(2090000000000002080,2090000000000001000,'settlement_import','导入结算单','settlement_import','/settlement/settlement-import','iconfont icon-caidanguanli',6,1,0,1,NULL,'',0),
+(2090000000000002081,2090000000000002080,'settlement_import_view','查看','settlement_import_view','','',1,2,0,1,NULL,'',0),
+(2090000000000002082,2090000000000002080,'settlement_import_add','新增','settlement_import_add','','',2,2,0,1,NULL,'',0),
+(2090000000000002083,2090000000000002080,'settlement_import_detail','详情','settlement_import_detail','','',3,2,0,1,NULL,'',0),
+(2090000000000002084,2090000000000002080,'settlement_import_void','作废','settlement_import_void','','',4,2,0,1,NULL,'',0),
+(2090000000000002085,2090000000000002080,'settlement_import_template','下载模板','settlement_import_template','','',5,2,0,1,NULL,'',0)
+ON DUPLICATE KEY UPDATE `name`=VALUES(`name`),`path`=VALUES(`path`),`is_deleted`=0;