diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/EnrouteSubmitDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/EnrouteSubmitDTO.java new file mode 100644 index 0000000..309b921 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/EnrouteSubmitDTO.java @@ -0,0 +1,42 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; + +/** + * 在途打卡提交 + */ +@Data +@Schema(description = "在途打卡提交") +public class EnrouteSubmitDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long waybillId; + + @Schema(description = "定位信息") + private Location location; + + @Schema(description = "货物照片URL") + private String photo; + + @Data + @Schema(description = "定位") + public static class Location implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + private Double longitude; + private Double latitude; + private String address; + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java new file mode 100644 index 0000000..6b16994 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/dto/NodeSubmitDTO.java @@ -0,0 +1,61 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.io.Serial; +import java.io.Serializable; +import java.util.List; + +/** + * 过程节点打卡提交(到场/装货/卸货/签收等,不含在途) + */ +@Data +@Schema(description = "过程节点打卡提交") +public class NodeSubmitDTO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单ID", requiredMode = Schema.RequiredMode.REQUIRED) + private Long waybillId; + + @Schema(description = "过程节点 key", requiredMode = Schema.RequiredMode.REQUIRED) + private String nodeCode; + + @Schema(description = "定位信息") + private Location location; + + @Schema(description = "凭证照片 URL 列表") + private List photos; + + @Schema(description = "重量(吨)") + private String weight; + + @Schema(description = "体积(方)") + private String volume; + + @Schema(description = "数量(件)") + private String quantity; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "是否异常") + private Boolean exception; + + @Data + @Schema(description = "定位") + public static class Location implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + private Double longitude; + private Double latitude; + private String address; + } + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java index aaa207b..81c481d 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/Waybill.java @@ -22,6 +22,8 @@ */ package org.springblade.transport.pojo.entity; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableName; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -31,6 +33,7 @@ import org.springblade.core.tenant.mp.TenantEntity; import java.io.Serial; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.Date; /** * 运单管理实体类 @@ -139,6 +142,26 @@ public class Waybill extends TenantEntity { @Schema(description = "司机手机号") private String driverPhone; + @Schema(description = "司机接单状态:pending待接单/accepted已接单/rejected已拒绝") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String driverAcceptStatus; + + @Schema(description = "司机接单时间") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Date driverAcceptTime; + + @Schema(description = "接单司机ID") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Long driverAcceptDriverId; + + @Schema(description = "司机拒绝接单时间") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private Date driverRejectTime; + + @Schema(description = "司机拒绝接单原因") + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private String driverRejectReason; + @Schema(description = "车/船/航班/班列号") private String vehicleNo; diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/WaybillEnroutePunch.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/WaybillEnroutePunch.java new file mode 100644 index 0000000..5c01db0 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/WaybillEnroutePunch.java @@ -0,0 +1,53 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; + +import java.io.Serial; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 运单在途打卡记录 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_waybill_enroute_punch") +@Schema(description = "运单在途打卡记录") +public class WaybillEnroutePunch extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单ID") + private Long waybillId; + + @Schema(description = "运单号") + private String waybillNo; + + @Schema(description = "打卡司机ID") + private Long driverId; + + @Schema(description = "打卡时间") + private Date punchTime; + + @Schema(description = "经度") + private BigDecimal longitude; + + @Schema(description = "纬度") + private BigDecimal latitude; + + @Schema(description = "打卡地址") + private String address; + + @Schema(description = "货物照片URL") + private String photo; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/WaybillNodePunch.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/WaybillNodePunch.java new file mode 100644 index 0000000..6c2cc15 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/entity/WaybillNodePunch.java @@ -0,0 +1,74 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.pojo.entity; + +import com.baomidou.mybatisplus.annotation.TableName; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.springblade.core.tenant.mp.TenantEntity; + +import java.io.Serial; +import java.math.BigDecimal; +import java.util.Date; + +/** + * 运单过程节点打卡记录 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@TableName("blade_waybill_node_punch") +@Schema(description = "运单过程节点打卡记录") +public class WaybillNodePunch extends TenantEntity { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单ID") + private Long waybillId; + + @Schema(description = "运单号") + private String waybillNo; + + @Schema(description = "打卡司机ID") + private Long driverId; + + @Schema(description = "过程节点 key") + private String nodeCode; + + @Schema(description = "过程节点名称") + private String nodeName; + + @Schema(description = "打卡时间") + private Date punchTime; + + @Schema(description = "经度") + private BigDecimal longitude; + + @Schema(description = "纬度") + private BigDecimal latitude; + + @Schema(description = "打卡地址") + private String address; + + @Schema(description = "凭证照片URL,多张逗号分隔") + private String photos; + + @Schema(description = "重量(吨)") + private String weight; + + @Schema(description = "体积(方)") + private String volume; + + @Schema(description = "数量(件)") + private String quantity; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "是否异常:0否 1是") + private Integer exceptionFlag; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnrouteRecordVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnrouteRecordVO.java new file mode 100644 index 0000000..73351e6 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverEnrouteRecordVO.java @@ -0,0 +1,32 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +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; + +/** + * 司机端在途打卡记录 + */ +@Data +@Schema(description = "司机端在途打卡记录") +public class DriverEnrouteRecordVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "打卡时间 HH:mm 或 yyyy-MM-dd HH:mm:ss") + private String time; + + @Schema(description = "打卡地址") + private String address; + + @Schema(description = "货物照片") + private String photo; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java new file mode 100644 index 0000000..4bca631 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverNodePunchVO.java @@ -0,0 +1,52 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +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.ArrayList; +import java.util.List; + +/** + * 司机端过程节点打卡结果 + */ +@Data +@Schema(description = "司机端过程节点打卡结果") +public class DriverNodePunchVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单ID") + private Long waybillId; + + @Schema(description = "节点 key") + private String nodeCode; + + @Schema(description = "节点名称") + private String nodeName; + + @Schema(description = "打卡时间(ISO 或 yyyy-MM-dd HH:mm:ss)") + private String checkinTime; + + @Schema(description = "打卡地址") + private String address; + + @Schema(description = "凭证照片") + private List photos = new ArrayList<>(); + + @Schema(description = "重量") + private String weight; + + @Schema(description = "体积") + private String volume; + + @Schema(description = "数量") + private String quantity; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java new file mode 100644 index 0000000..f7e8882 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchNodeVO.java @@ -0,0 +1,79 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +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.ArrayList; +import java.util.List; + +/** + * 司机端过程打卡节点(过程配置 punch=是) + */ +@Data +@Schema(description = "司机端过程打卡节点") +public class DriverPunchNodeVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "节点 key,如 arrive_scene / load / transit") + private String key; + + @Schema(description = "节点名称") + private String name; + + @Schema(description = "是否在途节点") + private Boolean transit; + + @Schema(description = "是否已打卡(在途=今日已打)") + private Boolean done; + + @Schema(description = "当前是否可打卡(顺序门禁 + 在途频次时段)") + private Boolean actionable; + + @Schema(description = "是否展示该卡(在途可能因频次/时段隐藏)") + private Boolean visible; + + @Schema(description = "是否默认展开(仅第一个可打卡节点)") + private Boolean defaultExpanded; + + @Schema(description = "打卡时间展示") + private String checkinTime; + + @Schema(description = "打卡地点") + private String checkinPlace; + + @Schema(description = "重量(吨)") + private String weight; + + @Schema(description = "体积(方)") + private String volume; + + @Schema(description = "数量(件)") + private String quantity; + + @Schema(description = "已上传凭证图(已打卡回显)") + private List photos = new ArrayList<>(); + + @Schema(description = "是否需要定位") + private Boolean needLocation; + + @Schema(description = "是否需要上传货量") + private Boolean needCargo; + + @Schema(description = "货量类型:重量/体积/数量") + private List cargoTypes = new ArrayList<>(); + + @Schema(description = "是否需要上传凭证") + private Boolean needVoucher; + + @Schema(description = "凭证类型") + private List voucherTypes = new ArrayList<>(); + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchPhotoVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchPhotoVO.java new file mode 100644 index 0000000..bbd9173 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverPunchPhotoVO.java @@ -0,0 +1,32 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +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; + +/** + * 司机端打卡凭证图 + */ +@Data +@Schema(description = "司机端打卡凭证图") +public class DriverPunchPhotoVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "凭证类型,如 委托单") + private String type; + + @Schema(description = "展示标签,如 装货-委托单") + private String label; + + @Schema(description = "图片 URL") + private String url; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java new file mode 100644 index 0000000..46bbfc7 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverVehicleCardVO.java @@ -0,0 +1,74 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * 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; + +/** + * 司机端车辆卡片(对齐小程序 VehicleAuthInfo) + */ +@Data +@Schema(description = "司机端车辆卡片") +public class DriverVehicleCardVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "车辆ID") + private Long id; + + @Schema(description = "车牌号") + private String plateNo; + + @Schema(description = "车辆类型") + private String vehicleType; + + @Schema(description = "行驶证主页照片") + private String licenseFrontUrl; + + @Schema(description = "行驶证副页照片") + private String licenseBackUrl; + + @Schema(description = "道路运输证号") + private String roadTransportNo; + + @Schema(description = "道路运输证照片") + private String roadTransportUrl; + + @Schema(description = "车架号") + private String vin; + + @Schema(description = "发动机号") + private String engineNo; + + @Schema(description = "行驶证有效期止") + private String licenseValidEnd; + + @Schema(description = "认证状态:0认证中 1认证通过 2认证驳回") + private Integer certificationStatus; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java new file mode 100644 index 0000000..1b87f5d --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillCardVO.java @@ -0,0 +1,136 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * 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; + +/** + * 司机端运单卡片(首页当前任务 / 待接预览 / 列表项) + *

+ * 字段对齐小程序 MockWaybillItem,status 为数字枚举: + * 0 待接单 / 1 运输中 / 2 已完成 / 3 已取消 + */ +@Data +@Schema(description = "司机端运单卡片") +public class DriverWaybillCardVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "运单ID") + private Long id; + + @Schema(description = "运单号") + private String waybillNo; + + @Schema(description = "起点名称") + private String fromName; + + @Schema(description = "终点名称") + private String toName; + + @Schema(description = "起点地址") + private String fromAddress; + + @Schema(description = "终点地址") + private String toAddress; + + @Schema(description = "货物名称列表") + private List cargoNames; + + @Schema(description = "货物类别") + private String cargoCategory; + + @Schema(description = "重量(带单位)") + private String weight; + + @Schema(description = "状态:0待接单/1运输中/2已完成/3已取消") + private Integer status; + + @Schema(description = "创建时间") + private String createTime; + + @Schema(description = "发布时间(兼容小程序 publishTime)") + private String publishTime; + + @Schema(description = "当前过程节点") + private String currentNode; + + @Schema(description = "计划/完成时间段展示") + private String timeRange; + + @Schema(description = "运费参考金额") + private BigDecimal freight; + + @Schema(description = "司机姓名") + private String driverName; + + @Schema(description = "司机手机号") + private String driverPhone; + + @Schema(description = "车牌号") + private String vehicleNo; + + @Schema(description = "是否需要司机确认接单") + private Boolean requireAccept; + + @Schema(description = "司机接单状态:pending待接单/accepted已接单/rejected已拒绝") + private String acceptStatus; + + @Schema(description = "司机拒绝接单原因") + private String rejectReason; + + @Schema(description = "过程配置是否启用在途打卡(在途节点 punch=是)") + private Boolean transitPunchEnabled; + + @Schema(description = "是否展示「今日在途打卡」面板(到期且在时段内,或今日已打)") + private Boolean transitCheckinVisible; + + @Schema(description = "今日是否需要在途打卡(到期且未打且在时段内)") + private Boolean requireTransitCheckinToday; + + @Schema(description = "今日是否已完成在途打卡") + private Boolean transitCheckinDoneToday; + + @Schema(description = "在途打卡频次(每 N 天 1 次)") + private Integer transitFrequencyDays; + + @Schema(description = "在途打卡时段开始 HH:mm") + private String transitTimeStart; + + @Schema(description = "在途打卡时段结束 HH:mm") + private String transitTimeEnd; + + @Schema(description = "在途打卡记录(详情返回)") + private List enrouteRecords; + + @Schema(description = "过程配置中 punch=是 的打卡节点列表(详情返回)") + private List punchNodes; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillPreviewVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillPreviewVO.java new file mode 100644 index 0000000..a4e6158 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillPreviewVO.java @@ -0,0 +1,49 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * 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.ArrayList; +import java.util.List; + +/** + * 司机端待接运单预览 + */ +@Data +@Schema(description = "司机端待接运单预览") +public class DriverWaybillPreviewVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "预览列表") + private List records = new ArrayList<>(); + + @Schema(description = "待接运单总数(角标)") + private Long total = 0L; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillTabCountsVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillTabCountsVO.java new file mode 100644 index 0000000..3aa95f5 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/DriverWaybillTabCountsVO.java @@ -0,0 +1,55 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * 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 统计 + *

+ * 对齐小程序 { all, pending, doing, done } + */ +@Data +@Schema(description = "司机端运单 Tab 统计") +public class DriverWaybillTabCountsVO 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; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java index 2cb7fb8..7783903 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ExceptionDisposalVO.java @@ -85,4 +85,12 @@ public class ExceptionDisposalVO extends ExceptionDisposal { @Schema(description = "扩展信息") private Map extra; + @TableField(exist = false) + @Schema(description = "运单路线:{start, end}") + private Map route; + + @TableField(exist = false) + @Schema(description = "货物信息:{name, weight}") + private Map cargo; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProcessConfigVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProcessConfigVO.java index 424a568..1a3facb 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProcessConfigVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/ProcessConfigVO.java @@ -66,4 +66,8 @@ public class ProcessConfigVO extends ProcessConfig { @Schema(description = "当前运单是否有关联凭证") private Boolean hasRelatedVoucher; + @TableField(exist = false) + @Schema(description = "关联项目是否已有运单") + private Boolean hasRelatedWaybill; + } diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchPhotoVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchPhotoVO.java new file mode 100644 index 0000000..e752b28 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchPhotoVO.java @@ -0,0 +1,38 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +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; + +/** + * 司机打卡上传图片(节点-凭证类型) + */ +@Data +@Schema(description = "司机打卡上传图片") +public class WaybillPunchPhotoVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "展示标签,如 装货-委托单") + private String label; + + @Schema(description = "图片 URL") + private String url; + + @Schema(description = "节点名称") + private String nodeName; + + @Schema(description = "凭证类型") + private String voucherType; + + @Schema(description = "打卡时间") + private String punchTime; + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java new file mode 100644 index 0000000..140d6bc --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordItemVO.java @@ -0,0 +1,73 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +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.ArrayList; +import java.util.List; + +/** + * 管理端单条打卡记录 + */ +@Data +@Schema(description = "管理端单条打卡记录") +public class WaybillPunchRecordItemVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "记录ID") + private Long id; + + @Schema(description = "类型:node / enroute") + private String type; + + @Schema(description = "节点 key") + private String nodeCode; + + @Schema(description = "节点名称") + private String nodeName; + + @Schema(description = "打卡时间") + private String punchTime; + + @Schema(description = "打卡地址") + private String address; + + @Schema(description = "经度") + private String longitude; + + @Schema(description = "纬度") + private String latitude; + + @Schema(description = "重量") + private String weight; + + @Schema(description = "体积") + private String volume; + + @Schema(description = "数量") + private String quantity; + + @Schema(description = "备注") + private String remark; + + @Schema(description = "是否异常") + private Boolean exceptionFlag; + + @Schema(description = "是否已打卡") + private Boolean punched; + + @Schema(description = "状态文案:已打卡/未打卡") + private String statusName; + + @Schema(description = "本条打卡凭证图") + private List photos = new ArrayList<>(); + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordsVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordsVO.java new file mode 100644 index 0000000..85a5394 --- /dev/null +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillPunchRecordsVO.java @@ -0,0 +1,31 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +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.ArrayList; +import java.util.List; + +/** + * 管理端运单打卡记录汇总 + */ +@Data +@Schema(description = "管理端运单打卡记录汇总") +public class WaybillPunchRecordsVO implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; + + @Schema(description = "打卡流水(按时间升序,含节点/在途)") + private List records = new ArrayList<>(); + + @Schema(description = "司机上传凭证图(扁平列表,label=节点-凭证类型)") + private List driverUploads = new ArrayList<>(); + +} diff --git a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java index 915d715..cea37c3 100644 --- a/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java +++ b/blade-service-api/blade-transport-api/src/main/java/org/springblade/transport/pojo/vo/WaybillVO.java @@ -66,6 +66,10 @@ public class WaybillVO extends Waybill { @Schema(description = "业务状态名称") private String businessStatusName; + @TableField(exist = false) + @Schema(description = "是否需要司机确认接单(过程配置接单节点)") + private Boolean requireAccept; + @TableField(exist = false) @Schema(description = "是否仅查询未配载运单") private Integer onlyUnassignedLoading; diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAppController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAppController.java new file mode 100644 index 0000000..6040145 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverAppController.java @@ -0,0 +1,69 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.tool.api.R; +import org.springblade.transport.pojo.vo.DriverVehicleCardVO; +import org.springblade.transport.pojo.vo.DriverVO; +import org.springblade.transport.service.IDriverAppService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 司机端档案(小程序) + *

+ * 对外路径:{@code /api/blade-transport/driver/**} + * 同时兼容未去前缀直连 {@code /blade-transport/driver/**}。 + */ +@RestController +@AllArgsConstructor +@RequestMapping({"/driver", "/blade-transport/driver"}) +@Tag(name = "司机端档案", description = "小程序司机个人档案") +public class DriverAppController extends BladeController { + + private final IDriverAppService driverAppService; + + @GetMapping("/mine") + @ApiOperationSupport(order = 1) + @Operation(summary = "当前登录司机档案", description = "按手机号匹配 blade_transport_driver.mobile;可传 mobile,未传则从登录态解析") + public R mine(@RequestParam(required = false) String mobile) { + return R.data(driverAppService.currentByPhone(mobile)); + } + + @GetMapping("/vehicles") + @ApiOperationSupport(order = 2) + @Operation(summary = "当前司机车辆列表", description = "按司机 driving_vehicle 车牌匹配 blade_transport_vehicle") + public R> vehicles() { + return R.data(driverAppService.myVehicles()); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java new file mode 100644 index 0000000..3605085 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/DriverWaybillController.java @@ -0,0 +1,156 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.github.xiaoymin.knife4j.annotations.ApiOperationSupport; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.AllArgsConstructor; +import org.springblade.core.boot.ctrl.BladeController; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; +import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO; +import org.springblade.transport.pojo.vo.DriverNodePunchVO; +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.service.IDriverWaybillService; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +/** + * 司机端运单接口(小程序) + *

+ * 对外完整路径:{@code /api/blade-transport/waybill/**} + * (网关 StripPrefix 去掉 {@code blade-transport} 后落入 {@code /waybill/**})。 + * 同时兼容未去前缀直连({@code /blade-transport/waybill/**}),避免 404。 + * 不挂管理端菜单鉴权,仅需登录态(Blade Secure)。 + */ +@RestController +@AllArgsConstructor +@RequestMapping({"/waybill", "/blade-transport/waybill"}) +@Tag(name = "司机端运单", description = "小程序司机端运单") +public class DriverWaybillController extends BladeController { + + private final IDriverWaybillService driverWaybillService; + + @GetMapping("/current-task") + @ApiOperationSupport(order = 1) + @Operation(summary = "首页:当前运输中任务", description = "当前司机绑定车牌下 businessStatus=running 的最新一条运单") + public R currentTask() { + return R.data(driverWaybillService.currentTask()); + } + + @GetMapping("/pending-preview") + @ApiOperationSupport(order = 2) + @Operation(summary = "首页:待接运单预览", description = "当前司机绑定车牌下 businessStatus=pending 的预览列表与总数") + public R pendingPreview( + @Parameter(description = "预览条数,默认 2") @RequestParam(required = false) Integer size) { + return R.data(driverWaybillService.pendingPreview(size)); + } + + @GetMapping("/counts") + @ApiOperationSupport(order = 3) + @Operation(summary = "运单 Tab 统计", description = "仅统计当前司机绑定车牌对应的运单:全部 / 待接单 / 进行中 / 已完成") + public R counts() { + return R.data(driverWaybillService.tabCounts()); + } + + @GetMapping("/page") + @ApiOperationSupport(order = 4) + @Operation(summary = "运单分页列表", description = "仅返回当前司机绑定车牌(driving_vehicle)匹配运单 vehicleNo/trailerVehicleNo 的数据;status:空=全部,0待接单,1运输中,2已完成") + public R> page( + @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.page(current, size, statusCode, keyword)); + } + + @GetMapping("/detail") + @ApiOperationSupport(order = 5) + @Operation(summary = "司机运单详情", description = "返回 requireAccept、在途打卡可见性(transitCheckinVisible / requireTransitCheckinToday)等字段") + public R detail( + @Parameter(description = "运单ID", required = true) @RequestParam Long id) { + return R.data(driverWaybillService.detail(id)); + } + + @PostMapping("/accept") + @ApiOperationSupport(order = 6) + @Operation(summary = "司机确认接单", description = "过程配置接单为「是」时,司机确认接单后运单进入进行中") + public R accept(@Parameter(description = "运单ID", required = true) @RequestParam Long id) { + return R.status(driverWaybillService.accept(id)); + } + + @PostMapping("/reject") + @ApiOperationSupport(order = 7) + @Operation(summary = "司机拒绝接单", description = "过程配置接单为「是」时,司机可拒绝接单,运单保持待执行并记录拒单") + public R reject( + @Parameter(description = "运单ID", required = true) @RequestParam Long id, + @Parameter(description = "拒绝原因") @RequestParam(required = false) String reason) { + return R.status(driverWaybillService.reject(id, reason)); + } + + @PostMapping("/enroute/submit") + @ApiOperationSupport(order = 8) + @Operation(summary = "提交在途打卡", description = "过程配置在途节点 punch=是,且满足频次/时段时允许提交") + public R submitEnroute(@RequestBody EnrouteSubmitDTO dto) { + return R.data(driverWaybillService.submitEnroute(dto)); + } + + @PostMapping("/node/submit") + @ApiOperationSupport(order = 9) + @Operation(summary = "提交过程节点打卡", description = "到场/装货/发货/到货/卸货/签收等 punch=是;在途请走 /enroute/submit") + public R submitNode(@RequestBody NodeSubmitDTO dto) { + return R.data(driverWaybillService.submitNode(dto)); + } + + @PostMapping("/complete") + @ApiOperationSupport(order = 10) + @Operation(summary = "司机完成运单", description = "校验司机归属后改状态为已完成,并检查生成应收应付明细(与管理端一致)") + public R complete(@Parameter(description = "运单ID", required = true) @RequestParam Long id) { + return R.status(driverWaybillService.complete(id)); + } + + /** 前端可能传空字符串表示「全部」 */ + private Integer parseStatus(String status) { + if (Func.isEmpty(status)) { + return null; + } + try { + return Integer.valueOf(status.trim()); + } catch (NumberFormatException ex) { + return null; + } + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java index f039e69..3a64704 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/ExceptionDisposalController.java @@ -33,6 +33,7 @@ import org.springblade.core.mp.support.Query; import org.springblade.core.secure.annotation.PreAuth; import org.springblade.core.tool.api.R; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; +import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IExceptionDisposalService; import org.springframework.web.bind.annotation.GetMapping; @@ -44,13 +45,14 @@ import org.springframework.web.bind.annotation.RestController; /** * 异常处置控制器 - * - * @author Chill + *

+ * 对外路径:{@code /api/blade-transport/exception-disposal/**} + * 同时兼容未去前缀直连 {@code /blade-transport/exception-disposal/**}。 + * 司机上报(submit)/ 列表 / 详情仅需登录态;跟进与完成保留菜单鉴权。 */ @RestController @AllArgsConstructor -@PreAuth(menu = "exception_disposal") -@RequestMapping("/exception-disposal") +@RequestMapping({"/exception-disposal", "/blade-transport/exception-disposal"}) @Tag(name = "异常处置", description = "异常处置") public class ExceptionDisposalController extends BladeController { @@ -70,8 +72,16 @@ public class ExceptionDisposalController extends BladeController { return R.data(exceptionDisposalService.detail(id)); } - @PostMapping("/follow") + @PostMapping("/submit") @ApiOperationSupport(order = 3) + @Operation(summary = "异常上报", description = "司机端上报异常;上报人取登录态,运单信息按 waybillId/waybillNo 回填") + public R submit(@RequestBody ExceptionDisposal request) { + return R.data(exceptionDisposalService.submitReport(request)); + } + + @PostMapping("/follow") + @PreAuth(menu = "exception_disposal") + @ApiOperationSupport(order = 4) @Operation(summary = "异常跟进") public R follow(@RequestBody ExceptionDisposalFollowRequest request) { exceptionDisposalService.follow(request); @@ -79,7 +89,8 @@ public class ExceptionDisposalController extends BladeController { } @PostMapping("/complete") - @ApiOperationSupport(order = 4) + @PreAuth(menu = "exception_disposal") + @ApiOperationSupport(order = 5) @Operation(summary = "完成异常") public R complete(@RequestBody ExceptionDisposalFollowRequest request) { exceptionDisposalService.complete(request.getId()); @@ -87,7 +98,8 @@ public class ExceptionDisposalController extends BladeController { } @PostMapping("/batch-complete") - @ApiOperationSupport(order = 5) + @PreAuth(menu = "exception_disposal") + @ApiOperationSupport(order = 6) @Operation(summary = "批量完成异常") public R batchComplete(@RequestParam String ids) { exceptionDisposalService.batchComplete(ids); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java index 36c761b..8479ad4 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/controller/WaybillController.java @@ -53,6 +53,7 @@ import org.springblade.transport.pojo.dto.WaybillImportBatchRequest; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.LoadingManageVO; import org.springblade.transport.pojo.vo.WaybillImportBatchVO; +import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.pojo.dto.WaybillMileageRequest; import org.springblade.transport.service.IContractManageService; @@ -100,6 +101,14 @@ public class WaybillController extends BladeController { return R.data(waybillService.detail(id)); } + @GetMapping("/punch-records") + @ApiOperationSupport(order = 1) + @Operation(summary = "打卡记录与司机上传", description = "返回节点/在途打卡流水,以及司机上传凭证图(label=节点-凭证类型)") + public R punchRecords( + @Parameter(description = "运单ID", required = true) @RequestParam Long waybillId) { + return R.data(waybillService.listPunchRecords(waybillId)); + } + @GetMapping("/list") @ApiOperationSupport(order = 2) @Operation(summary = "分页", description = "传入waybill") @@ -273,9 +282,9 @@ public class WaybillController extends BladeController { @PostMapping("/reassign") @ApiOperationSupport(order = 20) - @Operation(summary = "重新派单", description = "传入id") - public R reassign(@Parameter(description = "主键", required = true) @RequestParam Long id) { - return R.status(waybillService.reassign(id)); + @Operation(summary = "重新派单", description = "传入运单ID及新的司机、手机号、车牌") + public R reassign(@RequestBody Waybill waybill) { + return R.status(waybillService.reassign(waybill)); } @PostMapping("/complete") diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/WaybillEnroutePunchMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/WaybillEnroutePunchMapper.java new file mode 100644 index 0000000..8cba06a --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/WaybillEnroutePunchMapper.java @@ -0,0 +1,16 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.springblade.transport.pojo.entity.WaybillEnroutePunch; + +/** + * 运单在途打卡 Mapper + */ +@Mapper +public interface WaybillEnroutePunchMapper extends BaseMapper { +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/WaybillNodePunchMapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/WaybillNodePunchMapper.java new file mode 100644 index 0000000..c9f2bcb --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/mapper/WaybillNodePunchMapper.java @@ -0,0 +1,16 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + */ +package org.springblade.transport.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Mapper; +import org.springblade.transport.pojo.entity.WaybillNodePunch; + +/** + * 运单过程节点打卡 Mapper + */ +@Mapper +public interface WaybillNodePunchMapper extends BaseMapper { +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java new file mode 100644 index 0000000..5f357a4 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverAppService.java @@ -0,0 +1,47 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import org.springblade.transport.pojo.vo.DriverVehicleCardVO; +import org.springblade.transport.pojo.vo.DriverVO; + +import java.util.List; + +/** + * 司机端档案服务(小程序) + */ +public interface IDriverAppService { + + /** + * 当前登录用户对应的司机档案(按手机号匹配 blade_transport_driver.mobile) + * + * @param mobile 小程序可显式传入当前用户手机号;为空时从登录态解析 + */ + DriverVO currentByPhone(String mobile); + + /** + * 当前司机绑定的车辆列表(driver.driving_vehicle ↔ vehicle.plate_no) + */ + List myVehicles(); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java new file mode 100644 index 0000000..f9e804d --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IDriverWaybillService.java @@ -0,0 +1,96 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; +import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.vo.DriverEnrouteRecordVO; +import org.springblade.transport.pojo.vo.DriverNodePunchVO; +import org.springblade.transport.pojo.vo.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; +import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; + +/** + * 司机端运单服务(小程序首页 / 列表) + */ +public interface IDriverWaybillService { + + /** + * 当前登录司机的运输中任务(最多一条) + */ + DriverWaybillCardVO currentTask(); + + /** + * 当前登录司机的待接运单预览 + * + * @param size 预览条数,默认 2 + */ + DriverWaybillPreviewVO pendingPreview(Integer size); + + /** + * 列表 Tab 统计:全部 / 待接单 / 进行中 / 已完成 + */ + DriverWaybillTabCountsVO tabCounts(); + + /** + * 司机运单分页 + * + * @param current 当前页,从 1 开始 + * @param size 每页条数 + * @param status 小程序状态:空=全部,0待接单,1运输中,2已完成 + * @param keyword 关键字(运单号 / 起终点,可选) + */ + IPage page(Integer current, Integer size, Integer status, String keyword); + + /** + * 司机运单详情(含是否需要确认接单 requireAccept、在途打卡可见性等) + */ + DriverWaybillCardVO detail(Long id); + + /** + * 司机确认接单:过程配置要求接单且尚未接单时,写入接单记录并将运单改为进行中。 + */ + boolean accept(Long id); + + /** + * 司机拒绝接单:过程配置要求接单且尚未接单时,写入拒单记录,运单保持待执行。 + */ + boolean reject(Long id, String reason); + + /** + * 提交在途打卡(过程配置在途节点 punch=是,且满足频次/时段)。 + */ + DriverEnrouteRecordVO submitEnroute(EnrouteSubmitDTO dto); + + /** + * 提交过程节点打卡(到场/装货/卸货/签收等 punch=是;在途请走 submitEnroute)。 + */ + DriverNodePunchVO submitNode(NodeSubmitDTO dto); + + /** + * 司机完成运单:校验归属后改状态为已完成,并走与管理端相同的应收应付明细生成逻辑。 + */ + boolean complete(Long id); + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java index 8c51818..b04ee26 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/IExceptionDisposalService.java @@ -39,6 +39,11 @@ public interface IExceptionDisposalService extends BaseService { IPage selectWaybillPage(IPage page, WaybillVO waybill); WaybillVO detail(Long id); + + /** + * 管理端:运单打卡记录 + 司机上传凭证图(label=节点-凭证类型) + */ + WaybillPunchRecordsVO listPunchRecords(Long waybillId); + + Waybill syncDriverAcceptState(Waybill waybill); boolean submit(Waybill waybill); boolean saveDraft(Waybill waybill); BusinessRemoveResultVO removeWaybill(String ids); @@ -51,8 +59,15 @@ public interface IWaybillService extends BaseService { boolean changeRoute(Waybill waybill); boolean maintainMileage(WaybillMileageRequest request); boolean cancel(Long id); - boolean reassign(Long id); + boolean reassign(Waybill waybill); boolean complete(Long id); + + /** + * 司机端完成运单:跳过管理端部门校验,其余逻辑与 {@link #complete(Long)} 一致 + * (改状态 + 生成应收应付明细 + 尝试完成配载单)。 + */ + boolean completeWithoutDeptCheck(Long id); + BusinessRemoveResultVO batchComplete(String ids); LoadingManageVO roadLoading(String ids); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java new file mode 100644 index 0000000..551479d --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverAppServiceImpl.java @@ -0,0 +1,210 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * Author: Chill Zhuang (bladejava@qq.com) + */ +package org.springblade.transport.service.impl; + +import com.baomidou.mybatisplus.core.toolkit.Wrappers; +import lombok.RequiredArgsConstructor; +import org.springblade.core.log.exception.ServiceException; +import org.springblade.core.secure.utils.AuthUtil; +import org.springblade.core.tool.api.R; +import org.springblade.core.tool.utils.Func; +import org.springblade.system.feign.IUserClient; +import org.springblade.system.pojo.entity.User; +import org.springblade.transport.pojo.entity.Driver; +import org.springblade.transport.pojo.entity.TransportVehicle; +import org.springblade.transport.pojo.vo.DriverVehicleCardVO; +import org.springblade.transport.pojo.vo.DriverVO; +import org.springblade.transport.service.IDriverAppService; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.ITransportVehicleService; +import org.springblade.transport.wrapper.DriverWrapper; +import org.springframework.stereotype.Service; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 司机端档案服务实现 + */ +@Service +@RequiredArgsConstructor +public class DriverAppServiceImpl implements IDriverAppService { + + private final IDriverService driverService; + private final ITransportVehicleService transportVehicleService; + private final IUserClient userClient; + + @Override + public DriverVO currentByPhone(String mobile) { + Driver driver = currentDriver(mobile); + return driver == null ? null : DriverWrapper.build().entityVO(driver); + } + + @Override + public List myVehicles() { + Driver driver = currentDriver(null); + if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) { + return List.of(); + } + List plates = splitPlates(driver.getDrivingVehicle()); + if (plates.isEmpty()) { + return List.of(); + } + // 精确匹配 + 规范化匹配(兼容库中带间隔符/横线的车牌) + List vehicles = transportVehicleService.list(Wrappers.lambdaQuery() + .and(w -> { + w.in(TransportVehicle::getPlateNo, plates); + for (String plate : plates) { + w.or().apply( + "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(plate_no),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", + plate + ); + } + }) + .orderByDesc(TransportVehicle::getUpdateTime)); + // 去重(精确与规范化可能命中同一条) + Map uniq = new LinkedHashMap<>(); + for (TransportVehicle vehicle : vehicles) { + if (vehicle.getId() != null) { + uniq.putIfAbsent(vehicle.getId(), vehicle); + } + } + return uniq.values().stream().map(this::toCard).collect(Collectors.toList()); + } + + private Driver currentDriver(String mobileHint) { + Long userId = AuthUtil.getUserId(); + if (userId == null || userId <= 0) { + throw new ServiceException("未登录"); + } + String phone = resolvePhone(userId, mobileHint); + Driver driver = null; + if (Func.isNotEmpty(phone)) { + driver = driverService.getOne(Wrappers.lambdaQuery() + .eq(Driver::getMobile, phone) + .orderByDesc(Driver::getUpdateTime) + .last("LIMIT 1")); + } + if (driver == null) { + driver = driverService.getOne(Wrappers.lambdaQuery() + .eq(Driver::getUserId, userId) + .last("LIMIT 1")); + } + return driver; + } + + private List splitPlates(String drivingVehicle) { + String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",") + .replace(";", ",").replace("/", ",").replace("|", ","); + Set plates = new LinkedHashSet<>(); + for (String part : Func.toStrList(",", normalized)) { + if (Func.isEmpty(part)) { + continue; + } + String plate = normalizePlate(part); + if (Func.isNotEmpty(plate)) { + plates.add(plate); + } + } + return new ArrayList<>(plates); + } + + /** 车牌规范化:去空格/横线/间隔符并转大写,便于与车辆表关联 */ + private String normalizePlate(String plateNo) { + if (Func.isEmpty(plateNo)) { + return ""; + } + return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT); + } + + private DriverVehicleCardVO toCard(TransportVehicle vehicle) { + DriverVehicleCardVO card = new DriverVehicleCardVO(); + card.setId(vehicle.getId()); + card.setPlateNo(vehicle.getPlateNo()); + card.setVehicleType(vehicle.getVehicleType()); + card.setLicenseFrontUrl(Func.toStr(vehicle.getDrivingLicenseImage(), "")); + card.setLicenseBackUrl(firstNotEmpty(vehicle.getDrivingLicenseViceFront(), vehicle.getDrivingLicenseMainBack())); + card.setRoadTransportNo(Func.toStr(vehicle.getRoadTransportCertNo(), "")); + card.setRoadTransportUrl(Func.toStr(vehicle.getRoadTransportCertImage(), "")); + card.setVin(""); + card.setEngineNo(""); + if (vehicle.getDrivingLicenseEndDate() != null) { + card.setLicenseValidEnd(vehicle.getDrivingLicenseEndDate().toString()); + } else if (Integer.valueOf(1).equals(vehicle.getDrivingLicenseLongTerm())) { + card.setLicenseValidEnd("长期"); + } else { + card.setLicenseValidEnd(""); + } + card.setCertificationStatus(vehicle.getCertificationStatus()); + return card; + } + + private String firstNotEmpty(String first, String second) { + if (Func.isNotEmpty(first)) { + return first; + } + return Func.toStr(second, ""); + } + + /** + * 解析用于匹配司机档案的手机号。 + * 优先级:前端传入且与本人一致的 mobile → JWT account(司机账号多为手机号)→ 用户中心 phone/account + */ + private String resolvePhone(Long userId, String mobileHint) { + String selfPhone = resolveSelfPhone(userId); + String hint = Func.isEmpty(mobileHint) ? null : mobileHint.trim(); + if (Func.isNotEmpty(hint)) { + if (Func.isNotEmpty(selfPhone) && !selfPhone.equals(hint)) { + throw new ServiceException("只能查询本人司机档案"); + } + return hint; + } + return selfPhone; + } + + private String resolveSelfPhone(Long userId) { + String account = AuthUtil.getUserAccount(); + if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + return account.trim(); + } + R result = userClient.userInfoById(userId); + if (result == null || !R.isSuccess(result) || result.getData() == null) { + return Func.isEmpty(account) ? null : account.trim(); + } + User user = result.getData(); + if (Func.isNotEmpty(user.getPhone())) { + return user.getPhone().trim(); + } + if (Func.isNotEmpty(user.getAccount()) && user.getAccount().matches("^1\\d{10}$")) { + return user.getAccount().trim(); + } + return Func.isEmpty(account) ? null : account.trim(); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java new file mode 100644 index 0000000..2071910 --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/DriverWaybillServiceImpl.java @@ -0,0 +1,1069 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * 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.secure.utils.AuthUtil; +import org.springblade.core.tool.jackson.JsonUtil; +import org.springblade.core.tool.utils.DateUtil; +import org.springblade.core.tool.utils.Func; +import org.springblade.transport.mapper.WaybillEnroutePunchMapper; +import org.springblade.transport.mapper.WaybillNodePunchMapper; +import org.springblade.transport.pojo.dto.EnrouteSubmitDTO; +import org.springblade.transport.pojo.dto.NodeSubmitDTO; +import org.springblade.transport.pojo.entity.Driver; +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.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.DriverWaybillCardVO; +import org.springblade.transport.pojo.vo.DriverWaybillPreviewVO; +import org.springblade.transport.pojo.vo.DriverWaybillTabCountsVO; +import org.springblade.transport.service.IDriverService; +import org.springblade.transport.service.IDriverWaybillService; +import org.springblade.transport.service.IProcessConfigService; +import org.springblade.transport.service.IWaybillService; +import org.springblade.transport.support.WaybillProcessSupport; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 司机端运单服务实现 + */ +@Service +@RequiredArgsConstructor +public class DriverWaybillServiceImpl implements IDriverWaybillService { + + private static final String STATUS_PENDING = "pending"; + private static final String STATUS_WAITING_DISPATCH = "waiting_dispatch"; + private static final String STATUS_DISPATCHING = "dispatching"; + private static final String STATUS_RUNNING = "running"; + private static final String STATUS_COMPLETED = "completed"; + private static final String STATUS_CANCELLED = "cancelled"; + + /** 小程序「待接单」对应的后端业务状态 */ + private static final List PENDING_STATUSES = Arrays.asList( + STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING + ); + /** Tab「全部」统计口径:待接 + 运输中 + 已完成(不含已取消) */ + private static final List TAB_ALL_STATUSES = Arrays.asList( + STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING, STATUS_RUNNING, STATUS_COMPLETED + ); + + private static final int DEFAULT_PREVIEW_SIZE = 2; + private static final int MAX_PREVIEW_SIZE = 20; + private static final int DEFAULT_PAGE_SIZE = 10; + private static final int MAX_PAGE_SIZE = 50; + private static final DateTimeFormatter DATE_DOT = DateTimeFormatter.ofPattern("yyyy.MM.dd"); + private static final DateTimeFormatter DATE_DOT_SHORT = DateTimeFormatter.ofPattern("MM.dd"); + private static final DateTimeFormatter TIME_HM = DateTimeFormatter.ofPattern("HH:mm"); + + private final IWaybillService waybillService; + private final IDriverService driverService; + private final IProcessConfigService processConfigService; + private final WaybillEnroutePunchMapper enroutePunchMapper; + private final WaybillNodePunchMapper nodePunchMapper; + + @Override + public DriverWaybillCardVO currentTask() { + List plates = currentBoundPlates(); + if (plates.isEmpty()) { + return null; + } + // 进行中:running,或无需确认接单但仍为 pending 的历史数据 + List candidates = waybillService.list(scopedQuery(plates) + .in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING)) + .orderByDesc(Waybill::getUpdateTime) + .last("LIMIT 20")); + for (Waybill waybill : candidates) { + Waybill normalized = normalizeAcceptStatus(waybill); + if (STATUS_RUNNING.equals(normalized.getBusinessStatus())) { + return toCard(normalized); + } + } + return null; + } + + @Override + public DriverWaybillPreviewVO pendingPreview(Integer size) { + DriverWaybillPreviewVO preview = new DriverWaybillPreviewVO(); + List plates = currentBoundPlates(); + if (plates.isEmpty()) { + return preview; + } + int limit = normalizePreviewSize(size); + List pendingList = waybillService.list(scopedQuery(plates) + .in(Waybill::getBusinessStatus, PENDING_STATUSES) + .orderByDesc(Waybill::getCreateTime)); + List needAccept = pendingList.stream() + .map(this::normalizeAcceptStatus) + .filter(w -> STATUS_PENDING.equals(w.getBusinessStatus())) + .collect(Collectors.toList()); + preview.setTotal((long) needAccept.size()); + preview.setRecords(needAccept.stream().limit(limit).map(this::toCard).collect(Collectors.toList())); + return preview; + } + + @Override + public DriverWaybillTabCountsVO tabCounts() { + DriverWaybillTabCountsVO vo = new DriverWaybillTabCountsVO(); + List plates = currentBoundPlates(); + if (plates.isEmpty()) { + return vo; + } + List waybills = waybillService.list(scopedQuery(plates) + .in(Waybill::getBusinessStatus, TAB_ALL_STATUSES)); + long pending = 0; + long doing = 0; + long done = 0; + for (Waybill waybill : waybills) { + Waybill normalized = normalizeAcceptStatus(waybill); + Integer appStatus = toAppStatus(normalized.getBusinessStatus()); + if (appStatus == null) { + continue; + } + if (appStatus == 0) { + pending++; + } else if (appStatus == 1) { + doing++; + } else if (appStatus == 2) { + done++; + } + } + vo.setPending(pending); + vo.setDoing(doing); + vo.setDone(done); + vo.setAll(pending + doing + done); + return vo; + } + + @Override + public IPage page(Integer current, Integer size, Integer status, String keyword) { + int pageNo = current == null || current < 1 ? 1 : current; + int pageSize = size == null || size < 1 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); + Page empty = new Page<>(pageNo, pageSize); + + List plates = currentBoundPlates(); + if (plates.isEmpty()) { + return empty; + } + + LambdaQueryWrapper wrapper = scopedQuery(plates); + // 先按 Tab 口径拉候选,再按过程配置校正 pending→running 后内存分页 + applyStatusFilterForQuery(wrapper, status); + applyKeyword(wrapper, keyword); + wrapper.orderByDesc(Waybill::getCreateTime); + + List candidates = waybillService.list(wrapper); + List cards = candidates.stream() + .map(this::normalizeAcceptStatus) + .filter(w -> matchAppStatus(w, status)) + .map(this::toCard) + .collect(Collectors.toList()); + + long total = cards.size(); + int from = Math.min((pageNo - 1) * pageSize, cards.size()); + int to = Math.min(from + pageSize, cards.size()); + Page page = new Page<>(pageNo, pageSize, total); + page.setRecords(cards.subList(from, to)); + return page; + } + + @Override + public DriverWaybillCardVO detail(Long id) { + Driver driver = requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + return toCard(normalizeAcceptStatus(waybill), true); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DriverEnrouteRecordVO submitEnroute(EnrouteSubmitDTO dto) { + if (dto == null || dto.getWaybillId() == null) { + throw new ServiceException("运单ID不能为空"); + } + Driver driver = requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + waybill = normalizeAcceptStatus(waybill); + + Date lastPunchAt = findLastPunchTime(waybill.getId()); + WaybillProcessSupport.TransitCheckinDecision decision = WaybillProcessSupport.evaluateTransitCheckin( + resolveProcessJson(waybill), waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); + if (!decision.punchEnabled()) { + throw new ServiceException("该运单未启用在途打卡"); + } + if (decision.doneToday()) { + throw new ServiceException("今日已完成在途打卡,不可重复打卡"); + } + // 不做频次/时段门禁;定位按前端是否传参落库 + + Date now = new Date(); + WaybillEnroutePunch punch = new WaybillEnroutePunch(); + punch.setWaybillId(waybill.getId()); + punch.setWaybillNo(waybill.getWaybillNo()); + punch.setDriverId(driver.getId()); + punch.setPunchTime(now); + if (dto.getLocation() != null) { + if (dto.getLocation().getLongitude() != null) { + punch.setLongitude(BigDecimal.valueOf(dto.getLocation().getLongitude())); + } + if (dto.getLocation().getLatitude() != null) { + punch.setLatitude(BigDecimal.valueOf(dto.getLocation().getLatitude())); + } + punch.setAddress(Func.toStr(dto.getLocation().getAddress(), "").trim()); + } + punch.setPhoto(Func.isEmpty(dto.getPhoto()) ? null : dto.getPhoto().trim()); + enroutePunchMapper.insert(punch); + return toEnrouteRecord(punch); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public DriverNodePunchVO submitNode(NodeSubmitDTO dto) { + if (dto == null || dto.getWaybillId() == null) { + throw new ServiceException("运单ID不能为空"); + } + String nodeCode = Func.toStr(dto.getNodeCode(), "").trim(); + if (Func.isEmpty(nodeCode)) { + throw new ServiceException("节点编码不能为空"); + } + Driver driver = requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(dto.getWaybillId(), currentBoundPlates(driver)); + waybill = normalizeAcceptStatus(waybill); + + Map nodeCfg = findPunchNodeConfig(resolveProcessJson(waybill), nodeCode); + if (nodeCfg == null) { + throw new ServiceException("该节点未启用打卡或不存在"); + } + if (WaybillProcessSupport.isTransitNodePublic(nodeCfg)) { + throw new ServiceException("在途打卡请使用在途打卡接口"); + } + WaybillNodePunch existed = findNodePunch(latestNodePunchMap(waybill.getId()), nodeCfg); + if (existed != null) { + throw new ServiceException("该节点已打卡,不可重复打卡"); + } + + Date now = new Date(); + WaybillNodePunch punch = new WaybillNodePunch(); + punch.setWaybillId(waybill.getId()); + punch.setWaybillNo(waybill.getWaybillNo()); + punch.setDriverId(driver.getId()); + punch.setNodeCode(WaybillProcessSupport.nodeKey(nodeCfg)); + punch.setNodeName(WaybillProcessSupport.nodeName(nodeCfg)); + punch.setPunchTime(now); + if (dto.getLocation() != null) { + if (dto.getLocation().getLongitude() != null) { + punch.setLongitude(BigDecimal.valueOf(dto.getLocation().getLongitude())); + } + if (dto.getLocation().getLatitude() != null) { + punch.setLatitude(BigDecimal.valueOf(dto.getLocation().getLatitude())); + } + punch.setAddress(Func.toStr(dto.getLocation().getAddress(), "").trim()); + } + if (dto.getPhotos() != null && !dto.getPhotos().isEmpty()) { + List urls = dto.getPhotos().stream() + .filter(Objects::nonNull) + .map(String::trim) + .filter(Func::isNotEmpty) + .collect(Collectors.toList()); + if (!urls.isEmpty()) { + List types = WaybillProcessSupport.nodeStringList(nodeCfg, "voucherTypes"); + List> photoItems = new ArrayList<>(); + for (int i = 0; i < urls.size(); i++) { + Map item = new LinkedHashMap<>(); + String type = i < types.size() ? types.get(i) : ("凭证" + (i + 1)); + item.put("type", type); + item.put("url", urls.get(i)); + photoItems.add(item); + } + punch.setPhotos(JsonUtil.toJson(photoItems)); + } + } + punch.setWeight(trimOrNull(dto.getWeight())); + punch.setVolume(trimOrNull(dto.getVolume())); + punch.setQuantity(trimOrNull(dto.getQuantity())); + punch.setRemark(trimOrNull(dto.getRemark())); + punch.setExceptionFlag(Boolean.TRUE.equals(dto.getException()) ? 1 : 0); + nodePunchMapper.insert(punch); + + advanceCurrentProcessNode(waybill, nodeCfg); + return toNodePunchVO(punch); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean accept(Long id) { + Driver driver = requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + waybillService.syncDriverAcceptState(waybill); + assertAcceptable(waybill); + Date now = new Date(); + waybill.setDriverAcceptStatus(WaybillProcessSupport.ACCEPT_ACCEPTED); + waybill.setDriverAcceptTime(now); + waybill.setDriverAcceptDriverId(driver.getId()); + waybill.setDriverRejectTime(null); + waybill.setDriverRejectReason(null); + waybill.setBusinessStatus(STATUS_RUNNING); + return waybillService.updateById(waybill); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean reject(Long id, String reason) { + Driver driver = requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + waybillService.syncDriverAcceptState(waybill); + assertRejectable(waybill); + String rejectReason = Func.isEmpty(reason) ? null : reason.trim(); + if (Func.isNotEmpty(rejectReason) && rejectReason.length() > 200) { + throw new ServiceException("拒绝原因不能超过200字"); + } + Date now = new Date(); + waybill.setDriverAcceptStatus(WaybillProcessSupport.ACCEPT_REJECTED); + waybill.setDriverAcceptTime(null); + waybill.setDriverAcceptDriverId(null); + waybill.setDriverRejectTime(now); + waybill.setDriverRejectReason(rejectReason); + waybill.setBusinessStatus(STATUS_PENDING); + return waybillService.updateById(waybill); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean complete(Long id) { + if (id == null) { + throw new ServiceException("运单ID不能为空"); + } + Driver driver = requireCurrentDriver(); + Waybill waybill = loadDriverWaybill(id, currentBoundPlates(driver)); + waybill = normalizeAcceptStatus(waybill); + if (!STATUS_RUNNING.equals(waybill.getBusinessStatus()) + && !STATUS_PENDING.equals(waybill.getBusinessStatus()) + && !STATUS_WAITING_DISPATCH.equals(waybill.getBusinessStatus()) + && !STATUS_DISPATCHING.equals(waybill.getBusinessStatus())) { + throw new ServiceException("当前运单状态不允许完成"); + } + // 与管理端完成逻辑一致:改状态 + 生成应收应付明细 + 尝试完成配载单 + return waybillService.completeWithoutDeptCheck(waybill.getId()); + } + + private void assertAcceptable(Waybill waybill) { + if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) { + throw new ServiceException("当前运单状态不允许接单"); + } + if (!WaybillProcessSupport.requiresDriverAcceptConfirmation(resolveProcessJson(waybill))) { + throw new ServiceException("该运单无需确认接单"); + } + if (WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus())) { + throw new ServiceException("该运单已接单"); + } + } + + private void assertRejectable(Waybill waybill) { + if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) { + throw new ServiceException("当前运单状态不允许拒绝接单"); + } + if (!WaybillProcessSupport.requiresDriverAcceptConfirmation(resolveProcessJson(waybill))) { + throw new ServiceException("该运单无需确认接单"); + } + if (WaybillProcessSupport.isAccepted(waybill.getDriverAcceptStatus())) { + throw new ServiceException("该运单已接单,无法拒绝"); + } + } + + private Waybill loadDriverWaybill(Long id, List plates) { + if (id == null) { + throw new ServiceException("运单ID不能为空"); + } + if (plates.isEmpty()) { + throw new ServiceException("当前司机未绑定车辆"); + } + Waybill waybill = waybillService.getOne(scopedQuery(plates).eq(Waybill::getId, id).last("LIMIT 1")); + if (waybill == null) { + throw new ServiceException("运单不存在或无权操作"); + } + return waybill; + } + + private Driver requireCurrentDriver() { + Driver driver = currentDriver(); + if (driver == null) { + throw new ServiceException("未找到当前登录司机档案"); + } + return driver; + } + + private List currentBoundPlates(Driver driver) { + if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) { + return List.of(); + } + return splitPlates(driver.getDrivingVehicle()); + } + + /** + * 查询侧状态条件:进行中需包含可能被校正的 pending;待接单只查 pending 类。 + */ + private void applyStatusFilterForQuery(LambdaQueryWrapper wrapper, Integer status) { + if (status == null) { + wrapper.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES); + return; + } + switch (status) { + case 0 -> wrapper.in(Waybill::getBusinessStatus, PENDING_STATUSES); + case 1 -> wrapper.in(Waybill::getBusinessStatus, List.of(STATUS_RUNNING, STATUS_PENDING, + STATUS_WAITING_DISPATCH, STATUS_DISPATCHING)); + case 2 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_COMPLETED); + case 3 -> wrapper.eq(Waybill::getBusinessStatus, STATUS_CANCELLED); + default -> wrapper.in(Waybill::getBusinessStatus, TAB_ALL_STATUSES); + } + } + + private boolean matchAppStatus(Waybill waybill, Integer status) { + if (status == null) { + Integer app = toAppStatus(waybill.getBusinessStatus()); + return app != null && app >= 0 && app <= 2; + } + return Objects.equals(toAppStatus(waybill.getBusinessStatus()), status); + } + + /** + * 无过程配置或无需确认接单的 pending 运单,落库校正为 running; + * 需要确认接单且尚未接单的 running 运单,落库校正为 pending。 + */ + private Waybill normalizeAcceptStatus(Waybill waybill) { + return waybillService.syncDriverAcceptState(waybill); + } + + /** + * 司机可见运单范围:运单车牌(主车/挂车)落在当前司机绑定车牌内。 + * 绑定来源:blade_transport_driver.driving_vehicle + */ + private LambdaQueryWrapper scopedQuery(List plates) { + return Wrappers.lambdaQuery().and(w -> { + w.in(Waybill::getVehicleNo, plates) + .or().in(Waybill::getTrailerVehicleNo, plates); + for (String plate : plates) { + w.or().apply( + "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", + plate + ); + w.or().apply( + "REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(UPPER(IFNULL(trailer_vehicle_no,'')),'·',''),'•',''),'.',''),'-',''),' ','') = {0}", + plate + ); + } + }); + } + + private void applyKeyword(LambdaQueryWrapper wrapper, String keyword) { + if (Func.isEmpty(keyword)) { + return; + } + String kw = keyword.trim(); + wrapper.and(w -> w.like(Waybill::getWaybillNo, kw) + .or().like(Waybill::getDepartureName, kw) + .or().like(Waybill::getArrivalName, kw) + .or().like(Waybill::getDepartureAddress, kw) + .or().like(Waybill::getArrivalAddress, kw) + .or().like(Waybill::getVehicleNo, kw)); + } + + /** 当前登录司机绑定的规范化车牌列表;无司机或无绑定车牌则空 */ + private List currentBoundPlates() { + Driver driver = currentDriver(); + if (driver == null || Func.isEmpty(driver.getDrivingVehicle())) { + return List.of(); + } + return splitPlates(driver.getDrivingVehicle()); + } + + /** + * 当前登录司机:优先 userId,其次 JWT 账号(手机号)匹配 driver.mobile + */ + private Driver currentDriver() { + Long userId = AuthUtil.getUserId(); + if (userId == null || userId <= 0) { + return null; + } + Driver driver = driverService.getOne(Wrappers.lambdaQuery() + .eq(Driver::getUserId, userId) + .last("LIMIT 1")); + if (driver != null) { + return driver; + } + String account = AuthUtil.getUserAccount(); + if (Func.isNotEmpty(account) && account.matches("^1\\d{10}$")) { + return driverService.getOne(Wrappers.lambdaQuery() + .eq(Driver::getMobile, account.trim()) + .orderByDesc(Driver::getUpdateTime) + .last("LIMIT 1")); + } + return null; + } + + private List splitPlates(String drivingVehicle) { + String normalized = drivingVehicle.replace(",", ",").replace("、", ",").replace(";", ",") + .replace(";", ",").replace("/", ",").replace("|", ","); + Set plates = new LinkedHashSet<>(); + for (String part : Func.toStrList(",", normalized)) { + if (Func.isEmpty(part)) { + continue; + } + String plate = normalizePlate(part); + if (Func.isNotEmpty(plate)) { + plates.add(plate); + } + } + return new ArrayList<>(plates); + } + + private String normalizePlate(String plateNo) { + if (Func.isEmpty(plateNo)) { + return ""; + } + return plateNo.trim().replaceAll("[\\s\\-·•..]", "").toUpperCase(Locale.ROOT); + } + + private int normalizePreviewSize(Integer size) { + if (size == null || size < 1) { + return DEFAULT_PREVIEW_SIZE; + } + return Math.min(size, MAX_PREVIEW_SIZE); + } + + private DriverWaybillCardVO toCard(Waybill waybill) { + return toCard(waybill, false); + } + + private DriverWaybillCardVO toCard(Waybill waybill, boolean withEnrouteRecords) { + DriverWaybillCardVO card = new DriverWaybillCardVO(); + card.setId(waybill.getId()); + card.setWaybillNo(waybill.getWaybillNo()); + card.setFromName(Func.toStr(waybill.getDepartureName(), "")); + card.setToName(Func.toStr(waybill.getArrivalName(), "")); + card.setFromAddress(Func.toStr(waybill.getDepartureAddress(), card.getFromName())); + card.setToAddress(Func.toStr(waybill.getArrivalAddress(), card.getToName())); + card.setCargoNames(splitCargoNames(waybill.getCargoName())); + card.setCargoCategory(Func.toStr(waybill.getCargoType(), "")); + card.setWeight(formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); + card.setStatus(toAppStatus(waybill.getBusinessStatus())); + String createTime = formatDateTime(waybill.getCreateTime()); + card.setCreateTime(createTime); + card.setPublishTime(createTime); + card.setCurrentNode(Func.toStr(waybill.getCurrentProcessNode(), "")); + card.setTimeRange(formatTimeRange(waybill)); + card.setFreight(resolveFreight(waybill)); + card.setDriverName(Func.toStr(waybill.getDriverName(), "")); + card.setDriverPhone(Func.toStr(waybill.getDriverPhone(), "")); + card.setVehicleNo(Func.toStr(waybill.getVehicleNo(), "")); + String processJson = resolveProcessJson(waybill); + card.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson)); + card.setAcceptStatus(waybill.getDriverAcceptStatus()); + card.setRejectReason(waybill.getDriverRejectReason()); + + if (withEnrouteRecords) { + Date lastPunchAt = findLastPunchTime(waybill.getId()); + WaybillProcessSupport.TransitCheckinDecision transit = WaybillProcessSupport.evaluateTransitCheckin( + processJson, waybill.getBusinessStatus(), lastPunchAt, LocalDateTime.now()); + card.setTransitPunchEnabled(transit.punchEnabled()); + card.setTransitCheckinVisible(transit.visible()); + card.setRequireTransitCheckinToday(transit.dueToday()); + card.setTransitCheckinDoneToday(transit.doneToday()); + card.setTransitFrequencyDays(transit.frequencyDays()); + card.setTransitTimeStart(transit.timeStart()); + card.setTransitTimeEnd(transit.timeEnd()); + card.setEnrouteRecords(listEnrouteRecords(waybill.getId())); + card.setPunchNodes(buildPunchNodes(waybill, transit, processJson)); + } else { + // 列表/首页:只解析过程配置是否启用在途打卡,不做频次/时段与落库查询 + boolean punchEnabled = WaybillProcessSupport.isTransitPunchEnabled(processJson); + card.setTransitPunchEnabled(punchEnabled); + card.setTransitCheckinVisible(null); + card.setRequireTransitCheckinToday(null); + card.setTransitCheckinDoneToday(null); + } + return card; + } + + /** + * 动态获取项目启用中的过程配置节点 JSON;无则回退运单快照 processJson。 + */ + private String resolveProcessJson(Waybill waybill) { + if (waybill == null) { + return null; + } + String live = loadLiveProcessConfigJson(waybill.getProjectId()); + if (Func.isNotEmpty(live)) { + return live; + } + return waybill.getProcessJson(); + } + + private String loadLiveProcessConfigJson(Long projectId) { + if (projectId == null) { + return null; + } + String projectIdStr = String.valueOf(projectId); + return processConfigService.list(Wrappers.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); + } + + /** + * 组装过程配置 punch=是 的打卡节点列表。 + *

+ * 未打卡节点均可打,不做顺序/时段门禁; + * 默认展开:第一个未完成的可见节点。 + * 非在途节点「已打卡」以节点打卡表为准。 + * 节点字段(定位/货量/凭证)取自动态过程配置。 + */ + private List buildPunchNodes( + Waybill waybill, + WaybillProcessSupport.TransitCheckinDecision transit, + String processJson + ) { + List> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson); + if (punchConfigs.isEmpty()) { + return Collections.emptyList(); + } + + List nodes = new ArrayList<>(); + DriverEnrouteRecordVO latestEnroute = null; + List enroutes = listEnrouteRecords(waybill.getId()); + if (!enroutes.isEmpty()) { + latestEnroute = enroutes.get(enroutes.size() - 1); + } + Map latestNodePunchByCode = latestNodePunchMap(waybill.getId()); + + for (Map cfg : punchConfigs) { + boolean isTransit = WaybillProcessSupport.isTransitNodePublic(cfg); + DriverPunchNodeVO vo = new DriverPunchNodeVO(); + vo.setKey(WaybillProcessSupport.nodeKey(cfg)); + vo.setName(WaybillProcessSupport.nodeName(cfg)); + vo.setTransit(isTransit); + vo.setNeedLocation(WaybillProcessSupport.nodeNeedLocation(cfg)); + vo.setNeedCargo(WaybillProcessSupport.nodeNeedCargo(cfg)); + vo.setCargoTypes(WaybillProcessSupport.nodeStringList(cfg, "cargoTypes")); + vo.setNeedVoucher(WaybillProcessSupport.nodeNeedVoucher(cfg)); + vo.setVoucherTypes(WaybillProcessSupport.nodeStringList(cfg, "voucherTypes")); + vo.setDefaultExpanded(false); + + if (isTransit) { + // 在途:今日已打则不可再打,回显最新一次信息 + boolean punchOn = transit != null && transit.punchEnabled(); + boolean done = transit != null && transit.doneToday(); + if (!punchOn) { + continue; + } + vo.setVisible(true); + vo.setDone(done); + vo.setActionable(!done); + if (done && latestEnroute != null) { + vo.setCheckinTime(latestEnroute.getTime()); + vo.setCheckinPlace(latestEnroute.getAddress()); + if (Func.isNotEmpty(latestEnroute.getPhoto())) { + DriverPunchPhotoVO photo = new DriverPunchPhotoVO(); + String voucherType = vo.getVoucherTypes().isEmpty() ? "货物照片" : vo.getVoucherTypes().get(0); + photo.setType(voucherType); + photo.setLabel(vo.getName() + "-" + voucherType); + photo.setUrl(latestEnroute.getPhoto()); + vo.setPhotos(List.of(photo)); + } + } + nodes.add(vo); + continue; + } + + WaybillNodePunch punched = findNodePunch(latestNodePunchByCode, cfg); + boolean done = punched != null; + vo.setVisible(true); + vo.setDone(done); + vo.setActionable(!done); + if (done) { + if (punched.getPunchTime() != null) { + LocalDateTime ldt = punched.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + vo.setCheckinTime(ldt.format(TIME_HM)); + } + vo.setCheckinPlace(Func.toStr(punched.getAddress(), "")); + vo.setWeight(punched.getWeight()); + vo.setVolume(punched.getVolume()); + vo.setQuantity(punched.getQuantity()); + vo.setPhotos(decodeDriverPunchPhotos( + punched.getPhotos(), + vo.getName(), + vo.getVoucherTypes())); + } + nodes.add(vo); + } + + for (DriverPunchNodeVO vo : nodes) { + if (!Boolean.TRUE.equals(vo.getDone())) { + vo.setDefaultExpanded(true); + break; + } + } + return nodes; + } + + private Map findPunchNodeConfig(String processJson, String nodeCode) { + List> punchConfigs = WaybillProcessSupport.listDriverPunchNodes(processJson); + for (Map cfg : punchConfigs) { + String key = WaybillProcessSupport.nodeKey(cfg); + String name = WaybillProcessSupport.nodeName(cfg); + if (nodeCode.equalsIgnoreCase(key) || nodeCode.equals(name)) { + return cfg; + } + } + return null; + } + + /** 打卡后推进运单当前过程节点到下一启用节点(若已是末节点则保持本节点) */ + private void advanceCurrentProcessNode(Waybill waybill, Map punchedCfg) { + List> enabled = WaybillProcessSupport.listEnabledProcessNodes(resolveProcessJson(waybill)); + int idx = indexInEnabled(enabled, punchedCfg); + if (idx < 0) { + waybill.setCurrentProcessNode(WaybillProcessSupport.nodeKey(punchedCfg)); + waybillService.updateById(waybill); + return; + } + if (idx + 1 < enabled.size()) { + waybill.setCurrentProcessNode(WaybillProcessSupport.nodeKey(enabled.get(idx + 1))); + } else { + waybill.setCurrentProcessNode(WaybillProcessSupport.nodeKey(punchedCfg)); + } + waybillService.updateById(waybill); + } + + private Map latestNodePunchMap(Long waybillId) { + Map map = new HashMap<>(); + if (waybillId == null) { + return map; + } + List list = nodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId) + .orderByAsc(WaybillNodePunch::getPunchTime)); + for (WaybillNodePunch punch : list) { + String code = Func.toStr(punch.getNodeCode(), "").trim(); + if (Func.isNotEmpty(code)) { + map.put(code.toLowerCase(Locale.ROOT), punch); + } + } + return map; + } + + private WaybillNodePunch findNodePunch(Map map, Map cfg) { + if (map == null || map.isEmpty() || cfg == null) { + return null; + } + String key = WaybillProcessSupport.nodeKey(cfg); + if (Func.isNotEmpty(key)) { + WaybillNodePunch hit = map.get(key.toLowerCase(Locale.ROOT)); + if (hit != null) { + return hit; + } + } + String name = WaybillProcessSupport.nodeName(cfg); + if (Func.isNotEmpty(name)) { + return map.get(name.toLowerCase(Locale.ROOT)); + } + return null; + } + + @SuppressWarnings("unchecked") + private List decodeDriverPunchPhotos(String raw, String nodeName, List voucherTypes) { + List out = new ArrayList<>(); + if (Func.isEmpty(raw)) { + return out; + } + String text = raw.trim(); + List types = voucherTypes == null ? List.of() : voucherTypes; + if (text.startsWith("[")) { + try { + List list = JsonUtil.parse(text, List.class); + if (list != null) { + int i = 0; + for (Object item : list) { + if (item instanceof Map map) { + String url = Func.toStr(map.get("url"), "").trim(); + if (Func.isEmpty(url)) { + continue; + } + String type = Func.toStr(map.get("type"), "").trim(); + if (Func.isEmpty(type) && i < types.size()) { + type = types.get(i); + } + if (Func.isEmpty(type)) { + type = "凭证" + (i + 1); + } + out.add(buildDriverPunchPhoto(nodeName, type, url)); + i++; + } else if (item != null && Func.isNotEmpty(String.valueOf(item).trim())) { + String type = i < types.size() ? types.get(i) : ("凭证" + (i + 1)); + out.add(buildDriverPunchPhoto(nodeName, type, String.valueOf(item).trim())); + i++; + } + } + return out; + } + } catch (Exception ignored) { + // fall through + } + } + String[] urls = text.split(","); + for (int i = 0; i < urls.length; i++) { + String url = urls[i].trim(); + if (Func.isEmpty(url)) { + continue; + } + String type = i < types.size() ? types.get(i) : ("凭证" + (i + 1)); + out.add(buildDriverPunchPhoto(nodeName, type, url)); + } + return out; + } + + private DriverPunchPhotoVO buildDriverPunchPhoto(String nodeName, String type, String url) { + DriverPunchPhotoVO photo = new DriverPunchPhotoVO(); + photo.setType(type); + photo.setUrl(url); + photo.setLabel(Func.toStr(nodeName, "节点") + "-" + type); + return photo; + } + + private DriverNodePunchVO toNodePunchVO(WaybillNodePunch punch) { + DriverNodePunchVO vo = new DriverNodePunchVO(); + vo.setWaybillId(punch.getWaybillId()); + vo.setNodeCode(punch.getNodeCode()); + vo.setNodeName(punch.getNodeName()); + vo.setAddress(Func.toStr(punch.getAddress(), "")); + vo.setWeight(punch.getWeight()); + vo.setVolume(punch.getVolume()); + vo.setQuantity(punch.getQuantity()); + vo.setPhotos(extractPhotoUrls(punch.getPhotos())); + if (punch.getPunchTime() != null) { + vo.setCheckinTime(formatDateTime(punch.getPunchTime())); + } + return vo; + } + + @SuppressWarnings("unchecked") + private List extractPhotoUrls(String raw) { + List urls = new ArrayList<>(); + if (Func.isEmpty(raw)) { + return urls; + } + String text = raw.trim(); + if (text.startsWith("[")) { + try { + List list = JsonUtil.parse(text, List.class); + if (list != null) { + for (Object item : list) { + if (item instanceof Map map) { + String url = Func.toStr(map.get("url"), "").trim(); + if (Func.isNotEmpty(url)) { + urls.add(url); + } + } else if (item != null && Func.isNotEmpty(String.valueOf(item).trim())) { + urls.add(String.valueOf(item).trim()); + } + } + return urls; + } + } catch (Exception ignored) { + // fall through + } + } + return Arrays.stream(text.split(",")) + .map(String::trim) + .filter(Func::isNotEmpty) + .collect(Collectors.toList()); + } + + private static String trimOrNull(String value) { + String v = Func.toStr(value, "").trim(); + return Func.isEmpty(v) ? null : v; + } + + private int indexInEnabled(List> enabled, Map target) { + String key = WaybillProcessSupport.nodeKey(target); + String name = WaybillProcessSupport.nodeName(target); + for (int i = 0; i < enabled.size(); i++) { + Map n = enabled.get(i); + if (key.equals(WaybillProcessSupport.nodeKey(n)) || name.equals(WaybillProcessSupport.nodeName(n))) { + return i; + } + } + return -1; + } + + private Date findLastPunchTime(Long waybillId) { + if (waybillId == null) { + return null; + } + WaybillEnroutePunch latest = enroutePunchMapper.selectOne(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybillId) + .orderByDesc(WaybillEnroutePunch::getPunchTime) + .last("LIMIT 1")); + return latest == null ? null : latest.getPunchTime(); + } + + private List listEnrouteRecords(Long waybillId) { + if (waybillId == null) { + return Collections.emptyList(); + } + List punches = enroutePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybillId) + .orderByAsc(WaybillEnroutePunch::getPunchTime)); + return punches.stream().map(this::toEnrouteRecord).collect(Collectors.toList()); + } + + private DriverEnrouteRecordVO toEnrouteRecord(WaybillEnroutePunch punch) { + DriverEnrouteRecordVO vo = new DriverEnrouteRecordVO(); + vo.setAddress(Func.toStr(punch.getAddress(), "")); + vo.setPhoto(punch.getPhoto()); + if (punch.getPunchTime() != null) { + LocalDateTime ldt = punch.getPunchTime().toInstant().atZone(ZoneId.systemDefault()).toLocalDateTime(); + vo.setTime(ldt.format(TIME_HM)); + } else { + vo.setTime(""); + } + return vo; + } + + /** + * 后端 businessStatus → 小程序数字状态 + */ + private Integer toAppStatus(String businessStatus) { + if (Func.isEmpty(businessStatus)) { + return null; + } + return switch (businessStatus) { + case STATUS_PENDING, STATUS_WAITING_DISPATCH, STATUS_DISPATCHING -> 0; + case STATUS_RUNNING -> 1; + case STATUS_COMPLETED -> 2; + case STATUS_CANCELLED -> 3; + default -> null; + }; + } + + private List splitCargoNames(String cargoName) { + if (Func.isEmpty(cargoName)) { + return Collections.emptyList(); + } + String normalized = cargoName.replace(",", ",").replace("、", ","); + List names = Func.toStrList(",", normalized).stream() + .map(String::trim) + .filter(Func::isNotEmpty) + .collect(Collectors.toList()); + return names.isEmpty() ? List.of(cargoName.trim()) : names; + } + + private String formatWeight(BigDecimal quantity, String unit) { + if (quantity == null) { + return ""; + } + String qty = quantity.stripTrailingZeros().toPlainString(); + return Func.isEmpty(unit) ? qty : qty + unit; + } + + private String formatDateTime(Date date) { + if (date == null) { + return ""; + } + return DateUtil.format(date, DateUtil.PATTERN_DATETIME); + } + + private String formatTimeRange(Waybill waybill) { + LocalDate start = waybill.getEstimatedStartTime() != null + ? waybill.getEstimatedStartTime() + : waybill.getStartDate(); + LocalDate end = waybill.getEstimatedEndTime() != null + ? waybill.getEstimatedEndTime() + : waybill.getEndDate(); + if (start == null && end == null) { + return ""; + } + if (start != null && end != null) { + if (start.getYear() == end.getYear()) { + return start.format(DATE_DOT) + " - " + end.format(DATE_DOT_SHORT); + } + return start.format(DATE_DOT) + " - " + end.format(DATE_DOT); + } + LocalDate only = start != null ? start : end; + return only.format(DATE_DOT); + } + + private BigDecimal resolveFreight(Waybill waybill) { + if (waybill.getUnitPrice() != null && waybill.getQuantity() != null) { + return waybill.getUnitPrice().multiply(waybill.getQuantity()).setScale(2, RoundingMode.HALF_UP); + } + return waybill.getOtherFeeTotal() == null ? BigDecimal.ZERO : waybill.getOtherFeeTotal(); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java index 6c4f7bd..008e827 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ExceptionDisposalServiceImpl.java @@ -37,15 +37,22 @@ import org.springblade.transport.mapper.ExceptionDisposalMapper; import org.springblade.transport.pojo.dto.ExceptionDisposalFollowRequest; import org.springblade.transport.pojo.entity.ExceptionDisposal; import org.springblade.transport.pojo.entity.ExceptionDisposalFollowRecord; +import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.ExceptionDisposalFollowRecordVO; import org.springblade.transport.pojo.vo.ExceptionDisposalVO; import org.springblade.transport.service.IExceptionDisposalService; +import org.springblade.transport.service.IWaybillService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Objects; +import java.util.stream.Collectors; /** * 异常处置服务实现类 @@ -62,9 +69,12 @@ public class ExceptionDisposalServiceImpl private static final String STATUS_COMPLETED = "completed"; private final ExceptionDisposalFollowRecordMapper followRecordMapper; + private final IWaybillService waybillService; - public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper) { + public ExceptionDisposalServiceImpl(ExceptionDisposalFollowRecordMapper followRecordMapper, + IWaybillService waybillService) { this.followRecordMapper = followRecordMapper; + this.waybillService = waybillService; } @Override @@ -82,6 +92,79 @@ public class ExceptionDisposalServiceImpl return vo; } + @Override + @Transactional(rollbackFor = Exception.class) + public ExceptionDisposalVO submitReport(ExceptionDisposal request) { + if (request == null) { + throw new ServiceException("请填写异常信息"); + } + if (Func.isBlank(request.getExceptionType())) { + throw new ServiceException("请选择异常类型"); + } + if (Func.isBlank(request.getReportDescription())) { + throw new ServiceException("请填写上报说明"); + } + if (request.getReportDescription().length() > 500) { + throw new ServiceException("上报说明不能超过500字"); + } + + ExceptionDisposal disposal = new ExceptionDisposal(); + disposal.setExceptionType(request.getExceptionType().trim()); + disposal.setExceptionReason(Func.isBlank(request.getExceptionReason()) + ? null + : request.getExceptionReason().trim()); + disposal.setReportDescription(request.getReportDescription().trim()); + disposal.setScenePhotos(Func.isBlank(request.getScenePhotos()) + ? null + : request.getScenePhotos().trim()); + disposal.setDisposalStatus(STATUS_PENDING); + disposal.setReportTime(LocalDateTime.now()); + disposal.setReporterId(AuthUtil.getUserId()); + disposal.setReporterName(UserCache.getUserRealName(AuthUtil.getUserId())); + + fillFromWaybill(disposal, request.getWaybillId(), request.getWaybillNo()); + + if (disposal.getWaybillId() == null) { + throw new ServiceException("请关联运单后再上报"); + } + + if (!save(disposal)) { + throw new ServiceException("异常上报失败"); + } + return toVO(disposal); + } + + /** 按运单补齐运单号 / 车牌 / 项目 / 承运商等展示字段 */ + private void fillFromWaybill(ExceptionDisposal disposal, Long waybillId, String waybillNo) { + Waybill waybill = null; + if (waybillId != null) { + waybill = waybillService.getById(waybillId); + } + if (waybill == null && Func.isNotBlank(waybillNo)) { + waybill = waybillService.getOne(Wrappers.lambdaQuery() + .eq(Waybill::getWaybillNo, waybillNo) + .eq(Waybill::getIsDeleted, 0) + .last("LIMIT 1")); + } + if (waybill == null) { + if (waybillId != null || Func.isNotBlank(waybillNo)) { + throw new ServiceException("关联运单不存在"); + } + return; + } + disposal.setWaybillId(waybill.getId()); + disposal.setWaybillNo(waybill.getWaybillNo()); + disposal.setVehicleNo(waybill.getVehicleNo()); + disposal.setProjectId(waybill.getProjectId()); + disposal.setProjectName(waybill.getProjectName()); + disposal.setCarrierId(waybill.getCarrierId()); + disposal.setCarrierName(waybill.getCarrierName()); + String loadingOrMaster = Func.isNotBlank(waybill.getLoadingNo()) + ? waybill.getLoadingNo() + : waybill.getMasterNo(); + disposal.setLoadingOrMasterNo(loadingOrMaster); + } + @Override @Transactional(rollbackFor = Exception.class) public void follow(ExceptionDisposalFollowRequest request) { @@ -179,9 +262,49 @@ public class ExceptionDisposalServiceImpl vo.setCreateUserName(UserCache.getUserRealName(entity.getCreateUser())); vo.setUpdateUserName(UserCache.getUserRealName(entity.getUpdateUser())); vo.setDisposalStatusName(statusName(entity.getDisposalStatus())); + vo.setScenePhotoList(splitPhotos(entity.getScenePhotos())); + fillRouteAndCargo(vo, entity.getWaybillId()); return vo; } + /** 按关联运单补齐详情页路线 / 货物展示字段 */ + private void fillRouteAndCargo(ExceptionDisposalVO vo, Long waybillId) { + if (vo == null || waybillId == null) { + return; + } + Waybill waybill = waybillService.getById(waybillId); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + return; + } + Map route = new HashMap<>(2); + route.put("start", Func.toStr(waybill.getDepartureName(), "")); + route.put("end", Func.toStr(waybill.getArrivalName(), "")); + vo.setRoute(route); + + Map cargo = new HashMap<>(2); + cargo.put("name", Func.toStr(waybill.getCargoName(), "")); + cargo.put("weight", formatWeight(waybill.getQuantity(), waybill.getQuantityUnit())); + vo.setCargo(cargo); + } + + private String formatWeight(BigDecimal quantity, String unit) { + if (quantity == null) { + return ""; + } + String qty = quantity.stripTrailingZeros().toPlainString(); + return Func.isBlank(unit) ? qty : qty + unit; + } + + private List splitPhotos(String scenePhotos) { + if (Func.isBlank(scenePhotos)) { + return List.of(); + } + return Arrays.stream(scenePhotos.split(",")) + .map(String::trim) + .filter(s -> Func.isNotBlank(s)) + .collect(Collectors.toList()); + } + private List followRecords(Long id) { List records = followRecordMapper.selectList(Wrappers.lambdaQuery() .eq(ExceptionDisposalFollowRecord::getDisposalId, id) diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProcessConfigServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProcessConfigServiceImpl.java index 131e546..a0015e8 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProcessConfigServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/ProcessConfigServiceImpl.java @@ -33,7 +33,9 @@ import org.springblade.system.cache.UserCache; import org.springblade.system.pojo.entity.Dept; import org.springblade.transport.excel.ProcessConfigExportExcel; import org.springblade.transport.mapper.ProcessConfigMapper; +import org.springblade.transport.mapper.WaybillMapper; import org.springblade.transport.pojo.entity.ProcessConfig; +import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.ProcessConfigVO; import org.springblade.transport.service.IProcessConfigService; @@ -43,8 +45,11 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; import java.util.List; import java.util.Objects; +import java.util.Set; /** * 过程配置 服务实现类 @@ -54,15 +59,25 @@ import java.util.Objects; @Service public class ProcessConfigServiceImpl extends BaseServiceImpl implements IProcessConfigService { + private final WaybillMapper waybillMapper; + + public ProcessConfigServiceImpl(WaybillMapper waybillMapper) { + this.waybillMapper = waybillMapper; + } + @Override public IPage selectProcessConfigPage(IPage page, ProcessConfigVO processConfig) { IPage entityPage = page(page, buildQuery(processConfig)); - return ProcessConfigWrapper.build().pageVO(entityPage); + IPage voPage = ProcessConfigWrapper.build().pageVO(entityPage); + fillHasRelatedWaybill(voPage.getRecords()); + return voPage; } @Override public ProcessConfigVO detail(Long id) { - return ProcessConfigWrapper.build().entityVO(loadEditable(id, false)); + ProcessConfigVO detail = ProcessConfigWrapper.build().entityVO(loadEditable(id, false)); + fillHasRelatedWaybill(List.of(detail)); + return detail; } @Override @@ -71,6 +86,7 @@ public class ProcessConfigServiceImpl extends BaseServiceImpl records) { + if (Func.isEmpty(records)) { + return; + } + Set allProjectIds = new HashSet<>(); + for (ProcessConfigVO record : records) { + allProjectIds.addAll(parseProjectIds(record.getProjectIds())); + } + Set projectIdsWithWaybill = findProjectIdsWithWaybill(allProjectIds); + for (ProcessConfigVO record : records) { + List projectIds = parseProjectIds(record.getProjectIds()); + record.setHasRelatedWaybill(projectIds.stream().anyMatch(projectIdsWithWaybill::contains)); + } + } + + private boolean hasRelatedWaybill(String projectIds) { + return !findProjectIdsWithWaybill(new HashSet<>(parseProjectIds(projectIds))).isEmpty(); + } + + private Set findProjectIdsWithWaybill(Set projectIds) { + if (Func.isEmpty(projectIds)) { + return Set.of(); + } + Set result = new HashSet<>(); + for (Long projectId : projectIds) { + if (waybillMapper.selectCount(Wrappers.lambdaQuery() + .eq(Waybill::getProjectId, projectId) + .eq(Waybill::getIsDeleted, 0)) > 0) { + result.add(projectId); + } + } + return result; + } + + private List parseProjectIds(String projectIds) { + if (Func.isEmpty(projectIds)) { + return List.of(); + } + return Arrays.stream(projectIds.split(",")) + .map(String::trim) + .filter(Func::isNotEmpty) + .map(item -> { + try { + return Long.valueOf(item); + } catch (NumberFormatException ex) { + return null; + } + }) + .filter(Objects::nonNull) + .distinct() + .toList(); + } + private LambdaQueryWrapper buildQuery(ProcessConfigVO processConfig) { TransportBusinessSupport.validateAllDept(processConfig.getAllDept(), "过程配置"); LambdaQueryWrapper queryWrapper = Wrappers.lambdaQuery().eq(ProcessConfig::getIsDeleted, 0); diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java index 688d1dd..966e186 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/service/impl/WaybillServiceImpl.java @@ -36,15 +36,22 @@ import org.springblade.transport.excel.WaybillExcel; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.Arrays; +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.ProjectApply; 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.dto.WaybillMileageRequest; import org.springblade.transport.pojo.vo.BusinessRemoveResultVO; import org.springblade.transport.pojo.vo.LoadingManageVO; +import org.springblade.transport.pojo.vo.WaybillPunchPhotoVO; +import org.springblade.transport.pojo.vo.WaybillPunchRecordItemVO; +import org.springblade.transport.pojo.vo.WaybillPunchRecordsVO; import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.service.ILoadingManageService; import org.springblade.transport.service.IProcessConfigService; @@ -53,6 +60,7 @@ import org.springblade.transport.service.IReceivablePayableDetailService; import org.springblade.transport.service.IContractManageService; import org.springblade.transport.service.IWaybillService; import org.springblade.transport.support.TransportBusinessSupport; +import org.springblade.transport.support.WaybillProcessSupport; import org.springblade.transport.wrapper.WaybillWrapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -60,8 +68,11 @@ import lombok.extern.slf4j.Slf4j; import java.math.BigDecimal; import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; import java.util.LinkedHashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import java.util.Set; @@ -94,9 +105,16 @@ public class WaybillServiceImpl extends BaseServiceImpl @org.springframework.context.annotation.Lazy private IReceivablePayableDetailService receivablePayableDetailService; + @jakarta.annotation.Resource + private WaybillNodePunchMapper waybillNodePunchMapper; + + @jakarta.annotation.Resource + private WaybillEnroutePunchMapper waybillEnroutePunchMapper; + @Override public IPage selectWaybillPage(IPage page, WaybillVO waybill) { IPage entityPage = page(page, buildQuery(waybill)); + entityPage.getRecords().forEach(this::syncDriverAcceptState); IPage result = WaybillWrapper.build().pageVO(entityPage); fillMileageMaintainable(result.getRecords()); return result; @@ -104,12 +122,327 @@ public class WaybillServiceImpl extends BaseServiceImpl @Override public WaybillVO detail(Long id) { - WaybillVO result = WaybillWrapper.build().entityVO(loadEditable(id, false)); + WaybillVO result = WaybillWrapper.build().entityVO(syncDriverAcceptState(loadEditable(id, false))); fillCustomerNameFromContract(result); fillMileageMaintainable(List.of(result)); return result; } + @Override + public WaybillPunchRecordsVO listPunchRecords(Long waybillId) { + WaybillPunchRecordsVO vo = new WaybillPunchRecordsVO(); + if (waybillId == null) { + return vo; + } + Waybill waybill = getById(waybillId); + if (waybill == null || Objects.equals(waybill.getIsDeleted(), 1)) { + throw new ServiceException("运单不存在"); + } + String processJson = resolveLiveProcessJson(waybill); + Map> voucherTypesByNode = buildVoucherTypesIndex(processJson); + + List nodePunches = waybillNodePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillNodePunch::getWaybillId, waybillId) + .orderByAsc(WaybillNodePunch::getPunchTime) + .orderByAsc(WaybillNodePunch::getId)); + Map latestNodePunch = new LinkedHashMap<>(); + for (WaybillNodePunch punch : nodePunches) { + String code = Func.toStr(punch.getNodeCode(), "").trim(); + String name = Func.toStr(punch.getNodeName(), "").trim(); + if (Func.isNotEmpty(code)) { + latestNodePunch.put(code.toLowerCase(Locale.ROOT), punch); + } + if (Func.isNotEmpty(name)) { + latestNodePunch.put(name.toLowerCase(Locale.ROOT), punch); + } + } + + List enroutePunches = waybillEnroutePunchMapper.selectList(Wrappers.lambdaQuery() + .eq(WaybillEnroutePunch::getWaybillId, waybillId) + .orderByAsc(WaybillEnroutePunch::getPunchTime) + .orderByAsc(WaybillEnroutePunch::getId)); + WaybillEnroutePunch latestEnroute = enroutePunches.isEmpty() ? null : enroutePunches.get(enroutePunches.size() - 1); + List transitTypes = voucherTypesByNode.getOrDefault("transit", List.of("货物照片")); + + List records = new ArrayList<>(); + List uploads = new ArrayList<>(); + List> processNodes = WaybillProcessSupport.listEnabledProcessNodes(processJson); + if (processNodes.isEmpty()) { + processNodes = WaybillProcessSupport.listDriverPunchNodes(processJson); + } + + for (Map node : processNodes) { + String nodeCode = WaybillProcessSupport.nodeKey(node); + String nodeName = WaybillProcessSupport.nodeName(node); + boolean isTransit = WaybillProcessSupport.isTransitNodePublic(node); + WaybillPunchRecordItemVO item = new WaybillPunchRecordItemVO(); + item.setNodeCode(nodeCode); + item.setNodeName(nodeName); + item.setType(isTransit ? "enroute" : "node"); + item.setExceptionFlag(false); + item.setPhotos(new ArrayList<>()); + + if (isTransit) { + if (latestEnroute != null) { + item.setId(latestEnroute.getId()); + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(formatPunchTime(latestEnroute.getPunchTime())); + item.setAddress(Func.toStr(latestEnroute.getAddress(), "")); + item.setLongitude(decimalText(latestEnroute.getLongitude())); + item.setLatitude(decimalText(latestEnroute.getLatitude())); + if (Func.isNotEmpty(latestEnroute.getPhoto())) { + String voucherType = transitTypes.isEmpty() ? "货物照片" : transitTypes.get(0); + WaybillPunchPhotoVO photo = buildPhoto( + nodeName, voucherType, latestEnroute.getPhoto().trim(), item.getPunchTime()); + item.getPhotos().add(photo); + } + // 司机上传:展示全部在途照片(不仅最新一条) + for (WaybillEnroutePunch punch : enroutePunches) { + if (Func.isEmpty(punch.getPhoto())) { + continue; + } + String voucherType = transitTypes.isEmpty() ? "货物照片" : transitTypes.get(0); + uploads.add(buildPhoto(nodeName, voucherType, punch.getPhoto().trim(), formatPunchTime(punch.getPunchTime()))); + } + } else { + item.setPunched(false); + item.setStatusName("未打卡"); + item.setPunchTime(""); + } + records.add(item); + continue; + } + + WaybillNodePunch punched = findLatestNodePunch(latestNodePunch, nodeCode, nodeName); + if (punched != null) { + List types = resolveNodeVoucherTypes(voucherTypesByNode, punched.getNodeCode(), nodeName); + item.setId(punched.getId()); + item.setPunched(true); + item.setStatusName("已打卡"); + item.setPunchTime(formatPunchTime(punched.getPunchTime())); + item.setAddress(Func.toStr(punched.getAddress(), "")); + item.setLongitude(decimalText(punched.getLongitude())); + item.setLatitude(decimalText(punched.getLatitude())); + item.setWeight(punched.getWeight()); + item.setVolume(punched.getVolume()); + item.setQuantity(punched.getQuantity()); + item.setRemark(punched.getRemark()); + item.setExceptionFlag(Objects.equals(punched.getExceptionFlag(), 1)); + List photos = decodePunchPhotos(punched.getPhotos(), nodeName, types, item.getPunchTime()); + item.setPhotos(photos); + uploads.addAll(photos); + } else { + item.setPunched(false); + item.setStatusName("未打卡"); + item.setPunchTime(""); + } + records.add(item); + } + + vo.setRecords(records); + vo.setDriverUploads(uploads); + return vo; + } + + private WaybillNodePunch findLatestNodePunch(Map index, String nodeCode, String nodeName) { + if (index == null || index.isEmpty()) { + return null; + } + if (Func.isNotEmpty(nodeCode)) { + WaybillNodePunch hit = index.get(nodeCode.toLowerCase(Locale.ROOT)); + if (hit != null) { + return hit; + } + } + if (Func.isNotEmpty(nodeName)) { + return index.get(nodeName.toLowerCase(Locale.ROOT)); + } + return null; + } + + /** 动态过程配置优先,回退运单快照 */ + private String resolveLiveProcessJson(Waybill waybill) { + if (waybill.getProjectId() != null) { + String projectId = String.valueOf(waybill.getProjectId()); + String live = processConfigService.list(Wrappers.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; + } + } + return waybill.getProcessJson(); + } + + private Map> buildVoucherTypesIndex(String processJson) { + Map> map = new LinkedHashMap<>(); + for (Map node : WaybillProcessSupport.listDriverPunchNodes(processJson)) { + String key = WaybillProcessSupport.nodeKey(node); + String name = WaybillProcessSupport.nodeName(node); + List types = WaybillProcessSupport.nodeStringList(node, "voucherTypes"); + if (Func.isNotEmpty(key)) { + map.put(key.toLowerCase(Locale.ROOT), types); + } + if (Func.isNotEmpty(name)) { + map.put(name.toLowerCase(Locale.ROOT), types); + } + } + return map; + } + + private List resolveNodeVoucherTypes(Map> index, String nodeCode, String nodeName) { + if (index == null || index.isEmpty()) { + return List.of(); + } + if (Func.isNotEmpty(nodeCode)) { + List hit = index.get(nodeCode.toLowerCase(Locale.ROOT)); + if (hit != null) { + return hit; + } + } + if (Func.isNotEmpty(nodeName)) { + List hit = index.get(nodeName.toLowerCase(Locale.ROOT)); + if (hit != null) { + return hit; + } + } + return List.of(); + } + + /** + * 解析打卡 photos: + * 1) JSON 数组 [{"type":"委托单","url":"..."}] + * 2) 逗号分隔 URL,按 voucherTypes 下标回推类型 + */ + @SuppressWarnings("unchecked") + private List decodePunchPhotos( + String raw, + String nodeName, + List voucherTypes, + String punchTime + ) { + List out = new ArrayList<>(); + if (Func.isEmpty(raw)) { + return out; + } + String text = raw.trim(); + if (text.startsWith("[")) { + try { + List list = JsonUtil.parse(text, List.class); + if (list != null) { + int i = 0; + for (Object item : list) { + if (item instanceof Map map) { + String url = Func.toStr(map.get("url"), "").trim(); + if (Func.isEmpty(url)) { + continue; + } + String type = Func.toStr(map.get("type"), "").trim(); + if (Func.isEmpty(type) && voucherTypes != null && i < voucherTypes.size()) { + type = voucherTypes.get(i); + } + if (Func.isEmpty(type)) { + type = "凭证" + (i + 1); + } + out.add(buildPhoto(nodeName, type, url, punchTime)); + i++; + } else if (item != null) { + String url = String.valueOf(item).trim(); + if (Func.isEmpty(url)) { + continue; + } + String type = (voucherTypes != null && i < voucherTypes.size()) + ? voucherTypes.get(i) + : ("凭证" + (i + 1)); + out.add(buildPhoto(nodeName, type, url, punchTime)); + i++; + } + } + return out; + } + } catch (Exception ignored) { + // fall through to comma split + } + } + String[] urls = text.split(","); + for (int i = 0; i < urls.length; i++) { + String url = urls[i].trim(); + if (Func.isEmpty(url)) { + continue; + } + String type = (voucherTypes != null && i < voucherTypes.size()) + ? voucherTypes.get(i) + : ("凭证" + (i + 1)); + out.add(buildPhoto(nodeName, type, url, punchTime)); + } + return out; + } + + private WaybillPunchPhotoVO buildPhoto(String nodeName, String voucherType, String url, String punchTime) { + WaybillPunchPhotoVO photo = new WaybillPunchPhotoVO(); + photo.setNodeName(nodeName); + photo.setVoucherType(voucherType); + photo.setUrl(url); + photo.setPunchTime(punchTime); + photo.setLabel(nodeName + "-" + voucherType); + return photo; + } + + private String formatPunchTime(Date time) { + if (time == null) { + return ""; + } + return org.springblade.core.tool.utils.DateUtil.format(time, org.springblade.core.tool.utils.DateUtil.PATTERN_DATETIME); + } + + private String decimalText(BigDecimal value) { + return value == null ? "" : value.stripTrailingZeros().toPlainString(); + } + + @Override + public Waybill syncDriverAcceptState(Waybill waybill) { + if (waybill == null || waybill.getId() == null) { + return waybill; + } + if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) { + return waybill; + } + // 与司机端一致:优先项目最新过程配置,再回退运单快照 + String processJson = resolveLiveProcessJson(waybill); + if (Func.isNotEmpty(processJson)) { + waybill.setProcessJson(processJson); + } + boolean requireAccept = WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson); + String acceptStatus = waybill.getDriverAcceptStatus(); + if (requireAccept && Func.isEmpty(acceptStatus)) { + acceptStatus = WaybillProcessSupport.ACCEPT_PENDING; + } + String nextStatus = WaybillProcessSupport.normalizeBusinessStatus( + waybill.getBusinessStatus(), processJson, acceptStatus); + boolean acceptChanged = !Objects.equals(acceptStatus, waybill.getDriverAcceptStatus()); + boolean statusChanged = !Objects.equals(nextStatus, waybill.getBusinessStatus()); + if (!acceptChanged && !statusChanged) { + return waybill; + } + waybill.setDriverAcceptStatus(acceptStatus); + waybill.setBusinessStatus(nextStatus); + update(Wrappers.lambdaUpdate() + .set(Waybill::getBusinessStatus, nextStatus) + .set(Waybill::getDriverAcceptStatus, acceptStatus) + .eq(Waybill::getId, waybill.getId())); + return waybill; + } + @Override @Transactional(rollbackFor = Exception.class) public boolean submit(Waybill waybill) { @@ -128,20 +461,31 @@ public class WaybillServiceImpl extends BaseServiceImpl private void prepareForSave(Waybill waybill) { boolean created = Func.isEmpty(waybill.getId()); + Waybill oldRecord = null; if (!created) { - Waybill oldRecord = loadEditable(waybill.getId(), true); + oldRecord = loadEditable(waybill.getId(), true); assertNotLoaded(oldRecord); waybill.setWaybillNo(oldRecord.getWaybillNo()); waybill.setLoadingNo(oldRecord.getLoadingNo()); waybill.setDeptId(oldRecord.getDeptId()); waybill.setDeptName(oldRecord.getDeptName()); waybill.setMileageRemark(oldRecord.getMileageRemark()); + if (Func.isEmpty(waybill.getProcessJson())) { + waybill.setProcessJson(oldRecord.getProcessJson()); + } } else { waybill.setLoadingNo(null); waybill.setMileageRemark(null); - fillProjectProcessConfig(waybill); } + // 无过程快照时按项目回填;再按接单设置决定 pending / running + fillProjectProcessConfig(waybill); prepare(waybill); + if (oldRecord != null) { + preserveOrResetDriverAccept(waybill, oldRecord); + } else { + clearDriverAcceptRecord(waybill); + } + applyDriverAcceptBusinessStatus(waybill); fillCustomerName(waybill); if (created && Func.isEmpty(waybill.getWaybillNo())) { waybill.setWaybillNo(nextCode()); @@ -180,21 +524,60 @@ public class WaybillServiceImpl extends BaseServiceImpl } private void fillProjectProcessConfig(Waybill waybill) { - if (Func.isNotEmpty(waybill.getProcessJson()) || Func.isEmpty(waybill.getProjectId())) { + String live = resolveLiveProcessJson(waybill); + if (Func.isNotEmpty(live)) { + waybill.setProcessJson(live); + } + } + + /** + * 无过程配置,或接单设置为「无需确认接单」时,运单直接进入进行中(running); + * 需要司机确认接单且尚未接单时保持待执行(pending)。 + */ + private void applyDriverAcceptBusinessStatus(Waybill waybill) { + if (WaybillProcessSupport.isTerminalBusinessStatus(waybill.getBusinessStatus())) { return; } - String projectId = String.valueOf(waybill.getProjectId()); - processConfigService.list(Wrappers.lambdaQuery() - .eq(ProcessConfig::getStatus, 1) - .eq(ProcessConfig::getIsDeleted, 0) - .like(ProcessConfig::getProjectIds, projectId) - .orderByDesc(ProcessConfig::getCreateTime)) - .stream() - .filter(processConfig -> containsProjectId(processConfig.getProjectIds(), projectId)) - .map(ProcessConfig::getNodeConfigJson) - .filter(Func::isNotEmpty) - .findFirst() - .ifPresent(waybill::setProcessJson); + String processJson = resolveLiveProcessJson(waybill); + if (Func.isNotEmpty(processJson)) { + waybill.setProcessJson(processJson); + } + boolean requireAccept = WaybillProcessSupport.requiresDriverAcceptConfirmation(processJson); + if (requireAccept && Func.isEmpty(waybill.getDriverAcceptStatus())) { + waybill.setDriverAcceptStatus(WaybillProcessSupport.ACCEPT_PENDING); + } + waybill.setBusinessStatus(WaybillProcessSupport.normalizeBusinessStatus( + waybill.getBusinessStatus(), processJson, waybill.getDriverAcceptStatus())); + } + + private void preserveOrResetDriverAccept(Waybill waybill, Waybill oldRecord) { + if (driverAssignmentChanged(waybill, oldRecord)) { + clearDriverAcceptRecord(waybill); + return; + } + waybill.setDriverAcceptStatus(oldRecord.getDriverAcceptStatus()); + waybill.setDriverAcceptTime(oldRecord.getDriverAcceptTime()); + waybill.setDriverAcceptDriverId(oldRecord.getDriverAcceptDriverId()); + waybill.setDriverRejectTime(oldRecord.getDriverRejectTime()); + waybill.setDriverRejectReason(oldRecord.getDriverRejectReason()); + } + + private boolean driverAssignmentChanged(Waybill waybill, Waybill oldRecord) { + return !Objects.equals(waybill.getDriverId(), oldRecord.getDriverId()) + || !Objects.equals( + TransportBusinessSupport.trimToNull(waybill.getDriverPhone()), + TransportBusinessSupport.trimToNull(oldRecord.getDriverPhone())) + || !Objects.equals( + TransportBusinessSupport.trimToNull(waybill.getVehicleNo()), + TransportBusinessSupport.trimToNull(oldRecord.getVehicleNo())); + } + + private void clearDriverAcceptRecord(Waybill waybill) { + waybill.setDriverAcceptStatus(null); + waybill.setDriverAcceptTime(null); + waybill.setDriverAcceptDriverId(null); + waybill.setDriverRejectTime(null); + waybill.setDriverRejectReason(null); } private boolean containsProjectId(String projectIds, String projectId) { @@ -254,7 +637,10 @@ public class WaybillServiceImpl extends BaseServiceImpl excel.setUpdateUserName(UserCache.getUserRealName(record.getUpdateUser())); excel.setCreateTime(record.getCreateTime()); excel.setUpdateTime(record.getUpdateTime()); - excel.setBusinessStatus(WaybillWrapper.businessStatusName(record.getBusinessStatus())); + String processJson = resolveLiveProcessJson(record); + excel.setBusinessStatus(WaybillWrapper.businessStatusName( + WaybillProcessSupport.normalizeBusinessStatus( + record.getBusinessStatus(), processJson, record.getDriverAcceptStatus()))); return excel; }).toList(); } @@ -352,8 +738,10 @@ public class WaybillServiceImpl extends BaseServiceImpl target.setRemark(source.getRemark()); target.setBusinessStatus("pending"); target.setWaybillNo(nextCode()); + clearDriverAcceptRecord(target); fillProjectProcessConfig(target); prepare(target); + applyDriverAcceptBusinessStatus(target); validate(target); save(target); return detail(target.getId()); @@ -418,20 +806,61 @@ public class WaybillServiceImpl extends BaseServiceImpl @Override @Transactional(rollbackFor = Exception.class) - public boolean reassign(Long id) { - Waybill waybill = loadEditable(id, true); - assertNotLoaded(waybill); - if (!"pending".equals(waybill.getBusinessStatus())) { - throw new ServiceException("仅待执行运单允许重新派单"); + public boolean reassign(Waybill request) { + if (request == null || Func.isEmpty(request.getId())) { + throw new ServiceException("运单ID不能为空"); } - waybill.setBusinessStatus("pending"); + Waybill waybill = loadEditable(request.getId(), true); + assertNotLoaded(waybill); + if (!"pending".equals(waybill.getBusinessStatus()) && !"running".equals(waybill.getBusinessStatus())) { + throw new ServiceException("仅待执行/进行中运单允许重新派单"); + } + String driverName = TransportBusinessSupport.trimToNull(request.getDriverName()); + String driverPhone = TransportBusinessSupport.trimToNull(request.getDriverPhone()); + String vehicleNo = TransportBusinessSupport.trimToNull(request.getVehicleNo()); + TransportBusinessSupport.validateRequired(driverName, "司机不能为空"); + TransportBusinessSupport.validateRequired(driverPhone, "手机号不能为空"); + TransportBusinessSupport.validateRequired(vehicleNo, "车牌号不能为空"); + + waybill.setDriverId(request.getDriverId()); + waybill.setDriverName(driverName); + waybill.setDriverPhone(driverPhone); + waybill.setVehicleNo(vehicleNo); + // 同步承运/任务 JSON,避免列表与表单读到旧司机 + waybill.setCarrierJson(buildImportCarrierJson(waybill)); + waybill.setTaskInfoJson(buildTaskInfoJson(waybill)); + + fillProjectProcessConfig(waybill); + clearDriverAcceptRecord(waybill); + // 清空后重新进入待接单 + waybill.setDriverAcceptStatus(WaybillProcessSupport.ACCEPT_PENDING); + applyDriverAcceptBusinessStatus(waybill); return updateById(waybill); } @Override @Transactional(rollbackFor = Exception.class) public boolean complete(Long id) { - Waybill waybill = loadEditable(id, true); + return doComplete(loadEditable(id, true)); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public boolean completeWithoutDeptCheck(Long id) { + if (Func.isEmpty(id)) { + throw new ServiceException("运单ID不能为空"); + } + Waybill waybill = getById(id); + if (Func.isEmpty(waybill) || Objects.equals(waybill.getIsDeleted(), 1)) { + throw new ServiceException("运单不存在"); + } + return doComplete(waybill); + } + + /** + * 完成运单核心逻辑:状态改为 completed,并检查生成应收应付明细。 + */ + private boolean doComplete(Waybill waybill) { if ("completed".equals(waybill.getBusinessStatus()) || "cancelled".equals(waybill.getBusinessStatus())) { throw new ServiceException("当前状态不允许完成"); } @@ -682,6 +1111,8 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setCarrierJson(TransportBusinessSupport.trimToNull(waybill.getCarrierJson())); waybill.setTaskInfoJson(TransportBusinessSupport.trimToNull(waybill.getTaskInfoJson())); waybill.setProcessJson(TransportBusinessSupport.trimToNull(waybill.getProcessJson())); + waybill.setDriverAcceptStatus(TransportBusinessSupport.trimToNull(waybill.getDriverAcceptStatus())); + waybill.setDriverRejectReason(TransportBusinessSupport.trimToNull(waybill.getDriverRejectReason())); waybill.setRouteJson(TransportBusinessSupport.trimToNull(waybill.getRouteJson())); waybill.setFreightJson(TransportBusinessSupport.trimToNull(waybill.getFreightJson())); waybill.setAttachmentsJson(TransportBusinessSupport.trimToNull(waybill.getAttachmentsJson())); @@ -694,6 +1125,7 @@ public class WaybillServiceImpl extends BaseServiceImpl waybill.setDeptName(dept.getDeptName()); } if (waybill.getStatus() == null) { waybill.setStatus(1); } + // 默认 pending;最终 pending/running 由 applyDriverAcceptBusinessStatus 按过程配置校正 if (Func.isEmpty(waybill.getBusinessStatus())) { waybill.setBusinessStatus("pending"); } } diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java new file mode 100644 index 0000000..6e8236f --- /dev/null +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/support/WaybillProcessSupport.java @@ -0,0 +1,472 @@ +/** + * BladeX Commercial License Agreement + * Copyright (c) 2018-2099, https://bladex.cn. All rights reserved. + *

+ * Use of this software is governed by the Commercial License Agreement + * obtained after purchasing a license from BladeX. + *

+ * 1. This software is for development use only under a valid license + * from BladeX. + *

+ * 2. Redistribution of this software's source code to any third party + * without a commercial license is strictly prohibited. + *

+ * 3. Licensees may copyright their own code but cannot use segments + * from this software for such purposes. Copyright of this software + * remains with BladeX. + *

+ * Using this software signifies agreement to this License, and the software + * must not be used for illegal purposes. + *

+ * 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 java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.Map; + +/** + * 运单过程配置解析(对齐 web 端 waybill-manage / process-config) + */ +public final class WaybillProcessSupport { + + public static final String STATUS_PENDING = "pending"; + public static final String STATUS_RUNNING = "running"; + public static final String ACCEPT_PENDING = "pending"; + public static final String ACCEPT_ACCEPTED = "accepted"; + public static final String ACCEPT_REJECTED = "rejected"; + private static final String CONFIRM_YES = "yes"; + private static final String CONFIRM_NO_ACCEPT = "no_confirm_accept"; + private static final String NODE_TRANSIT = "transit"; + private static final String NODE_TRANSIT_NAME = "在途"; + private static final DateTimeFormatter HM = DateTimeFormatter.ofPattern("H:mm"); + private static final DateTimeFormatter HM_PADDED = DateTimeFormatter.ofPattern("HH:mm"); + + private WaybillProcessSupport() { + } + + /** + * 在途打卡判定结果(供司机端「今日在途打卡」面板使用)。 + */ + public record TransitCheckinDecision( + boolean punchEnabled, + boolean visible, + boolean dueToday, + boolean doneToday, + int frequencyDays, + String timeStart, + String timeEnd + ) { + public static TransitCheckinDecision hidden() { + return new TransitCheckinDecision(false, false, false, false, 1, "00:00", "23:59"); + } + } + + /** + * 是否需要接单确认。 + *

+ * 存在启用的接单节点,且 confirmMode=yes(是否确认=是)即为需要接单; + * 不依赖 confirmDriver(是否勾选司机)——只要尚未接单或已拒绝,业务状态均为待执行。 + *

+ * 无过程配置 / 接单节点为「无需确认接单」→ false。 + */ + public static boolean requiresDriverAcceptConfirmation(String processJson) { + List> nodes = parseProcessNodes(processJson); + if (nodes.isEmpty()) { + return false; + } + for (Map node : nodes) { + if (!isAcceptNode(node) || !isEnabled(node)) { + continue; + } + String confirmMode = stringVal(node.get("confirmMode")); + if (CONFIRM_NO_ACCEPT.equals(confirmMode)) { + return false; + } + if (CONFIRM_YES.equals(confirmMode) || Func.isEmpty(confirmMode)) { + return true; + } + } + return false; + } + + /** + * 根据过程配置决定司机侧初始业务状态: + * 需要确认接单 → pending(待执行/待接单);否则 → running(进行中)。 + */ + public static String resolveDriverFacingStatus(String processJson) { + return requiresDriverAcceptConfirmation(processJson) ? STATUS_PENDING : STATUS_RUNNING; + } + + public static boolean isAccepted(String driverAcceptStatus) { + return ACCEPT_ACCEPTED.equalsIgnoreCase(stringVal(driverAcceptStatus)); + } + + public static boolean isRejected(String driverAcceptStatus) { + return ACCEPT_REJECTED.equalsIgnoreCase(stringVal(driverAcceptStatus)); + } + + public static boolean isTerminalBusinessStatus(String businessStatus) { + return "draft".equals(businessStatus) + || "completed".equals(businessStatus) + || "cancelled".equals(businessStatus) + || "waiting_dispatch".equals(businessStatus) + || "dispatching".equals(businessStatus); + } + + /** + * 校正业务状态。 + *

+ * 需要接单(接单节点 confirmMode=yes)且尚未接单(未响应 / 已拒绝)→ pending(待执行); + * 已接单 → running(进行中);不需要接单 → running(仅当当前为空或 pending 时提升)。 + * draft / completed / cancelled 等终态或调度中间态不改动。 + */ + public static String normalizeBusinessStatus(String businessStatus, String processJson) { + return normalizeBusinessStatus(businessStatus, processJson, null); + } + + public static String normalizeBusinessStatus(String businessStatus, String processJson, String driverAcceptStatus) { + if (isTerminalBusinessStatus(businessStatus)) { + return businessStatus; + } + if (requiresDriverAcceptConfirmation(processJson)) { + return isAccepted(driverAcceptStatus) ? STATUS_RUNNING : STATUS_PENDING; + } + if (Func.isEmpty(businessStatus) || STATUS_PENDING.equals(businessStatus)) { + return STATUS_RUNNING; + } + return businessStatus; + } + + /** + * 过程配置是否启用在途打卡:在途节点 enabled 且 punch=是。 + */ + public static boolean isTransitPunchEnabled(String processJson) { + Map transit = findTransitNode(processJson); + return transit != null && isEnabled(transit) && isTruthy(transit.get("punch")); + } + + /** + * 计算「今日在途打卡」是否展示 / 是否到期。 + *

+ * 规则: + *

    + *
  • 在途节点未启用或 punch≠是 → 不展示
  • + *
  • 运单非进行中(running)→ 不展示
  • + *
  • 频次:每 N 天打卡 1 次;无历史 → 到期;上次打卡日 + N ≤ 今日 → 到期
  • + *
  • 时段:到期时须落在 timeStart~timeEnd(支持跨午夜);今日已打则仍展示(已打卡态)
  • + *
+ */ + public static TransitCheckinDecision evaluateTransitCheckin( + String processJson, + String businessStatus, + Date lastPunchAt, + LocalDateTime now + ) { + Map transit = findTransitNode(processJson); + if (transit == null || !isEnabled(transit) || !isTruthy(transit.get("punch"))) { + return TransitCheckinDecision.hidden(); + } + 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"); + if (!STATUS_RUNNING.equals(businessStatus)) { + return new TransitCheckinDecision(true, false, 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 dueToday; + if (lastDate == null) { + dueToday = true; + } else { + LocalDate nextDue = lastDate.plusDays(frequencyDays); + dueToday = !today.isBefore(nextDue); + } + boolean inWindow = isWithinTimeWindow(current.toLocalTime(), timeStart, timeEnd); + boolean visible = doneToday || (dueToday && inWindow); + return new TransitCheckinDecision(true, visible, dueToday && inWindow && !doneToday, doneToday, frequencyDays, timeStart, timeEnd); + } + + public static Map findTransitNode(String processJson) { + List> nodes = parseProcessNodes(processJson); + for (Map node : nodes) { + if (isTransitNode(node)) { + return node; + } + } + return null; + } + + /** + * 司机端应展示的打卡节点:enabled 且 punch=是,排除接单/回单。 + */ + public static List> listDriverPunchNodes(String processJson) { + List> result = new ArrayList<>(); + for (Map node : parseProcessNodes(processJson)) { + if (!isEnabled(node) || !isTruthy(node.get("punch"))) { + continue; + } + if (isAcceptNode(node) || isReturnNode(node)) { + continue; + } + result.add(node); + } + return result; + } + + /** + * 启用中的过程节点(按配置顺序,含接单/回单)。 + */ + public static List> listEnabledProcessNodes(String processJson) { + List> result = new ArrayList<>(); + for (Map node : parseProcessNodes(processJson)) { + if (isEnabled(node)) { + result.add(node); + } + } + return result; + } + + /** + * 当前过程节点在启用节点列表中的下标;找不到返回 0(视为从首个开始)。 + */ + public static int indexOfCurrentNode(List> enabledNodes, String currentProcessNode) { + if (enabledNodes == null || enabledNodes.isEmpty()) { + return 0; + } + String current = stringVal(currentProcessNode); + if (Func.isEmpty(current)) { + // 无当前节点:定位到第一个非接单节点 + for (int i = 0; i < enabledNodes.size(); i++) { + if (!isAcceptNode(enabledNodes.get(i))) { + return i; + } + } + return 0; + } + for (int i = 0; i < enabledNodes.size(); i++) { + Map node = enabledNodes.get(i); + String key = stringVal(node.get("key")); + String name = stringVal(node.get("name")); + if (current.equalsIgnoreCase(key) || current.equals(name) || name.contains(current) || current.contains(name)) { + return i; + } + } + return 0; + } + + public static boolean isTransitNodePublic(Map node) { + return isTransitNode(node); + } + + public static boolean nodeNeedLocation(Map node) { + return isTruthy(node.get("location")); + } + + public static boolean nodeNeedCargo(Map node) { + return isTruthy(node.get("uploadCargo")); + } + + public static boolean nodeNeedVoucher(Map node) { + return isTruthy(node.get("uploadVoucher")); + } + + @SuppressWarnings("unchecked") + public static List nodeStringList(Map node, String field) { + Object raw = node.get(field); + if (raw instanceof List list) { + List out = new ArrayList<>(); + for (Object item : list) { + if (item != null && Func.isNotEmpty(String.valueOf(item).trim())) { + out.add(String.valueOf(item).trim()); + } + } + return out; + } + if (raw instanceof String str && Func.isNotEmpty(str)) { + String[] parts = str.split("[,,]"); + List out = new ArrayList<>(); + for (String part : parts) { + if (Func.isNotEmpty(part.trim())) { + out.add(part.trim()); + } + } + return out; + } + return Collections.emptyList(); + } + + public static String nodeKey(Map node) { + return stringVal(node.get("key")); + } + + public static String nodeName(Map node) { + String name = stringVal(node.get("name")); + return Func.isEmpty(name) ? nodeKey(node) : name; + } + + @SuppressWarnings("unchecked") + public static List> parseProcessNodes(String processJson) { + if (Func.isEmpty(processJson)) { + return Collections.emptyList(); + } + try { + Object parsed = JsonUtil.parse(processJson, Object.class); + if (parsed instanceof List list) { + return castNodeList(list); + } + if (parsed instanceof Map map) { + Object nodes = map.get("nodes"); + if (nodes instanceof List list) { + return castNodeList(list); + } + Object nodeConfigJson = map.get("nodeConfigJson"); + if (nodeConfigJson instanceof String str && Func.isNotEmpty(str)) { + return parseProcessNodes(str); + } + if (nodeConfigJson instanceof List list) { + return castNodeList(list); + } + } + } catch (Exception ignored) { + return Collections.emptyList(); + } + return Collections.emptyList(); + } + + @SuppressWarnings("unchecked") + private static List> castNodeList(List list) { + return list.stream() + .filter(Map.class::isInstance) + .map(item -> (Map) item) + .toList(); + } + + private static boolean isReturnNode(Map node) { + String key = stringVal(node.get("key")); + String name = stringVal(node.get("name")); + return "return".equals(key) || "回单".equals(name); + } + + private static boolean isAcceptNode(Map node) { + String key = stringVal(node.get("key")); + String name = stringVal(node.get("name")); + return "accept".equals(key) || "接单".equals(name); + } + + private static boolean isTransitNode(Map node) { + String key = stringVal(node.get("key")); + String name = stringVal(node.get("name")); + String type = stringVal(node.get("type")); + return NODE_TRANSIT.equals(key) || NODE_TRANSIT_NAME.equals(name) || NODE_TRANSIT.equals(type); + } + + private static boolean isEnabled(Map node) { + Object enabled = node.get("enabled"); + if (enabled == null) { + return true; + } + if (enabled instanceof Boolean bool) { + return bool; + } + String text = String.valueOf(enabled).trim(); + return !("false".equalsIgnoreCase(text) || "0".equals(text)); + } + + private static boolean isTruthy(Object value) { + if (value == null) { + return false; + } + if (value instanceof Boolean bool) { + return bool; + } + String text = String.valueOf(value).trim(); + return "true".equalsIgnoreCase(text) || "1".equals(text) || "yes".equalsIgnoreCase(text); + } + + private static String stringVal(Object value) { + return value == null ? "" : String.valueOf(value).trim(); + } + + private static int parsePositiveInt(Object value, int defaultVal) { + if (value == null) { + return defaultVal; + } + try { + int n = Integer.parseInt(String.valueOf(value).trim()); + return n < 1 ? defaultVal : n; + } catch (NumberFormatException ex) { + return defaultVal; + } + } + + private static String normalizeHm(String value, String fallback) { + LocalTime t = parseHm(value); + if (t == null) { + return fallback; + } + return t.format(HM_PADDED); + } + + private static LocalTime parseHm(String value) { + if (Func.isEmpty(value)) { + return null; + } + String text = value.trim(); + try { + return LocalTime.parse(text, HM_PADDED); + } catch (DateTimeParseException ignored) { + // fallthrough + } + try { + return LocalTime.parse(text, HM); + } catch (DateTimeParseException ignored) { + return null; + } + } + + /** + * 是否在打卡时段内(按 HH:mm 分钟含端点);timeStart > timeEnd 视为跨午夜。 + */ + public static boolean isWithinTimeWindow(LocalTime now, String timeStart, String timeEnd) { + LocalTime start = parseHm(timeStart); + LocalTime end = parseHm(timeEnd); + if (start == null || end == null || now == null) { + return true; + } + int nowM = now.getHour() * 60 + now.getMinute(); + int startM = start.getHour() * 60 + start.getMinute(); + int endM = end.getHour() * 60 + end.getMinute(); + if (startM == endM) { + return true; + } + if (startM < endM) { + return nowM >= startM && nowM <= endM; + } + // 跨午夜:如 22:00-06:00 + return nowM >= startM || nowM <= endM; + } + + private static LocalDate toLocalDate(Date date) { + if (date == null) { + return null; + } + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + +} diff --git a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java index 8b9d066..64ea4aa 100644 --- a/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java +++ b/blade-service/blade-transport/src/main/java/org/springblade/transport/wrapper/WaybillWrapper.java @@ -30,6 +30,7 @@ import org.springblade.system.cache.UserCache; import org.springblade.transport.pojo.entity.Waybill; import org.springblade.transport.pojo.vo.WaybillVO; import org.springblade.transport.support.TransportBusinessSupport; +import org.springblade.transport.support.WaybillProcessSupport; import java.util.Objects; @@ -52,7 +53,11 @@ public class WaybillWrapper extends BaseEntityWrapper { waybillVO.setDataSource(TransportBusinessSupport.normalizeWaybillDataSource(waybill.getDataSource())); Long currentDeptId = Func.firstLong(AuthUtil.getDeptId()); waybillVO.setReadonly(currentDeptId != null && !Objects.equals(waybill.getDeptId(), currentDeptId)); - waybillVO.setBusinessStatusName(businessStatusName(waybill.getBusinessStatus())); + String displayStatus = WaybillProcessSupport.normalizeBusinessStatus( + waybill.getBusinessStatus(), waybill.getProcessJson(), waybill.getDriverAcceptStatus()); + waybillVO.setBusinessStatus(displayStatus); + waybillVO.setBusinessStatusName(businessStatusName(displayStatus)); + waybillVO.setRequireAccept(WaybillProcessSupport.requiresDriverAcceptConfirmation(waybill.getProcessJson())); return waybillVO; } diff --git a/doc/sql/transport/blade_tms_business.sql b/doc/sql/transport/blade_tms_business.sql index 16d2341..62a0472 100644 --- a/doc/sql/transport/blade_tms_business.sql +++ b/doc/sql/transport/blade_tms_business.sql @@ -254,6 +254,11 @@ CREATE TABLE `blade_waybill` ( `driver_id` bigint(20) DEFAULT NULL COMMENT '司机ID', `driver_name` varchar(100) DEFAULT NULL COMMENT '司机姓名', `driver_phone` varchar(50) DEFAULT NULL COMMENT '司机手机号', + `driver_accept_status` varchar(32) DEFAULT NULL COMMENT '司机接单状态:pending待接单/accepted已接单/rejected已拒绝', + `driver_accept_time` datetime DEFAULT NULL COMMENT '司机接单时间', + `driver_accept_driver_id` bigint(20) DEFAULT NULL COMMENT '接单司机ID', + `driver_reject_time` datetime DEFAULT NULL COMMENT '司机拒绝接单时间', + `driver_reject_reason` varchar(200) DEFAULT NULL COMMENT '司机拒绝接单原因', `vehicle_no` varchar(100) DEFAULT NULL COMMENT '车/船/航班/班列号', `captain_name` varchar(20) DEFAULT NULL COMMENT '船长', `cabin_no` varchar(30) DEFAULT NULL COMMENT '舱位', diff --git a/doc/sql/transport/blade_waybill_driver_accept_20260911.sql b/doc/sql/transport/blade_waybill_driver_accept_20260911.sql new file mode 100644 index 0000000..bab3405 --- /dev/null +++ b/doc/sql/transport/blade_waybill_driver_accept_20260911.sql @@ -0,0 +1,6 @@ +ALTER TABLE `blade_waybill` + ADD COLUMN `driver_accept_status` varchar(32) DEFAULT NULL COMMENT '司机接单状态:pending待接单/accepted已接单/rejected已拒绝' AFTER `driver_phone`, + ADD COLUMN `driver_accept_time` datetime DEFAULT NULL COMMENT '司机接单时间' AFTER `driver_accept_status`, + ADD COLUMN `driver_accept_driver_id` bigint(20) DEFAULT NULL COMMENT '接单司机ID' AFTER `driver_accept_time`, + ADD COLUMN `driver_reject_time` datetime DEFAULT NULL COMMENT '司机拒绝接单时间' AFTER `driver_accept_driver_id`, + ADD COLUMN `driver_reject_reason` varchar(200) DEFAULT NULL COMMENT '司机拒绝接单原因' AFTER `driver_reject_time`; diff --git a/doc/sql/transport/blade_waybill_enroute_punch_20260911.sql b/doc/sql/transport/blade_waybill_enroute_punch_20260911.sql new file mode 100644 index 0000000..702ec61 --- /dev/null +++ b/doc/sql/transport/blade_waybill_enroute_punch_20260911.sql @@ -0,0 +1,23 @@ +-- 运单在途打卡记录(过程配置 transit 节点 punch=是) +CREATE TABLE IF NOT EXISTS `blade_waybill_enroute_punch` ( + `id` bigint(20) NOT NULL COMMENT '主键', + `tenant_id` varchar(12) DEFAULT '000000' COMMENT '租户ID', + `waybill_id` bigint(20) NOT NULL COMMENT '运单ID', + `waybill_no` varchar(64) DEFAULT NULL COMMENT '运单号', + `driver_id` bigint(20) DEFAULT NULL COMMENT '打卡司机ID', + `punch_time` datetime NOT NULL COMMENT '打卡时间', + `longitude` decimal(12, 8) DEFAULT NULL COMMENT '经度', + `latitude` decimal(12, 8) DEFAULT NULL COMMENT '纬度', + `address` varchar(500) DEFAULT NULL COMMENT '打卡地址', + `photo` varchar(1000) DEFAULT NULL COMMENT '货物照片URL', + `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int(11) DEFAULT '1' COMMENT '状态', + `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_enroute_punch_waybill` (`waybill_id`, `punch_time`) USING BTREE, + KEY `idx_enroute_punch_driver` (`driver_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运单在途打卡记录'; diff --git a/doc/sql/transport/blade_waybill_node_punch_20260911.sql b/doc/sql/transport/blade_waybill_node_punch_20260911.sql new file mode 100644 index 0000000..f595a23 --- /dev/null +++ b/doc/sql/transport/blade_waybill_node_punch_20260911.sql @@ -0,0 +1,30 @@ +-- 运单过程节点打卡记录(到场/装货/发货/到货/卸货/签收等 punch=是,不含在途) +CREATE TABLE IF NOT EXISTS `blade_waybill_node_punch` ( + `id` bigint(20) NOT NULL COMMENT '主键', + `tenant_id` varchar(12) DEFAULT '000000' COMMENT '租户ID', + `waybill_id` bigint(20) NOT NULL COMMENT '运单ID', + `waybill_no` varchar(64) DEFAULT NULL COMMENT '运单号', + `driver_id` bigint(20) DEFAULT NULL COMMENT '打卡司机ID', + `node_code` varchar(64) NOT NULL COMMENT '过程节点 key', + `node_name` varchar(64) DEFAULT NULL COMMENT '过程节点名称', + `punch_time` datetime NOT NULL COMMENT '打卡时间', + `longitude` decimal(12, 8) DEFAULT NULL COMMENT '经度', + `latitude` decimal(12, 8) DEFAULT NULL COMMENT '纬度', + `address` varchar(500) DEFAULT NULL COMMENT '打卡地址', + `photos` varchar(2000) DEFAULT NULL COMMENT '凭证照片URL,多张逗号分隔', + `weight` varchar(32) DEFAULT NULL COMMENT '重量(吨)', + `volume` varchar(32) DEFAULT NULL COMMENT '体积(方)', + `quantity` varchar(32) DEFAULT NULL COMMENT '数量(件)', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `exception_flag` int(11) DEFAULT '0' COMMENT '是否异常:0否 1是', + `create_user` bigint(20) DEFAULT NULL COMMENT '创建人', + `create_dept` bigint(20) DEFAULT NULL COMMENT '创建部门', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_user` bigint(20) DEFAULT NULL COMMENT '修改人', + `update_time` datetime DEFAULT NULL COMMENT '修改时间', + `status` int(11) DEFAULT '1' COMMENT '状态', + `is_deleted` int(11) DEFAULT '0' COMMENT '是否已删除', + PRIMARY KEY (`id`) USING BTREE, + KEY `idx_node_punch_waybill` (`waybill_id`, `node_code`, `punch_time`) USING BTREE, + KEY `idx_node_punch_driver` (`driver_id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运单过程节点打卡记录';