Merge remote-tracking branch 'origin/master' into feature/20261019-bfi-all
# Conflicts: # blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/PaymentApplicationServiceImpl.java
This commit is contained in:
+7
@@ -150,6 +150,13 @@ public class Dept extends TenantEntity {
|
||||
@Schema(description = "合同编码前缀")
|
||||
private String contractCodePrefix;
|
||||
|
||||
/**
|
||||
* 合同初始流水号(该部门当年首个合同时从此号起编,范围1-999)
|
||||
*/
|
||||
@Schema(description = "合同初始流水号")
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private Integer contractInitialSerial;
|
||||
|
||||
/**
|
||||
* 组织简称
|
||||
*/
|
||||
|
||||
+2
@@ -36,6 +36,8 @@ public class PaymentApplicationSaveRequest implements Serializable {
|
||||
private List<Long> settlementIds;
|
||||
private Long preSettlementId;
|
||||
private List<Long> preSettlementIds;
|
||||
/** 预结算单号。驳回重提时用于在主键失真后找回预结算单 */
|
||||
private String preSettlementNo;
|
||||
private Long projectId;
|
||||
private String projectName;
|
||||
private Long deptId;
|
||||
|
||||
+6
@@ -51,6 +51,12 @@ public class PersonCertSubmitRequest implements Serializable {
|
||||
private String realName;
|
||||
@Schema(description = "身份证号")
|
||||
private String idCardNo;
|
||||
@Schema(description = "出生年月")
|
||||
private LocalDate birthday;
|
||||
@Schema(description = "住址-区划:省市区名称拼接串")
|
||||
private String addressRegion;
|
||||
@Schema(description = "住址-详细地址")
|
||||
private String address;
|
||||
@Schema(description = "身份证正面照")
|
||||
private String idCardFrontUrl;
|
||||
@Schema(description = "身份证反面照")
|
||||
|
||||
+26
@@ -92,4 +92,30 @@ public class AdminWaybillCardVO implements Serializable {
|
||||
@Schema(description = "采购方是否已付款(预留)")
|
||||
private Boolean buyerPaid;
|
||||
|
||||
/* ===== 运次(一趟车)字段:列表按运次下发,卡片的展示单号与路线取这些 ===== */
|
||||
|
||||
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
|
||||
private String tripId;
|
||||
|
||||
@Schema(description = "配载单号,未配载为空")
|
||||
private String loadingNo;
|
||||
|
||||
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
|
||||
private String tripNo;
|
||||
|
||||
@Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空")
|
||||
private String viaName;
|
||||
|
||||
@Schema(description = "站点数(整趟车,含起终点)")
|
||||
private Integer stationCount;
|
||||
|
||||
@Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)")
|
||||
private String cargoSummary;
|
||||
|
||||
@Schema(description = "合计重量(整趟车同单位时才有值,单位不一致为空)")
|
||||
private String totalWeight;
|
||||
|
||||
@Schema(description = "子运单数(可见口径;配载运次即整趟车的子单数)")
|
||||
private Integer waybillCount;
|
||||
|
||||
}
|
||||
|
||||
+27
-1
@@ -66,9 +66,12 @@ public class AdminWaybillDetailVO implements Serializable {
|
||||
@Schema(description = "卸货地址")
|
||||
private String unloadAddress;
|
||||
|
||||
@Schema(description = "货物名称")
|
||||
@Schema(description = "货物名称:运单上录入的全部货名以「、」连接")
|
||||
private String cargoName;
|
||||
|
||||
@Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次")
|
||||
private List<String> cargoNames;
|
||||
|
||||
@Schema(description = "货物数量(带单位)")
|
||||
private String cargoQuantity;
|
||||
|
||||
@@ -135,6 +138,29 @@ public class AdminWaybillDetailVO implements Serializable {
|
||||
@Schema(description = "途打卡记录")
|
||||
private List<DriverEnrouteRecordVO> enrouteRecords;
|
||||
|
||||
/* ===== 运次(一趟车)字段:详情按运次下发,与司机端接单页同一口径 ===== */
|
||||
|
||||
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
|
||||
private String tripId;
|
||||
|
||||
@Schema(description = "配载单号,未配载为空")
|
||||
private String loadingNo;
|
||||
|
||||
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
|
||||
private String tripNo;
|
||||
|
||||
@Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接,无中间站为空")
|
||||
private String viaName;
|
||||
|
||||
@Schema(description = "站点数(整趟车,含起终点)")
|
||||
private Integer stationCount;
|
||||
|
||||
@Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)")
|
||||
private String cargoSummary;
|
||||
|
||||
@Schema(description = "站点任务安排(整趟车按实际途经顺序,第 1 站即 ①;配载运次含每站装卸与货物条目)")
|
||||
private List<TripStationVO> stations;
|
||||
|
||||
@Data
|
||||
@Schema(description = "装卸点")
|
||||
public static class AdminRoutePointVO implements Serializable {
|
||||
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.pojo.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 货物明细行:一条货物一行
|
||||
* <p>
|
||||
* 详情页逐行展示「货物名称 + 数量单位」,名称与数量分开给,前端不需要再从汇总串里拆。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "货物明细行")
|
||||
public class CargoLineVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "货物名称")
|
||||
private String cargoName;
|
||||
|
||||
@Schema(description = "数量(含单位,如 1000吨);录入时只填了名称则为空")
|
||||
private String quantity;
|
||||
}
|
||||
+6
@@ -67,9 +67,15 @@ public class DriverPunchNodeVO implements Serializable {
|
||||
@Schema(description = "不可打卡的原因,供司机端展示")
|
||||
private String lockReason;
|
||||
|
||||
@Schema(description = "运单已结束(完成/取消):卡片仅展示、不可提交")
|
||||
private Boolean ended;
|
||||
|
||||
@Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)")
|
||||
private Boolean visible;
|
||||
|
||||
@Schema(description = "打这张卡即完成运单:该运单的末位可打卡节点,且过程配置不要求业务员确认回单")
|
||||
private Boolean finishOnPunch;
|
||||
|
||||
@Schema(description = "是否默认展开(仅第一个可打卡节点)")
|
||||
private Boolean defaultExpanded;
|
||||
|
||||
|
||||
+26
@@ -67,6 +67,9 @@ public class DriverWaybillCardVO implements Serializable {
|
||||
@Schema(description = "货物类别")
|
||||
private String cargoCategory;
|
||||
|
||||
@Schema(description = "货物明细(详情返回):一条一行,同名同单位的货物已合并数量")
|
||||
private List<CargoLineVO> cargos;
|
||||
|
||||
@Schema(description = "重量(带单位)")
|
||||
private String weight;
|
||||
|
||||
@@ -153,6 +156,26 @@ public class DriverWaybillCardVO implements Serializable {
|
||||
@Schema(description = "运输组织:common普通 / load配载")
|
||||
private String transportOrgType;
|
||||
|
||||
/* ===== 运次(一趟车)字段:运次接口返回,旧运单接口不填 ===== */
|
||||
|
||||
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
|
||||
private String tripId;
|
||||
|
||||
@Schema(description = "配载单号,未配载为空")
|
||||
private String loadingNo;
|
||||
|
||||
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
|
||||
private String tripNo;
|
||||
|
||||
@Schema(description = "途经站点:整趟车中间各站按顺序以「 → 」连接")
|
||||
private String viaName;
|
||||
|
||||
@Schema(description = "站点数(整趟车,含起终点)")
|
||||
private Integer stationCount;
|
||||
|
||||
@Schema(description = "货物汇总(整趟车,同名货物合并且单位一致时取数量合计)")
|
||||
private String cargoSummary;
|
||||
|
||||
@Schema(description = "计划发货时间")
|
||||
private String planShipTime;
|
||||
|
||||
@@ -168,6 +191,9 @@ public class DriverWaybillCardVO implements Serializable {
|
||||
@Schema(description = "装卸点(详情返回)")
|
||||
private List<DriverRoutePointVO> routePoints;
|
||||
|
||||
@Schema(description = "站点任务安排(详情返回,按实际途经顺序,第 1 站即 ①;有配载时含每站装卸与货物条目)")
|
||||
private List<TripStationVO> stations;
|
||||
|
||||
@Schema(description = "过程配置 JSON(详情返回,供前端兜底推导打卡节点)")
|
||||
private String processJson;
|
||||
|
||||
|
||||
+6
@@ -39,6 +39,7 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 收款流水视图实体类
|
||||
@@ -91,4 +92,9 @@ public class ReceiptFlowVO extends KingdeeReceiptFlow {
|
||||
private BigDecimal receiptAmountEnd;
|
||||
@TableField(exist = false)
|
||||
private List<ReceiptFlowRecord> claimRecords = new ArrayList<>();
|
||||
/**
|
||||
* 本流水历史关联且仍有剩余可认领金额的应收正式结算单(继续认领时预填)
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private List<Map<String, Object>> relatedSettlements = new ArrayList<>();
|
||||
}
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.pojo.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 运次查询条件
|
||||
* <p>
|
||||
* 司机端用 {@link #forDriver(String)} 按司机手机号收口;管理端用 {@link #forAdmin()} 不按司机收口。
|
||||
* 两者都不给时收口为空集——收口一旦缺省就等于把别人的运次发出去,故缺省不放行。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "运次查询条件")
|
||||
public class TripQueryVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "运次主键(详情按它取单个运次)")
|
||||
private String tripId;
|
||||
|
||||
@Schema(description = "司机手机号")
|
||||
private String driverPhone;
|
||||
|
||||
@Schema(description = "是否不按司机收口(管理端)")
|
||||
private boolean allDrivers;
|
||||
|
||||
@Schema(description = "可见组织 ID 集合:非空时只出这些组织的运单行,空集合表示不按组织过滤")
|
||||
private List<Long> deptIds;
|
||||
|
||||
@Schema(description = "状态:0待接单/1进行中/2已完成/3已取消,不传为全部(不含已取消)")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "多档状态:同上取值,非空时优先于 status(待处理列表要待接单与进行中两档)")
|
||||
private List<Integer> statuses;
|
||||
|
||||
@Schema(description = "关键字:运单号 / 配载单号 / 起终点 / 车牌")
|
||||
private String keyword;
|
||||
|
||||
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输,不传为全部(按子单行筛选)")
|
||||
private String transportOrgType;
|
||||
|
||||
@Schema(description = "异常筛选:exception 只看命中异常的子单 / normal 只看未命中的,不传为全部(按子单行筛选)")
|
||||
private String exception;
|
||||
|
||||
@Schema(description = "有未完成异常的子运单主键集合,配合 exception 使用(按子单行筛选)")
|
||||
private Set<Long> exceptionWaybillIds;
|
||||
|
||||
@Schema(description = "创建时间下界,按子单行筛选")
|
||||
private Date createdFrom;
|
||||
|
||||
@Schema(description = "创建时间上界,按子单行筛选")
|
||||
private Date createdTo;
|
||||
|
||||
@Schema(description = "是否排除「有子单被拒且这趟车还没结束」的运次(待处理列表用)")
|
||||
private boolean excludeRejectedTrips;
|
||||
|
||||
@Schema(description = "当前页,从 1 开始")
|
||||
private Integer current;
|
||||
|
||||
@Schema(description = "每页条数(按运次,不是运单)")
|
||||
private Integer size;
|
||||
|
||||
public static TripQueryVO forDriver(String driverPhone) {
|
||||
TripQueryVO query = new TripQueryVO();
|
||||
query.setDriverPhone(driverPhone);
|
||||
return query;
|
||||
}
|
||||
|
||||
public static TripQueryVO forAdmin() {
|
||||
TripQueryVO query = new TripQueryVO();
|
||||
query.setAllDrivers(true);
|
||||
return query;
|
||||
}
|
||||
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.pojo.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 运次站点(任务安排)
|
||||
* <p>
|
||||
* 有配载单时按配载单的路线信息给出实际途经顺序,第 1 站即 ①;站点上的「装N / 卸N」指向配载单运单集合里的第 N 张子运单,
|
||||
* 同一站可以既卸又装(装卸)。没有路线信息时回退到子运单的起讫点去重成站。
|
||||
* <p>
|
||||
* 站点上的货物条目取子运单,不取配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "运次站点")
|
||||
public class TripStationVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "站序,从 1 开始(展示为 ①②③)")
|
||||
private Integer index;
|
||||
|
||||
@Schema(description = "站名:展示地址优先,回退详细地址")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "详细地址")
|
||||
private String address;
|
||||
|
||||
@Schema(description = "站点标记原文:装N / 卸N")
|
||||
private List<String> tags;
|
||||
|
||||
@Schema(description = "操作类型:load 装货 / unload 卸货 / load_unload 装卸,无从判定时为空")
|
||||
private String operationType;
|
||||
|
||||
@Schema(description = "操作类型文案:装货 / 卸货 / 装卸")
|
||||
private String operationTypeLabel;
|
||||
|
||||
@Schema(description = "该站涉及的货物条目")
|
||||
private List<TripCargoVO> cargos;
|
||||
|
||||
/**
|
||||
* 站点货物条目
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "运次站点货物条目")
|
||||
public static class TripCargoVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "所属子运单主键,按字符串下发")
|
||||
private String waybillId;
|
||||
|
||||
@Schema(description = "所属运单号")
|
||||
private String waybillNo;
|
||||
|
||||
@Schema(description = "货物名称")
|
||||
private String cargoName;
|
||||
|
||||
@Schema(description = "货主名称")
|
||||
private String customerName;
|
||||
|
||||
@Schema(description = "数量")
|
||||
private BigDecimal quantity;
|
||||
|
||||
@Schema(description = "数量单位")
|
||||
private String quantityUnit;
|
||||
|
||||
@Schema(description = "该站的操作:load 装货 / unload 卸货")
|
||||
private String operation;
|
||||
|
||||
@Schema(description = "项目名称")
|
||||
private String projectName;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.pojo.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* 运次 Tab 统计
|
||||
* <p>
|
||||
* 与司机端运单统计字段同名同义,但计数单位是运次(一卡一趟车),不是运单。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "运次 Tab 统计")
|
||||
public class TripTabCountsVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "全部(待接单+进行中+已完成,不含已取消)")
|
||||
private long all;
|
||||
|
||||
@Schema(description = "待接单")
|
||||
private long pending;
|
||||
|
||||
@Schema(description = "进行中")
|
||||
private long doing;
|
||||
|
||||
@Schema(description = "已完成")
|
||||
private long done;
|
||||
|
||||
}
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.pojo.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 运次:一趟车的展示与操作单位
|
||||
* <p>
|
||||
* 有配载单时就是那张配载单连同它的全部子运单,没配载时就是一张运单;它没有自己的表。
|
||||
* 状态与接单需求都由子运单读时聚合,配载单上不落这两个字段。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "运次")
|
||||
public class TripVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "运次主键:配载单主键或运单主键,按字符串下发(19 位雪花号按数字下发会被前端取整)")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "展示单号:有配载取配载单号,否则取运单号")
|
||||
private String tripNo;
|
||||
|
||||
@Schema(description = "配载单号,未配载为空")
|
||||
private String loadingNo;
|
||||
|
||||
@Schema(description = "运输组织类型:load 公路配载 / common 普通运输")
|
||||
private String transportOrgType;
|
||||
|
||||
@Schema(description = "状态:0待接单/1运输中/2已完成/3已取消,子单集合为空时为空")
|
||||
private Integer status;
|
||||
|
||||
@Schema(description = "排序键:运次内子运单 create_time 的最大值")
|
||||
private Date sortTime;
|
||||
|
||||
@Schema(description = "是否需要接单:任一子运单的项目有接单节点即为是")
|
||||
private Boolean requireAccept;
|
||||
|
||||
@Schema(description = "接单态:accepted 已接单 / pending 待接单,无从判定时为空")
|
||||
private String acceptStatus;
|
||||
|
||||
@Schema(description = "子运单数(可见口径:已排除本司机拒单的子单)")
|
||||
private int waybillCount;
|
||||
|
||||
@Schema(description = "司机姓名")
|
||||
private String driverName;
|
||||
|
||||
@Schema(description = "司机手机号")
|
||||
private String driverPhone;
|
||||
|
||||
@Schema(description = "车牌号")
|
||||
private String vehicleNo;
|
||||
|
||||
@Schema(description = "挂车车牌号")
|
||||
private String trailerVehicleNo;
|
||||
|
||||
@Schema(description = "项目名称")
|
||||
private String projectName;
|
||||
|
||||
@Schema(description = "客户名称")
|
||||
private String customerName;
|
||||
|
||||
@Schema(description = "运输类型")
|
||||
private String transportType;
|
||||
|
||||
@Schema(description = "货物名称:整趟车全部货名以「、」连接,一格多名的历史写法已摊平")
|
||||
private String cargoName;
|
||||
|
||||
@Schema(description = "货物名称清单:整趟车全部货物,同名的只留一次,保持子单与录入顺序")
|
||||
private List<String> cargoNames;
|
||||
|
||||
@Schema(description = "货物类型:整趟车全部类型以「、」连接")
|
||||
private String cargoType;
|
||||
|
||||
@Schema(description = "货物类型清单:整趟车全部货物类型,同类型的只留一次")
|
||||
private List<String> cargoTypes;
|
||||
|
||||
@Schema(description = "发货地址")
|
||||
private String departureAddress;
|
||||
|
||||
@Schema(description = "途经地")
|
||||
private String transitAddress;
|
||||
|
||||
@Schema(description = "到货地")
|
||||
private String arrivalAddress;
|
||||
|
||||
@Schema(description = "起点名称:整趟车首站,展示地址优先、回退详细地址")
|
||||
private String departureName;
|
||||
|
||||
@Schema(description = "途经站点:中间各站按顺序以「 → 」连接,无中间站为空")
|
||||
private String transitName;
|
||||
|
||||
@Schema(description = "终点名称:整趟车末站,展示地址优先、回退详细地址")
|
||||
private String arrivalName;
|
||||
|
||||
@Schema(description = "站点数(整趟车,含起终点)")
|
||||
private int stationCount;
|
||||
|
||||
@Schema(description = "货物汇总:按货物名合并数量,同名同单位取合计")
|
||||
private String cargoSummary;
|
||||
|
||||
@Schema(description = "货物明细:一条货物一行,同名同单位的货物已合并数量")
|
||||
private List<CargoLineVO> cargos;
|
||||
|
||||
@Schema(description = "合计重量:全部子运单同单位时为数量合计,单位不一致时为空")
|
||||
private BigDecimal totalQuantity;
|
||||
|
||||
@Schema(description = "合计重量的单位,与 totalQuantity 同时为空或同时有值")
|
||||
private String quantityUnit;
|
||||
|
||||
@Schema(description = "计重合计:全部货物同单位时取该单位合计,单位不一致时只合计「吨」项(其余单位不参与);无可用合计时为空")
|
||||
private BigDecimal weighingQuantity;
|
||||
|
||||
@Schema(description = "计重合计的单位,与 weighingQuantity 同时为空或同时有值")
|
||||
private String weighingUnit;
|
||||
|
||||
@Schema(description = "计划发货日期(配载单,未配载为空)")
|
||||
private LocalDate estimatedStartDate;
|
||||
|
||||
@Schema(description = "计划完成日期(配载单,未配载为空)")
|
||||
private LocalDate estimatedEndDate;
|
||||
|
||||
@Schema(description = "任务备注(配载单的运输说明)")
|
||||
private String taskRemark;
|
||||
|
||||
@Schema(description = "路线信息,仅单个运次详情返回(列表不带,省流量)")
|
||||
private String routeJson;
|
||||
|
||||
@Schema(description = "货物信息,仅单个运次详情返回(列表不带,省流量)")
|
||||
private String goodsJson;
|
||||
|
||||
@Schema(description = "站点(任务安排,按实际途经顺序),仅单个运次详情返回;列表用起/经/终与站点数")
|
||||
private List<TripStationVO> stations;
|
||||
|
||||
@Schema(description = "子运单(可见子单,按创建时间升序)")
|
||||
private List<TripWaybillVO> waybills;
|
||||
|
||||
/**
|
||||
* 运次下的子运单
|
||||
* <p>
|
||||
* 接单事实仍落在每张子运单上,运次只是把它们的需要接单与接单态聚合起来。
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "运次下的子运单")
|
||||
public static class TripWaybillVO implements Serializable {
|
||||
|
||||
@Serial
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
@Schema(description = "运单主键,按字符串下发")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "运单号")
|
||||
private String waybillNo;
|
||||
|
||||
@Schema(description = "业务状态")
|
||||
private String businessStatus;
|
||||
|
||||
@Schema(description = "接单状态:pending待接单/accepted已接单/rejected已拒绝")
|
||||
private String acceptStatus;
|
||||
|
||||
@Schema(description = "该运单是否需要接单确认")
|
||||
private Boolean requireAccept;
|
||||
|
||||
@Schema(description = "项目ID")
|
||||
private Long projectId;
|
||||
|
||||
@Schema(description = "项目名称")
|
||||
private String projectName;
|
||||
|
||||
@Schema(description = "货物名称:运单上录入的全部货名以「、」连接,一格多名的历史写法已摊平")
|
||||
private String cargoName;
|
||||
|
||||
@Schema(description = "货物名称清单:运单上录入的全部货物,同名的只留一次")
|
||||
private List<String> cargoNames;
|
||||
|
||||
@Schema(description = "货物类型:主表首行的类型,完整清单见 cargoTypes")
|
||||
private String cargoType;
|
||||
|
||||
@Schema(description = "货物类型清单:运单上录入的全部货物类型,同类型的只留一次")
|
||||
private List<String> cargoTypes;
|
||||
|
||||
@Schema(description = "数量")
|
||||
private BigDecimal quantity;
|
||||
|
||||
@Schema(description = "数量单位")
|
||||
private String quantityUnit;
|
||||
|
||||
@Schema(description = "发货地")
|
||||
private String departureName;
|
||||
|
||||
@Schema(description = "发货地址")
|
||||
private String departureAddress;
|
||||
|
||||
@Schema(description = "收货地")
|
||||
private String arrivalName;
|
||||
|
||||
@Schema(description = "收货地址")
|
||||
private String arrivalAddress;
|
||||
|
||||
@Schema(description = "创建时间")
|
||||
private Date createTime;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+6
@@ -20,6 +20,12 @@ public class WaybillImportBatchVO extends WaybillImportBatch {
|
||||
private static final long serialVersionUID = 1L;
|
||||
private String importTypeName;
|
||||
private String statusName;
|
||||
/** 结算状态:unsettled/partial/settled */
|
||||
@Schema(description = "结算状态")
|
||||
private String settlementStatus;
|
||||
/** 结算状态名称:未结算/部分结算/已结算 */
|
||||
@Schema(description = "结算状态名称")
|
||||
private String settlementStatusName;
|
||||
private String createUserName;
|
||||
private String updateUserName;
|
||||
}
|
||||
|
||||
+9
@@ -29,6 +29,7 @@ import lombok.EqualsAndHashCode;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
|
||||
import java.io.Serial;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 运单管理视图实体类
|
||||
@@ -78,4 +79,12 @@ public class WaybillVO extends Waybill {
|
||||
@Schema(description = "是否仅查询未配载运单")
|
||||
private Integer onlyUnassignedLoading;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "是否仅查询可配载运单")
|
||||
private Integer loadableOnly;
|
||||
|
||||
@TableField(exist = false)
|
||||
@Schema(description = "运单主键集合")
|
||||
private List<Long> ids;
|
||||
|
||||
}
|
||||
|
||||
+4
@@ -132,6 +132,10 @@ public class User extends TenantEntity implements IPhone {
|
||||
* 是否是oa的账号
|
||||
*/
|
||||
private Integer isOa;
|
||||
/**
|
||||
* OA登录名
|
||||
*/
|
||||
private String oaLoginId;
|
||||
/**
|
||||
* OA上次更新时间(OA 的 modified 字段),仅同步比对使用
|
||||
*/
|
||||
|
||||
-2
@@ -11,7 +11,6 @@ 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.utils.AuthUtil;
|
||||
import org.springblade.core.tool.api.R;
|
||||
import org.springblade.core.tool.utils.StringUtil;
|
||||
import org.springblade.process.pojo.dto.ApprovalDTO;
|
||||
@@ -49,7 +48,6 @@ public class BusinessProcessController extends BladeController {
|
||||
if (param == null) {
|
||||
param = new ApprovalDTO();
|
||||
}
|
||||
param.setLoginName(AuthUtil.getUserAccount());
|
||||
IPage<ApprovalVO> pages = businessProcessService.queryMkApprovalList(Condition.getPage(query), param);
|
||||
return R.data(pages);
|
||||
}
|
||||
|
||||
+2
-2
@@ -71,7 +71,7 @@ public interface IBusinessProcessService extends IService<BusinessProcess> {
|
||||
* 获取流程当前节点详情
|
||||
*
|
||||
* @param processInstanceId 流程实例id
|
||||
* @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析
|
||||
* @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析
|
||||
* @return 当前节点列表
|
||||
*/
|
||||
List<?> getCurrentNodes(String processInstanceId, String loginName);
|
||||
@@ -80,7 +80,7 @@ public interface IBusinessProcessService extends IService<BusinessProcess> {
|
||||
* 获取流程实例详情
|
||||
*
|
||||
* @param processInstanceId 流程实例id
|
||||
* @param loginName MK登录名(手机号),可为空,为空时按流程发起人解析
|
||||
* @param loginName MK登录名(OA登录名),可为空,为空时按流程发起人解析
|
||||
* @return 流程实例详情
|
||||
*/
|
||||
Object getProcessInfo(String processInstanceId, String loginName);
|
||||
|
||||
+21
-27
@@ -132,9 +132,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
public String processSubmit(MKProcessCreateDTO param) {
|
||||
AssertUtils.notNull(param, "提交流程参数不能为空");
|
||||
AssertUtils.notBlank(param.getTemplateCode(), "模板编码不能为空");
|
||||
// 前端拿到的手机号可能经 @Sensitive 脱敏(如 137****8880),这里从库取真实手机号覆盖
|
||||
String loginName = resolveCurrentUserPhone();
|
||||
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法提交审核流");
|
||||
// MK loginName 使用当前用户 OA 登录名,未配置则不允许提交
|
||||
String loginName = resolveCurrentUserOaLoginId();
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法提交审核流");
|
||||
param.setSubmitIdentity(loginName);
|
||||
param.setLoginName(loginName);
|
||||
String bizType = param.getBizType();
|
||||
@@ -258,7 +258,7 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
resolvedLoginName = businessProcess.getPromoterLoginName();
|
||||
}
|
||||
if (StringUtil.isBlank(resolvedLoginName)) {
|
||||
resolvedLoginName = resolveCurrentUserPhone();
|
||||
resolvedLoginName = resolveCurrentUserOaLoginId();
|
||||
}
|
||||
if (StringUtil.isBlank(resolvedLoginName)) {
|
||||
return false;
|
||||
@@ -291,10 +291,10 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
}
|
||||
String loginName = businessProcess.getPromoterLoginName();
|
||||
if (StringUtil.isBlank(loginName)) {
|
||||
loginName = resolveCurrentUserPhone();
|
||||
loginName = resolveCurrentUserOaLoginId();
|
||||
}
|
||||
if (StringUtil.isBlank(loginName)) {
|
||||
log.warn("查询MK流程实例跳过,登录名为空 bizId={} processInstanceId={}",
|
||||
log.warn("查询MK流程实例跳过,OA登录名为空 bizId={} processInstanceId={}",
|
||||
bizId, businessProcess.getProcessInstanceId());
|
||||
return false;
|
||||
}
|
||||
@@ -357,9 +357,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
}
|
||||
String loginName = businessProcess.getPromoterLoginName();
|
||||
if (StringUtil.isBlank(loginName)) {
|
||||
loginName = resolveCurrentUserPhone();
|
||||
loginName = resolveCurrentUserOaLoginId();
|
||||
}
|
||||
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法删除审核流");
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法删除审核流");
|
||||
log.info("调用mk processDelete processInstanceId={} loginName={} bizId={} formInstanceId={}",
|
||||
businessProcess.getProcessInstanceId(), loginName, bizId, businessProcess.getFormInstanceId());
|
||||
boolean deleted = mkService.processDelete(businessProcess.getProcessInstanceId(), loginName);
|
||||
@@ -563,27 +563,18 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
}
|
||||
|
||||
/**
|
||||
* 从当前登录用户实体读取真实手机号(库内 SM2 密文先解密,绕过接口脱敏)
|
||||
* 从当前登录用户实体读取 OA 登录名,作为 MK 接口 loginName
|
||||
*/
|
||||
private String resolveCurrentUserPhone() {
|
||||
private String resolveCurrentUserOaLoginId() {
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (userId != null) {
|
||||
User user = userService.getById(userId);
|
||||
if (user != null) {
|
||||
userService.decryptPhone(user);
|
||||
if (StringUtil.isNotBlank(user.getPhone()) && !user.getPhone().contains("*")) {
|
||||
return user.getPhone().trim();
|
||||
}
|
||||
if (StringUtil.isNotBlank(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) {
|
||||
return user.getAccount().trim();
|
||||
}
|
||||
}
|
||||
if (userId == null) {
|
||||
return null;
|
||||
}
|
||||
String account = AuthUtil.getUserAccount();
|
||||
if (StringUtil.isNotBlank(account) && account.matches("^1\\d{10}$")) {
|
||||
return account.trim();
|
||||
User user = userService.getById(userId);
|
||||
if (user == null || StringUtil.isBlank(user.getOaLoginId())) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
return user.getOaLoginId().trim();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
@@ -703,8 +694,8 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
processInstanceId = businessProcess.getProcessInstanceId();
|
||||
}
|
||||
try {
|
||||
String loginName = resolveCurrentUserPhone();
|
||||
AssertUtils.notBlank(loginName, "当前用户手机号为空,无法获取审核跳转地址");
|
||||
String loginName = resolveCurrentUserOaLoginId();
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法获取审核跳转地址");
|
||||
String mkApprovalUrl = mkService.getMKApprovalUrl(processInstanceId, loginName);
|
||||
AssertUtils.notBlank(mkApprovalUrl, "获取mk审批页面链接异常");
|
||||
return mkApprovalUrl;
|
||||
@@ -921,6 +912,9 @@ public class BusinessProcessServiceImpl extends ServiceImpl<BusinessProcessMappe
|
||||
|
||||
@Override
|
||||
public IPage<ApprovalVO> queryMkApprovalList(IPage<ApprovalVO> page, ApprovalDTO param) {
|
||||
String loginName = resolveCurrentUserOaLoginId();
|
||||
AssertUtils.notBlank(loginName, "当前用户OA登录名为空,无法查询审核列表");
|
||||
param.setLoginName(loginName);
|
||||
if (MKDoc.RELATED.getCode().equals(param.getDocType())) {
|
||||
// 我参与的,调用流程列表接口
|
||||
MKProcessDTO processParam = approvalConvert.dto2mk(param);
|
||||
|
||||
+2
-2
@@ -49,8 +49,8 @@ public interface DeptConvert {
|
||||
@Mapping(source = "modified", target = "oaModified")
|
||||
Dept baseConvert(OACompanyResponse company);
|
||||
|
||||
@Mapping(source = "departmentname", target = "deptName")
|
||||
@Mapping(source = "departmentmark", target = "fullName")
|
||||
@Mapping(source = "departmentmark", target = "deptName")
|
||||
@Mapping(source = "departmentname", target = "fullName")
|
||||
@Mapping(source = "supdepid", target = "parentCode")
|
||||
@Mapping(source = "subcompanyid1", target = "belongCompanyCode")
|
||||
@Mapping(source = "created", target = "createTime", dateFormat = "yyyy-MM-dd HH:mm:ss")
|
||||
|
||||
+5
-3
@@ -70,6 +70,7 @@ public interface UserConvert {
|
||||
@Mapping(source = "lastname", target = "realName")
|
||||
@Mapping(source = "mobile", target = "phone")
|
||||
@Mapping(source = "email", target = "email")
|
||||
@Mapping(source = "loginid", target = "oaLoginId")
|
||||
@Mapping(source = "modified", target = "oaModified")
|
||||
User baseConvert(OAPersonResponse person);
|
||||
|
||||
@@ -147,7 +148,7 @@ public interface UserConvert {
|
||||
/**
|
||||
* oa人员转本系统用户部门
|
||||
* @param person
|
||||
* @param userMap 所有用户手机号与id map
|
||||
* @param userMap OA登录名与用户id
|
||||
* @return
|
||||
*/
|
||||
default UserDept person2userDept(OAPersonResponse person, Map<String, Long> userMap) {
|
||||
@@ -161,8 +162,9 @@ public interface UserConvert {
|
||||
userDept.setStatus(status);
|
||||
// 排序
|
||||
userDept.setSort(OAUtils.parseInt(person.getDsporder()));
|
||||
// 用户id
|
||||
userDept.setUserId(userMap.get(resolveAccount(person)));
|
||||
// 用户id,按 OA 登录名关联本批已新增或更新的用户
|
||||
String oaLoginId = person == null ? null : StringUtils.trimToNull(person.getLoginid());
|
||||
userDept.setUserId(oaLoginId == null ? null : userMap.get(oaLoginId));
|
||||
userDept.setSyncTime(new Date());
|
||||
return userDept;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
<result column="dept_code" property="deptCode"/>
|
||||
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
|
||||
<result column="contract_code_prefix" property="contractCodePrefix"/>
|
||||
<result column="contract_initial_serial" property="contractInitialSerial"/>
|
||||
<result column="short_name" property="shortName"/>
|
||||
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
|
||||
<result column="mnemonic_code" property="mnemonicCode"/>
|
||||
@@ -36,6 +37,7 @@
|
||||
<result column="dept_code" property="deptCode"/>
|
||||
<result column="kingdee_dept_code" property="kingdeeDeptCode"/>
|
||||
<result column="contract_code_prefix" property="contractCodePrefix"/>
|
||||
<result column="contract_initial_serial" property="contractInitialSerial"/>
|
||||
<result column="short_name" property="shortName"/>
|
||||
<result column="pinyin_mnemonic" property="pinyinMnemonic"/>
|
||||
<result column="mnemonic_code" property="mnemonicCode"/>
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
<result column="dept_id" property="deptId"/>
|
||||
<result column="post_id" property="postId"/>
|
||||
<result column="remark" property="remark"/>
|
||||
<result column="oa_login_id" property="oaLoginId"/>
|
||||
<result column="oa_person_id" property="oaPersonId"/>
|
||||
</resultMap>
|
||||
|
||||
|
||||
+11
@@ -687,6 +687,7 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
validateDeptCategory(dept);
|
||||
validateDeptCode(dept, parent);
|
||||
validateContractInitialSerial(dept);
|
||||
return saveOrUpdate(dept);
|
||||
}
|
||||
|
||||
@@ -874,6 +875,16 @@ public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements ID
|
||||
}
|
||||
}
|
||||
|
||||
private void validateContractInitialSerial(Dept dept) {
|
||||
Integer serial = dept.getContractInitialSerial();
|
||||
if (serial == null) {
|
||||
return;
|
||||
}
|
||||
if (serial < 1 || serial > 999) {
|
||||
throw new ServiceException("合同初始流水号范围为1到999");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateDeptCode(Dept dept, Dept parent) {
|
||||
String deptCode = dept.getDeptCode();
|
||||
// 组织编码非必填,为空时存 null,避免唯一索引冲突
|
||||
|
||||
+13
-4
@@ -761,7 +761,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
.filter(oaDept -> syncAll
|
||||
|| rootCompanyId == null
|
||||
|| !rootCompanyId.equals(oaDept.getSubcompanyid1())
|
||||
|| (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentname())))
|
||||
|| (OAConstant.ROOT_COMPANY_ID.equals(oaDept.getSupdepid()) && rootCompanyDeptNames.contains(oaDept.getDepartmentmark())))
|
||||
.map(dept -> deptConvert.dept2dept(dept, companyByOaId))
|
||||
.toList();
|
||||
int filteredSkipCount = oaDepts.size() - allParam.size();
|
||||
@@ -921,8 +921,9 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
.eq(Dept::getDeptCategory, deptCategory.getCode())
|
||||
.isNotNull(Dept::getOaId)
|
||||
.ne(Dept::getOaId, "")
|
||||
.select(Dept::getId, Dept::getDeptCode, Dept::getOaModified, Dept::getParentId,
|
||||
Dept::getOaId, Dept::getBelongCompanyCode, Dept::getParentCode, Dept::getOaSupSubComId));
|
||||
.select(Dept::getId, Dept::getDeptCode, Dept::getDeptName, Dept::getFullName,
|
||||
Dept::getOaModified, Dept::getParentId, Dept::getOaId, Dept::getBelongCompanyCode,
|
||||
Dept::getParentCode, Dept::getOaSupSubComId));
|
||||
Map<String, Dept> byOaId = new HashMap<>();
|
||||
for (Dept dept : deptList) {
|
||||
if (StringUtils.isNotBlank(dept.getOaId())) {
|
||||
@@ -1047,7 +1048,7 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
|
||||
/**
|
||||
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段。
|
||||
* 组织编码已按新规则重算、或父子挂载与库中不一致时,即使 modified 相同也要更新。
|
||||
* 组织编码已按新规则重算、父子挂载不一致、或名称字段与当前映射不一致时,即使 modified 相同也要更新。
|
||||
*/
|
||||
private boolean shouldSkipOaUpdate(Dept existing, Dept incoming) {
|
||||
if (existing == null) {
|
||||
@@ -1066,6 +1067,14 @@ public class OASyncServiceImpl implements IOASyncService {
|
||||
StringUtils.trimToEmpty(incoming.getParentCode()))) {
|
||||
return false;
|
||||
}
|
||||
if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getDeptName()),
|
||||
StringUtils.trimToEmpty(incoming.getDeptName()))) {
|
||||
return false;
|
||||
}
|
||||
if (incoming != null && !StringUtils.equals(StringUtils.trimToEmpty(existing.getFullName()),
|
||||
StringUtils.trimToEmpty(incoming.getFullName()))) {
|
||||
return false;
|
||||
}
|
||||
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
|
||||
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
|
||||
if (localModified.isEmpty() || remoteModified.isEmpty()) {
|
||||
|
||||
+60
-69
@@ -403,70 +403,37 @@ public class OaUserListSyncHelper {
|
||||
Map<String, OAPersonResponse> uniquePersonMap = new LinkedHashMap<>();
|
||||
int skippedCount = 0;
|
||||
for (OAPersonResponse oaPerson : oaPersons) {
|
||||
String account = userConvert.resolveAccount(oaPerson);
|
||||
if (StringUtils.isBlank(account)) {
|
||||
String oaLoginId = resolveOaLoginId(oaPerson);
|
||||
if (StringUtils.isBlank(oaLoginId)) {
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
uniquePersonMap.putIfAbsent(account, oaPerson);
|
||||
uniquePersonMap.put(oaLoginId, oaPerson);
|
||||
}
|
||||
if (uniquePersonMap.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().warn("OA人员均缺少loginid/工号/手机号,跳过人员同步");
|
||||
ComposeLogUtil.getLastLog().warn("OA人员均缺少登录名,跳过人员同步");
|
||||
return new PersonSyncCount(0, skippedCount);
|
||||
}
|
||||
String tenantId = resolveTenantId();
|
||||
String defaultPassword = DigestUtil.encrypt(ParamCache.getValue(DEFAULT_PARAM_PASSWORD));
|
||||
List<User> users = uniquePersonMap.values().stream()
|
||||
.map(person -> {
|
||||
User user = userConvert.person2user(person, defaultPassword);
|
||||
List<User> users = uniquePersonMap.entrySet().stream()
|
||||
.map(entry -> {
|
||||
User user = userConvert.person2user(entry.getValue(), defaultPassword);
|
||||
user.setTenantId(tenantId);
|
||||
user.setOaLoginId(entry.getKey());
|
||||
user.setAccount(entry.getKey());
|
||||
return user;
|
||||
})
|
||||
.toList();
|
||||
List<String> accounts = users.stream()
|
||||
.map(User::getAccount)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
List<String> phones = users.stream()
|
||||
.map(User::getPhone)
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
Map<String, User> existingByAccount = new HashMap<>();
|
||||
Map<String, User> existingByPhone = new HashMap<>();
|
||||
userService.list(Wrappers.<User>lambdaQuery()
|
||||
.and(wrapper -> {
|
||||
wrapper.in(User::getAccount, accounts);
|
||||
if (CollectionUtil.isNotEmpty(phones)) {
|
||||
wrapper.or().in(User::getPhone, phones);
|
||||
}
|
||||
})
|
||||
).stream()
|
||||
.peek(userService::decryptPhone)
|
||||
.forEach(user -> {
|
||||
if (StringUtils.isNotBlank(user.getAccount())) {
|
||||
existingByAccount.put(user.getAccount(), user);
|
||||
}
|
||||
if (StringUtils.isNotBlank(user.getPhone())) {
|
||||
existingByPhone.put(user.getPhone(), user);
|
||||
}
|
||||
});
|
||||
Map<String, User> existingByOaLoginId = this.loadUsersByOaLoginId(tenantId, uniquePersonMap.keySet());
|
||||
Map<String, Long> userMap = new HashMap<>();
|
||||
Set<Long> existsUserIds = new HashSet<>();
|
||||
Set<Long> unchangedUserIds = new HashSet<>();
|
||||
users.forEach(user -> {
|
||||
User existingUser = existingByAccount.get(user.getAccount());
|
||||
if (existingUser == null && StringUtils.isNotBlank(user.getPhone())) {
|
||||
existingUser = existingByPhone.get(user.getPhone());
|
||||
}
|
||||
User existingUser = existingByOaLoginId.get(user.getOaLoginId());
|
||||
if (existingUser != null) {
|
||||
user.setId(existingUser.getId());
|
||||
user.setPassword(null);
|
||||
existsUserIds.add(existingUser.getId());
|
||||
if (shouldSkipOaUpdate(existingUser, user)) {
|
||||
unchangedUserIds.add(existingUser.getId());
|
||||
}
|
||||
} else {
|
||||
user.setId(DefaultIdentifierGenerator.getInstance().nextId(null));
|
||||
user.setPostId("-1");
|
||||
@@ -474,9 +441,9 @@ public class OaUserListSyncHelper {
|
||||
user.setDataScopeRange(1);
|
||||
user.setDataLevelRange(1);
|
||||
user.setIncludeNewCustomer(0);
|
||||
userService.encryptPhone(user);
|
||||
}
|
||||
userMap.put(user.getAccount(), user.getId());
|
||||
userService.encryptPhone(user);
|
||||
userMap.put(user.getOaLoginId(), user.getId());
|
||||
});
|
||||
|
||||
List<User> addUsers = users.stream()
|
||||
@@ -488,18 +455,13 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
List<User> updateUsers = users.stream()
|
||||
.filter(user -> existsUserIds.contains(user.getId()))
|
||||
.filter(user -> !unchangedUserIds.contains(user.getId()))
|
||||
.toList();
|
||||
if (CollectionUtil.isNotEmpty(updateUsers)) {
|
||||
ComposeLogUtil.getLastLog().info("批量修改用户:{}", updateUsers.size());
|
||||
userService.updateBatchById(updateUsers);
|
||||
}
|
||||
if (!unchangedUserIds.isEmpty()) {
|
||||
ComposeLogUtil.getLastLog().info("跳过未变更用户:{}", unchangedUserIds.size());
|
||||
}
|
||||
ComposeLogUtil.getLastLog().info("缺少账号已跳过的人员:{}", skippedCount);
|
||||
ComposeLogUtil.getLastLog().info("缺少OA登录名已跳过的人员:{}", skippedCount);
|
||||
int syncedCount = addUsers.size() + updateUsers.size();
|
||||
skippedCount += unchangedUserIds.size();
|
||||
if (userMap.isEmpty()) {
|
||||
return new PersonSyncCount(0, skippedCount);
|
||||
}
|
||||
@@ -511,11 +473,8 @@ public class OaUserListSyncHelper {
|
||||
Set<Long> existsUserDeptIds = new HashSet<>(userDeptMap.values());
|
||||
List<UserDept> userDeptList = oaPersons.stream()
|
||||
.filter(oaPerson -> {
|
||||
String account = userConvert.resolveAccount(oaPerson);
|
||||
if (!userMap.containsKey(account)) {
|
||||
return false;
|
||||
}
|
||||
return !unchangedUserIds.contains(userMap.get(account));
|
||||
String oaLoginId = resolveOaLoginId(oaPerson);
|
||||
return StringUtils.isNotBlank(oaLoginId) && userMap.containsKey(oaLoginId);
|
||||
})
|
||||
.map(oaPerson -> this.buildUserDept(oaPerson, userMap, orgIndex))
|
||||
.filter(Objects::nonNull)
|
||||
@@ -554,7 +513,6 @@ public class OaUserListSyncHelper {
|
||||
.collect(Collectors.toSet());
|
||||
String defaultRoleId = getDefaultRoleId();
|
||||
List<User> updateUserParams = list.stream()
|
||||
.filter(userDeptIds -> !unchangedUserIds.contains(userDeptIds.getUserId()))
|
||||
.filter(userDeptIds -> {
|
||||
if (StringUtils.isBlank(userDeptIds.getDeptIds())) {
|
||||
return false;
|
||||
@@ -586,18 +544,51 @@ public class OaUserListSyncHelper {
|
||||
}
|
||||
|
||||
/**
|
||||
* oaModified 一致则跳过更新;任一侧为空无法确认一致,执行更新以补齐字段
|
||||
* 按 OA 登录名加载已有用户。同一登录名有多条时保留较早的一条,后续同步都更新这一条。
|
||||
*/
|
||||
private boolean shouldSkipOaUpdate(User existing, User incoming) {
|
||||
if (existing == null) {
|
||||
return false;
|
||||
private Map<String, User> loadUsersByOaLoginId(String tenantId, Collection<String> oaLoginIds) {
|
||||
Map<String, User> existingByOaLoginId = new HashMap<>();
|
||||
if (CollectionUtil.isEmpty(oaLoginIds)) {
|
||||
return existingByOaLoginId;
|
||||
}
|
||||
String localModified = StringUtils.trimToEmpty(existing.getOaModified());
|
||||
String remoteModified = StringUtils.trimToEmpty(incoming == null ? null : incoming.getOaModified());
|
||||
if (localModified.isEmpty() || remoteModified.isEmpty()) {
|
||||
return false;
|
||||
List<String> oaLoginIdList = oaLoginIds.stream()
|
||||
.filter(StringUtils::isNotBlank)
|
||||
.distinct()
|
||||
.toList();
|
||||
int batchSize = 500;
|
||||
for (int fromIndex = 0; fromIndex < oaLoginIdList.size(); fromIndex += batchSize) {
|
||||
List<String> oaLoginIdBatch = oaLoginIdList.subList(fromIndex, Math.min(fromIndex + batchSize, oaLoginIdList.size()));
|
||||
userService.list(Wrappers.<User>lambdaQuery()
|
||||
.eq(User::getTenantId, tenantId)
|
||||
.in(User::getOaLoginId, oaLoginIdBatch))
|
||||
.forEach(user -> {
|
||||
String oaLoginId = StringUtils.trimToNull(user.getOaLoginId());
|
||||
if (oaLoginId == null) {
|
||||
return;
|
||||
}
|
||||
User previous = existingByOaLoginId.get(oaLoginId);
|
||||
if (previous == null) {
|
||||
existingByOaLoginId.put(oaLoginId, user);
|
||||
return;
|
||||
}
|
||||
if (user.getId() != null && previous.getId() != null && user.getId() < previous.getId()) {
|
||||
ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}",
|
||||
oaLoginId, user.getId(), previous.getId());
|
||||
existingByOaLoginId.put(oaLoginId, user);
|
||||
return;
|
||||
}
|
||||
ComposeLogUtil.getLastLog().warn("OA登录名重复,保留较早用户 oaLoginId={} keepUserId={} dropUserId={}",
|
||||
oaLoginId, previous.getId(), user.getId());
|
||||
});
|
||||
}
|
||||
return localModified.equals(remoteModified);
|
||||
return existingByOaLoginId;
|
||||
}
|
||||
|
||||
private String resolveOaLoginId(OAPersonResponse oaPerson) {
|
||||
if (oaPerson == null) {
|
||||
return null;
|
||||
}
|
||||
return StringUtils.trimToNull(oaPerson.getLoginid());
|
||||
}
|
||||
|
||||
private boolean isPersonBypassCode(String deptCode, DeptCategory deptCategory) {
|
||||
@@ -638,8 +629,8 @@ public class OaUserListSyncHelper {
|
||||
Dept department = orgIndex.findDept(oaPerson);
|
||||
if (department == null || department.getId() == null) {
|
||||
ComposeLogUtil.getLastLog().warn(
|
||||
"人员未匹配到已有部门,请先同步组织架构 account={} departmentid={} departmentname={} subcompanyid1={}",
|
||||
userConvert.resolveAccount(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(),
|
||||
"人员未匹配到已有部门,请先同步组织架构 oaLoginId={} departmentid={} departmentname={} subcompanyid1={}",
|
||||
resolveOaLoginId(oaPerson), oaPerson.getDepartmentid(), oaPerson.getDepartmentname(),
|
||||
oaPerson.getSubcompanyid1());
|
||||
return null;
|
||||
}
|
||||
|
||||
+1
@@ -366,6 +366,7 @@ public class UserServiceImpl extends BaseServiceImpl<UserMapper, User> implement
|
||||
user.setPostId(null);
|
||||
user.setAccount(null);
|
||||
user.setIsLeader(null);
|
||||
user.setOaLoginId(null);
|
||||
CacheUtil.clear(USER_CACHE);
|
||||
return submitUserInfo(user);
|
||||
}
|
||||
|
||||
+7
-4
@@ -61,8 +61,9 @@ public class BillLedgerController extends BladeController {
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "付款申请可用汇票")
|
||||
public R<List<BillLedgerVO>> availableOptions(@RequestParam(required = false) String keyword,
|
||||
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId) {
|
||||
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId));
|
||||
@RequestParam(required = false) Long deptId, @RequestParam(required = false) Long selectedId,
|
||||
@RequestParam(required = false) String payerName, @RequestParam(required = false) String payeeName) {
|
||||
return R.data(billLedgerService.availableOptions(keyword, deptId, selectedId, payerName, payeeName));
|
||||
}
|
||||
|
||||
@GetMapping("/available-page")
|
||||
@@ -70,8 +71,10 @@ public class BillLedgerController extends BladeController {
|
||||
@Operation(summary = "付款申请可用汇票分页")
|
||||
public R<IPage<BillLedgerVO>> availablePage(Query pageQuery,
|
||||
@RequestParam(required = false) String keyword, @RequestParam(required = false) Long deptId,
|
||||
@RequestParam(required = false) Long selectedId) {
|
||||
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId));
|
||||
@RequestParam(required = false) Long selectedId, @RequestParam(required = false) String payerName,
|
||||
@RequestParam(required = false) String payeeName) {
|
||||
return R.data(billLedgerService.availablePage(Condition.getPage(pageQuery), keyword, deptId, selectedId,
|
||||
payerName, payeeName));
|
||||
}
|
||||
|
||||
@PostMapping("/submit")
|
||||
|
||||
+59
@@ -105,6 +105,65 @@ public class DriverWaybillController extends BladeController {
|
||||
return R.data(driverWaybillService.detail(id));
|
||||
}
|
||||
|
||||
@GetMapping("/task/current")
|
||||
@ApiOperationSupport(order = 21)
|
||||
@Operation(summary = "首页:当前运输中运次", description = "当前用户手机号可见的运输中运次(有配载单的一趟车算一个)最新一条")
|
||||
public R<DriverWaybillCardVO> tripCurrentTask() {
|
||||
return R.data(driverWaybillService.tripCurrentTask());
|
||||
}
|
||||
|
||||
@GetMapping("/task/pending-preview")
|
||||
@ApiOperationSupport(order = 22)
|
||||
@Operation(summary = "首页:待接运次预览", description = "当前用户手机号可见的待接运次预览与总数(按运次计)")
|
||||
public R<DriverWaybillPreviewVO> tripPendingPreview(
|
||||
@Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) {
|
||||
return R.data(driverWaybillService.tripPendingPreview(size));
|
||||
}
|
||||
|
||||
@GetMapping("/task/counts")
|
||||
@ApiOperationSupport(order = 23)
|
||||
@Operation(summary = "运次 Tab 统计", description = "按运次统计:全部 = 待接单 + 进行中 + 已完成(不含已取消)")
|
||||
public R<DriverWaybillTabCountsVO> tripCounts() {
|
||||
return R.data(driverWaybillService.tripTabCounts());
|
||||
}
|
||||
|
||||
@GetMapping("/task/page")
|
||||
@ApiOperationSupport(order = 24)
|
||||
@Operation(summary = "运次分页列表", description = "一页即若干个运次;status:空=全部,0待接单,1运输中,2已完成;keyword 支持运单号/配载单号/起终点/车牌")
|
||||
public R<IPage<DriverWaybillCardVO>> tripPage(
|
||||
@Parameter(description = "当前页") @RequestParam(required = false) Integer current,
|
||||
@Parameter(description = "每页运次数") @RequestParam(required = false) Integer size,
|
||||
@Parameter(description = "状态:0待接单/1运输中/2已完成,不传为全部") @RequestParam(required = false) String status,
|
||||
@Parameter(description = "关键字:运单号/配载单号/起终点/车牌") @RequestParam(required = false) String keyword) {
|
||||
Integer statusCode = parseStatus(status);
|
||||
return R.data(driverWaybillService.tripPage(current, size, statusCode, keyword));
|
||||
}
|
||||
|
||||
@GetMapping("/task/detail")
|
||||
@ApiOperationSupport(order = 25)
|
||||
@Operation(summary = "司机运次详情", description = "按运次主键(字符串)取整趟车;有配载单时下发站点 ①②③(stations)与每站货物条目")
|
||||
public R<DriverWaybillCardVO> tripDetail(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
|
||||
return R.data(driverWaybillService.tripDetail(id));
|
||||
}
|
||||
|
||||
@PostMapping("/task/accept")
|
||||
@ApiOperationSupport(order = 26)
|
||||
@Operation(summary = "运次确认接单", description = "一次接单把该运次全部子运单置为已接单;任一子单已接单即视为整单已接单")
|
||||
public R tripAccept(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id) {
|
||||
return R.status(driverWaybillService.tripAccept(id));
|
||||
}
|
||||
|
||||
@PostMapping("/task/reject")
|
||||
@ApiOperationSupport(order = 27)
|
||||
@Operation(summary = "运次拒绝接单", description = "整单拒绝:该运次全部未终态子运单一起置为已拒绝,此后该运次对本人不可见")
|
||||
public R tripReject(
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键)", required = true) @RequestParam String id,
|
||||
@Parameter(description = "拒绝原因") @RequestParam(required = false) String reason) {
|
||||
return R.status(driverWaybillService.tripReject(id, reason));
|
||||
}
|
||||
|
||||
@PostMapping("/track")
|
||||
@ApiOperationSupport(order = 11)
|
||||
@Operation(summary = "司机端行驶轨迹", description = "按当前用户手机号校验归属后,根据打卡记录/计划时间自动推导起止日期并查询 LBS 轨迹(WGS-84)")
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* THIS SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY. The author is
|
||||
* not liable for any claims arising from secondary or illegal development.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.controller;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tenant.annotation.TenantIgnore;
|
||||
import org.springblade.core.tool.api.R;
|
||||
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.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* HCS 合规回调 Mock 端点
|
||||
* <p>
|
||||
* 合同法务审批结果回调(D103)、付款法务审批结果回调(D104)、项目金蝶同步结果回调(D902)、
|
||||
* 合同金蝶同步结果回传接收(D903)四个入站回调的纯 mock 实现:仅校验来源系统标识后返回固定结果,
|
||||
* 不读写业务数据、不联动状态。真实处理逻辑保留在对应 Support 中,联调时把端点切回
|
||||
* {@link } 并调用 Support 方法即可。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@TenantIgnore
|
||||
@RequestMapping("hcs")
|
||||
@Tag(name = "HCS合规回调Mock", description = "HCS合规回调Mock端点(仅自测,返回固定 mock 数据)")
|
||||
public class HcsMockController {
|
||||
|
||||
/**
|
||||
* 来源系统标识(合规方约定值,与 third-party.hcs.src-system 默认值一致)
|
||||
*/
|
||||
private static final String SOURCE_SYSTEM = "WLTMS";
|
||||
|
||||
/**
|
||||
* 合同法务审批结果回调(D103)
|
||||
*/
|
||||
@PostMapping("/contractLegal/result")
|
||||
@ApiOperationSupport(order = 15)
|
||||
@Operation(summary = "合同法务审批结果回调Mock", description = "D103 合规推送合同特批(金蝶同步)结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> contractLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("合同法务审批结果", param);
|
||||
return R.data("mock特批结果已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 付款法务审批结果回调(D104)
|
||||
*/
|
||||
@PostMapping("/paymentLegal/result")
|
||||
@ApiOperationSupport(order = 16)
|
||||
@Operation(summary = "付款法务审批结果回调Mock", description = "D104 合规推送付款特批结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> paymentLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("付款法务审批结果", param);
|
||||
return R.data("mock付款特批结果接收已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目金蝶同步结果回调(D902)
|
||||
*/
|
||||
@PostMapping("/projectLegal/result")
|
||||
@ApiOperationSupport(order = 18)
|
||||
@Operation(summary = "项目金蝶同步结果回调Mock", description = "D902 合规推送项目金蝶同步结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> projectLegalResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("项目金蝶同步结果", param);
|
||||
return R.data("mock项目金蝶同步结果接收已处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 合同金蝶同步结果回传接收(D903)
|
||||
*/
|
||||
@PostMapping("/contractKingdee/result")
|
||||
@ApiOperationSupport(order = 19)
|
||||
@Operation(summary = "合同金蝶同步结果回传接收Mock", description = "D903 合规推送合同金蝶最终同步结果,仅校验来源系统并返回 mock 数据")
|
||||
public R<String> contractKingdeeResult(@RequestBody(required = false) Map<String, Object> param) {
|
||||
this.checkSourceSystem("合同金蝶同步结果", param);
|
||||
return R.data("mock合同金蝶同步结果接收处理:approved");
|
||||
}
|
||||
|
||||
/**
|
||||
* 来源系统校验:仅接受合规方约定的来源标识,防伪造回传
|
||||
*
|
||||
* @param bizName 业务名称(日志用)
|
||||
* @param param 回调报文
|
||||
*/
|
||||
private void checkSourceSystem(String bizName, Map<String, Object> param) {
|
||||
String sourceSystem = param == null ? null : Objects.toString(param.get("sourceSystem"), null);
|
||||
if (!StrUtil.equalsIgnoreCase(sourceSystem, SOURCE_SYSTEM)) {
|
||||
log.warn("{}推送来源系统标识错误:sourceSystem={}", bizName, sourceSystem);
|
||||
throw new ServiceException("来源系统标识错误");
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
-3
@@ -39,6 +39,7 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO;
|
||||
import org.springblade.transport.pojo.vo.WaybillTrackVO;
|
||||
import org.springblade.transport.service.IManageWaybillService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -69,7 +70,7 @@ public class ManageWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/stats")
|
||||
@ApiOperationSupport(order = 1)
|
||||
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;租户内不过滤组织(小程序调度账号组织常与运单不一致);在途异常=异常处置状态≠已完成")
|
||||
@Operation(summary = "运单状态统计", description = "待接单=pending,运输中=running,已完成=completed;只统计本组织的运单(超级管理员不过滤组织);在途异常=异常处置状态≠已完成")
|
||||
public R<AdminHomeStatsVO> stats() {
|
||||
return R.data(manageWaybillService.stats());
|
||||
}
|
||||
@@ -99,9 +100,9 @@ public class ManageWaybillController extends BladeController {
|
||||
|
||||
@GetMapping("/detail")
|
||||
@ApiOperationSupport(order = 4)
|
||||
@Operation(summary = "运单详情", description = "调度端查看运单详情(含 punchNodes / enrouteRecords),不校验司机归属与组织;字段对齐小程序 pages/waybill/detail")
|
||||
@Operation(summary = "运次详情", description = "调度端查看整趟车详情(含 stations / punchNodes / enrouteRecords),不校验司机归属与组织;id 传运次主键字符串,已拒单的运单主键回落成单票视图")
|
||||
public R<AdminWaybillDetailVO> detail(
|
||||
@Parameter(description = "运单ID", required = true) @RequestParam Long id) {
|
||||
@Parameter(description = "运次主键(配载单主键或运单主键,字符串)", required = true) @RequestParam String id) {
|
||||
return R.data(manageWaybillService.detail(id));
|
||||
}
|
||||
|
||||
@@ -159,4 +160,14 @@ public class ManageWaybillController extends BladeController {
|
||||
return R.status(waybillService.savePunchRecord(request));
|
||||
}
|
||||
|
||||
@PostMapping("/track")
|
||||
@ApiOperationSupport(order = 11)
|
||||
@Operation(summary = "车辆历史轨迹", description = "小程序调度端:仅需登录态,按运单绑定车牌和日期区间查询 LBS 轨迹")
|
||||
public R<WaybillTrackVO> track(
|
||||
@Parameter(description = "运单ID", required = true) @RequestParam Long id,
|
||||
@Parameter(description = "开始日期 YYYY-MM-DD", required = true) @RequestParam String startDate,
|
||||
@Parameter(description = "结束日期 YYYY-MM-DD", required = true) @RequestParam String endDate) {
|
||||
return R.data(waybillService.trackVehicle(id, startDate, endDate));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+7
@@ -373,6 +373,13 @@ public class WaybillController extends BladeController {
|
||||
return R.data(waybillService.batchComplete(ids));
|
||||
}
|
||||
|
||||
@PostMapping("/confirm-return")
|
||||
@ApiOperationSupport(order = 24)
|
||||
@Operation(summary = "确认回单", description = "回单节点要求业务员确认时用:该运单的司机打卡节点全部已打卡且无未了结异常,才置为已完成")
|
||||
public R confirmReturn(@Parameter(description = "主键", required = true) @RequestParam Long id) {
|
||||
return R.status(waybillService.confirmReturnReceipt(id));
|
||||
}
|
||||
|
||||
@PostMapping("/road-loading")
|
||||
@ApiOperationSupport(order = 23)
|
||||
@Operation(summary = "公路配载", description = "传入ids")
|
||||
|
||||
+30
-10
@@ -54,7 +54,8 @@ import java.util.Set;
|
||||
/**
|
||||
* 导入结算单 Excel 读取器(Apache POI)。
|
||||
* <p>
|
||||
* 定位「*运输费」或「运输费」列,其后直到「结算金额」为动态费用列。
|
||||
* 定位「*运输费」或「运输费」列,其后直到日期/备注等固定列(兼容旧模板中的「结算金额」)为动态费用列。
|
||||
* 结算金额不由模板填写,解析时按「运输费 + 各费用项」自动合计。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -66,6 +67,14 @@ public final class SettlementImportFeeReader {
|
||||
"actualStartDate", "actualEndDate", "planStartDate", "planEndDate"
|
||||
);
|
||||
private static final Set<String> OPTIONAL_DECIMAL_FIELDS = Set.of("mileage", "unitPrice");
|
||||
/** 费用列结束边界:其后为固定业务列;含旧模板「结算金额」以便兼容历史文件 */
|
||||
private static final Set<String> FEE_END_HEADERS = Set.of(
|
||||
"结算金额",
|
||||
"*实际发货时间", "实际发货时间",
|
||||
"*实际完成时间", "实际完成时间",
|
||||
"预计发货时间", "预计完成时间",
|
||||
"备注", "同一运单标识号"
|
||||
);
|
||||
|
||||
private SettlementImportFeeReader() {
|
||||
}
|
||||
@@ -119,15 +128,12 @@ public final class SettlementImportFeeReader {
|
||||
if (transportFeeColumn < 0) {
|
||||
throw new ServiceException("未找到「*运输费」或「运输费」列");
|
||||
}
|
||||
int settlementAmountColumn = findColumnExact(headers, "结算金额");
|
||||
List<Integer> feeColumns = new ArrayList<>();
|
||||
List<String> feeItemNames = new ArrayList<>();
|
||||
int endColumn = settlementAmountColumn > transportFeeColumn
|
||||
? settlementAmountColumn
|
||||
: headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1;
|
||||
int endColumn = findFeeEndColumn(headers, transportFeeColumn);
|
||||
for (int column = transportFeeColumn + 1; column < endColumn; column++) {
|
||||
String name = headers.get(column);
|
||||
if (name == null || name.isBlank() || "结算金额".equals(name)) {
|
||||
if (name == null || name.isBlank() || FEE_END_HEADERS.contains(name)) {
|
||||
continue;
|
||||
}
|
||||
feeColumns.add(column);
|
||||
@@ -229,7 +235,8 @@ public final class SettlementImportFeeReader {
|
||||
} else if ("transportFee".equals(field) || "quantity".equals(field)) {
|
||||
mapped.put(field, decimal(cellString(poiCell), trimmedHeader, excelRowIndex));
|
||||
} else if ("settlementAmount".equals(field)) {
|
||||
mapped.put(field, optionalDecimal(cellString(poiCell), trimmedHeader, excelRowIndex));
|
||||
// 旧模板可能仍含结算金额列,读入后忽略,统一按运输费+费用项自动合计
|
||||
continue;
|
||||
} else if ("serialNumber".equals(field)) {
|
||||
mapped.put(field, intValue(cellString(poiCell)));
|
||||
} else {
|
||||
@@ -255,9 +262,7 @@ public final class SettlementImportFeeReader {
|
||||
BigDecimal feeItemTotal = feeItems.values().stream()
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add)
|
||||
.setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal settlementAmount = mapped.get("settlementAmount") == null
|
||||
? transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP)
|
||||
: toDecimal(mapped.get("settlementAmount"));
|
||||
BigDecimal settlementAmount = transportFee.add(feeItemTotal).setScale(2, RoundingMode.HALF_UP);
|
||||
mapped.put("settlementAmount", settlementAmount);
|
||||
mapped.put("结算金额", settlementAmount);
|
||||
mapped.put("feeItems", feeItems);
|
||||
@@ -284,6 +289,21 @@ public final class SettlementImportFeeReader {
|
||||
return findColumnExact(headers, "运输费");
|
||||
}
|
||||
|
||||
/** 费用列右边界:取运输费之后第一个固定结束表头列下标;无则取表头最大下标 + 1。 */
|
||||
private static int findFeeEndColumn(Map<Integer, String> headers, int transportFeeColumn) {
|
||||
int endColumn = headers.keySet().stream().mapToInt(Integer::intValue).max().orElse(transportFeeColumn) + 1;
|
||||
for (Map.Entry<Integer, String> entry : headers.entrySet()) {
|
||||
int column = entry.getKey();
|
||||
if (column <= transportFeeColumn) {
|
||||
continue;
|
||||
}
|
||||
if (FEE_END_HEADERS.contains(entry.getValue()) && column < endColumn) {
|
||||
endColumn = column;
|
||||
}
|
||||
}
|
||||
return endColumn;
|
||||
}
|
||||
|
||||
private static int findColumnExact(Map<Integer, String> headers, String header) {
|
||||
return headers.entrySet().stream()
|
||||
.filter(entry -> header.equals(entry.getValue()))
|
||||
|
||||
+3
-6
@@ -37,7 +37,7 @@ import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
/**
|
||||
* 导入结算单 Excel 模板(固定列 + 运输单价 + *运输费 + 示例费用项)
|
||||
* 导入结算单 Excel 固定列模型(失败明细兜底导出;费用项由用户在原模板中自行增列)。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -96,11 +96,8 @@ public class SettlementImportTemplateExcel implements Serializable {
|
||||
private BigDecimal unitPrice;
|
||||
@ExcelProperty("*运输费")
|
||||
private BigDecimal transportFee;
|
||||
@ExcelProperty("装卸费")
|
||||
private BigDecimal loadingFee;
|
||||
@ExcelProperty("过路费")
|
||||
private BigDecimal tollFee;
|
||||
@ExcelProperty("结算金额")
|
||||
/** 模板已去掉结算金额列;仅失败明细内部保留,不导出为表头 */
|
||||
@ExcelIgnore
|
||||
private BigDecimal settlementAmount;
|
||||
@ExcelProperty("*实际发货时间")
|
||||
private String actualStartDate;
|
||||
|
||||
+25
-34
@@ -47,12 +47,12 @@ import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 导入结算单模板导出:填写说明 + 数据表头 + 运输方式下拉 + 系统费用项列。
|
||||
* 导入结算单模板导出:填写说明 + 固定数据表头 + 运输方式下拉。
|
||||
* <p>
|
||||
* 模板不预置费用项列;用户可在「*运输费」与后续固定列之间自行插入系统已启用的费用项列。
|
||||
*
|
||||
* @author Chill
|
||||
*/
|
||||
@@ -85,7 +85,6 @@ public final class SettlementImportTemplateExporter {
|
||||
);
|
||||
|
||||
private static final List<String> FIXED_HEADERS_AFTER_FEE = List.of(
|
||||
"结算金额",
|
||||
"*实际发货时间",
|
||||
"*实际完成时间",
|
||||
"预计发货时间",
|
||||
@@ -94,15 +93,18 @@ public final class SettlementImportTemplateExporter {
|
||||
"同一运单标识号"
|
||||
);
|
||||
|
||||
private static final List<String> DEFAULT_FEE_ITEMS = List.of("装卸费", "过路费");
|
||||
|
||||
private SettlementImportTemplateExporter() {
|
||||
}
|
||||
|
||||
public static void export(HttpServletResponse response, List<String> transportTypeOptions,
|
||||
List<String> feeItemNames) {
|
||||
List<String> headers = buildHeaders(feeItemNames);
|
||||
List<String> transportOptions = defaultTransportOptions(transportTypeOptions);
|
||||
List<String> headers = buildHeaders();
|
||||
// 运输方式由调用方传入业务字典 transport_type 的字典名称,禁止写死
|
||||
List<String> transportOptions = transportTypeOptions == null ? List.of() : transportTypeOptions.stream()
|
||||
.filter(item -> item != null && !item.isBlank())
|
||||
.map(String::trim)
|
||||
.distinct()
|
||||
.toList();
|
||||
try (Workbook workbook = new XSSFWorkbook()) {
|
||||
writeInstructionSheet(workbook, transportOptions, feeItemNames);
|
||||
Sheet dataSheet = workbook.createSheet("导入结算单模板");
|
||||
@@ -128,20 +130,9 @@ public final class SettlementImportTemplateExporter {
|
||||
}
|
||||
}
|
||||
|
||||
private static List<String> buildHeaders(List<String> feeItemNames) {
|
||||
/** 固定列模板:不预置费用项列,由用户自行插入。 */
|
||||
private static List<String> buildHeaders() {
|
||||
List<String> headers = new ArrayList<>(FIXED_HEADERS_BEFORE_FEE);
|
||||
Set<String> feeNames = new LinkedHashSet<>();
|
||||
if (feeItemNames != null) {
|
||||
for (String name : feeItemNames) {
|
||||
if (name != null && !name.isBlank()) {
|
||||
feeNames.add(name.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
if (feeNames.isEmpty()) {
|
||||
feeNames.addAll(DEFAULT_FEE_ITEMS);
|
||||
}
|
||||
headers.addAll(feeNames);
|
||||
headers.addAll(FIXED_HEADERS_AFTER_FEE);
|
||||
return headers;
|
||||
}
|
||||
@@ -155,18 +146,24 @@ public final class SettlementImportTemplateExporter {
|
||||
titleStyle.setFont(titleFont);
|
||||
List<String> lines = new ArrayList<>();
|
||||
lines.add("导入结算单填写说明");
|
||||
lines.add("1. 请不要修改「导入结算单模板」工作表的表头;带 * 号为必填项。");
|
||||
lines.add("2. 运输方式请从下拉项中选择,须与系统字典一致。");
|
||||
lines.add("1. 请不要修改「导入结算单模板」工作表的固定表头;带 * 号为必填项。");
|
||||
lines.add("2. 运输方式请从下拉项中选择,须与业务字典 transport_type 的字典名称一致。");
|
||||
lines.add("3. 日期请填写 yyyy-MM-dd(如 2026-01-01);Excel 日期单元格也可直接选择。");
|
||||
lines.add("4. 里程(km) 可为空;填写时须为正数。");
|
||||
lines.add("5. 「*运输费」之后、「结算金额」之前的列为费用项,列名须与系统费用项主数据名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
|
||||
lines.add("6. 结算金额 = 运输费 + 各费用项合计,系统会按此重算。");
|
||||
lines.add("7. 当前可选运输方式:" + String.join("、", transportOptions) + "。");
|
||||
lines.add("5. 「*运输费」之后、「*实际发货时间」之前可按需自行插入费用项列,列名须与系统已启用费用项名称完全一致;金额不为 0 时若不存在或已停用则无法导入。");
|
||||
lines.add("6. 模板不含结算金额列;导入后系统按「运输费 + 各费用项」自动合计结算金额。");
|
||||
if (transportOptions != null && !transportOptions.isEmpty()) {
|
||||
lines.add("7. 当前可选运输方式(业务字典 transport_type 名称):"
|
||||
+ String.join("、", transportOptions) + "。");
|
||||
} else {
|
||||
lines.add("7. 运输方式请从业务字典 transport_type 维护的名称中选择。");
|
||||
}
|
||||
if (feeItemNames != null && !feeItemNames.isEmpty()) {
|
||||
lines.add("8. 当前启用费用项示例:" + String.join("、", feeItemNames.stream().limit(30).toList())
|
||||
lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用费用项一致;当前系统已启用费用项:"
|
||||
+ String.join("、", feeItemNames.stream().limit(30).toList())
|
||||
+ (feeItemNames.size() > 30 ? " 等" : "") + "。");
|
||||
} else {
|
||||
lines.add("8. 模板默认包含装卸费、过路费列,可按系统费用项主数据增删费用列。");
|
||||
lines.add("8. 费用项请按实际需要在模板中自行增列,列名须与系统已启用的费用项主数据名称完全一致。");
|
||||
}
|
||||
for (int index = 0; index < lines.size(); index++) {
|
||||
Row row = sheet.createRow(index);
|
||||
@@ -202,10 +199,4 @@ public final class SettlementImportTemplateExporter {
|
||||
return style;
|
||||
}
|
||||
|
||||
private static List<String> defaultTransportOptions(List<String> transportTypeOptions) {
|
||||
if (transportTypeOptions != null && !transportTypeOptions.isEmpty()) {
|
||||
return transportTypeOptions;
|
||||
}
|
||||
return List.of("公路运输", "铁路运输", "水路运输", "航空运输");
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -102,7 +102,8 @@
|
||||
FROM blade_receipt_claim c
|
||||
INNER JOIN blade_kingdee_receipt_flow f ON f.id = c.receipt_flow_id
|
||||
WHERE <include refid="claimRecordConditions"/>
|
||||
ORDER BY c.create_time DESC
|
||||
<!-- 认领时间倒序:存量认领记录的 create_time 为空,排序改以认领日期为主、id 兜同日先后 -->
|
||||
ORDER BY c.claim_date DESC, c.id DESC
|
||||
</select>
|
||||
|
||||
<select id="selectClaimRecordDetail" resultType="org.springblade.transport.pojo.vo.ReceiptClaimRecordVO">
|
||||
|
||||
+3
-2
@@ -18,8 +18,9 @@ public interface IBillLedgerService extends BaseService<BillLedger> {
|
||||
IPage<BillLedgerVO> selectPage(IPage<BillLedger> page, BillLedgerVO query);
|
||||
BillLedgerVO detail(Long id);
|
||||
Map<String, Long> expiryCounts();
|
||||
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId);
|
||||
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId);
|
||||
List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId, String payerName, String payeeName);
|
||||
IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId, Long selectedId,
|
||||
String payerName, String payeeName);
|
||||
Long submit(BillLedgerSaveRequest request);
|
||||
void removeLedger(Long id);
|
||||
}
|
||||
|
||||
+47
@@ -71,6 +71,53 @@ public interface IDriverWaybillService {
|
||||
*/
|
||||
DriverWaybillCardVO detail(Long id);
|
||||
|
||||
/**
|
||||
* 当前登录司机的运输中运次(一趟车,最多一条)
|
||||
*/
|
||||
DriverWaybillCardVO tripCurrentTask();
|
||||
|
||||
/**
|
||||
* 当前登录司机的待接运次预览
|
||||
*
|
||||
* @param size 预览条数,默认 2
|
||||
*/
|
||||
DriverWaybillPreviewVO tripPendingPreview(Integer size);
|
||||
|
||||
/**
|
||||
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成(全部 = 前三者之和,不含已取消)
|
||||
*/
|
||||
DriverWaybillTabCountsVO tripTabCounts();
|
||||
|
||||
/**
|
||||
* 司机运次分页:一页即若干个运次(有配载单的一趟车算一个)
|
||||
*
|
||||
* @param current 当前页,从 1 开始
|
||||
* @param size 每页条数(按运次,默认 10)
|
||||
* @param status 小程序状态:空=全部,0待接单,1运输中,2已完成
|
||||
* @param keyword 关键字(运单号 / 配载单号 / 起终点 / 车牌,可选)
|
||||
*/
|
||||
IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword);
|
||||
|
||||
/**
|
||||
* 司机运次详情:按运次主键(字符串)取整趟车,有配载单时下发站点 ①②③
|
||||
*/
|
||||
DriverWaybillCardVO tripDetail(String tripId);
|
||||
|
||||
/**
|
||||
* 运次确认接单:一次把该运次全部子运单置为已接单,司机字段不动
|
||||
*
|
||||
* @param tripId 运次主键(配载单主键或运单主键)
|
||||
*/
|
||||
boolean tripAccept(String tripId);
|
||||
|
||||
/**
|
||||
* 运次拒绝接单:整单拒绝,该运次此后对本人不可见,等调度重新派单
|
||||
*
|
||||
* @param tripId 运次主键(配载单主键或运单主键)
|
||||
* @param reason 拒绝原因,可空,最长 200 字
|
||||
*/
|
||||
boolean tripReject(String tripId, String reason);
|
||||
|
||||
/**
|
||||
* 司机端运单行驶轨迹:按打卡记录 / 计划时间自动推导起止日期后查询 LBS 轨迹。
|
||||
* <p>
|
||||
|
||||
+5
-2
@@ -63,9 +63,12 @@ public interface IManageWaybillService {
|
||||
String exception, String transportType, String startDate, String endDate);
|
||||
|
||||
/**
|
||||
* 调度端运单详情(不校验司机归属)
|
||||
* 调度端运次详情(不校验司机归属)
|
||||
* <p>
|
||||
* 传运次主键:配载单主键或未配载运单主键,都按字符串。已拒单的子运单按运次取不到(运次口径
|
||||
* 不含已拒子单),此时按运单主键回落成单票视图,供重新派单等从运单进入的场景使用。
|
||||
*/
|
||||
AdminWaybillDetailVO detail(Long id);
|
||||
AdminWaybillDetailVO detail(String tripId);
|
||||
|
||||
/**
|
||||
* 待处理运单(待接单 / 运输中;可筛需重新派单)
|
||||
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
|
||||
/**
|
||||
* 运次读服务
|
||||
* <p>
|
||||
* 「一卡一运次」的取数、状态聚合、接单需求合并与分页只在这里实现一次,司机端与管理端列表都调它,
|
||||
* 各自只做字段裁剪。聚合全程只读,不在读路径上写业务状态——分档只按过程配置与接单态推导,
|
||||
* 不按库里的业务状态,也不借读路径做惰性校正。
|
||||
*/
|
||||
public interface ITripQueryService {
|
||||
|
||||
/**
|
||||
* 运次分页:一页就是若干个运次(有配载单的一趟车算一个)。
|
||||
* <p>
|
||||
* 库层只取回收口与行级筛选命中的运单行,分组、分档、筛选、排序与分页都在服务层算完再做。
|
||||
*
|
||||
* @param query 查询条件,收口由 driverPhone 或 allDrivers 给出
|
||||
* @return 运次分页
|
||||
*/
|
||||
IPage<TripVO> page(TripQueryVO query);
|
||||
|
||||
/**
|
||||
* 运次 Tab 统计:全部 / 待接单 / 进行中 / 已完成,分档口径与 page 完全一致
|
||||
*
|
||||
* @param query 查询条件,口径同 page
|
||||
* @return 运次计数
|
||||
*/
|
||||
TripTabCountsVO tabCounts(TripQueryVO query);
|
||||
|
||||
/**
|
||||
* 按运次主键取单个运次(含子运单),主键为字符串
|
||||
*
|
||||
* @param query 查询条件,tripId 必填
|
||||
* @return 运次
|
||||
*/
|
||||
TripVO detail(TripQueryVO query);
|
||||
|
||||
}
|
||||
+8
@@ -113,6 +113,14 @@ public interface IWaybillService extends BaseService<Waybill> {
|
||||
*/
|
||||
boolean completeWithoutDeptCheck(Long id);
|
||||
|
||||
/**
|
||||
* 确认回单:回单节点配成「需业务员确认」时,内部人员确认后把运单置为已完成。
|
||||
* <p>
|
||||
* 比 {@link #complete(Long)} 多两道校验:该运单的司机打卡节点(在途除外)必须全部已打卡,
|
||||
* 且没有未了结异常——不挡住就等于借这个入口绕过打卡门禁与异常门禁。
|
||||
*/
|
||||
boolean confirmReturnReceipt(Long id);
|
||||
|
||||
BusinessRemoveResultVO batchComplete(String ids);
|
||||
LoadingManageVO roadLoading(String ids);
|
||||
|
||||
|
||||
+38
-9
@@ -86,14 +86,15 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId) {
|
||||
return availableList(keyword, deptId, selectedId);
|
||||
public List<BillLedgerVO> availableOptions(String keyword, Long deptId, Long selectedId, String payerName,
|
||||
String payeeName) {
|
||||
return availableList(keyword, deptId, selectedId, payerName, payeeName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<BillLedgerVO> availablePage(IPage<BillLedger> page, String keyword, Long deptId,
|
||||
Long selectedId) {
|
||||
List<BillLedgerVO> available = availableList(keyword, deptId, selectedId);
|
||||
Long selectedId, String payerName, String payeeName) {
|
||||
List<BillLedgerVO> available = availableList(keyword, deptId, selectedId, payerName, payeeName);
|
||||
long current = Math.max(page.getCurrent(), 1);
|
||||
long size = Math.max(page.getSize(), 1);
|
||||
long from = Math.min((current - 1) * size, available.size());
|
||||
@@ -103,27 +104,50 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
return result;
|
||||
}
|
||||
|
||||
private List<BillLedgerVO> availableList(String keyword, Long deptId, Long selectedId) {
|
||||
private List<BillLedgerVO> availableList(String keyword, Long deptId, Long selectedId, String payerName,
|
||||
String payeeName) {
|
||||
LocalDate today = LocalDate.now();
|
||||
// 付款申请仅可选:收票类型、未到期、有可用余额;已选中的汇票始终带回以便回显
|
||||
// 未到期且有余额;收票且部门可用,或出票/收票单位与结算收付款方一致(开票、收票都可)
|
||||
return list(Wrappers.<BillLedger>lambdaQuery()
|
||||
.and(Func.isNotEmpty(keyword), wrapper -> wrapper.like(BillLedger::getBillNo, keyword)
|
||||
.or().like(BillLedger::getIssuerName, keyword)
|
||||
.or().like(BillLedger::getReceiverName, keyword))
|
||||
.and(wrapper -> wrapper
|
||||
.and(available -> available
|
||||
.eq(BillLedger::getBillType, "received")
|
||||
.ge(BillLedger::getMaturityDate, today)
|
||||
.gt(BillLedger::getAvailableBalance, BigDecimal.ZERO))
|
||||
.or(selectedId != null, child -> child.eq(BillLedger::getId, selectedId)))
|
||||
.orderByAsc(BillLedger::getMaturityDate)
|
||||
.orderByDesc(BillLedger::getCreateTime)).stream()
|
||||
.filter(item -> selectedId != null && Objects.equals(item.getId(), selectedId)
|
||||
|| departmentAvailable(item, deptId))
|
||||
|| selectableBill(item, deptId, payerName, payeeName))
|
||||
.map(item -> BillLedgerWrapper.build().entityVO(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 收票且当前部门在可用范围内,或出票单位、收票单位与结算付款方、收款方一致(顺序不限)。
|
||||
*/
|
||||
private boolean selectableBill(BillLedger ledger, Long deptId, String payerName, String payeeName) {
|
||||
boolean receivedAndDept = "received".equals(ledger.getBillType()) && departmentAvailable(ledger, deptId);
|
||||
return receivedAndDept || partiesMatch(ledger.getIssuerName(), ledger.getReceiverName(), payerName, payeeName);
|
||||
}
|
||||
|
||||
private boolean partiesMatch(String issuerName, String receiverName, String payerName, String payeeName) {
|
||||
String issuer = trimName(issuerName);
|
||||
String receiver = trimName(receiverName);
|
||||
String payer = trimName(payerName);
|
||||
String payee = trimName(payeeName);
|
||||
if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer));
|
||||
}
|
||||
|
||||
private String trimName(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long submit(BillLedgerSaveRequest request) {
|
||||
@@ -141,6 +165,11 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
}
|
||||
CustomerArchive issuer = customer(request.getIssuerId(), "出票单位");
|
||||
CustomerArchive feeBearer = customer(request.getFeeBearerId(), "费用承担方");
|
||||
String receiverName = required(request.getReceiverName(), "收票单位", 100);
|
||||
if (!Objects.equals(feeBearer.getId(), issuer.getId())
|
||||
&& !customerName(feeBearer).equals(receiverName)) {
|
||||
throw new ServiceException("费用承担方只能选择收票单位或出票单位");
|
||||
}
|
||||
BigDecimal faceAmount = positive(request.getFaceAmount(), "票面金额").setScale(2, RoundingMode.HALF_UP);
|
||||
BigDecimal usedAmount = entity.getId() == null ? BigDecimal.ZERO : activeUsedAmount(entity.getId());
|
||||
if (faceAmount.compareTo(usedAmount) < 0) {
|
||||
@@ -150,7 +179,7 @@ public class BillLedgerServiceImpl extends BaseServiceImpl<BillLedgerMapper, Bil
|
||||
entity.setBillNo(billNo);
|
||||
entity.setIssuerId(issuer.getId());
|
||||
entity.setIssuerName(customerName(issuer));
|
||||
entity.setReceiverName(required(request.getReceiverName(), "收票单位", 100));
|
||||
entity.setReceiverName(receiverName);
|
||||
entity.setBillType(request.getBillType());
|
||||
entity.setFaceAmount(faceAmount);
|
||||
entity.setAvailableBalance(faceAmount.subtract(usedAmount).setScale(2, RoundingMode.HALF_UP));
|
||||
|
||||
+4
@@ -354,6 +354,10 @@ public class CertAuditServiceImpl implements ICertAuditService {
|
||||
requireIdCardNotTaken(snapshot.getIdCardNo(), driver.getId());
|
||||
driver.setDriverName(snapshot.getRealName());
|
||||
driver.setIdCardNo(snapshot.getIdCardNo());
|
||||
driver.setBirthday(snapshot.getBirthday());
|
||||
// 空串不是「没填」:框架的 not_null 策略只跳过 null,写空串会把管理端填过的住址清掉
|
||||
driver.setAddressRegion(blankToNull(snapshot.getAddressRegion()));
|
||||
driver.setAddress(blankToNull(snapshot.getAddress()));
|
||||
driver.setIdCardFront(snapshot.getIdCardFrontUrl());
|
||||
driver.setIdCardBack(snapshot.getIdCardBackUrl());
|
||||
driver.setIdCardValidStart(snapshot.getIdCardValidStart());
|
||||
|
||||
+39
-4
@@ -298,6 +298,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
validateBillingPlans(request.getBillingPlanJson());
|
||||
validatePaymentRatios(request.getPaymentRatioJson());
|
||||
Map<String, Object> beforeData = buildContractChangeSnapshot(source);
|
||||
applyContractNo(source, request.getContractNo(), false);
|
||||
source.setContractName(request.getContractName()); source.setPartyB(request.getPartyB());
|
||||
source.setStartDate(request.getStartDate()); source.setEndDate(request.getEndDate()); source.setContractFormat(request.getContractFormat());
|
||||
source.setSettlementMode(request.getSettlementMode()); source.setLegalSealFlag(request.getLegalSealFlag()); source.setCopyCount(request.getCopyCount());
|
||||
@@ -507,8 +508,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setOrganizationId(TransportBusinessSupport.currentDeptId("合同管理"));
|
||||
contractManage.setOrganizationName(TransportBusinessSupport.currentDept("合同管理").getDeptName());
|
||||
}
|
||||
// 新增始终按部门规则生成合同编号,避免前端预览号并发冲突
|
||||
contractManage.setContractNo(nextContractNo(contractManage.getOrganizationId()));
|
||||
applyContractNo(contractManage, contractManage.getContractNo(), true);
|
||||
if (Func.isEmpty(contractManage.getHandlerUserId())) {
|
||||
contractManage.setHandlerUserId(AuthUtil.getUserId());
|
||||
contractManage.setHandlerUserName(AuthUtil.getUserName());
|
||||
@@ -516,7 +516,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
contractManage.setArchiveStatus(ARCHIVE_STATUS_PENDING);
|
||||
} else {
|
||||
ContractManage oldRecord = loadExists(contractManage.getId());
|
||||
contractManage.setContractNo(oldRecord.getContractNo());
|
||||
applyContractNo(contractManage, contractManage.getContractNo(), false);
|
||||
if (Func.isEmpty(contractManage.getOrganizationId())) {
|
||||
contractManage.setOrganizationId(oldRecord.getOrganizationId());
|
||||
contractManage.setOrganizationName(oldRecord.getOrganizationName());
|
||||
@@ -535,6 +535,28 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存合同编号。新增未填写时按部门规则生成,已填写或编辑时保留手工编号并校验唯一。
|
||||
*/
|
||||
private void applyContractNo(ContractManage contractManage, String contractNo, boolean generateWhenEmpty) {
|
||||
String normalizedContractNo = TransportBusinessSupport.trimToNull(contractNo);
|
||||
if (Func.isEmpty(normalizedContractNo)) {
|
||||
if (!generateWhenEmpty) {
|
||||
throw new ServiceException("请输入合同编号");
|
||||
}
|
||||
normalizedContractNo = nextContractNo(contractManage.getOrganizationId());
|
||||
}
|
||||
TransportBusinessSupport.validateLength(normalizedContractNo, 100, "合同编号不能超过100字");
|
||||
Long duplicatedCount = count(Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getContractNo, normalizedContractNo)
|
||||
.ne(Func.isNotEmpty(contractManage.getId()), ContractManage::getId, contractManage.getId()));
|
||||
if (duplicatedCount != null && duplicatedCount > 0) {
|
||||
throw new ServiceException("合同编号已存在");
|
||||
}
|
||||
contractManage.setContractNo(normalizedContractNo);
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized String nextContractNo(Long organizationId) {
|
||||
if (Func.isEmpty(organizationId)) {
|
||||
@@ -558,7 +580,8 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
.orderByDesc(ContractManage::getContractNo)
|
||||
.last("LIMIT 50"));
|
||||
Pattern pattern = Pattern.compile("^" + Pattern.quote(prefix) + "(" + year + ")(\\d{3})号$");
|
||||
int next = 1;
|
||||
// 当年尚无匹配编号时,从组织配置的初始流水号起编;否则在已有最大流水号上加1
|
||||
int next = resolveContractInitialSerial(dept);
|
||||
for (ContractManage item : latestList) {
|
||||
String contractNo = TransportBusinessSupport.trimToNull(item.getContractNo());
|
||||
if (Func.isEmpty(contractNo)) {
|
||||
@@ -577,6 +600,17 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
return head + String.format("%03d", next) + "号";
|
||||
}
|
||||
|
||||
private int resolveContractInitialSerial(Dept dept) {
|
||||
Integer serial = dept.getContractInitialSerial();
|
||||
if (serial == null || serial < 1) {
|
||||
return 1;
|
||||
}
|
||||
if (serial > 999) {
|
||||
throw new ServiceException("组织机构中配置的合同初始流水号不能超过999");
|
||||
}
|
||||
return serial;
|
||||
}
|
||||
|
||||
private void refreshArchiveStatusOnApprove(ContractManage contractManage) {
|
||||
if (hasSealArchiveFile(contractManage.getContractFileJson())) {
|
||||
contractManage.setArchiveStatus(ARCHIVE_STATUS_ARCHIVED);
|
||||
@@ -1015,6 +1049,7 @@ public class ContractManageServiceImpl extends BaseServiceImpl<ContractManageMap
|
||||
|
||||
private Map<String, Object> buildContractChangeSnapshot(ContractManage contractManage) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("contractNo", contractManage.getContractNo());
|
||||
data.put("contractName", contractManage.getContractName());
|
||||
data.put("partyB", contractManage.getPartyB());
|
||||
data.put("startDate", contractManage.getStartDate());
|
||||
|
||||
+507
-84
@@ -28,6 +28,7 @@ import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.BladeUser;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
@@ -45,24 +46,31 @@ import org.springblade.transport.pojo.dto.PunchPhotoDTO;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.TransportVehicle;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
import org.springblade.transport.support.VehiclePlateSupport;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
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.VehiclePlateSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO;
|
||||
import org.springblade.transport.pojo.vo.DriverNodePunchVO;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchPhotoVO;
|
||||
import org.springblade.transport.pojo.vo.CargoLineVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
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.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -130,6 +138,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
/** 被异常阻断时的不可打卡原因(司机端展示) */
|
||||
private static final String LOCK_REASON_EXCEPTION = "该运单存在未处理异常,处理完成后才能打卡";
|
||||
|
||||
/** 运次状态码:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消,与运单侧同一套 */
|
||||
private static final int TRIP_STATUS_PENDING = 0;
|
||||
private static final int TRIP_STATUS_RUNNING = 1;
|
||||
|
||||
private final IWaybillService waybillService;
|
||||
private final IDriverService driverService;
|
||||
private final IMpUserClient mpUserClient;
|
||||
@@ -139,6 +151,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
private final WaybillEnroutePunchMapper enroutePunchMapper;
|
||||
private final WaybillNodePunchMapper nodePunchMapper;
|
||||
private final ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
private final ITripQueryService tripQueryService;
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO currentTask() {
|
||||
@@ -252,6 +265,81 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return toCard(normalizeAcceptStatus(waybill), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO tripCurrentTask() {
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
return null;
|
||||
}
|
||||
TripQueryVO query = TripQueryVO.forDriver(phone);
|
||||
query.setStatus(TRIP_STATUS_RUNNING);
|
||||
query.setCurrent(1);
|
||||
query.setSize(1);
|
||||
IPage<TripVO> page = tripQueryService.page(query);
|
||||
return page.getRecords().isEmpty() ? null : toTripCard(page.getRecords().get(0), false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillPreviewVO tripPendingPreview(Integer size) {
|
||||
DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO();
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
return preview;
|
||||
}
|
||||
TripQueryVO query = TripQueryVO.forDriver(phone);
|
||||
query.setStatus(TRIP_STATUS_PENDING);
|
||||
query.setCurrent(1);
|
||||
query.setSize(normalizePreviewSize(size));
|
||||
IPage<TripVO> page = tripQueryService.page(query);
|
||||
preview.setTotal(page.getTotal());
|
||||
preview.setRecords(page.getRecords().stream()
|
||||
.map(trip -> toTripCard(trip, false))
|
||||
.collect(Collectors.toList()));
|
||||
return preview;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillTabCountsVO tripTabCounts() {
|
||||
DriverWaybillTabCountsVO counts = new DriverWaybillTabCountsVO();
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
return counts;
|
||||
}
|
||||
TripTabCountsVO tripCounts = tripQueryService.tabCounts(TripQueryVO.forDriver(phone));
|
||||
counts.setPending(tripCounts.getPending());
|
||||
counts.setDoing(tripCounts.getDoing());
|
||||
counts.setDone(tripCounts.getDone());
|
||||
counts.setAll(tripCounts.getAll());
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public IPage<DriverWaybillCardVO> tripPage(Integer current, Integer size, Integer status, String keyword) {
|
||||
String phone = currentUserPhone();
|
||||
if (Func.isEmpty(phone)) {
|
||||
return new Page<>(normalizePageNo(current), normalizePageSize(size));
|
||||
}
|
||||
TripQueryVO query = TripQueryVO.forDriver(phone);
|
||||
query.setStatus(status);
|
||||
query.setKeyword(keyword);
|
||||
query.setCurrent(current);
|
||||
query.setSize(size);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
Page<DriverWaybillCardVO> page = new Page<>(trips.getCurrent(), trips.getSize(), trips.getTotal());
|
||||
page.setRecords(trips.getRecords().stream()
|
||||
.map(trip -> toTripCard(trip, false))
|
||||
.collect(Collectors.toList()));
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public DriverWaybillCardVO tripDetail(String tripId) {
|
||||
requireCurrentDriver();
|
||||
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
|
||||
query.setTripId(tripId);
|
||||
return toTripCard(tripQueryService.detail(query), true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public WaybillTrackVO track(Long id) {
|
||||
requireCurrentDriver();
|
||||
@@ -262,9 +350,14 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
|
||||
/**
|
||||
* 按打卡记录 / 计划时间推导轨迹查询起止日期
|
||||
* <p>
|
||||
* TODO(临时):当前仅查当天,便于联调 LBS;恢复正式逻辑时删掉本段 early-return。
|
||||
*/
|
||||
private LocalDate[] resolveTrackDateRange(Waybill waybill) {
|
||||
LocalDate today = LocalDate.now();
|
||||
// TEMP: 行驶轨迹时间段临时改为仅当天
|
||||
return new LocalDate[]{today, today};
|
||||
/*
|
||||
List<LocalDate> punchDates = new ArrayList<>();
|
||||
nodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
|
||||
.eq(WaybillNodePunch::getWaybillId, waybill.getId())
|
||||
@@ -296,6 +389,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return new LocalDate[]{waybill.getStartDate(), today};
|
||||
}
|
||||
return new LocalDate[]{today.minusDays(3), today};
|
||||
*/
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -318,17 +412,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
Driver driver = requireCurrentDriver();
|
||||
// 在途打卡单独保留原取单判据,不排除已拒单(见 scopedQuery 的重载说明)
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true);
|
||||
// 加行锁:重复打卡是先查后插,并发请求读不到对方未提交的流水,不锁会各插一条
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId(), true, true);
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
assertPunchNotEnded(waybill.getBusinessStatus());
|
||||
assertPunchStarted(waybill.getBusinessStatus());
|
||||
|
||||
String processJson = resolveProcessJson(waybill);
|
||||
Date lastPunchAt = findLastPunchTime(waybill.getId());
|
||||
Date lastPunchAt = findLastPunchTime(waybill.getId(), true);
|
||||
WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin(
|
||||
processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now());
|
||||
if (!decision.punchEnabled()) {
|
||||
throw new ServiceException("该运单未启用在途打卡");
|
||||
}
|
||||
if (decision.doneToday()) {
|
||||
// 重复校验按「自然日是否打过」独立判:decision.doneToday() 只在进行中分支计算
|
||||
if (WaybillProcessSupport.isPunchedOn(lastPunchAt, null)) {
|
||||
throw new ServiceException("今日已完成在途打卡,不可重复打卡");
|
||||
}
|
||||
// 在途也是过程节点之一,占过程配置顺序里的位置,同样受异常门禁约束
|
||||
@@ -369,6 +467,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
Driver driver = requireCurrentDriver();
|
||||
Waybill waybill = loadDriverWaybill(dto.getWaybillId());
|
||||
waybill = normalizeAcceptStatus(waybill);
|
||||
assertPunchNotEnded(waybill.getBusinessStatus());
|
||||
assertPunchStarted(waybill.getBusinessStatus());
|
||||
|
||||
String processJson = resolveProcessJson(waybill);
|
||||
Map<String, Object> nodeCfg = findPunchNodeConfig(processJson, nodeCode);
|
||||
@@ -557,12 +657,150 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean tripAccept(String tripId) {
|
||||
Driver driver = requireCurrentDriver();
|
||||
// 认证门槛按整车一次过:一次接单只校验一次司机与车辆
|
||||
assertCertified(driver);
|
||||
List<TripVO.TripWaybillVO> children = tripChildren(tripId);
|
||||
assertTripAcceptable(children);
|
||||
List<Long> pendingIds = writeCandidateIds(children, "当前运单状态不允许接单");
|
||||
// 更新痕迹与单票接单一致:单票走 updateById,写前会把 update_time 刷成提交时刻、
|
||||
// 把 update_user 填成当前用户(无登录态则不填);这里一次写完,显式补上同样两列,
|
||||
// 时刻取与接单时刻同一个 now,操作人取同一个登录用户
|
||||
Date now = new Date();
|
||||
Long operatorId = currentOperatorId();
|
||||
return waybillService.update(Wrappers.<Waybill>lambdaUpdate()
|
||||
.set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_ACCEPTED)
|
||||
.set(Waybill::getDriverAcceptTime, now)
|
||||
.set(Waybill::getDriverAcceptDriverId, driver.getId())
|
||||
.set(Waybill::getDriverRejectTime, null)
|
||||
.set(Waybill::getDriverRejectReason, null)
|
||||
.set(Waybill::getBusinessStatus, STATUS_RUNNING)
|
||||
.set(Waybill::getUpdateTime, now)
|
||||
.set(operatorId != null, Waybill::getUpdateUser, operatorId)
|
||||
.in(Waybill::getId, pendingIds));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean tripReject(String tripId, String reason) {
|
||||
requireCurrentDriver();
|
||||
List<TripVO.TripWaybillVO> children = tripChildren(tripId);
|
||||
assertTripRejectable(children);
|
||||
String rejectReason = Func.isEmpty(reason) ? null : reason.trim();
|
||||
if (Func.isNotEmpty(rejectReason) && rejectReason.length() > 200) {
|
||||
throw new ServiceException("拒绝原因不能超过200字");
|
||||
}
|
||||
List<Long> pendingIds = writeCandidateIds(children, "当前运单状态不允许拒绝接单");
|
||||
// 整单拒绝落在每张未终态子运单上,一次一条语句写完,拒单时刻与更新痕迹全趟车一致
|
||||
Date now = new Date();
|
||||
Long operatorId = currentOperatorId();
|
||||
return waybillService.update(Wrappers.<Waybill>lambdaUpdate()
|
||||
.set(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
|
||||
.set(Waybill::getDriverAcceptTime, null)
|
||||
.set(Waybill::getDriverAcceptDriverId, null)
|
||||
.set(Waybill::getDriverRejectTime, now)
|
||||
.set(Waybill::getDriverRejectReason, rejectReason)
|
||||
.set(Waybill::getBusinessStatus, STATUS_PENDING)
|
||||
.set(Waybill::getUpdateTime, now)
|
||||
.set(operatorId != null, Waybill::getUpdateUser, operatorId)
|
||||
.in(Waybill::getId, pendingIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户主键:与框架写入时填充更新人的口径一致——没有登录态就不填
|
||||
*/
|
||||
private Long currentOperatorId() {
|
||||
BladeUser operator = AuthUtil.getUser();
|
||||
return operator == null ? null : operator.getUserId();
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次 → 子运单集合:配载的运次走运次读服务解析(配载单主键命中后按配载单号取子运单),
|
||||
* 未配载的就是那张运单本身。收口与列表一致——别人的运次在这里已经被挡住。
|
||||
* <p>
|
||||
* 子单集合是司机可见的那一批:已拒单的子单不在其中,否则接单会把本人拒掉的单重新拉回来。
|
||||
*/
|
||||
private List<TripVO.TripWaybillVO> tripChildren(String tripId) {
|
||||
TripQueryVO query = TripQueryVO.forDriver(currentUserPhone());
|
||||
query.setTripId(tripId);
|
||||
TripVO trip = tripQueryService.detail(query);
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
if (children.isEmpty()) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* 接单校验按整车:有子单没走完才可接单,任一子单的项目要接单整趟车就要接单,任一子单已接单即整单已接单
|
||||
*/
|
||||
private void assertTripAcceptable(List<TripVO.TripWaybillVO> children) {
|
||||
if (children.stream().allMatch(this::isTerminalChild)) {
|
||||
throw new ServiceException("当前运单状态不允许接单");
|
||||
}
|
||||
if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) {
|
||||
throw new ServiceException("该运单无需确认接单");
|
||||
}
|
||||
if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) {
|
||||
throw new ServiceException("该运单已接单");
|
||||
}
|
||||
if (children.stream().anyMatch(child -> WaybillProcessSupport.isRejected(child.getAcceptStatus()))) {
|
||||
throw new ServiceException("该运单已拒单,请联系调度重新派单");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 拒单校验按整车:有子单没走完才可拒绝,任一子单已接单则整单不能拒绝
|
||||
*/
|
||||
private void assertTripRejectable(List<TripVO.TripWaybillVO> children) {
|
||||
if (children.stream().allMatch(this::isTerminalChild)) {
|
||||
throw new ServiceException("当前运单状态不允许拒绝接单");
|
||||
}
|
||||
if (children.stream().noneMatch(child -> Boolean.TRUE.equals(child.getRequireAccept()))) {
|
||||
throw new ServiceException("该运单无需确认接单");
|
||||
}
|
||||
if (children.stream().anyMatch(child -> WaybillProcessSupport.isAccepted(child.getAcceptStatus()))) {
|
||||
throw new ServiceException("该运单已接单,无法拒绝");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 可写入的子运单:已走完的子单不动——把已完成/已取消的子单改回运输中或待执行等于回退历史状态
|
||||
*/
|
||||
private List<Long> writeCandidateIds(List<TripVO.TripWaybillVO> children, String emptyMessage) {
|
||||
List<Long> ids = children.stream()
|
||||
.filter(child -> !isTerminalChild(child))
|
||||
.map(child -> parseId(child.getId()))
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
if (ids.isEmpty()) {
|
||||
throw new ServiceException(emptyMessage);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private boolean isTerminalChild(TripVO.TripWaybillVO child) {
|
||||
return WaybillProcessSupport.isTerminalBusinessStatus(child.getBusinessStatus());
|
||||
}
|
||||
|
||||
private Waybill loadDriverWaybill(Long id) {
|
||||
return loadDriverWaybill(id, false);
|
||||
}
|
||||
|
||||
/** @param includeRejected 仅 submitEnroute 传 true,见 scopedQuery 的重载说明 */
|
||||
private Waybill loadDriverWaybill(Long id, boolean includeRejected) {
|
||||
return loadDriverWaybill(id, includeRejected, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param forUpdate 加行锁,仅 submitEnroute 使用:「同一天是否打过」是查完再插,
|
||||
* 不加锁时并发的两个请求都读不到对方未提交的流水,会各插一条。
|
||||
* 锁的是运单行,同一运单的打卡提交因此串行。
|
||||
*/
|
||||
private Waybill loadDriverWaybill(Long id, boolean includeRejected, boolean forUpdate) {
|
||||
if (id == null) {
|
||||
throw new ServiceException("运单ID不能为空");
|
||||
}
|
||||
@@ -570,8 +808,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
if (Func.isEmpty(phone)) {
|
||||
throw new ServiceException("当前用户手机号为空,无法查找运单");
|
||||
}
|
||||
// MySQL 的 FOR UPDATE 必须跟在 LIMIT 之后
|
||||
String tail = forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1";
|
||||
Waybill waybill = waybillService.getOne(scopedQuery(phone, includeRejected)
|
||||
.eq(Waybill::getId, id).last("LIMIT 1"));
|
||||
.eq(Waybill::getId, id).last(tail));
|
||||
if (waybill == null) {
|
||||
throw new ServiceException("运单不存在或无权操作");
|
||||
}
|
||||
@@ -748,7 +988,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return toCard(waybill, false);
|
||||
}
|
||||
|
||||
private DriverWaybillCardVO toCard(Waybill waybill, boolean withEnrouteRecords) {
|
||||
private DriverWaybillCardVO toCard(Waybill waybill, boolean withDetail) {
|
||||
DriverWaybillCardVO card = new DriverWaybillCardVO();
|
||||
card.setId(waybill.getId());
|
||||
card.setWaybillNo(waybill.getWaybillNo());
|
||||
@@ -758,9 +998,18 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
card.setToName(Func.toStrWithEmpty(waybill.getArrivalName(), Func.toStr(waybill.getArrivalAddress(), "")));
|
||||
card.setFromAddress(Func.toStrWithEmpty(waybill.getDepartureAddress(), card.getFromName()));
|
||||
card.setToAddress(Func.toStrWithEmpty(waybill.getArrivalAddress(), card.getToName()));
|
||||
card.setCargoNames(splitCargoNames(waybill.getCargoName()));
|
||||
card.setCargoCategory(Func.toStr(waybill.getCargoType(), ""));
|
||||
card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
|
||||
// 货名取运单上录入的全部货物:主表 cargo_name 只落了第一行,直接拿它会漏掉这张票的其余货物
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
card.setCargoNames(cargoNames);
|
||||
card.setCargoCategory(cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.collect(Collectors.joining("、")));
|
||||
// 计重合计:全部货物同单位时就是该单位合计,单位不一致时只有吨项参与(口径见 WaybillCargoSupport#weighing)
|
||||
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
|
||||
card.setWeight(weighing == null ? "" : formatWeight(weighing.value(), weighing.unit()));
|
||||
card.setStatus(toAppStatus(waybill.getBusinessStatus()));
|
||||
String createTime = formatDateTime(waybill.getCreateTime());
|
||||
card.setCreateTime(createTime);
|
||||
@@ -777,20 +1026,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
card.setRejectReason(waybill.getDriverRejectReason());
|
||||
|
||||
// 详情页字段(列表也可带上,体积很小)
|
||||
String cargoName = Func.toStr(waybill.getCargoName(), "");
|
||||
String cargoName = String.join("、", cargoNames);
|
||||
String weightText = card.getWeight();
|
||||
card.setCargoName(cargoName);
|
||||
card.setPickupAddress(card.getFromAddress());
|
||||
card.setUnloadAddress(card.getToAddress());
|
||||
card.setCargoQuantity(weightText);
|
||||
card.setTotalWeight(weightText);
|
||||
card.setCargos(withDetail ? cargoLines(cargos) : null);
|
||||
card.setTransportType(toTransportTypeLabel(waybill.getTransportType()));
|
||||
card.setTransportOrgType(Func.isNotBlank(waybill.getLoadingNo()) ? TRANSPORT_LOAD : TRANSPORT_COMMON);
|
||||
card.setPlanShipTime(formatLocalDateYmd(waybill.getEstimatedStartTime()));
|
||||
card.setPlanFinishTime(formatLocalDateYmd(waybill.getEstimatedEndTime()));
|
||||
card.setRemark(Func.toStr(waybill.getRemark(), ""));
|
||||
|
||||
if (withEnrouteRecords) {
|
||||
if (withDetail) {
|
||||
Date lastPunchAt = findLastPunchTime(waybill.getId());
|
||||
WaybillProcessSupport.TransitCheckinDecision transit = WaybillProcessSupport.evaluateTransitCheckin(
|
||||
processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now());
|
||||
@@ -828,6 +1078,143 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return List.of(load, unload);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片
|
||||
* <p>
|
||||
* 未配载的运次就是一张运单:详情照运单详情下发(打卡节点、在途打卡、过程配置都还在),
|
||||
* 只是状态按运次口径只读推导,不走会落库的接单态归一化。
|
||||
* 配载的运次按配载单下发整趟车的信息,运单级字段(运费、当前节点、打卡节点)留空——
|
||||
* 一趟车里每张子单各有各的项目配置与打卡节点。
|
||||
*/
|
||||
private DriverWaybillCardVO toTripCard(TripVO trip, boolean withDetail) {
|
||||
DriverWaybillCardVO card = Func.isEmpty(trip.getLoadingNo()) && withDetail
|
||||
? unloadedTripDetail(trip)
|
||||
: new DriverWaybillCardVO();
|
||||
fillTripFields(card, trip, withDetail);
|
||||
return card;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未配载运次的详情照运单详情拼,只读:不调用 normalizeAcceptStatus
|
||||
*/
|
||||
private DriverWaybillCardVO unloadedTripDetail(TripVO trip) {
|
||||
Long waybillId = parseId(trip.getId());
|
||||
Waybill waybill = waybillId == null ? null : waybillService.getById(waybillId);
|
||||
return waybill == null ? new DriverWaybillCardVO() : toCard(waybill, true);
|
||||
}
|
||||
|
||||
private void fillTripFields(DriverWaybillCardVO card, TripVO trip, boolean withDetail) {
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
card.setTripId(trip.getId());
|
||||
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
// 旧字段 id / waybillNo 保留原语义:未配载就是这张运单,配载取下第一张可见子运单(运次操作请用 tripId)
|
||||
card.setId(parseId(children.isEmpty() ? trip.getId() : children.get(0).getId()));
|
||||
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
card.setStatus(trip.getStatus());
|
||||
card.setRequireAccept(trip.getRequireAccept());
|
||||
card.setAcceptStatus(trip.getAcceptStatus());
|
||||
card.setTransportOrgType(trip.getTransportOrgType());
|
||||
card.setTransportType(toTransportTypeLabel(trip.getTransportType()));
|
||||
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
card.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
|
||||
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
card.setCreateTime(formatDateTime(trip.getSortTime()));
|
||||
card.setPublishTime(card.getCreateTime());
|
||||
// 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的。
|
||||
// 未配载详情进来时卡片已照运单填过,运次级为空就保留它,不覆盖成空
|
||||
if (trip.getEstimatedStartDate() != null) {
|
||||
card.setPlanShipTime(formatLocalDateYmd(trip.getEstimatedStartDate()));
|
||||
}
|
||||
if (trip.getEstimatedEndDate() != null) {
|
||||
card.setPlanFinishTime(formatLocalDateYmd(trip.getEstimatedEndDate()));
|
||||
}
|
||||
// 备注只有配载运次有运次级的值,未配载的保留运单卡片自己带来的备注
|
||||
if (Func.isNotEmpty(card.getLoadingNo())) {
|
||||
card.setRemark(Func.toStr(trip.getTaskRemark(), ""));
|
||||
}
|
||||
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
|
||||
card.setFromAddress(Func.toStrWithEmpty(trip.getDepartureAddress(), card.getFromName()));
|
||||
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
|
||||
card.setToAddress(Func.toStrWithEmpty(trip.getArrivalAddress(), card.getToName()));
|
||||
card.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
card.setStationCount(trip.getStationCount());
|
||||
card.setPickupAddress(card.getFromAddress());
|
||||
card.setUnloadAddress(card.getToAddress());
|
||||
// 货名取运次上算好的整趟车清单:它按子单录入的货物明细摊平去重,主表 cargo_name 只是首行。
|
||||
// 运次清单为空时按子单卡片自己的货名兜底,不让卡片上的货物行整个消失
|
||||
List<String> cargoNames = trip.getCargoNames() == null ? List.of() : trip.getCargoNames();
|
||||
if (cargoNames.isEmpty()) {
|
||||
cargoNames = children.stream()
|
||||
.flatMap(child -> splitCargoNames(child.getCargoName()).stream())
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
card.setCargoNames(cargoNames);
|
||||
card.setCargoName(String.join("、", cargoNames));
|
||||
List<String> cargoTypes = trip.getCargoTypes() == null ? List.of() : trip.getCargoTypes();
|
||||
if (cargoTypes.isEmpty()) {
|
||||
cargoTypes = children.stream()
|
||||
.map(TripVO.TripWaybillVO::getCargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
card.setCargoCategory(String.join("、", cargoTypes));
|
||||
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
// 计重合计:同单位时就是该单位合计,单位不一致时只有吨项参与(见 TripQueryServiceImpl#fillWeighing);
|
||||
// 都没有就留空,卡片上只剩货名,不拿汇总串冒充重量
|
||||
String weight = trip.getWeighingQuantity() == null
|
||||
? ""
|
||||
: formatWeight(trip.getWeighingQuantity(), trip.getWeighingUnit());
|
||||
card.setWeight(weight);
|
||||
card.setCargoQuantity(weight);
|
||||
card.setTotalWeight(weight);
|
||||
if (withDetail) {
|
||||
card.setStations(trip.getStations());
|
||||
card.setRoutePoints(toTripRoutePoints(trip.getStations()));
|
||||
card.setCargos(trip.getCargos());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情里的点位列:整趟车的全部站点,站序即途经顺序
|
||||
*/
|
||||
private List<DriverWaybillCardVO.DriverRoutePointVO> toTripRoutePoints(List<TripStationVO> stations) {
|
||||
if (Func.isEmpty(stations)) {
|
||||
return List.of();
|
||||
}
|
||||
return stations.stream().map(station -> {
|
||||
DriverWaybillCardVO.DriverRoutePointVO point = new DriverWaybillCardVO.DriverRoutePointVO();
|
||||
point.setName(Func.toStr(station.getName(), ""));
|
||||
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
|
||||
point.setStatus("pending");
|
||||
return point;
|
||||
}).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Long parseId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private int normalizePageNo(Integer current) {
|
||||
return current == null || current < 1 ? 1 : current;
|
||||
}
|
||||
|
||||
private int normalizePageSize(Integer size) {
|
||||
if (size == null || size < 1) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(size, MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
private String toTransportTypeLabel(String transportType) {
|
||||
if (Func.isBlank(transportType)) {
|
||||
return "";
|
||||
@@ -850,55 +1237,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照 processJson,
|
||||
* 避免项目配置改坏后司机端打卡页空白。
|
||||
* 优先用项目启用中的过程配置;若动态配置无打卡节点,回退运单快照,
|
||||
* 避免项目配置改坏后司机端打卡页空白。取法与规则都在 {@link LiveProcessConfigSupport}。
|
||||
*/
|
||||
private String resolveProcessJson(Waybill waybill) {
|
||||
if (waybill == null) {
|
||||
return null;
|
||||
}
|
||||
String snapshot = waybill.getProcessJson();
|
||||
String live = loadLiveProcessConfigJson(waybill.getProjectId());
|
||||
if (Func.isNotEmpty(live)) {
|
||||
if (!WaybillProcessSupport.listDriverPunchNodes(live).isEmpty()) {
|
||||
return live;
|
||||
}
|
||||
// 动态配置存在但无可打卡节点:仍回退快照
|
||||
if (Func.isNotEmpty(snapshot)
|
||||
&& !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
|
||||
return snapshot;
|
||||
}
|
||||
return live;
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private String loadLiveProcessConfigJson(Long projectId) {
|
||||
if (projectId == null) {
|
||||
return null;
|
||||
}
|
||||
String projectIdStr = String.valueOf(projectId);
|
||||
return processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.like(ProcessConfig::getProjectIds, projectIdStr)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime))
|
||||
.stream()
|
||||
.filter(cfg -> containsProjectId(cfg.getProjectIds(), projectIdStr))
|
||||
.map(ProcessConfig::getNodeConfigJson)
|
||||
.filter(Func::isNotEmpty)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private boolean containsProjectId(String projectIds, String projectId) {
|
||||
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(projectIds.split(","))
|
||||
.map(String::trim)
|
||||
.anyMatch(projectId::equals);
|
||||
return LiveProcessConfigSupport.resolveForPunch(
|
||||
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
|
||||
waybill.getProcessJson());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -998,6 +1346,32 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言运单未结束。
|
||||
* <p>
|
||||
* 运单已完成/已取消后不再接受司机打卡:在途与节点都拦,且先于其它门禁。
|
||||
* 结束不可逆,没有解除途径——处理完异常、等到第二天都还是打不了。
|
||||
*/
|
||||
private void assertPunchNotEnded(String businessStatus) {
|
||||
String reason = WaybillProcessSupport.punchEndedReason(businessStatus);
|
||||
if (reason != null) {
|
||||
throw new ServiceException(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言运单已开始运输。
|
||||
* <p>
|
||||
* 只有进行中的运单能打:草稿、待执行(待接单)、待调度、调度中都还没上路。
|
||||
* 与「运单已结束」分开判:结束没有解除途径,未开始会随接单/调度自行变为进行中。
|
||||
*/
|
||||
private void assertPunchStarted(String businessStatus) {
|
||||
String reason = WaybillProcessSupport.punchNotStartedReason(businessStatus);
|
||||
if (reason != null) {
|
||||
throw new ServiceException(reason);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 断言指定节点未被异常门禁阻断。
|
||||
* <p>
|
||||
@@ -1030,7 +1404,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
|
||||
processJson, punched, WaybillProcessSupport.nodeKey(nodeCfg));
|
||||
if (!unfinished.isEmpty()) {
|
||||
throw new ServiceException("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
|
||||
throw new ServiceException("请先完成「"
|
||||
+ WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1191,6 +1566,8 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
latestEnroute = enroutes.get(enroutes.size() - 1);
|
||||
}
|
||||
Map<String, WaybillNodePunch> latestNodePunchByCode = latestNodePunchMap(waybill.getId());
|
||||
// 未开始运输的运单在途卡仍下发,但不可提交:司机要看得到这张卡,才知道为什么不能打
|
||||
String notStartedReason = WaybillProcessSupport.punchNotStartedReason(waybill.getBusinessStatus());
|
||||
|
||||
for (Map<String, Object> cfg : punchConfigs) {
|
||||
boolean isTransit = WaybillProcessSupport.isTransitNodePublic(cfg);
|
||||
@@ -1218,7 +1595,10 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
vo.setCargoTypes(Collections.emptyList());
|
||||
vo.setVisible(true);
|
||||
vo.setDone(done);
|
||||
vo.setActionable(!done);
|
||||
vo.setActionable(!done && notStartedReason == null);
|
||||
if (notStartedReason != null) {
|
||||
vo.setLockReason(notStartedReason);
|
||||
}
|
||||
if (done && latestEnroute != null) {
|
||||
vo.setCheckinTime(latestEnroute.getTime());
|
||||
vo.setCheckinPlace(latestEnroute.getAddress());
|
||||
@@ -1239,7 +1619,16 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
boolean done = punched != null;
|
||||
vo.setVisible(true);
|
||||
vo.setDone(done);
|
||||
vo.setActionable(!done);
|
||||
vo.setActionable(!done && notStartedReason == null);
|
||||
if (notStartedReason != null && !done) {
|
||||
vo.setLockReason(notStartedReason);
|
||||
}
|
||||
// 末位卡上打一下就完成这张运单:判据与下方 submitNode 的自动完成同一份
|
||||
// (末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认),客户端只按它把按钮文案改成「完成运单」。
|
||||
// 两处判据必须同源,否则会出现「按钮写着完成运单、打下去这张运单没完成」。
|
||||
vo.setFinishOnPunch(WaybillProcessSupport.isLastDriverPunchNode(processJson, vo.getKey())
|
||||
&& WaybillProcessSupport.isDeliveryNode(vo.getKey(), vo.getName())
|
||||
&& WaybillProcessSupport.returnNeedsNoConfirmation(processJson));
|
||||
if (done) {
|
||||
if (punched.getPunchTime() != null) {
|
||||
LocalDateTime ldt = punched.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime();
|
||||
@@ -1268,6 +1657,11 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
if (Boolean.TRUE.equals(vo.getDone()) || !blockedKeys.contains(vo.getKey())) {
|
||||
continue;
|
||||
}
|
||||
// 未开始运输是更根本的拒绝:运单状态不变,处理完异常也打不了。
|
||||
// 这里不能再改写成异常文案,否则会暗示「处理完就能打」。
|
||||
if (notStartedReason != null) {
|
||||
continue;
|
||||
}
|
||||
vo.setBlocked(true);
|
||||
vo.setActionable(false);
|
||||
if (blocker != null) {
|
||||
@@ -1296,14 +1690,36 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
vo.setBlockedByOrder(true);
|
||||
vo.setActionable(false);
|
||||
vo.setOrderLockReason("请先完成「" + prerequisiteNames(processJson, unfinished) + "」打卡");
|
||||
vo.setOrderLockReason("请先完成「"
|
||||
+ WaybillProcessSupport.firstUnfinishedPrerequisiteName(processJson, unfinished) + "」打卡");
|
||||
}
|
||||
|
||||
// 已结束的运单不再接受打卡:卡片留着给司机回看,未打卡的节点一律不可提交。
|
||||
// 文案压过异常与顺序门禁——结束没有解除途径,说「处理完就能打」是错的。
|
||||
// 已打卡的节点只置 ended:它们要继续可展开回看照片与时间,原因文案也不再适用。
|
||||
String endedReason = WaybillProcessSupport.punchEndedReason(waybill.getBusinessStatus());
|
||||
if (endedReason != null) {
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
vo.setEnded(true);
|
||||
if (!Boolean.TRUE.equals(vo.getDone())) {
|
||||
vo.setActionable(false);
|
||||
vo.setLockReason(endedReason);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 是否允许展开:顺序门禁不允许展开(还没轮到,展开也没用);
|
||||
// 异常门禁允许展开——那里出过事,司机需要看到回填内容与「异常详情」。
|
||||
// 已结束的运单:在途卡一律不可展开(结束之后没有在途打卡可做,展开也无内容);
|
||||
// 未打卡的节点展开同样一无所有;已打卡的普通节点保留展开,供司机回看时间与照片。
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
boolean blockedByOrder = Boolean.TRUE.equals(vo.getBlockedByOrder());
|
||||
vo.setExpandable(!blockedByOrder);
|
||||
boolean ended = Boolean.TRUE.equals(vo.getEnded());
|
||||
boolean done = Boolean.TRUE.equals(vo.getDone());
|
||||
boolean transitCard = Boolean.TRUE.equals(vo.getTransit());
|
||||
// 未开始运输的未打卡卡片:打不了也展不开——展开是空表单,状态由锁文案说明
|
||||
boolean notStartedLocked = !done && notStartedReason != null;
|
||||
vo.setExpandable(!blockedByOrder && !notStartedLocked && (!ended || (done && !transitCard)));
|
||||
}
|
||||
|
||||
// 「异常」标签:只挂**存在未处理完成异常**的节点(与异常门禁同一口径,结案即消失)。
|
||||
@@ -1336,6 +1752,7 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
|
||||
for (DriverPunchNodeVO vo : nodes) {
|
||||
if (!Boolean.TRUE.equals(vo.getDone())
|
||||
&& !Boolean.TRUE.equals(vo.getEnded())
|
||||
&& !Boolean.TRUE.equals(vo.getBlocked())
|
||||
&& !Boolean.TRUE.equals(vo.getBlockedByOrder())) {
|
||||
vo.setDefaultExpanded(true);
|
||||
@@ -1345,18 +1762,6 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
return nodes;
|
||||
}
|
||||
|
||||
/** 把前置节点 key 拼成给人看的名称列表,如「装货、发货」 */
|
||||
private String prerequisiteNames(String processJson, Set<String> nodeKeys) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Map<String, Object> node : WaybillProcessSupport.listDriverPunchNodes(processJson)) {
|
||||
String key = WaybillProcessSupport.nodeKey(node);
|
||||
if (nodeKeys.contains(key)) {
|
||||
names.add(WaybillProcessSupport.nodeName(node));
|
||||
}
|
||||
}
|
||||
return names.isEmpty() ? "" : String.join("、", names);
|
||||
}
|
||||
|
||||
private Map<String, Object> findPunchNodeConfig(String processJson, String nodeCode) {
|
||||
List<Map<String, Object>> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson);
|
||||
for (Map<String, Object> cfg : punchConfigs) {
|
||||
@@ -1548,13 +1953,21 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
}
|
||||
|
||||
private Date findLastPunchTime(Long waybillId) {
|
||||
return findLastPunchTime(waybillId, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param forUpdate 仅 submitEnroute:与运单行锁配对。不加锁读走的是事务快照,
|
||||
* 并发提交的第一笔已提交后,第二笔可能仍读到旧快照而看不到它。
|
||||
*/
|
||||
private Date findLastPunchTime(Long waybillId, boolean forUpdate) {
|
||||
if (waybillId == null) {
|
||||
return null;
|
||||
}
|
||||
WaybillEnroutePunch latest = enroutePunchMapper.selectOne(Wrappers.<WaybillEnroutePunch>lambdaQuery()
|
||||
.eq(WaybillEnroutePunch::getWaybillId, waybillId)
|
||||
.orderByDesc(WaybillEnroutePunch::getPunchTime)
|
||||
.last("LIMIT 1"));
|
||||
.last(forUpdate ? "LIMIT 1 FOR UPDATE" : "LIMIT 1"));
|
||||
return latest == null ? null : latest.getPunchTime();
|
||||
}
|
||||
|
||||
@@ -1597,16 +2010,26 @@ public class DriverWaybillServiceImpl implements IDriverWaybillService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一格多名的历史写法(顿号、逗号分隔)摊平成货名清单,与货物明细同一个拆分口径
|
||||
*/
|
||||
private List<String> splitCargoNames(String cargoName) {
|
||||
if (Func.isEmpty(cargoName)) {
|
||||
return Collections.emptyList();
|
||||
List<String> names = WaybillCargoSupport.splitCargoNames(cargoName);
|
||||
return names.isEmpty() ? List.of() : names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物明细行:一条货物一行,数量带单位
|
||||
*/
|
||||
private List<CargoLineVO> cargoLines(List<WaybillCargoSupport.Cargo> cargos) {
|
||||
List<CargoLineVO> lines = new ArrayList<>();
|
||||
for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) {
|
||||
CargoLineVO vo = new CargoLineVO();
|
||||
vo.setCargoName(line.name());
|
||||
vo.setQuantity(line.quantity() == null ? "" : formatWeight(line.quantity(), line.unit()));
|
||||
lines.add(vo);
|
||||
}
|
||||
String normalized = cargoName.replace(",", ",").replace("、", ",");
|
||||
List<String> names = Func.toStrList(",", normalized).stream()
|
||||
.map(String::trim)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toList());
|
||||
return names.isEmpty() ? List.of(cargoName.trim()) : names;
|
||||
return lines;
|
||||
}
|
||||
|
||||
private String formatWeight(BigDecimal quantity, String unit) {
|
||||
|
||||
+11
@@ -144,6 +144,17 @@ public class ExceptionDisposalServiceImpl
|
||||
// 整单异常同样受这条约束:没有节点不等于没有上报人。
|
||||
driverWaybillService.assertCurrentDriverOwnsWaybill(disposal.getWaybillId());
|
||||
|
||||
// 上报门禁之零:运单得先在路上,且不能已经结束。
|
||||
// 判据与司机打卡同一来源;归属校验会顺手把「无需接单」的待执行运单校正为进行中,
|
||||
// 所以状态要在它之后重新读,不能用 fillFromWaybill 里校正前的那份对象。
|
||||
Waybill reportWaybill = waybillService.getById(disposal.getWaybillId());
|
||||
String blockedReason = reportWaybill == null
|
||||
? null
|
||||
: WaybillProcessSupport.reportBlockedReason(reportWaybill.getBusinessStatus());
|
||||
if (blockedReason != null) {
|
||||
throw new ServiceException(blockedReason);
|
||||
}
|
||||
|
||||
// 节点可以为空:全局入口(首页快捷格、打卡页底部按钮)上报的异常不指定环节,
|
||||
// 它无从定位阻断区间,由门禁按「阻断该运单全部未打卡节点」处理。
|
||||
// 节点非空时才要求它确属该运单;节点名取自过程配置而非请求体,避免落进一个该节点并不叫的名字。
|
||||
|
||||
+4
-2
@@ -1772,7 +1772,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
Map<Long, BigDecimal> receiptClaimPaidBySettlement = hasReceivable
|
||||
? receiptClaimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.in(ReceiptClaimSettlement::getFormalSettlementId, settledIds)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId,
|
||||
Collectors.reducing(BigDecimal.ZERO,
|
||||
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)))
|
||||
@@ -2204,7 +2205,8 @@ public class FormalSettlementServiceImpl extends BaseServiceImpl<FormalSettlemen
|
||||
: receiptClaimSettlementMapper.selectList(
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getFormalSettlementId, settlement.getId())
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1))
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0))
|
||||
.stream().map(ReceiptClaimSettlement::getAllocatedReceiptAmount).map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
+166
-33
@@ -28,7 +28,10 @@ import org.springblade.transport.pojo.vo.LoadingCarrierContractVO;
|
||||
import org.springblade.transport.pojo.vo.LoadingManageVO;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.LoadableAdmissionSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.LoadingManageWrapper;
|
||||
@@ -43,8 +46,10 @@ import java.time.LocalDateTime;
|
||||
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;
|
||||
@@ -76,6 +81,9 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
@Lazy
|
||||
private IReceivablePayableDetailService receivablePayableDetailService;
|
||||
|
||||
@Resource
|
||||
private IProcessConfigService processConfigService;
|
||||
|
||||
@Override
|
||||
public IPage<LoadingManageVO> selectLoadingManagePage(IPage<LoadingManage> page, LoadingManageVO loadingManage) {
|
||||
IPage<LoadingManage> entityPage = page(page, buildQuery(loadingManage));
|
||||
@@ -147,9 +155,12 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
prepare(loadingManage);
|
||||
validate(loadingManage, false);
|
||||
List<Long> oldWaybillIds = new ArrayList<>();
|
||||
boolean ownChildrenAdmitted = false;
|
||||
if (Func.isNotEmpty(loadingManage.getId())) {
|
||||
LoadingManage oldRecord = loadEditable(loadingManage.getId(), true);
|
||||
oldWaybillIds = waybillIds(oldRecord.getWaybillIdsJson());
|
||||
// 草稿只预占运单号、未过判据,草稿转正式不得沿用本单豁免
|
||||
ownChildrenAdmitted = !STATUS_DRAFT.equals(oldRecord.getBusinessStatus());
|
||||
loadingManage.setLoadingNo(oldRecord.getLoadingNo());
|
||||
loadingManage.setDeptId(oldRecord.getDeptId());
|
||||
loadingManage.setDeptName(oldRecord.getDeptName());
|
||||
@@ -160,10 +171,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
if (Func.isEmpty(loadingManage.getLoadingNo())) {
|
||||
loadingManage.setLoadingNo(nextCode());
|
||||
}
|
||||
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
|
||||
validateCarrierContract(loadingManage, true);
|
||||
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), ownChildrenAdmitted);
|
||||
validateCarrierContract(loadingManage, true, waybills);
|
||||
boolean result = saveOrUpdate(loadingManage);
|
||||
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
|
||||
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -409,11 +420,10 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
loadingManage.setDeptId(oldRecord.getDeptId());
|
||||
loadingManage.setDeptName(oldRecord.getDeptName());
|
||||
loadingManage.setBusinessStatus(STATUS_PENDING);
|
||||
validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()));
|
||||
validateCarrierContract(loadingManage, true);
|
||||
List<Waybill> waybills = validateWaybillsAvailable(loadingManage, waybillIds(loadingManage.getWaybillIdsJson()), true);
|
||||
validateCarrierContract(loadingManage, true, waybills);
|
||||
boolean result = updateById(loadingManage);
|
||||
syncAssociatedWaybills(loadingManage, STATUS_PENDING, oldWaybillIds);
|
||||
clearAssociatedWaybillAcceptRecords(loadingManage);
|
||||
writeWaybillsForDriver(loadingManage, oldWaybillIds, waybills);
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -735,29 +745,64 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
TransportBusinessSupport.validateLength(loadingManage.getTaskRemark(), 500, "任务备注不能超过500字");
|
||||
}
|
||||
|
||||
private void validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds) {
|
||||
private List<Waybill> validateWaybillsAvailable(LoadingManage loadingManage, List<Long> waybillIds, boolean ownChildrenAdmitted) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
throw new ServiceException("请选择待配载运单");
|
||||
}
|
||||
List<Waybill> waybills = waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.in(Waybill::getId, waybillIds));
|
||||
List<Waybill> waybills = loadWaybills(waybillIds);
|
||||
if (waybills.size() != waybillIds.size()) {
|
||||
throw new ServiceException("待配载运单不存在或已删除");
|
||||
}
|
||||
String ownLoadingNo = loadingManage.getLoadingNo();
|
||||
for (Waybill waybill : waybills) {
|
||||
TransportBusinessSupport.assertCurrentDept(waybill.getDeptId(), "运单管理");
|
||||
if (Func.isNotEmpty(waybill.getLoadingNo()) && !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
|
||||
throw new ServiceException("运单" + waybill.getWaybillNo() + "已关联其他配载单");
|
||||
if (ownChildrenAdmitted && LoadableAdmissionSupport.isOwnChild(waybill, ownLoadingNo)) {
|
||||
continue;
|
||||
}
|
||||
if ((Objects.equals(waybill.getBusinessStatus(), STATUS_COMPLETED) || Objects.equals(waybill.getBusinessStatus(), STATUS_CANCELLED))
|
||||
&& !Objects.equals(waybill.getLoadingNo(), loadingManage.getLoadingNo())) {
|
||||
throw new ServiceException("运单" + waybill.getWaybillNo() + "当前状态不允许配载");
|
||||
LoadableAdmissionSupport.UnloadableReason reason =
|
||||
LoadableAdmissionSupport.unloadableReason(waybill, ownLoadingNo);
|
||||
if (reason != null) {
|
||||
throw new ServiceException(unloadableMessage(waybill, reason));
|
||||
}
|
||||
}
|
||||
validateSameProject(waybills);
|
||||
return waybills;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逐张准入失败的可读原因:判据只给原因枚举,运单号与文案在这里拼。
|
||||
*/
|
||||
private String unloadableMessage(Waybill waybill, LoadableAdmissionSupport.UnloadableReason reason) {
|
||||
String prefix = "运单" + waybill.getWaybillNo();
|
||||
return prefix + switch (reason) {
|
||||
case NOT_ROAD -> "运输方式不是公路,不可配载";
|
||||
case NOT_PENDING -> "当前状态不是待执行,不可配载";
|
||||
case ON_OTHER_MANIFEST -> "已关联其他配载单";
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张配载单的全部子运单必须属于同一个项目,否则点名与其它子运单项目不同的运单。
|
||||
* <p>
|
||||
* 查询条件表达不了跨行约束,只能整批取回后在内存里比。
|
||||
*/
|
||||
private void validateSameProject(List<Waybill> waybills) {
|
||||
Long projectId = waybills.get(0).getProjectId();
|
||||
List<String> others = waybills.stream()
|
||||
.filter(waybill -> !Objects.equals(waybill.getProjectId(), projectId))
|
||||
.map(Waybill::getWaybillNo)
|
||||
.toList();
|
||||
if (Func.isNotEmpty(others)) {
|
||||
throw new ServiceException("配载单的子运单必须属于同一项目,运单"
|
||||
+ String.join("、", others) + "与其它子运单不属于同一项目");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateCarrierContract(LoadingManage loadingManage, boolean required) {
|
||||
validateCarrierContract(loadingManage, required, null);
|
||||
}
|
||||
|
||||
private void validateCarrierContract(LoadingManage loadingManage, boolean required, List<Waybill> waybills) {
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), "承运商")) {
|
||||
loadingManage.setCarrierContractId(null);
|
||||
if (!Objects.equals(loadingManage.getCarrierType(), CARRIER_SELF)) {
|
||||
@@ -772,10 +817,24 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
loadingManage.setCarrierName(null);
|
||||
return;
|
||||
}
|
||||
ContractManage carrierContract = availableCarrierContracts(projectIdsByWaybills(loadingManage)).stream()
|
||||
.filter(contract -> Objects.equals(contract.getId(), loadingManage.getCarrierContractId()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new ServiceException("所选承运商合同不存在、未审核通过或已失效"));
|
||||
ContractManage carrierContract = availableCarrierContractById(loadingManage.getCarrierContractId());
|
||||
if (carrierContract == null) {
|
||||
throw new ServiceException("所选承运商合同不存在、未审核通过或已失效");
|
||||
}
|
||||
List<Waybill> childWaybills = waybills == null
|
||||
? loadWaybills(waybillIds(loadingManage.getWaybillIdsJson()))
|
||||
: waybills;
|
||||
// 合同自带项目与乙方:合同的项目不属于本单子运单的项目,等于换了项目却没换乙方
|
||||
if (!projectIdsOf(childWaybills).contains(carrierContract.getProjectId())) {
|
||||
String waybillNos = childWaybills.stream()
|
||||
.filter(waybill -> !Objects.equals(waybill.getProjectId(), carrierContract.getProjectId()))
|
||||
.map(Waybill::getWaybillNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.joining("、"));
|
||||
throw new ServiceException(Func.isEmpty(waybillNos)
|
||||
? "所选承运商合同不属于本项目"
|
||||
: "所选承运商合同不属于运单" + waybillNos + "的项目");
|
||||
}
|
||||
loadingManage.setCarrierName(carrierContract.getPartyB());
|
||||
}
|
||||
|
||||
@@ -792,34 +851,98 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
}
|
||||
}
|
||||
|
||||
private List<Long> projectIdsByWaybills(LoadingManage loadingManage) {
|
||||
List<Long> waybillIds = waybillIds(loadingManage.getWaybillIdsJson());
|
||||
private List<Waybill> loadWaybills(List<Long> waybillIds) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
return List.of();
|
||||
}
|
||||
return waybillMapper.selectList(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getProjectId)
|
||||
.eq(Waybill::getIsDeleted, 0)
|
||||
.in(Waybill::getId, waybillIds))
|
||||
.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
|
||||
.in(Waybill::getId, waybillIds));
|
||||
}
|
||||
|
||||
private List<Long> projectIdsOf(List<Waybill> waybills) {
|
||||
return waybills.stream().map(Waybill::getProjectId).filter(Objects::nonNull).distinct().toList();
|
||||
}
|
||||
|
||||
private List<ContractManage> availableCarrierContracts(List<Long> projectIds) {
|
||||
if (Func.isEmpty(projectIds)) {
|
||||
return List.of();
|
||||
}
|
||||
return contractManageService.list(Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getStatus, 1)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
return contractManageService.list(availableCarrierContractQuery()
|
||||
.in(ContractManage::getProjectId, projectIds)
|
||||
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"))
|
||||
.orderByDesc(ContractManage::getCreateTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按主键取一张可用的承运商合同。
|
||||
* <p>
|
||||
* 不按项目过滤,才能把「合同不可用」与「合同不属于本项目」分成两种提示。
|
||||
* 取不到可用的合同返回 null。
|
||||
*/
|
||||
private ContractManage availableCarrierContractById(Long contractId) {
|
||||
if (Func.isEmpty(contractId)) {
|
||||
return null;
|
||||
}
|
||||
return contractManageService.list(availableCarrierContractQuery()
|
||||
.eq(ContractManage::getId, contractId)
|
||||
.orderByDesc(ContractManage::getCreateTime))
|
||||
.stream().findFirst().orElse(null);
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<ContractManage> availableCarrierContractQuery() {
|
||||
return Wrappers.<ContractManage>lambdaQuery()
|
||||
.eq(ContractManage::getIsDeleted, 0)
|
||||
.eq(ContractManage::getStatus, 1)
|
||||
.eq(ContractManage::getContractCategory, "承运商合同")
|
||||
.in(ContractManage::getApprovalStatus, "approved", "change_approved")
|
||||
.and(wrapper -> wrapper.isNull(ContractManage::getContractStage)
|
||||
.or().ne(ContractManage::getContractStage, "terminated"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 配载提交与重新派单共用:子运单状态逐张按该票自己项目的实时过程配置推导后落库,再清除接单事实。
|
||||
* <p>
|
||||
* 推导按「接单事实已清、尚未接单」的口径取值,故清接单事实放在状态写入之后,两者口径一致。
|
||||
*/
|
||||
private void writeWaybillsForDriver(LoadingManage loadingManage, List<Long> oldWaybillIds, List<Waybill> waybills) {
|
||||
syncAssociatedWaybills(loadingManage, deriveWaybillBusinessStatuses(waybills), oldWaybillIds);
|
||||
// 接单事实不清,被拒的票对新司机不可见——司机端一切入口都排除已拒绝的运单
|
||||
clearAssociatedWaybillAcceptRecords(loadingManage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 子运单业务状态逐张按各自项目的实时过程配置推导:需要接单 → 待执行,不需要 → 进行中。
|
||||
* <p>
|
||||
* 配置取法与运单读路径同一处:项目生效配置优先,取不到时才回退该运单的过程配置快照;
|
||||
* 快照只作兜底、永不优先,否则项目改配置后写入值与读时的归一化结果会分叉。
|
||||
* 项目为空且快照也为空 → 视为不需要接单 → 进行中。
|
||||
*/
|
||||
private Map<Long, String> deriveWaybillBusinessStatuses(List<Waybill> waybills) {
|
||||
Map<Long, String> businessStatusByWaybillId = new LinkedHashMap<>();
|
||||
if (Func.isEmpty(waybills)) {
|
||||
return businessStatusByWaybillId;
|
||||
}
|
||||
Map<Long, String> liveConfigByProject = LiveProcessConfigSupport.byProjects(processConfigService,
|
||||
waybills.stream().map(Waybill::getProjectId).toList());
|
||||
for (Waybill waybill : waybills) {
|
||||
String processJson = LiveProcessConfigSupport.resolve(
|
||||
liveConfigByProject.get(waybill.getProjectId()), waybill.getProcessJson());
|
||||
// 以「待执行 + 待接单」为输入走读路径同一个归一化:需要接单留下待执行,不需要接单提升为进行中
|
||||
businessStatusByWaybillId.put(waybill.getId(), WaybillProcessSupport.normalizeBusinessStatus(
|
||||
STATUS_PENDING, processJson, WaybillProcessSupport.ACCEPT_PENDING));
|
||||
}
|
||||
return businessStatusByWaybillId;
|
||||
}
|
||||
|
||||
private void syncAssociatedWaybills(LoadingManage loadingManage, String status, List<Long> oldWaybillIds) {
|
||||
Map<Long, String> statusByWaybillId = new LinkedHashMap<>();
|
||||
for (Long waybillId : waybillIds(loadingManage.getWaybillIdsJson())) {
|
||||
statusByWaybillId.put(waybillId, status);
|
||||
}
|
||||
syncAssociatedWaybills(loadingManage, statusByWaybillId, oldWaybillIds);
|
||||
}
|
||||
|
||||
private void syncAssociatedWaybills(LoadingManage loadingManage, Map<Long, String> statusByWaybillId, List<Long> oldWaybillIds) {
|
||||
List<Long> newWaybillIds = waybillIds(loadingManage.getWaybillIdsJson());
|
||||
if (Func.isNotEmpty(oldWaybillIds)) {
|
||||
List<Long> removedIds = new ArrayList<>(oldWaybillIds);
|
||||
@@ -829,8 +952,18 @@ public class LoadingManageServiceImpl extends BaseServiceImpl<LoadingManageMappe
|
||||
if (Func.isEmpty(newWaybillIds)) {
|
||||
return;
|
||||
}
|
||||
Map<String, List<Long>> waybillIdsByStatus = new LinkedHashMap<>();
|
||||
for (Long waybillId : newWaybillIds) {
|
||||
waybillIdsByStatus
|
||||
.computeIfAbsent(statusByWaybillId.getOrDefault(waybillId, STATUS_PENDING), key -> new ArrayList<>())
|
||||
.add(waybillId);
|
||||
}
|
||||
waybillIdsByStatus.forEach((status, ids) -> updateAssociatedWaybillFields(loadingManage, ids, status));
|
||||
}
|
||||
|
||||
private void updateAssociatedWaybillFields(LoadingManage loadingManage, List<Long> waybillIds, String status) {
|
||||
waybillMapper.update(null, Wrappers.<Waybill>lambdaUpdate()
|
||||
.in(Waybill::getId, newWaybillIds)
|
||||
.in(Waybill::getId, waybillIds)
|
||||
.set(Waybill::getLoadingNo, loadingManage.getLoadingNo())
|
||||
.set(Waybill::getBusinessStatus, status)
|
||||
.set(Waybill::getCarrierType, loadingManage.getCarrierType())
|
||||
|
||||
+649
-145
@@ -23,16 +23,19 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.utils.DateUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.UserDisplayCache;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.RiskDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.AdminDriverOptionVO;
|
||||
@@ -44,12 +47,21 @@ import org.springblade.transport.pojo.vo.AdminVehicleOptionVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IManageWaybillService;
|
||||
import org.springblade.transport.service.IRiskDisposalService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
@@ -58,16 +70,24 @@ import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
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.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 调度端:运单状态统计 + 异常/风险角标 + 待处理事项 + 运单列表
|
||||
* 调度端:运次状态统计 + 异常/风险角标 + 待处理事项 + 运次列表
|
||||
* <p>
|
||||
* 列表与详情与司机端同粒度:取数、状态聚合、接单需求合并都调共享运次读服务,
|
||||
* 本类只做字段裁剪与调度端独有的筛选(组织类型 / 异常 / 创建时间 / 待重新派单)。
|
||||
* <p>
|
||||
* 小程序调度账号(如「小程序管理」)组织常与运单业务组织不一致,故不做 dept 过滤,
|
||||
* 仅依赖租户隔离,口径接近后台 {@code /waybill-manage/list?allDept=1}。
|
||||
@@ -102,6 +122,11 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
/** 待重新派单的排序键:运次内子单被拒的时刻取最大值 */
|
||||
private static final String SQL_MAX_REJECT_TIME = "MAX(driver_reject_time)";
|
||||
/** 待重新派单的排序键:运次内子单创建时刻取最大值(与运次列表同一排序口径) */
|
||||
private static final String SQL_MAX_CREATE_TIME = "MAX(create_time)";
|
||||
|
||||
private static final DateTimeFormatter DATE_MD = DateTimeFormatter.ofPattern("MM-dd");
|
||||
private static final DateTimeFormatter DATE_YMD = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
@@ -110,13 +135,19 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
private final IExceptionDisposalService exceptionDisposalService;
|
||||
private final IRiskDisposalService riskDisposalService;
|
||||
private final IDriverWaybillService driverWaybillService;
|
||||
private final ITripQueryService tripQueryService;
|
||||
private final ILoadingManageService loadingManageService;
|
||||
|
||||
@Override
|
||||
public AdminHomeStatsVO stats() {
|
||||
AdminHomeStatsVO vo = new AdminHomeStatsVO();
|
||||
vo.setPendingAccept(countWaybillByStatus(STATUS_PENDING));
|
||||
vo.setTransporting(countWaybillByStatus(STATUS_RUNNING));
|
||||
vo.setCompleted(countWaybillByStatus(STATUS_COMPLETED));
|
||||
// 三档状态按运次计:有配载单的一趟车算一个,与列表和司机端同一份聚合
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
TripTabCountsVO tripCounts = tripQueryService.tabCounts(query);
|
||||
vo.setPendingAccept(tripCounts.getPending());
|
||||
vo.setTransporting(tripCounts.getDoing());
|
||||
vo.setCompleted(tripCounts.getDone());
|
||||
vo.setException(countIncompleteExceptions());
|
||||
return vo;
|
||||
}
|
||||
@@ -144,57 +175,46 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
if (EXCEPTION_YES.equals(exception) && exceptionWaybillIds.isEmpty()) {
|
||||
Page<AdminWaybillCardVO> emptyVo = new Page<>(pageNo, pageSize, 0);
|
||||
emptyVo.setRecords(List.of());
|
||||
return emptyVo;
|
||||
return emptyCardPage(pageNo, pageSize);
|
||||
}
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery();
|
||||
applyStatusFilter(wrapper, status);
|
||||
applyExceptionFilter(wrapper, exception, exceptionWaybillIds);
|
||||
applyTransportTypeFilter(wrapper, transportType);
|
||||
applyKeywordFilter(wrapper, keyword);
|
||||
applyCreateTimeFilter(wrapper, startDate, endDate);
|
||||
wrapper.orderByDesc(Waybill::getCreateTime);
|
||||
|
||||
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
|
||||
List<Long> pageIds = entityPage.getRecords().stream()
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> pageExceptionIds = pageIds.isEmpty()
|
||||
? Collections.emptySet()
|
||||
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
|
||||
|
||||
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
voPage.setRecords(entityPage.getRecords().stream()
|
||||
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
|
||||
.toList());
|
||||
return voPage;
|
||||
// 组织类型 / 异常 / 创建时间都是子单行级条件,与状态、关键字、分页一起交给运次读服务,
|
||||
// 一次取数取完整趟车:分组、分档、筛选、排序与分页都在它那边算完
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
query.setStatus(toTripStatus(status));
|
||||
query.setKeyword(keyword);
|
||||
query.setTransportOrgType(transportType);
|
||||
query.setException(exception);
|
||||
query.setExceptionWaybillIds(exceptionWaybillIds);
|
||||
query.setCreatedFrom(parseDayStart(startDate));
|
||||
query.setCreatedTo(parseDayEnd(endDate));
|
||||
query.setCurrent(pageNo);
|
||||
query.setSize(pageSize);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AdminWaybillDetailVO detail(Long id) {
|
||||
public AdminWaybillDetailVO detail(String tripId) {
|
||||
Long id = parseId(tripId);
|
||||
if (id == null) {
|
||||
throw new org.springblade.core.log.exception.ServiceException("运单ID不能为空");
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
TripVO trip = visibleTrip(tripId);
|
||||
if (trip != null) {
|
||||
return tripDetail(trip);
|
||||
}
|
||||
// 已拒单的子运单按运次取不到(运次口径不含已拒子单),此时按运单主键回落,供重新派单等从运单进入的场景使用
|
||||
Waybill waybill = waybillService.getById(id);
|
||||
if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
throw new org.springblade.core.log.exception.ServiceException("运单不存在");
|
||||
if (waybill != null && !Objects.equals(waybill.getIsDeleted(), 1)) {
|
||||
return waybillDetail(waybill);
|
||||
}
|
||||
boolean hasException = false;
|
||||
Long exceptionId = null;
|
||||
ExceptionDisposal latest = exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, id)
|
||||
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
|
||||
.orderByDesc(ExceptionDisposal::getReportTime)
|
||||
.orderByDesc(ExceptionDisposal::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
if (latest != null) {
|
||||
hasException = true;
|
||||
exceptionId = latest.getId();
|
||||
LoadingManage loading = loadingManageService.getById(id);
|
||||
if (loading == null) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
return toDetail(waybill, hasException, exceptionId);
|
||||
return loadingDetail(loading, visibleChildren(loading.getLoadingNo()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -202,32 +222,521 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
int pageNo = current == null || current < 1 ? 1 : current;
|
||||
int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE);
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = scopedWaybillQuery()
|
||||
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
|
||||
if (Boolean.TRUE.equals(needReassign)) {
|
||||
wrapper.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED);
|
||||
} else if (Boolean.FALSE.equals(needReassign)) {
|
||||
wrapper.and(w -> w.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED));
|
||||
// 待重新派单必须按「子单被拒」找:运次口径把已拒子单排除在外,整单拒绝后这趟车在运次里已经不存在
|
||||
return reassignPendingPage(pageNo, pageSize, keyword);
|
||||
}
|
||||
applyKeywordFilter(wrapper, keyword);
|
||||
wrapper.orderByDesc(Waybill::getUpdateTime).orderByDesc(Waybill::getCreateTime);
|
||||
|
||||
IPage<Waybill> entityPage = waybillService.page(new Page<>(pageNo, pageSize), wrapper);
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
List<Long> pageIds = entityPage.getRecords().stream()
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> pageExceptionIds = pageIds.isEmpty()
|
||||
? Collections.emptySet()
|
||||
: exceptionWaybillIds.stream().filter(pageIds::contains).collect(Collectors.toSet());
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(manageableDeptIds());
|
||||
query.setKeyword(keyword);
|
||||
// 待处理只要待接单与进行中两档;「未拒单」也是交给运次读服务按子单行筛
|
||||
query.setStatuses(List.of(0, 1));
|
||||
query.setExcludeRejectedTrips(Boolean.FALSE.equals(needReassign));
|
||||
query.setCurrent(pageNo);
|
||||
query.setSize(pageSize);
|
||||
IPage<TripVO> trips = tripQueryService.page(query);
|
||||
return tripCardPage(trips.getRecords(), pageNo, pageSize, trips.getTotal(), exceptionWaybillIds);
|
||||
}
|
||||
|
||||
Page<AdminWaybillCardVO> voPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
voPage.setRecords(entityPage.getRecords().stream()
|
||||
.map(w -> toCard(w, pageExceptionIds.contains(w.getId())))
|
||||
.toList());
|
||||
return voPage;
|
||||
private Page<AdminWaybillCardVO> emptyCardPage(int pageNo, int pageSize) {
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, 0);
|
||||
page.setRecords(List.of());
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片页:一次算清这一页的重派标记与异常,再逐条拼卡片
|
||||
*/
|
||||
private Page<AdminWaybillCardVO> tripCardPage(List<TripVO> trips, int pageNo, int pageSize, long total,
|
||||
Set<Long> exceptionWaybillIds) {
|
||||
List<AdminWaybillCardVO> cards = new ArrayList<>(trips.size());
|
||||
if (!trips.isEmpty()) {
|
||||
Set<String> reassignKeys = loadReassignTripKeys(
|
||||
trips.stream().map(this::tripKeyOf).collect(Collectors.toCollection(LinkedHashSet::new)));
|
||||
for (TripVO trip : trips) {
|
||||
cards.add(toTripCard(trip, exceptionWaybillIds, reassignKeys));
|
||||
}
|
||||
}
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, total);
|
||||
page.setRecords(cards);
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次卡片:展示单号、整趟车起/经/终与站点数、货物汇总、计划时间都取运次字段。
|
||||
* 旧字段 id / waybillNo 保留运单语义(未配载就是这张运单,配载取第一张可见子运单),
|
||||
* 打卡订正、重新派单等按运单进入的入口仍靠它们。
|
||||
*/
|
||||
private AdminWaybillCardVO toTripCard(TripVO trip, Set<Long> exceptionWaybillIds,
|
||||
Set<String> reassignTripIds) {
|
||||
AdminWaybillCardVO card = new AdminWaybillCardVO();
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
|
||||
boolean loaded = Func.isNotEmpty(trip.getLoadingNo());
|
||||
|
||||
card.setTripId(trip.getId());
|
||||
card.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
card.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
card.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
|
||||
card.setWaybillNo(children.isEmpty() ? card.getTripNo() : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
card.setWaybillCount(trip.getWaybillCount());
|
||||
card.setTransportMode(Func.toStr(trip.getTransportType(), ""));
|
||||
card.setTransportType(loaded ? TRANSPORT_LOAD : TRANSPORT_COMMON);
|
||||
card.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), Func.toStr(trip.getDepartureAddress(), "")));
|
||||
card.setToName(Func.toStrWithEmpty(trip.getArrivalName(), Func.toStr(trip.getArrivalAddress(), "")));
|
||||
card.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
card.setStationCount(trip.getStationCount());
|
||||
card.setCargo(Func.toStr(trip.getCargoSummary(), ""));
|
||||
card.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
|
||||
card.setWeight(weight);
|
||||
card.setTotalWeight(weight);
|
||||
// 计划时间两条路同源:配载运次取配载单,未配载运次取那张运单自己的
|
||||
card.setPlanTime(formatLocalDate(trip.getEstimatedStartDate()));
|
||||
card.setPlanTimeEnd(formatLocalDate(trip.getEstimatedEndDate()));
|
||||
card.setStatus(trip.getStatus());
|
||||
card.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
card.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
card.setCreateTime(formatDateTime(trip.getSortTime()));
|
||||
card.setNeedReassign(reassignTripIds.contains(tripKeyOf(trip)));
|
||||
card.setHasException(hasChildException(children.stream().map(TripVO.TripWaybillVO::getId).toList(), exceptionWaybillIds));
|
||||
card.setBuyerPaid(false);
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 这一页里带「子单被拒」标记的运次主键:判据与重派待办一致(已拒单 + 未取消未完成) */
|
||||
private Set<String> loadReassignTripKeys(Set<String> tripIds) {
|
||||
if (Func.isEmpty(tripIds)) {
|
||||
return Set.of();
|
||||
}
|
||||
// 被拒子单一次取回后在服务层换算运次键
|
||||
return waybillService.list(pendingReassignQuery().select(Waybill::getId, Waybill::getLoadingNo))
|
||||
.stream()
|
||||
.map(this::tripKeyOf)
|
||||
.filter(tripIds::contains)
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
private boolean hasRejectedChild(String tripId) {
|
||||
return Func.isNotEmpty(tripId) && !loadReassignTripKeys(Set.of(tripId)).isEmpty();
|
||||
}
|
||||
|
||||
/** 运次里有没有未完成的异常:异常仍是运单级的事实,落到运次上按「任一子单有」算 */
|
||||
private boolean hasChildException(Collection<String> childIds, Set<Long> exceptionWaybillIds) {
|
||||
if (exceptionWaybillIds.isEmpty() || childIds.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return childIds.stream().map(this::parseId).filter(Objects::nonNull).anyMatch(exceptionWaybillIds::contains);
|
||||
}
|
||||
|
||||
/** 小程序 status → 运次状态:0待接单 / 1进行中 / 2已完成 */
|
||||
private Integer toTripStatus(String status) {
|
||||
if (Func.isBlank(status)) {
|
||||
return null;
|
||||
}
|
||||
return switch (status.trim()) {
|
||||
case "0", STATUS_PENDING -> 0;
|
||||
case "1", STATUS_RUNNING, "transporting", "doing" -> 1;
|
||||
case "2", STATUS_COMPLETED, "done" -> 2;
|
||||
case "3", STATUS_CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Date parseDayStart(String date) {
|
||||
if (Func.isBlank(date)) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.parse(date.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
|
||||
}
|
||||
|
||||
private Date parseDayEnd(String date) {
|
||||
if (Func.isBlank(date)) {
|
||||
return null;
|
||||
}
|
||||
return DateUtil.parse(date.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
|
||||
}
|
||||
|
||||
private Long parseId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 运次的键:配载取配载单号,未配载取 W + 运单主键,与运次读服务同一个口径 */
|
||||
private String tripKeyOf(Waybill waybill) {
|
||||
return waybill == null ? null : TripAggregation.keyOf(waybill.getLoadingNo(), waybill.getId());
|
||||
}
|
||||
|
||||
/** 运次的键:同上,运次卡片取运次主键换算 */
|
||||
private String tripKeyOf(TripVO trip) {
|
||||
return trip == null ? null : TripAggregation.keyOf(trip.getLoadingNo(), parseId(trip.getId()));
|
||||
}
|
||||
|
||||
/* ===========================================================================
|
||||
* 详情:按运次展示;已拒单等取不到运次的场景回落成运单视图
|
||||
* =========================================================================*/
|
||||
|
||||
/** 按运次主键取可见运次;取不到(已拒子单、不是运次主键)返回空,交给运单级回落 */
|
||||
private TripVO visibleTrip(String tripId) {
|
||||
try {
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setTripId(tripId);
|
||||
return tripQueryService.detail(query);
|
||||
} catch (ServiceException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次详情:与司机端接单页同一口径(单号取配载单号,站点 ①②③ 与每站货物条目都带运单号)。
|
||||
* 打卡节点取第一张可见子运单:管理端打卡订正仍按运单走,逐子单的打卡视图不在运次这一层。
|
||||
*/
|
||||
private AdminWaybillDetailVO tripDetail(TripVO trip) {
|
||||
List<TripVO.TripWaybillVO> children = trip.getWaybills() == null ? List.of() : trip.getWaybills();
|
||||
Long firstChildId = children.isEmpty() ? null : parseId(children.get(0).getId());
|
||||
List<TripStationVO> stations = trip.getStations() == null ? List.of() : trip.getStations();
|
||||
List<Long> scopeIds = new ArrayList<>();
|
||||
children.forEach(child -> {
|
||||
Long childId = parseId(child.getId());
|
||||
if (childId != null) {
|
||||
scopeIds.add(childId);
|
||||
}
|
||||
});
|
||||
if (scopeIds.isEmpty() && parseId(trip.getId()) != null) {
|
||||
scopeIds.add(parseId(trip.getId()));
|
||||
}
|
||||
|
||||
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
|
||||
detail.setId(firstChildId == null ? parseId(trip.getId()) : firstChildId);
|
||||
detail.setWaybillNo(children.isEmpty() ? Func.toStr(trip.getTripNo(), "") : Func.toStr(children.get(0).getWaybillNo(), ""));
|
||||
detail.setStatus(trip.getStatus());
|
||||
detail.setTransportMode(Func.toStr(trip.getTransportType(), ""));
|
||||
detail.setTransportType(toTransportTypeLabel(Func.toStr(trip.getTransportType(), "")));
|
||||
detail.setTransportOrgType(Func.isEmpty(trip.getLoadingNo()) ? TRANSPORT_COMMON : TRANSPORT_LOAD);
|
||||
detail.setTripId(trip.getId());
|
||||
detail.setLoadingNo(Func.toStr(trip.getLoadingNo(), ""));
|
||||
detail.setTripNo(Func.toStr(trip.getTripNo(), ""));
|
||||
detail.setViaName(Func.toStr(trip.getTransitName(), ""));
|
||||
detail.setStationCount(trip.getStationCount());
|
||||
detail.setCargoSummary(Func.toStr(trip.getCargoSummary(), ""));
|
||||
String weight = formatWeight(trip.getTotalQuantity(), trip.getQuantityUnit());
|
||||
detail.setCargoName(Func.toStr(trip.getCargoName(), ""));
|
||||
detail.setCargoNames(trip.getCargoNames() == null ? List.of() : trip.getCargoNames());
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
String fromAddr = Func.toStr(trip.getDepartureAddress(), "");
|
||||
String toAddr = Func.toStr(trip.getArrivalAddress(), "");
|
||||
detail.setFromName(Func.toStrWithEmpty(trip.getDepartureName(), fromAddr));
|
||||
detail.setToName(Func.toStrWithEmpty(trip.getArrivalName(), toAddr));
|
||||
detail.setFromAddress(Func.toStrWithEmpty(fromAddr, detail.getFromName()));
|
||||
detail.setToAddress(Func.toStrWithEmpty(toAddr, detail.getToName()));
|
||||
detail.setPickupAddress(detail.getFromAddress());
|
||||
detail.setUnloadAddress(detail.getToAddress());
|
||||
detail.setPlanShipTime(formatLocalDate(trip.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(trip.getEstimatedEndDate()));
|
||||
detail.setCarrierName("");
|
||||
detail.setDriverName(Func.toStr(trip.getDriverName(), ""));
|
||||
detail.setDriverPhone(Func.toStr(trip.getDriverPhone(), ""));
|
||||
detail.setVehicleNo(Func.toStr(trip.getVehicleNo(), ""));
|
||||
detail.setRemark(Func.toStr(trip.getTaskRemark(), ""));
|
||||
detail.setNeedReassign(hasRejectedChild(tripKeyOf(trip)));
|
||||
detail.setAcceptStatus(Func.toStr(trip.getAcceptStatus(), ""));
|
||||
detail.setRejectReason("");
|
||||
detail.setStations(stations);
|
||||
detail.setRoutePoints(toRoutePoints(stations));
|
||||
fillException(detail, scopeIds);
|
||||
fillPunchSnapshot(detail, firstChildId);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单主键回落:重新派单、异常处置等按运单进入的场景仍按运单展示;
|
||||
* 它属于配载单时补上整趟车的展示字段(配载单号、站点 ①②③、计划时间),与列表和司机端同一口径。
|
||||
*/
|
||||
private AdminWaybillDetailVO waybillDetail(Waybill waybill) {
|
||||
Set<Long> scopeIds = new LinkedHashSet<>();
|
||||
scopeIds.add(waybill.getId());
|
||||
AdminWaybillDetailVO detail = toDetail(waybill, false, null);
|
||||
if (Func.isEmpty(waybill.getLoadingNo())) {
|
||||
fillException(detail, scopeIds);
|
||||
return detail;
|
||||
}
|
||||
List<Waybill> children = visibleChildren(waybill.getLoadingNo());
|
||||
children.forEach(child -> scopeIds.add(child.getId()));
|
||||
LoadingManage loading = findLoading(waybill.getLoadingNo());
|
||||
if (loading != null) {
|
||||
// 整趟车的子单都被拒时组里为空,站点退回配载单自己的发货/途经/到货
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
detail.setTripId(String.valueOf(loading.getId()));
|
||||
detail.setLoadingNo(loading.getLoadingNo());
|
||||
detail.setTripNo(loading.getLoadingNo());
|
||||
detail.setTransportOrgType(TRANSPORT_LOAD);
|
||||
detail.setViaName(TripStationAssembler.transitName(stations));
|
||||
detail.setStationCount(stations.size());
|
||||
detail.setStations(stations);
|
||||
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
|
||||
detail.setFromName(Func.toStrWithEmpty(TripStationAssembler.departureName(stations), detail.getFromName()));
|
||||
detail.setToName(Func.toStrWithEmpty(TripStationAssembler.arrivalName(stations), detail.getToName()));
|
||||
if (Func.isEmpty(detail.getDriverName())) {
|
||||
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
}
|
||||
if (Func.isEmpty(detail.getVehicleNo())) {
|
||||
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
}
|
||||
}
|
||||
fillException(detail, scopeIds);
|
||||
return detail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按配载单主键取整趟车:整单被拒时子单为空,站点与计划时间仍按配载单展示
|
||||
*/
|
||||
private AdminWaybillDetailVO loadingDetail(LoadingManage loading, List<Waybill> children) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
List<Long> scopeIds = children.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
|
||||
Waybill head = children.isEmpty() ? null : children.get(0);
|
||||
|
||||
AdminWaybillDetailVO detail = new AdminWaybillDetailVO();
|
||||
detail.setId(head == null ? loading.getId() : head.getId());
|
||||
detail.setWaybillNo(head == null ? loading.getLoadingNo() : Func.toStr(head.getWaybillNo(), loading.getLoadingNo()));
|
||||
detail.setStatus(TripAggregation.appStatus(tripBusinessStatus(children)));
|
||||
detail.setTransportMode(Func.toStr(loading.getTransportType(), ""));
|
||||
detail.setTransportType(toTransportTypeLabel(Func.toStr(loading.getTransportType(), "")));
|
||||
detail.setTransportOrgType(TRANSPORT_LOAD);
|
||||
detail.setTripId(String.valueOf(loading.getId()));
|
||||
detail.setLoadingNo(loading.getLoadingNo());
|
||||
detail.setTripNo(loading.getLoadingNo());
|
||||
detail.setViaName(TripStationAssembler.transitName(stations));
|
||||
detail.setStationCount(stations.size());
|
||||
detail.setCargoName(Func.toStrWithEmpty(loading.getCargoName(), head == null ? "" : Func.toStr(head.getCargoName(), "")));
|
||||
String weight = head == null ? "" : formatWeight(head.getQuantity(), head.getQuantityUnit());
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
detail.setFromName(TripStationAssembler.departureName(stations));
|
||||
detail.setToName(TripStationAssembler.arrivalName(stations));
|
||||
detail.setFromAddress(Func.toStr(loading.getDepartureAddress(), ""));
|
||||
detail.setToAddress(Func.toStr(loading.getArrivalAddress(), ""));
|
||||
detail.setPickupAddress(detail.getFromAddress());
|
||||
detail.setUnloadAddress(detail.getToAddress());
|
||||
detail.setPlanShipTime(formatLocalDate(loading.getEstimatedStartDate()));
|
||||
detail.setPlanFinishTime(formatLocalDate(loading.getEstimatedEndDate()));
|
||||
detail.setCarrierName("");
|
||||
detail.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
detail.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
|
||||
detail.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
detail.setRemark(Func.toStr(loading.getTaskRemark(), ""));
|
||||
detail.setNeedReassign(true);
|
||||
detail.setAcceptStatus(Func.toStr(TripAggregation.acceptStatus(children), ""));
|
||||
detail.setRejectReason("");
|
||||
detail.setStations(stations);
|
||||
detail.setRoutePoints(toRoutePoints(stations));
|
||||
fillException(detail, scopeIds);
|
||||
fillPunchSnapshot(detail, head == null ? null : head.getId());
|
||||
return detail;
|
||||
}
|
||||
|
||||
/** 一组子运单的业务状态:全部终态取已完成/已取消,否则有待执行取待执行、其余进行中 */
|
||||
private String tripBusinessStatus(List<Waybill> children) {
|
||||
if (children.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
|
||||
return TripAggregation.statusOf(children, requireAccept, accepted);
|
||||
}
|
||||
|
||||
private List<Waybill> visibleChildren(String loadingNo) {
|
||||
if (Func.isEmpty(loadingNo)) {
|
||||
return List.of();
|
||||
}
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getLoadingNo, loadingNo)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED))
|
||||
.orderByAsc(Waybill::getCreateTime));
|
||||
}
|
||||
|
||||
private LoadingManage findLoading(String loadingNo) {
|
||||
if (Func.isEmpty(loadingNo)) {
|
||||
return null;
|
||||
}
|
||||
return loadingManageService.getOne(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.eq(LoadingManage::getLoadingNo, loadingNo)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** 未完成的异常处置:整趟车取最新一条,与旧单票详情同口径 */
|
||||
private void fillException(AdminWaybillDetailVO detail, Collection<Long> waybillIds) {
|
||||
ExceptionDisposal latest = latestException(waybillIds);
|
||||
detail.setHasException(latest != null);
|
||||
detail.setExceptionId(latest == null ? null : latest.getId());
|
||||
}
|
||||
|
||||
private ExceptionDisposal latestException(Collection<Long> waybillIds) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
return null;
|
||||
}
|
||||
return exceptionDisposalService.getOne(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.in(ExceptionDisposal::getWaybillId, waybillIds)
|
||||
.in(ExceptionDisposal::getDisposalStatus, DISPOSAL_PENDING, DISPOSAL_PROCESSING)
|
||||
.orderByDesc(ExceptionDisposal::getReportTime)
|
||||
.orderByDesc(ExceptionDisposal::getCreateTime)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
/** 打卡节点与途打卡记录复用司机端组装;管理端打卡订正按运单走,故传第一张可见子运单 */
|
||||
private void fillPunchSnapshot(AdminWaybillDetailVO detail, Long waybillId) {
|
||||
if (waybillId == null) {
|
||||
return;
|
||||
}
|
||||
DriverWaybillCardVO punch = driverWaybillService.detailPunchSnapshot(waybillId);
|
||||
if (punch == null) {
|
||||
return;
|
||||
}
|
||||
detail.setPunchNodes(punch.getPunchNodes());
|
||||
detail.setEnrouteRecords(punch.getEnrouteRecords());
|
||||
if (punch.getRoutePoints() != null && !punch.getRoutePoints().isEmpty()) {
|
||||
detail.setRoutePoints(punch.getRoutePoints().stream().map(p -> {
|
||||
AdminWaybillDetailVO.AdminRoutePointVO rp = new AdminWaybillDetailVO.AdminRoutePointVO();
|
||||
rp.setName(p.getName());
|
||||
rp.setAddress(p.getAddress());
|
||||
rp.setStatus(p.getStatus());
|
||||
return rp;
|
||||
}).toList());
|
||||
}
|
||||
}
|
||||
|
||||
/** 点位列:整趟车的全部站点,站序即途经顺序 */
|
||||
private List<AdminWaybillDetailVO.AdminRoutePointVO> toRoutePoints(List<TripStationVO> stations) {
|
||||
if (Func.isEmpty(stations)) {
|
||||
return List.of();
|
||||
}
|
||||
return stations.stream().map(station -> {
|
||||
AdminWaybillDetailVO.AdminRoutePointVO point = new AdminWaybillDetailVO.AdminRoutePointVO();
|
||||
point.setName(Func.toStr(station.getName(), ""));
|
||||
point.setAddress(Func.toStrWithEmpty(station.getAddress(), point.getName()));
|
||||
point.setStatus("pending");
|
||||
return point;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 待重新派单列表:按库层分组把「同一配载单被拒的多张子单」收成一条,未配载运单各自一条。
|
||||
* 分页与总数都在库层按运次完成,避免整单拒绝后同一趟车重复出现。
|
||||
*/
|
||||
private IPage<AdminWaybillCardVO> reassignPendingPage(int pageNo, int pageSize, String keyword) {
|
||||
QueryWrapper<Waybill> wrapper = reassignGroupQuery();
|
||||
applyGroupKeyword(wrapper, keyword);
|
||||
wrapper.orderByDesc(SQL_MAX_REJECT_TIME);
|
||||
|
||||
Page<Waybill> rows = new Page<>(pageNo, pageSize);
|
||||
waybillService.page(rows, wrapper);
|
||||
|
||||
Set<Long> exceptionWaybillIds = loadIncompleteExceptionWaybillIds();
|
||||
Page<AdminWaybillCardVO> page = new Page<>(pageNo, pageSize, rows.getTotal());
|
||||
page.setRecords(toReassignCards(rows.getRecords(), exceptionWaybillIds));
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 库层按运次分组的「待重新派单」查询:一条即一个运次,段二不用再回查子单。
|
||||
* 展示用字段一律包聚合函数取组内代表值,既满足 ONLY_FULL_GROUP_BY,也让一条待办自带它要显示的信息。
|
||||
*/
|
||||
private QueryWrapper<Waybill> reassignGroupQuery() {
|
||||
// 分组键只在 TripAggregation 声明一次:库层分组与服务层分组的「运次」必须是同一个东西
|
||||
QueryWrapper<Waybill> wrapper = Wrappers.query();
|
||||
wrapper.select("MIN(id) AS id", "MIN(NULLIF(loading_no, '')) AS loading_no", "MIN(waybill_no) AS waybill_no",
|
||||
"MIN(transport_type) AS transport_type", "MIN(business_status) AS business_status",
|
||||
"MIN(departure_name) AS departure_name", "MIN(arrival_name) AS arrival_name",
|
||||
"MIN(cargo_name) AS cargo_name", "MIN(quantity) AS quantity", "MIN(quantity_unit) AS quantity_unit",
|
||||
"MIN(estimated_start_time) AS estimated_start_time", "MIN(estimated_end_time) AS estimated_end_time",
|
||||
"MIN(driver_name) AS driver_name", "MIN(vehicle_no) AS vehicle_no",
|
||||
"MIN(driver_reject_reason) AS driver_reject_reason",
|
||||
SQL_MAX_REJECT_TIME + " AS driver_reject_time", SQL_MAX_CREATE_TIME + " AS create_time");
|
||||
wrapper.eq("driver_accept_status", ACCEPT_REJECTED);
|
||||
wrapper.in("business_status", STATUS_PENDING, STATUS_RUNNING);
|
||||
// 这条查询不经运次读服务,组织收口得自己带上,否则待重派列表与首页重派角标会出别的组织的单
|
||||
List<Long> deptIds = manageableDeptIds();
|
||||
if (Func.isNotEmpty(deptIds)) {
|
||||
wrapper.in("dept_id", deptIds);
|
||||
}
|
||||
wrapper.groupBy(TripAggregation.SQL_TRIP_KEY);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
/** 一条待办 = 一个运次:整趟车取配载单的单号与站点,未配载就是这张运单本身 */
|
||||
private List<AdminWaybillCardVO> toReassignCards(List<Waybill> rows, Set<Long> exceptionWaybillIds) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return List.of();
|
||||
}
|
||||
Set<String> loadingNos = rows.stream().map(Waybill::getLoadingNo).filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, LoadingManage> loadings = loadingsByNo(loadingNos);
|
||||
|
||||
List<AdminWaybillCardVO> cards = new ArrayList<>(rows.size());
|
||||
for (Waybill row : rows) {
|
||||
String loadingNo = Func.toStr(row.getLoadingNo(), "");
|
||||
AdminWaybillCardVO card = toCard(row, hasExceptionIds(row, exceptionWaybillIds));
|
||||
card.setTripId(String.valueOf(Func.isEmpty(loadingNo) ? row.getId() : loadingId(loadings.get(loadingNo), row.getId())));
|
||||
card.setTripNo(Func.isEmpty(loadingNo) ? Func.toStr(row.getWaybillNo(), "") : loadingNo);
|
||||
card.setLoadingNo(loadingNo);
|
||||
card.setWaybillCount(1);
|
||||
card.setCargoSummary(Func.toStr(row.getCargoName(), ""));
|
||||
card.setTotalWeight(formatWeight(row.getQuantity(), row.getQuantityUnit()));
|
||||
card.setNeedReassign(true);
|
||||
if (Func.isNotEmpty(loadingNo)) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loadings.get(loadingNo), List.of());
|
||||
card.setTransportType(TRANSPORT_LOAD);
|
||||
card.setFromName(TripStationAssembler.departureName(stations));
|
||||
card.setToName(TripStationAssembler.arrivalName(stations));
|
||||
card.setViaName(TripStationAssembler.transitName(stations));
|
||||
card.setStationCount(stations.size());
|
||||
} else {
|
||||
card.setTransportType(TRANSPORT_COMMON);
|
||||
card.setStationCount(2);
|
||||
}
|
||||
card.setCreateTime(formatDateTime(row.getCreateTime()));
|
||||
cards.add(card);
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
|
||||
private Map<String, LoadingManage> loadingsByNo(Set<String> loadingNos) {
|
||||
if (loadingNos.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery().in(LoadingManage::getLoadingNo, loadingNos))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
|
||||
}
|
||||
|
||||
private Long loadingId(LoadingManage loading, Long fallback) {
|
||||
return loading == null || loading.getId() == null ? fallback : loading.getId();
|
||||
}
|
||||
|
||||
/** 分组查询的关键字:运单号 / 司机 / 车牌,与列表关键字口径一致 */
|
||||
private void applyGroupKeyword(QueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isBlank(keyword)) {
|
||||
return;
|
||||
}
|
||||
String key = keyword.trim();
|
||||
wrapper.and(w -> w.like("waybill_no", key).or().like("driver_name", key).or().like("vehicle_no", key));
|
||||
}
|
||||
|
||||
private boolean hasExceptionIds(Waybill waybill, Set<Long> exceptionWaybillIds) {
|
||||
return waybill != null && waybill.getId() != null && exceptionWaybillIds.contains(waybill.getId());
|
||||
}
|
||||
|
||||
private AdminWaybillDetailVO toDetail(Waybill waybill, boolean hasException, Long exceptionId) {
|
||||
@@ -250,9 +759,12 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
detail.setPickupAddress(fromAddr);
|
||||
detail.setUnloadAddress(toAddr);
|
||||
|
||||
String cargo = Func.toStr(waybill.getCargoName(), "");
|
||||
String weight = formatWeight(waybill.getQuantity(), waybill.getQuantityUnit());
|
||||
detail.setCargoName(cargo);
|
||||
// 货名与合计重量取运单上录入的全部货物:主表 cargo_name 只是首行、quantity 是各行合计而单位只是首行的
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
detail.setCargoName(String.join("、", cargoNames));
|
||||
detail.setCargoNames(cargoNames);
|
||||
String weight = cargoWeight(cargos, formatWeight(waybill.getQuantity(), waybill.getQuantityUnit()));
|
||||
detail.setCargoQuantity(weight);
|
||||
detail.setWeight(weight);
|
||||
detail.setTotalWeight(weight);
|
||||
@@ -371,82 +883,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
};
|
||||
}
|
||||
|
||||
private long countWaybillByStatus(String status) {
|
||||
return waybillService.count(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getBusinessStatus, status));
|
||||
/**
|
||||
* 调度端可见组织:普通用户(含各级角色)只出自己所属组织,超级管理员不过滤。
|
||||
* <p>
|
||||
* 小程序不校验「所属组织不能为空」:账号没有组织时不按组织收口,避免首页、待处理等接口直接报错。
|
||||
* 详情与重新派单本身不走这里。
|
||||
*/
|
||||
private List<Long> manageableDeptIds() {
|
||||
if (TransportBusinessSupport.isAdministrator()) {
|
||||
return List.of();
|
||||
}
|
||||
Long deptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
if (Func.isEmpty(deptId) || deptId <= 0) {
|
||||
return List.of();
|
||||
}
|
||||
return List.of(deptId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 小程序调度端不做组织过滤。
|
||||
* 「小程序管理」等账号 JWT/档案 dept 常与运单业务组织不一致,按 dept 过滤会导致统计全 0;
|
||||
* 与后台 allDept=1 一致,仅依赖租户隔离(MyBatis-Plus TenantLine)。
|
||||
* 已拒子单的查询底:只有租户与逻辑删除(MyBatis-Plus 自动补),不带组织。
|
||||
* <p>
|
||||
* 它服务的是「这个运次里有没有被拒的子单」,可见范围由调用方先定好——列表传的运次已经收过组织,
|
||||
* 详情按主键进入不收组织。这里再按组织收一次,详情里别的组织的运次会丢掉重派标记。
|
||||
*/
|
||||
private LambdaQueryWrapper<Waybill> scopedWaybillQuery() {
|
||||
private LambdaQueryWrapper<Waybill> rejectedChildQuery() {
|
||||
return Wrappers.<Waybill>lambdaQuery();
|
||||
}
|
||||
|
||||
private void applyStatusFilter(LambdaQueryWrapper<Waybill> wrapper, String status) {
|
||||
String businessStatus = toBusinessStatus(status);
|
||||
if (Func.isNotBlank(businessStatus)) {
|
||||
wrapper.eq(Waybill::getBusinessStatus, businessStatus);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyExceptionFilter(LambdaQueryWrapper<Waybill> wrapper, String exception, Set<Long> exceptionWaybillIds) {
|
||||
if (EXCEPTION_YES.equals(exception)) {
|
||||
wrapper.in(Waybill::getId, exceptionWaybillIds);
|
||||
} else if (EXCEPTION_NO.equals(exception) && !exceptionWaybillIds.isEmpty()) {
|
||||
wrapper.notIn(Waybill::getId, exceptionWaybillIds);
|
||||
}
|
||||
}
|
||||
|
||||
private void applyTransportTypeFilter(LambdaQueryWrapper<Waybill> wrapper, String transportType) {
|
||||
if (TRANSPORT_LOAD.equals(transportType)) {
|
||||
wrapper.isNotNull(Waybill::getLoadingNo).ne(Waybill::getLoadingNo, "");
|
||||
} else if (TRANSPORT_COMMON.equals(transportType)) {
|
||||
wrapper.and(w -> w.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
|
||||
}
|
||||
}
|
||||
|
||||
private void applyKeywordFilter(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isBlank(keyword)) {
|
||||
return;
|
||||
}
|
||||
String key = keyword.trim();
|
||||
wrapper.and(w -> w.like(Waybill::getWaybillNo, key)
|
||||
.or().like(Waybill::getDriverName, key)
|
||||
.or().like(Waybill::getVehicleNo, key));
|
||||
}
|
||||
|
||||
private void applyCreateTimeFilter(LambdaQueryWrapper<Waybill> wrapper, String startDate, String endDate) {
|
||||
if (Func.isNotBlank(startDate)) {
|
||||
Date start = DateUtil.parse(startDate.trim() + " 00:00:00", DateUtil.PATTERN_DATETIME);
|
||||
if (start != null) {
|
||||
wrapper.ge(Waybill::getCreateTime, start);
|
||||
}
|
||||
}
|
||||
if (Func.isNotBlank(endDate)) {
|
||||
Date end = DateUtil.parse(endDate.trim() + " 23:59:59", DateUtil.PATTERN_DATETIME);
|
||||
if (end != null) {
|
||||
wrapper.le(Waybill::getCreateTime, end);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 小程序 status → 后端 businessStatus */
|
||||
private String toBusinessStatus(String status) {
|
||||
if (Func.isBlank(status)) {
|
||||
return null;
|
||||
}
|
||||
return switch (status.trim()) {
|
||||
case "0", STATUS_PENDING -> STATUS_PENDING;
|
||||
case "1", STATUS_RUNNING, "transporting", "doing" -> STATUS_RUNNING;
|
||||
case "2", STATUS_COMPLETED, "done" -> STATUS_COMPLETED;
|
||||
case "3", STATUS_CANCELLED -> STATUS_CANCELLED;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
private Set<Long> loadIncompleteExceptionWaybillIds() {
|
||||
List<ExceptionDisposal> list = exceptionDisposalService.list(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.select(ExceptionDisposal::getWaybillId)
|
||||
@@ -546,6 +1009,33 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
return Func.isBlank(unit) ? qty : qty + unit;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重量文案:全部货物同单位时按货物行相加,单位不一致时不出合计
|
||||
* <p>
|
||||
* 主表 quantity 是各行数量的合计、quantity_unit 只是首行的单位,「20堆 + 50方」会变成「70堆」。
|
||||
* 拿明细算不出合计(有货物没填数量或单位)时退回主表那份汇总值,不让重量凭空消失。
|
||||
*/
|
||||
private String cargoWeight(List<WaybillCargoSupport.Cargo> cargos, String fallback) {
|
||||
if (cargos.isEmpty()) {
|
||||
return fallback;
|
||||
}
|
||||
String unit = null;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (WaybillCargoSupport.Cargo cargo : cargos) {
|
||||
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
|
||||
return fallback;
|
||||
}
|
||||
if (unit == null) {
|
||||
unit = cargoUnit;
|
||||
} else if (!unit.equals(cargoUnit)) {
|
||||
return "";
|
||||
}
|
||||
total = total.add(cargo.quantity());
|
||||
}
|
||||
return formatWeight(total, unit);
|
||||
}
|
||||
|
||||
private String formatLocalDate(LocalDate date) {
|
||||
if (date == null) {
|
||||
return "";
|
||||
@@ -570,13 +1060,20 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
.eq(RiskDisposal::getDisposalStatus, RISK_PENDING));
|
||||
}
|
||||
|
||||
/** 待重新派单:司机已拒单,且运单尚未取消/完成 */
|
||||
/**
|
||||
* 待重新派单的运次数:整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键去重后只算一个。
|
||||
* <p>
|
||||
* 这里不走运次读服务:它的可见口径已排除被拒子单,整单拒绝的配载单在运次里根本不存在;
|
||||
* 分组键用与运次读服务同一个表达式,两处的运次才是同一个东西。
|
||||
*/
|
||||
private long countPendingReassignments() {
|
||||
return waybillService.count(pendingReassignQuery());
|
||||
Page<Waybill> rows = new Page<>(1, 1);
|
||||
waybillService.page(rows, reassignGroupQuery());
|
||||
return rows.getTotal();
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Waybill> pendingReassignQuery() {
|
||||
return Wrappers.<Waybill>lambdaQuery()
|
||||
return rejectedChildQuery()
|
||||
.eq(Waybill::getDriverAcceptStatus, ACCEPT_REJECTED)
|
||||
.in(Waybill::getBusinessStatus, STATUS_PENDING, STATUS_RUNNING);
|
||||
}
|
||||
@@ -611,11 +1108,17 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 待重新派单的待办:一条 = 一个运次。
|
||||
* <p>
|
||||
* 整单拒绝会把一张配载单的多张子运单一起写成已拒绝,按运次键分组后同一趟车只出 1 条;
|
||||
* 未配载运单自成运次,各自一条。排序与取值都在库层按运次完成,与待处理列表、角标同一口径。
|
||||
*/
|
||||
private List<TimedTodo> buildReassignFeed() {
|
||||
List<Waybill> list = waybillService.list(pendingReassignQuery()
|
||||
.orderByDesc(Waybill::getDriverRejectTime)
|
||||
List<Waybill> rows = waybillService.list(reassignGroupQuery()
|
||||
.orderByDesc(SQL_MAX_REJECT_TIME)
|
||||
.last("LIMIT " + FEED_LIMIT));
|
||||
return list.stream()
|
||||
return rows.stream()
|
||||
.map(waybill -> new TimedTodo(toReassignTodoItem(waybill), rejectTimeOf(waybill)))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
@@ -649,7 +1152,8 @@ public class ManageWaybillServiceImpl implements IManageWaybillService {
|
||||
item.setTitle("重新派单待处理");
|
||||
item.setTimeAgo(formatTimeAgo(rejectTimeOf(waybill)));
|
||||
item.setDesc(buildReassignDesc(waybill));
|
||||
item.setWaybillNo(Func.toStr(waybill.getWaybillNo(), ""));
|
||||
// 一条待办即一个运次:有配载单的展示配载单号;跳转参数仍是运单主键,该页据此回显并直接重派
|
||||
item.setWaybillNo(Func.toStrWithEmpty(waybill.getLoadingNo(), Func.toStr(waybill.getWaybillNo(), "")));
|
||||
item.setActionLabel("重新派单");
|
||||
item.setTargetUrl(String.format(REASSIGN_TARGET_URL, waybill.getId()));
|
||||
return item;
|
||||
|
||||
+230
-51
@@ -321,14 +321,15 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
BigDecimal writeOffAmount = preSettlementReference
|
||||
? projectAdvanceWriteOffService.sumBySettlement(TransportBusiConstant.SETTLEMENT_BILL_PRE, referenceId)
|
||||
: projectAdvanceWriteOffService.sumBySettlement(TransportBusiConstant.SETTLEMENT_BILL_FORMAL, referenceId);
|
||||
// 预结算转正式时,来源预结算的已付预付款已计入正式结算单已付,尾款可付金额需同步扣减
|
||||
BigDecimal advancePaidAmount = preSettlementReference ? BigDecimal.ZERO
|
||||
: this.formalSettlementAdvancePaid(referenceId);
|
||||
// 正式结算尾款还需扣减来源预结算进度预付(与正式结算剩余应付口径一致)
|
||||
BigDecimal advanceOccupied = preSettlementReference
|
||||
? BigDecimal.ZERO
|
||||
: formalSettlementAdvanceOccupied(referenceId);
|
||||
BigDecimal occupiedTotal = cumulativeAppliedAmount.add(writeOffAmount).add(advanceOccupied);
|
||||
PaymentApplicationReferenceAmountVO amount = new PaymentApplicationReferenceAmountVO();
|
||||
amount.setSettlementAmount(settlementAmount);
|
||||
amount.setCumulativeAppliedAmount(cumulativeAppliedAmount.add(writeOffAmount).add(advancePaidAmount));
|
||||
amount.setPayableAmount(settlementAmount.subtract(cumulativeAppliedAmount).subtract(writeOffAmount)
|
||||
.subtract(advancePaidAmount).max(BigDecimal.ZERO));
|
||||
amount.setCumulativeAppliedAmount(occupiedTotal);
|
||||
amount.setPayableAmount(settlementAmount.subtract(occupiedTotal).max(BigDecimal.ZERO));
|
||||
amount.setPaymentSequence(resolvePaymentSequence(preSettlementReference, referenceId, excludeId));
|
||||
return amount;
|
||||
}
|
||||
@@ -373,8 +374,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
matchedInvoiceAmount = matchedInvoiceAmount.add(money(item.getMatchedAmount()));
|
||||
}
|
||||
entity.setMatchedInvoiceAmount(matchedInvoiceAmount);
|
||||
// 进度预付只关联预结算单。编辑回传的 settlementId 可能是预结算单 ID,不能拿去查正式结算单。
|
||||
normalizeSettlementReference(request);
|
||||
// 进度预付只关联预结算单。编辑回传的 settlementId 可能是预结算单 ID 或已失效的正式结算单 ID。
|
||||
normalizeSettlementReference(request, entity);
|
||||
if (TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) && request.getSettlementId() == null
|
||||
&& request.getSettlementIds() != null && !request.getSettlementIds().isEmpty()) {
|
||||
request.setSettlementId(request.getSettlementIds().get(0));
|
||||
@@ -1894,62 +1895,162 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度预付只认预结算单。尾款付款若同时带回一个并不存在的正式结算单 ID,改为按预结算单保存。
|
||||
* 进度预付只认预结算单;尾款付款若 settlementId 实际是预结算单 ID 或正式结算单已不存在,改为按预结算单保存。
|
||||
*/
|
||||
private void normalizeSettlementReference(PaymentApplicationSaveRequest request) {
|
||||
if ("progress_advance".equals(request.getPaymentType())) {
|
||||
if (request.getPreSettlementId() == null && request.getPreSettlementIds() != null) {
|
||||
request.setPreSettlementId(request.getPreSettlementIds().stream().filter(Objects::nonNull).findFirst().orElse(null));
|
||||
private void normalizeSettlementReference(PaymentApplicationSaveRequest request, PaymentApplication entity) {
|
||||
if ("progress_advance".equals(request.getPaymentType())
|
||||
|| (entity != null && "progress_advance".equals(entity.getPaymentType())
|
||||
&& Func.isEmpty(request.getPaymentType()))) {
|
||||
if (Func.isEmpty(request.getPaymentType())) {
|
||||
request.setPaymentType("progress_advance");
|
||||
}
|
||||
if (request.getPreSettlementId() == null) {
|
||||
request.setPreSettlementId(request.getSettlementId());
|
||||
Long preSettlementId = resolveProgressPreSettlementId(request, entity);
|
||||
request.setPreSettlementId(preSettlementId);
|
||||
if (preSettlementId == null) {
|
||||
request.setPreSettlementIds(null);
|
||||
} else if (request.getPreSettlementIds() == null || request.getPreSettlementIds().stream().noneMatch(Objects::nonNull)) {
|
||||
request.setPreSettlementIds(List.of(preSettlementId));
|
||||
}
|
||||
request.setSettlementId(null);
|
||||
request.setSettlementIds(null);
|
||||
return;
|
||||
}
|
||||
if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType()) || request.getSettlementId() == null) {
|
||||
if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType())) {
|
||||
return;
|
||||
}
|
||||
boolean hasPreSettlement = request.getPreSettlementId() != null
|
||||
|| (request.getPreSettlementIds() != null && request.getPreSettlementIds().stream().anyMatch(Objects::nonNull));
|
||||
if (!hasPreSettlement) {
|
||||
// settlementIds 中若含无效正式结算单 ID,先尝试纠正为预结算
|
||||
if (request.getSettlementId() == null && request.getSettlementIds() != null
|
||||
&& !request.getSettlementIds().isEmpty()) {
|
||||
request.setSettlementId(request.getSettlementIds().stream().filter(Objects::nonNull).findFirst().orElse(null));
|
||||
}
|
||||
if (request.getSettlementId() == null) {
|
||||
return;
|
||||
}
|
||||
FormalSettlement formalSettlement = formalSettlementMapper.selectById(request.getSettlementId());
|
||||
if (formalSettlement == null || Objects.equals(formalSettlement.getIsDeleted(), 1)) {
|
||||
if (formalSettlement != null && !Objects.equals(formalSettlement.getIsDeleted(), 1)) {
|
||||
return;
|
||||
}
|
||||
// 正式结算单不存在:若该 ID 实为预结算单,或请求已带预结算引用,则清空正式结算引用
|
||||
PreSettlement preSettlement = preSettlementMapper.selectById(request.getSettlementId());
|
||||
boolean isPreSettlement = preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1);
|
||||
boolean hasPreSettlement = request.getPreSettlementId() != null
|
||||
|| (request.getPreSettlementIds() != null && request.getPreSettlementIds().stream().anyMatch(Objects::nonNull));
|
||||
if (isPreSettlement && request.getPreSettlementId() == null) {
|
||||
request.setPreSettlementId(request.getSettlementId());
|
||||
if (request.getPreSettlementIds() == null || request.getPreSettlementIds().isEmpty()) {
|
||||
request.setPreSettlementIds(List.of(request.getSettlementId()));
|
||||
}
|
||||
}
|
||||
if (isPreSettlement || hasPreSettlement) {
|
||||
request.setSettlementId(null);
|
||||
request.setSettlementIds(null);
|
||||
}
|
||||
}
|
||||
|
||||
/** 预结算付款不挂正式结算单时,清掉历史误写入的正式结算单 ID,避免再次提交按正式结算单刷新。 */
|
||||
/**
|
||||
* 预结算付款不挂正式结算单时,清掉历史误写入的正式结算单 ID,避免再次提交按正式结算单刷新。
|
||||
*/
|
||||
private void clearDetachedFormalReference(PaymentApplication entity) {
|
||||
if (entity.getId() == null || entity.getPreSettlementId() == null || entity.getSettlementId() != null) {
|
||||
if (entity.getId() == null) {
|
||||
return;
|
||||
}
|
||||
boolean progressAdvance = "progress_advance".equals(entity.getPaymentType());
|
||||
boolean preOnly = entity.getPreSettlementId() != null && entity.getSettlementId() == null;
|
||||
if (!progressAdvance && !preOnly) {
|
||||
return;
|
||||
}
|
||||
baseMapper.update(null, Wrappers.<PaymentApplication>lambdaUpdate()
|
||||
.eq(PaymentApplication::getId, entity.getId())
|
||||
.setSql("settlement_id = null, settlement_no = null"));
|
||||
entity.setSettlementId(null);
|
||||
entity.setSettlementNo(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 进度预付驳回重提时,请求里的结算主键可能是失效正式结算单或精度失真后的 ID。
|
||||
* 依次用预结算主键、正式结算位上的预结算主键、单据已保存的预结算主键和预结算单号找回。
|
||||
*/
|
||||
private Long resolveProgressPreSettlementId(PaymentApplicationSaveRequest request, PaymentApplication entity) {
|
||||
List<Long> candidates = new java.util.ArrayList<>();
|
||||
if (request.getPreSettlementId() != null) {
|
||||
candidates.add(request.getPreSettlementId());
|
||||
}
|
||||
if (request.getPreSettlementIds() != null) {
|
||||
request.getPreSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add);
|
||||
}
|
||||
if (request.getSettlementId() != null) {
|
||||
candidates.add(request.getSettlementId());
|
||||
}
|
||||
if (request.getSettlementIds() != null) {
|
||||
request.getSettlementIds().stream().filter(Objects::nonNull).forEach(candidates::add);
|
||||
}
|
||||
if (entity != null && entity.getPreSettlementId() != null) {
|
||||
candidates.add(entity.getPreSettlementId());
|
||||
}
|
||||
for (Long candidate : candidates) {
|
||||
PreSettlement preSettlement = preSettlementMapper.selectById(candidate);
|
||||
if (preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1)) {
|
||||
return preSettlement.getId();
|
||||
}
|
||||
}
|
||||
String preSettlementNo = request.getPreSettlementNo();
|
||||
if (Func.isEmpty(preSettlementNo) && entity != null) {
|
||||
preSettlementNo = entity.getPreSettlementNo();
|
||||
}
|
||||
if (Func.isNotEmpty(preSettlementNo)) {
|
||||
String settlementNo = preSettlementNo.split("、")[0].trim();
|
||||
PreSettlement preSettlement = preSettlementMapper.selectOne(Wrappers.<PreSettlement>lambdaQuery()
|
||||
.eq(PreSettlement::getPreSettlementNo, settlementNo)
|
||||
.eq(PreSettlement::getIsDeleted, 0)
|
||||
.last("limit 1"));
|
||||
if (preSettlement != null) {
|
||||
return preSettlement.getId();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void fillReference(PaymentApplication entity, PaymentApplicationSaveRequest request) {
|
||||
if ("project_advance".equals(request.getPaymentType())) {
|
||||
if ("progress_advance".equals(request.getPaymentType())) {
|
||||
Long preSettlementId = resolveProgressPreSettlementId(request, entity);
|
||||
request.setPreSettlementId(preSettlementId);
|
||||
if (preSettlementId == null) {
|
||||
throw new ServiceException("进度预付必须关联预结算单");
|
||||
}
|
||||
request.setSettlementId(null);
|
||||
request.setSettlementIds(null);
|
||||
fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId()));
|
||||
} else if ("project_advance".equals(request.getPaymentType())) {
|
||||
ProjectApply project = projectApplyMapper.selectById(request.getProjectId());
|
||||
if (project == null || Objects.equals(project.getIsDeleted(), 1)) throw new ServiceException("所属项目不存在");
|
||||
entity.setSettlementId(null); entity.setSettlementNo(null); entity.setPreSettlementId(null); entity.setPreSettlementNo(null);
|
||||
entity.setProjectId(request.getProjectId()); entity.setProjectName(request.getProjectName()); entity.setDeptId(request.getDeptId()); entity.setDeptName(request.getDeptName()); entity.setContractId(request.getContractId()); entity.setContractNo(request.getContractNo()); entity.setContractName(request.getContractName()); entity.setPayerName(request.getPayerName()); entity.setPayeeName(request.getPayeeName()); entity.setSettlementAmount(request.getSettlementAmount()); entity.setPayableAmount(money(project.getFundDemand()).multiply(BigDecimal.valueOf(10000))); entity.setBillType(Func.isEmpty(project.getSettlementMode()) ? null : "项目预付");
|
||||
} else if (request.getSettlementId() != null) {
|
||||
FormalSettlement source = formalSettlementMapper.selectById(request.getSettlementId());
|
||||
if (source == null) throw new ServiceException("正式结算单不存在");
|
||||
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的正式结算单");
|
||||
entity.setSettlementId(source.getId()); entity.setSettlementNo(source.getFormalSettlementNo()); entity.setPreSettlementId(null); entity.setPreSettlementNo(null);
|
||||
entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(payableOfFormalSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); entity.setBillType("正式结算单");
|
||||
if (source == null || Objects.equals(source.getIsDeleted(), 1)) {
|
||||
// 驳回重提等场景:settlementId 可能误存为预结算单 ID,降级走预结算分支
|
||||
PreSettlement preSettlement = preSettlementMapper.selectById(request.getSettlementId());
|
||||
if (preSettlement != null && !Objects.equals(preSettlement.getIsDeleted(), 1)) {
|
||||
request.setPreSettlementId(preSettlement.getId());
|
||||
request.setSettlementId(null);
|
||||
fillPreSettlementReference(entity, preSettlement);
|
||||
} else if (request.getPreSettlementId() != null) {
|
||||
request.setSettlementId(null);
|
||||
fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId()));
|
||||
} else if (entity.getPreSettlementId() != null) {
|
||||
request.setSettlementId(null);
|
||||
request.setPreSettlementId(entity.getPreSettlementId());
|
||||
fillPreSettlementReference(entity, requireApprovedPreSettlement(entity.getPreSettlementId()));
|
||||
} else {
|
||||
throw new ServiceException("正式结算单不存在");
|
||||
}
|
||||
} else {
|
||||
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的正式结算单");
|
||||
entity.setSettlementId(source.getId()); entity.setSettlementNo(source.getFormalSettlementNo()); entity.setPreSettlementId(null); entity.setPreSettlementNo(null);
|
||||
entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(payableOfFormalSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); entity.setBillType("正式结算单");
|
||||
}
|
||||
} else if (request.getPreSettlementId() != null) {
|
||||
PreSettlement source = preSettlementMapper.selectById(request.getPreSettlementId());
|
||||
if (source == null) throw new ServiceException("预结算单不存在");
|
||||
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) throw new ServiceException("只能选择审批通过、未作废的预结算单");
|
||||
entity.setPreSettlementId(source.getId()); entity.setPreSettlementNo(source.getPreSettlementNo()); entity.setSettlementId(null); entity.setSettlementNo(null); entity.setProjectId(source.getProjectId()); entity.setProjectName(source.getProjectName()); entity.setDeptId(source.getDeptId()); entity.setDeptName(source.getDeptName()); entity.setContractId(source.getContractId()); entity.setContractNo(source.getContractNo()); entity.setContractName(source.getContractName()); entity.setPayerName(source.getPayerName()); entity.setPayeeName(source.getPayeeName()); entity.setSettlementAmount(source.getSettlementAmount()); entity.setPayableAmount(payableOfPreSettlement(source.getId(), source.getSettlementAmount(), entity.getId())); entity.setBillType("预结算单");
|
||||
fillPreSettlementReference(entity, requireApprovedPreSettlement(request.getPreSettlementId()));
|
||||
} else {
|
||||
entity.setProjectId(request.getProjectId()); entity.setProjectName(request.getProjectName()); entity.setDeptId(request.getDeptId()); entity.setDeptName(request.getDeptName()); entity.setContractId(request.getContractId()); entity.setContractNo(request.getContractNo()); entity.setContractName(request.getContractName()); entity.setPayerName(request.getPayerName()); entity.setPayeeName(request.getPayeeName()); entity.setSettlementAmount(request.getSettlementAmount()); entity.setPayableAmount(request.getPayableAmount()); entity.setBillType(request.getBillType());
|
||||
}
|
||||
@@ -1958,6 +2059,36 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
&& money(entity.getPayableAmount()).compareTo(BigDecimal.ZERO) > 0) throw new ServiceException("申请付款金额不能超过可付款金额");
|
||||
}
|
||||
|
||||
private PreSettlement requireApprovedPreSettlement(Long preSettlementId) {
|
||||
PreSettlement source = preSettlementMapper.selectById(preSettlementId);
|
||||
if (source == null || Objects.equals(source.getIsDeleted(), 1)) {
|
||||
throw new ServiceException("预结算单不存在");
|
||||
}
|
||||
if (!TransportBusiConstant.APPROVED.equals(source.getApprovalStatus())) {
|
||||
throw new ServiceException("只能选择审批通过、未作废的预结算单");
|
||||
}
|
||||
return source;
|
||||
}
|
||||
|
||||
private void fillPreSettlementReference(PaymentApplication entity, PreSettlement source) {
|
||||
entity.setPreSettlementId(source.getId());
|
||||
entity.setPreSettlementNo(source.getPreSettlementNo());
|
||||
entity.setSettlementId(null);
|
||||
entity.setSettlementNo(null);
|
||||
entity.setProjectId(source.getProjectId());
|
||||
entity.setProjectName(source.getProjectName());
|
||||
entity.setDeptId(source.getDeptId());
|
||||
entity.setDeptName(source.getDeptName());
|
||||
entity.setContractId(source.getContractId());
|
||||
entity.setContractNo(source.getContractNo());
|
||||
entity.setContractName(source.getContractName());
|
||||
entity.setPayerName(source.getPayerName());
|
||||
entity.setPayeeName(source.getPayeeName());
|
||||
entity.setSettlementAmount(source.getSettlementAmount());
|
||||
entity.setPayableAmount(payableOfPreSettlement(source.getId(), source.getSettlementAmount(), entity.getId()));
|
||||
entity.setBillType("预结算单");
|
||||
}
|
||||
|
||||
private List<FormalSettlement> resolveFormalSettlements(PaymentApplicationSaveRequest request, PaymentApplication entity) {
|
||||
if (!TransportBusiConstant.PAYMENT_TYPE_SETTLEMENT.equals(request.getPaymentType())) return List.of();
|
||||
List<Long> ids = request.getSettlementIds() == null ? List.of() : request.getSettlementIds();
|
||||
@@ -2361,28 +2492,43 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
return relationAmount.add(legacyAmount);
|
||||
}
|
||||
|
||||
/** 正式结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 − 来源预结算已付预付款 */
|
||||
/**
|
||||
* 正式结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 − 来源预结算进度预付占用。
|
||||
* 进度预付占用与正式结算台账 remainingPayable 口径对齐,避免转正式后仍可按全额申请尾款。
|
||||
*/
|
||||
private BigDecimal payableOfFormalSettlement(Long settlementId, BigDecimal settlementAmount, Long excludeId) {
|
||||
return money(settlementAmount)
|
||||
.subtract(cumulativeFormalSettlementApplied(settlementId, excludeId))
|
||||
.subtract(projectAdvanceWriteOffService.sumBySettlement(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_FORMAL, settlementId))
|
||||
.subtract(this.formalSettlementAdvancePaid(settlementId))
|
||||
.subtract(formalSettlementAdvanceOccupied(settlementId))
|
||||
.max(BigDecimal.ZERO);
|
||||
}
|
||||
|
||||
/** 正式结算单来源预结算的已付预付款合计(转正式时已计入结算单已付) */
|
||||
private BigDecimal formalSettlementAdvancePaid(Long formalSettlementId) {
|
||||
if (formalSettlementId == null) {
|
||||
return BigDecimal.ZERO;
|
||||
/**
|
||||
* 正式结算来源预结算的进度预付占用合计:优先取来源行快照,快照为空时回退预结算实时已申请/已付。
|
||||
*/
|
||||
private BigDecimal formalSettlementAdvanceOccupied(Long settlementId) {
|
||||
if (settlementId == null) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
return formalSettlementSourceMapper.selectList(Wrappers.<FormalSettlementSource>lambdaQuery()
|
||||
.select(FormalSettlementSource::getAdvancePaidAmount)
|
||||
.eq(FormalSettlementSource::getFormalSettlementId, formalSettlementId)
|
||||
.eq(FormalSettlementSource::getIsDeleted, 0)).stream()
|
||||
.map(FormalSettlementSource::getAdvancePaidAmount)
|
||||
.map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
List<FormalSettlementSource> sources = formalSettlementSourceMapper.selectList(
|
||||
Wrappers.<FormalSettlementSource>lambdaQuery()
|
||||
.eq(FormalSettlementSource::getFormalSettlementId, settlementId)
|
||||
.eq(FormalSettlementSource::getIsDeleted, 0));
|
||||
if (sources.isEmpty()) {
|
||||
return BigDecimal.ZERO.setScale(2, RoundingMode.HALF_UP);
|
||||
}
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
for (FormalSettlementSource source : sources) {
|
||||
BigDecimal occupied = money(source.getAdvanceAppliedAmount()).max(money(source.getAdvancePaidAmount()));
|
||||
if (occupied.compareTo(BigDecimal.ZERO) <= 0 && source.getPreSettlementId() != null) {
|
||||
occupied = findPreSettlementApplied(source.getPreSettlementId())
|
||||
.max(findPreSettlementPaid(source.getPreSettlementId()));
|
||||
}
|
||||
total = total.add(occupied);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 预结算可付款 = 结算金额 − 已申请付款 − 项目预付核销 */
|
||||
@@ -2401,7 +2547,8 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
return;
|
||||
}
|
||||
BillLedger ledger = billLedgerMapper.selectById(request.getBillLedgerId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId(),
|
||||
entity.getSettlementId(), entity.getPreSettlementId());
|
||||
entity.setBillLedgerId(ledger.getId());
|
||||
entity.setBillNo(ledger.getBillNo());
|
||||
}
|
||||
@@ -2409,25 +2556,57 @@ public class PaymentApplicationServiceImpl extends BaseServiceImpl<PaymentApplic
|
||||
private void validateSelectedBill(PaymentApplication entity, boolean locked) {
|
||||
if (!isBillPayment(entity.getPaymentMethod())) return;
|
||||
BillLedger ledger = locked ? lockedBill(entity.getBillLedgerId()) : billLedgerMapper.selectById(entity.getBillLedgerId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, entity.getId(),
|
||||
entity.getSettlementId(), entity.getPreSettlementId());
|
||||
}
|
||||
|
||||
private void validateBill(BillLedger ledger, Long deptId, BigDecimal amount, boolean checkMaturity, Long paymentApplicationId) {
|
||||
private void validateBill(BillLedger ledger, Long deptId, BigDecimal amount, boolean checkMaturity,
|
||||
Long paymentApplicationId, Long settlementId, Long preSettlementId) {
|
||||
if (ledger == null || Objects.equals(ledger.getIsDeleted(), 1)) throw new ServiceException("所选汇票台账不存在");
|
||||
if (!"received".equals(ledger.getBillType())) {
|
||||
throw new ServiceException("汇票付款仅可选择收票类型的汇票");
|
||||
boolean partyMatched = settlementPartiesMatch(ledger, settlementId, preSettlementId);
|
||||
if (!"received".equals(ledger.getBillType()) && !partyMatched) {
|
||||
throw new ServiceException("汇票付款仅可选择收票类型的汇票,或出票单位与收票单位均为当前结算收付款方的汇票");
|
||||
}
|
||||
if (checkMaturity && (ledger.getMaturityDate() == null || ledger.getMaturityDate().isBefore(LocalDate.now()))) {
|
||||
throw new ServiceException("所选汇票已到期");
|
||||
}
|
||||
BigDecimal available = money(ledger.getAvailableBalance()).add(occupiedAmount(paymentApplicationId, ledger.getId()));
|
||||
if (money(amount).compareTo(available) > 0) throw new ServiceException("申请付款金额超过汇票可用余额");
|
||||
if (!departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内");
|
||||
if (!partyMatched && !departmentAvailable(ledger, deptId)) throw new ServiceException("当前使用部门不在汇票可用部门范围内");
|
||||
}
|
||||
|
||||
/** 出票单位、收票单位与结算付款方、收款方一致(顺序不限) */
|
||||
private boolean settlementPartiesMatch(BillLedger ledger, Long settlementId, Long preSettlementId) {
|
||||
String payerName = null;
|
||||
String payeeName = null;
|
||||
if (settlementId != null) {
|
||||
FormalSettlement settlement = formalSettlementMapper.selectById(settlementId);
|
||||
if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) {
|
||||
payerName = settlement.getPayerName();
|
||||
payeeName = settlement.getPayeeName();
|
||||
}
|
||||
}
|
||||
if ((payerName == null || payeeName == null) && preSettlementId != null) {
|
||||
PreSettlement settlement = preSettlementMapper.selectById(preSettlementId);
|
||||
if (settlement != null && !Objects.equals(settlement.getIsDeleted(), 1)) {
|
||||
payerName = settlement.getPayerName();
|
||||
payeeName = settlement.getPayeeName();
|
||||
}
|
||||
}
|
||||
String issuer = ledger.getIssuerName() == null ? "" : ledger.getIssuerName().trim();
|
||||
String receiver = ledger.getReceiverName() == null ? "" : ledger.getReceiverName().trim();
|
||||
String payer = payerName == null ? "" : payerName.trim();
|
||||
String payee = payeeName == null ? "" : payeeName.trim();
|
||||
if (issuer.isEmpty() || receiver.isEmpty() || payer.isEmpty() || payee.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return (issuer.equals(payer) && receiver.equals(payee)) || (issuer.equals(payee) && receiver.equals(payer));
|
||||
}
|
||||
|
||||
private void useBillBalance(PaymentApplication entity) {
|
||||
BillLedger ledger = lockedBill(entity.getBillLedgerId());
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null);
|
||||
validateBill(ledger, entity.getDeptId(), entity.getAppliedAmount(), true, null,
|
||||
entity.getSettlementId(), entity.getPreSettlementId());
|
||||
BillLedgerUsage exists = lockedBillUsage(entity.getId());
|
||||
if (exists != null && TransportBusiConstant.APPROVED.equals(exists.getUsageStatus())) {
|
||||
throw new ServiceException("该付款申请已生成汇票使用记录");
|
||||
|
||||
+82
-65
@@ -206,6 +206,9 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
vo.setChangeRecords(changeRecordMapper.selectList(Wrappers.<PreSettlementChangeRecord>lambdaQuery()
|
||||
.eq(PreSettlementChangeRecord::getPreSettlementId, id)
|
||||
.eq(PreSettlementChangeRecord::getIsDeleted, 0)
|
||||
.and(wrapper -> wrapper.isNull(PreSettlementChangeRecord::getChangeContent)
|
||||
.or()
|
||||
.ne(PreSettlementChangeRecord::getChangeContent, "预结算审批通过"))
|
||||
.orderByDesc(PreSettlementChangeRecord::getChangeTime)));
|
||||
vo.setPrintTemplates(resolvePrintTemplates(settlement));
|
||||
return vo;
|
||||
@@ -355,8 +358,10 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
synchronizeDetails(settlement, request.getSourceDetailIds(), Boolean.TRUE.equals(request.getAllowSourceMismatch()));
|
||||
}
|
||||
settlement.setExchangeRate(normalizeRate(request.getExchangeRate(), settlement.getCurrency()));
|
||||
List<PreSettlementSummaryFee> summaryBefore = created ? new ArrayList<>() : listSummaryFees(settlement.getId());
|
||||
rebuildSummaryFees(settlement.getId(), true);
|
||||
applySummaryRequest(settlement.getId(), contractChanged ? List.of() : request.getSummaryFees());
|
||||
recordSummaryFeeChanges(settlement.getId(), summaryBefore);
|
||||
refreshSettlementAmount(settlement);
|
||||
projectAdvanceWriteOffService.replaceWriteOffs(
|
||||
TransportBusiConstant.SETTLEMENT_BILL_PRE,
|
||||
@@ -425,7 +430,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
settlement.setCurrentProcessor(AuthUtil.getUserName());
|
||||
settlement.setApprovedTime(LocalDateTime.now());
|
||||
updateById(settlement);
|
||||
saveChange(settlement.getId(), "结算单基本信息", null, "调整", "预结算审批通过", request.getReason());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -1129,11 +1133,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
throw new ServiceException("存在无效的手工费用行");
|
||||
}
|
||||
boolean creating = row.getId() == null;
|
||||
String beforeFeeType = creating ? null : row.getFeeType();
|
||||
String beforeFeeItem = creating ? null : row.getFeeItem();
|
||||
BigDecimal beforeAdjustAmount = creating ? null : money(row.getAdjustAmount());
|
||||
BigDecimal beforeSettlementAmount = creating ? null : money(row.getSettlementAmount());
|
||||
String beforeRemark = creating ? null : row.getRemark();
|
||||
row.setPreSettlementId(settlementId);
|
||||
row.setLineNo(lineNo++);
|
||||
row.setFeeType(feeType);
|
||||
@@ -1149,8 +1148,6 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
summaryFeeMapper.updateById(row);
|
||||
}
|
||||
retainedManualIds.add(row.getId());
|
||||
saveManualSummaryFeeChange(settlementId, creating, beforeFeeType, beforeFeeItem,
|
||||
beforeAdjustAmount, beforeSettlementAmount, beforeRemark, row);
|
||||
continue;
|
||||
}
|
||||
String feeItem = requiredText(requestRow.getFeeItem(), "费用项");
|
||||
@@ -1175,45 +1172,16 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
row.setFeeItem(feeItem);
|
||||
// 生成费用行的三个金额必须来源于结算明细快照,不能接受前端回传值覆盖原金额。
|
||||
// rebuildSummaryFees 已按明细重建 original/adjust/settlement,此处仅保留备注。
|
||||
BigDecimal before = money(row.getAdjustAmount());
|
||||
row.setOriginalAmount(money(row.getOriginalAmount()));
|
||||
row.setSettlementAmount(money(row.getSettlementAmount()));
|
||||
row.setAdjustAmount(money(row.getSettlementAmount().subtract(row.getOriginalAmount())));
|
||||
row.setRemark(limitRemark(requestRow.getRemark(), 50));
|
||||
if (row.getId() == null) summaryFeeMapper.insert(row); else summaryFeeMapper.updateById(row);
|
||||
retainedGeneratedIds.add(row.getId());
|
||||
if (before.compareTo(row.getAdjustAmount()) != 0) {
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
beforeData.put("费用项", row.getFeeItem());
|
||||
beforeData.put("调整金额", before);
|
||||
beforeData.put("结算金额", money(row.getOriginalAmount().add(before)));
|
||||
afterData.put("费用项", row.getFeeItem());
|
||||
afterData.put("调整金额", row.getAdjustAmount());
|
||||
afterData.put("结算金额", money(row.getSettlementAmount()));
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "调整",
|
||||
"【" + row.getFeeItem() + "-调整金额】从【" + before + "】调整为【" + row.getAdjustAmount() + "】",
|
||||
"", beforeData, afterData);
|
||||
}
|
||||
}
|
||||
for (PreSettlementSummaryFee manualRow : manualRows) {
|
||||
if (!retainedManualIds.contains(manualRow.getId())) {
|
||||
summaryFeeMapper.deleteById(manualRow.getId());
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
beforeData.put("费用类型", manualRow.getFeeType());
|
||||
beforeData.put("费用项", manualRow.getFeeItem());
|
||||
beforeData.put("调整金额", money(manualRow.getAdjustAmount()));
|
||||
beforeData.put("结算金额", money(manualRow.getSettlementAmount()));
|
||||
beforeData.put("备注", manualRow.getRemark());
|
||||
afterData.put("费用类型", "");
|
||||
afterData.put("费用项", "");
|
||||
afterData.put("调整金额", BigDecimal.ZERO.setScale(2));
|
||||
afterData.put("结算金额", BigDecimal.ZERO.setScale(2));
|
||||
afterData.put("备注", "");
|
||||
saveChange(settlementId, "合计费用项", manualRow.getLineNo(), "删除",
|
||||
"删除合计费用项【" + manualRow.getFeeItem() + "】金额【" + money(manualRow.getSettlementAmount()) + "】",
|
||||
"", beforeData, afterData);
|
||||
}
|
||||
}
|
||||
for (PreSettlementSummaryFee generatedRow : existingGeneratedRows) {
|
||||
@@ -1563,38 +1531,87 @@ public class PreSettlementServiceImpl extends BaseServiceImpl<PreSettlementMappe
|
||||
}
|
||||
}
|
||||
|
||||
private void saveManualSummaryFeeChange(Long settlementId, boolean creating, String beforeFeeType,
|
||||
String beforeFeeItem, BigDecimal beforeAdjustAmount, BigDecimal beforeSettlementAmount, String beforeRemark,
|
||||
PreSettlementSummaryFee row) {
|
||||
List<String> changes = new ArrayList<>();
|
||||
private void recordSummaryFeeChanges(Long settlementId, List<PreSettlementSummaryFee> beforeRows) {
|
||||
List<Map<String, Object>> beforeLines = summaryFeeLines(beforeRows);
|
||||
List<Map<String, Object>> afterLines = summaryFeeLines(listSummaryFees(settlementId));
|
||||
if (beforeLines.equals(afterLines)) {
|
||||
return;
|
||||
}
|
||||
String content = summaryFeeChangeContent(beforeLines, afterLines);
|
||||
if (Func.isEmpty(content)) {
|
||||
return;
|
||||
}
|
||||
Map<String, Object> beforeData = new LinkedHashMap<>();
|
||||
Map<String, Object> afterData = new LinkedHashMap<>();
|
||||
if (creating) {
|
||||
changes.add("新增合计费用项【" + row.getFeeItem() + "】金额【" + money(row.getSettlementAmount()) + "】");
|
||||
beforeData.put("费用类型", "");
|
||||
beforeData.put("费用项", "");
|
||||
beforeData.put("调整金额", BigDecimal.ZERO.setScale(2));
|
||||
beforeData.put("结算金额", BigDecimal.ZERO.setScale(2));
|
||||
beforeData.put("备注", "");
|
||||
afterData.put("费用类型", row.getFeeType());
|
||||
afterData.put("费用项", row.getFeeItem());
|
||||
afterData.put("调整金额", money(row.getAdjustAmount()));
|
||||
afterData.put("结算金额", money(row.getSettlementAmount()));
|
||||
afterData.put("备注", row.getRemark());
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "新增", String.join(";", changes), "",
|
||||
beforeData, afterData);
|
||||
return;
|
||||
beforeData.put("结算合计", beforeLines);
|
||||
afterData.put("结算合计", afterLines);
|
||||
String operationType = beforeLines.isEmpty() ? "新增" : afterLines.isEmpty() ? "删除" : "调整";
|
||||
saveChange(settlementId, "结算合计", null, operationType, content, "", beforeData, afterData);
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> summaryFeeLines(List<PreSettlementSummaryFee> rows) {
|
||||
Map<String, String> categoryNames = DictBizCache.getList("fee_category").stream()
|
||||
.collect(Collectors.toMap(DictBiz::getDictKey, DictBiz::getDictValue, (first, second) -> first));
|
||||
List<Map<String, Object>> lines = new ArrayList<>();
|
||||
for (PreSettlementSummaryFee row : rows) {
|
||||
String feeType = row.getFeeType() == null ? "" : row.getFeeType();
|
||||
Map<String, Object> line = new LinkedHashMap<>();
|
||||
line.put("来源", Integer.valueOf(1).equals(row.getManualFlag()) ? "手工新增" : "系统归集");
|
||||
line.put("费用类型", categoryNames.getOrDefault(feeType, feeType));
|
||||
line.put("费用项", row.getFeeItem() == null ? "" : row.getFeeItem());
|
||||
line.put("原金额", money(row.getOriginalAmount()));
|
||||
line.put("调整金额", money(row.getAdjustAmount()));
|
||||
line.put("结算金额", money(row.getSettlementAmount()));
|
||||
line.put("备注", row.getRemark() == null ? "" : row.getRemark());
|
||||
lines.add(line);
|
||||
}
|
||||
appendTextChange(changes, beforeData, afterData, "费用类型", beforeFeeType, row.getFeeType());
|
||||
appendTextChange(changes, beforeData, afterData, "费用项", beforeFeeItem, row.getFeeItem());
|
||||
appendChange(changes, beforeData, afterData, "调整金额", beforeAdjustAmount, row.getAdjustAmount());
|
||||
appendChange(changes, beforeData, afterData, "结算金额", beforeSettlementAmount, row.getSettlementAmount());
|
||||
appendTextChange(changes, beforeData, afterData, "备注", beforeRemark, row.getRemark());
|
||||
if (changes.isEmpty()) {
|
||||
return;
|
||||
return lines;
|
||||
}
|
||||
|
||||
private String summaryFeeChangeContent(List<Map<String, Object>> beforeLines, List<Map<String, Object>> afterLines) {
|
||||
Map<String, Map<String, Object>> beforeMap = indexSummaryLines(beforeLines);
|
||||
Map<String, Map<String, Object>> afterMap = indexSummaryLines(afterLines);
|
||||
List<String> changes = new ArrayList<>();
|
||||
afterMap.forEach((key, afterLine) -> {
|
||||
Map<String, Object> beforeLine = beforeMap.get(key);
|
||||
String label = summaryLineLabel(afterLine);
|
||||
if (beforeLine == null) {
|
||||
changes.add("新增结算合计【" + label + "】结算金额【" + afterLine.get("结算金额") + "】");
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(beforeLine.get("原金额"), afterLine.get("原金额"))
|
||||
|| !Objects.equals(beforeLine.get("调整金额"), afterLine.get("调整金额"))
|
||||
|| !Objects.equals(beforeLine.get("结算金额"), afterLine.get("结算金额"))
|
||||
|| !Objects.equals(String.valueOf(beforeLine.get("备注")), String.valueOf(afterLine.get("备注")))) {
|
||||
changes.add("调整结算合计【" + label + "】结算金额从【" + beforeLine.get("结算金额")
|
||||
+ "】调整为【" + afterLine.get("结算金额") + "】");
|
||||
}
|
||||
});
|
||||
beforeMap.forEach((key, beforeLine) -> {
|
||||
if (!afterMap.containsKey(key)) {
|
||||
changes.add("删除结算合计【" + summaryLineLabel(beforeLine) + "】结算金额【" + beforeLine.get("结算金额") + "】");
|
||||
}
|
||||
});
|
||||
return String.join(";", changes);
|
||||
}
|
||||
|
||||
private Map<String, Map<String, Object>> indexSummaryLines(List<Map<String, Object>> lines) {
|
||||
Map<String, Map<String, Object>> indexed = new LinkedHashMap<>();
|
||||
Map<String, Integer> counts = new HashMap<>();
|
||||
for (Map<String, Object> line : lines) {
|
||||
String base = String.valueOf(line.get("来源")) + "|" + line.get("费用类型") + "|" + line.get("费用项");
|
||||
int index = counts.merge(base, 1, Integer::sum);
|
||||
indexed.put(base + "#" + index, line);
|
||||
}
|
||||
saveChange(settlementId, "合计费用项", row.getLineNo(), "调整", String.join(";", changes), "",
|
||||
beforeData, afterData);
|
||||
return indexed;
|
||||
}
|
||||
|
||||
private String summaryLineLabel(Map<String, Object> line) {
|
||||
String feeType = String.valueOf(line.getOrDefault("费用类型", ""));
|
||||
String feeItem = String.valueOf(line.getOrDefault("费用项", ""));
|
||||
String source = String.valueOf(line.getOrDefault("来源", ""));
|
||||
String name = feeType.isBlank() ? feeItem : feeType + "-" + feeItem;
|
||||
return source + " " + name;
|
||||
}
|
||||
|
||||
private void saveChange(Long settlementId, String changeType, Integer lineNo, String operationType,
|
||||
|
||||
+20
-5
@@ -252,8 +252,8 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
BigDecimal flowClaimedAfter = money(flow.getClaimedAmount())
|
||||
.subtract(money(claim.getClaimAmount())).max(BigDecimal.ZERO);
|
||||
flow.setClaimedAmount(flowClaimedAfter);
|
||||
// 认领状态对照公告未认领金额重算(作废分摊不计入累计)
|
||||
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, flow.getUnclaimedAmount()));
|
||||
// 认领状态对照可认领上限重算(作废分摊不计入累计)
|
||||
flow.setClaimStatus(amountClaimStatus(flowClaimedAfter, claimCeiling(flow)));
|
||||
receiptFlowMapper.updateById(flow);
|
||||
|
||||
// 作废不生成金蝶认领冲单:金蝶单号/状态保留原值(未推送为空,已推送为真实收款单信息),
|
||||
@@ -411,13 +411,28 @@ public class ReceiptClaimRecordServiceImpl extends BaseServiceImpl<ReceiptClaimM
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(与收款流水侧口径一致)
|
||||
* 累计认领金额 → 流水认领状态:对照可认领上限判定(与收款流水侧口径一致)
|
||||
*/
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
|
||||
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return "unclaimed";
|
||||
}
|
||||
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? "claimed" : "partial";
|
||||
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? "claimed" : "partial";
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值(与收款流水侧口径一致)
|
||||
*/
|
||||
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
|
||||
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
|
||||
BigDecimal receipt = money(flow.getReceiptAmount());
|
||||
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
return unclaimed.min(receipt);
|
||||
}
|
||||
|
||||
private String amountStatus(BigDecimal paidAmount, BigDecimal settlementAmount) {
|
||||
|
||||
+126
-20
@@ -194,12 +194,16 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.eq(ReceiptFlowRecord::getReceiptFlowId, id)
|
||||
.in(ReceiptFlowRecord::getActionType, List.of("claim", "push"))
|
||||
.orderByDesc(ReceiptFlowRecord::getCreateTime)));
|
||||
// 继续认领:带回本流水历史关联且仍有剩余可认领金额的结算单
|
||||
vo.setRelatedSettlements(relatedSettlementCandidates(id));
|
||||
return vo;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> settlementCandidates(String keyword, Long flowId) {
|
||||
existing(flowId);
|
||||
// 本流水历史关联结算单优先保留(避免仅按创建时间 limit 截断后选不到)
|
||||
Set<Long> relatedIds = relatedSettlementIds(flowId);
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
@@ -210,7 +214,24 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.or().like(FormalSettlement::getProjectName, keyword)
|
||||
.or().like(FormalSettlement::getContractName, keyword))
|
||||
.orderByDesc(FormalSettlement::getCreateTime)
|
||||
.last("limit 200"));
|
||||
.last("limit 500"));
|
||||
if (!relatedIds.isEmpty()) {
|
||||
Set<Long> loadedIds = settlements.stream().map(FormalSettlement::getId)
|
||||
.filter(Objects::nonNull).collect(Collectors.toSet());
|
||||
List<Long> missingIds = relatedIds.stream().filter(id -> !loadedIds.contains(id)).toList();
|
||||
if (!missingIds.isEmpty()) {
|
||||
List<FormalSettlement> relatedSettlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.in(FormalSettlement::getId, missingIds)
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
.eq(FormalSettlement::getApprovalStatus, APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1));
|
||||
List<FormalSettlement> merged = new ArrayList<>(relatedSettlements.size() + settlements.size());
|
||||
merged.addAll(relatedSettlements);
|
||||
merged.addAll(settlements);
|
||||
settlements = merged;
|
||||
}
|
||||
}
|
||||
// 候选结算单的累计认领金额一次聚合查询(避免逐单 N+1)
|
||||
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
|
||||
.map(FormalSettlement::getId).toList());
|
||||
@@ -218,6 +239,14 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
.map(settlement -> candidateRow(settlement,
|
||||
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
|
||||
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
|
||||
.sorted((left, right) -> {
|
||||
boolean leftRelated = relatedIds.contains(toLong(left.get("id")));
|
||||
boolean rightRelated = relatedIds.contains(toLong(right.get("id")));
|
||||
if (leftRelated == rightRelated) {
|
||||
return 0;
|
||||
}
|
||||
return leftRelated ? -1 : 1;
|
||||
})
|
||||
.toList();
|
||||
}
|
||||
|
||||
@@ -234,6 +263,7 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getFormalSettlementId, formalSettlementId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
|
||||
.orderByDesc(ReceiptClaimSettlement::getCreateTime));
|
||||
if (relations.isEmpty()) {
|
||||
return List.of();
|
||||
@@ -315,19 +345,19 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
allocatedTotal = allocatedTotal.add(allocated);
|
||||
}
|
||||
|
||||
// 公告未认领金额(认领上限与认领完成判定基准,A301-2 口径)
|
||||
// 认领上限:公告未认领金额与收款金额取较小值(避免未认领金额异常偏大导致无法认领完成)
|
||||
if (flow.getUnclaimedAmount() == null) {
|
||||
throw new ServiceException("公告未认领金额缺失,请先手动同步金蝶收款流水");
|
||||
}
|
||||
BigDecimal unclaimedAmount = money(flow.getUnclaimedAmount());
|
||||
if (unclaimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("该流水公告未认领金额为0,无需认领");
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
if (claimCeiling.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new ServiceException("该流水可认领金额为0,无需认领");
|
||||
}
|
||||
// 流水认领前已认领金额
|
||||
BigDecimal previousFlowClaimed = flowClaimedAmount(flow.getId());
|
||||
if (previousFlowClaimed.add(allocatedTotal).compareTo(unclaimedAmount) > 0) {
|
||||
throw new ServiceException("累计认领金额不能超过公告未认领金额,剩余可认领 "
|
||||
+ unclaimedAmount.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
|
||||
if (previousFlowClaimed.add(allocatedTotal).compareTo(claimCeiling) > 0) {
|
||||
throw new ServiceException("累计认领金额不能超过可认领上限,剩余可认领 "
|
||||
+ claimCeiling.subtract(previousFlowClaimed).max(BigDecimal.ZERO));
|
||||
}
|
||||
|
||||
// 当前用户部门
|
||||
@@ -380,6 +410,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
relation.setClaimedReceiptAmount(previousClaimed);
|
||||
// 本次分摊金额
|
||||
relation.setAllocatedReceiptAmount(allocated);
|
||||
// 有效分摊(与汇总/候选查询 status=1 口径一致)
|
||||
relation.setStatus(1);
|
||||
relation.setIsDeleted(0);
|
||||
claimSettlementMapper.insert(relation);
|
||||
}
|
||||
|
||||
@@ -390,8 +423,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
String fromStatus = normalizeClaimStatus(flow.getClaimStatus());
|
||||
// 认领后累计金额
|
||||
BigDecimal claimedAfter = previousFlowClaimed.add(allocatedTotal);
|
||||
// 认领后状态(对照公告未认领金额)
|
||||
String toStatus = amountClaimStatus(claimedAfter, unclaimedAmount);
|
||||
// 认领后状态(对照可认领上限)
|
||||
String toStatus = amountClaimStatus(claimedAfter, claimCeiling);
|
||||
// 更新流水已认领金额
|
||||
flow.setClaimedAmount(claimedAfter);
|
||||
// 更新流水认领状态
|
||||
@@ -499,9 +532,9 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
|
||||
// 复制公告数据到实体(不覆盖本地认领字段)
|
||||
copyAnnounceRow(entity, announce, detailSerialNo, receiptAmount);
|
||||
// 根据本地累计认领金额与最新公告未认领金额重新计算认领状态
|
||||
// 根据本地累计认领金额与可认领上限重新计算认领状态
|
||||
entity.setClaimStatus(amountClaimStatus(money(entity.getClaimedAmount()),
|
||||
entity.getUnclaimedAmount()));
|
||||
claimCeiling(entity)));
|
||||
|
||||
if (created) {
|
||||
toInsert.add(entity);
|
||||
@@ -621,6 +654,46 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 本流水历史关联过的结算单 id(有效分摊)
|
||||
*/
|
||||
private Set<Long> relatedSettlementIds(Long flowId) {
|
||||
if (flowId == null) {
|
||||
return Set.of();
|
||||
}
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.select(ReceiptClaimSettlement::getFormalSettlementId)
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.map(ReceiptClaimSettlement::getFormalSettlementId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续认领预填:本流水历史关联且仍有剩余可认领金额的结算单
|
||||
*/
|
||||
private List<Map<String, Object>> relatedSettlementCandidates(Long flowId) {
|
||||
Set<Long> relatedIds = relatedSettlementIds(flowId);
|
||||
if (relatedIds.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<FormalSettlement> settlements = formalSettlementMapper.selectList(
|
||||
Wrappers.<FormalSettlement>lambdaQuery()
|
||||
.in(FormalSettlement::getId, relatedIds)
|
||||
.eq(FormalSettlement::getSettlementType, RECEIVABLE)
|
||||
.eq(FormalSettlement::getApprovalStatus, APPROVED)
|
||||
.eq(FormalSettlement::getStatus, 1));
|
||||
Map<Long, BigDecimal> claimedMap = receiptClaimedAmounts(settlements.stream()
|
||||
.map(FormalSettlement::getId).toList());
|
||||
return settlements.stream()
|
||||
.map(settlement -> candidateRow(settlement,
|
||||
claimedMap.getOrDefault(settlement.getId(), BigDecimal.ZERO)))
|
||||
.filter(row -> ((BigDecimal) row.get("remainingReceiptAmount")).compareTo(BigDecimal.ZERO) > 0)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量聚合结算单的累计有效认领金额:一次 IN 查询按结算单分组求和,避免逐单 N+1
|
||||
*/
|
||||
@@ -631,7 +704,8 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.in(ReceiptClaimSettlement::getFormalSettlementId, ids)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.collect(Collectors.groupingBy(ReceiptClaimSettlement::getFormalSettlementId,
|
||||
Collectors.reducing(BigDecimal.ZERO,
|
||||
item -> money(item.getAllocatedReceiptAmount()), BigDecimal::add)));
|
||||
@@ -640,12 +714,42 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
private BigDecimal flowClaimedAmount(Long flowId) {
|
||||
return claimSettlementMapper.selectList(Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flowId)
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)).stream()
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)).stream()
|
||||
.map(ReceiptClaimSettlement::getAllocatedReceiptAmount)
|
||||
.map(this::money)
|
||||
.reduce(BigDecimal.ZERO, BigDecimal::add);
|
||||
}
|
||||
|
||||
/**
|
||||
* 可认领上限:公告未认领金额与收款金额取较小正值。
|
||||
* 主口径仍为 A301-2 公告未认领金额;与收款金额取 min,避免未认领金额异常偏大导致无法进入「认领完成」。
|
||||
*/
|
||||
private BigDecimal claimCeiling(KingdeeReceiptFlow flow) {
|
||||
BigDecimal unclaimed = money(flow.getUnclaimedAmount());
|
||||
BigDecimal receipt = money(flow.getReceiptAmount());
|
||||
if (receipt.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
if (unclaimed.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return unclaimed;
|
||||
}
|
||||
return unclaimed.min(receipt);
|
||||
}
|
||||
|
||||
private Long toLong(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (value instanceof Long longValue) {
|
||||
return longValue;
|
||||
}
|
||||
if (value instanceof Number number) {
|
||||
return number.longValue();
|
||||
}
|
||||
return Func.toLong(String.valueOf(value));
|
||||
}
|
||||
|
||||
private KingdeeReceiptFlow lockedFlow(Long flowId) {
|
||||
KingdeeReceiptFlow flow = baseMapper.selectOne(Wrappers.<KingdeeReceiptFlow>lambdaQuery()
|
||||
.eq(KingdeeReceiptFlow::getId, flowId)
|
||||
@@ -824,18 +928,20 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
flow.setClaimMe(announce.getClaimme());
|
||||
BigDecimal claimedTotal = flowClaimedAmount(flow.getId());
|
||||
flow.setClaimedAmount(claimedTotal);
|
||||
String claimStatus = amountClaimStatus(claimedTotal, flow.getUnclaimedAmount());
|
||||
BigDecimal claimCeiling = claimCeiling(flow);
|
||||
String claimStatus = amountClaimStatus(claimedTotal, claimCeiling);
|
||||
flow.setClaimStatus(claimStatus);
|
||||
updateById(flow);
|
||||
if (!CLAIMED.equals(claimStatus)) {
|
||||
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与公告未认领金额("
|
||||
+ money(flow.getUnclaimedAmount()) + ")不一致,不满足推送条件");
|
||||
return ReceiptClaimPushResult.fail("累计认领金额(" + claimedTotal + ")与可认领上限("
|
||||
+ claimCeiling + ")不一致,不满足推送条件");
|
||||
}
|
||||
// 分录取该流水全部有效分摊行(结算单id升序,跨认领主单归集)
|
||||
List<ReceiptClaimSettlement> allocations = claimSettlementMapper.selectList(
|
||||
Wrappers.<ReceiptClaimSettlement>lambdaQuery()
|
||||
.eq(ReceiptClaimSettlement::getReceiptFlowId, flow.getId())
|
||||
.eq(ReceiptClaimSettlement::getStatus, 1)
|
||||
.eq(ReceiptClaimSettlement::getIsDeleted, 0)
|
||||
.orderByAsc(ReceiptClaimSettlement::getFormalSettlementId));
|
||||
if (allocations.isEmpty()) {
|
||||
return ReceiptClaimPushResult.fail("该流水无有效认领分摊,无法推送");
|
||||
@@ -1075,13 +1181,13 @@ public class ReceiptFlowServiceImpl extends BaseServiceImpl<KingdeeReceiptFlowMa
|
||||
}
|
||||
|
||||
/**
|
||||
* 累计认领金额 → 流水认领状态:对照公告未认领金额判定(claimed 即「认领完成」,唯一可推送状态)
|
||||
* 累计认领金额 → 流水认领状态:对照可认领上限判定(claimed 即「认领完成」,唯一可推送状态)
|
||||
*/
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal unclaimedAmount) {
|
||||
private String amountClaimStatus(BigDecimal claimedAmount, BigDecimal claimCeiling) {
|
||||
if (claimedAmount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
return UNCLAIMED;
|
||||
}
|
||||
return claimedAmount.compareTo(money(unclaimedAmount)) >= 0 ? CLAIMED : PARTIAL;
|
||||
return claimedAmount.compareTo(money(claimCeiling)) >= 0 ? CLAIMED : PARTIAL;
|
||||
}
|
||||
|
||||
private String amountStatus(BigDecimal claimedAmount, BigDecimal settlementAmount) {
|
||||
|
||||
+27
-11
@@ -424,9 +424,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
});
|
||||
ReceivablePayableFeeDetailVO result = buildFeeDetail(rows);
|
||||
LinkedHashSet<String> feeItemNames = new LinkedHashSet<>(contractFeeItemNames(detail.getContractId()));
|
||||
feeItemNames.addAll(result.getFeeItemNames());
|
||||
result.setFeeItemNames(new ArrayList<>(feeItemNames));
|
||||
// 只展示本明细已生成的费用项。合同上其他计费方案的费用项不能叠到当前明细。
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1968,21 +1966,35 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId) {
|
||||
return calculatedFees(waybill, contract, planId, false);
|
||||
return calculatedFees(waybill, contract, planId, false, false);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId, boolean matchOnly) {
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId,
|
||||
boolean matchOnly) {
|
||||
return calculatedFees(waybill, contract, planId, matchOnly, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param strictPlan 更新费用时为 true:选定方案有规则但未命中时,不再回退运单历史费用,避免旧费用项叠加
|
||||
*/
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, ContractManage contract, String planId,
|
||||
boolean matchOnly, boolean strictPlan) {
|
||||
List<Map<String, Object>> plans = parseList(contract == null ? null : contract.getBillingPlanJson());
|
||||
Map<String, Object> plan = "__matched__".equals(planId)
|
||||
? resolveDefaultBillingPlan(plans, waybill.getTransportType())
|
||||
: resolveBillingPlan(plans, planId);
|
||||
return calculatedFees(waybill, plan, matchOnly);
|
||||
return calculatedFees(waybill, plan, matchOnly, strictPlan);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, Map<String, Object> plan,
|
||||
boolean matchOnly) {
|
||||
return calculatedFees(waybill, plan, matchOnly, false);
|
||||
}
|
||||
|
||||
private List<ReceivablePayableCargoFee> calculatedFees(Waybill waybill, Map<String, Object> plan,
|
||||
boolean matchOnly, boolean strictPlan) {
|
||||
if (plan == null || !(plan.get("rules") instanceof List<?>)) {
|
||||
return matchOnly ? List.of() : buildCargoFees(waybill);
|
||||
return matchOnly || strictPlan ? List.of() : buildCargoFees(waybill);
|
||||
}
|
||||
Map<List<String>, ReceivablePayableCargoFee> feesByCargo = new LinkedHashMap<>();
|
||||
Map<List<String>, Map<String, BigDecimal>> feeItemsByCargo = new LinkedHashMap<>();
|
||||
@@ -2024,7 +2036,11 @@ public class ReceivablePayableDetailServiceImpl
|
||||
ReceivablePayableCargoFee fee = result.get(index);
|
||||
fee.setLineNo(String.format("%04d", index + 1));
|
||||
}
|
||||
return result.isEmpty() && !matchOnly ? buildCargoFees(waybill) : result;
|
||||
// 已选定计费方案时,只保留该方案算出的费用项,不用运单上的历史费用回填。
|
||||
if (strictPlan || matchOnly) {
|
||||
return result;
|
||||
}
|
||||
return result.isEmpty() ? buildCargoFees(waybill) : result;
|
||||
}
|
||||
|
||||
private ReceivablePayableCargoFee buildCalculatedCargoFee(Waybill waybill, Waybill feeWaybill,
|
||||
@@ -2934,7 +2950,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
throw new ServiceException("关联运单不存在");
|
||||
}
|
||||
ContractManage contract = contractManageService.getById(detail.getContractId());
|
||||
List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId);
|
||||
List<ReceivablePayableCargoFee> fees = calculatedFees(waybill, contract, billingPlanId, false, true);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery().eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
fees.forEach(fee -> { fee.setDetailId(detail.getId()); cargoFeeMapper.insert(fee); });
|
||||
BigDecimal freight = fees.stream().map(fee -> money(fee.getFreightAmount()))
|
||||
@@ -2959,7 +2975,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
ContractManage contract = contractManageService.getById(detail.getContractId());
|
||||
List<Waybill> masterGoods = masterOrderGoods(masterOrder);
|
||||
List<ReceivablePayableCargoFee> fees = masterGoods.stream()
|
||||
.flatMap(goods -> calculatedFees(goods, contract, billingPlanId).stream()).toList();
|
||||
.flatMap(goods -> calculatedFees(goods, contract, billingPlanId, false, true).stream()).toList();
|
||||
normalizeMasterFeeLines(fees);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
@@ -2995,7 +3011,7 @@ public class ReceivablePayableDetailServiceImpl
|
||||
}
|
||||
ContractManage contract = contractManageService.getById(detail.getContractId());
|
||||
List<ReceivablePayableCargoFee> fees = waybills.stream()
|
||||
.flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId).stream()).toList();
|
||||
.flatMap(waybill -> calculatedFees(waybill, contract, billingPlanId, false, true).stream()).toList();
|
||||
normalizeWaybillFeeLines(fees);
|
||||
cargoFeeMapper.delete(Wrappers.<ReceivablePayableCargoFee>lambdaQuery()
|
||||
.eq(ReceivablePayableCargoFee::getDetailId, detail.getId()));
|
||||
|
||||
+10
-22
@@ -41,7 +41,6 @@ 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.DictBizCache;
|
||||
import org.springblade.system.cache.DictCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.feign.ISysClient;
|
||||
import org.springblade.system.pojo.entity.DictBiz;
|
||||
@@ -824,7 +823,7 @@ public class SettlementImportBatchServiceImpl
|
||||
for (Map.Entry<String, BigDecimal> entry : feeItems.entrySet()) {
|
||||
String name = normalizeFeeName(entry.getKey());
|
||||
BigDecimal amount = entry.getValue() == null ? BigDecimal.ZERO : entry.getValue();
|
||||
// 模板自带的装卸费、过路费等列未填写时金额为 0,不要求费用项主数据存在同名档案
|
||||
// 用户自行增列的费用项金额为 0 时跳过校验;金额不为 0 须匹配系统已启用费用项
|
||||
if (Func.isEmpty(name) || amount.compareTo(BigDecimal.ZERO) == 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -1149,9 +1148,6 @@ public class SettlementImportBatchServiceImpl
|
||||
excel.setMileage(decimalValue(row, "mileage", "里程(km)", "里程"));
|
||||
excel.setUnitPrice(decimalValue(row, "unitPrice", "运输单价", "单价"));
|
||||
excel.setTransportFee(decimalValue(row, "transportFee", "*运输费", "运输费"));
|
||||
Map<String, BigDecimal> 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", "*实际完成时间", "实际完成时间"));
|
||||
@@ -1330,34 +1326,26 @@ public class SettlementImportBatchServiceImpl
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运输方式取业务字典 transport_type 的字典名称(dictValue),不写死枚举。
|
||||
*/
|
||||
private List<String> loadTransportTypeLabels() {
|
||||
LinkedHashSet<String> labels = new LinkedHashSet<>();
|
||||
try {
|
||||
List<DictBiz> dictBizList = DictBizCache.getList("transport_type");
|
||||
if (Func.isNotEmpty(dictBizList)) {
|
||||
dictBizList.stream()
|
||||
.map(DictBiz::getDictValue)
|
||||
.filter(dict -> dict != null && Func.isNotEmpty(dict.getDictValue())
|
||||
&& !"-1".equals(Func.toStr(dict.getDictKey())))
|
||||
.map(dict -> dict.getDictValue().trim())
|
||||
.filter(Func::isNotEmpty)
|
||||
.map(String::trim)
|
||||
.forEach(labels::add);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 业务字典不可用时继续读取系统字典
|
||||
}
|
||||
try {
|
||||
List<org.springblade.system.pojo.entity.Dict> dictList = DictCache.getList("transport_type");
|
||||
if (Func.isNotEmpty(dictList)) {
|
||||
dictList.stream()
|
||||
.map(org.springblade.system.pojo.entity.Dict::getDictValue)
|
||||
.filter(Func::isNotEmpty)
|
||||
.map(String::trim)
|
||||
.forEach(labels::add);
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 忽略
|
||||
} catch (Exception exception) {
|
||||
throw new ServiceException("业务字典 transport_type 读取失败,请检查业务字典配置");
|
||||
}
|
||||
if (labels.isEmpty()) {
|
||||
return List.of("公路运输", "铁路运输", "水路运输", "航空运输");
|
||||
throw new ServiceException("业务字典 transport_type 未配置运输方式,请先维护字典名称");
|
||||
}
|
||||
return new ArrayList<>(labels);
|
||||
}
|
||||
|
||||
+2
-4
@@ -246,10 +246,8 @@ public class TemporaryCreditLimitServiceImpl extends BaseServiceImpl<TemporaryCr
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getProjectCode(), "项目编号不能为空");
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getUndertakeDeptName(), "承办部门不能为空");
|
||||
TransportBusinessSupport.validateRequired(temporaryCreditLimit.getValidUntil() == null ? null : temporaryCreditLimit.getValidUntil().toString(), "请选择申请有效期至");
|
||||
if (temporaryCreditLimit.getProjectFundLimit() == null || temporaryCreditLimit.getUsedFundLimit() == null || temporaryCreditLimit.getRemainingFundLimit() == null) {
|
||||
throw new ServiceException("项目额度数据异常,请重新选择项目");
|
||||
}
|
||||
if (temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
|
||||
if (temporaryCreditLimit.getRemainingFundLimit() != null
|
||||
&& temporaryCreditLimit.getRemainingFundLimit().compareTo(BigDecimal.ZERO) < 0) {
|
||||
throw new ServiceException("项目剩余额度不能小于0");
|
||||
}
|
||||
}
|
||||
|
||||
+31
-1
@@ -81,6 +81,8 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
|
||||
@Override
|
||||
public IPage<TransportPlanVO> selectTransportPlanPage(IPage<TransportPlan> page, TransportPlanVO transportPlan) {
|
||||
// 历史手工创建未写入 dept_id,allDept=0 按组织过滤时会被排除
|
||||
backfillMissingDept(transportPlan);
|
||||
IPage<TransportPlan> entityPage = page(page, buildQuery(transportPlan));
|
||||
IPage<TransportPlanVO> resultPage = TransportPlanWrapper.build().pageVO(entityPage);
|
||||
fillCustomerNamesFromContracts(resultPage.getRecords());
|
||||
@@ -151,6 +153,12 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
if (created) {
|
||||
transportPlan.setPlanNo(nextCode());
|
||||
}
|
||||
// 新建或历史空组织一律落到当前组织,避免列表按 dept_id 过滤后看不到
|
||||
if (created || Func.isEmpty(transportPlan.getDeptId()) || transportPlan.getDeptId() <= 0) {
|
||||
Dept dept = TransportBusinessSupport.currentDept("运输计划");
|
||||
transportPlan.setDeptId(dept.getId());
|
||||
transportPlan.setDeptName(dept.getDeptName());
|
||||
}
|
||||
validate(transportPlan);
|
||||
return saveOrUpdate(transportPlan);
|
||||
}
|
||||
@@ -662,11 +670,33 @@ public class TransportPlanServiceImpl extends BaseServiceImpl<TransportPlanMappe
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把所属组织为空、且创建组织等于当前组织的运输计划回填 dept_id,使 allDept=0 列表能查到。
|
||||
*/
|
||||
private void backfillMissingDept(TransportPlanVO transportPlan) {
|
||||
if (Objects.equals(transportPlan.getAllDept(), 1)) {
|
||||
return;
|
||||
}
|
||||
Dept dept = TransportBusinessSupport.currentDept("运输计划");
|
||||
if (dept == null || dept.getId() == null) {
|
||||
return;
|
||||
}
|
||||
update(Wrappers.<TransportPlan>lambdaUpdate()
|
||||
.set(TransportPlan::getDeptId, dept.getId())
|
||||
.set(TransportPlan::getDeptName, dept.getDeptName())
|
||||
.isNull(TransportPlan::getDeptId)
|
||||
.eq(TransportPlan::getCreateDept, dept.getId())
|
||||
.eq(TransportPlan::getIsDeleted, 0));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<TransportPlan> buildQuery(TransportPlanVO transportPlan) {
|
||||
TransportBusinessSupport.validateAllDept(transportPlan.getAllDept(), "运输计划");
|
||||
LambdaQueryWrapper<TransportPlan> queryWrapper = Wrappers.<TransportPlan>lambdaQuery().eq(TransportPlan::getIsDeleted, 0);
|
||||
if (!Objects.equals(transportPlan.getAllDept(), 1)) {
|
||||
queryWrapper.eq(TransportPlan::getDeptId, TransportBusinessSupport.currentDeptId("运输计划"));
|
||||
Long currentDeptId = TransportBusinessSupport.currentDeptId("运输计划");
|
||||
// dept_id 为空的历史数据按创建组织归入当前组织
|
||||
queryWrapper.and(wrapper -> wrapper.eq(TransportPlan::getDeptId, currentDeptId)
|
||||
.or(item -> item.isNull(TransportPlan::getDeptId).eq(TransportPlan::getCreateDept, currentDeptId)));
|
||||
} else if (Func.isNotEmpty(transportPlan.getDeptId())) {
|
||||
queryWrapper.eq(TransportPlan::getDeptId, transportPlan.getDeptId());
|
||||
}
|
||||
|
||||
+2
-2
@@ -291,13 +291,13 @@ public class TransportVehicleServiceImpl extends BaseServiceImpl<TransportVehicl
|
||||
if (Func.isEmpty(vehicle.getDrivingLicenseNo())) {
|
||||
throw new ServiceException("行驶证档案编号不能为空");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
|
||||
if (vehicle.getDrivingLicenseLongTerm() != 1 && Func.isEmpty(vehicle.getDrivingLicenseEndDate())) {
|
||||
throw new ServiceException("行驶证有效期不能为空");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getRoadTransportCertNo())) {
|
||||
throw new ServiceException("道路运输证号不能为空");
|
||||
}
|
||||
if (Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
|
||||
if (vehicle.getRoadTransportCertLongTerm() != 1 && Func.isEmpty(vehicle.getRoadTransportCertEndDate())) {
|
||||
throw new ServiceException("道路运输证有效期不能为空");
|
||||
}
|
||||
if (vehicle.getAnnualReviewLongTerm() != 1 && Func.isEmpty(vehicle.getAnnualReviewEndDate())) {
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 运次聚合口径:四档状态 + 接单需求 OR + 运次键
|
||||
* <p>
|
||||
* 四档与接单需求都按子运单的过程配置与接单态在这里推导,Tab 过滤与卡片状态用同一份推导,
|
||||
* 库层不再按已落库的业务状态分档——业务状态靠读取时的惰性校正维持,与过程配置不一致是常态。
|
||||
* <p>
|
||||
* 运次键与它的库层表达式只在这里声明一次:库层分组(重新派单待办)与服务层分组必须是同一个键。
|
||||
*/
|
||||
final class TripAggregation {
|
||||
|
||||
/** 运单业务状态 */
|
||||
static final String PENDING = "pending";
|
||||
static final String WAITING_DISPATCH = "waiting_dispatch";
|
||||
static final String DISPATCHING = "dispatching";
|
||||
static final String RUNNING = "running";
|
||||
static final String COMPLETED = "completed";
|
||||
static final String CANCELLED = "cancelled";
|
||||
|
||||
/** 待接单档:与运单侧 Tab「待接单」取同一组状态 */
|
||||
static final List<String> PENDING_STATUSES = List.of(PENDING, WAITING_DISPATCH, DISPATCHING);
|
||||
/** 终态档 */
|
||||
static final List<String> TERMINAL_STATUSES = List.of(COMPLETED, CANCELLED);
|
||||
/**
|
||||
* 进运次的子单状态:草稿尚未派单、不成运次;已取消要进,
|
||||
* 否则「子单全已取消」会被算成运输中而不是已取消。
|
||||
*/
|
||||
static final List<String> CHILD_STATUSES = List.of(
|
||||
PENDING, WAITING_DISPATCH, DISPATCHING, RUNNING, COMPLETED, CANCELLED
|
||||
);
|
||||
|
||||
/** 待重新派单的口径:子单被拒且这趟车还没结束 */
|
||||
static final List<String> REASSIGN_STATUSES = List.of(PENDING, RUNNING);
|
||||
|
||||
/**
|
||||
* 运次键的库层表达式:有配载按配载单号成组,未配载每张运单自成一组(W 前缀避免与配载单号撞键)。
|
||||
* 服务层换 {@link #keyOf(String, Long)} 算同一个键。
|
||||
*/
|
||||
static final String SQL_TRIP_KEY = "IFNULL(NULLIF(loading_no, ''), CONCAT('W', id))";
|
||||
|
||||
private TripAggregation() {
|
||||
}
|
||||
|
||||
/** 运次键:与 {@link #SQL_TRIP_KEY} 同一个口径 */
|
||||
static String keyOf(String loadingNo, Long id) {
|
||||
return Func.isEmpty(loadingNo) ? "W" + id : loadingNo;
|
||||
}
|
||||
|
||||
/** 运次的四档状态:0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 */
|
||||
static Integer appStatus(String status) {
|
||||
return switch (Func.toStr(status, "")) {
|
||||
case PENDING -> 0;
|
||||
case RUNNING -> 1;
|
||||
case COMPLETED -> 2;
|
||||
case CANCELLED -> 3;
|
||||
default -> null;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次状态:终态优先——子单全为终态时有已完成取已完成、否则已取消;
|
||||
* 否则需接单且未接单取待接单,其余取运输中。
|
||||
* <p>
|
||||
* 子单集合为空时无从判定,返回空。
|
||||
*/
|
||||
static String statusOf(List<Waybill> children, boolean requireAccept, boolean accepted) {
|
||||
String status = classify(count(children));
|
||||
if (status == null || TERMINAL_STATUSES.contains(status)) {
|
||||
return status;
|
||||
}
|
||||
if (!requireAccept) {
|
||||
return RUNNING;
|
||||
}
|
||||
return accepted ? RUNNING : PENDING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 子单聚合结果:子单总数、终态数、已完成数、待接单档数
|
||||
*/
|
||||
static Counters count(List<Waybill> children) {
|
||||
int total = 0;
|
||||
int terminal = 0;
|
||||
int completed = 0;
|
||||
int pending = 0;
|
||||
for (Waybill child : children) {
|
||||
String status = child == null ? null : child.getBusinessStatus();
|
||||
total++;
|
||||
if (status != null && TERMINAL_STATUSES.contains(status)) {
|
||||
terminal++;
|
||||
}
|
||||
if (COMPLETED.equals(status)) {
|
||||
completed++;
|
||||
}
|
||||
if (status != null && PENDING_STATUSES.contains(status)) {
|
||||
pending++;
|
||||
}
|
||||
}
|
||||
return new Counters(total, terminal, completed, pending);
|
||||
}
|
||||
|
||||
private static String classify(Counters counters) {
|
||||
if (counters.total() == 0) {
|
||||
return null;
|
||||
}
|
||||
if (counters.terminal() == counters.total()) {
|
||||
return counters.completed() > 0 ? COMPLETED : CANCELLED;
|
||||
}
|
||||
return counters.pending() > 0 ? PENDING : RUNNING;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次是否需要接单:任一子运单的过程配置有启用的接单节点即为是。
|
||||
* <p>
|
||||
* 跨项目的运次常遇到「要接单的票」与「不要接单的票」混在一起,按 OR 合并后整单按需接单处理。
|
||||
*/
|
||||
static boolean requiresAccept(List<Waybill> children) {
|
||||
return children.stream()
|
||||
.anyMatch(child -> child != null
|
||||
&& WaybillProcessSupport.requiresDriverAcceptConfirmation(child.getProcessJson()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次接单态:任一子单已接单即为已接单(一次接单把整趟车的子单置为已接单),
|
||||
* 否则有子单待接单为待接单,无从判定时为空。
|
||||
*/
|
||||
static String acceptStatus(List<Waybill> children) {
|
||||
boolean anyPending = false;
|
||||
for (Waybill child : children) {
|
||||
String status = child == null ? null : child.getDriverAcceptStatus();
|
||||
if (WaybillProcessSupport.ACCEPT_ACCEPTED.equals(status)) {
|
||||
return WaybillProcessSupport.ACCEPT_ACCEPTED;
|
||||
}
|
||||
if (WaybillProcessSupport.ACCEPT_PENDING.equals(status)) {
|
||||
anyPending = true;
|
||||
}
|
||||
}
|
||||
return anyPending ? WaybillProcessSupport.ACCEPT_PENDING : null;
|
||||
}
|
||||
|
||||
/** 子单聚合计数 */
|
||||
record Counters(int total, int terminal, int completed, int pending) {
|
||||
}
|
||||
|
||||
}
|
||||
+849
@@ -0,0 +1,849 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* 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 lombok.RequiredArgsConstructor;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.CargoLineVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
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.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 运次读服务实现
|
||||
* <p>
|
||||
* 取数两段,段内都是批量查询:段一一次取回收口与行级筛选命中的可见运单行,分组、分档、排序与分页
|
||||
* 都在服务层算完再做;段二按这一页运次的配载单号批量取配载单。子运单不再回查——段一拿回来的行就是它们。
|
||||
* <p>
|
||||
* 分档只按过程配置与接单态推导,与卡片状态同一份口径:库里的业务状态靠读取时的惰性校正维持,
|
||||
* 拿它分档会让「归在待接单的运次、卡片显示运输中」。分页因此必须在过滤后的完整运次序列上做,
|
||||
* 先分页再过滤会把该在第一页的运次挤到后面几页。
|
||||
* <p>
|
||||
* 可见运次=派给该司机的未配载运单(每张自成运次),加上配载单派给该司机、且至少有一张可见子运单的运次。
|
||||
* 司机侧可见范围与运单列表同口径:司机手机号命中且未被该司机拒单;子单里已拒单的不进运次,
|
||||
* 于是整趟车被拒单后整个运次自然消失。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class TripQueryServiceImpl implements ITripQueryService {
|
||||
|
||||
private static final int DEFAULT_PAGE_SIZE = 10;
|
||||
private static final int MAX_PAGE_SIZE = 50;
|
||||
|
||||
/** 运输组织类型:有配载单为公路配载,否则普通运输 */
|
||||
private static final String TRANSPORT_LOAD = "load";
|
||||
private static final String TRANSPORT_COMMON = "common";
|
||||
|
||||
/** 未配载运次的键前缀:与 TripAggregation.SQL_TRIP_KEY 的 W 前缀同一个口径 */
|
||||
private static final String LONE_KEY_PREFIX = "W";
|
||||
|
||||
/** 异常筛选的取值:命中未完成异常的子单 */
|
||||
private static final String EXCEPTION_YES = "exception";
|
||||
private static final String EXCEPTION_NO = "normal";
|
||||
|
||||
/** 关键字在子单上匹配的字段 */
|
||||
private static final String[] KEYWORD_COLUMNS = {
|
||||
"waybill_no", "loading_no", "departure_name", "arrival_name",
|
||||
"departure_address", "arrival_address", "vehicle_no"
|
||||
};
|
||||
|
||||
private final IWaybillService waybillService;
|
||||
private final ILoadingManageService loadingManageService;
|
||||
private final IProcessConfigService processConfigService;
|
||||
|
||||
@Override
|
||||
public IPage<TripVO> page(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
int pageNo = pageNo(condition.getCurrent());
|
||||
int pageSize = pageSize(condition.getSize());
|
||||
if (!scoped(condition)) {
|
||||
// 收口缺失时返回空页,不能退回「不按司机收口」:那等于把别人的运次发出去
|
||||
return new Page<>(pageNo, pageSize);
|
||||
}
|
||||
List<TripRows> trips = trips(condition);
|
||||
Page<TripVO> page = new Page<>(pageNo, pageSize, trips.size());
|
||||
int from = Math.min((pageNo - 1) * pageSize, trips.size());
|
||||
int to = Math.min(from + pageSize, trips.size());
|
||||
page.setRecords(assemble(trips.subList(from, to)));
|
||||
return page;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TripTabCountsVO tabCounts(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
TripTabCountsVO counts = new TripTabCountsVO();
|
||||
if (!scoped(condition)) {
|
||||
return counts;
|
||||
}
|
||||
// 一次取数算清四档:分档口径与列表、卡片同一份推导,不再按库里的业务状态分批计数
|
||||
long pending = 0;
|
||||
long doing = 0;
|
||||
long done = 0;
|
||||
for (TripRows trip : trips(withoutTab(condition))) {
|
||||
Integer tier = trip.tier();
|
||||
if (tier == null) {
|
||||
continue;
|
||||
}
|
||||
switch (tier) {
|
||||
case 0 -> pending++;
|
||||
case 1 -> doing++;
|
||||
case 2 -> done++;
|
||||
default -> {
|
||||
}
|
||||
}
|
||||
}
|
||||
counts.setPending(pending);
|
||||
counts.setDoing(doing);
|
||||
counts.setDone(done);
|
||||
counts.setAll(pending + doing + done);
|
||||
return counts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public TripVO detail(TripQueryVO query) {
|
||||
TripQueryVO condition = condition(query);
|
||||
Long id = tripId(condition.getTripId());
|
||||
if (id == null || !scoped(condition)) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
LoadingManage loading = visibleLoading(id, condition);
|
||||
if (loading != null) {
|
||||
List<Waybill> children = loadChildren(Set.of(loading.getLoadingNo()), Set.of())
|
||||
.getOrDefault(loading.getLoadingNo(), List.of());
|
||||
if (children.isEmpty()) {
|
||||
// 子单全部被拒单(或已释放)的配载运次对司机不可见,与列表口径一致
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
// 详情与列表同一份分档口径:接单需求也按项目生效中的配置判,否则列表说待接单、详情与接收入口说无需接单
|
||||
applyLiveProcessConfig(children);
|
||||
return loadingTrip(loading, children, true);
|
||||
}
|
||||
Waybill waybill = visibleWaybill(id, condition);
|
||||
if (waybill == null) {
|
||||
throw new ServiceException("运次不存在或无权操作");
|
||||
}
|
||||
applyLiveProcessConfig(List.of(waybill));
|
||||
return waybillTrip(waybill, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* 全部可见运次:一次取回运单行后在服务层分组、分档、筛选并按排序键倒序
|
||||
* <p>
|
||||
* 分档用过程配置与接单态推导,于是 Tab 与卡片必然同档;行级筛选(组织类型 / 异常 / 创建时间)落在库层,
|
||||
* 命中的行同时决定这一趟车的子单集合与分档。
|
||||
*/
|
||||
private List<TripRows> trips(TripQueryVO condition) {
|
||||
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill row : visibleRows(condition)) {
|
||||
grouped.computeIfAbsent(TripAggregation.keyOf(row.getLoadingNo(), row.getId()),
|
||||
ignored -> new ArrayList<>()).add(row);
|
||||
}
|
||||
Set<String> excluded = condition.isExcludeRejectedTrips() ? rejectedTripKeys() : Set.of();
|
||||
Set<Integer> tiers = tiers(condition);
|
||||
List<TripRows> trips = new ArrayList<>(grouped.size());
|
||||
grouped.forEach((key, rows) -> {
|
||||
if (excluded.contains(key)) {
|
||||
return;
|
||||
}
|
||||
List<Waybill> children = sortedChildren(rows);
|
||||
if (!inScope(children, condition)) {
|
||||
return;
|
||||
}
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(TripAggregation.acceptStatus(children));
|
||||
String status = TripAggregation.statusOf(children, requireAccept, accepted);
|
||||
Integer tier = TripAggregation.appStatus(status);
|
||||
if (tier == null || !tiers.contains(tier)) {
|
||||
return;
|
||||
}
|
||||
trips.add(new TripRows(key, rows.get(0).getLoadingNo(), children, status, tier, maxCreateTime(children)));
|
||||
});
|
||||
// 排序键是子单 create_time 的最大值,倒序;键相同再按运次键定序,免得同分页里顺序漂移
|
||||
trips.sort(Comparator.comparing(TripRows::sortTime, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparing(TripRows::key));
|
||||
return trips;
|
||||
}
|
||||
|
||||
/** 子单按创建时刻升序:站点拼装与货物汇总都按这个顺序 */
|
||||
private List<Waybill> sortedChildren(List<Waybill> rows) {
|
||||
List<Waybill> children = new ArrayList<>(rows);
|
||||
children.sort(Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder())));
|
||||
return children;
|
||||
}
|
||||
|
||||
/**
|
||||
* 段一:一次取回该收口与行级筛选命中的可见运单行
|
||||
* <p>
|
||||
* 只 select 拼装与分档用得上的列:运单表上有几张大 JSON 列,整行拖回不值得。
|
||||
* goods_json 必须随行取回——卡片上的货名、货物汇总与合计重量都以它为准,一张运单可以录多条货物,
|
||||
* 主表 cargo_name 只落了第一行。可见口径与运单列表同一条:状态进运次的才取,被该司机拒单的不取。
|
||||
*/
|
||||
private List<Waybill> visibleRows(TripQueryVO condition) {
|
||||
LambdaQueryWrapper<Waybill> wrapper = Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo, Waybill::getWaybillNo, Waybill::getBusinessStatus,
|
||||
Waybill::getDriverAcceptStatus, Waybill::getProcessJson, Waybill::getProjectId, Waybill::getProjectName,
|
||||
Waybill::getCargoName, Waybill::getCargoType, Waybill::getQuantity, Waybill::getQuantityUnit,
|
||||
// 多货物运单的主表字段只有首行,货物明细只能从 goods_json 拿
|
||||
Waybill::getGoodsJson,
|
||||
Waybill::getDepartureName, Waybill::getDepartureAddress, Waybill::getArrivalName,
|
||||
Waybill::getArrivalAddress, Waybill::getDriverName, Waybill::getDriverPhone, Waybill::getVehicleNo,
|
||||
Waybill::getCustomerName, Waybill::getTransportType, Waybill::getTaskRemark,
|
||||
// 未配载运次没有配载单可依,计划时间要取运单自己的,故必须随行取回
|
||||
Waybill::getEstimatedStartTime, Waybill::getEstimatedEndTime, Waybill::getCreateTime)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED));
|
||||
applyScope(wrapper, condition);
|
||||
applyDeptScope(wrapper, condition);
|
||||
applyOrgTypeFilter(wrapper, condition);
|
||||
applyKeyword(wrapper, condition.getKeyword());
|
||||
List<Waybill> rows = waybillService.list(wrapper);
|
||||
applyLiveProcessConfig(rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 组织收口:条件里给了组织 ID 集合时只出这些组织的运单行,空集合表示不过滤。
|
||||
* <p>
|
||||
* 与司机手机号收口一样落在库层的运单行上:分档、分页、总数都由它缩过的行集合算出来。
|
||||
* 读服务不自己解析当前用户的组织——它同时服务司机端与管理端,可见范围由调用方算好再传。
|
||||
*/
|
||||
private void applyDeptScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (Func.isEmpty(condition.getDeptIds())) {
|
||||
return;
|
||||
}
|
||||
wrapper.in(Waybill::getDeptId, condition.getDeptIds());
|
||||
}
|
||||
|
||||
/**
|
||||
* 运输组织类型按子单行筛:它在同一趟车里必然是同一个值(配载的每张子单带同一个配载单号,
|
||||
* 未配载的每张自成一趟车),所以这一层不会把一趟车拆开。
|
||||
*/
|
||||
private void applyOrgTypeFilter(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (TRANSPORT_LOAD.equals(condition.getTransportOrgType())) {
|
||||
wrapper.apply("IFNULL(loading_no, '') <> ''");
|
||||
} else if (TRANSPORT_COMMON.equals(condition.getTransportOrgType())) {
|
||||
wrapper.apply("IFNULL(loading_no, '') = ''");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 这趟车在不在筛选范围内:异常按「任一子单命中」,创建时间按子单 create_time 的最大值(运次列表的排序键,
|
||||
* 也是调度端卡片上显示的创建时间)。两个判据都按整趟车的子单集合算——先按筛选把子单集合缩小再分档,
|
||||
* 会让同一趟车归在某一档、卡片却显示另一档。
|
||||
* <p>
|
||||
* 筛选只决定这趟车在不在范围内,分档仍看它完整的可见子单集合。
|
||||
*/
|
||||
private boolean inScope(List<Waybill> children, TripQueryVO condition) {
|
||||
Set<Long> exceptionIds = condition.getExceptionWaybillIds();
|
||||
if (Func.isNotEmpty(exceptionIds)) {
|
||||
boolean hit = children.stream().map(Waybill::getId).anyMatch(exceptionIds::contains);
|
||||
if (EXCEPTION_YES.equals(condition.getException()) && !hit) {
|
||||
return false;
|
||||
}
|
||||
if (EXCEPTION_NO.equals(condition.getException()) && hit) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
Date created = maxCreateTime(children);
|
||||
if (condition.getCreatedFrom() != null
|
||||
&& (created == null || created.before(condition.getCreatedFrom()))) {
|
||||
return false;
|
||||
}
|
||||
return condition.getCreatedTo() == null
|
||||
|| (created != null && !created.after(condition.getCreatedTo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按单票路径的口径把生效中的过程配置写回内存里的行:项目启用中的配置优先,它没有可打卡节点时回退运单快照。
|
||||
* <p>
|
||||
* 只改内存里的对象,分档与卡片下发都读它,不落库;生效配置一次取回后按项目复用,不逐条回查。
|
||||
* 取法与二选一规则都在 {@link LiveProcessConfigSupport},这里只做批量写回。
|
||||
*/
|
||||
private void applyLiveProcessConfig(List<Waybill> rows) {
|
||||
if (Func.isEmpty(rows)) {
|
||||
return;
|
||||
}
|
||||
Set<Long> projectIds = rows.stream().map(Waybill::getProjectId).filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
if (projectIds.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, projectIds);
|
||||
if (live.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
for (Waybill row : rows) {
|
||||
String liveJson = live.get(row.getProjectId());
|
||||
if (Func.isNotEmpty(liveJson)) {
|
||||
row.setProcessJson(LiveProcessConfigSupport.resolveForPunch(liveJson, row.getProcessJson()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 有子单被拒、且这趟车还没结束的运次键:整单拒绝后这趟车不再算待处理
|
||||
*/
|
||||
private Set<String> rejectedTripKeys() {
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo)
|
||||
.eq(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED)
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.REASSIGN_STATUSES))
|
||||
.stream()
|
||||
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 要哪几档:多档优先,其次单档,都不传按「全部」=待接单/进行中/已完成(不含已取消)
|
||||
*/
|
||||
private Set<Integer> tiers(TripQueryVO condition) {
|
||||
if (Func.isNotEmpty(condition.getStatuses())) {
|
||||
return new LinkedHashSet<>(condition.getStatuses());
|
||||
}
|
||||
if (condition.getStatus() != null) {
|
||||
return Set.of(condition.getStatus());
|
||||
}
|
||||
return Set.of(0, 1, 2);
|
||||
}
|
||||
|
||||
/**
|
||||
* 运次可见范围:配载段按配载单的司机手机号收口,未配载段按运单的司机手机号收口。
|
||||
* <p>
|
||||
* 手机号允许带空格,与运单列表的收口判据一致。
|
||||
*/
|
||||
private void applyScope(LambdaQueryWrapper<Waybill> wrapper, TripQueryVO condition) {
|
||||
if (condition.isAllDrivers()) {
|
||||
return;
|
||||
}
|
||||
String phone = Func.toStr(condition.getDriverPhone(), "").trim();
|
||||
wrapper.and(scope -> scope
|
||||
.apply("(IFNULL(loading_no, '') <> '' AND loading_no IN (SELECT lm.loading_no FROM blade_loading_manage lm"
|
||||
+ " WHERE lm.is_deleted = 0 AND lm.driver_phone = {0}))", phone)
|
||||
.or(lone -> lone
|
||||
.apply("IFNULL(loading_no, '') = ''")
|
||||
.and(row -> row.eq(Waybill::getDriverPhone, phone)
|
||||
.or().apply("REPLACE(IFNULL(driver_phone, ''), ' ', '') = {0}", phone))));
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键字按运次匹配:任一子单或配载单号命中即整趟车命中。
|
||||
* <p>
|
||||
* 先按关键字查出命中的运次键,再要求这一行的所属运次在其中——关键字不缩小分组内的子单集合,
|
||||
* 否则状态与排序会按命中的那一部分子单算。
|
||||
*/
|
||||
private void applyKeyword(LambdaQueryWrapper<Waybill> wrapper, String keyword) {
|
||||
if (Func.isEmpty(keyword)) {
|
||||
return;
|
||||
}
|
||||
Set<String> keys = keywordTripKeys(keyword.trim());
|
||||
if (keys.isEmpty()) {
|
||||
wrapper.apply("1 = 0");
|
||||
return;
|
||||
}
|
||||
Set<String> loadingNos = keys.stream().filter(key -> !key.startsWith(LONE_KEY_PREFIX))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Set<Long> loneIds = keys.stream().filter(key -> key.startsWith(LONE_KEY_PREFIX))
|
||||
.map(key -> key.substring(LONE_KEY_PREFIX.length()))
|
||||
.map(this::tripId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
wrapper.and(matched -> {
|
||||
if (!loadingNos.isEmpty()) {
|
||||
matched.in(Waybill::getLoadingNo, loadingNos);
|
||||
}
|
||||
if (!loneIds.isEmpty()) {
|
||||
if (!loadingNos.isEmpty()) {
|
||||
matched.or();
|
||||
}
|
||||
matched.apply("IFNULL(loading_no, '') = ''").in(Waybill::getId, loneIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 关键字命中的运次键:在子单上按关键字找一遍,再换算成运次键 */
|
||||
private Set<String> keywordTripKeys(String keyword) {
|
||||
String like = "%" + keyword + "%";
|
||||
Object[] params = new Object[KEYWORD_COLUMNS.length];
|
||||
StringBuilder matched = new StringBuilder();
|
||||
for (int i = 0; i < KEYWORD_COLUMNS.length; i++) {
|
||||
if (i > 0) {
|
||||
matched.append(" OR ");
|
||||
}
|
||||
matched.append(KEYWORD_COLUMNS[i]).append(" LIKE {").append(i).append('}');
|
||||
params[i] = like;
|
||||
}
|
||||
return waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getLoadingNo)
|
||||
.apply("(" + matched + ")", params))
|
||||
.stream()
|
||||
.map(row -> TripAggregation.keyOf(row.getLoadingNo(), row.getId()))
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把这一页的运次组装成运次:先批量取配载单,再逐趟拼装
|
||||
* <p>
|
||||
* 子运单就是段一取回来的那些行,不再回查:回查会拿回未经行级筛选的子单,
|
||||
* 卡片的状态与货物汇总就会与分档、与筛选结果对不上。
|
||||
*/
|
||||
private List<TripVO> assemble(List<TripRows> trips) {
|
||||
if (Func.isEmpty(trips)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
Set<String> loadingNos = trips.stream()
|
||||
.map(TripRows::loadingNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(LinkedHashSet::new));
|
||||
Map<String, LoadingManage> loadings = loadLoadings(loadingNos);
|
||||
List<TripVO> result = new ArrayList<>(trips.size());
|
||||
for (TripRows trip : trips) {
|
||||
if (Func.isEmpty(trip.loadingNo())) {
|
||||
result.add(waybillTrip(trip.children().get(0), false));
|
||||
continue;
|
||||
}
|
||||
LoadingManage loading = loadings.get(trip.loadingNo());
|
||||
if (loading == null) {
|
||||
// 配载单被并发删除:不给出没有主键的运次
|
||||
continue;
|
||||
}
|
||||
result.add(loadingTrip(loading, trip.children(), false));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 段二:按本页运次的配载单号一次取回配载单
|
||||
*/
|
||||
private Map<String, LoadingManage> loadLoadings(Set<String> loadingNos) {
|
||||
if (loadingNos.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
return loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.in(LoadingManage::getLoadingNo, loadingNos))
|
||||
.stream()
|
||||
.collect(Collectors.toMap(LoadingManage::getLoadingNo, Function.identity(), (first, ignored) -> first));
|
||||
}
|
||||
|
||||
/**
|
||||
* 段二:按本页运次的配载单号与未配载运单主键一次取回子运单,不逐条查
|
||||
* <p>
|
||||
* 子单归属一律以配载单号为准:库层就是按配载单号分组聚合的,服务层复核用别的关联关系,
|
||||
* 同一页里就会出现「Tab 说它在进行中、卡片按子单算出来是待接单」。
|
||||
* 配载单上的运单号集合只是派单时的快照,子单被释放(配载单号清空)后不再属于该运次。
|
||||
*/
|
||||
private Map<String, List<Waybill>> loadChildren(Set<String> loadingNos, Set<Long> loneIds) {
|
||||
if (loadingNos.isEmpty() && loneIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Waybill> children = waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.in(Waybill::getBusinessStatus, TripAggregation.CHILD_STATUSES)
|
||||
.and(visible -> visible.isNull(Waybill::getDriverAcceptStatus)
|
||||
.or().ne(Waybill::getDriverAcceptStatus, WaybillProcessSupport.ACCEPT_REJECTED))
|
||||
.and(scope -> scope.in(Func.isNotEmpty(loadingNos), Waybill::getLoadingNo, loadingNos)
|
||||
.or(Func.isNotEmpty(loneIds), lone -> lone.in(Waybill::getId, loneIds))));
|
||||
Map<String, List<Waybill>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
String key = Func.isEmpty(child.getLoadingNo()) ? String.valueOf(child.getId()) : child.getLoadingNo();
|
||||
grouped.computeIfAbsent(key, ignored -> new ArrayList<>()).add(child);
|
||||
}
|
||||
grouped.values().forEach(group -> group.sort(
|
||||
Comparator.comparing(Waybill::getCreateTime, Comparator.nullsLast(Comparator.naturalOrder()))));
|
||||
return grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* 配载运次:司机、车辆、路线取配载单(配载单是运输执行主体),状态与接单需求由子单聚合
|
||||
*/
|
||||
private TripVO loadingTrip(LoadingManage loading, List<Waybill> children, boolean withPayload) {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(tripIdOf(loading.getId()));
|
||||
trip.setLoadingNo(loading.getLoadingNo());
|
||||
trip.setTripNo(loading.getLoadingNo());
|
||||
trip.setTransportOrgType(TRANSPORT_LOAD);
|
||||
trip.setDriverName(Func.toStr(loading.getDriverName(), ""));
|
||||
trip.setDriverPhone(Func.toStr(loading.getDriverPhone(), ""));
|
||||
trip.setVehicleNo(Func.toStr(loading.getVehicleNo(), ""));
|
||||
trip.setTrailerVehicleNo(Func.toStr(loading.getTrailerVehicleNo(), ""));
|
||||
trip.setProjectName(Func.toStr(loading.getProjectName(), ""));
|
||||
trip.setCustomerName(Func.toStr(loading.getCustomerName(), ""));
|
||||
trip.setTransportType(Func.toStr(loading.getTransportType(), ""));
|
||||
trip.setCargoName(firstNotEmpty(loading.getCargoName(), children, Waybill::getCargoName));
|
||||
trip.setCargoType(firstNotEmpty(loading.getCargoType(), children, Waybill::getCargoType));
|
||||
trip.setDepartureAddress(Func.toStr(loading.getDepartureAddress(), ""));
|
||||
trip.setTransitAddress(Func.toStr(loading.getTransitAddress(), ""));
|
||||
trip.setArrivalAddress(Func.toStr(loading.getArrivalAddress(), ""));
|
||||
trip.setEstimatedStartDate(loading.getEstimatedStartDate());
|
||||
trip.setEstimatedEndDate(loading.getEstimatedEndDate());
|
||||
trip.setTaskRemark(Func.toStr(loading.getTaskRemark(), ""));
|
||||
if (withPayload) {
|
||||
trip.setRouteJson(loading.getRouteJson());
|
||||
trip.setGoodsJson(loading.getGoodsJson());
|
||||
}
|
||||
fillAggregation(trip, children);
|
||||
fillRouteAndCargo(trip, loading, children, withPayload);
|
||||
return trip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 未配载运次:整张运单就是这一趟车
|
||||
*/
|
||||
private TripVO waybillTrip(Waybill waybill, boolean withPayload) {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(tripIdOf(waybill.getId()));
|
||||
trip.setTripNo(waybill.getWaybillNo());
|
||||
trip.setTransportOrgType(TRANSPORT_COMMON);
|
||||
trip.setDriverName(Func.toStr(waybill.getDriverName(), ""));
|
||||
trip.setDriverPhone(Func.toStr(waybill.getDriverPhone(), ""));
|
||||
trip.setVehicleNo(Func.toStr(waybill.getVehicleNo(), ""));
|
||||
trip.setProjectName(Func.toStr(waybill.getProjectName(), ""));
|
||||
trip.setCustomerName(Func.toStr(waybill.getCustomerName(), ""));
|
||||
trip.setTransportType(Func.toStr(waybill.getTransportType(), ""));
|
||||
trip.setCargoName(Func.toStr(waybill.getCargoName(), ""));
|
||||
trip.setCargoType(Func.toStr(waybill.getCargoType(), ""));
|
||||
trip.setDepartureAddress(Func.toStr(waybill.getDepartureAddress(), ""));
|
||||
trip.setArrivalAddress(Func.toStr(waybill.getArrivalAddress(), ""));
|
||||
// 未配载没有配载单可依,计划时间只能取这张运单自己的
|
||||
trip.setEstimatedStartDate(waybill.getEstimatedStartTime());
|
||||
trip.setEstimatedEndDate(waybill.getEstimatedEndTime());
|
||||
trip.setTaskRemark(Func.toStr(waybill.getTaskRemark(), ""));
|
||||
if (withPayload) {
|
||||
trip.setRouteJson(waybill.getRouteJson());
|
||||
trip.setGoodsJson(waybill.getGoodsJson());
|
||||
}
|
||||
fillAggregation(trip, List.of(waybill));
|
||||
fillRouteAndCargo(trip, null, List.of(waybill), withPayload);
|
||||
return trip;
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合出运次的状态、接单需求、接单态与排序键:与分档用同一份推导,Tab 与卡片因此必然同档
|
||||
*/
|
||||
private void fillAggregation(TripVO trip, List<Waybill> children) {
|
||||
String acceptStatus = TripAggregation.acceptStatus(children);
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = WaybillProcessSupport.ACCEPT_ACCEPTED.equals(acceptStatus);
|
||||
trip.setAcceptStatus(acceptStatus);
|
||||
trip.setRequireAccept(requireAccept);
|
||||
trip.setStatus(TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted)));
|
||||
trip.setWaybillCount(children.size());
|
||||
trip.setSortTime(maxCreateTime(children));
|
||||
trip.setWaybills(children.stream().map(this::toWaybillVO).toList());
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点与货物都按整趟车算:站点明细只在详情下发,列表带走起/经/终、站点数与货物汇总
|
||||
*/
|
||||
private void fillRouteAndCargo(TripVO trip, LoadingManage loading, List<Waybill> children, boolean withPayload) {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
trip.setDepartureName(TripStationAssembler.departureName(stations));
|
||||
trip.setTransitName(TripStationAssembler.transitName(stations));
|
||||
trip.setArrivalName(TripStationAssembler.arrivalName(stations));
|
||||
trip.setStationCount(stations.size());
|
||||
if (withPayload) {
|
||||
trip.setStations(stations);
|
||||
}
|
||||
List<WaybillCargoSupport.Cargo> cargos = children.stream()
|
||||
.flatMap(child -> WaybillCargoSupport.of(child).stream())
|
||||
.toList();
|
||||
List<String> cargoNames = WaybillCargoSupport.cargoNames(cargos);
|
||||
// 运次级货名覆盖整趟车:主表 cargo_name 只是首行,配载单的 cargo_name 也是派单时的快照
|
||||
trip.setCargoNames(cargoNames);
|
||||
if (!cargoNames.isEmpty()) {
|
||||
trip.setCargoName(String.join("、", cargoNames));
|
||||
}
|
||||
List<String> cargoTypes = cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList();
|
||||
trip.setCargoTypes(cargoTypes);
|
||||
if (!cargoTypes.isEmpty()) {
|
||||
trip.setCargoType(String.join("、", cargoTypes));
|
||||
}
|
||||
trip.setCargoSummary(cargoSummary(children));
|
||||
trip.setCargos(cargoLines(cargos));
|
||||
fillTotalQuantity(trip, children);
|
||||
fillWeighing(trip, cargos);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物汇总:同名货物合并且单位一致时取数量合计,没有数量的只列货名
|
||||
* <p>
|
||||
* 一条运单可以有多条货物,明细在 goods_json;主表 cargo_name 只落了第一行,拿它汇总等于把其余货物丢掉。
|
||||
*/
|
||||
private String cargoSummary(List<Waybill> children) {
|
||||
Map<String, Map<String, BigDecimal>> grouped = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(child);
|
||||
if (cargos.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
for (WaybillCargoSupport.Cargo cargo : cargos) {
|
||||
String name = cargo.displayName();
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
Map<String, BigDecimal> byUnit = grouped.computeIfAbsent(name, key -> new LinkedHashMap<>());
|
||||
BigDecimal quantity = cargo.quantity() == null ? BigDecimal.ZERO : cargo.quantity();
|
||||
byUnit.merge(unit, quantity, BigDecimal::add);
|
||||
}
|
||||
}
|
||||
List<String> parts = new ArrayList<>();
|
||||
grouped.forEach((name, byUnit) -> byUnit.forEach((unit, quantity) ->
|
||||
parts.add(quantity.signum() == 0 ? name : name + " " + formatQuantity(quantity, unit))));
|
||||
return String.join("、", parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物明细行:与 cargoSummary 同一套合并口径,但数量与单位分开留,供详情页逐行展示
|
||||
*/
|
||||
private List<CargoLineVO> cargoLines(List<WaybillCargoSupport.Cargo> cargos) {
|
||||
List<CargoLineVO> lines = new ArrayList<>();
|
||||
for (WaybillCargoSupport.Line line : WaybillCargoSupport.lines(cargos)) {
|
||||
CargoLineVO vo = new CargoLineVO();
|
||||
vo.setCargoName(line.name());
|
||||
vo.setQuantity(line.quantity() == null ? "" : formatQuantity(line.quantity(), line.unit()));
|
||||
lines.add(vo);
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 合计重量:只有全部货物同单位时才能相加,单位不一致时留空(汇总文案里仍分单位列出)
|
||||
* <p>
|
||||
* 按货物行算而不是按运单行算:一张运单里的多条货物可能是不同的单位与数量,
|
||||
* 主表 quantity 是这些行的合计、quantity_unit 只是首行的单位,拿它当合计会把「20堆 + 50方」说成「70堆」。
|
||||
*/
|
||||
private void fillTotalQuantity(TripVO trip, List<Waybill> children) {
|
||||
String unit = null;
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
boolean any = false;
|
||||
for (Waybill child : children) {
|
||||
for (WaybillCargoSupport.Cargo cargo : WaybillCargoSupport.of(child)) {
|
||||
String cargoUnit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (cargo.quantity() == null || Func.isEmpty(cargoUnit)) {
|
||||
return;
|
||||
}
|
||||
if (unit == null) {
|
||||
unit = cargoUnit;
|
||||
} else if (!unit.equals(cargoUnit)) {
|
||||
return;
|
||||
}
|
||||
total = total.add(cargo.quantity());
|
||||
any = true;
|
||||
}
|
||||
}
|
||||
if (!any || unit == null) {
|
||||
return;
|
||||
}
|
||||
trip.setTotalQuantity(total);
|
||||
trip.setQuantityUnit(unit);
|
||||
}
|
||||
|
||||
/**
|
||||
* 计重合计:卡片上那个「货物重量」,口径见 WaybillCargoSupport#weighing
|
||||
*/
|
||||
private void fillWeighing(TripVO trip, List<WaybillCargoSupport.Cargo> cargos) {
|
||||
WaybillCargoSupport.Quantity weighing = WaybillCargoSupport.weighing(cargos);
|
||||
if (weighing == null) {
|
||||
return;
|
||||
}
|
||||
trip.setWeighingQuantity(weighing.value());
|
||||
trip.setWeighingUnit(weighing.unit());
|
||||
}
|
||||
|
||||
private String formatQuantity(BigDecimal quantity, String unit) {
|
||||
String value = quantity.stripTrailingZeros().toPlainString();
|
||||
return Func.isEmpty(unit) ? value : value + unit;
|
||||
}
|
||||
|
||||
private TripVO.TripWaybillVO toWaybillVO(Waybill waybill) {
|
||||
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
|
||||
child.setId(tripIdOf(waybill.getId()));
|
||||
child.setWaybillNo(waybill.getWaybillNo());
|
||||
child.setBusinessStatus(waybill.getBusinessStatus());
|
||||
child.setAcceptStatus(waybill.getDriverAcceptStatus());
|
||||
child.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson()));
|
||||
child.setProjectId(waybill.getProjectId());
|
||||
child.setProjectName(waybill.getProjectName());
|
||||
// 货名按运单上实际录入的货物给全:主表 cargo_name 只是首行,多货物运单会漏掉其余货物
|
||||
List<WaybillCargoSupport.Cargo> cargos = WaybillCargoSupport.of(waybill);
|
||||
child.setCargoName(String.join("、", WaybillCargoSupport.cargoNames(cargos)));
|
||||
child.setCargoNames(WaybillCargoSupport.cargoNames(cargos));
|
||||
child.setCargoTypes(cargos.stream()
|
||||
.map(WaybillCargoSupport.Cargo::cargoType)
|
||||
.filter(Func::isNotEmpty)
|
||||
.distinct()
|
||||
.toList());
|
||||
child.setCargoType(waybill.getCargoType());
|
||||
child.setQuantity(waybill.getQuantity());
|
||||
child.setQuantityUnit(waybill.getQuantityUnit());
|
||||
child.setDepartureName(waybill.getDepartureName());
|
||||
child.setDepartureAddress(waybill.getDepartureAddress());
|
||||
child.setArrivalName(waybill.getArrivalName());
|
||||
child.setArrivalAddress(waybill.getArrivalAddress());
|
||||
child.setCreateTime(waybill.getCreateTime());
|
||||
return child;
|
||||
}
|
||||
|
||||
/** 排序键:子单 create_time 的最大值 */
|
||||
private Date maxCreateTime(List<Waybill> children) {
|
||||
return children.stream()
|
||||
.map(Waybill::getCreateTime)
|
||||
.filter(Objects::nonNull)
|
||||
.max(Comparator.naturalOrder())
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
private String firstNotEmpty(String preferred, List<Waybill> children, Function<Waybill, String> getter) {
|
||||
if (Func.isNotEmpty(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
return children.stream().map(getter).filter(Func::isNotEmpty).findFirst().orElse("");
|
||||
}
|
||||
|
||||
/**
|
||||
* 按运次主键取配载单:主键命中配载单即配载运次,再按司机收口
|
||||
*/
|
||||
private LoadingManage visibleLoading(Long id, TripQueryVO condition) {
|
||||
LoadingManage loading = loadingManageService.getById(id);
|
||||
if (loading == null) {
|
||||
return null;
|
||||
}
|
||||
if (condition.isAllDrivers()) {
|
||||
return loading;
|
||||
}
|
||||
return samePhone(loading.getDriverPhone(), condition.getDriverPhone()) ? loading : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按运次主键取未配载运单:必须还没配载、在可见状态里,且属于收口范围
|
||||
*/
|
||||
private Waybill visibleWaybill(Long id, TripQueryVO condition) {
|
||||
Waybill waybill = waybillService.getById(id);
|
||||
if (waybill == null
|
||||
|| Func.isNotEmpty(waybill.getLoadingNo())
|
||||
|| !TripAggregation.CHILD_STATUSES.contains(waybill.getBusinessStatus())
|
||||
|| WaybillProcessSupport.ACCEPT_REJECTED.equals(waybill.getDriverAcceptStatus())) {
|
||||
return null;
|
||||
}
|
||||
if (condition.isAllDrivers()) {
|
||||
return waybill;
|
||||
}
|
||||
return samePhone(waybill.getDriverPhone(), condition.getDriverPhone()) ? waybill : null;
|
||||
}
|
||||
|
||||
private boolean scoped(TripQueryVO condition) {
|
||||
return condition.isAllDrivers() || Func.isNotEmpty(Func.toStr(condition.getDriverPhone(), "").trim());
|
||||
}
|
||||
|
||||
private boolean samePhone(String left, String right) {
|
||||
String expected = Func.toStr(right, "").trim().replace(" ", "");
|
||||
if (Func.isEmpty(expected)) {
|
||||
return false;
|
||||
}
|
||||
return expected.equals(Func.toStr(left, "").trim().replace(" ", ""));
|
||||
}
|
||||
|
||||
private TripQueryVO condition(TripQueryVO query) {
|
||||
return query == null ? new TripQueryVO() : query;
|
||||
}
|
||||
|
||||
/** 去掉 Tab 条件、其余照搬:统计四档时不按某一档收窄 */
|
||||
private TripQueryVO withoutTab(TripQueryVO source) {
|
||||
TripQueryVO copy = new TripQueryVO();
|
||||
copy.setDriverPhone(source.getDriverPhone());
|
||||
copy.setAllDrivers(source.isAllDrivers());
|
||||
copy.setDeptIds(source.getDeptIds());
|
||||
copy.setKeyword(source.getKeyword());
|
||||
copy.setTransportOrgType(source.getTransportOrgType());
|
||||
copy.setException(source.getException());
|
||||
copy.setExceptionWaybillIds(source.getExceptionWaybillIds());
|
||||
copy.setCreatedFrom(source.getCreatedFrom());
|
||||
copy.setCreatedTo(source.getCreatedTo());
|
||||
copy.setExcludeRejectedTrips(source.isExcludeRejectedTrips());
|
||||
return copy;
|
||||
}
|
||||
|
||||
private Long tripId(String value) {
|
||||
if (Func.isEmpty(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(value.trim());
|
||||
} catch (NumberFormatException ignored) {
|
||||
// 运次主键只有配载单与运单两种数字雪花号,其余一律当作不存在的运次
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 运次主键按字符串下发:19 位雪花号按数字下发会被前端取整 */
|
||||
private String tripIdOf(Long id) {
|
||||
return id == null ? null : String.valueOf(id);
|
||||
}
|
||||
|
||||
/** 一趟车:运次键、子单行(段一取回的、含行级筛选后的)、派生状态与排序键 */
|
||||
private record TripRows(String key, String loadingNo, List<Waybill> children, String status, Integer tier,
|
||||
Date sortTime) {
|
||||
}
|
||||
|
||||
private int pageNo(Integer current) {
|
||||
return current == null || current < 1 ? 1 : current;
|
||||
}
|
||||
|
||||
private int pageSize(Integer size) {
|
||||
if (size == null || size < 1) {
|
||||
return DEFAULT_PAGE_SIZE;
|
||||
}
|
||||
return Math.min(size, MAX_PAGE_SIZE);
|
||||
}
|
||||
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.support.WaybillCargoSupport;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 运次站点拼装:把一趟车的实际途经顺序与每站的装卸、货物条目拼出来
|
||||
* <p>
|
||||
* 站点顺序优先取配载单的路线信息(有序站点数组);站点标记「装N / 卸N」的 N 指向配载单运单集合里的第 N 张子运单。
|
||||
* 没有路线信息时回退到子运单的起讫点去重成站(保留首次出现顺序),再退到配载单自己的发货/途经/到货地址;
|
||||
* 都没有则给空站点数组,前端按空站点渲染而不是崩。
|
||||
* <p>
|
||||
* 货物条目取子运单而不是配载单的货物信息:配载单的货物条数与子运单不一定同数,行序也对不上站点编号。
|
||||
*/
|
||||
final class TripStationAssembler {
|
||||
|
||||
private static final String LOAD = "load";
|
||||
private static final String UNLOAD = "unload";
|
||||
private static final String LOAD_UNLOAD = "load_unload";
|
||||
/** 途经站点之间的连接符 */
|
||||
private static final String TRANSIT_JOINER = " → ";
|
||||
/** 站点去重用的键分隔符:站名与地址都为空的两个点不该被当成同一个 */
|
||||
private static final String KEY_SEPARATOR = "\u0000";
|
||||
|
||||
private TripStationAssembler() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 拼装整趟车的站点,站序从 1 开始
|
||||
*
|
||||
* @param loading 配载单,未配载传 null
|
||||
* @param children 该运次的可见子运单
|
||||
*/
|
||||
static List<TripStationVO> assemble(LoadingManage loading, List<Waybill> children) {
|
||||
List<TripStationVO> stations = fromRoute(loading, children);
|
||||
if (stations.isEmpty()) {
|
||||
stations = fromWaybillPoints(children);
|
||||
}
|
||||
if (stations.isEmpty() && loading != null) {
|
||||
stations = fromLoadingAddresses(loading);
|
||||
}
|
||||
for (int i = 0; i < stations.size(); i++) {
|
||||
stations.get(i).setIndex(i + 1);
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 起点名称:首站站名,没有站点时为空
|
||||
*/
|
||||
static String departureName(List<TripStationVO> stations) {
|
||||
return stations.isEmpty() ? "" : Func.toStr(stations.get(0).getName(), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 终点名称:末站站名,没有站点时为空
|
||||
*/
|
||||
static String arrivalName(List<TripStationVO> stations) {
|
||||
return stations.isEmpty() ? "" : Func.toStr(stations.get(stations.size() - 1).getName(), "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 途经站点:中间各站按顺序连接,不足三站为空
|
||||
*/
|
||||
static String transitName(List<TripStationVO> stations) {
|
||||
if (stations.size() < 3) {
|
||||
return "";
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (int i = 1; i < stations.size() - 1; i++) {
|
||||
String name = Func.toStr(stations.get(i).getName(), "");
|
||||
if (Func.isNotEmpty(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return String.join(TRANSIT_JOINER, names);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点来自配载单的路线信息:按数组顺序成站,标记里第 N 张子运单按配载单运单集合的顺序取
|
||||
*/
|
||||
private static List<TripStationVO> fromRoute(LoadingManage loading, List<Waybill> children) {
|
||||
if (loading == null) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<Map<String, Object>> route = objectList(loading.getRouteJson());
|
||||
if (route.isEmpty()) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
List<String> waybillIds = stringList(loading.getWaybillIdsJson());
|
||||
Map<String, Waybill> byId = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
if (child.getId() != null) {
|
||||
byId.put(String.valueOf(child.getId()), child);
|
||||
}
|
||||
}
|
||||
List<TripStationVO> stations = new ArrayList<>();
|
||||
for (Map<String, Object> element : route) {
|
||||
String address = stringValue(element.get("address"));
|
||||
String display = stringValue(element.get("displayAddress"));
|
||||
TripStationVO station = new TripStationVO();
|
||||
station.setName(Func.toStrWithEmpty(display, address));
|
||||
station.setAddress(Func.toStrWithEmpty(address, station.getName()));
|
||||
List<String> tags = stringList(element.get("tags"));
|
||||
station.setTags(tags);
|
||||
List<TripStationVO.TripCargoVO> cargos = new ArrayList<>();
|
||||
boolean load = false;
|
||||
boolean unload = false;
|
||||
for (String tag : tags) {
|
||||
boolean loadTag = tag.contains("装");
|
||||
boolean unloadTag = tag.contains("卸");
|
||||
if (!loadTag && !unloadTag) {
|
||||
continue;
|
||||
}
|
||||
Waybill child = childOfTag(tag, waybillIds, byId);
|
||||
if (child == null) {
|
||||
continue;
|
||||
}
|
||||
load |= loadTag;
|
||||
unload |= unloadTag;
|
||||
addCargo(cargos, child, loadTag ? LOAD : UNLOAD);
|
||||
if (loadTag && unloadTag) {
|
||||
addCargo(cargos, child, UNLOAD);
|
||||
}
|
||||
}
|
||||
fillOperation(station, load, unload);
|
||||
station.setCargos(cargos);
|
||||
stations.add(station);
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点回退到子运单的起讫点:按子运单顺序每张的起点、终点依次成站,同名同址只留首次出现
|
||||
*/
|
||||
private static List<TripStationVO> fromWaybillPoints(List<Waybill> children) {
|
||||
Map<String, TripStationVO> stations = new LinkedHashMap<>();
|
||||
Map<String, boolean[]> operations = new LinkedHashMap<>();
|
||||
for (Waybill child : children) {
|
||||
String fromName = pointName(child.getDepartureName(), child.getDepartureAddress());
|
||||
String fromAddress = Func.toStrWithEmpty(child.getDepartureAddress(), fromName);
|
||||
String toName = pointName(child.getArrivalName(), child.getArrivalAddress());
|
||||
String toAddress = Func.toStrWithEmpty(child.getArrivalAddress(), toName);
|
||||
point(stations, operations, fromName, fromAddress, true, child);
|
||||
point(stations, operations, toName, toAddress, false, child);
|
||||
}
|
||||
operations.forEach((key, flags) -> fillOperation(stations.get(key), flags[0], flags[1]));
|
||||
return new ArrayList<>(stations.values());
|
||||
}
|
||||
|
||||
private static void point(Map<String, TripStationVO> stations, Map<String, boolean[]> operations,
|
||||
String name, String address, boolean load, Waybill child) {
|
||||
if (Func.isEmpty(name) && Func.isEmpty(address)) {
|
||||
return;
|
||||
}
|
||||
String key = name + KEY_SEPARATOR + address;
|
||||
TripStationVO station = stations.get(key);
|
||||
if (station == null) {
|
||||
station = new TripStationVO();
|
||||
station.setName(name);
|
||||
station.setAddress(address);
|
||||
station.setTags(new ArrayList<>());
|
||||
station.setCargos(new ArrayList<>());
|
||||
stations.put(key, station);
|
||||
operations.put(key, new boolean[2]);
|
||||
}
|
||||
boolean[] flags = operations.get(key);
|
||||
flags[0] |= load;
|
||||
flags[1] |= !load;
|
||||
addCargo(station.getCargos(), child, load ? LOAD : UNLOAD);
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点再退到配载单自己的发货 / 途经 / 到货地址
|
||||
*/
|
||||
private static List<TripStationVO> fromLoadingAddresses(LoadingManage loading) {
|
||||
List<TripStationVO> stations = new ArrayList<>();
|
||||
for (String address : List.of(
|
||||
Func.toStr(loading.getDepartureAddress(), ""),
|
||||
Func.toStr(loading.getTransitAddress(), ""),
|
||||
Func.toStr(loading.getArrivalAddress(), ""))) {
|
||||
if (Func.isEmpty(address)) {
|
||||
continue;
|
||||
}
|
||||
TripStationVO station = new TripStationVO();
|
||||
station.setName(address);
|
||||
station.setAddress(address);
|
||||
station.setTags(new ArrayList<>());
|
||||
station.setCargos(new ArrayList<>());
|
||||
stations.add(station);
|
||||
}
|
||||
return stations;
|
||||
}
|
||||
|
||||
/**
|
||||
* 站点标记里第 N 张子运单:N 是配载单运单集合里的位次,不是可见子单里的位次
|
||||
*/
|
||||
private static Waybill childOfTag(String tag, List<String> waybillIds, Map<String, Waybill> byId) {
|
||||
String digits = tag.replaceAll("\\D+", "");
|
||||
if (Func.isEmpty(digits)) {
|
||||
return null;
|
||||
}
|
||||
int order = Func.toInt(digits, 0);
|
||||
if (order < 1 || order > waybillIds.size()) {
|
||||
return null;
|
||||
}
|
||||
return byId.get(waybillIds.get(order - 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条子运单在该站的货物条目:运单上录了几条货物就出几条,同一张运单的多条货物不能被折成一行,
|
||||
* 否则站点上看不出这票到底装/卸了哪几种货。去重键含货名,站点标记重复出现时不会重复列出。
|
||||
* <p>
|
||||
* 一条货物都没录的运单仍出一行(货名为空):站点上的「这站有这张票」由这一行承载,
|
||||
* 只按货名判断有没有货物会让没填货名的单在任务安排里凭空消失。
|
||||
*/
|
||||
private static void addCargo(List<TripStationVO.TripCargoVO> cargos, Waybill child, String operation) {
|
||||
if (child == null) {
|
||||
return;
|
||||
}
|
||||
String waybillId = child.getId() == null ? null : String.valueOf(child.getId());
|
||||
List<WaybillCargoSupport.Cargo> items = WaybillCargoSupport.of(child);
|
||||
if (items.isEmpty()) {
|
||||
// 一条货物都没录:站点条目仍要有这一行,否则任务安排里这票凭空消失
|
||||
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
|
||||
cargo.setWaybillId(waybillId);
|
||||
cargo.setWaybillNo(child.getWaybillNo());
|
||||
cargo.setCargoName("");
|
||||
cargo.setCustomerName(child.getCustomerName());
|
||||
cargo.setOperation(operation);
|
||||
cargo.setProjectName(child.getProjectName());
|
||||
cargos.add(cargo);
|
||||
return;
|
||||
}
|
||||
for (WaybillCargoSupport.Cargo item : items) {
|
||||
String cargoName = item.displayName();
|
||||
boolean exists = cargos.stream().anyMatch(cargo ->
|
||||
Objects.equals(cargo.getWaybillId(), waybillId)
|
||||
&& Objects.equals(cargo.getOperation(), operation)
|
||||
&& Objects.equals(cargo.getCargoName(), cargoName));
|
||||
if (exists) {
|
||||
continue;
|
||||
}
|
||||
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
|
||||
cargo.setWaybillId(waybillId);
|
||||
cargo.setWaybillNo(child.getWaybillNo());
|
||||
cargo.setCargoName(cargoName);
|
||||
cargo.setCustomerName(child.getCustomerName());
|
||||
cargo.setQuantity(item.quantity());
|
||||
cargo.setQuantityUnit(item.quantityUnit());
|
||||
cargo.setOperation(operation);
|
||||
cargo.setProjectName(child.getProjectName());
|
||||
cargos.add(cargo);
|
||||
}
|
||||
}
|
||||
|
||||
private static void fillOperation(TripStationVO station, boolean load, boolean unload) {
|
||||
if (load && unload) {
|
||||
station.setOperationType(LOAD_UNLOAD);
|
||||
station.setOperationTypeLabel("装卸");
|
||||
} else if (load) {
|
||||
station.setOperationType(LOAD);
|
||||
station.setOperationTypeLabel("装货");
|
||||
} else if (unload) {
|
||||
station.setOperationType(UNLOAD);
|
||||
station.setOperationTypeLabel("卸货");
|
||||
} else {
|
||||
station.setOperationType(null);
|
||||
station.setOperationTypeLabel("");
|
||||
}
|
||||
}
|
||||
|
||||
private static String pointName(String name, String address) {
|
||||
return Func.toStrWithEmpty(name, Func.toStr(address, ""));
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> objectList(String json) {
|
||||
if (Func.isEmpty(json)) {
|
||||
return new ArrayList<>();
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(json, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
List<Map<String, Object>> result = new ArrayList<>();
|
||||
for (Object item : list) {
|
||||
if (item instanceof Map<?, ?> map) {
|
||||
Map<String, Object> element = new LinkedHashMap<>();
|
||||
map.forEach((key, value) -> element.put(String.valueOf(key), value));
|
||||
result.add(element);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 路线信息坏了不该让整趟车读不出来,按没有路线信息回退
|
||||
}
|
||||
return new ArrayList<>();
|
||||
}
|
||||
|
||||
private static List<String> stringList(Object value) {
|
||||
List<String> result = new ArrayList<>();
|
||||
if (value instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String text = stringValue(item);
|
||||
if (Func.isNotEmpty(text)) {
|
||||
result.add(text);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
String text = stringValue(value);
|
||||
if (Func.isEmpty(text)) {
|
||||
return result;
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(text, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
for (Object item : list) {
|
||||
String item2 = stringValue(item);
|
||||
if (Func.isNotEmpty(item2)) {
|
||||
result.add(item2);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 不是 JSON 数组就当逗号分隔处理
|
||||
}
|
||||
return Func.toStrList(",", text);
|
||||
}
|
||||
|
||||
private static String stringValue(Object value) {
|
||||
return value == null ? "" : String.valueOf(value).trim();
|
||||
}
|
||||
|
||||
}
|
||||
+177
-43
@@ -30,7 +30,9 @@ import org.springblade.transport.excel.WaybillImportBatchExcel;
|
||||
import org.springblade.transport.mapper.WaybillImportBatchMapper;
|
||||
import org.springblade.transport.pojo.dto.WaybillImportBatchRequest;
|
||||
import org.springblade.transport.pojo.entity.CustomerArchive;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.ProjectApply;
|
||||
import org.springblade.transport.pojo.entity.ReceivablePayableDetail;
|
||||
import org.springblade.transport.pojo.entity.TransportPlan;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillImportBatch;
|
||||
@@ -76,6 +78,13 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
private static final String STATUS_COMPLETED = "completed";
|
||||
private static final String IMPORT_TYPE_WAYBILL = "waybill";
|
||||
private static final String IMPORT_TYPE_SETTLEMENT = "settlement";
|
||||
/** 批次结算状态:全部运单均未进入预/正式结算。 */
|
||||
private static final String SETTLEMENT_UNSETTLED = "unsettled";
|
||||
/** 批次结算状态:部分运单已进入结算。 */
|
||||
private static final String SETTLEMENT_PARTIAL = "partial";
|
||||
/** 批次结算状态:全部运单均已进入结算。 */
|
||||
private static final String SETTLEMENT_SETTLED = "settled";
|
||||
private static final String LOADING_DATA_SOURCE_IMPORT = "批量导入";
|
||||
/** 车牌号校验正则:首位汉字,次位大写字母,总长度7或8位 */
|
||||
private static final Pattern VEHICLE_NO_PATTERN = Pattern.compile("^[一-龥][A-Z][A-Z0-9]{5,6}$");
|
||||
/** 中国机动车号牌省份简称。 */
|
||||
@@ -358,6 +367,7 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
}
|
||||
IPage<WaybillImportBatch> entityPage = page(page, queryWrapper);
|
||||
List<WaybillImportBatchVO> records = entityPage.getRecords().stream().map(this::toVO).toList();
|
||||
fillSettlementStatus(records);
|
||||
Page<WaybillImportBatchVO> resultPage = new Page<>(entityPage.getCurrent(), entityPage.getSize(), entityPage.getTotal());
|
||||
resultPage.setRecords(records);
|
||||
return resultPage;
|
||||
@@ -389,18 +399,33 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
List<WaybillImportBatch> batches = listByIds(idList).stream()
|
||||
.filter(batch -> !Objects.equals(batch.getIsDeleted(), 1))
|
||||
.toList();
|
||||
// 与前端一致:仅草稿可删;选中含已完成/进行中时整批拒绝
|
||||
boolean hasNonDraft = batches.stream()
|
||||
.anyMatch(batch -> !STATUS_DRAFT.equals(batch.getImportStatus()));
|
||||
if (hasNonDraft) {
|
||||
throw new ServiceException("已完成运单不可删除");
|
||||
if (Func.isEmpty(batches)) {
|
||||
throw new ServiceException("运单批次不存在或已删除");
|
||||
}
|
||||
Map<Long, List<Waybill>> waybillsByBatchId = listWaybillsByBatchIds(
|
||||
batches.stream().map(WaybillImportBatch::getId).toList());
|
||||
List<Long> allWaybillIds = waybillsByBatchId.values().stream()
|
||||
.flatMap(List::stream)
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds);
|
||||
// 仅未结算批次可整批删除;选中含部分结算/已结算时整批拒绝
|
||||
for (WaybillImportBatch batch : batches) {
|
||||
List<Waybill> waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of());
|
||||
String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds);
|
||||
if (!SETTLEMENT_UNSETTLED.equals(settlementStatus)) {
|
||||
throw new ServiceException("仅未结算的运单批次可以删除");
|
||||
}
|
||||
}
|
||||
BusinessRemoveResultVO result = new BusinessRemoveResultVO();
|
||||
for (WaybillImportBatch batch : batches) {
|
||||
List<Waybill> waybills = waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.eq(Waybill::getImportBatchId, batch.getId()).eq(Waybill::getIsDeleted, 0));
|
||||
if (Func.isNotEmpty(waybills)) {
|
||||
waybillService.deleteLogic(waybills.stream().map(Waybill::getId).toList());
|
||||
List<Waybill> waybills = waybillsByBatchId.getOrDefault(batch.getId(), List.of());
|
||||
List<Long> waybillIds = waybills.stream().map(Waybill::getId).filter(Objects::nonNull).toList();
|
||||
if (Func.isNotEmpty(waybillIds)) {
|
||||
clearUnsettledReceivableDetails(waybillIds);
|
||||
clearImportedLoadings(waybills);
|
||||
waybillService.deleteLogic(waybillIds);
|
||||
}
|
||||
deleteLogic(List.of(batch.getId()));
|
||||
result.setSuccessCount(result.getSuccessCount() + 1);
|
||||
@@ -671,11 +696,148 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
if (vo == null) throw new ServiceException("运单批次数据转换失败");
|
||||
vo.setImportTypeName(IMPORT_TYPE_SETTLEMENT.equals(batch.getImportType()) ? "结算单" : "运单");
|
||||
vo.setStatusName(importStatusName(batch.getImportStatus()));
|
||||
vo.setSettlementStatus(SETTLEMENT_UNSETTLED);
|
||||
vo.setSettlementStatusName(settlementStatusName(SETTLEMENT_UNSETTLED));
|
||||
vo.setCreateUserName(UserCache.getUserRealName(batch.getCreateUser()));
|
||||
vo.setUpdateUserName(UserCache.getUserRealName(batch.getUpdateUser()));
|
||||
return vo;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按批次下运单是否已关联预/正式结算单,汇总结算状态:
|
||||
* 全部未关联=未结算,部分关联=部分结算,全部关联=已结算。
|
||||
*/
|
||||
private void fillSettlementStatus(List<WaybillImportBatchVO> records) {
|
||||
if (Func.isEmpty(records)) {
|
||||
return;
|
||||
}
|
||||
List<Long> batchIds = records.stream().map(WaybillImportBatchVO::getId).filter(Objects::nonNull).toList();
|
||||
Map<Long, List<Waybill>> waybillsByBatchId = listWaybillsByBatchIds(batchIds);
|
||||
List<Long> allWaybillIds = waybillsByBatchId.values().stream()
|
||||
.flatMap(List::stream)
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.toList();
|
||||
Set<Long> settledWaybillIds = receivablePayableDetailService.settlementLinkedWaybillIds(allWaybillIds);
|
||||
for (WaybillImportBatchVO record : records) {
|
||||
List<Waybill> waybills = waybillsByBatchId.getOrDefault(record.getId(), List.of());
|
||||
String settlementStatus = resolveSettlementStatus(waybills, settledWaybillIds);
|
||||
record.setSettlementStatus(settlementStatus);
|
||||
record.setSettlementStatusName(settlementStatusName(settlementStatus));
|
||||
}
|
||||
}
|
||||
|
||||
private Map<Long, List<Waybill>> listWaybillsByBatchIds(List<Long> batchIds) {
|
||||
if (Func.isEmpty(batchIds)) {
|
||||
return Map.of();
|
||||
}
|
||||
List<Waybill> waybills = waybillService.list(Wrappers.<Waybill>lambdaQuery()
|
||||
.select(Waybill::getId, Waybill::getImportBatchId, Waybill::getLoadingNo)
|
||||
.in(Waybill::getImportBatchId, batchIds)
|
||||
.eq(Waybill::getIsDeleted, 0));
|
||||
return waybills.stream()
|
||||
.filter(waybill -> waybill.getImportBatchId() != null)
|
||||
.collect(Collectors.groupingBy(Waybill::getImportBatchId));
|
||||
}
|
||||
|
||||
private String resolveSettlementStatus(List<Waybill> waybills, Set<Long> settledWaybillIds) {
|
||||
if (Func.isEmpty(waybills)) {
|
||||
return SETTLEMENT_UNSETTLED;
|
||||
}
|
||||
int settledCount = 0;
|
||||
for (Waybill waybill : waybills) {
|
||||
if (waybill.getId() != null && settledWaybillIds.contains(waybill.getId())) {
|
||||
settledCount++;
|
||||
}
|
||||
}
|
||||
if (settledCount <= 0) {
|
||||
return SETTLEMENT_UNSETTLED;
|
||||
}
|
||||
if (settledCount >= waybills.size()) {
|
||||
return SETTLEMENT_SETTLED;
|
||||
}
|
||||
return SETTLEMENT_PARTIAL;
|
||||
}
|
||||
|
||||
private String settlementStatusName(String settlementStatus) {
|
||||
if (SETTLEMENT_SETTLED.equals(settlementStatus)) {
|
||||
return "已结算";
|
||||
}
|
||||
if (SETTLEMENT_PARTIAL.equals(settlementStatus)) {
|
||||
return "部分结算";
|
||||
}
|
||||
return "未结算";
|
||||
}
|
||||
|
||||
/** 删除批次前清理尚未进入预/正式结算的应收应付明细,避免残留待结算数据。 */
|
||||
private void clearUnsettledReceivableDetails(List<Long> waybillIds) {
|
||||
if (Func.isEmpty(waybillIds)) {
|
||||
return;
|
||||
}
|
||||
List<ReceivablePayableDetail> details = receivablePayableDetailService.list(
|
||||
Wrappers.<ReceivablePayableDetail>lambdaQuery()
|
||||
.in(ReceivablePayableDetail::getWaybillId, waybillIds)
|
||||
.eq(ReceivablePayableDetail::getIsDeleted, 0)
|
||||
.and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getPreSettlementNo)
|
||||
.or().eq(ReceivablePayableDetail::getPreSettlementNo, ""))
|
||||
.and(wrapper -> wrapper.isNull(ReceivablePayableDetail::getFormalSettlementNo)
|
||||
.or().eq(ReceivablePayableDetail::getFormalSettlementNo, "")));
|
||||
if (Func.isNotEmpty(details)) {
|
||||
receivablePayableDetailService.deleteLogic(
|
||||
details.stream().map(ReceivablePayableDetail::getId).toList());
|
||||
}
|
||||
}
|
||||
|
||||
/** 清理本批次运单关联的批量导入配载单(配载单运单均属本批时整单删除)。 */
|
||||
private void clearImportedLoadings(List<Waybill> waybills) {
|
||||
if (Func.isEmpty(waybills)) {
|
||||
return;
|
||||
}
|
||||
Set<Long> batchWaybillIds = waybills.stream()
|
||||
.map(Waybill::getId)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
Set<String> loadingNos = waybills.stream()
|
||||
.map(Waybill::getLoadingNo)
|
||||
.filter(Func::isNotEmpty)
|
||||
.collect(Collectors.toCollection(HashSet::new));
|
||||
if (Func.isEmpty(loadingNos)) {
|
||||
return;
|
||||
}
|
||||
List<LoadingManage> loadings = loadingManageService.list(Wrappers.<LoadingManage>lambdaQuery()
|
||||
.in(LoadingManage::getLoadingNo, loadingNos)
|
||||
.eq(LoadingManage::getDataSource, LOADING_DATA_SOURCE_IMPORT)
|
||||
.eq(LoadingManage::getIsDeleted, 0));
|
||||
List<Long> deleteLoadingIds = new ArrayList<>();
|
||||
for (LoadingManage loading : loadings) {
|
||||
List<Long> loadingWaybillIds = parseLoadingWaybillIds(loading.getWaybillIdsJson());
|
||||
if (Func.isEmpty(loadingWaybillIds) || batchWaybillIds.containsAll(loadingWaybillIds)) {
|
||||
deleteLoadingIds.add(loading.getId());
|
||||
}
|
||||
}
|
||||
if (Func.isNotEmpty(deleteLoadingIds)) {
|
||||
loadingManageService.deleteLogic(deleteLoadingIds);
|
||||
}
|
||||
}
|
||||
|
||||
private List<Long> parseLoadingWaybillIds(String waybillIdsJson) {
|
||||
if (Func.isEmpty(waybillIdsJson)) {
|
||||
return List.of();
|
||||
}
|
||||
try {
|
||||
Object parsed = JsonUtil.parse(waybillIdsJson, List.class);
|
||||
if (parsed instanceof List<?> list) {
|
||||
return list.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(item -> Long.valueOf(String.valueOf(item)))
|
||||
.toList();
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// 兼容历史逗号分隔存储
|
||||
}
|
||||
return Func.toLongList(waybillIdsJson);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String nextBatchNo() {
|
||||
return nextCode();
|
||||
@@ -758,25 +920,22 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
// 10. 里程校验
|
||||
validatePositiveNumber(row, "mileage", "里程(km)", errors);
|
||||
|
||||
// 11. 运费合计校验
|
||||
validateFreightTotal(row, errors);
|
||||
|
||||
// 12. 实际发货时间校验
|
||||
// 11. 实际发货时间校验
|
||||
validateActualStartDate(row, isDraft, errors);
|
||||
|
||||
// 13. 实际完成时间校验
|
||||
// 12. 实际完成时间校验
|
||||
validateActualEndDate(row, isDraft, errors);
|
||||
|
||||
// 14. 预计发货时间校验
|
||||
// 13. 预计发货时间校验
|
||||
validatePlanStartDate(row, errors);
|
||||
|
||||
// 15. 预计完成时间校验
|
||||
// 14. 预计完成时间校验
|
||||
validatePlanEndDate(row, errors);
|
||||
|
||||
// 16. 同一运单标识号校验
|
||||
// 15. 同一运单标识号校验
|
||||
validateWaybillIdentifier(row, i, waybillIdentifierMap, rows, errors);
|
||||
|
||||
// 17. 同一运单标识号组内一致性校验(地址/配载标识号/数量单位,多货物合并的前提)
|
||||
// 16. 同一运单标识号组内一致性校验(地址/配载标识号/数量单位,多货物合并的前提)
|
||||
validateWaybillIdentifierConsistency(row, i, waybillIdentifierMap, rows, errors);
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
@@ -939,31 +1098,6 @@ public class WaybillImportBatchServiceImpl extends BaseServiceImpl<WaybillImport
|
||||
}
|
||||
}
|
||||
|
||||
private void validateFreightTotal(Map<String, Object> row, List<String> errors) {
|
||||
String freightTotalStr = stringValue(row, "freightTotal", "运费合计");
|
||||
if (Func.isEmpty(freightTotalStr) || isImportOptionalNumberEmpty(freightTotalStr)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
BigDecimal freightTotal = new BigDecimal(freightTotalStr);
|
||||
String freightStr = stringValue(row, "freight", "运费");
|
||||
String otherFeeTotalStr = stringValue(row, "otherFeeTotal", "其他费用合计");
|
||||
|
||||
BigDecimal freight = Func.isNotEmpty(freightStr) && !isImportOptionalNumberEmpty(freightStr)
|
||||
? new BigDecimal(freightStr) : BigDecimal.ZERO;
|
||||
BigDecimal otherFeeTotal = Func.isNotEmpty(otherFeeTotalStr) && !isImportOptionalNumberEmpty(otherFeeTotalStr)
|
||||
? new BigDecimal(otherFeeTotalStr) : BigDecimal.ZERO;
|
||||
|
||||
BigDecimal calculatedTotal = freight.add(otherFeeTotal);
|
||||
if (freightTotal.compareTo(calculatedTotal) != 0) {
|
||||
errors.add("运费合计应等于运费+其他费用合计");
|
||||
}
|
||||
} catch (NumberFormatException e) {
|
||||
errors.add("运费相关字段格式不正确");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateActualStartDate(Map<String, Object> row, boolean isDraft, List<String> errors) {
|
||||
Object actualStartDate = row.get("actualStartDate");
|
||||
if (actualStartDate == null || String.valueOf(actualStartDate).isBlank()) {
|
||||
|
||||
+81
-29
@@ -40,12 +40,13 @@ import org.springblade.thirdparty.lbs.feign.ILbsClient;
|
||||
import org.springblade.thirdparty.lbs.pojo.dto.LbsLocateRequest;
|
||||
import org.springblade.thirdparty.lbs.pojo.vo.LbsLocateResponse;
|
||||
import org.springblade.transport.excel.WaybillExcel;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ProjectApply;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
@@ -66,6 +67,8 @@ import org.springblade.transport.service.IProjectApplyService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.LiveProcessConfigSupport;
|
||||
import org.springblade.transport.support.LoadableAdmissionSupport;
|
||||
import org.springblade.transport.support.TransportBusinessSupport;
|
||||
import org.springblade.transport.support.WaybillProcessSupport;
|
||||
import org.springblade.transport.wrapper.WaybillWrapper;
|
||||
@@ -82,6 +85,7 @@ import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
@@ -125,6 +129,9 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
@jakarta.annotation.Resource
|
||||
private WaybillEnroutePunchMapper waybillEnroutePunchMapper;
|
||||
|
||||
@jakarta.annotation.Resource
|
||||
private ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
|
||||
@jakarta.annotation.Resource
|
||||
private ILbsClient lbsClient;
|
||||
|
||||
@@ -822,27 +829,20 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 动态过程配置优先,回退运单快照 */
|
||||
/**
|
||||
* 生效配置优先,回退运单快照。
|
||||
* <p>
|
||||
* 取法与二选一规则在 {@link LiveProcessConfigSupport};这里不用「生效配置没有可打卡节点就回退快照」
|
||||
* 那一版——接单需求、节点门禁、节点订正都读这份配置判断节点存不存在,生效配置是权威,
|
||||
* 不能因为快照里有节点而被顶掉。
|
||||
*/
|
||||
private String resolveLiveProcessJson(Waybill waybill) {
|
||||
if (waybill.getProjectId() != null) {
|
||||
String projectId = String.valueOf(waybill.getProjectId());
|
||||
String live = processConfigService.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.like(ProcessConfig::getProjectIds, projectId)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime))
|
||||
.stream()
|
||||
.filter(cfg -> containsProjectId(cfg.getProjectIds(), projectId))
|
||||
.map(ProcessConfig::getNodeConfigJson)
|
||||
.filter(Func::isNotEmpty)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
if (Func.isNotEmpty(live)) {
|
||||
return live;
|
||||
}
|
||||
if (waybill == null) {
|
||||
return null;
|
||||
}
|
||||
return waybill.getProcessJson();
|
||||
return LiveProcessConfigSupport.resolve(
|
||||
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
|
||||
waybill.getProcessJson());
|
||||
}
|
||||
|
||||
private Map<String, List<String>> buildVoucherTypesIndex(String processJson) {
|
||||
@@ -1175,15 +1175,6 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
waybill.setDriverRejectReason(null);
|
||||
}
|
||||
|
||||
private boolean containsProjectId(String projectIds, String projectId) {
|
||||
if (Func.isEmpty(projectIds)) {
|
||||
return false;
|
||||
}
|
||||
return List.of(projectIds.split(",")).stream()
|
||||
.map(String::trim)
|
||||
.anyMatch(projectId::equals);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public BusinessRemoveResultVO removeWaybill(String ids) {
|
||||
@@ -1512,6 +1503,61 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
return doComplete(waybill);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public boolean confirmReturnReceipt(Long id) {
|
||||
Waybill waybill = loadEditable(id, true);
|
||||
String processJson = LiveProcessConfigSupport.resolve(
|
||||
LiveProcessConfigSupport.byProject(processConfigService, waybill.getProjectId()),
|
||||
waybill.getProcessJson());
|
||||
if (WaybillProcessSupport.returnNeedsNoConfirmation(processJson)) {
|
||||
// 不要求确认时司机打完末位卡就已经完成了,运单不会停在进行中等确认;配置对不上要说清,
|
||||
// 否则用户会以为是确认没生效
|
||||
throw new ServiceException("该运单的回单不需要业务员确认");
|
||||
}
|
||||
assertPunchNodesFinished(waybill.getId(), processJson);
|
||||
assertNoUnresolvedException(waybill.getId());
|
||||
return doComplete(waybill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 司机打卡节点(在途除外)必须全部已打卡:完成接口本身不要求节点打卡,
|
||||
* 不挡住就能从「确认回单」绕过打卡门禁。
|
||||
*/
|
||||
private void assertPunchNodesFinished(Long waybillId, String processJson) {
|
||||
List<String> missing = WaybillProcessSupport.missingPunchNodeNames(processJson, punchedNodeKeys(waybillId));
|
||||
if (!missing.isEmpty()) {
|
||||
throw new ServiceException("该运单还有未打卡的节点:" + String.join("、", missing));
|
||||
}
|
||||
}
|
||||
|
||||
/** 该运单已打卡的节点编码与名称(小写):打卡记录里两者都写过,对节点时任一命中即可 */
|
||||
private Set<String> punchedNodeKeys(Long waybillId) {
|
||||
Set<String> keys = new LinkedHashSet<>();
|
||||
List<WaybillNodePunch> punches = waybillNodePunchMapper.selectList(Wrappers.<WaybillNodePunch>lambdaQuery()
|
||||
.eq(WaybillNodePunch::getWaybillId, waybillId));
|
||||
for (WaybillNodePunch punch : punches) {
|
||||
if (Func.isNotEmpty(punch.getNodeCode())) {
|
||||
keys.add(punch.getNodeCode().trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
if (Func.isNotEmpty(punch.getNodeName())) {
|
||||
keys.add(punch.getNodeName().trim().toLowerCase(Locale.ROOT));
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** 存在未了结异常时不允许置完成:判据与司机端完成同一道门(处置单达终态才算解除) */
|
||||
private void assertNoUnresolvedException(Long waybillId) {
|
||||
Long unresolved = exceptionDisposalMapper.selectCount(Wrappers.<ExceptionDisposal>lambdaQuery()
|
||||
.eq(ExceptionDisposal::getWaybillId, waybillId)
|
||||
.eq(ExceptionDisposal::getIsDeleted, 0)
|
||||
.notIn(ExceptionDisposal::getDisposalStatus, WaybillProcessSupport.SETTLED_DISPOSAL_STATUSES));
|
||||
if (unresolved != null && unresolved > 0) {
|
||||
throw new ServiceException("该运单存在未处理异常,处理完成后才能确认回单");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成运单核心逻辑:状态改为 completed,并检查生成应收应付明细。
|
||||
*/
|
||||
@@ -1691,6 +1737,12 @@ public class WaybillServiceImpl extends BaseServiceImpl<WaybillMapper, Waybill>
|
||||
if (Objects.equals(waybill.getOnlyUnassignedLoading(), 1)) {
|
||||
queryWrapper.and(wrapper -> wrapper.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
|
||||
}
|
||||
if (Func.isNotEmpty(waybill.getIds())) {
|
||||
queryWrapper.in(Waybill::getId, waybill.getIds());
|
||||
}
|
||||
if (Objects.equals(waybill.getLoadableOnly(), 1)) {
|
||||
LoadableAdmissionSupport.applyLoadableConditions(queryWrapper);
|
||||
}
|
||||
if (Func.isNotEmpty(waybill.getBatchNo())) {
|
||||
queryWrapper.like(Waybill::getBatchNo, waybill.getBatchNo());
|
||||
}
|
||||
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 过程配置的取法与「生效配置还是运单快照」的二选一规则,只此一处。
|
||||
* <p>
|
||||
* 生效配置({@code blade_process_config})按项目生效,是活的:改一次,该项目下所有运单下次读取
|
||||
* 都按新配置呈现;运单创建时另存一份 {@code process_json} 快照,只在生效配置取不到时兜底。
|
||||
* 生效配置按更新时间倒序取第一条命中该项目的,项目集合是逗号串,故先 LIKE 粗筛再按逗号分段精确比对 ——
|
||||
* 只靠 LIKE 会把「1234」命中到「12345」上。
|
||||
* <p>
|
||||
* 两个二选一版本的区别是**得到什么结果**,不是风格:
|
||||
* <ul>
|
||||
* <li>{@link #resolve} 生效配置优先 —— 接单需求、节点门禁、节点订正这些判断节点存不存在的路径用它。
|
||||
* 生效配置是权威,即使它没配可打卡节点也不能被快照顶掉,否则判断依据会随时间漂移。</li>
|
||||
* <li>{@link #resolveForPunch} 生效配置没有可打卡节点时回退快照 —— 司机端打卡页与列表卡用它。
|
||||
* 配置被改坏(节点全删)时,司机端仍能按快照渲染出打卡节点,不至于空白。</li>
|
||||
* </ul>
|
||||
*/
|
||||
public final class LiveProcessConfigSupport {
|
||||
|
||||
private LiveProcessConfigSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 单个项目的生效配置 JSON,没有则返回 null
|
||||
*
|
||||
* @param service 过程配置服务
|
||||
* @param projectId 项目主键
|
||||
*/
|
||||
public static String byProject(IProcessConfigService service, Long projectId) {
|
||||
if (service == null || projectId == null) {
|
||||
return null;
|
||||
}
|
||||
String projectIdStr = String.valueOf(projectId);
|
||||
return service.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.like(ProcessConfig::getProjectIds, projectIdStr)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime))
|
||||
.stream()
|
||||
.filter(config -> containsProjectId(config.getProjectIds(), projectIdStr))
|
||||
.map(ProcessConfig::getNodeConfigJson)
|
||||
.filter(Func::isNotEmpty)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一批项目的生效配置:项目主键 → 节点配置 JSON,一次查全再按项目匹配,供列表页避免逐条回查
|
||||
*
|
||||
* @param service 过程配置服务
|
||||
* @param projectIds 项目主键集合,允许含 null
|
||||
*/
|
||||
public static Map<Long, String> byProjects(IProcessConfigService service, Collection<Long> projectIds) {
|
||||
Map<Long, String> live = new LinkedHashMap<>();
|
||||
if (service == null || Func.isEmpty(projectIds)) {
|
||||
return live;
|
||||
}
|
||||
Set<Long> wanted = new LinkedHashSet<>();
|
||||
projectIds.stream().filter(Objects::nonNull).forEach(wanted::add);
|
||||
if (wanted.isEmpty()) {
|
||||
return live;
|
||||
}
|
||||
List<ProcessConfig> configs = service.list(Wrappers.<ProcessConfig>lambdaQuery()
|
||||
.eq(ProcessConfig::getStatus, 1)
|
||||
.eq(ProcessConfig::getIsDeleted, 0)
|
||||
.orderByDesc(ProcessConfig::getUpdateTime)
|
||||
.orderByDesc(ProcessConfig::getCreateTime));
|
||||
if (Func.isEmpty(configs)) {
|
||||
return live;
|
||||
}
|
||||
for (Long projectId : wanted) {
|
||||
String projectIdStr = String.valueOf(projectId);
|
||||
for (ProcessConfig config : configs) {
|
||||
if (Func.isEmpty(config.getNodeConfigJson())
|
||||
|| !containsProjectId(config.getProjectIds(), projectIdStr)) {
|
||||
continue;
|
||||
}
|
||||
live.put(projectId, config.getNodeConfigJson());
|
||||
break;
|
||||
}
|
||||
}
|
||||
return live;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效配置优先,取不到时回退运单快照
|
||||
*
|
||||
* @param liveJson 生效配置 JSON,可为空
|
||||
* @param snapshot 运单快照 JSON,可为空
|
||||
*/
|
||||
public static String resolve(String liveJson, String snapshot) {
|
||||
return Func.isNotEmpty(liveJson) ? liveJson : snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生效配置优先;生效配置没有可打卡节点时回退快照,两边都没有节点就按生效配置来
|
||||
*
|
||||
* @param liveJson 生效配置 JSON,可为空
|
||||
* @param snapshot 运单快照 JSON,可为空
|
||||
*/
|
||||
public static String resolveForPunch(String liveJson, String snapshot) {
|
||||
if (Func.isEmpty(liveJson)) {
|
||||
return snapshot;
|
||||
}
|
||||
if (!WaybillProcessSupport.listDriverPunchNodes(liveJson).isEmpty()) {
|
||||
return liveJson;
|
||||
}
|
||||
if (Func.isNotEmpty(snapshot) && !WaybillProcessSupport.listDriverPunchNodes(snapshot).isEmpty()) {
|
||||
return snapshot;
|
||||
}
|
||||
return liveJson;
|
||||
}
|
||||
|
||||
/** 项目集合是逗号串,必须按逗号分段精确比对 */
|
||||
private static boolean containsProjectId(String projectIds, String projectId) {
|
||||
if (Func.isEmpty(projectIds) || Func.isEmpty(projectId)) {
|
||||
return false;
|
||||
}
|
||||
return Arrays.stream(projectIds.split(","))
|
||||
.map(String::trim)
|
||||
.anyMatch(projectId::equals);
|
||||
}
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 可配载准入判据:公路 + 待执行 + 未关联其它配载单。
|
||||
* <p>
|
||||
* 接单状态不进判据:拒单票照样可配载。配载提交会把它的接单事实清成待接单,
|
||||
* 否则新司机看不到这张票——司机端入口一律排除已拒绝的运单。
|
||||
* <p>
|
||||
* 候选列表取数与配载提交/编辑/重新派单的校验共用这一份定义。
|
||||
*/
|
||||
public final class LoadableAdmissionSupport {
|
||||
|
||||
/** 可配载只放行待执行:已完成、已取消、草稿与调度中间态一律不可配载 */
|
||||
public static final String LOADABLE_BUSINESS_STATUS = WaybillProcessSupport.STATUS_PENDING;
|
||||
|
||||
/**
|
||||
* 公路运输方式的同义写法,按「包含」比对,与前端运单管理页的识别口径一致。
|
||||
* <p>
|
||||
* 运输方式字典只有 road/railway/river/ocean/air,而库里存在「公路运输」「水路运输」这类中文取值,
|
||||
* 因此词表写在代码里,不从字典取。
|
||||
*/
|
||||
public static final List<String> ROAD_TRANSPORT_KEYWORDS = List.of("公路", "道路", "汽车", "陆运", "road", "highway");
|
||||
|
||||
/** 公路运输方式的历史短码,按整体相等比对(按包含比对会误伤含 gl 的其它取值) */
|
||||
public static final List<String> ROAD_TRANSPORT_CODES = List.of("gl");
|
||||
|
||||
private LoadableAdmissionSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 不可配载的原因,按判定顺序排列:公路 → 待执行 → 未关联其它配载单。
|
||||
* <p>
|
||||
* 判据只产出原因,中文文案由调用方映射——面向用户的措辞不进这一层。
|
||||
*/
|
||||
public enum UnloadableReason {
|
||||
|
||||
/** 运输方式不是公路 */
|
||||
NOT_ROAD,
|
||||
|
||||
/** 业务状态不是待执行 */
|
||||
NOT_PENDING,
|
||||
|
||||
/** 已关联其它配载单 */
|
||||
ON_OTHER_MANIFEST
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否公路运输。未知或为空一律按非公路处理。
|
||||
*
|
||||
* @param transportType 运单的运输方式取值
|
||||
* @return true-公路
|
||||
*/
|
||||
public static boolean isRoadTransport(String transportType) {
|
||||
String value = text(transportType).toLowerCase(Locale.ROOT);
|
||||
if (value.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
if (ROAD_TRANSPORT_CODES.contains(value)) {
|
||||
return true;
|
||||
}
|
||||
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
|
||||
if (value.contains(keyword)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单此刻是否可被配载。
|
||||
* <p>
|
||||
* ownLoadingNo 是正在编辑或重新派单的配载单号:运单已挂在本单上不算冲突,
|
||||
* 否则编辑保存会把自己的子运单判为不可用。
|
||||
*
|
||||
* @param waybill 运单
|
||||
* @param ownLoadingNo 本配载单号;新建时传 null
|
||||
* @return true-可配载
|
||||
*/
|
||||
public static boolean isLoadable(Waybill waybill, String ownLoadingNo) {
|
||||
return unloadableReason(waybill, ownLoadingNo) == null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单此刻不可配载的原因。
|
||||
*
|
||||
* @param waybill 运单
|
||||
* @param ownLoadingNo 本配载单号;新建时传 null
|
||||
* @return 命中的第一条原因;可配载时返回 null
|
||||
*/
|
||||
public static UnloadableReason unloadableReason(Waybill waybill, String ownLoadingNo) {
|
||||
if (waybill == null || !isRoadTransport(waybill.getTransportType())) {
|
||||
return UnloadableReason.NOT_ROAD;
|
||||
}
|
||||
if (!LOADABLE_BUSINESS_STATUS.equals(text(waybill.getBusinessStatus()))) {
|
||||
return UnloadableReason.NOT_PENDING;
|
||||
}
|
||||
String loadingNo = text(waybill.getLoadingNo());
|
||||
if (loadingNo.isEmpty() || loadingNo.equals(text(ownLoadingNo))) {
|
||||
return null;
|
||||
}
|
||||
return UnloadableReason.ON_OTHER_MANIFEST;
|
||||
}
|
||||
|
||||
/**
|
||||
* 该运单是否已挂在本配载单上。
|
||||
* <p>
|
||||
* 本单既有子运单在先前提交时已过准入判据,重新提交/编辑时不再重复判:
|
||||
* 不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了,重新派单也无从下手。
|
||||
*
|
||||
* @param waybill 运单
|
||||
* @param ownLoadingNo 本配载单号;新建时传 null
|
||||
* @return true-已是本单子运单
|
||||
*/
|
||||
public static boolean isOwnChild(Waybill waybill, String ownLoadingNo) {
|
||||
if (waybill == null) {
|
||||
return false;
|
||||
}
|
||||
String own = text(ownLoadingNo);
|
||||
return !own.isEmpty() && own.equals(text(waybill.getLoadingNo()));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把整条可配载判据加到运单查询上:公路 + 待执行 + 未配载。
|
||||
*
|
||||
* @param wrapper 运单列表查询条件
|
||||
*/
|
||||
public static void applyLoadableConditions(LambdaQueryWrapper<Waybill> wrapper) {
|
||||
applyRoadTransportCondition(wrapper);
|
||||
wrapper.eq(Waybill::getBusinessStatus, LOADABLE_BUSINESS_STATUS);
|
||||
wrapper.and(nested -> nested.isNull(Waybill::getLoadingNo).or().eq(Waybill::getLoadingNo, ""));
|
||||
}
|
||||
|
||||
private static void applyRoadTransportCondition(LambdaQueryWrapper<Waybill> wrapper) {
|
||||
wrapper.and(nested -> {
|
||||
boolean started = false;
|
||||
for (String keyword : ROAD_TRANSPORT_KEYWORDS) {
|
||||
if (started) {
|
||||
nested.or();
|
||||
}
|
||||
nested.like(Waybill::getTransportType, keyword);
|
||||
started = true;
|
||||
}
|
||||
for (String code : ROAD_TRANSPORT_CODES) {
|
||||
nested.or().eq(Waybill::getTransportType, code);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static String text(String value) {
|
||||
return value == null ? "" : value.trim();
|
||||
}
|
||||
|
||||
}
|
||||
+6
-1
@@ -279,6 +279,10 @@ public final class OcrFieldSupport {
|
||||
Map<String, List<String>> aliases = new LinkedHashMap<>();
|
||||
aliases.put("realName", List.of("姓名"));
|
||||
aliases.put("idCardNo", List.of("公民身份号码", "公民身份证号码", "身份证号", "身份证号码"));
|
||||
// 身份证正面的「出生」是一整串日期(1990年1月1日),按 birthday 归一成 yyyy-MM-dd;
|
||||
// 住址是身份证上的登记住址,写进 driver.address
|
||||
aliases.put("birthday", List.of("出生", "出生年月", "出生日期"));
|
||||
aliases.put("address", List.of("住址", "地址"));
|
||||
aliases.put("idCardValidStart", List.of("签发日期", "有效起始日期", "有效期限起"));
|
||||
aliases.put("idCardValidEnd", List.of("失效日期", "有效期限", "有效期限止"));
|
||||
return aliases;
|
||||
@@ -319,7 +323,8 @@ public final class OcrFieldSupport {
|
||||
}
|
||||
|
||||
private static boolean isDateField(String key) {
|
||||
return key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
|
||||
// birthday 的语义是日期但拼写不以 Date 结尾,漏了它会把「1990年1月1日」原样下发
|
||||
return "birthday".equals(key) || key.endsWith("Date") || key.endsWith("ValidStart") || key.endsWith("ValidEnd");
|
||||
}
|
||||
|
||||
private static boolean isNumberField(String key) {
|
||||
|
||||
+36
-3
@@ -28,7 +28,9 @@ import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.constant.RoleConstant;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.system.cache.SysCache;
|
||||
import org.springblade.system.cache.UserCache;
|
||||
import org.springblade.system.pojo.entity.Dept;
|
||||
import org.springblade.system.pojo.entity.User;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
@@ -50,6 +52,8 @@ public final class TransportBusinessSupport {
|
||||
public static final String DATA_SOURCE_EXTERNAL = "外部系统";
|
||||
|
||||
private static final Pattern PHONE_PATTERN = Pattern.compile("^(1\\d{10}|0\\d{2,3}-?\\d{7,8})$");
|
||||
/** 用户档案「数据权限范围」的取值:全部组织,与组织树过滤口径一致 */
|
||||
private static final int DATA_SCOPE_ALL = 2;
|
||||
private static final BigDecimal MIN_LONGITUDE = new BigDecimal("-180");
|
||||
private static final BigDecimal MAX_LONGITUDE = new BigDecimal("180");
|
||||
private static final BigDecimal MIN_LATITUDE = new BigDecimal("-90");
|
||||
@@ -76,6 +80,26 @@ public final class TransportBusinessSupport {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否被授予跨组织数据权限:超级管理员,或用户档案「数据权限范围」为全部组织。
|
||||
* <p>
|
||||
* 「数据权限范围」在用户管理里配置,组织树已按它过滤(DeptServiceImpl 的组织树数据权限);
|
||||
* 运输业务的列表过滤与写入校验必须与组织树同一口径。
|
||||
*
|
||||
* @return true-不限组织
|
||||
*/
|
||||
public static boolean hasAllDeptScope() {
|
||||
if (isAdministrator()) {
|
||||
return true;
|
||||
}
|
||||
Long userId = AuthUtil.getUserId();
|
||||
if (Func.isEmpty(userId) || userId <= 0) {
|
||||
return false;
|
||||
}
|
||||
User user = UserCache.getUser(userId);
|
||||
return Objects.equals(user == null ? null : user.getDataScopeRange(), DATA_SCOPE_ALL);
|
||||
}
|
||||
|
||||
public static Long currentDeptId(String moduleName) {
|
||||
Long deptId = Func.firstLong(AuthUtil.getDeptId());
|
||||
if (Func.isEmpty(deptId) || deptId <= 0) {
|
||||
@@ -92,14 +116,22 @@ public final class TransportBusinessSupport {
|
||||
return dept;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验目标业务数据属于当前用户可操作的组织。
|
||||
* <p>
|
||||
* 超管与「数据权限范围=全部组织」的用户不限组织;其余用户仍严格要求与当前会话组织相等。
|
||||
*/
|
||||
public static void assertCurrentDept(Long deptId, String moduleName) {
|
||||
if (hasAllDeptScope()) {
|
||||
return;
|
||||
}
|
||||
if (!Objects.equals(deptId, currentDeptId(moduleName))) {
|
||||
throw new ServiceException("无权操作其他组织" + moduleName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateAllDept(Integer allDept, String moduleName) {
|
||||
if (Objects.equals(allDept, 1) && !isAdministrator()) {
|
||||
if (Objects.equals(allDept, 1) && !hasAllDeptScope()) {
|
||||
throw new ServiceException("无权查看全部组织" + moduleName);
|
||||
}
|
||||
}
|
||||
@@ -107,7 +139,8 @@ public final class TransportBusinessSupport {
|
||||
/**
|
||||
* 解析列表组织筛选 ID。
|
||||
* <p>
|
||||
* 普通用户 allDept!=1 时强制当前组织;超管默认不按组织过滤,仅在 allDept=1 且传入 deptId 时精确匹配该组织。
|
||||
* 普通用户 allDept!=1 时强制当前组织;超管与「全部组织」用户默认不按组织过滤,
|
||||
* 仅在 allDept=1 且传入 deptId 时精确匹配该组织。
|
||||
*
|
||||
* @param allDept 是否查看全部组织
|
||||
* @param deptId 筛选组织 ID
|
||||
@@ -116,7 +149,7 @@ public final class TransportBusinessSupport {
|
||||
*/
|
||||
public static List<Long> resolveDeptFilterIds(Integer allDept, Long deptId, String moduleName) {
|
||||
validateAllDept(allDept, moduleName);
|
||||
if (isAdministrator()) {
|
||||
if (hasAllDeptScope()) {
|
||||
if (Objects.equals(allDept, 1) && Func.isNotEmpty(deptId) && deptId > 0) {
|
||||
return Collections.singletonList(deptId);
|
||||
}
|
||||
|
||||
+302
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* BladeX Commercial License Agreement
|
||||
* Copyright (c) 2018-2099, https://bladex.cn. All rights reserved.
|
||||
* <p>
|
||||
* Use of this software is governed by the Commercial License Agreement
|
||||
* obtained after purchasing a license from BladeX.
|
||||
* <p>
|
||||
* 1. This software is for development use only under a valid license
|
||||
* from BladeX.
|
||||
* <p>
|
||||
* 2. Redistribution of this software's source code to any third party
|
||||
* without a commercial license is strictly prohibited.
|
||||
* <p>
|
||||
* 3. Licensees may copyright their own code but cannot use segments
|
||||
* from this software for such purposes. Copyright of this software
|
||||
* remains with BladeX.
|
||||
* <p>
|
||||
* Using this software signifies agreement to this License, and the software
|
||||
* must not be used for illegal purposes.
|
||||
* <p>
|
||||
* Author: Chill Zhuang (bladejava@qq.com)
|
||||
*/
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.core.tool.utils.Func;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* 运单货物明细的取法,只此一处。
|
||||
* <p>
|
||||
* 一张运单可以录多条货物,明细落在 {@code goods_json};主表的 {@code cargo_name} / {@code cargo_type} /
|
||||
* {@code quantity} 只是给列表与报表用的首行汇总(数量是各行合计),拿它当货物清单会把其余货物丢掉。
|
||||
* 所以「这张运单装了什么」一律以 goods_json 为准,主表字段只在 goods_json 缺失或解析不出任何货名时兜底。
|
||||
* <p>
|
||||
* 货名也可能是主表里一格多名的历史写法(顿号、逗号分隔),按同一口径摊平。
|
||||
*/
|
||||
public final class WaybillCargoSupport {
|
||||
|
||||
/** 计重时唯一允许跨货物相加的单位:其他单位只在「全部货物同单位」时才合计 */
|
||||
private static final String UNIT_TON = "吨";
|
||||
|
||||
/** 货物没有填名称时的展示名:宁可让司机看到「货物」,也不要出现空行 */
|
||||
private static final String FALLBACK_NAME = "货物";
|
||||
|
||||
/** 货名分隔符:历史数据里顿号、中英文逗号都出现过 */
|
||||
private static final String NAME_SEPARATOR_REGEX = "[、,,]";
|
||||
|
||||
/** 明细里可能承载数量的字段,按优先级取第一个有值的 */
|
||||
private static final String[] QUANTITY_KEYS = {"quantity", "cargoQuantity", "goodsQuantity"};
|
||||
|
||||
/** 明细里可能承载单位的字段,按优先级取第一个有值的 */
|
||||
private static final String[] UNIT_KEYS = {"quantityUnit", "goodsQuantityUnit", "unit"};
|
||||
|
||||
/** 明细里可能承载名称的字段,按优先级取第一个有值的 */
|
||||
private static final String[] NAME_KEYS = {"cargoName", "goodsName"};
|
||||
|
||||
/** 明细里可能承载类型的字段,按优先级取第一个有值的 */
|
||||
private static final String[] TYPE_KEYS = {"cargoType", "goodsType"};
|
||||
|
||||
private WaybillCargoSupport() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 一张运单的货物条目:goods_json 能解析出货物就用它,否则回退到主表的货物字段。
|
||||
* <p>
|
||||
* 每条明细至少出一条:货名、数量都为空的行也保留,让「这张票录了一条空货物」与「这条货物没填数量」可区分。
|
||||
*/
|
||||
public static List<Cargo> of(Waybill waybill) {
|
||||
if (waybill == null) {
|
||||
return List.of();
|
||||
}
|
||||
List<Cargo> detail = parseDetail(waybill.getGoodsJson());
|
||||
if (!detail.isEmpty()) {
|
||||
return detail;
|
||||
}
|
||||
return fallback(waybill);
|
||||
}
|
||||
|
||||
/**
|
||||
* 货名清单:同名的只留一次,保持录入顺序;档口与报表需要横向摊平时用它
|
||||
*/
|
||||
public static List<String> cargoNames(List<Cargo> cargos) {
|
||||
List<String> names = new ArrayList<>();
|
||||
for (Cargo cargo : cargos) {
|
||||
String name = Func.toStr(cargo.cargoName(), "").trim();
|
||||
if (Func.isNotEmpty(name) && !names.contains(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 goods_json 为货物条目;字段缺失、JSON 非法或解析不出任何条目都返回空集合,由调用方兜底
|
||||
*/
|
||||
private static List<Cargo> parseDetail(String goodsJson) {
|
||||
if (Func.isEmpty(goodsJson)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Map> rows;
|
||||
try {
|
||||
rows = JsonUtil.parseArray(goodsJson, Map.class);
|
||||
} catch (Exception exception) {
|
||||
// 历史脏数据:JSON 坏了就当没有明细,回退主表字段,不让列表与详情因一条脏数据打不开
|
||||
return List.of();
|
||||
}
|
||||
if (Func.isEmpty(rows)) {
|
||||
return List.of();
|
||||
}
|
||||
List<Cargo> cargos = new ArrayList<>(rows.size());
|
||||
for (Map row : rows) {
|
||||
if (row == null) {
|
||||
continue;
|
||||
}
|
||||
cargos.add(new Cargo(
|
||||
firstText(row, NAME_KEYS),
|
||||
firstText(row, TYPE_KEYS),
|
||||
quantityOf(row),
|
||||
firstText(row, UNIT_KEYS)));
|
||||
}
|
||||
return cargos;
|
||||
}
|
||||
|
||||
/**
|
||||
* 回退:主表货名按历史分隔符摊平,每一段配同一个数量与单位(主表数量是各行合计,摊到哪一段都不准,
|
||||
* 故只给一段配数量;多段说明主表数量不可拆分,宁可只展示货名也不重复同一份合计)
|
||||
*/
|
||||
private static List<Cargo> fallback(Waybill waybill) {
|
||||
List<String> names = splitCargoNames(waybill.getCargoName());
|
||||
if (names.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
List<Cargo> cargos = new ArrayList<>(names.size());
|
||||
boolean first = true;
|
||||
for (String name : names) {
|
||||
cargos.add(new Cargo(
|
||||
name,
|
||||
Func.toStr(waybill.getCargoType(), ""),
|
||||
first ? waybill.getQuantity() : null,
|
||||
first ? Func.toStr(waybill.getQuantityUnit(), "") : ""));
|
||||
first = false;
|
||||
}
|
||||
return cargos;
|
||||
}
|
||||
|
||||
/**
|
||||
* 计重合计:卡片上那个「货物重量」
|
||||
* <p>
|
||||
* 全部货物同单位时就是该单位合计;单位不一致时只合计「吨」项,其余单位不参与——
|
||||
* 「100吨 + 20堆」报 100 吨,「20堆 + 50方」没有吨项就不出合计。
|
||||
* 有货物缺数量时该单位合计不完整,宁可不报也不报一个偏小的数。
|
||||
*
|
||||
* @return 有可用合计时返回数量与单位,否则 null
|
||||
*/
|
||||
public static Quantity weighing(List<Cargo> cargos) {
|
||||
Map<String, BigDecimal> totals = new LinkedHashMap<>();
|
||||
Set<String> incomplete = new HashSet<>();
|
||||
for (Cargo cargo : cargos) {
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
if (Func.isEmpty(unit)) {
|
||||
continue;
|
||||
}
|
||||
if (cargo.quantity() == null) {
|
||||
incomplete.add(unit);
|
||||
continue;
|
||||
}
|
||||
totals.merge(unit, cargo.quantity(), BigDecimal::add);
|
||||
}
|
||||
if (totals.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
String unit;
|
||||
if (totals.size() == 1) {
|
||||
unit = totals.keySet().iterator().next();
|
||||
} else {
|
||||
unit = totals.containsKey(UNIT_TON) ? UNIT_TON : null;
|
||||
}
|
||||
if (unit == null || incomplete.contains(unit)) {
|
||||
return null;
|
||||
}
|
||||
return new Quantity(totals.get(unit), unit);
|
||||
}
|
||||
|
||||
/** 计重合计的数量与单位 */
|
||||
public record Quantity(BigDecimal value, String unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 货物明细行:一条货物一行,同名同单位的合并数量,按录入顺序
|
||||
* <p>
|
||||
* 与 {@link #cargoSummary(List)} 的合并口径一致,但数量与单位保持分开,供详情页逐行展示。
|
||||
* 数量为空的货物只留名称,不按 0 参与合并。
|
||||
*/
|
||||
public static List<Line> lines(List<Cargo> cargos) {
|
||||
Map<String, Integer> index = new LinkedHashMap<>();
|
||||
List<Line> lines = new ArrayList<>();
|
||||
for (Cargo cargo : cargos) {
|
||||
String name = cargo.displayName();
|
||||
String unit = Func.toStr(cargo.quantityUnit(), "");
|
||||
String key = name + '\u0000' + unit;
|
||||
Integer at = index.get(key);
|
||||
if (at == null) {
|
||||
index.put(key, lines.size());
|
||||
lines.add(new Line(name, cargo.quantity(), unit));
|
||||
continue;
|
||||
}
|
||||
Line merged = lines.get(at);
|
||||
if (cargo.quantity() == null) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal quantity = merged.quantity() == null
|
||||
? cargo.quantity()
|
||||
: merged.quantity().add(cargo.quantity());
|
||||
lines.set(at, new Line(name, quantity, unit));
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
/** 一条货物明细:数量可能为空(录入时只填了名称) */
|
||||
public record Line(String name, BigDecimal quantity, String unit) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 主表货名按顿号、逗号摊平;空格与空段去掉,全空时给空集合
|
||||
*/
|
||||
public static List<String> splitCargoNames(String cargoName) {
|
||||
if (Func.isEmpty(cargoName)) {
|
||||
return List.of();
|
||||
}
|
||||
List<String> names = new ArrayList<>();
|
||||
for (String part : cargoName.split(NAME_SEPARATOR_REGEX)) {
|
||||
String name = part.trim();
|
||||
if (Func.isNotEmpty(name)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
private static String firstText(Map row, String... keys) {
|
||||
for (String key : keys) {
|
||||
Object value = row.get(key);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (Func.isNotEmpty(text)) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
private static BigDecimal quantityOf(Map row) {
|
||||
for (String key : QUANTITY_KEYS) {
|
||||
Object value = row.get(key);
|
||||
if (value == null) {
|
||||
continue;
|
||||
}
|
||||
String text = String.valueOf(value).trim();
|
||||
if (Func.isEmpty(text)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
BigDecimal quantity = new BigDecimal(text);
|
||||
// 录入端用 -1 表示「未填」,落到汇总里会把合计算成负数
|
||||
if (quantity.compareTo(BigDecimal.ONE.negate()) == 0) {
|
||||
continue;
|
||||
}
|
||||
return quantity;
|
||||
} catch (NumberFormatException exception) {
|
||||
// 这一字段不是数字:继续试下一个候选字段
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单上的一条货物
|
||||
* <p>
|
||||
* 数量与单位可能为空:录货物时允许只填名称。展示端要把「没填数量」与「数量为 0」区分开。
|
||||
*/
|
||||
public record Cargo(String cargoName, String cargoType, BigDecimal quantity, String quantityUnit) {
|
||||
|
||||
/**
|
||||
* 展示用货名:空名称回退成「货物」
|
||||
*/
|
||||
public String displayName() {
|
||||
return Func.toStrWithEmpty(cargoName, FALLBACK_NAME);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
+135
-4
@@ -38,6 +38,7 @@ import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -132,6 +133,81 @@ public final class WaybillProcessSupport {
|
||||
return ACCEPT_REJECTED.equalsIgnoreCase(stringVal(driverAcceptStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单已结束:已完成或已取消。此后不再接受司机打卡。
|
||||
* <p>
|
||||
* 只认这两个状态,大小写不敏感。草稿、待执行、待调度、调度中的运单司机仍要能打卡,
|
||||
* 所以不能按 {@link #isTerminalBusinessStatus} 的「终态或调度中间态」口径判定。
|
||||
*/
|
||||
public static boolean isPunchEnded(String businessStatus) {
|
||||
String status = stringVal(businessStatus);
|
||||
return "completed".equalsIgnoreCase(status) || "cancelled".equalsIgnoreCase(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* 结束态下的打卡拒绝文案;非结束态返回 null。
|
||||
*/
|
||||
public static String punchEndedReason(String businessStatus) {
|
||||
String status = stringVal(businessStatus);
|
||||
if ("completed".equalsIgnoreCase(status)) {
|
||||
return "运单已完成";
|
||||
}
|
||||
if ("cancelled".equalsIgnoreCase(status)) {
|
||||
return "运单已取消";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 运单已开始运输:只有进行中可以打卡(在途与普通节点同一判据)。
|
||||
* <p>
|
||||
* 草稿、待执行(待接单)、待调度、调度中都还没上路,任何打卡都没有承载物。
|
||||
* 判据是校正后落库的状态:无需接单的待执行运单在 {@link #normalizeBusinessStatus}
|
||||
* 里已被提升为进行中,未提升的待执行就是真的还没接单。
|
||||
*/
|
||||
public static boolean isTransitPunchStarted(String businessStatus) {
|
||||
return STATUS_RUNNING.equalsIgnoreCase(stringVal(businessStatus));
|
||||
}
|
||||
|
||||
/**
|
||||
* 未开始运输时的打卡拒绝文案;进行中与已结束返回 null(结束文案由 {@link #punchEndedReason} 给)。
|
||||
*/
|
||||
public static String punchNotStartedReason(String businessStatus) {
|
||||
if (isTransitPunchStarted(businessStatus) || isPunchEnded(businessStatus)) {
|
||||
return null;
|
||||
}
|
||||
return "运单尚未开始运输,暂不可打卡";
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定自然日是否已打过在途卡。
|
||||
* <p>
|
||||
* 与运单状态无关,只回答「这一天打过没有」。{@link TransitCheckinDecision#doneToday()}
|
||||
* 只在进行中分支计算,状态一非进行中就回到 false,两者不可互相替代。
|
||||
*/
|
||||
public static boolean isPunchedOn(Date lastPunchAt, LocalDateTime now) {
|
||||
LocalDate day = (now == null ? LocalDateTime.now() : now).toLocalDate();
|
||||
LocalDate last = toLocalDate(lastPunchAt);
|
||||
return last != null && last.equals(day);
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报异常前的运单状态门禁原因;可上报时返回 null。
|
||||
* <p>
|
||||
* 与打卡共用「已开始运输」判据,但文案说的是能不能报,不是能不能打。
|
||||
* 结束优先于未开始:已完成/已取消的运单先说结束,不再提「等你上路」。
|
||||
*/
|
||||
public static String reportBlockedReason(String businessStatus) {
|
||||
String endedReason = punchEndedReason(businessStatus);
|
||||
if (endedReason != null) {
|
||||
return endedReason + ",不能上报异常";
|
||||
}
|
||||
if (!isTransitPunchStarted(businessStatus)) {
|
||||
return "运单尚未开始运输,暂不可上报异常";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static boolean isTerminalBusinessStatus(String businessStatus) {
|
||||
return "draft".equals(businessStatus)
|
||||
|| "completed".equals(businessStatus)
|
||||
@@ -197,15 +273,20 @@ public final class WaybillProcessSupport {
|
||||
int frequencyDays = parsePositiveInt(transit.get("frequencyDays"), 1);
|
||||
String timeStart = normalizeHm(stringVal(transit.get("timeStart")), "00:00");
|
||||
String timeEnd = normalizeHm(stringVal(transit.get("timeEnd")), "23:59");
|
||||
// punch=是即展示卡片;非进行中仅不可作为「今日待打」
|
||||
if (!STATUS_RUNNING.equals(businessStatus)) {
|
||||
return new TransitCheckinDecision(true, true, false, false, frequencyDays, timeStart, timeEnd);
|
||||
// 结束不可逆,没有解除途径:卡片仍下发,但不可提交,也不算今日待打
|
||||
if (isPunchEnded(businessStatus)) {
|
||||
return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd);
|
||||
}
|
||||
// punch=是即展示卡片;未开始运输(草稿/待执行/待调度/调度中)的卡片仍下发,
|
||||
// 但不可提交——打卡的前提是运单已上路(判据同时用于节点打卡)。
|
||||
if (!isTransitPunchStarted(businessStatus)) {
|
||||
return new TransitCheckinDecision(false, true, false, false, frequencyDays, timeStart, timeEnd);
|
||||
}
|
||||
|
||||
LocalDateTime current = now == null ? LocalDateTime.now() : now;
|
||||
LocalDate today = current.toLocalDate();
|
||||
LocalDate lastDate = toLocalDate(lastPunchAt);
|
||||
boolean doneToday = lastDate != null && lastDate.equals(today);
|
||||
boolean doneToday = isPunchedOn(lastPunchAt, current);
|
||||
boolean dueByFrequency;
|
||||
if (lastDate == null) {
|
||||
dueByFrequency = true;
|
||||
@@ -419,6 +500,28 @@ public final class WaybillProcessSupport {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* 顺序门禁点名的那一个前置节点名称:取**最先未完成**的。
|
||||
* <p>
|
||||
* 它才是司机现在能去打卡的那一步——后面那些未完成的前置节点自己也还打不了,
|
||||
* 一起列出来既长,又不是一个可执行的下一步。
|
||||
*
|
||||
* @param processJson 过程配置
|
||||
* @param nodeKeys 未完成的前置节点 key,即 {@link #unfinishedPrerequisiteKeys} 的返回
|
||||
*/
|
||||
public static String firstUnfinishedPrerequisiteName(String processJson, Collection<String> nodeKeys) {
|
||||
if (nodeKeys == null || nodeKeys.isEmpty()) {
|
||||
return "";
|
||||
}
|
||||
for (Map<String, Object> node : listDriverPunchNodes(processJson)) {
|
||||
String key = nodeKey(node);
|
||||
if (nodeKeys.contains(key)) {
|
||||
return nodeName(node);
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/**
|
||||
* 两个节点编码是否指同一个节点:两侧去空白、忽略大小写。
|
||||
* <p>
|
||||
@@ -637,6 +740,34 @@ public final class WaybillProcessSupport {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 还没打卡的司机打卡节点名称(在途除外):回单由业务员确认前,这些节点必须都打过卡,否则
|
||||
* 「确认回单」就成了绕过打卡门禁的通道——完成接口本身不要求节点打卡。
|
||||
* <p>
|
||||
* 在途是频次打卡节点、不是必经的一步,故不参与;空列表表示该打卡的都打完了。
|
||||
*
|
||||
* @param punchedKeys 已打卡的节点编码与名称,**小写**(库里的 node_code / node_name 都可能被用来对节点)
|
||||
*/
|
||||
public static List<String> missingPunchNodeNames(String processJson, Collection<String> punchedKeys) {
|
||||
List<Map<String, Object>> punchConfigs = listDriverPunchNodes(processJson);
|
||||
if (punchConfigs.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
Collection<String> punched = punchedKeys == null ? Collections.emptySet() : punchedKeys;
|
||||
List<String> missing = new ArrayList<>();
|
||||
for (Map<String, Object> node : punchConfigs) {
|
||||
if (isTransitNode(node)) {
|
||||
continue;
|
||||
}
|
||||
String code = nodeKey(node).toLowerCase(Locale.ROOT);
|
||||
String name = nodeName(node).toLowerCase(Locale.ROOT);
|
||||
if (!punched.contains(code) && !punched.contains(name)) {
|
||||
missing.add(nodeName(node));
|
||||
}
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
private static boolean isTransitNode(Map<String, Object> node) {
|
||||
String key = stringVal(node.get("key"));
|
||||
String name = stringVal(node.get("name"));
|
||||
|
||||
+7
-2
@@ -70,10 +70,15 @@ public class ReceiptFlowWrapper extends BaseEntityWrapper<KingdeeReceiptFlow, Re
|
||||
case "failed" -> "处理失败";
|
||||
default -> "未推送";
|
||||
});
|
||||
// 剩余可认领金额 = 公告未认领金额 − 累计认领金额(认领上限为公告口径)
|
||||
// 剩余可认领金额 = 可认领上限 − 累计认领金额(上限取公告未认领与收款金额较小值)
|
||||
BigDecimal unclaimedAmount = money(entity.getUnclaimedAmount());
|
||||
BigDecimal receiptAmount = money(entity.getReceiptAmount());
|
||||
BigDecimal claimCeiling = unclaimedAmount;
|
||||
if (receiptAmount.compareTo(BigDecimal.ZERO) > 0 && unclaimedAmount.compareTo(BigDecimal.ZERO) > 0) {
|
||||
claimCeiling = unclaimedAmount.min(receiptAmount);
|
||||
}
|
||||
BigDecimal claimedAmount = money(entity.getClaimedAmount());
|
||||
vo.setRemainingAmount(unclaimedAmount.subtract(claimedAmount).max(BigDecimal.ZERO));
|
||||
vo.setRemainingAmount(claimCeiling.subtract(claimedAmount).max(BigDecimal.ZERO));
|
||||
vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser()));
|
||||
vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser()));
|
||||
return vo;
|
||||
|
||||
+3
-1
@@ -28,6 +28,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
@@ -100,7 +101,8 @@ class DriverNodePunchPhotoTest {
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
exceptionDisposalMapper,
|
||||
mock(ITripQueryService.class));
|
||||
|
||||
when(driverService.getOne(any())).thenReturn(driver());
|
||||
when(waybillService.getOne(any())).thenReturn(waybill());
|
||||
|
||||
+163
@@ -0,0 +1,163 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillEnroutePunch;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.pojo.vo.DriverPunchNodeVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 末位打卡卡上的「完成运单」标记:打这一张卡这张运单就完成了,客户端据此把按钮文案从「提交打卡」改成「完成运单」。
|
||||
* <p>
|
||||
* 判据必须与 {@code submitNode} 的自动完成同一份——末位可打卡节点 + 到货/卸货/签收 + 回单不要求业务员确认;
|
||||
* 两处不同源就会出现「按钮写着完成运单、打下去这张运单没完成」。接单与回单不是司机打卡节点,不参与排位。
|
||||
*/
|
||||
class DriverPunchFinishFlagTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5601L;
|
||||
|
||||
private static final String NO_RETURN_JSON = """
|
||||
[{"key":"accept","name":"接单","type":"confirm","enabled":true,"punch":true},
|
||||
{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
private static final String RETURN_NO_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}]
|
||||
""";
|
||||
|
||||
private static final String RETURN_NEED_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
/** 末位可打卡节点是「到场」:不在到货/卸货/签收之列,打完不算完成 */
|
||||
private static final String LAST_NODE_NOT_DELIVERY_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"arrive_scene","name":"到场","type":"location","enabled":true,"punch":true}]
|
||||
""";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillEnroutePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
WaybillEnroutePunchMapper enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
|
||||
ExceptionDisposalMapper exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
mock(IDriverService.class),
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
enroutePunchMapper,
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper,
|
||||
mock(ITripQueryService.class));
|
||||
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(enroutePunchMapper.selectOne(any())).thenReturn(null);
|
||||
when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(exceptionDisposalMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("末位是签收且没有回单节点:只有它带完成标记,装货与接单都不带")
|
||||
void lastDeliveryNodeCarriesFinishFlag() {
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes(NO_RETURN_JSON);
|
||||
|
||||
assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()), "签收是末位到货类节点,打完即完成");
|
||||
assertFalse(Boolean.TRUE.equals(nodes.get("load").getFinishOnPunch()), "装货不是末位");
|
||||
assertFalse(nodes.containsKey("accept"), "接单不是司机打卡节点,不出现在卡片里");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单要求业务员确认:末位卡不带完成标记(司机打完不置完成,等业务员确认)")
|
||||
void returnNeedingConfirmationTakesTheFlagAway() {
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes(RETURN_NEED_CONFIRM_JSON);
|
||||
|
||||
assertFalse(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()),
|
||||
"回单要业务员确认时,签收打完不算完成——按钮文案也不能写成完成运单");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单不要求业务员确认:末位卡仍带完成标记")
|
||||
void returnWithoutConfirmationKeepsTheFlag() {
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes(RETURN_NO_CONFIRM_JSON);
|
||||
|
||||
assertTrue(Boolean.TRUE.equals(nodes.get("sign").getFinishOnPunch()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("末位节点不是到货/卸货/签收:不带完成标记")
|
||||
void lastNodeOutsideDeliveryDoesNotCarryFinishFlag() {
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes(LAST_NODE_NOT_DELIVERY_JSON);
|
||||
|
||||
assertFalse(Boolean.TRUE.equals(nodes.get("arrive_scene").getFinishOnPunch()),
|
||||
"到场不在到货/卸货/签收之列,打完不置完成");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
/** 用给定过程配置快照取这趟车下发的节点卡,按 key 索引 */
|
||||
private Map<String, DriverPunchNodeVO> punchNodes(String processJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610180001");
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setIsDeleted(0);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
return service.detailPunchSnapshot(WAYBILL_ID).getPunchNodes().stream()
|
||||
.collect(Collectors.toMap(DriverPunchNodeVO::getKey, Function.identity()));
|
||||
}
|
||||
|
||||
}
|
||||
+42
-1
@@ -23,6 +23,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -92,7 +93,8 @@ class DriverPunchNodeCardTest {
|
||||
mock(CertificationGate.class),
|
||||
enroutePunchMapper,
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
exceptionDisposalMapper,
|
||||
mock(ITripQueryService.class));
|
||||
|
||||
Waybill waybill = waybill();
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
@@ -182,6 +184,45 @@ class DriverPunchNodeCardTest {
|
||||
assertTrue(json.contains("\"9007199254740993\""), json);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单已完成:在途卡不可展开,已打卡的普通节点仍可展开回看")
|
||||
void completedWaybillTransitNodeIsNotExpandable() {
|
||||
Waybill completed = waybill();
|
||||
completed.setBusinessStatus("completed");
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(completed);
|
||||
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes();
|
||||
|
||||
DriverPunchNodeVO transit = nodes.get("transit");
|
||||
assertTrue(Boolean.TRUE.equals(transit.getEnded()), "已完成运单的在途卡要进结束态");
|
||||
assertFalse(Boolean.TRUE.equals(transit.getExpandable()), "已完成运单的在途卡不允许展开");
|
||||
assertFalse(Boolean.TRUE.equals(transit.getActionable()));
|
||||
assertTrue(Boolean.TRUE.equals(nodes.get("load").getExpandable()),
|
||||
"已打卡的普通节点仍可展开回看时间与照片");
|
||||
assertFalse(Boolean.TRUE.equals(nodes.get("sign").getExpandable()),
|
||||
"未打卡节点展开没有内容,也不可展开");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单未开始运输:未打卡的在途与普通节点都不可打、不可展开,文案说明是状态原因")
|
||||
void notStartedWaybillLocksEveryUnpunchedCard() {
|
||||
Waybill dispatching = waybill();
|
||||
dispatching.setBusinessStatus("dispatching");
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(dispatching);
|
||||
|
||||
Map<String, DriverPunchNodeVO> nodes = punchNodes();
|
||||
|
||||
assertTrue(Boolean.TRUE.equals(nodes.get("load").getExpandable()),
|
||||
"已打卡的装货仍可展开回看,未开始运输不影响历史记录");
|
||||
for (String key : List.of("sign", "transit")) {
|
||||
DriverPunchNodeVO node = nodes.get(key);
|
||||
assertFalse(Boolean.TRUE.equals(node.getDone()), key + " 未打卡");
|
||||
assertFalse(Boolean.TRUE.equals(node.getActionable()), key + " 未开始运输不可打");
|
||||
assertEquals("运单尚未开始运输,暂不可打卡", node.getLockReason(), key);
|
||||
assertFalse(Boolean.TRUE.equals(node.getExpandable()), key + " 不可展开");
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubUnresolved(ExceptionDisposal... disposals) {
|
||||
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.BladeUser;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 运次级接单与拒单:一次写完整趟车、写不上已完成/已取消的子单、司机字段一概不动。
|
||||
* <p>
|
||||
* 运次的解析与收口在运次读服务里,这里以 mock 给出「该司机可见的运次」,只验运次级校验与写入口径。
|
||||
*/
|
||||
class DriverTripAcceptRejectTest {
|
||||
|
||||
private static final Long USER_ID = 7401L;
|
||||
private static final Long DRIVER_ID = 9001L;
|
||||
private static final String PHONE = "17587421208";
|
||||
private static final String TRIP_ID = "2101146192739459074";
|
||||
private static final Long PENDING_ID = 2101144166395699202L;
|
||||
private static final Long SECOND_ID = 2101145869819994113L;
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private IDriverService driverService;
|
||||
private CertificationGate certificationGate;
|
||||
private ITripQueryService tripQueryService;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
certificationGate = mock(CertificationGate.class);
|
||||
tripQueryService = mock(ITripQueryService.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
driverService,
|
||||
mock(IMpUserClient.class),
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
certificationGate,
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
mock(WaybillNodePunchMapper.class),
|
||||
mock(ExceptionDisposalMapper.class),
|
||||
tripQueryService);
|
||||
lenient().when(driverService.getOne(any())).thenReturn(driver());
|
||||
lenient().when(certificationGate.isDriverAvailable(any())).thenReturn(true);
|
||||
lenient().when(waybillService.update(any())).thenReturn(true);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次接单:一次把两张子运单都写成已接单 + 运输中 + 同一接单时刻,司机字段一个不碰")
|
||||
void tripAcceptWritesWholeTrip() {
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"),
|
||||
requireAcceptChild(SECOND_ID, "running", null));
|
||||
|
||||
boolean result = runAsDriver(() -> service.tripAccept(TRIP_ID));
|
||||
|
||||
assertTrue(result);
|
||||
assertEquals(List.of(
|
||||
"driver_accept_status",
|
||||
"driver_accept_time",
|
||||
"driver_accept_driver_id",
|
||||
"driver_reject_time",
|
||||
"driver_reject_reason",
|
||||
"business_status",
|
||||
"update_time",
|
||||
"update_user"), updatedColumns(), "只写接单相关的列与刷新痕迹,司机字段一概不动");
|
||||
assertTrue(capturedUpdateParams().containsAll(List.of(PENDING_ID, SECOND_ID, DRIVER_ID)),
|
||||
"两张子运单与接单司机一起下发:" + capturedUpdateParams());
|
||||
assertTrue(capturedWhereSql().contains("(id IN ("), capturedWhereSql());
|
||||
assertEquals(TRIP_ID, capturedQuery().getTripId());
|
||||
assertEquals(PHONE, capturedQuery().getDriverPhone(), "归属仍按司机手机号收口");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次接单的更新时间与接单时刻是同一个 now、更新人是当前操作人")
|
||||
void tripAcceptRefreshesUpdateTrace() {
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
|
||||
|
||||
runAsDriver(() -> service.tripAccept(TRIP_ID));
|
||||
|
||||
List<Date> dates = capturedUpdateDates();
|
||||
assertEquals(2, dates.size(), "接单时刻与更新时间各占一列");
|
||||
assertSame(dates.get(0), dates.get(1), "update_time 与接单时刻必须是同一个 now");
|
||||
assertTrue(capturedUpdateParams().contains(USER_ID), "update_user 就是当前操作人:" + capturedUpdateParams());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有登录态时不写 update_user,但 update_time 仍随操作刷新")
|
||||
void tripAcceptWithoutLoginSkipsUpdateUser() {
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
|
||||
|
||||
runWithoutLoginUser(() -> service.tripAccept(TRIP_ID));
|
||||
|
||||
assertEquals(List.of(
|
||||
"driver_accept_status",
|
||||
"driver_accept_time",
|
||||
"driver_accept_driver_id",
|
||||
"driver_reject_time",
|
||||
"driver_reject_reason",
|
||||
"business_status",
|
||||
"update_time"), updatedColumns(), "无登录态时与框架填充口径一致:只刷 update_time");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次拒单的更新时间与拒单时刻是同一个 now、更新人是当前操作人")
|
||||
void tripRejectRefreshesUpdateTrace() {
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
|
||||
|
||||
runAsDriver(() -> service.tripReject(TRIP_ID, "不要了"));
|
||||
|
||||
List<Date> dates = capturedUpdateDates();
|
||||
assertEquals(2, dates.size(), "拒单时刻与更新时间各占一列");
|
||||
assertSame(dates.get(0), dates.get(1), "update_time 与拒单时刻必须是同一个 now");
|
||||
assertTrue(capturedUpdateParams().contains(USER_ID), "update_user 就是当前操作人:" + capturedUpdateParams());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("任一子单已接单 → 整单已接单,不再重复写")
|
||||
void tripAcceptRefusesSecondCall() {
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "running", "accepted"),
|
||||
requireAcceptChild(SECOND_ID, "running", "accepted"));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripAccept(TRIP_ID)));
|
||||
|
||||
assertEquals("该运单已接单", exception.getMessage());
|
||||
verify(waybillService, never()).update(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("无接单节点的运次不进接单流程:给出「该运单无需确认接单」")
|
||||
void tripAcceptWithoutAcceptNodeIsRefused() {
|
||||
TripVO.TripWaybillVO first = child(PENDING_ID, "running", null, false);
|
||||
TripVO.TripWaybillVO second = child(SECOND_ID, "running", null, false);
|
||||
stubTrip(first, second);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripAccept(TRIP_ID)));
|
||||
|
||||
assertEquals("该运单无需确认接单", exception.getMessage());
|
||||
verify(waybillService, never()).update(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("接单只写没走完的子单,已完成的子单不回到运输中")
|
||||
void tripAcceptSkipsTerminalChildren() {
|
||||
// 已完成的子单(自动完成,没有接单状态)不进写入集合;另一张还在途,整趟车仍可接单
|
||||
stubTrip(new TripVO.TripWaybillVO[]{
|
||||
child(PENDING_ID, "completed", null, true),
|
||||
requireAcceptChild(SECOND_ID, "running", null)});
|
||||
|
||||
runAsDriver(() -> service.tripAccept(TRIP_ID));
|
||||
|
||||
assertTrue(capturedUpdateParams().contains(SECOND_ID));
|
||||
assertFalse(capturedUpdateParams().contains(PENDING_ID), "已完成的子单不进写入集合:" + capturedUpdateParams());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单全走完的运次不允许接单")
|
||||
void tripAcceptAllTerminalIsRefused() {
|
||||
stubTrip(new TripVO.TripWaybillVO[]{
|
||||
child(PENDING_ID, "completed", "accepted", true),
|
||||
child(SECOND_ID, "cancelled", null, true)});
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripAccept(TRIP_ID)));
|
||||
|
||||
assertEquals("当前运单状态不允许接单", exception.getMessage());
|
||||
verify(waybillService, never()).update(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("司机与车辆的认证门槛按整车只校验一次")
|
||||
void tripAcceptChecksCertificationOnce() {
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"),
|
||||
requireAcceptChild(SECOND_ID, "pending", "pending"));
|
||||
when(certificationGate.isDriverAvailable(any())).thenReturn(false);
|
||||
when(certificationGate.driverUnavailableReason()).thenReturn("司机认证未通过");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripAccept(TRIP_ID)));
|
||||
|
||||
assertEquals("司机认证未通过", exception.getMessage());
|
||||
verify(certificationGate, times(1)).isDriverAvailable(any());
|
||||
verify(waybillService, never()).update(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次拒单:两张子运单写同一个原因、同一拒单时刻,接单记录清空,司机字段不动")
|
||||
void tripRejectWritesWholeTrip() {
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"),
|
||||
requireAcceptChild(SECOND_ID, "running", null));
|
||||
|
||||
boolean result = runAsDriver(() -> service.tripReject(TRIP_ID, " 车坏了 "));
|
||||
|
||||
assertTrue(result);
|
||||
assertEquals(List.of(
|
||||
"driver_accept_status",
|
||||
"driver_accept_time",
|
||||
"driver_accept_driver_id",
|
||||
"driver_reject_time",
|
||||
"driver_reject_reason",
|
||||
"business_status",
|
||||
"update_time",
|
||||
"update_user"), updatedColumns(), "只写拒单相关的列与刷新痕迹,司机字段一概不动");
|
||||
assertTrue(capturedUpdateParams().contains("车坏了"), "拒单原因原样下发(去首尾空格):" + capturedUpdateParams());
|
||||
assertTrue(capturedUpdateParams().containsAll(List.of(PENDING_ID, SECOND_ID)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拒单原因可空,超 200 字直接拒绝且不写库")
|
||||
void tripRejectReasonValidation() {
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
|
||||
|
||||
assertTrue(runAsDriver(() -> service.tripReject(TRIP_ID, null)));
|
||||
assertTrue(capturedUpdateParams().stream().noneMatch(""::equals), "空原因按 null 下发");
|
||||
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "pending", "pending"));
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripReject(TRIP_ID, "x".repeat(201))));
|
||||
|
||||
assertEquals("拒绝原因不能超过200字", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("任一子单已接单 → 整单不能拒单")
|
||||
void tripRejectRefusesAcceptedTrip() {
|
||||
stubTrip(requireAcceptChild(PENDING_ID, "running", "accepted"),
|
||||
requireAcceptChild(SECOND_ID, "pending", "pending"));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripReject(TRIP_ID, "不要了")));
|
||||
|
||||
assertEquals("该运单已接单,无法拒绝", exception.getMessage());
|
||||
verify(waybillService, never()).update(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单全走完的运次不允许拒单")
|
||||
void tripRejectAllTerminalIsRefused() {
|
||||
stubTrip(new TripVO.TripWaybillVO[]{
|
||||
child(PENDING_ID, "completed", null, true),
|
||||
child(SECOND_ID, "completed", null, true)});
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripReject(TRIP_ID, "不要了")));
|
||||
|
||||
assertEquals("当前运单状态不允许拒绝接单", exception.getMessage());
|
||||
verify(waybillService, never()).update(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未配载运单只写它自己")
|
||||
void unloadedTripWritesItselfOnly() {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(String.valueOf(PENDING_ID));
|
||||
trip.setWaybills(List.of(requireAcceptChild(PENDING_ID, "pending", "pending")));
|
||||
when(tripQueryService.detail(any())).thenReturn(trip);
|
||||
|
||||
assertTrue(runAsDriver(() -> service.tripAccept(String.valueOf(PENDING_ID))));
|
||||
|
||||
assertTrue(capturedUpdateParams().contains(PENDING_ID));
|
||||
assertEquals(1, capturedUpdateParams().stream().filter(PENDING_ID::equals).count());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("越权与不存在的运次一律被拒,且不写库")
|
||||
void foreignTripIsRefused() {
|
||||
when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作"));
|
||||
|
||||
assertEquals("运次不存在或无权操作", assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripAccept(TRIP_ID))).getMessage());
|
||||
assertEquals("运次不存在或无权操作", assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripReject(TRIP_ID, null))).getMessage());
|
||||
verify(waybillService, never()).update(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次里一张可见子单都没有时不给出写入入口")
|
||||
void emptyChildrenAreRefused() {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(TRIP_ID);
|
||||
when(tripQueryService.detail(any())).thenReturn(trip);
|
||||
|
||||
assertEquals("运次不存在或无权操作", assertThrows(ServiceException.class,
|
||||
() -> runAsDriver(() -> service.tripAccept(TRIP_ID))).getMessage());
|
||||
verify(waybillService, never()).update(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private <T> T runAsDriver(Supplier<T> action) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
authUtil.when(AuthUtil::getUser).thenReturn(bladeUser());
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
|
||||
/** 框架填充更新人看的是 getUserId() 背后的登录用户:为空就不填,这里把这一口径钉住 */
|
||||
private <T> T runWithoutLoginUser(Supplier<T> action) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
authUtil.when(AuthUtil::getUser).thenReturn(null);
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
|
||||
private BladeUser bladeUser() {
|
||||
BladeUser user = new BladeUser();
|
||||
user.setUserId(USER_ID);
|
||||
user.setUserName("黄宇富");
|
||||
return user;
|
||||
}
|
||||
|
||||
private void stubTrip(TripVO.TripWaybillVO... children) {
|
||||
stubTrip(new TripVO.TripWaybillVO[0], children);
|
||||
}
|
||||
|
||||
private void stubTrip(TripVO.TripWaybillVO[] leading, TripVO.TripWaybillVO... children) {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(TRIP_ID);
|
||||
trip.setDriverPhone(PHONE);
|
||||
List<TripVO.TripWaybillVO> all = new java.util.ArrayList<>(List.of(leading));
|
||||
all.addAll(List.of(children));
|
||||
trip.setWaybills(all);
|
||||
when(tripQueryService.detail(any())).thenReturn(trip);
|
||||
}
|
||||
|
||||
private TripVO.TripWaybillVO requireAcceptChild(Long id, String businessStatus, String acceptStatus) {
|
||||
return child(id, businessStatus, acceptStatus, true);
|
||||
}
|
||||
|
||||
private TripVO.TripWaybillVO child(Long id, String businessStatus, String acceptStatus, boolean requireAccept) {
|
||||
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
|
||||
child.setId(String.valueOf(id));
|
||||
child.setWaybillNo("YD-" + id);
|
||||
child.setBusinessStatus(businessStatus);
|
||||
child.setAcceptStatus(acceptStatus);
|
||||
child.setRequireAccept(requireAccept);
|
||||
return child;
|
||||
}
|
||||
|
||||
private Driver driver() {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(DRIVER_ID);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDriverName("黄宇富");
|
||||
driver.setDrivingVehicle("桂A32523");
|
||||
driver.setMobile(PHONE);
|
||||
return driver;
|
||||
}
|
||||
|
||||
private TripQueryVO capturedQuery() {
|
||||
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
|
||||
verify(tripQueryService, org.mockito.Mockito.atLeastOnce()).detail(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private LambdaUpdateWrapper<Waybill> capturedUpdate() {
|
||||
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(waybillService, org.mockito.Mockito.atLeastOnce()).update(captor.capture());
|
||||
return (LambdaUpdateWrapper<Waybill>) captor.getValue();
|
||||
}
|
||||
|
||||
private String capturedWhereSql() {
|
||||
return capturedUpdate().getTargetSql();
|
||||
}
|
||||
|
||||
/** SET 子句里真正被写的列名:司机字段是否被碰,看这张清单最直白 */
|
||||
private List<String> updatedColumns() {
|
||||
return java.util.Arrays.stream(capturedUpdate().getSqlSet().split(","))
|
||||
.map(part -> part.substring(0, part.indexOf('=')).trim())
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<Date> capturedUpdateDates() {
|
||||
return capturedUpdateParams().stream().filter(Date.class::isInstance).map(Date.class::cast).toList();
|
||||
}
|
||||
|
||||
/** SET 与 WHERE 的参数都是渲染时才进参数表的,取值前先把两段都渲染一遍 */
|
||||
private Collection<Object> capturedUpdateParams() {
|
||||
LambdaUpdateWrapper<Waybill> update = capturedUpdate();
|
||||
update.getSqlSet();
|
||||
update.getTargetSql();
|
||||
return update.getParamNameValuePairs().values();
|
||||
}
|
||||
|
||||
}
|
||||
+474
@@ -0,0 +1,474 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.Driver;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO;
|
||||
import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 司机端运次接口:五个入口切运次口径后的卡片与分页,以及未配载运次照运单详情的只读下发。
|
||||
* <p>
|
||||
* 运次侧的取数与聚合在运次服务里,这里只验司机端的映射与收口;运次服务以 mock 给出「库层已经算好的运次」。
|
||||
*/
|
||||
class DriverTripCardTest {
|
||||
|
||||
private static final Long USER_ID = 7401L;
|
||||
private static final Long DRIVER_ID = 9001L;
|
||||
private static final String PHONE = "17587421208";
|
||||
private static final String LOADING_NO = "PZ-20261017-001";
|
||||
private static final Long LOADING_ID = 2111350784070275123L;
|
||||
private static final Long WAYBILL_ID = 2101508430981652482L;
|
||||
private static final String ROUTE_JSON = "["
|
||||
+ "{\"address\":\"广西壮族自治区南宁市南宁吴圩机场\",\"displayAddress\":\"广西壮族自治区南宁市南宁吴圩机场\",\"tags\":[\"装1\"]},"
|
||||
+ "{\"address\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\","
|
||||
+ "\"displayAddress\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\",\"tags\":[\"卸1\",\"装2\",\"装3\",\"装4\"]},"
|
||||
+ "{\"address\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\","
|
||||
+ "\"displayAddress\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\",\"tags\":[\"卸2\",\"卸3\",\"卸4\"]}]";
|
||||
private static final String WAYBILL_IDS_JSON =
|
||||
"[\"2102780956493299713\", \"2092186187239780353\", \"2092186187470467073\", \"2092186187571130369\"]";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private IDriverService driverService;
|
||||
private IMpUserClient mpUserClient;
|
||||
private ITripQueryService tripQueryService;
|
||||
private WaybillEnroutePunchMapper enroutePunchMapper;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private DriverWaybillServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
com.baomidou.mybatisplus.core.MybatisConfiguration configuration =
|
||||
new com.baomidou.mybatisplus.core.MybatisConfiguration();
|
||||
org.apache.ibatis.builder.MapperBuilderAssistant assistant =
|
||||
new org.apache.ibatis.builder.MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
driverService = mock(IDriverService.class);
|
||||
mpUserClient = mock(IMpUserClient.class);
|
||||
tripQueryService = mock(ITripQueryService.class);
|
||||
enroutePunchMapper = mock(WaybillEnroutePunchMapper.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
service = new DriverWaybillServiceImpl(
|
||||
waybillService,
|
||||
driverService,
|
||||
mpUserClient,
|
||||
mock(IProcessConfigService.class),
|
||||
mock(ITransportVehicleService.class),
|
||||
mock(CertificationGate.class),
|
||||
enroutePunchMapper,
|
||||
nodePunchMapper,
|
||||
mock(ExceptionDisposalMapper.class),
|
||||
tripQueryService);
|
||||
lenient().when(driverService.getOne(any())).thenReturn(driver());
|
||||
lenient().when(enroutePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
lenient().when(enroutePunchMapper.selectOne(any())).thenReturn(null);
|
||||
lenient().when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次列表按运次分页:一页 10 个运次,卡片带运次号、整趟车起/经/终与站点数")
|
||||
void tripPageMapsTripCard() {
|
||||
stubTripPage(1, loadingTrip());
|
||||
TripQueryVO given = null;
|
||||
|
||||
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(1, 10, null, null));
|
||||
|
||||
given = capturedQuery();
|
||||
assertEquals(PHONE, given.getDriverPhone());
|
||||
assertEquals(1, given.getCurrent());
|
||||
assertEquals(10, given.getSize(), "一页 10 条即 10 个运次");
|
||||
assertNull(given.getStatus());
|
||||
assertNull(given.getTripId());
|
||||
|
||||
DriverWaybillCardVO card = page.getRecords().get(0);
|
||||
assertEquals(String.valueOf(LOADING_ID), card.getTripId());
|
||||
assertEquals(LOADING_NO, card.getLoadingNo());
|
||||
assertEquals(LOADING_NO, card.getTripNo(), "有配载取配载单号");
|
||||
assertEquals("load", card.getTransportOrgType());
|
||||
assertEquals(1, card.getStatus(), "四张子单都在运输中");
|
||||
assertEquals(Boolean.FALSE, card.getRequireAccept());
|
||||
assertEquals("广西壮族自治区南宁市南宁吴圩机场", card.getFromName());
|
||||
assertEquals("广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司", card.getViaName());
|
||||
assertEquals("广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府", card.getToName());
|
||||
assertEquals(3, card.getStationCount());
|
||||
assertEquals(List.of("石英砂A", "技嘉4070显卡"), card.getCargoNames());
|
||||
assertEquals("石英砂A 131.84吨、技嘉4070显卡 30吨", card.getCargoSummary());
|
||||
assertEquals("161.84吨", card.getWeight(), "合计重量按同单位相加");
|
||||
assertEquals("161.84吨", card.getTotalWeight());
|
||||
assertEquals("桂A32523", card.getVehicleNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次列表不因配载卡去回查运单,也不写库")
|
||||
void tripPageDoesNotLookUpWaybillsOrWrite() {
|
||||
stubTripPage(1, loadingTrip());
|
||||
|
||||
runAsDriver(() -> service.tripPage(1, 10, 1, "PZ-20261017"));
|
||||
|
||||
verify(waybillService, never()).getById(any());
|
||||
verify(waybillService, never()).list(anyWrapper());
|
||||
verify(waybillService, never()).updateById(any());
|
||||
verify(waybillService, never()).update(any(), any());
|
||||
verify(waybillService, never()).syncDriverAcceptState(any());
|
||||
assertEquals(1, capturedQuery().getStatus());
|
||||
assertEquals("PZ-20261017", capturedQuery().getKeyword());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次卡片总数就是运次数:总数与每页条数按运次传下去")
|
||||
void tripPageTotalCountsTrips() {
|
||||
Page<TripVO> trips = new Page<>(1, 10, 71);
|
||||
trips.setRecords(List.of(loadingTrip()));
|
||||
when(tripQueryService.page(any())).thenReturn(trips);
|
||||
|
||||
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(1, 10, null, null));
|
||||
|
||||
assertEquals(71, page.getTotal(), "总数按运次算");
|
||||
assertEquals(10, page.getSize());
|
||||
assertEquals(1, page.getRecords().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未配载运单仍是独立运次:主键是运单主键的字符串形式,未被取整")
|
||||
void unloadedTripKeepsStringPrimaryKey() {
|
||||
stubTripPage(1, unloadedTrip());
|
||||
|
||||
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(1, 10, null, null));
|
||||
|
||||
DriverWaybillCardVO card = page.getRecords().get(0);
|
||||
assertEquals("2101508430981652482", card.getTripId());
|
||||
assertEquals(19, card.getTripId().length());
|
||||
assertEquals(Long.valueOf(WAYBILL_ID), card.getId(), "旧字段仍是运单主键");
|
||||
assertEquals("", card.getLoadingNo());
|
||||
assertEquals("YD-20260920-0003", card.getTripNo());
|
||||
assertEquals("common", card.getTransportOrgType());
|
||||
assertEquals("2026-09-20", card.getPlanShipTime(), "未配载运次的卡片也要带计划时间");
|
||||
assertEquals("2026-09-22", card.getPlanFinishTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Tab 统计按运次计,全部=待接单+进行中+已完成")
|
||||
void tripTabCountsArePerTrip() {
|
||||
TripTabCountsVO counts = new TripTabCountsVO();
|
||||
counts.setPending(3);
|
||||
counts.setDoing(24);
|
||||
counts.setDone(44);
|
||||
counts.setAll(71);
|
||||
when(tripQueryService.tabCounts(any())).thenReturn(counts);
|
||||
|
||||
DriverWaybillTabCountsVO result = runAsDriver(() -> service.tripTabCounts());
|
||||
|
||||
assertEquals(3, result.getPending());
|
||||
assertEquals(24, result.getDoing());
|
||||
assertEquals(44, result.getDone());
|
||||
assertEquals(71, result.getAll());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首页当前任务取运输中运次,最多一条")
|
||||
void tripCurrentTaskTakesRunningTrip() {
|
||||
stubTripPage(1, loadingTrip());
|
||||
|
||||
DriverWaybillCardVO card = runAsDriver(() -> service.tripCurrentTask());
|
||||
|
||||
assertNotNull(card);
|
||||
assertEquals(1, capturedQuery().getStatus());
|
||||
assertEquals(1, capturedQuery().getSize());
|
||||
|
||||
Page<TripVO> empty = new Page<>(1, 1, 0);
|
||||
when(tripQueryService.page(any())).thenReturn(empty);
|
||||
assertNull(runAsDriver(() -> service.tripCurrentTask()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("首页待接预览按运次计,总数是待接运次数")
|
||||
void tripPendingPreviewCountsTrips() {
|
||||
Page<TripVO> trips = new Page<>(1, 2, 3);
|
||||
trips.setRecords(List.of(loadingTrip(), unloadedTrip()));
|
||||
when(tripQueryService.page(any())).thenReturn(trips);
|
||||
|
||||
DriverWaybillPreviewVO preview = runAsDriver(() -> service.tripPendingPreview(null));
|
||||
|
||||
assertEquals(3, preview.getTotal());
|
||||
assertEquals(2, preview.getRecords().size());
|
||||
assertEquals(0, capturedQuery().getStatus());
|
||||
assertEquals(2, capturedQuery().getSize(), "不传时预览两条");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次详情的站点是整趟车:三站按途经顺序、第二站装卸、货物明细带运单号")
|
||||
void tripDetailCarriesWholeCarStations() {
|
||||
stubTripDetail(loadingTrip());
|
||||
|
||||
DriverWaybillCardVO card = runAsDriver(() -> service.tripDetail(String.valueOf(LOADING_ID)));
|
||||
|
||||
assertEquals(String.valueOf(LOADING_ID), card.getTripId());
|
||||
assertEquals(3, card.getRoutePoints().size(), "装卸点是整趟车的三站,不再只有本票两点");
|
||||
assertEquals(3, card.getStations().size());
|
||||
assertEquals(1, card.getStations().get(0).getIndex());
|
||||
assertEquals("装货", card.getStations().get(0).getOperationTypeLabel());
|
||||
assertEquals("装卸", card.getStations().get(1).getOperationTypeLabel());
|
||||
assertEquals("卸货", card.getStations().get(2).getOperationTypeLabel());
|
||||
assertEquals("YD-20260923-0008", card.getStations().get(1).getCargos().get(0).getWaybillNo(),
|
||||
"货物明细里能看到运单号");
|
||||
assertEquals("广西壮族自治区南宁市南宁吴圩机场", card.getRoutePoints().get(0).getName());
|
||||
verify(waybillService, never()).getById(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未配载运次详情照运单详情下发,且只读:不调用会落库的接单态归一化")
|
||||
void unloadedTripDetailStaysReadOnly() {
|
||||
TripVO trip = unloadedTrip();
|
||||
stubTripDetail(trip);
|
||||
Waybill waybill = unloadedWaybill();
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
|
||||
DriverWaybillCardVO first = runAsDriver(() -> service.tripDetail(String.valueOf(WAYBILL_ID)));
|
||||
DriverWaybillCardVO second = runAsDriver(() -> service.tripDetail(String.valueOf(WAYBILL_ID)));
|
||||
|
||||
assertEquals("2101508430981652482", first.getTripId());
|
||||
assertEquals("", first.getLoadingNo());
|
||||
assertNotNull(first.getProcessJson(), "运单详情的过程配置照旧下发");
|
||||
assertNotNull(first.getPunchNodes());
|
||||
assertEquals(2, first.getStations().size());
|
||||
assertEquals(2, first.getRoutePoints().size());
|
||||
assertEquals("运单备注", first.getRemark(), "未配载运次的备注仍是运单备注");
|
||||
assertEquals("2026-09-20", first.getPlanShipTime(), "未配载运次的计划时间仍是运单的计划时间");
|
||||
assertEquals("在途", first.getCurrentNode(), "未配载运次的当前节点照旧");
|
||||
assertEquals(first.getStatus(), second.getStatus(), "连读两次状态不漂移");
|
||||
verify(waybillService, never()).syncDriverAcceptState(any());
|
||||
verify(waybillService, never()).updateById(any());
|
||||
verify(waybillService, never()).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("收口缺失时不放行:空页、零统计、当前任务为空,且不查运次")
|
||||
void missingPhoneIsRefused() {
|
||||
when(driverService.getOne(any())).thenReturn(null);
|
||||
when(mpUserClient.userById(any())).thenReturn(null);
|
||||
|
||||
IPage<DriverWaybillCardVO> page = runAsDriver(() -> service.tripPage(null, null, null, null));
|
||||
DriverWaybillPreviewVO preview = runAsDriver(() -> service.tripPendingPreview(null));
|
||||
DriverWaybillTabCountsVO counts = runAsDriver(() -> service.tripTabCounts());
|
||||
|
||||
assertEquals(0, page.getTotal());
|
||||
assertTrue(page.getRecords().isEmpty());
|
||||
assertEquals(10, page.getSize());
|
||||
assertEquals(0, preview.getTotal());
|
||||
assertTrue(preview.getRecords().isEmpty());
|
||||
assertEquals(0, counts.getAll());
|
||||
assertNull(runAsDriver(() -> service.tripCurrentTask()));
|
||||
assertThrows(ServiceException.class, () -> runAsDriver(() -> service.tripDetail("1")));
|
||||
verify(tripQueryService, never()).page(any());
|
||||
verify(tripQueryService, never()).tabCounts(any());
|
||||
verify(tripQueryService, never()).detail(any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill> anyWrapper() {
|
||||
return org.mockito.ArgumentMatchers.<com.baomidou.mybatisplus.core.conditions.Wrapper<Waybill>>any();
|
||||
}
|
||||
|
||||
private <T> T runAsDriver(Supplier<T> action) {
|
||||
try (MockedStatic<AuthUtil> authUtil = mockStatic(AuthUtil.class)) {
|
||||
authUtil.when(AuthUtil::getUserId).thenReturn(USER_ID);
|
||||
return action.get();
|
||||
}
|
||||
}
|
||||
|
||||
private void stubTripDetail(TripVO trip) {
|
||||
when(tripQueryService.detail(any())).thenReturn(trip);
|
||||
}
|
||||
|
||||
private void stubTripPage(long total, TripVO... trips) { Page<TripVO> page = new Page<>(1, 10, total);
|
||||
page.setRecords(List.of(trips));
|
||||
when(tripQueryService.page(any())).thenReturn(page);
|
||||
}
|
||||
|
||||
private TripQueryVO capturedQuery() {
|
||||
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
|
||||
verify(tripQueryService, org.mockito.Mockito.atLeastOnce()).page(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private Driver driver() {
|
||||
Driver driver = new Driver();
|
||||
driver.setId(DRIVER_ID);
|
||||
driver.setUserId(USER_ID);
|
||||
driver.setDriverName("黄宇富");
|
||||
driver.setDrivingVehicle("桂A32523");
|
||||
driver.setMobile(PHONE);
|
||||
return driver;
|
||||
}
|
||||
|
||||
/** 本地库实测的 PZ-20261017-001:4 张子单、3 站、状态运输中 */
|
||||
private TripVO loadingTrip() {
|
||||
// 回查到的子单按创建时间升序(与服务取子单的顺序一致)
|
||||
List<Waybill> children = List.of(
|
||||
tripChild("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10"),
|
||||
tripChild("2092186187470467073", "YD-20260825-0007", "石英砂A", "广西武宣冠超矿业有限公司", "47.57"),
|
||||
tripChild("2092186187571130369", "YD-20260825-0008", "石英砂A", "广西武宣冠超矿业有限公司", "42.17"),
|
||||
tripChild("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "9/20测试mk001", "30.00"));
|
||||
LoadingManage loading = new LoadingManage();
|
||||
loading.setId(LOADING_ID);
|
||||
loading.setLoadingNo(LOADING_NO);
|
||||
loading.setRouteJson(ROUTE_JSON);
|
||||
loading.setWaybillIdsJson(WAYBILL_IDS_JSON);
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(String.valueOf(LOADING_ID));
|
||||
trip.setLoadingNo(LOADING_NO);
|
||||
trip.setTripNo(LOADING_NO);
|
||||
trip.setTransportOrgType("load");
|
||||
trip.setStatus(1);
|
||||
trip.setRequireAccept(false);
|
||||
trip.setSortTime(new Date(1787671422000L));
|
||||
trip.setDepartureName(TripStationAssembler.departureName(stations));
|
||||
trip.setTransitName(TripStationAssembler.transitName(stations));
|
||||
trip.setArrivalName(TripStationAssembler.arrivalName(stations));
|
||||
trip.setStationCount(stations.size());
|
||||
trip.setStations(stations);
|
||||
trip.setCargoSummary("石英砂A 131.84吨、技嘉4070显卡 30吨");
|
||||
trip.setTotalQuantity(new BigDecimal("161.84"));
|
||||
trip.setQuantityUnit("吨");
|
||||
trip.setWeighingQuantity(new BigDecimal("161.84"));
|
||||
trip.setWeighingUnit("吨");
|
||||
trip.setDriverName("黄宇富");
|
||||
trip.setDriverPhone(PHONE);
|
||||
trip.setVehicleNo("桂A32523");
|
||||
trip.setProjectName("武宣建筑原料运输项目");
|
||||
trip.setTaskRemark("多点装卸测试");
|
||||
trip.setWaybills(children.stream().map(this::tripChildVO).toList());
|
||||
return trip;
|
||||
}
|
||||
|
||||
private TripVO unloadedTrip() {
|
||||
Waybill waybill = unloadedWaybill();
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(String.valueOf(WAYBILL_ID));
|
||||
trip.setTripNo("YD-20260920-0003");
|
||||
trip.setTransportOrgType("common");
|
||||
trip.setStatus(0);
|
||||
trip.setRequireAccept(true);
|
||||
trip.setAcceptStatus("pending");
|
||||
trip.setSortTime(new Date(1787629629000L));
|
||||
trip.setEstimatedStartDate(java.time.LocalDate.of(2026, 9, 20));
|
||||
trip.setEstimatedEndDate(java.time.LocalDate.of(2026, 9, 22));
|
||||
trip.setDepartureName("南宁武鸣");
|
||||
trip.setArrivalName("钦州港");
|
||||
trip.setStationCount(2);
|
||||
trip.setCargoSummary("石灰石 30吨");
|
||||
trip.setTotalQuantity(new BigDecimal("30"));
|
||||
trip.setQuantityUnit("吨");
|
||||
trip.setWeighingQuantity(new BigDecimal("30"));
|
||||
trip.setWeighingUnit("吨");
|
||||
trip.setDriverName("黄宇富");
|
||||
trip.setDriverPhone(PHONE);
|
||||
trip.setVehicleNo("桂A32523");
|
||||
trip.setWaybills(List.of(tripChildVO(waybill)));
|
||||
trip.setStations(TripStationAssembler.assemble(null, List.of(waybill)));
|
||||
return trip;
|
||||
}
|
||||
|
||||
private Waybill unloadedWaybill() {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD-20260920-0003");
|
||||
waybill.setBusinessStatus("pending");
|
||||
waybill.setDriverAcceptStatus("pending");
|
||||
waybill.setDriverPhone(PHONE);
|
||||
waybill.setVehicleNo("桂A32523");
|
||||
waybill.setDepartureName("南宁武鸣");
|
||||
waybill.setDepartureAddress("南宁武鸣");
|
||||
waybill.setArrivalName("钦州港");
|
||||
waybill.setArrivalAddress("钦州港");
|
||||
waybill.setCargoName("石灰石");
|
||||
waybill.setQuantity(new BigDecimal("30"));
|
||||
waybill.setQuantityUnit("吨");
|
||||
waybill.setProcessJson("[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]");
|
||||
waybill.setCreateTime(new Date(1787629629000L));
|
||||
waybill.setRemark("运单备注");
|
||||
waybill.setTaskRemark("任务备注");
|
||||
waybill.setCurrentProcessNode("在途");
|
||||
waybill.setEstimatedStartTime(java.time.LocalDate.of(2026, 9, 20));
|
||||
waybill.setEstimatedEndTime(java.time.LocalDate.of(2026, 9, 22));
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private Waybill tripChild(String id, String waybillNo, String cargoName, String customerName, String quantity) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(Long.valueOf(id));
|
||||
waybill.setWaybillNo(waybillNo);
|
||||
waybill.setCargoName(cargoName);
|
||||
waybill.setCustomerName(customerName);
|
||||
waybill.setQuantity(new BigDecimal(quantity));
|
||||
waybill.setQuantityUnit("吨");
|
||||
waybill.setBusinessStatus("running");
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private TripVO.TripWaybillVO tripChildVO(Waybill waybill) {
|
||||
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
|
||||
child.setId(String.valueOf(waybill.getId()));
|
||||
child.setWaybillNo(waybill.getWaybillNo());
|
||||
child.setCargoName(waybill.getCargoName());
|
||||
child.setCargoType("矿石");
|
||||
child.setQuantity(waybill.getQuantity());
|
||||
child.setQuantityUnit(waybill.getQuantityUnit());
|
||||
child.setBusinessStatus(waybill.getBusinessStatus());
|
||||
return child;
|
||||
}
|
||||
|
||||
}
|
||||
+17
-11
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.system.feign.IMpUserClient;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
@@ -24,11 +25,14 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
@@ -100,7 +104,8 @@ class DriverWaybillAutoCompleteTest {
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
exceptionDisposalMapper,
|
||||
mock(ITripQueryService.class));
|
||||
|
||||
when(driverService.getOne(any())).thenReturn(driver());
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
@@ -165,28 +170,29 @@ class DriverWaybillAutoCompleteTest {
|
||||
// ------------------------------------------------------------ 前置:运单本身能否完成
|
||||
|
||||
@Test
|
||||
@DisplayName("业务状态为 draft 时只打卡不完成,打卡照常成功")
|
||||
void draftWaybillIsPunchedButNotCompleted() {
|
||||
@DisplayName("草稿运单未开始运输:打卡被拒,不落流水也不自动完成")
|
||||
void draftWaybillCannotBePunched() {
|
||||
givenWaybill(SIGN_LAST_JSON, "draft");
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> submit("sign"));
|
||||
|
||||
assertEquals("运单尚未开始运输,暂不可打卡", exception.getMessage());
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
verify(nodePunchMapper).insert(any(WaybillNodePunch.class));
|
||||
assertFalse(vo.getWaybillCompleted());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待执行与调度中间态同样允许自动完成")
|
||||
void dispatchableStatusesStillComplete() {
|
||||
@DisplayName("待执行、待调度、调度中都未开始运输:节点打卡一律被拒")
|
||||
void dispatchableStatusesCannotPunch() {
|
||||
for (String status : List.of("pending", "waiting_dispatch", "dispatching")) {
|
||||
givenWaybill(SIGN_LAST_JSON, status);
|
||||
when(waybillService.completeWithoutDeptCheck(WAYBILL_ID)).thenReturn(true);
|
||||
|
||||
DriverNodePunchVO vo = submit("sign");
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> submit("sign"), status);
|
||||
|
||||
assertTrue(vo.getWaybillCompleted(), status + " 属于可完成状态");
|
||||
assertEquals("运单尚未开始运输,暂不可打卡", exception.getMessage(), status);
|
||||
}
|
||||
verify(nodePunchMapper, never()).insert(any(WaybillNodePunch.class));
|
||||
verify(waybillService, never()).completeWithoutDeptCheck(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-1
@@ -20,6 +20,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
@@ -78,7 +79,8 @@ class DriverWaybillCardLocationTest {
|
||||
mock(CertificationGate.class),
|
||||
enroutePunchMapper,
|
||||
nodePunchMapper,
|
||||
exceptionDisposalMapper);
|
||||
exceptionDisposalMapper,
|
||||
mock(ITripQueryService.class));
|
||||
|
||||
when(waybillService.syncDriverAcceptState(any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
|
||||
+3
-1
@@ -16,6 +16,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
@@ -60,7 +61,8 @@ class DriverWaybillNodePunchTest {
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
nodePunchMapper,
|
||||
mock(ExceptionDisposalMapper.class));
|
||||
mock(ExceptionDisposalMapper.class),
|
||||
mock(ITripQueryService.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+3
-1
@@ -22,6 +22,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
@@ -72,7 +73,8 @@ class DriverWaybillOwnershipTest {
|
||||
mock(CertificationGate.class),
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
mock(WaybillNodePunchMapper.class),
|
||||
mock(ExceptionDisposalMapper.class));
|
||||
mock(ExceptionDisposalMapper.class),
|
||||
mock(ITripQueryService.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+6
-2
@@ -28,6 +28,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.support.CertificationGate;
|
||||
|
||||
import java.util.List;
|
||||
@@ -88,7 +89,8 @@ class DriverWaybillRejectDetachTest {
|
||||
certificationGate,
|
||||
mock(WaybillEnroutePunchMapper.class),
|
||||
mock(WaybillNodePunchMapper.class),
|
||||
mock(ExceptionDisposalMapper.class));
|
||||
mock(ExceptionDisposalMapper.class),
|
||||
mock(ITripQueryService.class));
|
||||
lenient().when(driverService.getOne(any())).thenReturn(driver());
|
||||
lenient().when(waybillService.updateById(any())).thenReturn(true);
|
||||
// syncDriverAcceptState 是 IWaybillService 的方法,mock 默认返 null 会让调用方 NPE;
|
||||
@@ -180,7 +182,9 @@ class DriverWaybillRejectDetachTest {
|
||||
void enrouteSubmitKeepsUnfilteredScope() {
|
||||
EnrouteSubmitDTO dto = new EnrouteSubmitDTO();
|
||||
dto.setWaybillId(REJECTED_ID);
|
||||
when(waybillService.getOne(any())).thenReturn(rejectedWaybill());
|
||||
// 已拒单 + 进行中:历史脏数据,取单不能被拒单条件挡住,收口交给后面的过程配置检查
|
||||
when(waybillService.getOne(any()))
|
||||
.thenReturn(waybill(REJECTED_ID, "YD-20261010-0001", "running", "rejected"));
|
||||
|
||||
// 断言的是「取单这一步没有被拒单条件拦住」:若被拦住会抛「运单不存在或无权操作」,
|
||||
// 而后面因过程配置为空会走「该运单未启用在途打卡」,两者可区分
|
||||
|
||||
+54
@@ -245,6 +245,51 @@ class ExceptionDisposalReportGateTest {
|
||||
assertEquals("韦司机", savedDisposal().getReporterName());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 运单状态门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("未开始运输的运单不能上报:整单异常同样被拒")
|
||||
void notStartedWaybillRejectsWaybillLevelReport() {
|
||||
stubWaybillStatus("waiting_dispatch");
|
||||
|
||||
ServiceException exception = submitExpectRejected(null);
|
||||
|
||||
assertEquals("运单尚未开始运输,暂不可上报异常", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已完成的运单不能再上报")
|
||||
void completedWaybillRejectsReport() {
|
||||
stubWaybillStatus("completed");
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("运单已完成,不能上报异常", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已取消的运单不能再上报")
|
||||
void cancelledWaybillRejectsReport() {
|
||||
stubWaybillStatus("cancelled");
|
||||
|
||||
ServiceException exception = submitExpectRejected(null);
|
||||
|
||||
assertEquals("运单已取消,不能上报异常", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运单状态门禁在节点门禁之前:未开始运输时先说没上路,不报节点已打卡")
|
||||
void statusGateBeatsNodeGate() {
|
||||
stubWaybillStatus("pending");
|
||||
stubNode("load", "装货", "cargo");
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, "load")).thenReturn(true);
|
||||
|
||||
ServiceException exception = submitExpectRejected("load");
|
||||
|
||||
assertEquals("运单尚未开始运输,暂不可上报异常", exception.getMessage());
|
||||
verify(driverWaybillService, never()).findPunchNode(any(), any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 上报人归属
|
||||
|
||||
@Test
|
||||
@@ -273,6 +318,13 @@ class ExceptionDisposalReportGateTest {
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
/** 改写夹具运单的业务状态,供状态门禁用例复写 getById 的桩 */
|
||||
private void stubWaybillStatus(String businessStatus) {
|
||||
Waybill waybill = waybill();
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(waybill);
|
||||
}
|
||||
|
||||
private void stubNode(String key, String name, String type) {
|
||||
when(driverWaybillService.findPunchNode(WAYBILL_ID, key)).thenReturn(node(key, name, type));
|
||||
when(driverWaybillService.isNodePunched(WAYBILL_ID, key)).thenReturn(false);
|
||||
@@ -358,6 +410,8 @@ class ExceptionDisposalReportGateTest {
|
||||
waybill.setWaybillNo("YD202610070001");
|
||||
waybill.setVehicleNo("桂AAW3255");
|
||||
waybill.setIsDeleted(0);
|
||||
// 状态门禁:夹具默认进行中,状态类用例自己改
|
||||
waybill.setBusinessStatus("running");
|
||||
return waybill;
|
||||
}
|
||||
|
||||
|
||||
+412
@@ -0,0 +1,412 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.transport.mapper.LoadingManageMapper;
|
||||
import org.springblade.transport.mapper.WaybillMapper;
|
||||
import org.springblade.transport.pojo.entity.ContractManage;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
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.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 配载提交/编辑/重新派单的准入校验:落库前的一处判据决定这批运单能不能被配载。
|
||||
* <p>
|
||||
* 逐张准入用的是读侧候选列表的同一份判据;已挂在本单号上的子运单在先前提交时已过判据,
|
||||
* 重新提交不再重复判——不需接单项目的票提交后即为进行中,重复判会让本单再也保存不了。
|
||||
* 暂存只预占运单号、未过判据,故草稿转正式时本单号不构成豁免。
|
||||
* <p>
|
||||
* 跨行判据(同项目、合同属于本项目)只对提交/编辑/重新派单生效,暂存草稿不受影响。
|
||||
*/
|
||||
class LoadingManageAdmissionTest {
|
||||
|
||||
private static final Long DEPT_ID = 1001L;
|
||||
private static final Long PROJECT_A = 2001L;
|
||||
private static final Long PROJECT_B = 2002L;
|
||||
private static final Long CONTRACT_ID = 3001L;
|
||||
private static final Long OTHER_CONTRACT_ID = 3002L;
|
||||
private static final Long OWN_LOADING_ID = 9001L;
|
||||
private static final String OWN_LOADING_NO = "PZ20261018-001";
|
||||
private static final String WAYBILL_NO_A = "YD202610180001";
|
||||
private static final String WAYBILL_NO_B = "YD202610180002";
|
||||
|
||||
private LoadingManageServiceImpl service;
|
||||
private WaybillMapper waybillMapper;
|
||||
private LoadingManageMapper loadingManageMapper;
|
||||
private IContractManageService contractManageService;
|
||||
private MockedStatic<AuthUtil> authUtil;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ContractManage.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
waybillMapper = mock(WaybillMapper.class);
|
||||
loadingManageMapper = mock(LoadingManageMapper.class);
|
||||
contractManageService = mock(IContractManageService.class);
|
||||
service = new LoadingManageServiceImpl();
|
||||
inject("baseMapper", loadingManageMapper);
|
||||
inject("waybillMapper", waybillMapper);
|
||||
inject("contractManageService", contractManageService);
|
||||
authUtil = mockStatic(AuthUtil.class);
|
||||
authUtil.when(AuthUtil::getDeptId).thenReturn(String.valueOf(DEPT_ID));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
authUtil.close();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 逐张准入
|
||||
|
||||
@Test
|
||||
@DisplayName("非公路运单被拒,错误含运单号与原因")
|
||||
void nonRoadWaybillRejected() {
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", null, PROJECT_A));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submit(submitPayload(List.of(1L))));
|
||||
|
||||
assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("状态不是待执行一律被拒:进行中、已完成、已取消、草稿")
|
||||
void nonPendingStatusRejected() {
|
||||
for (String status : List.of("running", "completed", "cancelled", "draft")) {
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", status, null, PROJECT_A));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submit(submitPayload(List.of(1L))), status);
|
||||
|
||||
assertEquals("运单" + WAYBILL_NO_A + "当前状态不是待执行,不可配载", exception.getMessage(), status);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已挂在其它的配载单上的运单被拒")
|
||||
void waybillOnAnotherManifestRejected() {
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", "PZ20261018-999", PROJECT_A));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submit(submitPayload(List.of(1L))));
|
||||
|
||||
assertEquals("运单" + WAYBILL_NO_A + "已关联其他配载单", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已拒单但未配载的公路待执行运单可通过提交:接单事实由配载写入路径清除,新司机由此看到它")
|
||||
void rejectedWaybillIsAdmitted() {
|
||||
Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A);
|
||||
rejected.setDriverAcceptStatus("rejected");
|
||||
stubWaybills(rejected);
|
||||
stubContract(CONTRACT_ID, PROJECT_A);
|
||||
|
||||
assertDoesNotThrow(() -> service.submit(submitPayload(List.of(1L))));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 本单既有子运单
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑本单:子运单挂的就是本单号,不算冲突")
|
||||
void editingOwnChildrenPasses() throws Exception {
|
||||
stubExistingManifest();
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A));
|
||||
stubContract(CONTRACT_ID, PROJECT_A);
|
||||
|
||||
LoadingManage payload = submitPayload(List.of(1L));
|
||||
payload.setId(OWN_LOADING_ID);
|
||||
|
||||
assertDoesNotThrow(() -> service.submit(payload));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不需接单项目的子运单提交后即为进行中;重新提交本单不得因此被挡")
|
||||
void editingOwnRunningChildrenPasses() throws Exception {
|
||||
stubExistingManifest();
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A));
|
||||
stubContract(CONTRACT_ID, PROJECT_A);
|
||||
|
||||
LoadingManage payload = submitPayload(List.of(1L));
|
||||
payload.setId(OWN_LOADING_ID);
|
||||
|
||||
assertDoesNotThrow(() -> service.submit(payload));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("本单既有子运单放行,但同一次请求新塞进来的进行中运单仍被拒")
|
||||
void newlyAddedRunningWaybillIsStillRejected() throws Exception {
|
||||
stubExistingManifest();
|
||||
stubWaybills(
|
||||
waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A),
|
||||
waybill(2L, WAYBILL_NO_B, "road", "running", null, PROJECT_A));
|
||||
|
||||
LoadingManage payload = submitPayload(List.of(1L, 2L));
|
||||
payload.setId(OWN_LOADING_ID);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class, () -> service.submit(payload));
|
||||
|
||||
assertEquals("运单" + WAYBILL_NO_B + "当前状态不是待执行,不可配载", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重新派单:本单被拒的子运单仍挂在本单号上,不因拒单被挡——重新派单本就是为它而设")
|
||||
void reassignKeepsRejectedOwnChild() throws Exception {
|
||||
stubExistingManifest();
|
||||
when(waybillMapper.selectCount(anyWaybillWrapper())).thenReturn(1L);
|
||||
Waybill rejected = waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A);
|
||||
rejected.setDriverAcceptStatus("rejected");
|
||||
stubWaybills(rejected);
|
||||
stubContract(CONTRACT_ID, PROJECT_A);
|
||||
|
||||
LoadingManage payload = submitPayload(List.of(1L));
|
||||
payload.setId(OWN_LOADING_ID);
|
||||
|
||||
assertDoesNotThrow(() -> service.reassign(payload));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("交叉判据同时命中时按 公路 → 待执行 → 已关联其它配载单 取第一条")
|
||||
void firstFailingConditionWins() {
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", "PZ20261018-999", PROJECT_A));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submit(submitPayload(List.of(1L))));
|
||||
|
||||
assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 草稿预占
|
||||
|
||||
@Test
|
||||
@DisplayName("暂存只预占运单号:草稿里那张进行中的票在草稿转正式时被拒")
|
||||
void draftReservedRunningWaybillRejected() {
|
||||
stubManifest("draft");
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "running", OWN_LOADING_NO, PROJECT_A));
|
||||
stubContract(CONTRACT_ID, PROJECT_A);
|
||||
|
||||
assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L))));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submit(existingPayload(List.of(1L))));
|
||||
|
||||
assertEquals("运单" + WAYBILL_NO_A + "当前状态不是待执行,不可配载", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("暂存只预占运单号:草稿里那张非公路的票在草稿转正式时被拒")
|
||||
void draftReservedNonRoadWaybillRejected() {
|
||||
stubManifest("draft");
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "railway", "pending", OWN_LOADING_NO, PROJECT_A));
|
||||
stubContract(CONTRACT_ID, PROJECT_A);
|
||||
|
||||
assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L))));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submit(existingPayload(List.of(1L))));
|
||||
|
||||
assertEquals("运单" + WAYBILL_NO_A + "运输方式不是公路,不可配载", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("暂存预占的票本身合格时,草稿转正式照常通过")
|
||||
void draftReservationStaysAdmissibleOnSubmit() {
|
||||
stubManifest("draft");
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", OWN_LOADING_NO, PROJECT_A));
|
||||
stubContract(CONTRACT_ID, PROJECT_A);
|
||||
|
||||
assertDoesNotThrow(() -> service.saveDraft(existingPayload(List.of(1L))));
|
||||
assertDoesNotThrow(() -> service.submit(existingPayload(List.of(1L))));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 跨行判据
|
||||
|
||||
@Test
|
||||
@DisplayName("跨项目的子运单集合被拒,错误点名与其它子运单不同项目的运单号")
|
||||
void crossProjectChildrenRejected() {
|
||||
stubWaybills(
|
||||
waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A),
|
||||
waybill(2L, WAYBILL_NO_B, "road", "pending", null, PROJECT_B));
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submit(submitPayload(List.of(1L, 2L))));
|
||||
|
||||
assertEquals("配载单的子运单必须属于同一项目,运单" + WAYBILL_NO_B + "与其它子运单不属于同一项目",
|
||||
exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("合同不属于本项目:与本项目无可选合同区分开")
|
||||
void contractOfAnotherProjectRejected() {
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A));
|
||||
stubContract(CONTRACT_ID, PROJECT_B);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submit(submitPayload(List.of(1L))));
|
||||
|
||||
assertEquals("所选承运商合同不属于运单" + WAYBILL_NO_A + "的项目", exception.getMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("合同本身不可用(未审核通过或已失效)仍报原有文案,不与「不属于本项目」混同")
|
||||
void unavailableContractKeepsExistingMessage() {
|
||||
stubWaybills(waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A));
|
||||
when(contractManageService.list(anyContractWrapper())).thenReturn(List.of());
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.submit(submitPayload(List.of(1L))));
|
||||
|
||||
assertEquals("所选承运商合同不存在、未审核通过或已失效", exception.getMessage());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 放行
|
||||
|
||||
@Test
|
||||
@DisplayName("公路 + 待执行 + 未配载 + 同项目 + 合同属于本项目:通过并带出合同乙方")
|
||||
void legalRequestPasses() {
|
||||
stubWaybills(
|
||||
waybill(1L, WAYBILL_NO_A, "road", "pending", null, PROJECT_A),
|
||||
waybill(2L, WAYBILL_NO_B, "公路运输", "pending", "", PROJECT_A));
|
||||
stubContract(CONTRACT_ID, PROJECT_A);
|
||||
|
||||
LoadingManage payload = submitPayload(List.of(1L, 2L));
|
||||
|
||||
assertDoesNotThrow(() -> service.submit(payload));
|
||||
assertEquals("甲承运商", payload.getCarrierName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("暂存不受准入校验影响:跨项目、非公路、进行中的票照旧落草稿")
|
||||
void draftIsNotGated() {
|
||||
LoadingManage draft = new LoadingManage();
|
||||
draft.setDeptId(DEPT_ID);
|
||||
draft.setLoadingSubNos(WAYBILL_NO_A);
|
||||
draft.setCarrierType("自运");
|
||||
draft.setWaybillIdsJson(waybillIdsJson(List.of(1L, 2L)));
|
||||
|
||||
assertDoesNotThrow(() -> service.saveDraft(draft));
|
||||
verify(waybillMapper, never()).selectList(anyWaybillWrapper());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private Waybill waybill(Long id, String waybillNo, String transportType, String businessStatus,
|
||||
String loadingNo, Long projectId) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(id);
|
||||
waybill.setWaybillNo(waybillNo);
|
||||
waybill.setTransportType(transportType);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setLoadingNo(loadingNo);
|
||||
waybill.setProjectId(projectId);
|
||||
waybill.setDeptId(DEPT_ID);
|
||||
waybill.setIsDeleted(0);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private LoadingManage submitPayload(List<Long> waybillIdList) {
|
||||
LoadingManage loadingManage = new LoadingManage();
|
||||
loadingManage.setDeptId(DEPT_ID);
|
||||
loadingManage.setLoadingSubNos(WAYBILL_NO_A);
|
||||
loadingManage.setTransportType("road");
|
||||
loadingManage.setCarrierType("承运商");
|
||||
loadingManage.setCarrierContractId(CONTRACT_ID);
|
||||
loadingManage.setVehicleNo("苏A12345");
|
||||
loadingManage.setDepartureAddress("南京");
|
||||
loadingManage.setArrivalAddress("上海");
|
||||
loadingManage.setWaybillIdsJson(waybillIdsJson(waybillIdList));
|
||||
return loadingManage;
|
||||
}
|
||||
|
||||
private LoadingManage existingPayload(List<Long> waybillIdList) {
|
||||
LoadingManage loadingManage = submitPayload(waybillIdList);
|
||||
loadingManage.setId(OWN_LOADING_ID);
|
||||
return loadingManage;
|
||||
}
|
||||
|
||||
private static String waybillIdsJson(List<Long> waybillIdList) {
|
||||
return waybillIdList.stream().map(String::valueOf).collect(Collectors.joining(",", "[", "]"));
|
||||
}
|
||||
|
||||
private void stubExistingManifest() {
|
||||
stubManifest("pending");
|
||||
}
|
||||
|
||||
private void stubManifest(String businessStatus) {
|
||||
LoadingManage existing = new LoadingManage();
|
||||
existing.setId(OWN_LOADING_ID);
|
||||
existing.setLoadingNo(OWN_LOADING_NO);
|
||||
existing.setDeptId(DEPT_ID);
|
||||
existing.setBusinessStatus(businessStatus);
|
||||
when(loadingManageMapper.selectById(OWN_LOADING_ID)).thenReturn(existing);
|
||||
}
|
||||
|
||||
private void stubWaybills(Waybill... waybills) {
|
||||
when(waybillMapper.selectList(anyWaybillWrapper())).thenReturn(List.of(waybills));
|
||||
}
|
||||
|
||||
private void stubContract(Long contractId, Long projectId) {
|
||||
ContractManage contract = new ContractManage();
|
||||
contract.setId(contractId);
|
||||
contract.setProjectId(projectId);
|
||||
contract.setPartyB(contractId.equals(OTHER_CONTRACT_ID) ? "乙承运商" : "甲承运商");
|
||||
when(contractManageService.list(anyContractWrapper())).thenReturn(List.of(contract));
|
||||
}
|
||||
|
||||
private static Wrapper<Waybill> anyWaybillWrapper() {
|
||||
return ArgumentMatchers.<Wrapper<Waybill>>any();
|
||||
}
|
||||
|
||||
private static Wrapper<ContractManage> anyContractWrapper() {
|
||||
return ArgumentMatchers.<Wrapper<ContractManage>>any();
|
||||
}
|
||||
|
||||
private void inject(String field, Object value) throws Exception {
|
||||
Class<?> type = service.getClass();
|
||||
while (type != null) {
|
||||
try {
|
||||
Field declared = type.getDeclaredField(field);
|
||||
declared.setAccessible(true);
|
||||
declared.set(service, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
type = type.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("找不到字段:" + field);
|
||||
}
|
||||
|
||||
}
|
||||
+477
@@ -0,0 +1,477 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.core.tool.jackson.JsonUtil;
|
||||
import org.springblade.transport.mapper.WaybillMapper;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.service.IContractManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.doReturn;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 配载提交 / 重新派单写入子运单状态:状态逐张按各票自己项目的实时过程配置推导,
|
||||
* 并把这批票的接单事实清成「待接单」,使新司机能看到它们。
|
||||
* <p>
|
||||
* 推导必须与运单读路径的状态归一化同口径(同一份实时配置、同一个归一化函数),
|
||||
* 否则写完再读会漂移。
|
||||
*/
|
||||
class LoadingManageChildStatusWriteTest {
|
||||
|
||||
/** 当前登录组织,与夹具运单的 deptId 一致 */
|
||||
private static final String DEPT_ID = "1123598813738675201";
|
||||
private static final Long DEPT_ID_LONG = 1123598813738675201L;
|
||||
private static final String LOADING_NO = "PZ-20261018-001";
|
||||
private static final Long LOADING_ID = 1800000000000000001L;
|
||||
private static final String DRIVER_PHONE = "13800138000";
|
||||
|
||||
private static final Long NEED_ACCEPT_WAYBILL_ID = 3101L;
|
||||
private static final Long NO_ACCEPT_WAYBILL_ID = 3102L;
|
||||
private static final Long NEED_ACCEPT_PROJECT_ID = 2101L;
|
||||
private static final Long NO_ACCEPT_PROJECT_ID = 2102L;
|
||||
|
||||
/** 接单节点「是否确认=是」→ 需要司机接单确认 */
|
||||
private static final String NEED_ACCEPT_JSON =
|
||||
"[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]";
|
||||
/** 接单节点「无需确认接单」→ 不需要接单 */
|
||||
private static final String NO_ACCEPT_JSON =
|
||||
"[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]";
|
||||
|
||||
/** 配载写入给子运单落的全部配载字段,逐条都不能因为状态要分批写而漏掉 */
|
||||
private static final List<String> SYNC_COLUMNS = List.of(
|
||||
"loading_no",
|
||||
"business_status",
|
||||
"carrier_type",
|
||||
"carrier_contract_id",
|
||||
"carrier_name",
|
||||
"driver_name",
|
||||
"driver_phone",
|
||||
"vehicle_no",
|
||||
"trailer_vehicle_no",
|
||||
"escort_name",
|
||||
"escort_phone",
|
||||
"mileage",
|
||||
"estimated_start_time",
|
||||
"estimated_end_time",
|
||||
"task_remark",
|
||||
"task_info_json",
|
||||
"current_process_node");
|
||||
|
||||
private static final List<String> ACCEPT_CLEAR_COLUMNS = List.of(
|
||||
"driver_accept_status",
|
||||
"driver_accept_time",
|
||||
"driver_accept_driver_id",
|
||||
"driver_reject_time",
|
||||
"driver_reject_reason");
|
||||
|
||||
private WaybillMapper waybillMapper;
|
||||
private IProcessConfigService processConfigService;
|
||||
private IContractManageService contractManageService;
|
||||
private IReceivablePayableDetailService receivablePayableDetailService;
|
||||
private LoadingManageServiceImpl service;
|
||||
private MockedStatic<AuthUtil> authUtil;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper / LambdaUpdateWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ProcessConfig.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillMapper = mock(WaybillMapper.class);
|
||||
processConfigService = mock(IProcessConfigService.class);
|
||||
contractManageService = mock(IContractManageService.class);
|
||||
receivablePayableDetailService = mock(IReceivablePayableDetailService.class);
|
||||
service = spy(new LoadingManageServiceImpl());
|
||||
inject(service, "waybillMapper", waybillMapper);
|
||||
inject(service, "processConfigService", processConfigService);
|
||||
inject(service, "contractManageService", contractManageService);
|
||||
inject(service, "receivablePayableDetailService", receivablePayableDetailService);
|
||||
doReturnTrue(service);
|
||||
authUtil = mockStatic(AuthUtil.class);
|
||||
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
authUtil.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不需接单的项目:提交时子单直接落进行中,不留待执行等读路径去提升")
|
||||
void submitWritesRunningForProjectWithoutAcceptNode() {
|
||||
stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID));
|
||||
stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
|
||||
|
||||
assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID)));
|
||||
|
||||
CapturedUpdate statusUpdate = statusSyncUpdates().get(0);
|
||||
assertEquals("running", statusUpdate.setValue("business_status"));
|
||||
assertEquals(List.of(NO_ACCEPT_WAYBILL_ID), statusUpdate.whereIds());
|
||||
assertEquals(SYNC_COLUMNS, statusUpdate.columns(), "配载字段一张不少地随状态一起写下去");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("需接单的项目:提交时子单落待执行,等新司机接单")
|
||||
void submitWritesPendingForProjectWithAcceptNode() {
|
||||
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
|
||||
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
|
||||
|
||||
assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID)));
|
||||
|
||||
assertEquals("pending", statusSyncUpdates().get(0).setValue("business_status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("跨项目的子单逐张按各自项目推导,互不牵连,配置只查一次")
|
||||
void deriveEachWaybillStatusFromItsOwnProject() throws Exception {
|
||||
stubConfigs(
|
||||
config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON),
|
||||
config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
|
||||
|
||||
// 提交与重新派单在准入阶段就拦下跨项目的子单,故直接测逐张推导本身
|
||||
assertEquals(Map.of(
|
||||
NEED_ACCEPT_WAYBILL_ID, "pending",
|
||||
NO_ACCEPT_WAYBILL_ID, "running"),
|
||||
deriveBusinessStatuses(
|
||||
waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID),
|
||||
waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID)));
|
||||
verify(processConfigService, times(1)).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("项目生效配置优先:项目不需要接单时,运单自己的历史快照说需要接单也不采信")
|
||||
void liveConfigWinsOverWaybillSnapshot() {
|
||||
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
|
||||
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
|
||||
|
||||
assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID)));
|
||||
|
||||
assertEquals("running", statusSyncUpdates().get(0).setValue("business_status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有项目也没有快照:视为不需要接单,写进行中")
|
||||
void waybillWithoutProjectAndSnapshotWritesRunning() {
|
||||
stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, null));
|
||||
stubConfigs();
|
||||
|
||||
assertTrue(service.submit(loadingForm(NO_ACCEPT_WAYBILL_ID)));
|
||||
|
||||
assertEquals("running", statusSyncUpdates().get(0).setValue("business_status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("项目为空的运单:配置取不到时按运单快照兜底,与读路径的归一化保持一致")
|
||||
void waybillWithoutProjectFallsBackToSnapshot() {
|
||||
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, null, NEED_ACCEPT_JSON));
|
||||
stubConfigs();
|
||||
|
||||
assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID)));
|
||||
|
||||
assertEquals("pending", statusSyncUpdates().get(0).setValue("business_status"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("提交清除本次合进来子单的接单事实:置为待接单,接单/拒单痕迹一并清空")
|
||||
void submitClearsAcceptRecordOfWaybillsTakenIn() {
|
||||
// 同一配载单的子单必须同项目,故两张票同项目,接单事实按整批清
|
||||
stubWaybills(
|
||||
waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID),
|
||||
waybill(NO_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
|
||||
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
|
||||
|
||||
assertTrue(service.submit(loadingForm(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID)));
|
||||
|
||||
CapturedUpdate clearUpdate = acceptClearUpdates().get(0);
|
||||
assertEquals(ACCEPT_CLEAR_COLUMNS, clearUpdate.columns());
|
||||
assertEquals("pending", clearUpdate.setValue("driver_accept_status"));
|
||||
assertNull(clearUpdate.setValue("driver_accept_time"));
|
||||
assertNull(clearUpdate.setValue("driver_accept_driver_id"));
|
||||
assertNull(clearUpdate.setValue("driver_reject_time"));
|
||||
assertNull(clearUpdate.setValue("driver_reject_reason"));
|
||||
assertEquals(
|
||||
Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID),
|
||||
Set.copyOf(clearUpdate.whereIds()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("重新派单:与提交同一套逐张推导,并同样清除接单事实;先写状态后清事实")
|
||||
void reassignDerivesStatusAndClearsAcceptRecord() {
|
||||
stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID));
|
||||
stubWaybills(waybill(NEED_ACCEPT_WAYBILL_ID, NEED_ACCEPT_PROJECT_ID));
|
||||
stubConfigs(config(NEED_ACCEPT_PROJECT_ID, NEED_ACCEPT_JSON));
|
||||
stubRejectedWaybillExists();
|
||||
|
||||
LoadingManage form = editForm(LOADING_ID, NEED_ACCEPT_WAYBILL_ID);
|
||||
assertTrue(service.reassign(form));
|
||||
|
||||
List<CapturedUpdate> updates = waybillUpdates();
|
||||
assertEquals("pending", updates.get(0).setValue("business_status"));
|
||||
assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), updates.get(1).whereIds());
|
||||
assertEquals(ACCEPT_CLEAR_COLUMNS, updates.get(1).columns(), "清接单事实紧跟状态写入之后");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑配载单移除运单:被移除的票照旧退回待执行,不参与本次的状态推导与清接单事实")
|
||||
void removedWaybillIsReleasedWithoutDerivation() {
|
||||
stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID));
|
||||
stubWaybills(waybill(NO_ACCEPT_WAYBILL_ID, NO_ACCEPT_PROJECT_ID));
|
||||
stubConfigs(config(NO_ACCEPT_PROJECT_ID, NO_ACCEPT_JSON));
|
||||
|
||||
assertTrue(service.submit(editForm(LOADING_ID, NO_ACCEPT_WAYBILL_ID)));
|
||||
|
||||
assertEquals(
|
||||
Map.of(NO_ACCEPT_WAYBILL_ID, "running"),
|
||||
statusByWaybillId(),
|
||||
"留下的票按自己项目推导,被移除的票不出现在状态写入里");
|
||||
CapturedUpdate releaseUpdate = waybillUpdates().stream()
|
||||
.filter(update -> !update.columns().contains("driver_name"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertEquals(List.of(NEED_ACCEPT_WAYBILL_ID), releaseUpdate.whereIds(), "被移除的票退回未配载");
|
||||
assertEquals("pending", releaseUpdate.setValue("business_status"));
|
||||
assertEquals(List.of(NO_ACCEPT_WAYBILL_ID), acceptClearUpdates().get(0).whereIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回归:开始配载仍整批置进行中,不按项目逐张推导")
|
||||
void startStillWritesRunningForWholeBatch() {
|
||||
stubLoadingRecord(loadingRecord("pending", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID));
|
||||
|
||||
assertTrue(service.start(LOADING_ID));
|
||||
|
||||
CapturedUpdate statusUpdate = statusSyncUpdates().get(0);
|
||||
assertEquals("running", statusUpdate.setValue("business_status"));
|
||||
assertEquals(
|
||||
Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID),
|
||||
Set.copyOf(statusUpdate.whereIds()));
|
||||
verify(processConfigService, never()).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回归:完成配载仍整批置已完成,结算触发照旧")
|
||||
void completeStillWritesCompletedForWholeBatch() {
|
||||
stubLoadingRecord(loadingRecord("running", NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID));
|
||||
|
||||
assertTrue(service.complete(LOADING_ID));
|
||||
|
||||
CapturedUpdate statusUpdate = statusSyncUpdates().get(0);
|
||||
assertEquals("completed", statusUpdate.setValue("business_status"));
|
||||
assertEquals(
|
||||
Set.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID),
|
||||
Set.copyOf(statusUpdate.whereIds()));
|
||||
verify(receivablePayableDetailService).generateForCompletedLoading(
|
||||
List.of(NEED_ACCEPT_WAYBILL_ID, NO_ACCEPT_WAYBILL_ID), null, LOADING_NO);
|
||||
verify(processConfigService, never()).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private LoadingManage loadingForm(Long... waybillIds) {
|
||||
return fillForm(new LoadingManage(), List.of(waybillIds));
|
||||
}
|
||||
|
||||
private LoadingManage editForm(Long loadingId, Long... waybillIds) {
|
||||
LoadingManage form = new LoadingManage();
|
||||
form.setId(loadingId);
|
||||
return fillForm(form, List.of(waybillIds));
|
||||
}
|
||||
|
||||
private LoadingManage fillForm(LoadingManage form, List<Long> ids) {
|
||||
form.setLoadingNo(LOADING_NO);
|
||||
form.setDeptId(DEPT_ID_LONG);
|
||||
form.setDeptName("测试组织");
|
||||
form.setWaybillIdsJson(JsonUtil.toJson(ids));
|
||||
form.setLoadingSubNos(ids.stream().map(id -> "YD-" + id).reduce((left, right) -> left + "," + right).orElse(""));
|
||||
form.setTransportType("公路运输");
|
||||
form.setCarrierType("自运");
|
||||
form.setVehicleNo("桂A12345");
|
||||
form.setDriverName("张三");
|
||||
form.setDriverPhone(DRIVER_PHONE);
|
||||
form.setDepartureAddress("南宁");
|
||||
form.setArrivalAddress("广州");
|
||||
return form;
|
||||
}
|
||||
|
||||
private static LoadingManage loadingRecord(String businessStatus, Long... waybillIds) {
|
||||
LoadingManage record = new LoadingManage();
|
||||
record.setId(LOADING_ID);
|
||||
record.setLoadingNo(LOADING_NO);
|
||||
record.setDeptId(DEPT_ID_LONG);
|
||||
record.setDeptName("测试组织");
|
||||
record.setCarrierType("自运");
|
||||
record.setBusinessStatus(businessStatus);
|
||||
record.setWaybillIdsJson(JsonUtil.toJson(List.of(waybillIds)));
|
||||
return record;
|
||||
}
|
||||
|
||||
private void stubLoadingRecord(LoadingManage record) {
|
||||
doReturn(record).when(service).getById(LOADING_ID);
|
||||
}
|
||||
|
||||
/** 重新派单只对「有被拒子单」的配载单开放 */
|
||||
private void stubRejectedWaybillExists() {
|
||||
when(waybillMapper.selectCount(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(1L);
|
||||
}
|
||||
|
||||
private static Waybill waybill(Long id, Long projectId) {
|
||||
return waybill(id, projectId, null);
|
||||
}
|
||||
|
||||
private static Waybill waybill(Long id, Long projectId, String processJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(id);
|
||||
waybill.setWaybillNo("YD-" + id);
|
||||
waybill.setProjectId(projectId);
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setDeptId(DEPT_ID_LONG);
|
||||
// 可配载只放行公路 + 待执行 + 未配载,夹具的票都是这种票
|
||||
waybill.setTransportType("公路运输");
|
||||
waybill.setBusinessStatus("pending");
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private void stubWaybills(Waybill... waybills) {
|
||||
when(waybillMapper.selectList(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(List.of(waybills));
|
||||
}
|
||||
|
||||
private void stubConfigs(ProcessConfig... configs) {
|
||||
when(processConfigService.list(ArgumentMatchers.<Wrapper<ProcessConfig>>any())).thenReturn(List.of(configs));
|
||||
}
|
||||
|
||||
private static ProcessConfig config(Long projectId, String nodeConfigJson) {
|
||||
ProcessConfig config = new ProcessConfig();
|
||||
config.setProjectIds(String.valueOf(projectId));
|
||||
config.setNodeConfigJson(nodeConfigJson);
|
||||
return config;
|
||||
}
|
||||
|
||||
/** 提交路径上 mock 掉框架自带的落库与回查,只留业务写入可断言 */
|
||||
private static void doReturnTrue(LoadingManageServiceImpl target) {
|
||||
doReturn(true).when(target).saveOrUpdate(any(LoadingManage.class));
|
||||
doReturn(true).when(target).updateById(any(LoadingManage.class));
|
||||
}
|
||||
|
||||
/** 配载字段 + 状态那条批量更新;退回未配载的那条只写 loading_no/合同/状态,靠 driver_name 区分 */
|
||||
private List<CapturedUpdate> statusSyncUpdates() {
|
||||
return waybillUpdates().stream().filter(update -> update.columns().contains("driver_name")).toList();
|
||||
}
|
||||
|
||||
/** 状态写入的 where 只有运单 id,故一条更新对应一张票 */
|
||||
private Map<Long, String> statusByWaybillId() {
|
||||
Map<Long, String> statusByWaybillId = new LinkedHashMap<>();
|
||||
statusSyncUpdates().forEach(update -> statusByWaybillId.put(
|
||||
update.whereIds().get(0), String.valueOf(update.setValue("business_status"))));
|
||||
return statusByWaybillId;
|
||||
}
|
||||
|
||||
/** 逐张推导的结果:提交/重新派单在准入阶段就拦下跨项目子单,跨项目推导只能直接测这个函数 */
|
||||
private Map<Long, String> deriveBusinessStatuses(Waybill... waybills) throws Exception {
|
||||
Method method = LoadingManageServiceImpl.class
|
||||
.getDeclaredMethod("deriveWaybillBusinessStatuses", List.class);
|
||||
method.setAccessible(true);
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<Long, String> statusByWaybillId = (Map<Long, String>) method.invoke(service, List.of(waybills));
|
||||
return statusByWaybillId;
|
||||
}
|
||||
|
||||
private List<CapturedUpdate> acceptClearUpdates() {
|
||||
return waybillUpdates().stream()
|
||||
.filter(update -> update.columns().contains("driver_accept_status"))
|
||||
.toList();
|
||||
}
|
||||
|
||||
private List<CapturedUpdate> waybillUpdates() {
|
||||
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(waybillMapper, atLeastOnce()).update(ArgumentMatchers.<Waybill>isNull(), captor.capture());
|
||||
return captor.getAllValues().stream().map(CapturedUpdate::of).toList();
|
||||
}
|
||||
|
||||
private static void inject(LoadingManageServiceImpl target, String fieldName, Object value) {
|
||||
try {
|
||||
Field field = LoadingManageServiceImpl.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
field.set(target, value);
|
||||
} catch (ReflectiveOperationException exception) {
|
||||
throw new IllegalStateException("注入测试替身失败:" + fieldName, exception);
|
||||
}
|
||||
}
|
||||
|
||||
/** 一条被捕获的批量更新:SET 的列 → 值、WHERE 的运单 id */
|
||||
private record CapturedUpdate(String sqlSet, String whereSql, Map<String, Object> params, List<String> columns) {
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
static CapturedUpdate of(Wrapper<Waybill> wrapper) {
|
||||
LambdaUpdateWrapper<Waybill> update = (LambdaUpdateWrapper<Waybill>) wrapper;
|
||||
String sqlSet = update.getSqlSet();
|
||||
// SET 与 WHERE 的参数都是渲染时才进参数表,取值前先把两段都渲染一遍
|
||||
String whereSql = update.getTargetSql();
|
||||
List<String> columns = Arrays.stream(sqlSet.split(","))
|
||||
.map(part -> part.substring(0, part.indexOf('=')).trim())
|
||||
.toList();
|
||||
return new CapturedUpdate(sqlSet, whereSql, update.getParamNameValuePairs(), columns);
|
||||
}
|
||||
|
||||
Object setValue(String column) {
|
||||
for (String part : sqlSet.split(",")) {
|
||||
int equalsIndex = part.indexOf('=');
|
||||
if (!column.equals(part.substring(0, equalsIndex).trim())) {
|
||||
continue;
|
||||
}
|
||||
String reference = part.substring(equalsIndex + 1).trim();
|
||||
if (!reference.startsWith("#{")) {
|
||||
return reference;
|
||||
}
|
||||
String key = reference.substring(reference.lastIndexOf('.') + 1, reference.length() - 1);
|
||||
return params.get(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
List<Long> whereIds() {
|
||||
return params.values().stream().filter(Long.class::isInstance).map(Long.class::cast).toList();
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-3
@@ -2,22 +2,27 @@ package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.AdminTodoItemVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IRiskDisposalService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@@ -32,6 +37,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@@ -49,10 +55,14 @@ class ManageAdminHomeFeedTest {
|
||||
|
||||
private static final Long EXCEPTION_ID = 8801L;
|
||||
private static final String REJECTED_WAYBILL_NO = "YD-20261010-0001";
|
||||
private static final String REJECTED_LOADING_NO = "PZ-20261017-001";
|
||||
private static final String ROLE_ORDINARY = "yewu";
|
||||
private static final String DEPT_ID = "1123598813738675201";
|
||||
|
||||
private ManageWaybillServiceImpl service;
|
||||
private IWaybillService waybillService;
|
||||
private IExceptionDisposalService exceptionService;
|
||||
private MockedStatic<AuthUtil> authUtil;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
@@ -72,11 +82,22 @@ class ManageAdminHomeFeedTest {
|
||||
mock(IDriverService.class),
|
||||
exceptionService,
|
||||
mock(IRiskDisposalService.class),
|
||||
mock(IDriverWaybillService.class));
|
||||
mock(IDriverWaybillService.class),
|
||||
mock(ITripQueryService.class),
|
||||
mock(ILoadingManageService.class));
|
||||
when(exceptionService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
// 重派待办的库层查询要带可见组织;没有请求上下文时按普通用户 + 本组织给出
|
||||
authUtil = mockStatic(AuthUtil.class);
|
||||
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY);
|
||||
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
authUtil.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -141,6 +162,43 @@ class ManageAdminHomeFeedTest {
|
||||
"拒单发生在昨日 22:00,异常上报于 26 小时前(更早),故重派条目在前");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("整单拒绝的多张子单按运次收成一条:同一配载单只出一条重新派单待办")
|
||||
void rejectedLoadingChildrenCollapseToOneTodo() {
|
||||
// 库层按运次分组后,一张配载单无论几张子单被拒都只回一行(分组键见下面的 SQL 断言)
|
||||
Waybill grouped = rejectedWaybill(7210L, "黄大富", "车辆不符");
|
||||
grouped.setLoadingNo(REJECTED_LOADING_NO);
|
||||
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(List.of(grouped));
|
||||
|
||||
List<AdminTodoItemVO> feed = service.buildAdminFeed();
|
||||
|
||||
assertEquals(1, feed.size(), "整单拒绝只应出一条待办");
|
||||
assertEquals(REJECTED_LOADING_NO, feed.get(0).getWaybillNo(), "配载运次的待办展示配载单号");
|
||||
assertEquals(Long.valueOf(7210L), feed.get(0).getId(), "跳转参数仍是运单主键:重新派单按运单写入");
|
||||
|
||||
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(waybillService).list(captor.capture());
|
||||
QueryWrapper<Waybill> wrapper = (QueryWrapper<Waybill>) captor.getValue();
|
||||
String sql = wrapper.getTargetSql();
|
||||
assertTrue(sql.contains("GROUP BY"), "去重要落在库层分组上,否则多张被拒子单会各出一条:" + sql);
|
||||
assertTrue(sql.contains("CONCAT('W', id)"),
|
||||
"分组键与运次读服务同源:有配载按配载单号、未配载每张运单自成一组:" + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("两张未配载运单被拒仍是两条待办:未配载运单各自就是一趟运次")
|
||||
void twoLoneRejectedWaybillsStayTwoTodos() {
|
||||
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(List.of(
|
||||
rejectedWaybill(7221L, "黄大富", null),
|
||||
rejectedWaybill(7222L, "黄宇富", null)));
|
||||
|
||||
List<AdminTodoItemVO> feed = service.buildAdminFeed();
|
||||
|
||||
assertEquals(2, feed.size(), "未配载运单不合并");
|
||||
assertEquals(List.of(7221L, 7222L),
|
||||
feed.stream().map(AdminTodoItemVO::getId).sorted().collect(Collectors.toList()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("判据只认「已拒单 + 未取消未完成」,已取消已完成即便被拒过也不参与")
|
||||
void reassignQueryExcludesTerminalStatus() {
|
||||
@@ -148,7 +206,7 @@ class ManageAdminHomeFeedTest {
|
||||
|
||||
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(waybillService).list(captor.capture());
|
||||
LambdaQueryWrapper<Waybill> wrapper = (LambdaQueryWrapper<Waybill>) captor.getValue();
|
||||
QueryWrapper<Waybill> wrapper = (QueryWrapper<Waybill>) captor.getValue();
|
||||
String sql = wrapper.getTargetSql();
|
||||
|
||||
assertTrue(sql.contains("driver_accept_status"), "重派判据要落在接单状态上:" + sql);
|
||||
|
||||
+338
@@ -0,0 +1,338 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillCardVO;
|
||||
import org.springblade.transport.pojo.vo.AdminWaybillDetailVO;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IRiskDisposalService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 调度端列表与详情的运次口径:一卡一运次、整趟车的站点与货物。
|
||||
* <p>
|
||||
* 卡片上的状态、站点与货物汇总都出自共享运次读服务,本类只做字段裁剪;这里钉住的是
|
||||
* 「运次字段有没有被摆到卡片与详情上」,以及取不到运次时按运单主键与配载单主键的回落路径。
|
||||
*/
|
||||
class ManageTripListDetailTest {
|
||||
|
||||
private static final String LOADING_NO = "PZ-20261017-001";
|
||||
private static final Long LOADING_ID = 2111350784070275074L;
|
||||
private static final Long CHILD_A = 2111350784070275123L;
|
||||
private static final Long CHILD_B = 2102780956493299713L;
|
||||
private static final String ROLE_ORDINARY = "yewu";
|
||||
private static final String DEPT_ID = "1123598813738675201";
|
||||
|
||||
private ManageWaybillServiceImpl service;
|
||||
private IWaybillService waybillService;
|
||||
private IExceptionDisposalService exceptionDisposalService;
|
||||
private ITripQueryService tripQueryService;
|
||||
private ILoadingManageService loadingManageService;
|
||||
private MockedStatic<AuthUtil> authUtil;
|
||||
private IDriverWaybillService driverWaybillService;
|
||||
|
||||
private static java.time.LocalDate toLocalDate(Date date) {
|
||||
return java.time.Instant.ofEpochMilli(date.getTime()).atZone(java.time.ZoneId.systemDefault()).toLocalDate();
|
||||
}
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
exceptionDisposalService = mock(IExceptionDisposalService.class);
|
||||
tripQueryService = mock(ITripQueryService.class);
|
||||
loadingManageService = mock(ILoadingManageService.class);
|
||||
driverWaybillService = mock(IDriverWaybillService.class);
|
||||
service = new ManageWaybillServiceImpl(
|
||||
waybillService,
|
||||
mock(IDriverService.class),
|
||||
exceptionDisposalService,
|
||||
mock(IRiskDisposalService.class),
|
||||
driverWaybillService,
|
||||
tripQueryService,
|
||||
loadingManageService);
|
||||
when(exceptionDisposalService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(new ArrayList<>());
|
||||
// 列表与待处理都要先算出可见组织;没有请求上下文时按普通用户 + 本组织给出
|
||||
authUtil = mockStatic(AuthUtil.class);
|
||||
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY);
|
||||
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
authUtil.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("列表一卡一运次:单号、组织类型、起经终、站点数、货物汇总都取运次字段")
|
||||
void pageListShowsOneCardPerTrip() {
|
||||
when(tripQueryService.page(any())).thenReturn(pageOf(1, trip()));
|
||||
|
||||
IPage<AdminWaybillCardVO> page = service.pageList(1, 10, null, null, null, null, null, null);
|
||||
|
||||
assertEquals(1, page.getTotal());
|
||||
assertEquals(1, page.getRecords().size(), "四张子单只出一条卡");
|
||||
AdminWaybillCardVO card = page.getRecords().get(0);
|
||||
assertEquals(String.valueOf(LOADING_ID), card.getTripId());
|
||||
assertEquals(LOADING_NO, card.getLoadingNo());
|
||||
assertEquals(LOADING_NO, card.getTripNo());
|
||||
assertEquals("load", card.getTransportType(), "有配载单即为公路配载");
|
||||
assertEquals(2, card.getStatus());
|
||||
assertEquals(4, card.getWaybillCount());
|
||||
assertEquals(3, card.getStationCount());
|
||||
assertEquals("南宁吴圩机场", card.getFromName());
|
||||
assertEquals("新福兴硅科技有限公司", card.getViaName());
|
||||
assertEquals("宾阳", card.getToName());
|
||||
assertEquals("石英砂A 131.84吨、技嘉4070显卡 30吨", card.getCargoSummary());
|
||||
assertEquals("161.84吨", card.getTotalWeight());
|
||||
assertEquals("2026-09-23 23:23:42", card.getCreateTime(), "卡片时间取运次排序键");
|
||||
assertEquals(Long.valueOf(CHILD_A), card.getId(), "旧 id 仍是运单主键,打卡订正等按它进入");
|
||||
assertEquals("YD-20260825-0006", card.getWaybillNo());
|
||||
assertFalse(card.getNeedReassign());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("列表筛选走同一次共享取数:组织类型、异常与创建时间随条件一起交给运次读服务")
|
||||
void pageListFiltersGoThroughSharedQuery() {
|
||||
when(tripQueryService.page(any())).thenReturn(pageOf(1, trip()));
|
||||
|
||||
service.pageList(2, 20, "PZ-2026", "0", "normal", "load", "2026-09-01", "2026-09-30");
|
||||
|
||||
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
|
||||
verify(tripQueryService, times(1)).page(captor.capture());
|
||||
TripQueryVO query = captor.getValue();
|
||||
assertEquals("load", query.getTransportOrgType());
|
||||
assertEquals("normal", query.getException());
|
||||
assertEquals(0, query.getStatus());
|
||||
assertEquals("PZ-2026", query.getKeyword());
|
||||
assertEquals(2, query.getCurrent());
|
||||
assertEquals(20, query.getSize());
|
||||
assertEquals(java.time.LocalDate.of(2026, 9, 1), toLocalDate(query.getCreatedFrom()));
|
||||
assertEquals(java.time.LocalDate.of(2026, 9, 30), toLocalDate(query.getCreatedTo()));
|
||||
verify(waybillService, never()).page(any(), any());
|
||||
verify(tripQueryService, never()).detail(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待处理列表:两档与「未拒单」交给同一次共享取数;待重新派单仍走子单被拒那条路")
|
||||
void pendingListUsesSharedQuery() {
|
||||
when(tripQueryService.page(any())).thenReturn(pageOf(0));
|
||||
when(waybillService.page(any(), any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
|
||||
service.pendingList(1, 10, null, false);
|
||||
|
||||
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
|
||||
verify(tripQueryService, times(1)).page(captor.capture());
|
||||
assertEquals(List.of(0, 1), captor.getValue().getStatuses(), "待处理只要待接单与进行中两档");
|
||||
assertTrue(captor.getValue().isExcludeRejectedTrips(), "未拒单也交给共享取数");
|
||||
|
||||
service.pendingList(1, 10, null, true);
|
||||
|
||||
verify(tripQueryService, times(1)).page(any());
|
||||
verify(waybillService, times(1)).page(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("详情按运次主键取:配载单号、组织类型、站点 ①②③ 与每站货物都带运单号")
|
||||
void detailShowsWholeTripStations() {
|
||||
when(tripQueryService.detail(any())).thenReturn(trip());
|
||||
|
||||
AdminWaybillDetailVO detail = service.detail(String.valueOf(LOADING_ID));
|
||||
|
||||
assertEquals(String.valueOf(LOADING_ID), detail.getTripId());
|
||||
assertEquals(LOADING_NO, detail.getLoadingNo());
|
||||
assertEquals(LOADING_NO, detail.getTripNo());
|
||||
assertEquals("load", detail.getTransportOrgType());
|
||||
assertEquals(2, detail.getStatus());
|
||||
assertEquals(3, detail.getStationCount());
|
||||
assertEquals(3, detail.getStations().size());
|
||||
assertEquals(3, detail.getRoutePoints().size(), "点位列与站点同源");
|
||||
assertEquals("161.84吨", detail.getTotalWeight());
|
||||
|
||||
TripStationVO first = detail.getStations().get(0);
|
||||
assertEquals(1, first.getIndex());
|
||||
assertEquals("装货", first.getOperationTypeLabel());
|
||||
assertEquals("2102780956493299713", first.getCargos().get(0).getWaybillId(), "货物条目带所属子运单主键");
|
||||
assertEquals("YD-20260923-0008", first.getCargos().get(0).getWaybillNo());
|
||||
|
||||
TripStationVO second = detail.getStations().get(1);
|
||||
assertEquals("装卸", second.getOperationTypeLabel());
|
||||
assertEquals(2, second.getCargos().size());
|
||||
assertEquals("YD-20260923-0008", second.getCargos().get(0).getWaybillNo(), "同一站可卸一张、装另一张");
|
||||
assertEquals("2131150784070275011", second.getCargos().get(1).getWaybillId());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("取不到运次时按运单主键与配载单主键回落:整单被拒的配载单仍能打开并重新派单")
|
||||
void detailFallsBackWhenTripIsNotReachable() {
|
||||
when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作"));
|
||||
when(waybillService.getById(CHILD_A)).thenReturn(null);
|
||||
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading());
|
||||
|
||||
AdminWaybillDetailVO detail = service.detail(String.valueOf(LOADING_ID));
|
||||
|
||||
assertEquals(String.valueOf(LOADING_ID), detail.getTripId());
|
||||
assertEquals(LOADING_NO, detail.getLoadingNo());
|
||||
assertEquals("load", detail.getTransportOrgType());
|
||||
assertTrue(detail.getNeedReassign(), "整单被拒时按配载单回落,标记待重新派单");
|
||||
assertEquals(3, detail.getStationCount(), "子单为空时站点退回配载单自己的发货/途经/到货");
|
||||
assertEquals("南宁吴圩机场", detail.getFromName());
|
||||
assertEquals("宾阳", detail.getToName());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次主键不是数字时直接拒绝,不去查库")
|
||||
void detailRefusesUnparsableTripId() {
|
||||
assertThrows(ServiceException.class, () -> service.detail("not-a-number"));
|
||||
assertThrows(ServiceException.class, () -> service.detail(null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private Page<TripVO> pageOf(long total, TripVO... trips) {
|
||||
Page<TripVO> page = new Page<>(1, 10, total);
|
||||
page.setRecords(List.of(trips));
|
||||
return page;
|
||||
}
|
||||
|
||||
/**
|
||||
* 一趟 4 张子单的配载运次:站点与货物按本库真实样本同形(三站,第二站装卸)
|
||||
*/
|
||||
private TripVO trip() {
|
||||
TripVO trip = new TripVO();
|
||||
trip.setId(String.valueOf(LOADING_ID));
|
||||
trip.setTripNo(LOADING_NO);
|
||||
trip.setLoadingNo(LOADING_NO);
|
||||
trip.setTransportOrgType("load");
|
||||
trip.setStatus(2);
|
||||
trip.setRequireAccept(false);
|
||||
trip.setWaybillCount(4);
|
||||
trip.setDriverName("黄宇富");
|
||||
trip.setVehicleNo("桂A32523");
|
||||
trip.setDepartureName("南宁吴圩机场");
|
||||
trip.setTransitName("新福兴硅科技有限公司");
|
||||
trip.setArrivalName("宾阳");
|
||||
trip.setStationCount(3);
|
||||
trip.setCargoSummary("石英砂A 131.84吨、技嘉4070显卡 30吨");
|
||||
trip.setTotalQuantity(new BigDecimal("161.84"));
|
||||
trip.setQuantityUnit("吨");
|
||||
trip.setSortTime(at(1790177022000L));
|
||||
trip.setEstimatedStartDate(LocalDate.of(2026, 8, 25));
|
||||
trip.setEstimatedEndDate(LocalDate.of(2026, 9, 23));
|
||||
trip.setWaybills(List.of(
|
||||
tripWaybill(CHILD_A, "YD-20260825-0006"),
|
||||
tripWaybill(2111350784070275124L, "YD-20260825-0007"),
|
||||
tripWaybill(2111350784070275125L, "YD-20260825-0008"),
|
||||
tripWaybill(CHILD_B, "YD-20260923-0008")));
|
||||
trip.setStations(List.of(
|
||||
station(1, "南宁吴圩机场", "load", "装货", cargo("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "30")),
|
||||
station(2, "武宣", "load_unload", "装卸",
|
||||
cargo("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "30"),
|
||||
cargo("2131150784070275011", "YD-20260825-0006", "石英砂A", "42.10")),
|
||||
station(3, "宾阳", "unload", "卸货", cargo("2131150784070275011", "YD-20260825-0006", "石英砂A", "42.10"))));
|
||||
return trip;
|
||||
}
|
||||
|
||||
private TripVO.TripWaybillVO tripWaybill(Long id, String waybillNo) {
|
||||
TripVO.TripWaybillVO child = new TripVO.TripWaybillVO();
|
||||
child.setId(String.valueOf(id));
|
||||
child.setWaybillNo(waybillNo);
|
||||
child.setBusinessStatus("running");
|
||||
return child;
|
||||
}
|
||||
|
||||
private TripStationVO station(int index, String name, String operationType, String label,
|
||||
TripStationVO.TripCargoVO... cargos) {
|
||||
TripStationVO station = new TripStationVO();
|
||||
station.setIndex(index);
|
||||
station.setName(name);
|
||||
station.setAddress(name);
|
||||
station.setOperationType(operationType);
|
||||
station.setOperationTypeLabel(label);
|
||||
station.setTags(new ArrayList<>());
|
||||
station.setCargos(List.of(cargos));
|
||||
return station;
|
||||
}
|
||||
|
||||
private TripStationVO.TripCargoVO cargo(String waybillId, String waybillNo, String cargoName, String quantity) {
|
||||
TripStationVO.TripCargoVO cargo = new TripStationVO.TripCargoVO();
|
||||
cargo.setWaybillId(waybillId);
|
||||
cargo.setWaybillNo(waybillNo);
|
||||
cargo.setCargoName(cargoName);
|
||||
cargo.setQuantity(new BigDecimal(quantity));
|
||||
cargo.setQuantityUnit("吨");
|
||||
return cargo;
|
||||
}
|
||||
|
||||
private LoadingManage loading() {
|
||||
LoadingManage loading = new LoadingManage();
|
||||
loading.setId(LOADING_ID);
|
||||
loading.setLoadingNo(LOADING_NO);
|
||||
loading.setDriverName("黄宇富");
|
||||
loading.setDriverPhone("17587421208");
|
||||
loading.setVehicleNo("桂A32523");
|
||||
loading.setDepartureAddress("南宁吴圩机场");
|
||||
loading.setTransitAddress("武宣");
|
||||
loading.setArrivalAddress("宾阳");
|
||||
loading.setCargoName("石英砂A");
|
||||
return loading;
|
||||
}
|
||||
|
||||
private Date at(long millis) {
|
||||
return new Date(millis);
|
||||
}
|
||||
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.IDriverWaybillService;
|
||||
import org.springblade.transport.service.IExceptionDisposalService;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IRiskDisposalService;
|
||||
import org.springblade.transport.service.ITripQueryService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.atLeastOnce;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 调度端的组织可见范围:普通用户只出自己所属组织,超级管理员不过滤。
|
||||
* <p>
|
||||
* 三个取数入口必须同一条规则——列表、待处理、首页三档统计各走一次查询,漏一处就会出现
|
||||
* 「统计说 46 张、列表只出 18 张」这种对不上的数;待重新派单另走库层分组查询,也要单独带上。
|
||||
* <p>
|
||||
* 所属组织为空时不拦截:小程序调度端不要求账号必须挂组织。
|
||||
*/
|
||||
class ManageWaybillOrgScopeTest {
|
||||
|
||||
private static final String DEPT_ID = "1123598813738675202";
|
||||
private static final Long DEPT = 1123598813738675202L;
|
||||
private static final String ROLE_ORDINARY = "yewu";
|
||||
private static final String ROLE_ADMINISTRATOR = "administrator";
|
||||
|
||||
private ManageWaybillServiceImpl service;
|
||||
private IWaybillService waybillService;
|
||||
private ITripQueryService tripQueryService;
|
||||
private MockedStatic<AuthUtil> authUtil;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
tripQueryService = mock(ITripQueryService.class);
|
||||
IExceptionDisposalService exceptionDisposalService = mock(IExceptionDisposalService.class);
|
||||
service = new ManageWaybillServiceImpl(
|
||||
waybillService,
|
||||
mock(IDriverService.class),
|
||||
exceptionDisposalService,
|
||||
mock(IRiskDisposalService.class),
|
||||
mock(IDriverWaybillService.class),
|
||||
tripQueryService,
|
||||
mock(ILoadingManageService.class));
|
||||
when(exceptionDisposalService.list(ArgumentMatchers.<Wrapper<ExceptionDisposal>>any()))
|
||||
.thenReturn(new ArrayList<>());
|
||||
when(waybillService.list(ArgumentMatchers.<Wrapper<Waybill>>any())).thenReturn(new ArrayList<>());
|
||||
// 待重派那次分页查询回它收到的 Page(总数从入参上读,回 null 会空指针)
|
||||
when(waybillService.page(any(), any())).thenAnswer(invocation -> invocation.getArgument(0));
|
||||
// 运次读服务的两次取数:本类只看传给它的条件,行集合给空页即可
|
||||
when(tripQueryService.page(any())).thenReturn(emptyTripPage());
|
||||
when(tripQueryService.tabCounts(any())).thenReturn(new TripTabCountsVO());
|
||||
authUtil = mockStatic(AuthUtil.class);
|
||||
asOrdinaryUser();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
authUtil.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("普通用户:列表把当前组织交给运次读服务,行级收口落在库层")
|
||||
void pageListScopesCurrentDept() {
|
||||
service.pageList(1, 10, null, null, null, null, null, null);
|
||||
|
||||
TripQueryVO query = capturedPageQuery();
|
||||
assertEquals(List.of(DEPT), query.getDeptIds(), "列表只出本组织");
|
||||
assertTrue(query.isAllDrivers(), "组织收口不影响司机收口口径");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("普通用户:待处理列表与列表同一条组织规则")
|
||||
void pendingListScopesCurrentDept() {
|
||||
service.pendingList(1, 10, null, false);
|
||||
|
||||
assertEquals(List.of(DEPT), capturedPageQuery().getDeptIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("普通用户:首页三档统计与列表同一口径,否则统计与列表的数对不上")
|
||||
void statsScopesCurrentDept() {
|
||||
service.stats();
|
||||
|
||||
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
|
||||
verify(tripQueryService, atLeastOnce()).tabCounts(captor.capture());
|
||||
assertEquals(List.of(DEPT), captor.getValue().getDeptIds());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("超级管理员:三处都不带组织,看全租户")
|
||||
void administratorSeesEveryDept() {
|
||||
asAdministrator();
|
||||
|
||||
service.pageList(1, 10, null, null, null, null, null, null);
|
||||
ArgumentCaptor<TripQueryVO> pageCaptor = ArgumentCaptor.forClass(TripQueryVO.class);
|
||||
verify(tripQueryService).page(pageCaptor.capture());
|
||||
assertTrue(pageCaptor.getValue().getDeptIds().isEmpty(), "超管不过滤组织");
|
||||
|
||||
service.stats();
|
||||
ArgumentCaptor<TripQueryVO> statsCaptor = ArgumentCaptor.forClass(TripQueryVO.class);
|
||||
verify(tripQueryService, atLeastOnce()).tabCounts(statsCaptor.capture());
|
||||
assertTrue(statsCaptor.getValue().getDeptIds().isEmpty(), "超管的统计也不过过滤组织");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待重新派单:库层分组查询也带组织,否则重派列表与角标会出别的组织的单")
|
||||
void reassignQueryScopesCurrentDept() {
|
||||
service.pendingList(1, 10, null, true);
|
||||
|
||||
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(waybillService).page(any(), captor.capture());
|
||||
QueryWrapper<Waybill> wrapper = (QueryWrapper<Waybill>) captor.getValue();
|
||||
String sql = String.valueOf(wrapper.getTargetSql());
|
||||
assertTrue(sql.contains("dept_id"), "重派查询要带上组织:" + sql);
|
||||
Set<Object> values = new HashSet<>(wrapper.getParamNameValuePairs().values());
|
||||
assertTrue(values.contains(DEPT), "组织值要作为参数下发:" + values);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待重新派单:超管的库层分组查询不带组织")
|
||||
void reassignQuerySkipsScopeForAdministrator() {
|
||||
asAdministrator();
|
||||
|
||||
service.pendingList(1, 10, null, true);
|
||||
|
||||
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(waybillService).page(any(), captor.capture());
|
||||
String sql = String.valueOf(captor.getValue().getTargetSql());
|
||||
assertFalse(sql.contains("dept_id"), "超管不按组织收口:" + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("详情不按组织收口:按主键进入,不带组织条件")
|
||||
void detailIsNotScoped() {
|
||||
when(tripQueryService.detail(any())).thenThrow(new ServiceException("运次不存在或无权操作"));
|
||||
when(waybillService.getById(any())).thenReturn(null);
|
||||
assertThrows(ServiceException.class, () -> service.detail("7201"));
|
||||
|
||||
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
|
||||
verify(tripQueryService).detail(captor.capture());
|
||||
assertTrue(captor.getValue().getDeptIds() == null || captor.getValue().getDeptIds().isEmpty(),
|
||||
"详情与后台同口径:不按组织收口");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("组织取不到时不拦截,小程序接口不按组织收口")
|
||||
void blankDeptDoesNotFail() {
|
||||
authUtil.when(AuthUtil::getDeptId).thenReturn("");
|
||||
|
||||
service.pageList(1, 10, null, null, null, null, null, null);
|
||||
|
||||
assertTrue(capturedPageQuery().getDeptIds().isEmpty(), "没有组织时不报错、不按组织过滤");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private IPage<TripVO> emptyTripPage() {
|
||||
Page<TripVO> page = new Page<>(1, 10, 0);
|
||||
page.setRecords(List.of());
|
||||
return page;
|
||||
}
|
||||
|
||||
private TripQueryVO capturedPageQuery() {
|
||||
ArgumentCaptor<TripQueryVO> captor = ArgumentCaptor.forClass(TripQueryVO.class);
|
||||
verify(tripQueryService).page(captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private void asOrdinaryUser() {
|
||||
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ORDINARY);
|
||||
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
|
||||
}
|
||||
|
||||
private void asAdministrator() {
|
||||
authUtil.when(AuthUtil::getUserRole).thenReturn(ROLE_ADMINISTRATOR);
|
||||
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
|
||||
}
|
||||
|
||||
}
|
||||
+35
@@ -18,6 +18,7 @@ import org.springblade.transport.service.IDriverService;
|
||||
import org.springblade.transport.service.ITransportCertRecordService;
|
||||
import org.springblade.transport.service.ITransportVehicleService;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
@@ -239,6 +240,15 @@ class PersonCertRoundTripTest {
|
||||
if (patch.getIdCardNo() != null) {
|
||||
target.setIdCardNo(patch.getIdCardNo());
|
||||
}
|
||||
if (patch.getBirthday() != null) {
|
||||
target.setBirthday(patch.getBirthday());
|
||||
}
|
||||
if (patch.getAddressRegion() != null) {
|
||||
target.setAddressRegion(patch.getAddressRegion());
|
||||
}
|
||||
if (patch.getAddress() != null) {
|
||||
target.setAddress(patch.getAddress());
|
||||
}
|
||||
if (patch.getIdCardFront() != null) {
|
||||
target.setIdCardFront(patch.getIdCardFront());
|
||||
}
|
||||
@@ -287,6 +297,9 @@ class PersonCertRoundTripTest {
|
||||
@DisplayName("审核通过:证件内容写回司机档案,档案与生效值一致")
|
||||
void approveWritesCertContentBackToDriver() {
|
||||
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
|
||||
request.setBirthday(LocalDate.of(1990, 1, 1));
|
||||
request.setAddressRegion("广西壮族自治区柳州市柳北区");
|
||||
request.setAddress("跃进路88号");
|
||||
request.setHeadPhoto("http://example.com/head.jpg");
|
||||
request.setDriverClass("A2");
|
||||
request.setDriverFileNo("450100769875");
|
||||
@@ -302,6 +315,9 @@ class PersonCertRoundTripTest {
|
||||
|
||||
// 档案里必须是这次提交的内容:只切指针、不写内容的话,
|
||||
// 认证状态显示「已通过」而档案仍是旧值,管理端与司机端会各看各的
|
||||
assertEquals(LocalDate.of(1990, 1, 1), driver.getBirthday());
|
||||
assertEquals("广西壮族自治区柳州市柳北区", driver.getAddressRegion());
|
||||
assertEquals("跃进路88号", driver.getAddress());
|
||||
assertEquals("http://example.com/head.jpg", driver.getHeadPhoto());
|
||||
assertEquals("A2", driver.getDrivingType());
|
||||
assertEquals("450100769875", driver.getDrivingLicenseNo());
|
||||
@@ -313,6 +329,25 @@ class PersonCertRoundTripTest {
|
||||
assertEquals("http://example.com/q-back.jpg", driver.getQualificationBack());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核通过:住址留空不清空管理端已填的住址")
|
||||
void approveKeepsAddressWhenSnapshotBlank() {
|
||||
driver.setAddressRegion("广西壮族自治区柳州市柳北区");
|
||||
driver.setAddress("跃进路88号");
|
||||
|
||||
PersonCertSubmitRequest request = personCert("张三", "110101199001011234");
|
||||
request.setAddressRegion(" ");
|
||||
request.setAddress("");
|
||||
Long recordId = driverCertService.submitPersonCert(request);
|
||||
certAuditService.audit(recordId, true, null);
|
||||
|
||||
// 框架的 not_null 更新策略只跳过 null:写空串会把管理端填过的住址清掉
|
||||
assertEquals("广西壮族自治区柳州市柳北区", driver.getAddressRegion(),
|
||||
"区划是选填项,没选就保持档案原值");
|
||||
assertEquals("跃进路88号", driver.getAddress(),
|
||||
"详细地址是选填项,没填就保持档案原值");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("审核通过:姓名与身份证号跟着认证资料走,档案与生效值不再各说各话")
|
||||
void approveOverwritesIdentity() {
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 运次四档状态、接单需求合并与运次键口径。
|
||||
* <p>
|
||||
* 四档只在这一处推导演算,Tab 过滤与卡片状态都用它:这里断言的就是分档与卡片共用的那份口径。
|
||||
*/
|
||||
class TripAggregationTest {
|
||||
|
||||
private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]";
|
||||
private static final String ACCEPT_YES_DISABLED = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":false,\"confirmMode\":\"yes\"}]";
|
||||
private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]";
|
||||
|
||||
@Test
|
||||
@DisplayName("子单全部完成 → 已完成")
|
||||
void allCompleted() {
|
||||
List<Waybill> children = List.of(waybill("completed", null, null), waybill("completed", "accepted", null));
|
||||
|
||||
assertEquals("completed", TripAggregation.statusOf(children, false, false));
|
||||
assertEquals(2, TripAggregation.appStatus(TripAggregation.statusOf(children, false, false)));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单全部取消 → 已取消")
|
||||
void allCancelled() {
|
||||
List<Waybill> children = List.of(waybill("cancelled", null, null), waybill("cancelled", null, null));
|
||||
|
||||
String status = TripAggregation.statusOf(children, false, false);
|
||||
|
||||
assertEquals("cancelled", status);
|
||||
assertEquals(3, TripAggregation.appStatus(status));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单全是终态且含已完成 → 已完成(不看取消的那张)")
|
||||
void mixedTerminalKeepsCompleted() {
|
||||
List<Waybill> children = List.of(waybill("completed", null, null), waybill("cancelled", null, null));
|
||||
|
||||
assertEquals("completed", TripAggregation.statusOf(children, false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("需接单且未接单 → 待接单")
|
||||
void requireAcceptWithoutAcceptance() {
|
||||
List<Waybill> children = List.of(waybill("pending", "pending", ACCEPT_YES));
|
||||
|
||||
String status = TripAggregation.statusOf(children, true, false);
|
||||
|
||||
assertEquals("pending", status);
|
||||
assertEquals(0, TripAggregation.appStatus(status));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不需接单 → 运输中")
|
||||
void withoutAcceptRequirement() {
|
||||
List<Waybill> children = List.of(waybill("pending", null, ACCEPT_NO));
|
||||
|
||||
String status = TripAggregation.statusOf(children, false, false);
|
||||
|
||||
assertEquals("running", status);
|
||||
assertEquals(1, TripAggregation.appStatus(status));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("需接单且已接单 → 运输中")
|
||||
void requireAcceptAccepted() {
|
||||
List<Waybill> children = List.of(waybill("pending", "accepted", ACCEPT_YES));
|
||||
|
||||
assertEquals("running", TripAggregation.statusOf(children, true, true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单还有没走完的且需接单 → 待接单")
|
||||
void pendingChildKeepsTripPending() {
|
||||
List<Waybill> children = List.of(waybill("running", null, ACCEPT_YES), waybill("dispatching", null, null));
|
||||
|
||||
assertEquals("pending", TripAggregation.statusOf(children, true, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单集合为空不抛异常,状态无可判定为空")
|
||||
void emptyChildrenDoNotThrow() {
|
||||
List<Waybill> children = List.of();
|
||||
|
||||
assertNull(TripAggregation.statusOf(children, true, false));
|
||||
assertNull(TripAggregation.appStatus(null));
|
||||
assertEquals(0, TripAggregation.count(children).total());
|
||||
assertNull(TripAggregation.acceptStatus(children));
|
||||
assertFalse(TripAggregation.requiresAccept(children));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("跨项目运次按 OR 合并接单需求:一张要接单整趟车都要接单")
|
||||
void crossProjectRequirementOr() {
|
||||
List<Waybill> children = List.of(
|
||||
waybill("running", null, null),
|
||||
waybill("running", null, ACCEPT_YES));
|
||||
|
||||
assertTrue(TripAggregation.requiresAccept(children));
|
||||
assertEquals("pending", TripAggregation.statusOf(children, true, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("跨项目但都没接单节点 → 整趟车不需要接单")
|
||||
void crossProjectWithoutAnyRequirement() {
|
||||
List<Waybill> children = List.of(
|
||||
waybill("running", null, null),
|
||||
waybill("running", null, ACCEPT_NO));
|
||||
|
||||
assertFalse(TripAggregation.requiresAccept(children));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("停用的接单节点不算需要接单")
|
||||
void disabledAcceptNodeIsNotRequirement() {
|
||||
List<Waybill> children = List.of(waybill("running", null, ACCEPT_YES_DISABLED));
|
||||
|
||||
assertFalse(TripAggregation.requiresAccept(children));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("接单态:任一子单已接单即整单已接单,否则待接单,无从判定为空")
|
||||
void acceptStatusAggregation() {
|
||||
assertEquals("accepted", TripAggregation.acceptStatus(List.of(
|
||||
waybill("running", "pending", ACCEPT_YES), waybill("running", "accepted", ACCEPT_YES))));
|
||||
assertEquals("pending", TripAggregation.acceptStatus(List.of(
|
||||
waybill("running", "pending", ACCEPT_YES), waybill("running", null, ACCEPT_YES))));
|
||||
assertNull(TripAggregation.acceptStatus(List.of(waybill("completed", null, null))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("四档都由同一份推导给出:待接单 / 进行中 / 已完成 / 已取消")
|
||||
void tiersComeFromTheSameDerivation() {
|
||||
// 需接单且未接单 → 待接单档
|
||||
assertEquals(0, tier(List.of(waybill("pending", "pending", ACCEPT_YES))));
|
||||
// 需接单且已接单 → 进行中档
|
||||
assertEquals(1, tier(List.of(waybill("running", "accepted", ACCEPT_YES))));
|
||||
// 子单全完成 → 已完成档
|
||||
assertEquals(2, tier(List.of(waybill("completed", "accepted", ACCEPT_YES))));
|
||||
// 子单全取消 → 已取消档
|
||||
assertEquals(3, tier(List.of(waybill("cancelled", null, ACCEPT_YES))));
|
||||
// 子单无从判定(空集合)时不落任何档,不会混进「全部」
|
||||
assertNull(tier(List.of()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("库层业务状态与过程配置不一致时按推导分档,不按库值")
|
||||
void tiersFollowDerivationNotStoredStatus() {
|
||||
// 库里落的是 pending(惰性校正没跑过),但该项目不需要接单 → 推导为进行中,不进待接单档
|
||||
assertEquals(1, tier(List.of(waybill("pending", "pending", ACCEPT_NO))));
|
||||
// 反过来:库里落的是 running,但项目要接单且没接 → 推导为待接单
|
||||
assertEquals(0, tier(List.of(waybill("running", "pending", ACCEPT_YES))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("拼载的多张子单按 OR 合并接单需求:任一要接单且未接单就落在待接单档")
|
||||
void mixedTripFollowsOrRule() {
|
||||
assertEquals(0, tier(List.of(waybill("running", null, ACCEPT_NO), waybill("running", "pending", ACCEPT_YES))));
|
||||
assertEquals(1, tier(List.of(waybill("running", null, ACCEPT_NO), waybill("running", "accepted", ACCEPT_YES))));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次键:有配载取配载单号,未配载取 W + 运单主键")
|
||||
void tripKeyFollowsLoadingNo() {
|
||||
assertEquals("PZ-20260919-001", TripAggregation.keyOf("PZ-20260919-001", 123L));
|
||||
assertEquals("W123", TripAggregation.keyOf(null, 123L));
|
||||
assertEquals("W123", TripAggregation.keyOf("", 123L));
|
||||
}
|
||||
|
||||
private Integer tier(List<Waybill> children) {
|
||||
boolean requireAccept = TripAggregation.requiresAccept(children);
|
||||
boolean accepted = org.springblade.transport.support.WaybillProcessSupport.ACCEPT_ACCEPTED
|
||||
.equals(TripAggregation.acceptStatus(children));
|
||||
return TripAggregation.appStatus(TripAggregation.statusOf(children, requireAccept, accepted));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("草稿子单不进运次,已取消子单要进(否则全取消会被算成运输中)")
|
||||
void childStatusScope() {
|
||||
assertFalse(TripAggregation.CHILD_STATUSES.contains("draft"));
|
||||
assertTrue(TripAggregation.CHILD_STATUSES.contains("cancelled"));
|
||||
assertTrue(TripAggregation.CHILD_STATUSES.containsAll(TripAggregation.TERMINAL_STATUSES));
|
||||
}
|
||||
|
||||
private Waybill waybill(String businessStatus, String acceptStatus, String processJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setDriverAcceptStatus(acceptStatus);
|
||||
waybill.setProcessJson(processJson);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
}
|
||||
+710
@@ -0,0 +1,710 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripTabCountsVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.Date;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 运次读服务的取数与聚合输出。
|
||||
* <p>
|
||||
* 库层只负责取回运单行:分组、分档、筛选、排序与分页都在服务层,mock 出来的行必须能自己撑起一趟车,
|
||||
* 不再有「库层已经分好组、回查子单」的第二段。
|
||||
*/
|
||||
class TripQueryServiceImplTest {
|
||||
|
||||
private static final String PHONE = "17587421208";
|
||||
private static final String LOADING_NO = "PZ-20261017-001";
|
||||
private static final Long LOADING_ID = 2111350784070275123L;
|
||||
private static final Long WAYBILL_ID = 2110690671695380513L;
|
||||
private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]";
|
||||
private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private ILoadingManageService loadingManageService;
|
||||
private IProcessConfigService processConfigService;
|
||||
private TripQueryServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
loadingManageService = mock(ILoadingManageService.class);
|
||||
processConfigService = mock(IProcessConfigService.class);
|
||||
lenient().when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of());
|
||||
service = new TripQueryServiceImpl(waybillService, loadingManageService, processConfigService);
|
||||
lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
|
||||
lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("段一在库层只做收口与行级筛选,分组与分档不在 SQL 里")
|
||||
void rowsAreFetchedWithoutDatabaseGrouping() {
|
||||
TripQueryVO query = TripQueryVO.forDriver(PHONE);
|
||||
query.setStatus(1);
|
||||
query.setCurrent(1);
|
||||
query.setSize(10);
|
||||
|
||||
service.page(query);
|
||||
|
||||
String sql = capturedRowSql();
|
||||
assertFalse(sql.contains("GROUP BY"), "库层不再分组:" + sql);
|
||||
assertFalse(sql.contains("HAVING"), "库层不再分档:" + sql);
|
||||
assertFalse(sql.contains("LIMIT"), "分页在服务层,不在 wrapper 里拼:" + sql);
|
||||
assertFalse(sql.contains("MAX(create_time)"), "排序键在服务层算:" + sql);
|
||||
assertTrue(sql.contains("blade_loading_manage"), "配载段按配载单的司机收口:" + sql);
|
||||
assertTrue(sql.contains("lm.driver_phone = ?"), "司机手机号要作为参数下发:" + sql);
|
||||
assertTrue(sql.contains("REPLACE(IFNULL(driver_phone, ''), ' ', '') = ?"),
|
||||
"未配载段按运单司机收口,且容忍手机号带空格:" + sql);
|
||||
assertTrue(sql.contains("business_status IN"), "草稿子单不成运次:" + sql);
|
||||
assertTrue(sql.contains("driver_accept_status"), "被该司机拒单的子单不进运次:" + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("段一只 select 用得上的列,大 JSON 列不拖回来")
|
||||
void onlyNeededColumnsAreSelected() {
|
||||
service.page(TripQueryVO.forDriver(PHONE));
|
||||
|
||||
String select = String.valueOf(capturedRowQuery().getSqlSelect());
|
||||
assertTrue(select.contains("process_json"), "分档要按过程配置推导,必须取:" + select);
|
||||
assertTrue(select.contains("create_time"), "排序键要用:" + select);
|
||||
assertTrue(select.contains("estimated_start_time"), "未配载运次的计划时间取自运单,必须取:" + select);
|
||||
assertTrue(select.contains("estimated_end_time"), select);
|
||||
assertFalse(select.contains("route_json"), "运单表上的大 JSON 列不必拖回:" + select);
|
||||
assertTrue(select.contains("goods_json"), "货物明细只能从 goods_json 拿,必须随行取回:" + select);
|
||||
assertFalse(select.contains("task_info_json"), select);
|
||||
assertFalse(select.contains(String.valueOf((Object) null)) && select.contains("*"), "不得整行拖回:" + select);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("组织收口给了就落在库层运单行上,分档与分页都由缩过的行集合算")
|
||||
void deptScopeIsAppliedOnRows() {
|
||||
TripQueryVO query = TripQueryVO.forDriver(PHONE);
|
||||
query.setDeptIds(List.of(1123598813738675202L, 1123598813738675201L));
|
||||
|
||||
service.page(query);
|
||||
|
||||
String sql = capturedRowSql();
|
||||
assertTrue(sql.contains("dept_id IN"), "组织收口要下发到库层:" + sql);
|
||||
Set<Object> values = new HashSet<>(capturedRowQuery().getParamNameValuePairs().values());
|
||||
assertTrue(values.contains(1123598813738675202L) && values.contains(1123598813738675201L),
|
||||
"组织值按参数下发:" + values);
|
||||
assertTrue(capturedRowSql().contains("driver_phone"), "组织收口不顶替司机收口:两种收口同时生效");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("统计四档时组织收口不能丢:去掉的只有 Tab 条件,其余条件照搬")
|
||||
void tabCountsKeepsDeptScope() {
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setDeptIds(List.of(1123598813738675202L));
|
||||
|
||||
service.tabCounts(query);
|
||||
|
||||
assertTrue(capturedRowSql().contains("dept_id IN"),
|
||||
"统计与列表必须是同一份可见范围,否则首页三张卡与列表的数对不上:" + capturedRowSql());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("组织收口缺省时不下发组织条件:超管与司机端都按不传处理")
|
||||
void deptScopeIsOptional() {
|
||||
service.page(TripQueryVO.forDriver(PHONE));
|
||||
|
||||
assertFalse(capturedRowSql().contains("dept_id"), "不给组织集合即不过滤:" + capturedRowSql());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Tab 在服务层按推导分档过滤,每张卡的 status 与该档一致")
|
||||
void tabFilterHappensOnDerivedTier() {
|
||||
stubRows(
|
||||
waybill(1L, "YD-1", null, "running", null, at(3000L), ACCEPT_NO),
|
||||
waybill(2L, "YD-2", null, "completed", "accepted", at(2000L), ACCEPT_NO),
|
||||
waybill(3L, "YD-3", null, "running", "accepted", at(1000L), ACCEPT_NO));
|
||||
|
||||
TripQueryVO doing = TripQueryVO.forDriver(PHONE);
|
||||
doing.setStatus(1);
|
||||
IPage<TripVO> doingPage = service.page(doing);
|
||||
|
||||
assertEquals(2, doingPage.getTotal(), "只有两趟车在进行中");
|
||||
assertEquals(List.of("1", "3"), doingPage.getRecords().stream().map(TripVO::getId).toList(),
|
||||
"排序键是子单 create_time 最大值,倒序(YD-1 的 3000 > YD-3 的 1000)");
|
||||
assertTrue(doingPage.getRecords().stream().allMatch(trip -> Integer.valueOf(1).equals(trip.getStatus())),
|
||||
"每张卡的 status 都要等于这一档");
|
||||
|
||||
TripQueryVO done = TripQueryVO.forDriver(PHONE);
|
||||
done.setStatus(2);
|
||||
assertEquals(List.of("2"), service.page(done).getRecords().stream().map(TripVO::getId).toList());
|
||||
|
||||
TripQueryVO all = TripQueryVO.forDriver(PHONE);
|
||||
all.setStatus(null);
|
||||
assertEquals(3, service.page(all).getTotal(), "不传 Tab 为全部(不含已取消)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("库里落的状态与过程配置不一致时按推导分档,不按库值")
|
||||
void tierFollowsDerivationNotStoredStatus() {
|
||||
// 一张库值 pending 但项目不需要接单(推导为进行中),一张库值 running 但项目要接单且没接(推导为待接单)
|
||||
stubRows(
|
||||
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES),
|
||||
waybill(1L, "YD-1", null, "pending", "pending", at(1000L), ACCEPT_NO));
|
||||
|
||||
TripQueryVO pending = TripQueryVO.forDriver(PHONE);
|
||||
pending.setStatus(0);
|
||||
assertEquals(List.of("2"), service.page(pending).getRecords().stream().map(TripVO::getId).toList(),
|
||||
"库值 running 但要接单未接 → 落待接单档");
|
||||
|
||||
TripQueryVO doing = TripQueryVO.forDriver(PHONE);
|
||||
doing.setStatus(1);
|
||||
assertEquals(List.of("1"), service.page(doing).getRecords().stream().map(TripVO::getId).toList(),
|
||||
"库值 pending 但不需要接单 → 落进行中档");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("关键字按运次匹配:任一子单或配载单号命中即整趟车命中,不缩小这一趟车的子单集合")
|
||||
void keywordMatchesWholeTrip() {
|
||||
stubRowsBySql(
|
||||
List.of(waybill(9L, "YD-9", LOADING_NO, "running", null, at(1000L), ACCEPT_NO)),
|
||||
null,
|
||||
List.of(
|
||||
waybill(1L, "YD-1", LOADING_NO, "running", null, at(3000L), ACCEPT_YES),
|
||||
waybill(2L, "YD-2", LOADING_NO, "running", null, at(2000L), ACCEPT_YES)));
|
||||
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
|
||||
TripQueryVO query = TripQueryVO.forDriver(PHONE);
|
||||
query.setKeyword("PZ-20261017");
|
||||
|
||||
TripVO trip = service.page(query).getRecords().get(0);
|
||||
|
||||
String keywordSql = capturedKeywordSql();
|
||||
assertTrue(keywordSql.contains("waybill_no LIKE ?"), "关键字在子单上找:" + keywordSql);
|
||||
assertTrue(keywordSql.contains("loading_no LIKE ?"), keywordSql);
|
||||
assertEquals(2, trip.getWaybillCount(), "整趟车的子单都留着,不按命中的那一张缩小");
|
||||
assertTrue(capturedRowSql().contains("loading_no IN ("), "命中的运次键换成配载单号下发:" + capturedRowSql());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未配载运单自成一个运次,主键是 19 位运单主键的字符串形式")
|
||||
void unloadedWaybillIsItsOwnTrip() {
|
||||
stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO));
|
||||
|
||||
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
|
||||
|
||||
assertEquals(1, page.getTotal());
|
||||
assertEquals(1, page.getRecords().size());
|
||||
TripVO trip = page.getRecords().get(0);
|
||||
assertEquals("2110690671695380513", trip.getId());
|
||||
assertEquals(19, trip.getId().length(), "19 位雪花号按字符串下发,不被取整");
|
||||
assertNull(trip.getLoadingNo());
|
||||
assertEquals("common", trip.getTransportOrgType());
|
||||
assertEquals("YD-20260920-0003", trip.getTripNo());
|
||||
assertEquals(1, trip.getStatus());
|
||||
assertEquals(1, trip.getWaybillCount());
|
||||
assertEquals(1, trip.getWaybills().size());
|
||||
assertEquals("2110690671695380513", trip.getWaybills().get(0).getId());
|
||||
verify(waybillService, times(1)).list(anyWaybillWrapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未配载运次的状态与接单需求来自段一取回的那张运单")
|
||||
void unloadedTripStateComesFromFetchedWaybill() {
|
||||
stubRows(waybill(WAYBILL_ID, "YD-20260920-0003", null, "pending", "pending", at(1000L), ACCEPT_YES));
|
||||
|
||||
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
|
||||
|
||||
assertEquals(Boolean.TRUE, trip.getRequireAccept());
|
||||
assertEquals("pending", trip.getAcceptStatus());
|
||||
assertEquals(0, trip.getStatus());
|
||||
assertEquals(1, trip.getWaybillCount());
|
||||
assertEquals("YD-20260920-0003", trip.getTripNo());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未配载运次的计划时间取自这张运单:没有配载单可依,不能留空")
|
||||
void unloadedTripPlanTimeComesFromWaybill() {
|
||||
Waybill row = waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_NO);
|
||||
row.setEstimatedStartTime(LocalDate.of(2026, 8, 7));
|
||||
row.setEstimatedEndTime(LocalDate.of(2026, 8, 24));
|
||||
stubRows(row);
|
||||
|
||||
TripVO listed = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
|
||||
|
||||
assertEquals(LocalDate.of(2026, 8, 7), listed.getEstimatedStartDate(), "列表卡片与详情同源,不能只在列表补");
|
||||
assertEquals(LocalDate.of(2026, 8, 24), listed.getEstimatedEndDate());
|
||||
|
||||
when(loadingManageService.getById(WAYBILL_ID)).thenReturn(null);
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(row);
|
||||
|
||||
TripVO detailed = service.detail(tripQuery(String.valueOf(WAYBILL_ID)));
|
||||
|
||||
assertEquals(LocalDate.of(2026, 8, 7), detailed.getEstimatedStartDate(), "管理端详情走这条路,不留空");
|
||||
assertEquals(LocalDate.of(2026, 8, 24), detailed.getEstimatedEndDate());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("配载单在两次查询之间被删掉时,不给出没有主键的运次")
|
||||
void loadingMissingIsDropped() {
|
||||
stubRows(waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(1000L), ACCEPT_YES));
|
||||
|
||||
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
|
||||
|
||||
assertTrue(page.getRecords().isEmpty(), "配载单取不到就不给这一趟车");
|
||||
assertEquals(1, page.getTotal(), "总数按运次算,卡片少一张时总数仍在");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("配载运次:主键取配载单主键,子单就是段一取回的行,状态与排序键按子单聚合")
|
||||
void loadingTripAggregatesChildren() {
|
||||
Date earlier = at(1787657033000L);
|
||||
Date latest = at(1787671422000L);
|
||||
stubRows(
|
||||
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, earlier, ACCEPT_NO),
|
||||
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", null, earlier, ACCEPT_NO),
|
||||
waybill(2092186187571130369L, "YD-20260825-0008", LOADING_NO, "running", null, earlier, ACCEPT_NO),
|
||||
waybill(2102780956493299713L, "YD-20260923-0008", LOADING_NO, "running", null, latest, ACCEPT_NO));
|
||||
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
|
||||
|
||||
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
|
||||
|
||||
assertEquals(String.valueOf(LOADING_ID), trip.getId());
|
||||
assertEquals(LOADING_NO, trip.getLoadingNo());
|
||||
assertEquals(LOADING_NO, trip.getTripNo());
|
||||
assertEquals("load", trip.getTransportOrgType());
|
||||
assertEquals(4, trip.getWaybillCount());
|
||||
assertEquals(4, trip.getWaybills().size());
|
||||
assertEquals(1, trip.getStatus(), "四张子单都在运输中");
|
||||
assertEquals(Boolean.FALSE, trip.getRequireAccept());
|
||||
assertEquals(latest, trip.getSortTime(), "排序键是子单 create_time 的最大值");
|
||||
verify(waybillService, times(1)).list(anyWaybillWrapper());
|
||||
verify(loadingManageService, times(1)).list(anyLoadingWrapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("配载运次的状态与接单态由子单聚合:需接单未接为待接单,无需接单为运输中")
|
||||
void loadingTripStatusFollowsProcessConfig() {
|
||||
stubRows(
|
||||
waybill(2092186187239780353L, "YD-1", LOADING_NO, "pending", "pending", at(2000L), ACCEPT_YES),
|
||||
waybill(2092186187470467073L, "YD-2", LOADING_NO, "running", null, at(1000L), ACCEPT_NO));
|
||||
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
|
||||
|
||||
TripVO trip = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
|
||||
|
||||
assertEquals(Boolean.TRUE, trip.getRequireAccept(), "跨项目按 OR 合并:一张要接单整趟车都要接单");
|
||||
assertEquals("pending", trip.getAcceptStatus());
|
||||
assertEquals(0, trip.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同一运次连读两次状态与接单态不漂移,聚合路径零写库")
|
||||
void repeatedReadsDoNotDriftAndNeverWrite() {
|
||||
stubOneLoadedTrip();
|
||||
|
||||
TripVO first = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
|
||||
TripVO second = service.page(TripQueryVO.forDriver(PHONE)).getRecords().get(0);
|
||||
|
||||
assertEquals(first.getStatus(), second.getStatus());
|
||||
assertEquals(first.getAcceptStatus(), second.getAcceptStatus());
|
||||
assertEquals(first.getRequireAccept(), second.getRequireAccept());
|
||||
assertEquals(first.getSortTime(), second.getSortTime());
|
||||
assertEquals(first.getWaybillCount(), second.getWaybillCount());
|
||||
verify(waybillService, never()).updateById(any());
|
||||
verify(waybillService, never()).update(any(), any());
|
||||
verify(waybillService, never()).saveOrUpdate(any());
|
||||
verify(waybillService, never()).syncDriverAcceptState(any());
|
||||
verify(loadingManageService, never()).updateById(any());
|
||||
verify(loadingManageService, never()).update(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("收口缺失时不放行:返回空页、零统计,详情直接拒绝,且不查库")
|
||||
void missingScopeIsRefused() {
|
||||
TripQueryVO blank = new TripQueryVO();
|
||||
|
||||
IPage<TripVO> page = service.page(blank);
|
||||
TripTabCountsVO counts = service.tabCounts(blank);
|
||||
|
||||
assertEquals(0, page.getTotal());
|
||||
assertTrue(page.getRecords().isEmpty());
|
||||
assertEquals(0, counts.getAll());
|
||||
assertThrows(ServiceException.class, () -> service.detail(blank));
|
||||
verify(waybillService, never()).list(anyWaybillWrapper());
|
||||
verify(waybillService, never()).page(any(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("分页落在过滤后的完整运次序列上:总数即运次数,换每页条数不串页")
|
||||
void pagingIsPerTrip() {
|
||||
stubRows(
|
||||
waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_YES),
|
||||
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_YES),
|
||||
waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_YES));
|
||||
|
||||
TripQueryVO firstPage = TripQueryVO.forDriver(PHONE);
|
||||
firstPage.setCurrent(1);
|
||||
firstPage.setSize(2);
|
||||
assertEquals(3, service.page(firstPage).getTotal(), "总数是过滤后的运次数,不是运单数");
|
||||
assertEquals(List.of("3", "2"), service.page(firstPage).getRecords().stream().map(TripVO::getId).toList());
|
||||
|
||||
TripQueryVO secondPage = TripQueryVO.forDriver(PHONE);
|
||||
secondPage.setCurrent(2);
|
||||
secondPage.setSize(2);
|
||||
assertEquals(List.of("1"), service.page(secondPage).getRecords().stream().map(TripVO::getId).toList(),
|
||||
"第 2 页接着第 1 页,不重复不遗漏");
|
||||
|
||||
TripQueryVO bySeven = TripQueryVO.forDriver(PHONE);
|
||||
bySeven.setCurrent(1);
|
||||
bySeven.setSize(7);
|
||||
assertEquals(3, service.page(bySeven).getTotal(), "换每页条数总数不变");
|
||||
assertTrue(service.page(bySeven).getCurrent() == 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Tab 统计一次取数算清四档,全部=待接单+进行中+已完成")
|
||||
void tabCountsPerTrip() {
|
||||
stubRows(
|
||||
waybill(4L, "YD-4", null, "pending", "pending", at(4000L), ACCEPT_YES),
|
||||
waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_NO),
|
||||
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO),
|
||||
waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO));
|
||||
|
||||
TripTabCountsVO counts = service.tabCounts(TripQueryVO.forDriver(PHONE));
|
||||
|
||||
assertEquals(1, counts.getPending());
|
||||
assertEquals(2, counts.getDoing());
|
||||
assertEquals(1, counts.getDone());
|
||||
assertEquals(4, counts.getAll(), "全部不含已取消档");
|
||||
verify(waybillService, times(1)).list(anyWaybillWrapper());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("待处理两档与「未拒单」都走同一次取数:多档筛选与已拒运次排除")
|
||||
void multiTierAndRejectedExclusion() {
|
||||
stubRowsBySql(null,
|
||||
// 被拒子单的运次键:只有 YD-9 那一趟
|
||||
List.of(waybill(9L, "YD-9", null, "pending", "rejected", at(500L), ACCEPT_NO)),
|
||||
List.of(
|
||||
waybill(3L, "YD-3", null, "running", null, at(3000L), ACCEPT_NO),
|
||||
waybill(2L, "YD-2", null, "pending", "pending", at(2000L), ACCEPT_NO),
|
||||
waybill(1L, "YD-1", null, "completed", "accepted", at(1000L), ACCEPT_NO)));
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setStatuses(List.of(0, 1));
|
||||
query.setExcludeRejectedTrips(true);
|
||||
|
||||
List<String> ids = service.page(query).getRecords().stream().map(TripVO::getId).toList();
|
||||
|
||||
assertEquals(List.of("3", "2"), ids, "只要待接单与进行中两档");
|
||||
assertEquals(2, service.page(query).getTotal());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("管理端筛选只决定哪些运次在范围内,分档仍看整趟车的子单")
|
||||
void filtersDoNotShrinkTheTieringChildSet() {
|
||||
// 一趟车两张子单:一张已完成(命中异常子单,也落在创建时间区间内)、一张还在运输中
|
||||
stubRows(
|
||||
waybill(2L, "YD-2", LOADING_NO, "completed", "accepted", at(2000L), ACCEPT_NO),
|
||||
waybill(1L, "YD-1", LOADING_NO, "running", null, at(1000L), ACCEPT_NO));
|
||||
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setException("exception");
|
||||
query.setExceptionWaybillIds(java.util.Set.of(2L));
|
||||
query.setCreatedFrom(at(1900L));
|
||||
query.setCreatedTo(at(2100L));
|
||||
|
||||
TripVO trip = service.page(query).getRecords().get(0);
|
||||
|
||||
assertEquals(2, trip.getWaybillCount(), "筛选不缩小子单集合");
|
||||
assertEquals(1, trip.getStatus(), "整趟车还在运输中,不能因为命中的是那张已完成的子单就归到已完成档");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("管理端筛选:组织类型按子单行筛(同一趟车里是同一个值),异常与创建时间不进 SQL")
|
||||
void adminFiltersStayAtTripGranularity() {
|
||||
TripQueryVO query = TripQueryVO.forAdmin();
|
||||
query.setTransportOrgType("common");
|
||||
query.setException("normal");
|
||||
query.setExceptionWaybillIds(java.util.Set.of(7L, 8L));
|
||||
query.setCreatedFrom(at(1000L));
|
||||
query.setCreatedTo(at(2000L));
|
||||
|
||||
service.page(query);
|
||||
|
||||
String sql = capturedRowSql();
|
||||
assertTrue(sql.contains("IFNULL(loading_no, '') = ''"), "普通运输在库层按子单行筛:" + sql);
|
||||
assertFalse(sql.contains("id NOT IN ("), "异常要看整趟车,不能在库层缩小子单集合:" + sql);
|
||||
assertFalse(sql.contains("create_time"), "创建时间按运次排序键在服务层判:" + sql);
|
||||
assertFalse(sql.contains("blade_loading_manage"), "管理端不按司机收口:" + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("创建时间按子单 create_time 的最大值(卡片上显示的就是它)判区间")
|
||||
void createdRangeUsesTripSortKey() {
|
||||
// 两张子单:一张在区间外、一张在区间内,运次的排序键取后创建的那张
|
||||
stubRows(
|
||||
waybill(2L, "YD-2", null, "running", null, at(2000L), ACCEPT_NO),
|
||||
waybill(1L, "YD-1", null, "running", null, at(1000L), ACCEPT_NO));
|
||||
|
||||
TripQueryVO inside = TripQueryVO.forAdmin();
|
||||
inside.setCreatedFrom(at(1900L));
|
||||
inside.setCreatedTo(at(2100L));
|
||||
assertEquals(1, service.page(inside).getTotal(), "排序键 2000 落在区间内 → 这趟车在范围内");
|
||||
|
||||
TripQueryVO outside = TripQueryVO.forAdmin();
|
||||
outside.setCreatedFrom(at(1100L));
|
||||
outside.setCreatedTo(at(1900L));
|
||||
assertEquals(0, service.page(outside).getTotal(), "排序键 2000 不在区间内 → 这趟车不在范围内");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("详情与列表同口径:子单快照没有接单节点、项目生效配置要求接单时,详情也算待接单")
|
||||
void detailUsesLiveProcessConfig() {
|
||||
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
|
||||
Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending",
|
||||
at(2000L), ACCEPT_NO);
|
||||
child.setProjectId(7001L);
|
||||
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(child));
|
||||
when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(processConfig(7001L, ACCEPT_YES)));
|
||||
|
||||
TripVO trip = service.detail(tripQuery(String.valueOf(LOADING_ID)));
|
||||
|
||||
assertEquals(0, trip.getStatus(), "生效配置要求接单且未接 → 待接单档,与列表一致");
|
||||
assertEquals(Boolean.TRUE, trip.getRequireAccept(), "接单需求按生效配置判,不看快照");
|
||||
assertEquals(Boolean.TRUE, trip.getWaybills().get(0).getRequireAccept());
|
||||
assertEquals("pending", trip.getAcceptStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("详情仍是只读取数:生效配置只写回内存里的行,不落库")
|
||||
void detailStaysReadOnly() {
|
||||
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
|
||||
Waybill child = waybill(2092186187239780353L, "YD-20260915-0001", LOADING_NO, "running", "pending",
|
||||
at(2000L), ACCEPT_NO);
|
||||
child.setProjectId(7001L);
|
||||
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(child));
|
||||
when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(processConfig(7001L, ACCEPT_YES)));
|
||||
|
||||
service.detail(tripQuery(String.valueOf(LOADING_ID)));
|
||||
|
||||
verify(waybillService, never()).updateById(any());
|
||||
verify(waybillService, never()).update(any(), any());
|
||||
verify(waybillService, never()).syncDriverAcceptState(any());
|
||||
assertEquals(ACCEPT_YES, child.getProcessJson(), "生效配置只写回内存里的行,库里的快照列不动");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("详情:未配载按运单主键取,配载按配载单主键取")
|
||||
void detailResolvesBothTripKinds() {
|
||||
when(loadingManageService.getById(WAYBILL_ID)).thenReturn(null);
|
||||
when(waybillService.getById(WAYBILL_ID))
|
||||
.thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES));
|
||||
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
|
||||
stubChildren();
|
||||
|
||||
TripVO lone = service.detail(tripQuery(String.valueOf(WAYBILL_ID)));
|
||||
TripVO loaded = service.detail(tripQuery(String.valueOf(LOADING_ID)));
|
||||
|
||||
assertEquals("2110690671695380513", lone.getId());
|
||||
assertEquals(1, lone.getWaybillCount());
|
||||
assertEquals(String.valueOf(LOADING_ID), loaded.getId());
|
||||
assertEquals(LOADING_NO, loaded.getTripNo());
|
||||
assertEquals(2, loaded.getWaybillCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("详情的收口与列表一致:别人的运次、子单全被拒单的配载运次都不可达")
|
||||
void detailRefusesForeignTrips() {
|
||||
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading("13800000001"));
|
||||
when(waybillService.getById(WAYBILL_ID))
|
||||
.thenReturn(waybill(WAYBILL_ID, "YD-20260920-0003", null, "running", null, at(1000L), ACCEPT_YES));
|
||||
|
||||
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID))));
|
||||
|
||||
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading(PHONE));
|
||||
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
|
||||
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(LOADING_ID))));
|
||||
|
||||
when(waybillService.getById(WAYBILL_ID)).thenReturn(null);
|
||||
assertThrows(ServiceException.class, () -> service.detail(tripQuery(String.valueOf(WAYBILL_ID))));
|
||||
assertThrows(ServiceException.class, () -> service.detail(tripQuery("not-a-number")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已拒单的子单不进运次,整趟车只剩被拒的那张时该运次不再出现")
|
||||
void rejectedChildrenLeaveTheTrip() {
|
||||
// 段一的可见口径已经把被拒子单挡在库层,取回来是空则这趟车不存在
|
||||
stubRows();
|
||||
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
|
||||
|
||||
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
|
||||
|
||||
assertTrue(page.getRecords().isEmpty(), "可见子单为空时不给没有子单的运次");
|
||||
assertEquals(0, page.getTotal());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Wrapper<Waybill> anyWaybillWrapper() {
|
||||
return ArgumentMatchers.<Wrapper<Waybill>>any();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Wrapper<LoadingManage> anyLoadingWrapper() {
|
||||
return ArgumentMatchers.<Wrapper<LoadingManage>>any();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Wrapper<ProcessConfig> anyConfigWrapper() {
|
||||
return ArgumentMatchers.<Wrapper<ProcessConfig>>any();
|
||||
}
|
||||
|
||||
/** 段一取回的可见运单行 */
|
||||
private void stubRows(Waybill... rows) {
|
||||
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(rows));
|
||||
}
|
||||
|
||||
/**
|
||||
* 关键字先查一次命中的运次键、再查一次运单行,两次都走 list:按 SQL 里有没有 LIKE 分流
|
||||
*/
|
||||
private void stubRowsBySql(List<Waybill> keywordRows, List<Waybill> rejectedRows, List<Waybill> rows) {
|
||||
when(waybillService.list(anyWaybillWrapper())).thenAnswer(invocation -> {
|
||||
Wrapper<Waybill> wrapper = invocation.getArgument(0);
|
||||
String sql = String.valueOf(wrapper.getTargetSql());
|
||||
if (sql.contains("LIKE")) {
|
||||
return keywordRows;
|
||||
}
|
||||
// 被拒运次那次按等值找已拒子单;段一那次用 IS NULL 兼容为空
|
||||
return sql.contains("IS NULL") ? rows : rejectedRows;
|
||||
});
|
||||
}
|
||||
|
||||
private void stubOneLoadedTrip() {
|
||||
stubRows(
|
||||
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES),
|
||||
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO));
|
||||
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading(PHONE)));
|
||||
}
|
||||
|
||||
private void stubChildren() {
|
||||
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(
|
||||
waybill(2092186187239780353L, "YD-20260825-0006", LOADING_NO, "running", null, at(2000L), ACCEPT_YES),
|
||||
waybill(2092186187470467073L, "YD-20260825-0007", LOADING_NO, "running", "pending", at(1000L), ACCEPT_NO)));
|
||||
}
|
||||
|
||||
private ProcessConfig processConfig(Long projectId, String nodeConfigJson) {
|
||||
ProcessConfig config = new ProcessConfig();
|
||||
config.setProjectIds(String.valueOf(projectId));
|
||||
config.setNodeConfigJson(nodeConfigJson);
|
||||
config.setStatus(1);
|
||||
config.setIsDeleted(0);
|
||||
return config;
|
||||
}
|
||||
|
||||
private TripQueryVO tripQuery(String tripId) {
|
||||
TripQueryVO query = TripQueryVO.forDriver(PHONE);
|
||||
query.setTripId(tripId);
|
||||
return query;
|
||||
}
|
||||
|
||||
private LoadingManage loading(String driverPhone) {
|
||||
LoadingManage loading = new LoadingManage();
|
||||
loading.setId(LOADING_ID);
|
||||
loading.setLoadingNo(LOADING_NO);
|
||||
loading.setDriverPhone(driverPhone);
|
||||
loading.setDriverName("黄宇富");
|
||||
loading.setVehicleNo("桂A32523");
|
||||
loading.setProjectName("武宣建筑原料运输项目");
|
||||
return loading;
|
||||
}
|
||||
|
||||
private static Date at(long millis) {
|
||||
return new Date(millis);
|
||||
}
|
||||
|
||||
private Waybill waybill(Long id, String no, String loadingNo, String businessStatus, String acceptStatus,
|
||||
Date createTime, String processJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(id);
|
||||
waybill.setWaybillNo(no);
|
||||
waybill.setLoadingNo(loadingNo);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setDriverAcceptStatus(acceptStatus);
|
||||
waybill.setDriverPhone(PHONE);
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setCreateTime(createTime);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private ArgumentCaptor<LambdaQueryWrapper<Waybill>> rowCaptor() {
|
||||
return ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
}
|
||||
|
||||
/** 段一的运单行查询:按可见口径那次(带 IS NULL)取,关键字与已拒运次那两次另算 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private LambdaQueryWrapper<Waybill> capturedRowQuery() {
|
||||
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(waybillService, org.mockito.Mockito.atLeastOnce()).list(captor.capture());
|
||||
return (LambdaQueryWrapper<Waybill>) captor.getAllValues().stream()
|
||||
.filter(wrapper -> String.valueOf(wrapper.getTargetSql()).contains("IS NULL"))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("没有发出段一的运单行查询"));
|
||||
}
|
||||
|
||||
private String capturedRowSql() {
|
||||
return String.valueOf(capturedRowQuery().getTargetSql());
|
||||
}
|
||||
|
||||
/** 关键字那次查询:带 LIKE 的那次调用 */
|
||||
private String capturedKeywordSql() {
|
||||
ArgumentCaptor<Wrapper<Waybill>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(waybillService, times(2)).list(captor.capture());
|
||||
return captor.getAllValues().stream()
|
||||
.map(wrapper -> String.valueOf(wrapper.getTargetSql()))
|
||||
.filter(sql -> sql.contains("LIKE"))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new AssertionError("没有发出关键字查询"));
|
||||
}
|
||||
|
||||
}
|
||||
+414
@@ -0,0 +1,414 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.TripQueryVO;
|
||||
import org.springblade.transport.pojo.vo.TripVO;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IWaybillService;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 运次读服务的聚合边界:四档状态、排序键、空子单集合与主键下发形态。
|
||||
* <p>
|
||||
* 断言都从服务出口读({@code page} / {@code detail}),不直接调判定函数:随卡片下发的
|
||||
* {@code status} 与 {@code requireAccept} 是两个字段,只验静态判定看不出服务有没有把子单真身取回来——
|
||||
* 库层分组行只投影主键、分组键与排序键,business_status 是 NULL。
|
||||
*/
|
||||
class TripReadServiceBoundaryTest {
|
||||
|
||||
private static final String PHONE = "17587421208";
|
||||
private static final String LOADING_NO = "PZ-20261017-001";
|
||||
/** 19 位雪花号,按数字下发会被双精度取整 */
|
||||
private static final Long LOADING_ID = 2111350784070275074L;
|
||||
private static final Long CHILD_A = 2111350784070275123L;
|
||||
private static final Long CHILD_B = 2102780956493299713L;
|
||||
private static final String ACCEPT_YES = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"yes\"}]";
|
||||
private static final String ACCEPT_NO = "[{\"key\":\"accept\",\"name\":\"接单\",\"enabled\":true,\"confirmMode\":\"no_confirm_accept\"}]";
|
||||
|
||||
private IWaybillService waybillService;
|
||||
private ILoadingManageService loadingManageService;
|
||||
private IProcessConfigService processConfigService;
|
||||
private TripQueryServiceImpl service;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, LoadingManage.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
waybillService = mock(IWaybillService.class);
|
||||
loadingManageService = mock(ILoadingManageService.class);
|
||||
processConfigService = mock(IProcessConfigService.class);
|
||||
lenient().when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of());
|
||||
service = new TripQueryServiceImpl(waybillService, loadingManageService, processConfigService);
|
||||
lenient().when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of());
|
||||
lenient().when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单全部完成 → 运次已完成(2)")
|
||||
void allChildrenCompleted() {
|
||||
stubLoadedTrip(
|
||||
child(CHILD_A, "completed", null, ACCEPT_NO, at(1787657033000L)),
|
||||
child(CHILD_B, "completed", "accepted", ACCEPT_NO, at(1787671422000L)));
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals(2, trip.getStatus(), "终态且含已完成为已完成档");
|
||||
assertEquals(2, trip.getWaybillCount());
|
||||
assertEquals("accepted", trip.getAcceptStatus(), "任一子单已接单即整单已接单");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单全部取消 → 运次已取消(3),不是运输中")
|
||||
void allChildrenCancelled() {
|
||||
stubLoadedTrip(
|
||||
child(CHILD_A, "cancelled", null, ACCEPT_NO, at(1787657033000L)),
|
||||
child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L)));
|
||||
|
||||
// 「全部」按定义不含已取消档,别在它上面找这趟车;这一趟车本身仍要算成已取消
|
||||
TripQueryVO query = TripQueryVO.forDriver(PHONE);
|
||||
query.setStatus(3);
|
||||
TripVO trip = onlyTrip(query);
|
||||
|
||||
assertEquals(3, trip.getStatus(), "已取消子单必须进运次,否则全取消会被算成运输中");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("混合终态且含 completed → 运次已完成(2),不看取消的那张")
|
||||
void mixedTerminalWithCompleted() {
|
||||
stubLoadedTrip(
|
||||
child(CHILD_A, "completed", null, ACCEPT_NO, at(1787657033000L)),
|
||||
child(CHILD_B, "cancelled", null, ACCEPT_NO, at(1787671422000L)));
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals(2, trip.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("需接单且未接单 → 待接单(0)")
|
||||
void requireAcceptWithoutAcceptance() {
|
||||
Waybill required = child(CHILD_A, "pending", "pending", ACCEPT_YES, at(1787657033000L));
|
||||
required.setProjectId(1L);
|
||||
required.setProjectName("MK项目测试-1");
|
||||
stubLoadedTrip(required);
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals(Boolean.TRUE, trip.getRequireAccept());
|
||||
assertEquals("pending", trip.getAcceptStatus());
|
||||
assertEquals(0, trip.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不需接单 → 运输中(1)")
|
||||
void withoutAcceptRequirement() {
|
||||
Waybill notRequired = child(CHILD_A, "pending", null, ACCEPT_NO, at(1787657033000L));
|
||||
notRequired.setProjectId(2L);
|
||||
notRequired.setProjectName("武宣建筑原料运输项目");
|
||||
stubLoadedTrip(notRequired);
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals(Boolean.FALSE, trip.getRequireAccept());
|
||||
assertEquals(1, trip.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("跨项目子单的接单需求按 OR 合并:一张要接单整趟车都要接单")
|
||||
void crossProjectRequirementMergesWithOr() {
|
||||
Waybill required = child(CHILD_A, "running", null, ACCEPT_YES, at(1787657033000L));
|
||||
required.setProjectId(1L);
|
||||
required.setProjectName("MK项目测试-1");
|
||||
Waybill notRequired = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L));
|
||||
notRequired.setProjectId(2L);
|
||||
notRequired.setProjectName("武宣建筑原料运输项目");
|
||||
stubLoadedTrip(required, notRequired);
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals(Boolean.TRUE, trip.getRequireAccept());
|
||||
assertEquals(0, trip.getStatus());
|
||||
assertEquals(Boolean.TRUE, trip.getWaybills().get(0).getRequireAccept());
|
||||
assertEquals(Boolean.FALSE, trip.getWaybills().get(1).getRequireAccept());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("跨项目但都没接单节点 → 整趟车不需要接单")
|
||||
void crossProjectWithoutAnyRequirement() {
|
||||
Waybill first = child(CHILD_A, "running", null, null, at(1787657033000L));
|
||||
first.setProjectId(1L);
|
||||
Waybill second = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L));
|
||||
second.setProjectId(2L);
|
||||
stubLoadedTrip(first, second);
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals(Boolean.FALSE, trip.getRequireAccept());
|
||||
assertEquals(1, trip.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("排序键取子单 create_time 的最大值,不取第一张也不取最后一张")
|
||||
void sortTimeIsMaxChildCreateTime() {
|
||||
Date earliest = at(1787657033000L);
|
||||
Date latest = at(1787671422000L);
|
||||
Date middle = at(1787663000000L);
|
||||
stubLoadedTrip(
|
||||
child(CHILD_A, "running", null, ACCEPT_NO, middle),
|
||||
child(2111350784070275999L, "running", null, ACCEPT_NO, latest),
|
||||
child(CHILD_B, "running", null, ACCEPT_NO, earliest));
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals(latest, trip.getSortTime());
|
||||
assertEquals(3, trip.getWaybillCount());
|
||||
assertEquals(earliest, trip.getWaybills().get(0).getCreateTime(), "子单按创建时间升序摆");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单集合为空不抛异常:列表不给这张卡,详情直接拒绝而不是 NPE")
|
||||
void emptyChildrenDoNotThrow() {
|
||||
stubLoadedTrip();
|
||||
|
||||
IPage<TripVO> page = service.page(TripQueryVO.forDriver(PHONE));
|
||||
|
||||
assertTrue(page.getRecords().isEmpty(), "配载单在、可见子单为空时不给没有子单的运次");
|
||||
|
||||
when(loadingManageService.getById(LOADING_ID)).thenReturn(loading());
|
||||
assertThrows(ServiceException.class, () -> service.detail(detailQuery()),
|
||||
"子单全被拒单的配载运次对司机不可见");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("子单状态为空时不抛异常:读不出状态按没走完兜底,不崩也不误判成终态")
|
||||
void nullBusinessStatusDoesNotThrow() {
|
||||
Waybill blank = new Waybill();
|
||||
blank.setId(CHILD_A);
|
||||
blank.setWaybillNo("YD-20260920-0003");
|
||||
blank.setLoadingNo(LOADING_NO);
|
||||
blank.setBusinessStatus(null);
|
||||
stubLoadedTrip(blank);
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals(1, trip.getStatus(), "读不出状态按没走完处理,不是终态也不抛异常");
|
||||
assertEquals(1, trip.getWaybillCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未配载运次的状态与接单需求取自段一取回的那张运单")
|
||||
void loneTripReadsStateFromFetchedWaybill() {
|
||||
Waybill bare = new Waybill();
|
||||
bare.setId(CHILD_A);
|
||||
bare.setWaybillNo("YD-20260920-0003");
|
||||
bare.setBusinessStatus("pending");
|
||||
bare.setDriverAcceptStatus("pending");
|
||||
bare.setProcessJson(ACCEPT_YES);
|
||||
bare.setDriverPhone(PHONE);
|
||||
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(bare));
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals("2111350784070275123", trip.getId());
|
||||
assertNull(trip.getLoadingNo());
|
||||
assertEquals(19, trip.getId().length(), "19 位雪花号的末位不被取整");
|
||||
assertEquals(Boolean.TRUE, trip.getRequireAccept(), "接单需求取自回查到的真身");
|
||||
assertEquals(0, trip.getStatus());
|
||||
assertEquals(1, trip.getWaybillCount());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("运次主键按字符串下发:19 位雪花号的末位不被双精度取整")
|
||||
void tripIdIsSentAsString() {
|
||||
stubLoadedTrip(child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L)));
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertNotNull(trip.getId());
|
||||
assertEquals("2111350784070275074", trip.getId());
|
||||
assertEquals(19, trip.getId().length());
|
||||
assertEquals("2111350784070275123", trip.getWaybills().get(0).getId());
|
||||
assertEquals(19, trip.getWaybills().get(0).getId().length());
|
||||
assertTrue(trip.getId() instanceof String);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("货物与合计重量按整趟车算:同名同单位合并,单位不一致时合计留空")
|
||||
void cargoSummarySpansWholeTrip() {
|
||||
Waybill first = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L));
|
||||
first.setCargoName("石英砂A");
|
||||
first.setQuantity(new BigDecimal("42.10"));
|
||||
first.setQuantityUnit("吨");
|
||||
Waybill second = child(CHILD_B, "running", null, ACCEPT_NO, at(1787671422000L));
|
||||
second.setCargoName("石英砂A");
|
||||
second.setQuantity(new BigDecimal("47.57"));
|
||||
second.setQuantityUnit("吨");
|
||||
stubLoadedTrip(first, second);
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals("石英砂A 89.67吨", trip.getCargoSummary());
|
||||
assertEquals(0, new BigDecimal("89.67").compareTo(trip.getTotalQuantity()));
|
||||
assertEquals("吨", trip.getQuantityUnit());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("一张运单录了多条货物时,货名与汇总都要全带上,不能只取主表首行")
|
||||
void multiCargoWaybillKeepsEveryCargo() {
|
||||
Waybill multi = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L));
|
||||
// 主表只落了首行:货名与单位都是第一行的,数量却是两行的合计
|
||||
multi.setCargoName("阿什顿");
|
||||
multi.setCargoType("服饰");
|
||||
multi.setQuantity(new BigDecimal("70.00"));
|
||||
multi.setQuantityUnit("堆");
|
||||
multi.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"cargoType\":\"服饰\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"},"
|
||||
+ "{\"cargoName\":\"显卡050\",\"cargoType\":\"显卡\",\"quantity\":\"50\",\"quantityUnit\":\"方\"}]");
|
||||
stubLoadedTrip(multi);
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertEquals(List.of("阿什顿", "显卡050"), trip.getCargoNames(), "两种货物都要在,不能只剩主表首行");
|
||||
assertEquals(List.of("阿什顿", "显卡050"), trip.getWaybills().get(0).getCargoNames(), "子单也要带上完整货名");
|
||||
assertEquals("阿什顿 20堆、显卡050 50方", trip.getCargoSummary());
|
||||
assertNull(trip.getTotalQuantity(), "两种货物单位不同,不能把 20堆+50方 说成 70堆");
|
||||
assertNull(trip.getQuantityUnit());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计重合计:单位不一致时只合计吨项,堆方不参与;全是吨时就是吨合计")
|
||||
void weighingOnlyCountsTonsWhenUnitsDiffer() {
|
||||
Waybill mixed = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L));
|
||||
mixed.setCargoName("阿什顿");
|
||||
mixed.setQuantity(new BigDecimal("100"));
|
||||
mixed.setQuantityUnit("吨");
|
||||
mixed.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"quantity\":\"100\",\"quantityUnit\":\"吨\"},"
|
||||
+ "{\"cargoName\":\"显卡050\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"}]");
|
||||
stubLoadedTrip(mixed);
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertNull(trip.getTotalQuantity(), "单位不一致,合计重量仍然为空");
|
||||
assertEquals(0, new BigDecimal("100").compareTo(trip.getWeighingQuantity()), "计重合计只算吨项");
|
||||
assertEquals("吨", trip.getWeighingUnit());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("计重合计:没有吨项且单位不一致时不出合计,合计重量同样为空")
|
||||
void weighingEmptyWhenNoTonAndUnitsDiffer() {
|
||||
Waybill mixed = child(CHILD_A, "running", null, ACCEPT_NO, at(1787657033000L));
|
||||
mixed.setCargoName("阿什顿");
|
||||
mixed.setQuantity(new BigDecimal("70.00"));
|
||||
mixed.setQuantityUnit("堆");
|
||||
mixed.setGoodsJson("[{\"cargoName\":\"阿什顿\",\"quantity\":\"20\",\"quantityUnit\":\"堆\"},"
|
||||
+ "{\"cargoName\":\"显卡050\",\"quantity\":\"50\",\"quantityUnit\":\"方\"}]");
|
||||
stubLoadedTrip(mixed);
|
||||
|
||||
TripVO trip = onlyTrip();
|
||||
|
||||
assertNull(trip.getWeighingQuantity(), "没有吨项就不出计重合计");
|
||||
assertNull(trip.getWeighingUnit());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Wrapper<Waybill> anyWaybillWrapper() {
|
||||
return ArgumentMatchers.<Wrapper<Waybill>>any();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Wrapper<LoadingManage> anyLoadingWrapper() {
|
||||
return ArgumentMatchers.<Wrapper<LoadingManage>>any();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static Wrapper<ProcessConfig> anyConfigWrapper() {
|
||||
return ArgumentMatchers.<Wrapper<ProcessConfig>>any();
|
||||
}
|
||||
|
||||
/** 配载运次:段一给一行分组行,段二给配载单与这一组的子单 */
|
||||
private void stubLoadedTrip(Waybill... children) {
|
||||
when(loadingManageService.list(anyLoadingWrapper())).thenReturn(List.of(loading()));
|
||||
when(waybillService.list(anyWaybillWrapper())).thenReturn(List.of(children));
|
||||
}
|
||||
|
||||
private TripVO onlyTrip() {
|
||||
return onlyTrip(TripQueryVO.forDriver(PHONE));
|
||||
}
|
||||
|
||||
private TripVO onlyTrip(TripQueryVO query) {
|
||||
IPage<TripVO> page = service.page(query);
|
||||
assertEquals(1, page.getRecords().size(), "本页应只有一个运次");
|
||||
return page.getRecords().get(0);
|
||||
}
|
||||
|
||||
private TripQueryVO detailQuery() {
|
||||
TripQueryVO query = TripQueryVO.forDriver(PHONE);
|
||||
query.setTripId(String.valueOf(LOADING_ID));
|
||||
return query;
|
||||
}
|
||||
|
||||
private LoadingManage loading() {
|
||||
LoadingManage loading = new LoadingManage();
|
||||
loading.setId(LOADING_ID);
|
||||
loading.setLoadingNo(LOADING_NO);
|
||||
loading.setDriverPhone(PHONE);
|
||||
loading.setDriverName("黄宇富");
|
||||
loading.setVehicleNo("桂A32523");
|
||||
loading.setProjectName("武宣建筑原料运输项目");
|
||||
return loading;
|
||||
}
|
||||
|
||||
private Waybill child(Long id, String businessStatus, String acceptStatus, String processJson, Date createTime) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(id);
|
||||
waybill.setWaybillNo("YD-" + id);
|
||||
waybill.setLoadingNo(LOADING_NO);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setDriverAcceptStatus(acceptStatus);
|
||||
waybill.setDriverPhone(PHONE);
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setCreateTime(createTime);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
private Date at(long millis) {
|
||||
return new Date(millis);
|
||||
}
|
||||
|
||||
}
|
||||
+218
@@ -0,0 +1,218 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.pojo.entity.LoadingManage;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.TripStationVO;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 运次站点拼装:配载单路线信息 → 站点 ①②③,站点标记指向配载单运单集合里的第 N 张子运单。
|
||||
* <p>
|
||||
* 夹具用的是本地库实测的配载单数据(路线信息、运单集合顺序与四张子运单的货名/货主/数量),
|
||||
* 单元测试里没有库,所以「第 N 张」只能按运单集合的位次断言。
|
||||
*/
|
||||
class TripStationAssemblerTest {
|
||||
|
||||
/** PZ-20261017-001 的路线信息:三站,标记分别是 装1 / 卸1,装2,装3,装4 / 卸2,卸3,卸4 */
|
||||
private static final String ROUTE_JSON = "["
|
||||
+ "{\"address\":\"广西壮族自治区南宁市南宁吴圩机场\",\"displayAddress\":\"广西壮族自治区南宁市南宁吴圩机场\","
|
||||
+ "\"tags\":[\"装1\"],\"locked\":false},"
|
||||
+ "{\"address\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\","
|
||||
+ "\"displayAddress\":\"广西壮族自治区北海市合浦县白沙镇广西新福兴硅科技有限公司\","
|
||||
+ "\"tags\":[\"卸1\",\"装2\",\"装3\",\"装4\"],\"locked\":false},"
|
||||
+ "{\"address\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\","
|
||||
+ "\"displayAddress\":\"广西壮族自治区南宁市宾阳县宾州镇宾阳县宾阳县人民政府\","
|
||||
+ "\"tags\":[\"卸2\",\"卸3\",\"卸4\"],\"locked\":false}]";
|
||||
|
||||
private static final String WAYBILL_IDS_JSON =
|
||||
"[\"2102780956493299713\", \"2092186187239780353\", \"2092186187470467073\", \"2092186187571130369\"]";
|
||||
|
||||
@Test
|
||||
@DisplayName("路线信息成三站:第一站装货、第二站卸货并装货、第三站卸货,货物条目取子运单")
|
||||
void stationsFollowRouteTags() {
|
||||
LoadingManage loading = loading(ROUTE_JSON, WAYBILL_IDS_JSON);
|
||||
List<Waybill> children = List.of(
|
||||
child("2102780956493299713", "YD-20260923-0008", "技嘉4070显卡", "9/20测试mk001", "30.00"),
|
||||
child("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10"),
|
||||
child("2092186187470467073", "YD-20260825-0007", "石英砂A", "广西武宣冠超矿业有限公司", "47.57"),
|
||||
child("2092186187571130369", "YD-20260825-0008", "石英砂A", "广西武宣冠超矿业有限公司", "42.17"));
|
||||
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
|
||||
assertEquals(3, stations.size());
|
||||
assertEquals(List.of(1, 2, 3), stations.stream().map(TripStationVO::getIndex).toList());
|
||||
|
||||
TripStationVO first = stations.get(0);
|
||||
assertEquals("广西壮族自治区南宁市南宁吴圩机场", first.getName(), "站名取展示地址");
|
||||
assertEquals("装货", first.getOperationTypeLabel());
|
||||
assertEquals("load", first.getOperationType());
|
||||
assertEquals(List.of("装1"), first.getTags());
|
||||
assertEquals(1, first.getCargos().size());
|
||||
assertEquals("YD-20260923-0008", first.getCargos().get(0).getWaybillNo());
|
||||
assertEquals("技嘉4070显卡", first.getCargos().get(0).getCargoName());
|
||||
assertEquals("9/20测试mk001", first.getCargos().get(0).getCustomerName());
|
||||
assertEquals(0, new BigDecimal("30.00").compareTo(first.getCargos().get(0).getQuantity()));
|
||||
assertEquals("load", first.getCargos().get(0).getOperation());
|
||||
|
||||
TripStationVO second = stations.get(1);
|
||||
assertEquals("装卸", second.getOperationTypeLabel(), "同一站既卸又装");
|
||||
assertEquals(4, second.getCargos().size(), "卸 1 张 + 装 3 张");
|
||||
assertEquals("unload", second.getCargos().get(0).getOperation());
|
||||
assertEquals("YD-20260923-0008", second.getCargos().get(0).getWaybillNo());
|
||||
assertEquals(List.of("YD-20260825-0006", "YD-20260825-0007", "YD-20260825-0008"),
|
||||
second.getCargos().subList(1, 4).stream().map(TripStationVO.TripCargoVO::getWaybillNo).toList());
|
||||
assertEquals(List.of("42.10", "47.57", "42.17"),
|
||||
second.getCargos().subList(1, 4).stream()
|
||||
.map(cargo -> cargo.getQuantity().toPlainString()).toList());
|
||||
assertTrue(second.getCargos().subList(1, 4).stream()
|
||||
.allMatch(cargo -> "load".equals(cargo.getOperation())));
|
||||
|
||||
TripStationVO third = stations.get(2);
|
||||
assertEquals("卸货", third.getOperationTypeLabel());
|
||||
assertEquals(3, third.getCargos().size());
|
||||
assertTrue(third.getCargos().stream().allMatch(cargo -> "unload".equals(cargo.getOperation())));
|
||||
|
||||
assertEquals(first.getName(), TripStationAssembler.departureName(stations));
|
||||
assertEquals(second.getName(), TripStationAssembler.transitName(stations), "中间站按顺序连接");
|
||||
assertEquals(third.getName(), TripStationAssembler.arrivalName(stations));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("站点标记的第 N 张按配载单运单集合的位次算,被拒单看不到的子单只是缺一条明细")
|
||||
void tagOrderFollowsWaybillIdsNotVisibleChildren() {
|
||||
LoadingManage loading = loading(ROUTE_JSON, WAYBILL_IDS_JSON);
|
||||
// 第 1 张子单(2102780956493299713)在当前可见子单里不存在,但它仍占运单集合的第 1 位
|
||||
List<Waybill> children = List.of(
|
||||
child("2092186187239780353", "YD-20260825-0006", "石英砂A", "广西武宣冠超矿业有限公司", "42.10"));
|
||||
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
|
||||
assertTrue(stations.get(0).getCargos().isEmpty(), "标记指向的子单不可见时该站没有明细");
|
||||
assertEquals("load", stations.get(1).getCargos().get(0).getOperation());
|
||||
assertEquals("YD-20260825-0006", stations.get(1).getCargos().get(0).getWaybillNo(),
|
||||
"装2 仍指向运单集合的第 2 张");
|
||||
assertEquals(1, stations.get(2).getCargos().size());
|
||||
assertEquals("unload", stations.get(2).getCargos().get(0).getOperation());
|
||||
assertEquals("YD-20260825-0006", stations.get(2).getCargos().get(0).getWaybillNo(),
|
||||
"卸2 也指向运单集合的第 2 张");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有路线信息时回退子运单起讫点去重成站,保留首次出现顺序")
|
||||
void fallsBackToWaybillPoints() {
|
||||
LoadingManage loading = loading(null, WAYBILL_IDS_JSON);
|
||||
List<Waybill> children = List.of(
|
||||
points("2102786022868598785", "YD-20260923-0010", "广西壮族自治区南宁市宾阳县", "广西壮族自治区来宾市武宣县"),
|
||||
points("2102786023208337409", "YD-20260923-0011", "广西壮族自治区来宾市武宣县", "福建省三明市三明沙县机场"),
|
||||
points("2102786023485161474", "YD-20260923-0012", "福建省三明市三明沙县机场", "广西壮族自治区北海市合浦县白沙镇"));
|
||||
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading, children);
|
||||
|
||||
assertEquals(List.of(
|
||||
"广西壮族自治区南宁市宾阳县",
|
||||
"广西壮族自治区来宾市武宣县",
|
||||
"福建省三明市三明沙县机场",
|
||||
"广西壮族自治区北海市合浦县白沙镇"),
|
||||
stations.stream().map(TripStationVO::getName).toList());
|
||||
assertEquals("装货", stations.get(0).getOperationTypeLabel());
|
||||
assertEquals("装卸", stations.get(1).getOperationTypeLabel(), "上一张的终点=下一张的起点");
|
||||
assertEquals("卸货", stations.get(3).getOperationTypeLabel());
|
||||
assertEquals(2, stations.get(1).getCargos().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("起讫点没有名称时用地址,名称与地址都没有才退到配载单自己的地址")
|
||||
void fallsBackToAddressThenLoadingAddress() {
|
||||
LoadingManage loading = loading(null, WAYBILL_IDS_JSON);
|
||||
loading.setDepartureAddress("配载单发货地址");
|
||||
loading.setTransitAddress("配载单途经地址");
|
||||
loading.setArrivalAddress("配载单到货地址");
|
||||
List<Waybill> addressOnly = List.of(points("1", "YD-1", null, null));
|
||||
|
||||
List<TripStationVO> fromLoading = TripStationAssembler.assemble(loading, addressOnly);
|
||||
|
||||
assertEquals(List.of("配载单发货地址", "配载单途经地址", "配载单到货地址"),
|
||||
fromLoading.stream().map(TripStationVO::getName).toList());
|
||||
assertNull(fromLoading.get(0).getOperationType(), "退到配载单地址时无从判定站点操作");
|
||||
assertTrue(fromLoading.get(0).getCargos().isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("站点、起讫点、货物全为空时不抛异常,下发空站点数组")
|
||||
void emptyEverythingStaysEmpty() {
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading(null, null), List.of(points("1", "YD-1", null, null)));
|
||||
|
||||
assertTrue(stations.isEmpty());
|
||||
assertEquals("", TripStationAssembler.departureName(stations));
|
||||
assertEquals("", TripStationAssembler.transitName(stations));
|
||||
assertEquals("", TripStationAssembler.arrivalName(stations));
|
||||
assertEquals("", TripStationAssembler.transitName(TripStationAssembler.assemble(null, List.of())));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("未配载运单成两点:起点装货、终点卸货")
|
||||
void unloadedWaybillBecomesTwoPoints() {
|
||||
List<Waybill> children = List.of(points("2101508430981652482", "YD-20260920-0003", "南宁武鸣", "钦州港"));
|
||||
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(null, children);
|
||||
|
||||
assertEquals(2, stations.size());
|
||||
assertEquals("南宁武鸣", stations.get(0).getName());
|
||||
assertEquals("装货", stations.get(0).getOperationTypeLabel());
|
||||
assertEquals("钦州港", stations.get(1).getName());
|
||||
assertEquals("卸货", stations.get(1).getOperationTypeLabel());
|
||||
assertNotNull(stations.get(0).getCargos());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("路线信息坏掉时按没有路线信息处理,不抛异常")
|
||||
void brokenRouteJsonFallsBack() {
|
||||
List<Waybill> children = List.of(points("1", "YD-1", "起点", "终点"));
|
||||
|
||||
List<TripStationVO> stations = TripStationAssembler.assemble(loading("{不是数组", WAYBILL_IDS_JSON), children);
|
||||
|
||||
assertEquals(2, stations.size());
|
||||
assertEquals("起点", stations.get(0).getName());
|
||||
}
|
||||
|
||||
private LoadingManage loading(String routeJson, String waybillIdsJson) {
|
||||
LoadingManage loading = new LoadingManage();
|
||||
loading.setRouteJson(routeJson);
|
||||
loading.setWaybillIdsJson(waybillIdsJson);
|
||||
return loading;
|
||||
}
|
||||
|
||||
private Waybill child(String id, String waybillNo, String cargoName, String customerName, String quantity) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(Long.valueOf(id));
|
||||
waybill.setWaybillNo(waybillNo);
|
||||
waybill.setCargoName(cargoName);
|
||||
waybill.setCustomerName(customerName);
|
||||
waybill.setQuantity(new BigDecimal(quantity));
|
||||
waybill.setQuantityUnit("吨");
|
||||
waybill.setProjectName("武宣建筑原料运输项目");
|
||||
return waybill;
|
||||
}
|
||||
|
||||
/** 只有起讫点的子运单(名称与地址都取自参数) */
|
||||
private Waybill points(String id, String waybillNo, String departure, String arrival) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(Long.valueOf(id));
|
||||
waybill.setWaybillNo(waybillNo);
|
||||
waybill.setDepartureName(departure);
|
||||
waybill.setDepartureAddress(departure);
|
||||
waybill.setArrivalName(arrival);
|
||||
waybill.setArrivalAddress(arrival);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
}
|
||||
+229
@@ -0,0 +1,229 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.log.exception.ServiceException;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.transport.mapper.ExceptionDisposalMapper;
|
||||
import org.springblade.transport.mapper.WaybillEnroutePunchMapper;
|
||||
import org.springblade.transport.mapper.WaybillMapper;
|
||||
import org.springblade.transport.mapper.WaybillNodePunchMapper;
|
||||
import org.springblade.transport.pojo.entity.ExceptionDisposal;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.entity.WaybillNodePunch;
|
||||
import org.springblade.transport.service.ILoadingManageService;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
import org.springblade.transport.service.IReceivablePayableDetailService;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.List;
|
||||
|
||||
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.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 确认回单:回单节点要求业务员确认时,内部人员确认后把运单置为已完成。
|
||||
* <p>
|
||||
* 完整入口不校验节点打卡,故这里必须自己挡住两件事——该运单的司机打卡节点(在途除外)全部已打卡、
|
||||
* 无未了结异常;不挡住就成了绕过打卡门禁与异常门禁的通道。回单不要求确认时不能走这个入口。
|
||||
*/
|
||||
class WaybillConfirmReturnTest {
|
||||
|
||||
private static final Long WAYBILL_ID = 5701L;
|
||||
private static final String DEPT_ID = "1123598813738675201";
|
||||
|
||||
private static final String RETURN_NEED_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
private static final String RETURN_NO_CONFIRM_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"no_confirm_complete"}]
|
||||
""";
|
||||
|
||||
/** 带在途节点:在途是频次打卡节点、不是必经一步,没打不该拦住确认回单 */
|
||||
private static final String WITH_TRANSIT_JSON = """
|
||||
[{"key":"load","name":"装货","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"transit","name":"在途","type":"transit","enabled":true,"punch":true},
|
||||
{"key":"sign","name":"签收","type":"cargo","enabled":true,"punch":true},
|
||||
{"key":"return","name":"回单","type":"confirm","enabled":true,"punch":true,"confirmMode":"yes"}]
|
||||
""";
|
||||
|
||||
private WaybillServiceImpl service;
|
||||
private WaybillMapper waybillMapper;
|
||||
private WaybillNodePunchMapper nodePunchMapper;
|
||||
private ExceptionDisposalMapper exceptionDisposalMapper;
|
||||
private ILoadingManageService loadingManageService;
|
||||
private IReceivablePayableDetailService receivablePayableDetailService;
|
||||
private MockedStatic<AuthUtil> authUtil;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
TableInfoHelper.initTableInfo(assistant, WaybillNodePunch.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ExceptionDisposal.class);
|
||||
TableInfoHelper.initTableInfo(assistant, ProcessConfig.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
waybillMapper = mock(WaybillMapper.class);
|
||||
nodePunchMapper = mock(WaybillNodePunchMapper.class);
|
||||
exceptionDisposalMapper = mock(ExceptionDisposalMapper.class);
|
||||
loadingManageService = mock(ILoadingManageService.class);
|
||||
receivablePayableDetailService = mock(IReceivablePayableDetailService.class);
|
||||
IProcessConfigService processConfigService = mock(IProcessConfigService.class);
|
||||
when(processConfigService.list(org.mockito.ArgumentMatchers.<Wrapper<ProcessConfig>>any())).thenReturn(List.of());
|
||||
|
||||
service = new WaybillServiceImpl();
|
||||
inject("baseMapper", waybillMapper);
|
||||
inject("waybillNodePunchMapper", nodePunchMapper);
|
||||
inject("waybillEnroutePunchMapper", mock(WaybillEnroutePunchMapper.class));
|
||||
inject("exceptionDisposalMapper", exceptionDisposalMapper);
|
||||
inject("processConfigService", processConfigService);
|
||||
inject("loadingManageService", loadingManageService);
|
||||
inject("receivablePayableDetailService", receivablePayableDetailService);
|
||||
|
||||
when(waybillMapper.updateById(any(Waybill.class))).thenReturn(1);
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(List.of());
|
||||
when(exceptionDisposalMapper.selectCount(any())).thenReturn(0L);
|
||||
authUtil = mockStatic(AuthUtil.class);
|
||||
authUtil.when(AuthUtil::getDeptId).thenReturn(DEPT_ID);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
authUtil.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单要求确认且节点都打完:置为已完成,并照常生成应收应付、尝试完成配载单")
|
||||
void confirmsReturnAndCompletesWaybill() {
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON);
|
||||
stubPunches("load", "sign");
|
||||
|
||||
boolean result = service.confirmReturnReceipt(WAYBILL_ID);
|
||||
|
||||
assertEquals(true, result);
|
||||
verify(waybillMapper).updateById(org.mockito.ArgumentMatchers.<Waybill>argThat(waybill ->
|
||||
"completed".equals(waybill.getBusinessStatus()) && WAYBILL_ID.equals(waybill.getId())));
|
||||
verify(receivablePayableDetailService).generateForCompletedWaybills(List.of(WAYBILL_ID));
|
||||
verify(loadingManageService).completeIfAllWaybillsCompleted(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途节点没打卡也放行:它不是必经一步")
|
||||
void transitNodeDoesNotBlockConfirmation() {
|
||||
stubWaybill(WITH_TRANSIT_JSON);
|
||||
stubPunches("load", "sign");
|
||||
|
||||
assertEquals(true, service.confirmReturnReceipt(WAYBILL_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("还有节点没打卡:拒绝并点名缺哪个节点,不改状态")
|
||||
void unfinishedPunchNodeBlocksConfirmation() {
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON);
|
||||
stubPunches("load");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.confirmReturnReceipt(WAYBILL_ID));
|
||||
|
||||
assertEquals("该运单还有未打卡的节点:签收", exception.getMessage());
|
||||
verify(waybillMapper, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("有未了结异常:拒绝,不改状态")
|
||||
void unresolvedExceptionBlocksConfirmation() {
|
||||
stubWaybill(RETURN_NEED_CONFIRM_JSON);
|
||||
stubPunches("load", "sign");
|
||||
when(exceptionDisposalMapper.selectCount(any())).thenReturn(1L);
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.confirmReturnReceipt(WAYBILL_ID));
|
||||
|
||||
assertEquals("该运单存在未处理异常,处理完成后才能确认回单", exception.getMessage());
|
||||
verify(waybillMapper, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("回单不要求业务员确认:这个入口不适用,说清而不是静默完成")
|
||||
void returnWithoutConfirmationIsRejected() {
|
||||
stubWaybill(RETURN_NO_CONFIRM_JSON);
|
||||
stubPunches("load", "sign");
|
||||
|
||||
ServiceException exception = assertThrows(ServiceException.class,
|
||||
() -> service.confirmReturnReceipt(WAYBILL_ID));
|
||||
|
||||
assertEquals("该运单的回单不需要业务员确认", exception.getMessage());
|
||||
verify(waybillMapper, never()).updateById(any(Waybill.class));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private void stubWaybill(String processJson) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setId(WAYBILL_ID);
|
||||
waybill.setWaybillNo("YD202610180002");
|
||||
waybill.setDeptId(Long.valueOf(DEPT_ID));
|
||||
waybill.setProjectId(7001L);
|
||||
waybill.setProcessJson(processJson);
|
||||
waybill.setBusinessStatus("running");
|
||||
waybill.setLoadingNo("PZ-20261018-001");
|
||||
waybill.setIsDeleted(0);
|
||||
when(waybillMapper.selectById(WAYBILL_ID)).thenReturn(waybill);
|
||||
}
|
||||
|
||||
private void stubPunches(String... nodeCodes) {
|
||||
List<WaybillNodePunch> punches = java.util.Arrays.stream(nodeCodes).map(code -> {
|
||||
WaybillNodePunch punch = new WaybillNodePunch();
|
||||
punch.setWaybillId(WAYBILL_ID);
|
||||
punch.setNodeCode(code);
|
||||
return punch;
|
||||
}).toList();
|
||||
when(nodePunchMapper.selectList(any())).thenReturn(punches);
|
||||
}
|
||||
|
||||
private void inject(String field, Object value) throws Exception {
|
||||
Class<?> type = service.getClass();
|
||||
while (type != null) {
|
||||
try {
|
||||
Field declared = type.getDeclaredField(field);
|
||||
declared.setAccessible(true);
|
||||
declared.set(service, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
type = type.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("未找到字段:" + field);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
private static Wrapper<Waybill> anyWaybillWrapper() {
|
||||
return org.mockito.ArgumentMatchers.any();
|
||||
}
|
||||
|
||||
}
|
||||
+171
@@ -0,0 +1,171 @@
|
||||
package org.springblade.transport.service.impl;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.MockedStatic;
|
||||
import org.springblade.core.secure.utils.AuthUtil;
|
||||
import org.springblade.transport.mapper.WaybillMapper;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
import org.springblade.transport.pojo.vo.WaybillVO;
|
||||
import org.springblade.transport.support.LoadableAdmissionSupport;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.mockStatic;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 运单列表的可配载取数:开关一次带上整条判据,另支持按主键集合回查。
|
||||
* <p>
|
||||
* 候选列表与「公路配载」按钮预检都走这次查询,所以断言下在生成的 SQL 片段上——
|
||||
* 开关不启用时默认列表不能被顺手改口径,那是全部运单入口共用的查询。
|
||||
*/
|
||||
class WaybillLoadableQueryTest {
|
||||
|
||||
private WaybillMapper waybillMapper;
|
||||
private WaybillServiceImpl service;
|
||||
private MockedStatic<AuthUtil> authUtil;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, Waybill.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
waybillMapper = mock(WaybillMapper.class);
|
||||
service = new WaybillServiceImpl();
|
||||
inject("baseMapper", waybillMapper);
|
||||
when(waybillMapper.selectPage(any(), any())).thenReturn(new Page<Waybill>(1, 10));
|
||||
// 组织过滤只看当前用户;超级管理员不带组织条件,断言里就只剩本用例关心的条件
|
||||
authUtil = mockStatic(AuthUtil.class);
|
||||
when(AuthUtil.getUserRole()).thenReturn("administrator");
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() {
|
||||
authUtil.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("开关开启:公路词表、待执行、未配载一次全部进 wrapper,接单状态不参与")
|
||||
void loadableSwitchAppliesWholePredicate() {
|
||||
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(loadableQuery());
|
||||
String sql = String.valueOf(wrapper.getTargetSql());
|
||||
Collection<Object> params = wrapper.getParamNameValuePairs().values();
|
||||
|
||||
assertTrue(sql.contains("transport_type"), "公路判定要落在运单自己的运输方式上:" + sql);
|
||||
for (String keyword : LoadableAdmissionSupport.ROAD_TRANSPORT_KEYWORDS) {
|
||||
assertTrue(params.contains("%" + keyword + "%"),
|
||||
"公路词表要整份进条件,只写 road 会漏掉中文取值的运单:" + params);
|
||||
}
|
||||
assertTrue(sql.contains("business_status =") && params.contains("pending"),
|
||||
"只放行待执行:" + sql + " " + params);
|
||||
assertFalse(params.contains("running"),
|
||||
"进行中一律不可配载,不能沿用「待执行与进行中都可配载」的旧口径:" + params);
|
||||
assertTrue(sql.contains("loading_no IS NULL") && sql.contains("loading_no ="),
|
||||
"未配载要同时覆盖 NULL 与空串:" + sql);
|
||||
assertFalse(sql.contains("driver_accept_status"),
|
||||
"接单状态不进可配载判据:拒单票照样可被重新配载:" + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("开关开启 + 主键集合:收窄到所选运单,用于按钮预检")
|
||||
void loadableSwitchNarrowsBySelectedIds() {
|
||||
WaybillVO query = loadableQuery();
|
||||
query.setIds(List.of(11L, 22L));
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(query);
|
||||
String sql = String.valueOf(wrapper.getTargetSql());
|
||||
Collection<Object> params = wrapper.getParamNameValuePairs().values();
|
||||
|
||||
assertTrue(sql.contains("id IN"), "按主键回查:" + sql);
|
||||
assertTrue(params.contains(11L) && params.contains(22L), "所选运单主键要进条件:" + params);
|
||||
assertTrue(sql.contains("transport_type"), "回查仍要带整条判据:" + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("只传主键集合:按主键收窄,不附带可配载判据")
|
||||
void idsNarrowWithoutTheSwitch() {
|
||||
WaybillVO query = new WaybillVO();
|
||||
query.setAllDept(1);
|
||||
query.setIds(List.of(33L));
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(query);
|
||||
String sql = String.valueOf(wrapper.getTargetSql());
|
||||
|
||||
assertTrue(sql.contains("id IN"), "按主键回查:" + sql);
|
||||
assertTrue(wrapper.getParamNameValuePairs().values().contains(33L), sql);
|
||||
assertFalse(sql.contains("transport_type"), "没开可配载开关就不加公路条件:" + sql);
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("开关未开启:运单管理默认列表条件与展示口径不变")
|
||||
void withoutSwitchTheDefaultListIsUntouched() {
|
||||
WaybillVO query = new WaybillVO();
|
||||
query.setAllDept(1);
|
||||
|
||||
LambdaQueryWrapper<Waybill> wrapper = capturedQuery(query);
|
||||
String sql = String.valueOf(wrapper.getTargetSql());
|
||||
|
||||
assertFalse(sql.contains("transport_type"), sql);
|
||||
assertFalse(sql.contains("business_status"), sql);
|
||||
assertFalse(sql.contains("loading_no"), sql);
|
||||
assertFalse(sql.contains("driver_accept_status"), sql);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 夹具
|
||||
|
||||
private WaybillVO loadableQuery() {
|
||||
WaybillVO query = new WaybillVO();
|
||||
query.setAllDept(1);
|
||||
query.setLoadableOnly(1);
|
||||
return query;
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<Waybill> capturedQuery(WaybillVO query) {
|
||||
IPage<WaybillVO> page = service.selectWaybillPage(new Page<>(1, 10), query);
|
||||
assertTrue(page.getRecords().isEmpty());
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<LambdaQueryWrapper<Waybill>> captor = ArgumentCaptor.forClass(LambdaQueryWrapper.class);
|
||||
verify(waybillMapper).selectPage(any(), captor.capture());
|
||||
return captor.getValue();
|
||||
}
|
||||
|
||||
private void inject(String field, Object value) throws Exception {
|
||||
Class<?> type = service.getClass();
|
||||
while (type != null) {
|
||||
try {
|
||||
Field declared = type.getDeclaredField(field);
|
||||
declared.setAccessible(true);
|
||||
declared.set(service, value);
|
||||
return;
|
||||
} catch (NoSuchFieldException ignored) {
|
||||
type = type.getSuperclass();
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("找不到字段:" + field);
|
||||
}
|
||||
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentMatchers;
|
||||
import org.springblade.transport.pojo.entity.ProcessConfig;
|
||||
import org.springblade.transport.service.IProcessConfigService;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* 生效配置的取法与「生效配置 vs 快照」二选一的唯一实现处。
|
||||
* <p>
|
||||
* 两条规则必须分得清:接单需求与节点门禁读 {@code resolve}(生效配置是权威,不能因为快照里有节点被顶掉),
|
||||
* 司机端打卡页读 {@code resolveForPunch}(生效配置被改坏成没有可打卡节点时回退快照,避免打卡页空白)。
|
||||
* 排序由 SQL 负责(更新时间倒序、创建时间倒序),这里按「已排好序的列表」构造输入。
|
||||
*/
|
||||
class LiveProcessConfigSupportTest {
|
||||
|
||||
/** 有可打卡节点(装货,punch=是) */
|
||||
private static final String PUNCH_JSON =
|
||||
"[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":true,\"location\":true}]";
|
||||
/** 一条可打卡节点都没有(装货被关掉 punch) */
|
||||
private static final String NO_PUNCH_JSON =
|
||||
"[{\"key\":\"load\",\"name\":\"装货\",\"enabled\":true,\"punch\":false}]";
|
||||
/** 另一个项目用的、与 PUNCH_JSON 可区分的一份 */
|
||||
private static final String OTHER_PUNCH_JSON =
|
||||
"[{\"key\":\"unload\",\"name\":\"卸货\",\"enabled\":true,\"punch\":true,\"location\":true}]";
|
||||
|
||||
private IProcessConfigService processConfigService;
|
||||
|
||||
@BeforeAll
|
||||
static void initTableInfo() {
|
||||
// 构造 LambdaQueryWrapper 需要 MyBatis-Plus 的实体元数据缓存
|
||||
MybatisConfiguration configuration = new MybatisConfiguration();
|
||||
MapperBuilderAssistant assistant = new MapperBuilderAssistant(configuration, "");
|
||||
assistant.setCurrentNamespace("test");
|
||||
TableInfoHelper.initTableInfo(assistant, ProcessConfig.class);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
processConfigService = mock(IProcessConfigService.class);
|
||||
}
|
||||
|
||||
private void stubConfigs(ProcessConfig... configs) {
|
||||
when(processConfigService.list(anyConfigWrapper())).thenReturn(List.of(configs));
|
||||
}
|
||||
|
||||
private static Wrapper<ProcessConfig> anyConfigWrapper() {
|
||||
return ArgumentMatchers.<Wrapper<ProcessConfig>>any();
|
||||
}
|
||||
|
||||
private static ProcessConfig config(String projectIds, String nodeConfigJson) {
|
||||
ProcessConfig config = new ProcessConfig();
|
||||
config.setProjectIds(projectIds);
|
||||
config.setNodeConfigJson(nodeConfigJson);
|
||||
return config;
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单项目:命中项目的第一条生效配置(列表已按更新时间倒序)")
|
||||
void singleProjectTakesFirstHit() {
|
||||
stubConfigs(config("999", OTHER_PUNCH_JSON), config("1234,5678", PUNCH_JSON));
|
||||
|
||||
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.byProject(processConfigService, 1234L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单项目:项目 id 按逗号分段精确比对,1234 不命中 12345")
|
||||
void singleProjectMatchesExactId() {
|
||||
stubConfigs(config("12345", PUNCH_JSON));
|
||||
|
||||
assertNull(LiveProcessConfigSupport.byProject(processConfigService, 1234L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单项目:没有生效配置、或项目为空时返回 null")
|
||||
void singleProjectWithoutConfig() {
|
||||
stubConfigs();
|
||||
|
||||
assertNull(LiveProcessConfigSupport.byProject(processConfigService, 1234L));
|
||||
assertNull(LiveProcessConfigSupport.byProject(processConfigService, null));
|
||||
assertNull(LiveProcessConfigSupport.byProject(null, 1234L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("单项目:节点配置为空的配置跳过,取后面那条有内容的")
|
||||
void singleProjectSkipsEmptyNodeJson() {
|
||||
stubConfigs(config("1234", ""), config("1234", PUNCH_JSON));
|
||||
|
||||
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.byProject(processConfigService, 1234L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("批量:一次查全后按项目分别命中,未命中的项目不进结果")
|
||||
void batchByProject() {
|
||||
stubConfigs(config("1234", PUNCH_JSON), config("5678", OTHER_PUNCH_JSON));
|
||||
|
||||
Map<Long, String> live = LiveProcessConfigSupport.byProjects(processConfigService, List.of(1234L, 5678L, 4321L));
|
||||
|
||||
assertEquals(2, live.size());
|
||||
assertEquals(PUNCH_JSON, live.get(1234L));
|
||||
assertEquals(OTHER_PUNCH_JSON, live.get(5678L));
|
||||
assertTrue(live.containsKey(1234L));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolve:生效配置优先,取不到才用快照")
|
||||
void resolvePrefersLive() {
|
||||
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolve(PUNCH_JSON, NO_PUNCH_JSON));
|
||||
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolve(null, NO_PUNCH_JSON));
|
||||
assertNull(LiveProcessConfigSupport.resolve(null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("resolveForPunch:生效配置没有可打卡节点时才回退快照,两边都没有节点仍用生效配置")
|
||||
void resolveForPunchFallsBackToSnapshot() {
|
||||
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(PUNCH_JSON, NO_PUNCH_JSON),
|
||||
"生效配置有可打卡节点:用生效配置");
|
||||
assertEquals(PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, PUNCH_JSON),
|
||||
"生效配置没有可打卡节点、快照有:回退快照");
|
||||
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, NO_PUNCH_JSON),
|
||||
"两边都没有可打卡节点:用生效配置");
|
||||
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(null, NO_PUNCH_JSON),
|
||||
"项目没有生效配置:用快照");
|
||||
assertEquals(NO_PUNCH_JSON, LiveProcessConfigSupport.resolveForPunch(NO_PUNCH_JSON, null),
|
||||
"快照为空:用生效配置");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("生效配置只查一次:批量取法与单项目取法都不逐条回查项目")
|
||||
void configQueryIsInvokedOncePerCall() {
|
||||
stubConfigs(config("1234", PUNCH_JSON), config("5678", OTHER_PUNCH_JSON));
|
||||
|
||||
LiveProcessConfigSupport.byProjects(processConfigService, List.of(1234L, 5678L));
|
||||
|
||||
verify(processConfigService, times(1)).list(ArgumentMatchers.<Wrapper<ProcessConfig>>any());
|
||||
verify(processConfigService, never()).getById(any());
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
package org.springblade.transport.support;
|
||||
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springblade.transport.pojo.entity.Waybill;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* 可配载准入判据:公路词表与单张运单的准入检查。
|
||||
* <p>
|
||||
* 判据是读侧候选列表与写侧提交/编辑/重新派单校验共用的同一份定义。
|
||||
*/
|
||||
class LoadableAdmissionSupportTest {
|
||||
|
||||
// ------------------------------------------------------------ 公路词表
|
||||
|
||||
@Test
|
||||
@DisplayName("公路:road 与中文同义写法都命中")
|
||||
void roadVocabularyHits() {
|
||||
assertTrue(LoadableAdmissionSupport.isRoadTransport("road"));
|
||||
assertTrue(LoadableAdmissionSupport.isRoadTransport("ROAD"));
|
||||
assertTrue(LoadableAdmissionSupport.isRoadTransport("公路运输"));
|
||||
assertTrue(LoadableAdmissionSupport.isRoadTransport("公路"));
|
||||
assertTrue(LoadableAdmissionSupport.isRoadTransport("道路运输"));
|
||||
assertTrue(LoadableAdmissionSupport.isRoadTransport("汽车运输"));
|
||||
assertTrue(LoadableAdmissionSupport.isRoadTransport("陆运"));
|
||||
assertTrue(LoadableAdmissionSupport.isRoadTransport("highway"));
|
||||
assertTrue(LoadableAdmissionSupport.isRoadTransport("gl"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非公路与空值一律不命中——未知运输方式按非公路拒绝,不能放行")
|
||||
void nonRoadVocabularyMisses() {
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport("railway"));
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport("water"));
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport("air"));
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport("river"));
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport("水路运输"));
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport("铁路运输"));
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport("航空运输"));
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport(""));
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport(" "));
|
||||
assertFalse(LoadableAdmissionSupport.isRoadTransport(null));
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 单张运单准入
|
||||
|
||||
@Test
|
||||
@DisplayName("公路 + 待执行 + 未配载才可配载")
|
||||
void loadableOnlyWhenAllConditionsHold() {
|
||||
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "pending"), null));
|
||||
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("公路运输", "pending", "", null), null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("已拒单的票照样可配载:接单事实由配载提交清除,不靠把拒单票挡在候选之外")
|
||||
void rejectedWaybillIsLoadable() {
|
||||
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"), null));
|
||||
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, "rejected"), "PZ20260101-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("非公路、非待执行、已关联配载单各自单独即可拒绝")
|
||||
void eachConditionRejectsOnItsOwn() {
|
||||
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("railway", "pending", null, "pending"), null),
|
||||
"非公路不可配载");
|
||||
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "running", null, null), null),
|
||||
"进行中不可配载:不需要接单的项目保存后即为进行中,那是过程配置的产物,不代表可配载");
|
||||
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "draft", null, null), null));
|
||||
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "completed", null, null), null));
|
||||
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "cancelled", null, null), null));
|
||||
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null), null),
|
||||
"已挂在其它配载单上的票不可再配载");
|
||||
assertFalse(LoadableAdmissionSupport.isLoadable(null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("不可配载原因按 公路 → 待执行 → 未关联其它配载单 取第一条")
|
||||
void unloadableReasonFollowsPredicateOrder() {
|
||||
assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_ROAD,
|
||||
LoadableAdmissionSupport.unloadableReason(
|
||||
waybill("railway", "running", "PZ20260101-002", null), "PZ20260101-001"));
|
||||
assertEquals(LoadableAdmissionSupport.UnloadableReason.NOT_PENDING,
|
||||
LoadableAdmissionSupport.unloadableReason(
|
||||
waybill("road", "running", "PZ20260101-002", null), "PZ20260101-001"));
|
||||
assertEquals(LoadableAdmissionSupport.UnloadableReason.ON_OTHER_MANIFEST,
|
||||
LoadableAdmissionSupport.unloadableReason(
|
||||
waybill("road", "pending", "PZ20260101-002", null), "PZ20260101-001"));
|
||||
assertNull(LoadableAdmissionSupport.unloadableReason(waybill("road", "pending", null, null), null));
|
||||
assertNull(LoadableAdmissionSupport.unloadableReason(
|
||||
waybill("road", "pending", "PZ20260101-001", null), "PZ20260101-001"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("编辑/重新派单:loadingNo 等于本配载单号时不算冲突,其它配载单号仍拒绝")
|
||||
void ownLoadingNoIsNotAConflict() {
|
||||
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-001", null), "PZ20260101-001"));
|
||||
assertTrue(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", null, null), "PZ20260101-001"));
|
||||
assertFalse(LoadableAdmissionSupport.isLoadable(waybill("road", "pending", "PZ20260101-002", null), "PZ20260101-001"),
|
||||
"挂在别的配载单上时即使正在编辑本单也不能合进来");
|
||||
}
|
||||
|
||||
private Waybill waybill(String transportType, String businessStatus, String loadingNo, String driverAcceptStatus) {
|
||||
Waybill waybill = new Waybill();
|
||||
waybill.setTransportType(transportType);
|
||||
waybill.setBusinessStatus(businessStatus);
|
||||
waybill.setLoadingNo(loadingNo);
|
||||
waybill.setDriverAcceptStatus(driverAcceptStatus);
|
||||
return waybill;
|
||||
}
|
||||
|
||||
}
|
||||
+4
@@ -124,6 +124,8 @@ class OcrFieldSupportTest {
|
||||
{
|
||||
"words_result": {
|
||||
"姓名": {"words": "黄宇富"},
|
||||
"出生": {"words": "1979年11月17日"},
|
||||
"住址": {"words": "广西壮族自治区柳州市柳北区跃进路88号"},
|
||||
"公民身份号码": {"words": "452730197911172318"},
|
||||
"签发日期": {"words": "2015-03-12"},
|
||||
"失效日期": {"words": "2035-03-12"}
|
||||
@@ -133,6 +135,8 @@ class OcrFieldSupportTest {
|
||||
Map<String, Object> person = OcrFieldSupport.extract(BaiduOcrType.ID_CARD, "front", idCard);
|
||||
assertEquals("黄宇富", person.get("realName"));
|
||||
assertEquals("452730197911172318", person.get("idCardNo"));
|
||||
assertEquals("1979-11-17", person.get("birthday"), "「出生」是一串中文日期,要归一成 yyyy-MM-dd 才能填进日期框");
|
||||
assertEquals("广西壮族自治区柳州市柳北区跃进路88号", person.get("address"));
|
||||
assertEquals("2015-03-12", person.get("idCardValidStart"));
|
||||
assertEquals("2035-03-12", person.get("idCardValidEnd"));
|
||||
|
||||
|
||||
+148
@@ -3,14 +3,18 @@ package org.springblade.transport.support;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
@@ -123,6 +127,24 @@ class WaybillProcessSupportTest {
|
||||
"「到货」还没打,所以卸货的前置是到货;在途不是前置(它没有完成含义)");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("顺序门禁只点名最先未完成的那一个前置节点,不把没打的前置全列出来")
|
||||
void namesOnlyTheEarliestUnfinishedPrerequisite() {
|
||||
Set<String> unfinished = WaybillProcessSupport.unfinishedPrerequisiteKeys(
|
||||
TRANSPORT_JSON, Collections.emptySet(), "arrival");
|
||||
|
||||
assertEquals(keys("arrive_scene", "load", "dispatch"), unfinished, "到货前面三个打卡节点都没打");
|
||||
assertEquals("到场", WaybillProcessSupport.firstUnfinishedPrerequisiteName(TRANSPORT_JSON, unfinished),
|
||||
"只报司机现在能去打卡的那一个:装货、发货自己也还打不了,列出来不是可执行的下一步");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("没有未完成的前置节点时不点名")
|
||||
void namesNothingWhenNothingIsUnfinished() {
|
||||
assertEquals("",
|
||||
WaybillProcessSupport.firstUnfinishedPrerequisiteName(TRANSPORT_JSON, Collections.emptySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("顺序门禁:在途豁免,前置一个都没打也返回空集")
|
||||
void transitIsExemptFromOrderGate() {
|
||||
@@ -312,4 +334,130 @@ class WaybillProcessSupportTest {
|
||||
"TRANSPORT_JSON 的回单启用且没有 confirmMode:缺字段当没配「无需确认完成」,否则会跳过本该人工确认的回单");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 结束门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("结束门禁:只认已完成与已取消,大小写不敏感")
|
||||
void punchEndedOnlyForCompletedAndCancelled() {
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("completed"));
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("COMPLETED"));
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("cancelled"));
|
||||
assertTrue(WaybillProcessSupport.isPunchEnded("Cancelled"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("结束门禁:草稿、待执行、待调度、调度中都不算结束")
|
||||
void punchEndedExcludesStatusesThatStillAllowPunching() {
|
||||
for (String status : List.of("draft", "pending", "waiting_dispatch", "dispatching", "running")) {
|
||||
assertFalse(WaybillProcessSupport.isPunchEnded(status), status + " 不是结束态");
|
||||
assertNull(WaybillProcessSupport.punchEndedReason(status), status + " 不该有结束文案");
|
||||
}
|
||||
assertFalse(WaybillProcessSupport.isPunchEnded(null));
|
||||
assertFalse(WaybillProcessSupport.isPunchEnded(""));
|
||||
assertFalse(WaybillProcessSupport.isPunchEnded(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("结束门禁文案:完成与取消各一句,非结束态没有文案")
|
||||
void punchEndedReasonOnlyForEndedStatuses() {
|
||||
assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason("completed"));
|
||||
assertEquals("运单已完成", WaybillProcessSupport.punchEndedReason(" COMPLETED "));
|
||||
assertEquals("运单已取消", WaybillProcessSupport.punchEndedReason("cancelled"));
|
||||
assertNull(WaybillProcessSupport.punchEndedReason(null));
|
||||
assertNull(WaybillProcessSupport.punchEndedReason(""));
|
||||
assertNull(WaybillProcessSupport.punchEndedReason(" "));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途卡:已完成的运单今天打过卡也不可再打,且不算今日已打")
|
||||
void transitCardOnEndedWaybillIsVisibleButNotPunchable() {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0);
|
||||
// 打卡时间就落在 now 所在的那一天
|
||||
Date lastPunchAt = Date.from(now.toLocalDate().atStartOfDay(ZoneId.systemDefault()).plusHours(8).toInstant());
|
||||
|
||||
WaybillProcessSupport.TransitCheckinDecision decision =
|
||||
WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "completed", lastPunchAt, now);
|
||||
|
||||
assertTrue(decision.visible(), "卡片仍要下发,司机能回看今天打过没有");
|
||||
assertFalse(decision.punchEnabled(), "运单已结束,不能提交打卡");
|
||||
assertFalse(decision.doneToday(), "结束不是「今日已打卡」,两者的后续动作不同");
|
||||
assertFalse(decision.dueToday());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途卡回归:进行中且今天没打卡时仍可打")
|
||||
void transitCardOnRunningWaybillStaysPunchable() {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0);
|
||||
|
||||
WaybillProcessSupport.TransitCheckinDecision decision =
|
||||
WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "running", null, now);
|
||||
|
||||
assertTrue(decision.visible());
|
||||
assertTrue(decision.punchEnabled());
|
||||
assertTrue(decision.dueToday(), "frequencyDays 默认为 1、无历史打卡,且 noon 落在默认时段内");
|
||||
assertFalse(decision.doneToday());
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ 开始运输门禁
|
||||
|
||||
@Test
|
||||
@DisplayName("开始门禁:只有进行中算已开始运输")
|
||||
void transitPunchStartedOnlyRunning() {
|
||||
assertTrue(WaybillProcessSupport.isTransitPunchStarted("running"));
|
||||
assertTrue(WaybillProcessSupport.isTransitPunchStarted(" RUNNING "));
|
||||
for (String status : List.of("draft", "pending", "waiting_dispatch", "dispatching", "completed", "cancelled")) {
|
||||
assertFalse(WaybillProcessSupport.isTransitPunchStarted(status), status + " 还没开始运输");
|
||||
}
|
||||
assertFalse(WaybillProcessSupport.isTransitPunchStarted(null));
|
||||
assertFalse(WaybillProcessSupport.isTransitPunchStarted(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("开始门禁文案:未开始一句话,进行中与已结束都交给别的门禁")
|
||||
void punchNotStartedReasonOnlyForNotStartedStatuses() {
|
||||
assertEquals("运单尚未开始运输,暂不可打卡", WaybillProcessSupport.punchNotStartedReason("pending"));
|
||||
assertEquals("运单尚未开始运输,暂不可打卡", WaybillProcessSupport.punchNotStartedReason("dispatching"));
|
||||
assertNull(WaybillProcessSupport.punchNotStartedReason("running"));
|
||||
// 结束态由结束文案负责,两句话不能抢
|
||||
assertNull(WaybillProcessSupport.punchNotStartedReason("completed"));
|
||||
assertNull(WaybillProcessSupport.punchNotStartedReason("cancelled"));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("在途卡:未开始运输的运单仍下发卡片,但不可打卡")
|
||||
void transitCardOnNotStartedWaybillIsVisibleButNotPunchable() {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0);
|
||||
|
||||
WaybillProcessSupport.TransitCheckinDecision decision =
|
||||
WaybillProcessSupport.evaluateTransitCheckin(TRANSPORT_JSON, "dispatching", null, now);
|
||||
|
||||
assertTrue(decision.visible(), "卡片仍要下发,司机要看得到这张卡为什么不能打");
|
||||
assertFalse(decision.punchEnabled(), "未开始运输不能提交打卡");
|
||||
assertFalse(decision.doneToday());
|
||||
assertFalse(decision.dueToday());
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("同日判重:只认同一个自然日,跨日与空值都不算")
|
||||
void isPunchedOnComparesByNaturalDay() {
|
||||
LocalDateTime now = LocalDateTime.of(2026, 10, 20, 12, 0);
|
||||
Date today = Date.from(LocalDateTime.of(2026, 10, 20, 8, 0).atZone(ZoneId.systemDefault()).toInstant());
|
||||
Date yesterday = Date.from(LocalDateTime.of(2026, 10, 19, 23, 0).atZone(ZoneId.systemDefault()).toInstant());
|
||||
|
||||
assertTrue(WaybillProcessSupport.isPunchedOn(today, now));
|
||||
assertFalse(WaybillProcessSupport.isPunchedOn(yesterday, now));
|
||||
assertFalse(WaybillProcessSupport.isPunchedOn(null, now));
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("上报状态门禁:只有进行中可报,未开始与已结束各有各的话")
|
||||
void reportBlockedReasonOnlyForRunningWaybill() {
|
||||
assertNull(WaybillProcessSupport.reportBlockedReason("running"));
|
||||
assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("pending"));
|
||||
assertEquals("运单尚未开始运输,暂不可上报异常", WaybillProcessSupport.reportBlockedReason("dispatching"));
|
||||
// 结束优先:已完成/已取消先说结束,不提「等你上路」
|
||||
assertEquals("运单已完成,不能上报异常", WaybillProcessSupport.reportBlockedReason("completed"));
|
||||
assertEquals("运单已取消,不能上报异常", WaybillProcessSupport.reportBlockedReason("cancelled"));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -305,6 +305,7 @@ CREATE TABLE `blade_dept` (
|
||||
`dept_code` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织编码',
|
||||
`kingdee_dept_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '金蝶部门代码',
|
||||
`contract_code_prefix` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '合同编码前缀',
|
||||
`contract_initial_serial` int NULL DEFAULT NULL COMMENT '合同初始流水号',
|
||||
`short_name` varchar(45) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '组织简称',
|
||||
`pinyin_mnemonic` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '拼音助记码',
|
||||
`mnemonic_code` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '助记码',
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- 组织机构新增合同初始流水号:该部门当年首个合同从此流水号起编,后续递增
|
||||
ALTER TABLE `blade_dept`
|
||||
ADD COLUMN `contract_initial_serial` int(11) DEFAULT NULL COMMENT '合同初始流水号' AFTER `contract_code_prefix`;
|
||||
@@ -0,0 +1,30 @@
|
||||
-- 收票登记:删除冗余的 kingdee_bill_no / kingdee_status 两列
|
||||
-- 这两列由收票登记旧版金蝶同步链路写入,同步功能已下线,代码中已无任何读写。
|
||||
-- 收票登记的金蝶状态以 kingdee_push_status 为唯一状态源(未推送/已保存收票单/已指定应付/推送失败);
|
||||
-- 金蝶单据号可经 kingdee_invoice_pool_id 关联 blade_kingdee_invoice_pool 取 serial_no,
|
||||
-- 两列的取值集合也与新模型对不上(旧值 pushed / assigned 在新模型里不存在),故不搬运数据。
|
||||
-- 基线建表脚本 blade_invoice_receipt_20260821.sql 仍声明这两列,
|
||||
-- 而 CREATE TABLE IF NOT EXISTS 对已存在的表是空操作,存量库只能靠 ALTER 收敛。
|
||||
-- 以下语句可重复执行。
|
||||
SET @ddl_sql = (
|
||||
SELECT IF(COUNT(*) > 0,
|
||||
'ALTER TABLE `blade_invoice_receipt` DROP COLUMN `kingdee_bill_no`',
|
||||
'SELECT 1')
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_bill_no'
|
||||
);
|
||||
PREPARE ddl_stmt FROM @ddl_sql;
|
||||
EXECUTE ddl_stmt;
|
||||
DEALLOCATE PREPARE ddl_stmt;
|
||||
|
||||
-- 索引 idx_invoice_receipt_kingdee 建在 kingdee_status 上,随该列一并删除,无需单独 DROP INDEX
|
||||
SET @ddl_sql = (
|
||||
SELECT IF(COUNT(*) > 0,
|
||||
'ALTER TABLE `blade_invoice_receipt` DROP COLUMN `kingdee_status`',
|
||||
'SELECT 1')
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'blade_invoice_receipt' AND COLUMN_NAME = 'kingdee_status'
|
||||
);
|
||||
PREPARE ddl_stmt FROM @ddl_sql;
|
||||
EXECUTE ddl_stmt;
|
||||
DEALLOCATE PREPARE ddl_stmt;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user