From 0c05c3c385aabee59f49606eb1202b3c8568e159 Mon Sep 17 00:00:00 2001
From: weicw <594098497@qq.com>
Date: Sat, 24 Oct 2026 21:29:16 +0800
Subject: [PATCH 03/15] =?UTF-8?q?:sparkles:=20=E8=BF=90=E5=8D=95=E9=87=8C?=
=?UTF-8?q?=E7=A8=8B=E6=94=B9=E4=B8=BA=E6=9C=AB=E4=BD=8D=E6=89=93=E5=8D=A1?=
=?UTF-8?q?=E9=87=87=E9=9B=86=EF=BC=8C=E5=90=AB=E9=87=8C=E7=A8=8B=E8=AE=A1?=
=?UTF-8?q?=E8=B4=B9=E6=97=B6=E5=8F=B8=E6=9C=BA=E5=BF=85=E5=A1=AB?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../transport/pojo/dto/NodeSubmitDTO.java | 9 ++
.../dto/WaybillPunchRecordSaveRequest.java | 8 ++
.../transport/pojo/vo/DriverPunchNodeVO.java | 12 +++
.../pojo/vo/WaybillPunchRecordItemVO.java | 13 +++
.../IReceivablePayableDetailService.java | 17 ++++
.../transport/service/IWaybillService.java | 11 +++
.../impl/DriverWaybillServiceImpl.java | 23 +++++-
.../ReceivablePayableDetailServiceImpl.java | 74 ++++++++++++++++-
.../service/impl/WaybillServiceImpl.java | 82 +++++++++++++++++--
.../support/TransportBusinessSupport.java | 30 +++++++
.../impl/DriverNodePunchPhotoTest.java | 3 +-
.../impl/DriverPunchFinishFlagTest.java | 3 +-
.../service/impl/DriverPunchNodeCardTest.java | 3 +-
.../impl/DriverTripAcceptRejectTest.java | 3 +-
.../service/impl/DriverTripCardTest.java | 3 +-
.../impl/DriverWaybillAutoCompleteTest.java | 3 +-
.../impl/DriverWaybillCardLocationTest.java | 3 +-
.../impl/DriverWaybillNodePunchTest.java | 3 +-
.../impl/DriverWaybillOwnershipTest.java | 3 +-
.../impl/DriverWaybillRejectDetachTest.java | 3 +-
.../impl/DriverWaybillTabStatusTest.java | 3 +-
.../impl/WaybillConfirmReturnTest.java | 11 ++-
.../impl/WaybillPunchRecordListTest.java | 43 ++++++++++
23 files changed, 341 insertions(+), 25 deletions(-)
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java
index 0ba2fba4..46720941 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java
@@ -54,6 +54,15 @@ public class NodeSubmitDTO implements Serializable {
@Schema(description = "数量(件)")
private String quantity;
+ /**
+ * 里程(公里):只由末位打卡节点采集,运单计费规则含里程时必填。
+ *
+ * 用字符串接收——旧版小程序不传、新版按输入框传字符串,两种都要能进;
+ * 非末位节点收到该值时忽略,不报错。
+ */
+ @Schema(description = "里程(公里),末位打卡节点采集")
+ private String mileage;
+
@Schema(description = "备注")
private String remark;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java
index 763f8f31..af45b2df 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/WaybillPunchRecordSaveRequest.java
@@ -27,6 +27,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
+import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@@ -84,6 +85,13 @@ public class WaybillPunchRecordSaveRequest implements Serializable {
@Schema(description = "数量(件)")
private String quantity;
+ /**
+ * 里程(公里):null 表示不改动运单里程;只在末位打卡节点上生效。
+ * 管理端为选填,留空即保持原值。
+ */
+ @Schema(description = "里程(公里),为空表示不改")
+ private BigDecimal mileage;
+
@Schema(description = "备注")
private String remark;
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java
index eaeec543..7533ce2e 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java
@@ -11,6 +11,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
+import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@@ -94,6 +95,17 @@ public class DriverPunchNodeVO implements Serializable {
@Schema(description = "数量(件)")
private String quantity;
+ /**
+ * 本节点是否需要采集里程:末位可打卡节点、且运单计费规则含里程(应收或应付任一侧)。
+ *
+ * 判据与 finishOnPunch、自动完成同源的「末位」定义;客户端只认这个标记,不自己推末位。
+ */
+ @Schema(description = "本节点是否需要采集里程(末位可打卡节点且计费规则含里程)")
+ private Boolean needMileage;
+
+ @Schema(description = "运单里程(公里),needMileage 为真时用于回显")
+ private BigDecimal mileage;
+
@Schema(description = "已上传凭证图(已打卡回显)")
private List photos = new ArrayList<>();
diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java
index 140d6bc8..62f089ba 100644
--- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java
+++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java
@@ -9,6 +9,7 @@ import lombok.Data;
import java.io.Serial;
import java.io.Serializable;
+import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
@@ -55,6 +56,18 @@ public class WaybillPunchRecordItemVO implements Serializable {
@Schema(description = "数量")
private String quantity;
+ /**
+ * 本条记录是否采集里程:末位打卡节点、且运单计费规则含里程。只有该条为真时管理端才展示里程项。
+ */
+ @Schema(description = "本条是否需要采集里程(末位打卡节点且计费规则含里程)")
+ private Boolean needMileage;
+
+ @Schema(description = "运单里程(公里),needMileage 为真时用于回显")
+ private BigDecimal mileage;
+
+ @Schema(description = "里程是否已冻结(运单已生成结算单,只能看不能改)")
+ private Boolean mileageLocked;
+
@Schema(description = "备注")
private String remark;
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 82592347..c472b14e 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
@@ -84,12 +84,29 @@ public interface IReceivablePayableDetailService extends BaseService waybillIds);
+ /**
+ * 同上,但按 {@code mileageOverrides} 覆盖运单里程后再算。
+ *
+ * 生成走独立事务(REQUIRES_NEW),读不到调用方事务里尚未提交的里程更新,
+ * 由调用方把本次刚写入的里程显式传进来,否则按里程计费会读到旧值或 null(算成 0 元)。
+ */
+ void generateForCompletedWaybills(List waybillIds, Map mileageOverrides);
+
/**
* 运单维护里程后,同步未挂结算单的应收应付明细里程;
* 对按里程、按吨·公里计费的费用行按新里程重新计算。
*/
void syncMileageFromWaybill(Long waybillId, BigDecimal mileage);
+ /**
+ * 运单的计费规则是否包含里程:客户合同(应收)与承运商合同(应付)任一侧命中里程类计费要素即为真。
+ *
+ * 判据与费用生成同源(同一份方案解析与规则命中),只读、不产生任何写操作;
+ * 无合同、方案不匹配、规则不命中一律返回 false。
+ *
+ */
+ boolean requiresMileage(Waybill waybill);
+
/**
* 批量导入完成运单后按合同系统计费模式生成应收、应付明细。
* 与导入事务共用同一事务,运单尚未提交,因此直接传入实体而非主键。
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java
index e87bc79a..53e50ad5 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IWaybillService.java
@@ -35,6 +35,7 @@ import org.springblade.transport.pojo.vo.WaybillVO;
import org.springblade.transport.pojo.dto.WaybillMileageRequest;
import org.springblade.transport.pojo.dto.WaybillPunchRecordSaveRequest;
+import java.math.BigDecimal;
import java.util.List;
/**
@@ -97,6 +98,16 @@ public interface IWaybillService extends BaseService {
WaybillVO copy(Long id);
boolean changeRoute(Waybill waybill);
boolean maintainMileage(WaybillMileageRequest request);
+
+ /**
+ * 只写运单里程(定向更新),用于打卡链路采集里程。
+ *
+ * 不做 maintainMileage 的「仅已完成 / 未生成结算单」前置——末位打卡发生在完成之前;
+ * 结算状态由调用方按各自入口的规则判定,也不触发费用重算,由调用方决定是否同步。
+ *
+ */
+ void savePunchMileage(Long waybillId, BigDecimal mileage);
+
boolean cancel(Long id);
boolean reassign(Waybill waybill);
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java
index 428c4398..b4817c57 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java
@@ -52,6 +52,7 @@ import org.springblade.transport.pojo.entity.WaybillNodePunch;
import org.springblade.transport.service.ITransportVehicleService;
import org.springblade.transport.support.CertificationGate;
import org.springblade.transport.support.LiveProcessConfigSupport;
+import org.springblade.transport.support.TransportBusinessSupport;
import org.springblade.transport.support.VehiclePlateSupport;
import org.springblade.transport.support.WaybillCargoSupport;
import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO;
@@ -70,6 +71,7 @@ import org.springblade.transport.pojo.vo.WaybillTrackVO;
import org.springblade.transport.service.IDriverService;
import org.springblade.transport.service.IDriverWaybillService;
import org.springblade.transport.service.IProcessConfigService;
+import org.springblade.transport.service.IReceivablePayableDetailService;
import org.springblade.transport.service.ITripQueryService;
import org.springblade.transport.service.IWaybillService;
import org.springblade.transport.support.WaybillProcessSupport;
@@ -157,6 +159,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
private final WaybillNodePunchMapper nodePunchMapper;
private final ExceptionDisposalMapper exceptionDisposalMapper;
private final ITripQueryService tripQueryService;
+ private final IReceivablePayableDetailService receivablePayableDetailService;
@Override
public DriverWaybillCardVO currentTask() {
@@ -497,6 +500,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
: legacyNodePunchPhotos(dto.getPhotos(), WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes"));
assertNodePunchPhotos(photoItems, nodeCfg, typedPhotoItems);
+ // 里程:末位可打卡节点 + 运单计费规则含里程(应收或应付任一侧)时必填。
+ // 必须在完成判定之前落库——应收应付在本次打卡触发的完成里生成,读到的得是新里程。
+ String submitNodeKey = WaybillProcessSupport.nodeKey(nodeCfg);
+ if (WaybillProcessSupport.isLastDriverPunchNode(processJson, submitNodeKey)
+ && receivablePayableDetailService.requiresMileage(waybill)) {
+ BigDecimal mileage = TransportBusinessSupport.requireMileage(dto.getMileage());
+ waybillService.savePunchMileage(waybill.getId(), mileage);
+ waybill.setMileage(mileage);
+ }
+
Date now = new Date();
WaybillNodePunch punch = new WaybillNodePunch();
punch.setWaybillId(waybill.getId());
@@ -1564,6 +1577,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
Map latestNodePunchByCode = latestNodePunchMap(waybill.getId());
// 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打
String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus());
+ // 计费规则是否含里程与节点无关,整条运单算一次;放在循环外避免逐节点回查合同。
+ boolean lastNodeNeedsMileage = receivablePayableDetailService.requiresMileage(waybill);
for (Map cfg : punchConfigs) {
boolean isTransit = WaybillProcessSupport.isTransitNodePublic(cfg);
@@ -1622,9 +1637,15 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
// 末位卡上打一下就完成这张运单:判据与下方 submitNode 的自动完成同一份
// (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认),客户端只按它把按钮文案改成「完成运单」。
// 两处判据必须同源,否则会出现「按钮写着完成运单、打下去这张运单没完成」。
- vo.setFinishOnPunch(WaybillProcessSupport.isLastDriverPunchNode(processJson, vo.getKey())
+ boolean lastPunchNode = WaybillProcessSupport.isLastDriverPunchNode(processJson, vo.getKey());
+ vo.setFinishOnPunch(lastPunchNode
&& WaybillProcessSupport.isDeliveryNode(vo.getKey(), vo.getName())
&& WaybillProcessSupport.returnNeedsNoConfirmation(processJson));
+ // 里程同样只在末位卡采集:末位判定与上面同一个变量,避免两处各算一次走偏。
+ if (lastPunchNode && lastNodeNeedsMileage) {
+ vo.setNeedMileage(true);
+ vo.setMileage(waybill.getMileage());
+ }
if (done) {
if (punched.getPunchTime() != null) {
LocalDateTime ldt = punched.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java
index c9865f90..6bdbdcdd 100644
--- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java
+++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ReceivablePayableDetailServiceImpl.java
@@ -200,6 +200,60 @@ public class ReceivablePayableDetailServiceImpl
return result;
}
+ @Override
+ public boolean requiresMileage(Waybill waybill) {
+ if (waybill == null) {
+ return false;
+ }
+ ContractManage receivableContract = Func.isEmpty(waybill.getContractId())
+ ? null : contractManageService.getById(waybill.getContractId());
+ if (hasMileageBillingRule(receivableContract, waybill)) {
+ return true;
+ }
+ ContractManage payableContract;
+ try {
+ payableContract = resolveCarrierContract(waybill);
+ } catch (ServiceException exception) {
+ // 承运商合同数据有问题时不在打卡页抛错:费用生成会带着明确文案拒绝,
+ // 打卡页只回答"这一趟要不要填里程",不该被合同脏数据卡住。
+ log.warn("判定运单是否含里程计费时承运商合同解析失败,按不含里程处理,waybillId:{},原因:{}",
+ waybill.getId(), exception.getMessage());
+ return false;
+ }
+ return hasMileageBillingRule(payableContract, waybill);
+ }
+
+ /**
+ * 这套合同命中的计费规则里,是否有里程类要素且规则本身命中该运单。
+ *
+ * 方案解析与规则命中都复用费用生成那两条私有方法,保证「打卡时要里程」与「结算时按里程算」同一口径。
+ */
+ private boolean hasMileageBillingRule(ContractManage contract, Waybill waybill) {
+ if (contract == null || waybill == null) {
+ return false;
+ }
+ Map plan = resolveDefaultBillingPlan(
+ parseList(contract.getBillingPlanJson()), waybill.getTransportType());
+ if (plan == null || !(plan.get("rules") instanceof List> rules)) {
+ return false;
+ }
+ for (Object value : rules) {
+ if (!(value instanceof Map, ?> raw)) {
+ continue;
+ }
+ Map rule = toRuleMap(raw);
+ if (!isMileageBasedElement(stringValue(rule, "billingElement", ""))) {
+ continue;
+ }
+ boolean matched = feeWaybills(rule, waybill).stream()
+ .anyMatch(feeWaybill -> matchesBillingRule(raw, rule, feeWaybill));
+ if (matched) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@Override
@Transactional(rollbackFor = Exception.class)
public void syncMileageFromWaybill(Long waybillId, BigDecimal mileage) {
@@ -1052,7 +1106,25 @@ public class ReceivablePayableDetailServiceImpl
@Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
rollbackFor = Exception.class)
public void generateForCompletedWaybills(List waybillIds) {
- generateAutomaticWaybillDetails(loadWaybills(waybillIds), true,
+ generateForCompletedWaybills(waybillIds, Map.of());
+ }
+
+ @Override
+ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW,
+ rollbackFor = Exception.class)
+ public void generateForCompletedWaybills(List waybillIds, Map mileageOverrides) {
+ List waybills = loadWaybills(waybillIds);
+ // 独立事务读的是调用方提交前的数据:司机打卡刚写的里程在这里不可见,
+ // 不覆盖就会按 null 里程算出 0 元运费。
+ if (Func.isNotEmpty(mileageOverrides)) {
+ waybills.forEach(waybill -> {
+ BigDecimal mileage = mileageOverrides.get(waybill.getId());
+ if (mileage != null) {
+ waybill.setMileage(mileage);
+ }
+ });
+ }
+ generateAutomaticWaybillDetails(waybills, true,
this::resolveWaybillCarrierContract, AuthUtil.getUserId(), new Date());
}
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 be049adc..45a9ffa8 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
@@ -421,6 +421,16 @@ public class WaybillServiceImpl extends BaseServiceImpl
if (processNodes.isEmpty()) {
processNodes = WaybillProcessSupport.listDriverPunchNodes(processJson);
}
+ // 里程只在末位打卡节点采集:判据与自动完成、司机端 needMileage 同源(司机可打卡节点的末位)。
+ List