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] =?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> driverPunchNodes = WaybillProcessSupport.listDriverPunchNodes(processJson); + String lastPunchKey = driverPunchNodes.isEmpty() ? null + : WaybillProcessSupport.nodeKey(driverPunchNodes.get(driverPunchNodes.size() - 1)); + boolean lastNodeNeedsMileage = lastPunchKey != null + && receivablePayableDetailService.requiresMileage(waybill); + // 已生成结算单的运单,里程只能看不能改(与「维护里程」同一条判据) + boolean punchMileageLocked = lastNodeNeedsMileage + && receivablePayableDetailService.settlementLinkedWaybillIds(List.of(waybill.getId())) + .contains(waybill.getId()); for (Map node : processNodes) { String nodeCode = WaybillProcessSupport.nodeKey(node); @@ -430,6 +440,11 @@ public class WaybillServiceImpl extends BaseServiceImpl item.setNodeCode(nodeCode); item.setNodeName(nodeName); item.setType(isTransit ? "enroute" : "node"); + if (lastNodeNeedsMileage && !isTransit && lastPunchKey.equals(nodeCode)) { + item.setNeedMileage(true); + item.setMileage(waybill.getMileage()); + item.setMileageLocked(punchMileageLocked); + } item.setExceptionFlag(false); item.setPhotos(new ArrayList<>()); @@ -541,8 +556,46 @@ public class WaybillServiceImpl extends BaseServiceImpl return revertIfReturnConfirmPreconditionLost(waybill, punched, saveEnroutePunchRecord(waybill, request, punched)); } - return revertIfReturnConfirmPreconditionLost(waybill, punched, - saveNodePunchRecord(waybill, request, punched)); + boolean saved = saveNodePunchRecord(waybill, request, punched); + if (saved) { + applyPunchRecordMileage(waybill, request, punched); + } + return revertIfReturnConfirmPreconditionLost(waybill, punched, saved); + } + + /** + * 管理端订正打卡时写里程:只在末位打卡节点、且本次是打卡(非取消)时生效。 + *

+ * 里程留空(null)表示不改动;已生成结算单时拒绝改值,但其余字段的保存不受影响。 + * 写入后同步未结算明细的里程并按新里程重算,与「维护里程」同一条链路。 + */ + private void applyPunchRecordMileage(Waybill waybill, WaybillPunchRecordSaveRequest request, boolean punched) { + if (!punched || request.getMileage() == null) { + return; + } + String processJson = resolveLiveProcessJson(waybill); + Map nodeConfig = findEnabledNodeConfig( + processJson, request.getNodeCode(), request.getNodeName()); + String nodeCode = nodeConfig == null + ? TransportBusinessSupport.trimToEmpty(request.getNodeCode()) + : WaybillProcessSupport.nodeKey(nodeConfig); + // 非末位节点收到里程就忽略:客户端不该传,后端也不该因此拒掉整次订正。 + if (!WaybillProcessSupport.isLastDriverPunchNode(processJson, nodeCode)) { + return; + } + BigDecimal mileage = TransportBusinessSupport.requireMileage(request.getMileage()); + BigDecimal current = waybill.getMileage(); + // 同值放行:订正其它字段时前端会带回落库现值,不该被当成"改里程"。 + if (current != null && current.compareTo(mileage) == 0) { + return; + } + if (receivablePayableDetailService.settlementLinkedWaybillIds(List.of(waybill.getId())) + .contains(waybill.getId())) { + throw new ServiceException("该运单已生成结算单,无法修改里程"); + } + savePunchMileage(waybill.getId(), mileage); + waybill.setMileage(mileage); + receivablePayableDetailService.syncMileageFromWaybill(waybill.getId(), mileage); } /** @@ -1450,11 +1503,8 @@ public class WaybillServiceImpl extends BaseServiceImpl .contains(waybill.getId())) { throw new ServiceException("该运单已生成结算单,无法维护里程"); } - BigDecimal mileage = request.getMileage(); - if (mileage == null || mileage.compareTo(BigDecimal.ZERO) <= 0 - || mileage.stripTrailingZeros().scale() > 0 || mileage.stripTrailingZeros().precision() > 10) { - throw new ServiceException("里程必须为不超过10位的正整数"); - } + // 与打卡采集共用同一套校验(正整数、有效位 ≤10),两处文案不许分叉 + BigDecimal mileage = TransportBusinessSupport.requireMileage(request.getMileage()); String mileageRemark = TransportBusinessSupport.trimToNull(request.getMileageRemark()); TransportBusinessSupport.validateLength(mileageRemark, 200, "里程维护备注不能超过200字"); waybill.setMileage(mileage); @@ -1466,6 +1516,18 @@ public class WaybillServiceImpl extends BaseServiceImpl return updated; } + @Override + @Transactional(rollbackFor = Exception.class) + public void savePunchMileage(Long waybillId, BigDecimal mileage) { + if (waybillId == null || mileage == null) { + return; + } + // 只 set 里程列:整行 updateById 会把并发改过的其它字段一并覆盖回去。 + update(Wrappers.lambdaUpdate() + .set(Waybill::getMileage, mileage) + .eq(Waybill::getId, waybillId)); + } + @Override @Transactional(rollbackFor = Exception.class) public boolean cancel(Long id) { @@ -1608,7 +1670,11 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setEndDate(LocalDate.now()); boolean updated = updateById(waybill); if (updated) { - receivablePayableDetailService.generateForCompletedWaybills(List.of(waybill.getId())); + // 计费生成是独立事务,读不到本事务里刚写的里程:把当前里程显式带过去, + // 否则司机末位打卡写入的里程会被当成 null,按里程的费用算成 0 元。 + Map mileageOverrides = waybill.getMileage() == null + ? Map.of() : Map.of(waybill.getId(), waybill.getMileage()); + receivablePayableDetailService.generateForCompletedWaybills(List.of(waybill.getId()), mileageOverrides); loadingManageService.completeIfAllWaybillsCompleted(waybill.getLoadingNo()); } return updated; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java index ad497230..5f1e8efd 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/TransportBusinessSupport.java @@ -252,4 +252,34 @@ public final class TransportBusinessSupport { } } + /** + * 里程(公里):正整数、有效位不超过 10 位。 + *

+ * 打卡采集与「维护里程」共用这一条判据,两处文案必须一致。空值按「没有填」拒回, + * 非数字按格式错误拒回——两者给司机/业务员的下一步动作不同。 + */ + public static BigDecimal requireMileage(String value) { + String text = trimToNull(value); + if (text == null) { + throw new ServiceException("请输入里程"); + } + try { + return requireMileage(new BigDecimal(text)); + } catch (NumberFormatException exception) { + throw new ServiceException("里程必须为不超过10位的正整数"); + } + } + + public static BigDecimal requireMileage(BigDecimal mileage) { + if (mileage == null) { + throw new ServiceException("请输入里程"); + } + if (mileage.compareTo(BigDecimal.ZERO) <= 0 + || mileage.stripTrailingZeros().scale() > 0 + || mileage.stripTrailingZeros().precision() > 10) { + throw new ServiceException("里程必须为不超过10位的正整数"); + } + return mileage; + } + } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java index 703070cb..bc544d0a 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverNodePunchPhotoTest.java @@ -102,7 +102,8 @@ class DriverNodePunchPhotoTest { mock(WaybillEnroutePunchMapper.class), nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(driverService.getOne(any())).thenReturn(driver()); when(waybillService.getOne(any())).thenReturn(waybill()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java index daecf3cf..06667c50 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchFinishFlagTest.java @@ -99,7 +99,8 @@ class DriverPunchFinishFlagTest { enroutePunchMapper, nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(nodePunchMapper.selectList(any())).thenReturn(List.of()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java index 2b35117f..0009525f 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverPunchNodeCardTest.java @@ -94,7 +94,8 @@ class DriverPunchNodeCardTest { enroutePunchMapper, nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); Waybill waybill = waybill(); when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java index 24d1d635..05c3af55 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripAcceptRejectTest.java @@ -92,7 +92,8 @@ class DriverTripAcceptRejectTest { mock(WaybillEnroutePunchMapper.class), mock(WaybillNodePunchMapper.class), mock(ExceptionDisposalMapper.class), - tripQueryService); + tripQueryService, + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); lenient().when(driverService.getOne(any())).thenReturn(driver()); lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true); lenient().when(waybillService.update(any())).thenReturn(true); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java index 41189b41..c34e0441 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverTripCardTest.java @@ -108,7 +108,8 @@ class DriverTripCardTest { enroutePunchMapper, nodePunchMapper, mock(ExceptionDisposalMapper.class), - tripQueryService); + tripQueryService, + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); lenient().when(driverService.getOne(any())).thenReturn(driver()); lenient().when(enroutePunchMapper.selectList(any())).thenReturn(List.of()); lenient().when(enroutePunchMapper.selectOne(any())).thenReturn(null); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java index 6a23c1ac..42b742fe 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillAutoCompleteTest.java @@ -123,7 +123,8 @@ class DriverWaybillAutoCompleteTest { mock(WaybillEnroutePunchMapper.class), nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(driverService.getOne(any())).thenReturn(driver()); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java index 35e8dc58..13ace47b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillCardLocationTest.java @@ -80,7 +80,8 @@ class DriverWaybillCardLocationTest { enroutePunchMapper, nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); when(nodePunchMapper.selectList(any())).thenReturn(List.of()); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java index 83e8475e..f1223d3c 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillNodePunchTest.java @@ -62,7 +62,8 @@ class DriverWaybillNodePunchTest { mock(WaybillEnroutePunchMapper.class), nodePunchMapper, mock(ExceptionDisposalMapper.class), - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java index a96f086f..7f3f6cae 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillOwnershipTest.java @@ -74,7 +74,8 @@ class DriverWaybillOwnershipTest { mock(WaybillEnroutePunchMapper.class), mock(WaybillNodePunchMapper.class), mock(ExceptionDisposalMapper.class), - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); } @Test diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java index b11451aa..49502666 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillRejectDetachTest.java @@ -90,7 +90,8 @@ class DriverWaybillRejectDetachTest { mock(WaybillEnroutePunchMapper.class), mock(WaybillNodePunchMapper.class), mock(ExceptionDisposalMapper.class), - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); lenient().when(driverService.getOne(any())).thenReturn(driver()); lenient().when(waybillService.updateById(any())).thenReturn(true); // syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE; diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java index 668dc965..1789c892 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/DriverWaybillTabStatusTest.java @@ -88,7 +88,8 @@ class DriverWaybillTabStatusTest { mock(WaybillEnroutePunchMapper.class), nodePunchMapper, exceptionDisposalMapper, - mock(ITripQueryService.class)); + mock(ITripQueryService.class), + mock(org.springblade.transport.service.IReceivablePayableDetailService.class)); when(driverService.getOne(any())).thenReturn(driver()); when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0)); diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java index b753a03b..c8bcc5fd 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillConfirmReturnTest.java @@ -27,10 +27,13 @@ import org.springblade.transport.support.WaybillProcessSupport; import java.lang.reflect.Field; import java.util.List; +import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mockStatic; import static org.mockito.Mockito.never; @@ -131,7 +134,7 @@ class WaybillConfirmReturnTest { assertEquals(true, result); verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.argThat(waybill -> "completed".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId()))); - verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID)); + verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID), Map.of()); verify(loadingManageService).completeIfAllWaybillsCompleted(any()); } @@ -156,7 +159,7 @@ class WaybillConfirmReturnTest { assertEquals("当前状态不允许确认回单", exception.getMessage()); verify(waybillMapper, never()).updateById(any(Waybill.class)); - verify(receivablePayableDetailService, never()).generateForCompletedWaybills(any()); + verify(receivablePayableDetailService, never()).generateForCompletedWaybills(anyList(), anyMap()); } @Test @@ -222,7 +225,7 @@ class WaybillConfirmReturnTest { verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.argThat(waybill -> "completed".equals(waybill.getBusinessStatus()))); - verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID)); + verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID), Map.of()); } @Test @@ -248,7 +251,7 @@ class WaybillConfirmReturnTest { verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.argThat(waybill -> "cancelled".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId()))); - verify(receivablePayableDetailService, never()).generateForCompletedWaybills(any()); + verify(receivablePayableDetailService, never()).generateForCompletedWaybills(anyList(), anyMap()); verify(loadingManageService, never()).completeIfAllWaybillsCompleted(any()); } diff --git a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java index 6f055f13..d9e7e76b 100644 --- a/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java +++ b/blade-service/blade-transport/src/test/java/org/springblade/transport/service/impl/WaybillPunchRecordListTest.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test; import org.springblade.transport.mapper.WaybillEnroutePunchMapper; import org.springblade.transport.mapper.WaybillMapper; import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.service.IReceivablePayableDetailService; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.entity.WaybillEnroutePunch; import org.springblade.transport.pojo.entity.WaybillNodePunch; @@ -20,6 +21,7 @@ import java.lang.reflect.Field; import java.text.ParseException; import java.text.SimpleDateFormat; import java.util.Date; +import java.math.BigDecimal; import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -57,6 +59,7 @@ class WaybillPunchRecordListTest { private WaybillMapper waybillMapper; private WaybillNodePunchMapper nodePunchMapper; private WaybillEnroutePunchMapper enroutePunchMapper; + private IReceivablePayableDetailService receivablePayableDetailService; private WaybillServiceImpl service; @BeforeAll @@ -74,10 +77,12 @@ class WaybillPunchRecordListTest { waybillMapper = mock(WaybillMapper.class); nodePunchMapper = mock(WaybillNodePunchMapper.class); enroutePunchMapper = mock(WaybillEnroutePunchMapper.class); + receivablePayableDetailService = mock(IReceivablePayableDetailService.class); service = new WaybillServiceImpl(); inject("baseMapper", waybillMapper); inject("waybillNodePunchMapper", nodePunchMapper); inject("waybillEnroutePunchMapper", enroutePunchMapper); + inject("receivablePayableDetailService", receivablePayableDetailService); } @Test @@ -187,6 +192,44 @@ class WaybillPunchRecordListTest { assertEquals(7001L, load.getId()); } + @Test + @DisplayName("计费含里程:里程只挂在末位可打卡节点上,其它节点不带") + void mileageOnlyOnLastDriverPunchNode() { + Waybill waybill = waybill(CONFIRM_ACCEPT_JSON, "running", "accepted"); + waybill.setMileage(new BigDecimal("128")); + stubWaybill(waybill); + when(receivablePayableDetailService.requiresMileage(waybill)).thenReturn(true); + + List records = service.listPunchRecords(WAYBILL_ID).getRecords(); + WaybillPunchRecordItemVO accept = records.stream() + .filter(record -> "accept".equals(record.getNodeCode())).findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有接单节点")); + WaybillPunchRecordItemVO load = records.stream() + .filter(record -> "load".equals(record.getNodeCode())).findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有装货节点")); + + // 接单不参与司机打卡序列,末位是装货 + assertFalse(Boolean.TRUE.equals(accept.getNeedMileage())); + assertTrue(Boolean.TRUE.equals(load.getNeedMileage())); + assertEquals(0, new BigDecimal("128").compareTo(load.getMileage())); + assertFalse(Boolean.TRUE.equals(load.getMileageLocked())); + } + + @Test + @DisplayName("计费不含里程:末位节点也不带里程标记") + void noMileageFlagWhenBillingHasNoMileage() { + Waybill waybill = waybill(CONFIRM_ACCEPT_JSON, "running", "accepted"); + stubWaybill(waybill); + when(receivablePayableDetailService.requiresMileage(waybill)).thenReturn(false); + + WaybillPunchRecordItemVO load = service.listPunchRecords(WAYBILL_ID).getRecords().stream() + .filter(record -> "load".equals(record.getNodeCode())).findFirst() + .orElseThrow(() -> new AssertionError("时间轴里没有装货节点")); + + assertFalse(Boolean.TRUE.equals(load.getNeedMileage())); + assertNull(load.getMileage()); + } + // ------------------------------------------------------------ 夹具 private void stubWaybill(Waybill waybill) {