提交
This commit is contained in:
@@ -39,6 +39,9 @@ public interface FleetPickupMapper extends BaseMapper<FleetPickupTask> {
|
||||
/** 解除计划中的取货关联,保留历史后允许再次安排。 */
|
||||
@Update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
|
||||
int unassign(@Param("tripNo") String tripNo);
|
||||
/** 原单同时取消时保留车次关联供详情和审计追溯,不再回到待安排。 */
|
||||
@Update("UPDATE ll_fleet_pickup_task SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'")
|
||||
int cancelTasks(@Param("tripNo") String tripNo);
|
||||
/** 不可变操作历史覆盖安排、解除、取货、到仓及结案。 */
|
||||
@Insert("INSERT INTO ll_fleet_pickup_history(customer_id,transfer_no,trip_no,action,operator_id,occurred_at,detail) VALUES(#{customer},#{number},#{trip},#{action},#{operator},CURRENT_TIMESTAMP,#{detail})")
|
||||
int history(@Param("customer") String customer,@Param("number") String number,@Param("trip") String trip,
|
||||
|
||||
+8
-2
@@ -24,7 +24,10 @@ public final class FleetPickupModels {
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey) { }
|
||||
public record Command(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@NotBlank @Size(max=1000) String reason) { }
|
||||
@NotBlank @Size(max=1000) String reason,List<@NotNull @Positive Long> evidenceFileIds) {
|
||||
/** 无照片的非送仓命令保持原调用形式。 */
|
||||
public Command(Long recordVersion,String idempotencyKey,String reason){this(recordVersion,idempotencyKey,reason,null);}
|
||||
}
|
||||
public record Pickup(@NotNull @Min(0) Long recordVersion,
|
||||
@NotBlank @Size(min=8,max=128) String idempotencyKey,
|
||||
@Min(1) @Max(5000) int actualPackages,
|
||||
@@ -35,6 +38,9 @@ public final class FleetPickupModels {
|
||||
String status, String tripNo, String vehiclePlate, String driverName, String driverPhone,
|
||||
Instant plannedDepartureAt, Integer actualPackages, Instant pickedAt, Instant arrivedAt,
|
||||
String remark, List<Evidence> evidence) { }
|
||||
public record Evidence(String fileId, String fileName, String contentType, long sizeBytes) { }
|
||||
public record Evidence(String fileId, String fileName, String contentType, long sizeBytes,String phase) {
|
||||
/** 旧取货凭证明确归入取货阶段,不冒充到仓照片。 */
|
||||
public Evidence(String fileId,String fileName,String contentType,long sizeBytes){this(fileId,fileName,contentType,sizeBytes,"PICKUP");}
|
||||
}
|
||||
public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { }
|
||||
}
|
||||
|
||||
+2
@@ -34,6 +34,8 @@ public class FleetPickupRepository {
|
||||
public FleetPickupTask lockTask(long id) { return mapper.lockTask(id); }
|
||||
/** 释放未开始的成员关联。 */
|
||||
public int unassign(String number) { return mapper.unassign(number); }
|
||||
/** 联动取消保留原车次与转运单的可追溯关联。 */
|
||||
public int cancelTasks(String number) { return mapper.cancelTasks(number); }
|
||||
/** 保存不可变业务操作记录。 */
|
||||
public void history(FleetPickupTask task,String action,String operator,String detail) { mapper.history(task.getCustomerId(),task.getTransferNo(),task.getTripNo(),action,operator,detail); }
|
||||
/** 核对旧拼车承运关系,防止同票走两条运输链。 */
|
||||
|
||||
+26
-4
@@ -28,11 +28,13 @@ public class FleetPickupService {
|
||||
private final VehicleRepository vehicles;
|
||||
private final TripOperationsEvidenceGateway files;
|
||||
private final ObjectMapper json;
|
||||
private final PickupDepartureReview departureReview;
|
||||
/** 共用现有车次、司机资格、车辆占用及鉴权文件能力。 */
|
||||
public FleetPickupService(FleetPickupRepository repository,FleetTripService trips,FleetWarehouseDirectory warehouses,
|
||||
DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) {
|
||||
DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json,PickupDepartureReview departureReview) {
|
||||
this.repository=repository;this.trips=trips;this.warehouses=warehouses;this.drivers=drivers;
|
||||
this.vehicles=vehicles;this.files=files;this.json=json;
|
||||
this.departureReview=departureReview;
|
||||
}
|
||||
/** 审核通过的原单自动出现在候选中,客户自送和库存转运不参与。 */
|
||||
@Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ)
|
||||
@@ -120,6 +122,7 @@ public class FleetPickupService {
|
||||
if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number);
|
||||
version(trip,request.recordVersion());
|
||||
if(!"PLANNED".equals(trip.getStatus()) || repository.tasks(number).isEmpty())throw conflict("仅有取货任务的计划车次可以发车");
|
||||
departureReview.requireApproved(trip.getId());
|
||||
requireResources(trip);
|
||||
for(var task:repository.tasks(number))eligible(source(task.getCustomerId(),task.getTransferNo(),true));
|
||||
trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
|
||||
@@ -142,28 +145,47 @@ public class FleetPickupService {
|
||||
var trip=pickupTrip(number,true);String hash=commandHash("PICKED:"+customer+":"+transferNo,request);
|
||||
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
|
||||
version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
|
||||
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("请先发车,且该单尚未确认取货");
|
||||
if(!Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("该单尚未处于可确认取货阶段");
|
||||
if("PLANNED".equals(trip.getStatus()))departureReview.requireApproved(trip.getId());
|
||||
if(request.actualPackages()<=0 || request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty() || request.evidenceFileIds().size()>10)throw bad("请填写实提箱数并上传取货照片");
|
||||
eligible(source(customer,transferNo,true));
|
||||
var evidence=request.evidenceFileIds().stream().distinct().map(id->{
|
||||
if(!repository.ownsUpload(task.getTransferOrderId(),number,id))throw bad("取货凭证不属于当前订单或车次");
|
||||
var file=files.metadata(trip.getId(),id);
|
||||
if(file.contentType()==null || !file.contentType().startsWith("image/"))throw bad("请上传有效取货照片");
|
||||
return new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes());
|
||||
}).toList();
|
||||
task.setStatus("PICKED");task.setActualPackages(request.actualPackages());task.setPickedAt(now());
|
||||
task.setEvidenceJson(write(evidence));task.setRemark(blank(request.remark()));task.setUpdatedAt(now());repository.update(task);
|
||||
repository.history(task,"PICKED",operator,"实提"+request.actualPackages()+"箱;"+Objects.toString(task.getRemark(),""));
|
||||
// 全车照片和实提数量核齐后自动进入派送中,不能以发车按钮代替取货照片。
|
||||
if("PLANNED".equals(trip.getStatus()) && repository.tasks(number).stream().allMatch(row->"PICKED".equals(row.getStatus()))) {
|
||||
requireResources(trip);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now());
|
||||
}
|
||||
trips.recordExecution(trip,request.recordVersion(),"PICKUP_PICKED","确认原单取货",operator);
|
||||
repository.command(number,request.idempotencyKey(),hash);return tasks(number);
|
||||
}
|
||||
/** 到仓确认只开放 PDA 清点,不覆盖预报箱数和 SKU 件数。 */
|
||||
/** 到仓照片独立归档,全部送仓后自动完成本次取货派送并开放PDA清点。 */
|
||||
@Transactional
|
||||
public List<FleetPickupModels.Task> arrive(String number,String customer,String transferNo,String operator,FleetPickupModels.Command request) {
|
||||
var trip=pickupTrip(number,true);String hash=commandHash("ARRIVED:"+customer+":"+transferNo,request);
|
||||
if(replayed(trip,request.idempotencyKey(),hash))return tasks(number);
|
||||
version(trip,request.recordVersion());var task=member(trip,customer,transferNo);
|
||||
if(!"IN_TRANSIT".equals(trip.getStatus()) || !"PICKED".equals(task.getStatus()))throw conflict("请先确认该单已取货");
|
||||
source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setUpdatedAt(now());repository.update(task);
|
||||
if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty() || request.evidenceFileIds().size()>10)throw bad("请上传到仓照片");
|
||||
var old=evidence(task);var arrival=new ArrayList<FleetPickupModels.Evidence>(old);
|
||||
for(long id:request.evidenceFileIds().stream().distinct().toList()) {
|
||||
if(!repository.ownsUpload(task.getTransferOrderId(),number,id) || old.stream().anyMatch(file->file.fileId().equals(String.valueOf(id))))throw bad("请上传本票新的到仓照片,不能复用取货照片");
|
||||
var file=files.metadata(trip.getId(),id);
|
||||
if(file.contentType()==null || !file.contentType().startsWith("image/"))throw bad("请上传有效到仓照片");
|
||||
arrival.add(new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes(),"ARRIVAL"));
|
||||
}
|
||||
source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setEvidenceJson(write(arrival));task.setUpdatedAt(now());repository.update(task);
|
||||
repository.history(task,"ARRIVED",operator,request.reason());
|
||||
if(repository.tasks(number).stream().allMatch(row->"ARRIVED".equals(row.getStatus()))) {
|
||||
trip.setStatus("COMPLETED");trip.setCompletedAt(now());
|
||||
trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,trip.getCompletedAt().toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of())));
|
||||
}
|
||||
trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator);
|
||||
repository.command(number,request.idempotencyKey(),hash);return tasks(number);
|
||||
}
|
||||
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.baiyue.lastleg.fleet.pickup;
|
||||
|
||||
/** 取货发车必须通过排车主单的本轮两级审核。 */
|
||||
public interface PickupDepartureReview {
|
||||
/** 调用方已持车次锁,校验并锁定对应排车审核事实。 */
|
||||
void requireApproved(long tripId);
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.fleet.pickup;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** 原单所属域负责取消未执行取货的转运业务,失败时整车取消回滚。 */
|
||||
public interface PickupOrderCancellation {
|
||||
/** 调用方持有车次锁,按稳定来源顺序取消全部成员。 */
|
||||
void cancel(List<FleetPickupModels.Source> sources,String tripNo,String operator,String reason);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ public class FleetTripService {
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations;
|
||||
private final FleetTripRepository repository;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups;
|
||||
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation pickupCancellation;
|
||||
private final FleetDispatchGateway dispatches;
|
||||
private final VehicleRepository vehicles;
|
||||
private final DriverService drivers;
|
||||
@@ -467,18 +468,20 @@ public class FleetTripService {
|
||||
|
||||
|
||||
|
||||
/** 取消未执行车次,释放资源并将取货原单退回待安排。 */
|
||||
/** 取消未执行车次,取货同时取消全部转运原单并释放资源。 */
|
||||
@Transactional
|
||||
public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) {
|
||||
FleetTripEntity trip = planned(tripNo, request.recordVersion());
|
||||
requireReason(request.reason());
|
||||
if ("PICKUP".equals(trip.getTripPurpose())) {
|
||||
// 取消车次与释放原单关联同事务,失败时全部回滚。
|
||||
for (var task : pickups.tasks(tripNo)) {
|
||||
var tasks=pickups.tasks(tripNo);
|
||||
for (var task : tasks) {
|
||||
if (!"ASSIGNED".equals(task.getStatus())) throw conflict("已开始取货,不能取消车次");
|
||||
pickups.history(task,"UNASSIGN",operator,request.reason());
|
||||
}
|
||||
pickups.unassign(tripNo);
|
||||
pickupCancellation.cancel(tasks.stream().map(task->new com.baiyue.lastleg.fleet.pickup.FleetPickupModels.Source(task.getCustomerId(),task.getTransferNo())).toList(),tripNo,operator,request.reason());
|
||||
for(var task:tasks)pickups.history(task,"CANCEL",operator,"取消排车及转运单:"+request.reason());
|
||||
if(pickups.cancelTasks(tripNo)!=tasks.size())throw conflict("取货关联已变化,取消未完成");
|
||||
}
|
||||
if (!repository.listBindings(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次");
|
||||
if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次");
|
||||
@@ -554,6 +557,7 @@ public class FleetTripService {
|
||||
if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在");
|
||||
if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试");
|
||||
if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作");
|
||||
if("PICKUP".equals(trip.getTripPurpose()) && pickups.tasks(number).stream().anyMatch(task->!"ASSIGNED".equals(task.getStatus())))throw conflict("已开始取货,不能修改资源、计划或取消排车");
|
||||
return trip;
|
||||
}
|
||||
/** 复核创建请求摘要,同键不同内容不允许复用。 */
|
||||
|
||||
Reference in New Issue
Block a user