This commit is contained in:
2026-10-21 17:26:43 +08:00
parent 0976b6ef0d
commit c442c9eaa7
32 changed files with 587 additions and 43 deletions
@@ -25,7 +25,10 @@ public final class DispatchContracts {
/** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */
public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn,
List<Long> fileIds, Instant expectedTaskEndAt) { }
List<Long> fileIds, Instant expectedTaskEndAt, List<Long> photoFileIds) {
/** 司机照片独立于驾驶证,缺省为空且引用须有界、有效、不重复。 */
public DriverEvidence { photoFileIds = ReviewAttachments.checked(photoFileIds); }
}
public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate,
String carrierName, String vehicleStatus, Capacity capacity,
@@ -51,6 +51,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads;
@jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership;
@jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks;
/** 领域编排只访问Repository和提供者应用入口。 */
public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources,
@@ -304,7 +305,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L);
row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L);
row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0)));
Resources resources=resources(shell); row.setResourcesJson(write(resources));
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
Resources resources=resources(shell,photos); row.setResourcesJson(write(resources));
row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));
row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0);
@@ -382,8 +385,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
requireEditing(row,request.recordVersion());
String before=basis(row);
// 本趟司机照片随资源维护保存,逐个核对本车及照片类别。
var photos=request.driver()==null || request.driver().evidence()==null?List.<Long>of():request.driver().evidence().photoFileIds();
if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos);
var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver()));
row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate());
row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark());
if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改");
fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark());
@@ -398,7 +404,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
requireEditing(row,request.recordVersion());
if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对");
verifyReviewResources(row);
if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核");
if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核");
long round=row.getReviewRound()+1;String basis=basis(row);
row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name());
row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null)));
@@ -415,7 +421,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(actor!=null && actor.mode()==ActorMode.PDA) {
if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action))
throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核");
required(actor,number,false);fleet.lockDispatchShell(number);row=required(actor,number,true);
lockReviewRoot(actor,number);row=required(actor,number,true);
} else row=lockPlatform(actor,number);
var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request);
var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get();
@@ -610,7 +616,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
}
/** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);fleet.lockDispatchShell(number);return required(actor,number,true); }
private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); }
/** 运输取货锁原车次,仓库派送锁其整车资源;统一先父后主单。 */
private void lockReviewRoot(Actor actor,String number) {
var row=required(actor,number,false);
if("PICKUP".equals(row.getTaskType()))transportTasks.transport(number,true);else fleet.lockDispatchShell(number);
}
/** 严格请求身份不接收前端摘要或操作者。 */
private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));}
/** 每次状态变更、历史及不可变命令结果同事务保存。 */
@@ -627,7 +638,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */
private String basis(DispatchAggregate row) {
var facts=new TreeMap<String,Object>();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class));
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));return hashes.source(facts);
facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());
if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson());
else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));}
return hashes.source(facts);
}
/** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */
private void invalidate(DispatchAggregate row,String reason) {
@@ -652,6 +666,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
/** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */
void verifyReviewResources(DispatchAggregate row) {
if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;}
var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver();
if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话");
var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积");
@@ -659,9 +674,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期");
if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds());
if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds());
if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds());
var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()),
new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt()));
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核");
}
/** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */
private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List<Long> ids) {
@@ -848,8 +864,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
// 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。
if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) {
var blocked=new EnumMap<Action,Gate>(Action.class);
for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART)
blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作"));
boolean pickup="PICKUP".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus());
for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) {
boolean state=pickup && switch(action) {
case EDIT,CANCEL -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage());
case SUBMIT_REVIEW -> "BEFORE_LOADING".equals(row.getStage()) && canSubmitReview(row.getReviewState());
case DISPATCH_REVIEW -> "DISPATCH_PENDING".equals(row.getReviewState());
case LOADING_REVIEW -> "LOADING_PENDING".equals(row.getReviewState());
default -> false;
};
boolean allowed=state && hasPermission(actor,action);
blocked.put(action,new Gate(allowed,allowed?null:"TRANSPORT_TASK",allowed?null:"请按取货运输审核流程操作"));
}
return Collections.unmodifiableMap(blocked);
}
var gates=new EnumMap<Action,Gate>(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus());
@@ -940,12 +966,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
return List.copyOf(result);
}
/** 把Fleet权威主档或真实临时输入转换成整车审阅快照。 */
private static Resources resources(FleetExecutionModels.DispatchShell shell) {
/** 合并Fleet权威资源与本趟司机照片,照片不写入司机主档。 */
private static Resources resources(FleetExecutionModels.DispatchShell shell,List<Long> photos) {
var v=shell.vehicle(); var d=shell.driver();
return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(),
new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(),
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt())));
new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos)));
}
/** 主档选择不接受请求覆盖其车牌和容量。 */
@@ -19,7 +19,7 @@ import java.util.TreeMap;
@Component
public final class DispatchCommandHasher {
private static final Set<String> UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services",
"batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds");
"batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds", "photoFileIds");
private final ObjectMapper json;
/** 复用已注册的时间、数值和公开字段序列化配置。 */
@@ -60,7 +60,12 @@ public final class DispatchCommandHasher {
if (node == null || node.isNull()) return "null";
if (node.isObject()) {
var values = new TreeMap<String, String>();
node.fields().forEachRemaining(entry -> values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey())));
node.fields().forEachRemaining(entry -> {
// 新增可选照片为空时沿用旧摘要,保留历史命令重放与已通过审核的依据。
if (entry.getKey().equals("photoFileIds") && (entry.getValue().isNull()
|| entry.getValue().isArray() && entry.getValue().isEmpty())) return;
values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey()));
});
var result = new ArrayList<String>();
values.forEach((key, value) -> result.add(quoted(key) + ":" + value));
return "{" + String.join(",", result) + "}";
@@ -18,12 +18,13 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*;
/** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */
@Service
public class TransportTaskProjection {
public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.PickupDepartureReview {
private final DispatchAggregateRepository roots;
private final TransportTaskRepository links;
private final UsTruckOrderRepository trucks;
private final DispatchNumberGenerator numbers;
private final ObjectMapper json;
@jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickupFacts;
/** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */
public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) {
this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json;
@@ -50,11 +51,22 @@ public class TransportTaskProjection {
var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return;
var found=links.byTrip(trip.getId());if(found==null)return;
var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion();
row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt());
row.setRecordVersion(version+1);row.setUpdatedAt(trip.getUpdatedAt());
row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE");
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";});
row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";case "PLANNED"->row.getStage();default->"BEFORE_LOADING";});
// 旧发车入口只记录出发事实,取货照片尚未齐全时仍在装货中。
if("PICKUP".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus())
&& pickupFacts.tasks(trip.getTripNo()).stream().anyMatch(task->"ASSIGNED".equals(task.getStatus())))row.setStage("LOADING");
row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt());
String oldResources=row.getResourcesJson();var oldDeparture=row.getPlannedDepartureAt();var oldArrival=row.getEstimatedArrivalAt();
row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip);
// 资源或计划改变后作废本轮审核,保留审核驳回原因供修改时核对。
if("PICKUP".equals(row.getTaskType()) && "PLANNED".equals(trip.getStatus())
&& (!Objects.equals(oldResources,row.getResourcesJson()) || !Objects.equals(oldDeparture,row.getPlannedDepartureAt()) || !Objects.equals(oldArrival,row.getEstimatedArrivalAt()))) {
row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING");
row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound()));
row.setLoadingReviewJson(invalidated(row.getLoadingReviewJson(),row.getReviewRound()));
}
if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败");
roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt());
var link=links.truck(row.getDispatchNo());if(link==null)return;
@@ -62,6 +74,30 @@ public class TransportTaskProjection {
transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion());
if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo());
}
/** 旧取货发车入口也必须锁定并核验同轮两级批准,不允许无主单发车。 */
@Override
public void requireApproved(long tripId) {
var found=links.byTrip(tripId);
if(found==null)throw new IllegalStateException("取货排车缺失,请先接入排车审核");
var row=roots.lock(found.getDispatchNo());
if(!"PICKUP".equals(row.getTaskType()) || !"ACTIVE".equals(row.getStatus()) || !"APPROVED".equals(row.getReviewState()) || row.getReviewBasisHash()==null)
throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"请先完成调度审核和理货员审核");
for(String value:List.of(row.getDispatchReviewJson(),row.getLoadingReviewJson())) {
try {
var review=json.readValue(value,Review.class);
if(review.status()!=ReviewStatus.APPROVED || review.reviewRound()!=row.getReviewRound() || !Objects.equals(review.basisHash(),row.getReviewBasisHash()))
throw new IllegalStateException("取货审核依据已失效,请重新提交审核");
} catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);}
}
}
/** 保留驳回事实,同时明确此前批准已因资源变更失效。 */
private String invalidated(String value,long round) {
try {
var old=json.readValue(value,Review.class);
return write(new Review(old.status()==ReviewStatus.REJECTED?ReviewStatus.REJECTED:ReviewStatus.NOT_SUBMITTED,
old.reason(),old.reviewerId(),old.reviewedAt(),round,null,"车辆、司机或计划已变更,请重新提交两级审核",old.attachments()));
} catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);}
}
/** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */
public void transition(String customer,String number,String target,String action,String operator,String reason,String key) {
var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在");
@@ -51,6 +51,22 @@ public class TransportTaskService {
var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator);
return detail(row.getDispatchNo());
}
/** 审核通过后按原单锁幂等生成待发车排车;重复审核保留原安排及取消结果。 */
@Transactional
public void ensureApprovedPickup(String customer,String number,String operator) {
var source=pickupFacts.source(customer,number,true);
if(source==null)throw missing("转运单不存在");
if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !number.equals(source.getInboundCode())
|| !"WAIT_PDA_RECEIVING".equals(source.getStatus()))return;
var task=pickupFacts.lockTask(source.getId());
// 已安排、执行或主动取消的取货任务均不因审核重放重新建单。
if(task!=null)return;
var departure=Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS);
createPickup(operator,new FleetPickupModels.Create(List.of(new FleetPickupModels.Source(customer,number)),
departure,departure.plus(Duration.ofHours(24)),
"审核自动生成到仓取货排车;时间为暂定计划,调度确认后分配车辆司机",
"transfer-approval:"+source.getId()));
}
/** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */
@Transactional
public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) {
@@ -97,6 +113,15 @@ public class TransportTaskService {
var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request);
if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number);
}
/** 取货提交与两级通过时复验真实占用、有效证照和车辆容量。 */
public void verifyPickupReview(String number) {
var trip=transport(number,true);
if(!"PICKUP".equals(trip.getTripPurpose()) || !"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).isEmpty())throw conflict("仅有真实取货任务的未发车排车可以审核");
if(trip.getPlannedDepartureAt()==null || trip.getExpectedDriverEndAt()==null || !trip.getExpectedDriverEndAt().isAfter(trip.getPlannedDepartureAt()) || !trip.getExpectedDriverEndAt().isAfter(now()))throw conflict("请维护有效的计划发车与任务结束时间");
var pallets=trip.getMaxPalletSpaces();var weight=trip.getMaxLoadKg();var volume=trip.getMaxVolumeCbm();
requireResources(trip);
if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息");
}
/** 取消运输并保留原单、排车及资源审计。 */
@Transactional
public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) {
@@ -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,
@@ -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) { }
}
@@ -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); }
/** 核对旧拼车承运关系,防止同票走两条运输链。 */
@@ -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);
}
@@ -0,0 +1,7 @@
package com.baiyue.lastleg.fleet.pickup;
/** 取货发车必须通过排车主单的本轮两级审核。 */
public interface PickupDepartureReview {
/** 调用方已持车次锁,校验并锁定对应排车审核事实。 */
void requireApproved(long tripId);
}
@@ -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;
}
/** 复核创建请求摘要,同键不同内容不允许复用。 */
@@ -0,0 +1,30 @@
package com.baiyue.lastleg.transfer.mapper;
import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder;
import org.apache.ibatis.annotations.*;
/** 仅取消本客户外来取货的待执行原单,不释放或回滚任何已入库库存。 */
@Mapper
public interface TransferPickupCancellationMapper {
/** 与PDA共用转运原单锁,串行核验收货事实。 */
@Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE")
WarehouseTransferOrder lock(@Param("customer") String customer,@Param("number") String number);
/** 任何收货、箱清点或托盘测量开始后均禁止普通取消。 */
@Select("SELECT (SELECT COUNT(*) FROM ll_warehouse_transfer_receipt WHERE customer_id=#{customer} AND transfer_order_id=#{id}) + (SELECT COUNT(*) FROM ll_warehouse_transfer_carton WHERE customer_id=#{customer} AND transfer_order_id=#{id} AND counted_at IS NOT NULL) + (SELECT COUNT(*) FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customer} AND transfer_order_id=#{id})")
int started(@Param("customer") String customer,@Param("id") long id);
/** 同一取消事务只接受待PDA收货的转运单。 */
@Update("UPDATE ll_warehouse_transfer_order SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND status='WAIT_PDA_RECEIVING'")
int cancelTransfer(@Param("customer") String customer,@Param("id") long id);
/** 写入主单取消事实并递增版本,防止旧页面继续写入。 */
@Update("UPDATE ll_order SET current_status='CANCELLED',sub_order_status='WAREHOUSE_TRANSFER:CANCELLED',version=version+1,updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND current_status='INBOUND_MEASURING'")
int cancelOrder(@Param("customer") String customer,@Param("id") long id);
/** 只取消尚未接货的外来入仓单。 */
@Update("UPDATE ll_inbound_order SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND status='PENDING_INBOUND'")
int cancelInbound(@Param("customer") String customer,@Param("id") long id);
/** 只取消原转运生成且仍待PDA的唯一计划。 */
@Update("UPDATE ll_inbound_plan SET status='CANCELLED',version=version+1,updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND source_order_id=#{order} AND creation_mode='WAREHOUSE_TRANSFER' AND status='PDA_PENDING'")
int cancelPlan(@Param("customer") String customer,@Param("order") long order);
/** 不可变历史保留取消前状态、原车次与操作者。 */
@Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,event_id,remark,created_at) SELECT customer_id,id,current_status,'CANCELLED',#{trip},CONCAT('取消取货排车及转运单;操作人:',#{operator},';原因:',#{reason}),CURRENT_TIMESTAMP FROM ll_order WHERE customer_id=#{customer} AND id=#{id}")
int history(@Param("customer") String customer,@Param("id") long id,@Param("trip") String trip,@Param("operator") String operator,@Param("reason") String reason);
}
@@ -0,0 +1,24 @@
package com.baiyue.lastleg.transfer.repository;
import com.baiyue.lastleg.transfer.mapper.TransferPickupCancellationMapper;
import org.springframework.stereotype.Repository;
/** 未执行取货取消的原单、入仓与历史持久化边界。 */
@Repository
public class TransferPickupCancellationRepository {
private final TransferPickupCancellationMapper mapper;
/** 注入本业务取消映射器。 */
public TransferPickupCancellationRepository(TransferPickupCancellationMapper mapper){this.mapper=mapper;}
/** 锁定真实客户所属转运来源。 */
public com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder lock(String customer,String number){return mapper.lock(customer,number);}
/** 已存在清点、托盘或收货事实时禁止普通取消。 */
public boolean started(String customer,long id){return mapper.started(customer,id)>0;}
/** 取消转运并检查当前状态。 */
public int cancelTransfer(String customer,long id){return mapper.cancelTransfer(customer,id);}
/** 取消尾程主单并记录当前状态来源。 */
public int cancelOrder(String customer,long id,String trip,String operator,String reason){mapper.history(customer,id,trip,operator,reason);return mapper.cancelOrder(customer,id);}
/** 取消原入仓单,保留不可删除的业务关联。 */
public int cancelInbound(String customer,long id){return mapper.cancelInbound(customer,id);}
/** 取消待收货计划,已执行的计划导致整车取消失败。 */
public int cancelPlan(String customer,long order){return mapper.cancelPlan(customer,order);}
}
@@ -0,0 +1,32 @@
package com.baiyue.lastleg.transfer.service;
import com.baiyue.lastleg.fleet.pickup.*;
import com.baiyue.lastleg.transfer.repository.TransferPickupCancellationRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/** 取货排车取消时原子取消其全部转运、尾程、入仓单及待执行入仓计划。 */
@Service
public class TransferPickupCancellationService implements PickupOrderCancellation {
private final TransferPickupCancellationRepository repository;
/** 取消只访问本域持久化边界,不反向调用排车服务。 */
public TransferPickupCancellationService(TransferPickupCancellationRepository repository){this.repository=repository;}
/** 任一来源已经执行或归属不符时抛错,使外层整车取消全部回滚。 */
@Override @Transactional(propagation=Propagation.MANDATORY)
public void cancel(List<FleetPickupModels.Source> sources,String tripNo,String operator,String reason) {
if(sources==null || sources.isEmpty())throw conflict();
for(var source:sources) {
var row=repository.lock(source.customerId(),source.transferNo());
if(row==null || !"WAREHOUSE_PICKUP".equals(row.getDeliveryMethod()) || !row.getTransferNo().equals(row.getInboundCode()) || !"WAIT_PDA_RECEIVING".equals(row.getStatus()) || repository.started(source.customerId(),row.getId()))throw conflict();
if(repository.cancelTransfer(source.customerId(),row.getId())!=1
|| repository.cancelOrder(source.customerId(),row.getLastlegOrderId(),tripNo,operator,reason)!=1
|| repository.cancelInbound(source.customerId(),row.getInboundOrderId())!=1
|| repository.cancelPlan(source.customerId(),row.getLastlegOrderId())!=1)throw conflict();
}
}
/** 保留用户上下文,不把部分取消作为成功返回。 */
private static ResponseStatusException conflict(){return new ResponseStatusException(HttpStatus.CONFLICT,"转运单已开始收货或关联状态变化,不能取消排车及转运单,请刷新核对");}
}
@@ -94,6 +94,7 @@ import java.util.stream.Collectors;
public class WarehouseTransferService {
@Resource private TransferQuantityService transferQuantityService;
@Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService;
@Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTaskService;
/** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */
public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:";
@Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers;
@@ -814,6 +815,7 @@ public class WarehouseTransferService {
return new WarehouseTransferResponses.Label(transferNo, cartons, html);
}
/** 审核与入仓计划、上门取货排车原子提交,重放复用原业务关联。 */
@Transactional
public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId,
WarehouseTransferRequests.Review request) {
@@ -836,6 +838,7 @@ public class WarehouseTransferService {
.contains(transfer.getStatus())) {
if (APPROVED.equals(next) && transfer.getTransferNo().equals(transfer.getInboundCode())) {
activateTransferInbound(customerId, transfer, LocalDateTime.now());
transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId);
}
return detail(customerId, transferNo);
}
@@ -865,6 +868,9 @@ public class WarehouseTransferService {
"Warehouse transfer reviewed: " + next
+ (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now);
}
if (APPROVED.equals(next) && incomingTransit) {
transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId);
}
return detail(customerId, transferNo);
}