From c442c9eaa751183740b6affb56362657e849f02d Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 21 Oct 2026 17:26:43 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispatch/port/DispatchContracts.java | 5 +- .../service/DispatchAggregateService.java | 50 +++++++--- .../service/DispatchCommandHasher.java | 9 +- .../transport/TransportTaskProjection.java | 42 ++++++++- .../transport/TransportTaskService.java | 25 +++++ .../fleet/pickup/FleetPickupMapper.java | 3 + .../fleet/pickup/FleetPickupModels.java | 10 +- .../fleet/pickup/FleetPickupRepository.java | 2 + .../fleet/pickup/FleetPickupService.java | 30 +++++- .../fleet/pickup/PickupDepartureReview.java | 7 ++ .../fleet/pickup/PickupOrderCancellation.java | 9 ++ .../lastleg/fleet/trip/FleetTripService.java | 12 ++- .../TransferPickupCancellationMapper.java | 30 ++++++ .../TransferPickupCancellationRepository.java | 24 +++++ .../TransferPickupCancellationService.java | 32 +++++++ .../service/WarehouseTransferService.java | 6 ++ .../openapi/dispatch-transport-tasks-v1.yaml | 4 + .../openapi/fleet-transfer-pickup-v1.yaml | 13 ++- .../openapi/overseas-business-v1.yaml | 8 ++ .../DispatchAggregatePersistenceTest.java | 4 +- .../service/DispatchRepairWorkflowTest.java | 2 +- ...DispatchResourcePhotosPersistenceTest.java | 93 +++++++++++++++++++ ...DispatchReviewWorkflowPersistenceTest.java | 26 ++++++ .../TransportTaskProjectionTest.java | 29 ++++++ .../transport/TransportTaskServiceTest.java | 24 +++++ .../fleet/pickup/FleetPickupMapperTest.java | 8 ++ .../fleet/pickup/FleetPickupServiceTest.java | 48 +++++++++- .../trip/FleetTripAuthorizationTest.java | 2 + .../pda/PdaOperationsOpenApiContractTest.java | 4 +- ...sferPickupCancellationPersistenceTest.java | 62 +++++++++++++ .../WarehouseTransferCartonCountTest.java | 2 +- .../service/WarehouseTransferServiceTest.java | 5 +- 32 files changed, 587 insertions(+), 43 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupDepartureReview.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupOrderCancellation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferPickupCancellationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferPickupCancellationRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationPersistenceTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java index 1326b540b..d4c8f3e8c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -25,7 +25,10 @@ public final class DispatchContracts { /** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */ public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, - List fileIds, Instant expectedTaskEndAt) { } + List fileIds, Instant expectedTaskEndAt, List photoFileIds) { + /** 司机照片独立于驾驶证,缺省为空且引用须有界、有效、不重复。 */ + public DriverEvidence { photoFileIds = ReviewAttachments.checked(photoFileIds); } + } public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate, String carrierName, String vehicleStatus, Capacity capacity, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 56215676a..397f2b149 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -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.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.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();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 ids) { @@ -848,8 +864,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA // 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。 if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) { var blocked=new EnumMap(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.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 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))); } /** 主档选择不接受请求覆盖其车牌和容量。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java index baa4ab651..246464c21 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java @@ -19,7 +19,7 @@ import java.util.TreeMap; @Component public final class DispatchCommandHasher { private static final Set 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(); - 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(); values.forEach((key, value) -> result.add(quoted(key) + ":" + value)); return "{" + String.join(",", result) + "}"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java index 3566ebc32..4a9b5085d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java @@ -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("关联本土卡车单不存在"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java index 848875bc1..d661e2cb1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java @@ -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) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java index 18d1c5813..010ba8fbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java @@ -39,6 +39,9 @@ public interface FleetPickupMapper extends BaseMapper { /** 解除计划中的取货关联,保留历史后允许再次安排。 */ @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, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java index 5c9544cfb..9792ba1a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java @@ -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) { } - 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) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java index ceb88b69c..356cf44de 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java @@ -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); } /** 核对旧拼车承运关系,防止同票走两条运输链。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java index 50002ec18..c7bca9d45 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java @@ -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 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(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); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupDepartureReview.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupDepartureReview.java new file mode 100644 index 000000000..10bcc33f8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupDepartureReview.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.fleet.pickup; + +/** 取货发车必须通过排车主单的本轮两级审核。 */ +public interface PickupDepartureReview { + /** 调用方已持车次锁,校验并锁定对应排车审核事实。 */ + void requireApproved(long tripId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupOrderCancellation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupOrderCancellation.java new file mode 100644 index 000000000..19125145d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupOrderCancellation.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.fleet.pickup; + +import java.util.List; + +/** 原单所属域负责取消未执行取货的转运业务,失败时整车取消回滚。 */ +public interface PickupOrderCancellation { + /** 调用方持有车次锁,按稳定来源顺序取消全部成员。 */ + void cancel(List sources,String tripNo,String operator,String reason); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 98c5886db..a42438b8f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -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; } /** 复核创建请求摘要,同键不同内容不允许复用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferPickupCancellationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferPickupCancellationMapper.java new file mode 100644 index 000000000..2633b2714 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferPickupCancellationMapper.java @@ -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); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferPickupCancellationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferPickupCancellationRepository.java new file mode 100644 index 000000000..76ed7d5f8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferPickupCancellationRepository.java @@ -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);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationService.java new file mode 100644 index 000000000..a56488e0c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationService.java @@ -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 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,"转运单已开始收货或关联状态变化,不能取消排车及转运单,请刷新核对");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 8d2e9859c..d18b0c51d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -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); } diff --git a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml index 3b4cf3ba8..15cb98986 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml @@ -8,6 +8,10 @@ info: list读取、create安排、edit分配/执行/释放、depart发车、cancel取消权限前缀均为platform:overseas-warehouse:dispatch:。 创建及执行使用稳定idempotencyKey;重试保留完整原载荷。取消仅限未发车;完成后分别释放司机、车辆。 到仓取货逐原单确认实提和送仓,送仓只开放仓库收货门禁;本土卡车同步原单运输状态,完成须本任务凭证。 + 仓储转运上门取货入仓单审核通过时自动生成待发车排车并绑定原转运单;重复审核不重复建单,主动取消排车时对应转运、尾程、入仓单和待收货计划一并取消。 + PICKUP 维护车辆后复用 dispatches/{dispatchNo}/submit-review、dispatch-review、loading-review 两级审核入口及版本化命令。 + 调度通过后待理货审核,任一级驳回返回车辆信息修改重提;两级通过后进入装货中;逐票上传取货照片并确认后自动进入派送中,逐票上传到仓照片并确认后自动完成取货派送,再走原仓库收货流程,不走仓内拣货扣库存。 + 车辆、司机或计划时刻变化作废本轮审核;运输版本与排车审核版本各自递增,命令使用对应详情的当前版本。 servers: - url: /api/platform/v1/last-leg/dispatches/transport-tasks security: diff --git a/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml index 39b1a15f8..b4bd3a5e0 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml @@ -3,12 +3,16 @@ info: title: 仓储转运上门取货与车辆排车 version: 1.0.0 description: | - 审核通过的 WAREHOUSE_PICKUP 外来转运单自动进入待取货候选;客户自送、库存转运、未审核及已完成测量单不进入。 + WAREHOUSE_PICKUP 外来转运单审核通过后,同事务自动生成绑定原单的 PICKUP 排车单(PC号),待分配车辆司机;客户自送、库存转运不自动排车。 + 自动计划暂定为审核时刻发车、24小时后结束,并在备注标明待调度确认;不填写实际发车、实提或送仓事实。 + 取货排车先维护车辆信息、提交调度审核,经调度和理货员同轮审核通过后进入装货中。逐票上传取货照片并确认实提,全车齐全后自动进入派送中;逐票上传独立到仓照片,全部到仓后自动完成取货派送,再继续原仓库收货流程。 + 修改车辆、司机或计划时刻后本轮审核失效,须重新提交;审核不触发仓内拣货或库存扣减。 + 重复审核沿原单锁复用已有任务,不重复建单,也不恢复已主动取消的任务;取消排车时对应转运单、尾程单、入仓单及待PDA计划同事务取消,不再进入取货候选。 入仓取货和出库派送共用 ll_fleet_trip 及车辆司机占用,tripPurpose 分别为 PICKUP / DELIVERY,禁止混用库存出库命令、外接预订及余位发布。 同车多单必须送往同一公共海外仓,原客户归属与转运单保留,不重复生成尾程单、不修改预报箱唛或库存。 取货确认必须填写实提箱数并引用本票本车已上传的 PDF/JPG/PNG 凭证;送仓后开放 PDA 清点。 司机实提箱数不替代仓库实收。全部送仓后结案运输,车辆和司机仍通过原 resources/release 分别结束任务。 - 取消 PLANNED 车次原子释放资源和未执行取货关联,原单重新成为候选;已发车不可取消。 + 取消 PLANNED 车次原子取消本车全部对应转运单、尾程单、入仓单及待PDA计划并释放资源;任一成员已有清点或收货事实则全部回滚,已发车不可取消。 写命令具备车次版本及请求键,同键同内容重放不重复写入,同键不同内容拒绝。普通业务返回 HTTP 200 + body.code。 security: - bearerAuth: [] @@ -79,7 +83,7 @@ paths: parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}] post: summary: 逐票确认取货 - description: 仅平台主体,权限 platform:fleet:trip:edit。车次已发车且任务 ASSIGNED,核对本票本车上传归属后转 PICKED,实提箱数不覆盖预报。 + description: 仅平台主体,权限 platform:fleet:trip:edit。两级审核通过的计划车次或已出发车次,任务 ASSIGNED;核对本票本车图片上传归属后转 PICKED,实提箱数不覆盖预报。全车取货照片齐全后自动进入 IN_TRANSIT,排车阶段为 DELIVERING。 requestBody: required: true content: @@ -98,7 +102,7 @@ paths: parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}] post: summary: 确认本票送达海外仓 - description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;开放原 PDA 清点,不直接完成测量或入库。 + description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;evidenceFileIds 必填且为本票本车新上传的图片,不得复用取货照片。Evidence.phase 为 PICKUP 或 ARRIVAL 区分两次照片。全车均 ARRIVED 后自动完成运输,开放原 PDA 清点,不直接完成测量或入库。 requestBody: {$ref: '#/components/requestBodies/command'} responses: {'200': {description: ApiResponse 当前本车 Task 数组;车次版本已更新}} /api/platform/v1/last-leg/fleet/pickups/{tripNo}/complete: @@ -157,3 +161,4 @@ components: recordVersion: {type: integer, format: int64, minimum: 0} idempotencyKey: {type: string, minLength: 8, maxLength: 128} reason: {type: string, maxLength: 1000} + evidenceFileIds: {type: array, minItems: 1, maxItems: 10, items: {type: string}, description: 送仓确认必填,必须为本票本车新上传的到仓图片} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index a5739f874..b1213ba0a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -5253,6 +5253,14 @@ components: type: object x-java-record: DispatchContracts.DriverEvidence properties: + photoFileIds: + type: array + maxItems: 20 + uniqueItems: true + description: 维护车辆司机时保存的本趟司机照片,按DRIVER_PHOTO类别校验,与驾驶证附件独立。 + items: + type: string + pattern: ^[1-9][0-9]*$ licenseNo: type: string nullable: true diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index d5dcc0e6f..672223c04 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -148,7 +148,7 @@ public class DispatchAggregatePersistenceTest { String dispatch(ResourceMode mode,Long driverId,List ids) throws Exception { var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of()))); var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789", - new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z")))); + new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of()))); f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo(); } /** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */ @@ -591,7 +591,7 @@ public class DispatchAggregatePersistenceTest { return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方", new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)), new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789", - new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400))), + new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())), departure,null,null,sources,List.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java index 87b5f2e07..1d5bdc914 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java @@ -157,7 +157,7 @@ class DispatchRepairWorkflowTest { assertThat(created.dispatchNo()).isNotEqualTo("DP-1"); var current=dispatch.detail(aggregateFixture.actor,created.dispatchNo()); assertThat(current.actions().get(DispatchTypes.Action.CANCEL).allowed()).isTrue(); - assertThat(current.actions().get(DispatchTypes.Action.ARRANGE_DELIVERY).allowed()).isFalse(); + assertThat(current.actions()).doesNotContainKey(DispatchTypes.Action.ARRANGE_DELIVERY); assertThat(created.stage()).isEqualTo(DispatchTypes.Stage.BEFORE_LOADING); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1); assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(aggregateFixture.repository.find(created.dispatchNo()).getId()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java new file mode 100644 index 000000000..48bbcca4c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +/** 车辆维护保存司机照片后,真实资源快照、审核和原键恢复均保留相同引用。 */ +class DispatchResourcePhotosPersistenceTest { + /** 新字段缺省不改变升级前命令及审核依据的摘要,实际照片变化仍必须识别。 */ + @Test void optionalPhotosPreserveLegacyHashesButBindActualReferences() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("legacy-photos", List.of()); + var request = update(input, 0L, "legacy-edit", List.of()); + var oldJson = f.json.valueToTree(request); + ((com.fasterxml.jackson.databind.node.ObjectNode) oldJson.path("driver").path("evidence")).remove("photoFileIds"); + var hashes = new DispatchCommandHasher(f.json); + var action = com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.EDIT; + var oldHash = hashes.command(f.actor, action, "PC-OLD", 0L, oldJson); + assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, request)).isEqualTo(oldHash); + assertThat(hashes.source(request)).isEqualTo(hashes.source(oldJson)); + var withPhotos = update(input, 0L, "legacy-edit", List.of(401L, 402L)); + assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, withPhotos)).isNotEqualTo(oldHash) + .isEqualTo(hashes.command(f.actor, action, "PC-OLD", 0L, update(input, 0L, "legacy-edit", List.of(402L, 401L)))); + } + + /** 使用真实资源持久化与两级审核,防止车队资源规范化吞掉独立司机照片。 */ + @Test void savedPhotosSurviveReplayAndBothReviews() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("photos-create", List.of(f.source(1, "101", 501, "ONT8", 3))); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var request = update(input, created.recordVersion(), "photos-save", List.of(401L)); + var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)); + assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue(); + var approved = f.approve(saved); + var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())); + assertThat(detail.vehicleInfo().driver().evidence().photoFileIds()).containsExactly(401L); + assertThat(detail.vehicleInfo().driver().evidence().fileIds()).containsExactly(202L); + verify(gateway(f), atLeastOnce()).metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L); + assertThat(approved.recordVersion()).isGreaterThan(saved.recordVersion()); + } + + /** 错车或错类附件失败时,事务不得覆盖原资源、推进版本或留下成功命令。 */ + @Test void foreignPhotoCannotChangeSavedResources() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("foreign-photo-create", List.of(f.source(1, "101", 501, "ONT8", 3))); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var before = f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo()); + doThrow(new IllegalArgumentException("本车分类归属不匹配")).when(gateway(f)) + .metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L); + var request = update(input, created.recordVersion(), "foreign-photo-save", List.of(401L)); + assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request))) + .hasMessageContaining("归属不匹配"); + assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='foreign-photo-save'", Long.class)).isZero(); + } + + /** 编辑清空照片必须真实持久化;历史资源缺该字段时仍按空集合读取。 */ + @Test void photosCanBeRemovedAndOldSnapshotsRemainReadable() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("remove-photo-create", List.of()); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, created.recordVersion(), "add-photo", List.of(401L)))); + f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, saved.recordVersion(), "remove-photo", List.of()))); + assertThat(f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())).vehicleInfo().driver().evidence().photoFileIds()).isEmpty(); + var legacy = f.json.readValue("{\"licenseNo\":\"TEST\",\"fileIds\":[202]}", DispatchContracts.DriverEvidence.class); + assertThat(legacy.photoFileIds()).isEmpty(); + assertThat(legacy.fileIds()).containsExactly(202L); + } + + /** 仅替换司机照片,保留车辆、驾驶证和计划字段。 */ + private static DispatchCommands.Update update(DispatchCommands.Create input, long version, String key, List photos) { + var d = input.driver(); + var e = d.evidence(); + var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(), + new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos)); + return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片"); + } + + /** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */ + private static DispatchEvidenceGateway gateway(DispatchAggregatePersistenceTest.Fixture f) { + return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index 4c2a439f1..1fea3557a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -11,6 +11,32 @@ import static org.assertj.core.api.Assertions.*; /** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */ class DispatchReviewWorkflowPersistenceTest { + /** 取货复用同轮两级审核与驳回重提,不创建仓内拣货任务。 */ + @Test void pickupUsesBothReviewsAndRejectsSkippingOrStaleApproval() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pickup-review",List.of(f.source(1,"101",501,"ONT8",3))))); + f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",created.dispatchNo()); + var transport=org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class); + var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setStopsJson("[]"); + org.mockito.Mockito.when(transport.transport(org.mockito.ArgumentMatchers.eq(created.dispatchNo()),org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(trip); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",transport); + var submitted=submit(f,created,"pickup-submit");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW); + assertThatThrownBy(()->review(f,f.actor,submitted,true,"skip",Decision.APPROVED,null)).hasMessageContaining("状态已变化"); + var approved=review(f,f.actor,submitted,false,"pickup-dispatch",Decision.APPROVED,null); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW); + var rejected=review(f,f.actor,approved,true,"pickup-reject",Decision.REJECTED,"车辆不符"); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + var next=submit(f,rejected,"pickup-resubmit"); + var nextApproved=review(f,f.actor,next,false,"pickup-dispatch-2",Decision.APPROVED,null); + var loaded=review(f,f.actor,nextApproved,true,"pickup-loading-2",Decision.APPROVED,null); + assertThat(loaded.stage()).isEqualTo(Stage.LOADING); + var row=f.repository.find(created.dispatchNo()); + assertThat(row.getReviewState()).isEqualTo("APPROVED"); + assertThat(f.service.actions(f.actor,row).get(Action.PICK).allowed()).isFalse(); + assertThat(f.service.actions(f.actor,row).get(Action.LOADING_COMPLETE).allowed()).isFalse(); + assertThat(f.repository.outbounds(row.getId())).isEmpty(); + org.mockito.Mockito.verify(transport,org.mockito.Mockito.times(5)).verifyPickupReview(created.dispatchNo()); + } /** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */ @Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java index 0465b9a48..cac9de227 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java @@ -15,6 +15,35 @@ import static org.mockito.ArgumentMatchers.*; import static org.assertj.core.api.Assertions.*; class TransportTaskProjectionTest { + /** 已出发也不能跳过照片阶段,全部取货照片确认后才进入派送中。 */ + @Test void oldDepartureDoesNotSkipPickupPhotosStage() { + var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var pickups=mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); + var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules()); + org.springframework.test.util.ReflectionTestUtils.setField(projection,"pickupFacts",pickups); + var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setRecordVersion(10L);root.setStage("LOADING"); + var task=new com.baiyue.lastleg.fleet.pickup.FleetPickupTask();task.setStatus("ASSIGNED"); + when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(eq(root),anyLong())).thenReturn(1);when(pickups.tasks("TR1")).thenReturn(java.util.List.of(task)); + var trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT"); + projection.changed(new TransportTripChanged(trip,"PICKUP_DEPART","出发","OP"));assertThat(root.getStage()).isEqualTo("LOADING"); + task.setStatus("PICKED");projection.changed(new TransportTripChanged(trip,"PICKUP_PICKED","照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERING"); + trip.setStatus("COMPLETED");projection.changed(new TransportTripChanged(trip,"PICKUP_ARRIVED","到仓照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERED"); + } + /** 审核推进后车次版本可能较小,换车仍须递增主单版本并作废两级审核。 */ + @Test void pickupResourceChangeInvalidatesApprovalWithoutRegressingVersion() throws Exception { + var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class); + var json=new ObjectMapper().findAndRegisterModules(); + var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),json); + var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setStatus("ACTIVE");root.setStage("LOADING");root.setRecordVersion(10L);root.setReviewRound(1L);root.setReviewState("APPROVED");root.setReviewBasisHash("basis");root.setResourcesJson("{}"); + var review=new com.baiyue.lastleg.dispatch.port.DispatchContracts.Review(com.baiyue.lastleg.dispatch.port.DispatchTypes.ReviewStatus.APPROVED,null,"OP",java.time.Instant.now(),1,"basis",null,null); + root.setDispatchReviewJson(json.writeValueAsString(review));root.setLoadingReviewJson(json.writeValueAsString(review)); + when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,10L)).thenReturn(1); + assertThatCode(()->projection.requireApproved(1L)).doesNotThrowAnyException(); + var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("PICKUP");trip.setStatus("PLANNED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now()); + projection.changed(new TransportTripChanged(trip,"ASSIGN","换车","OP")); + assertThat(root.getRecordVersion()).isEqualTo(11L);assertThat(root.getReviewState()).isEqualTo("DRAFT");assertThat(root.getStage()).isEqualTo("BEFORE_LOADING"); + assertThatThrownBy(()->projection.requireApproved(1L)).hasMessageContaining("调度审核和理货员审核"); + verify(roots).saveState(root,10L); + } /** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */ @Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){ var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java index 8279ac045..93563ceec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java @@ -58,6 +58,30 @@ class TransportTaskServiceTest { @Test void evidenceDownloadRequiresPersistedConfirmation(){ assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files); } + /** 只对审核通过的上门取货生成排车,已有及取消任务都不得重建。 */ + @Test void approvalCreatesOnlyEligibleUnassignedPickup() { + var facts=mock(FleetPickupRepository.class); + var subject=spy(new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips, + mock(FleetPickupService.class),facts,trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class), + mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules())); + var source=new FleetPickupSource();source.setId(42L);source.setTransferNo("ZY1");source.setInboundCode("ZY1"); + source.setDeliveryMethod("WAREHOUSE_PICKUP");source.setStatus("WAIT_PDA_RECEIVING"); + when(facts.source("C1","ZY1",true)).thenReturn(source); + doReturn(null).when(subject).createPickup(anyString(),any()); + subject.ensureApprovedPickup("C1","ZY1","OP"); + var request=org.mockito.ArgumentCaptor.forClass(FleetPickupModels.Create.class); + verify(subject).createPickup(eq("OP"),request.capture()); + assertThat(request.getValue().sources()).containsExactly(new FleetPickupModels.Source("C1","ZY1")); + assertThat(request.getValue().idempotencyKey()).isEqualTo("transfer-approval:42"); + assertThat(request.getValue().expectedDriverEndAt()).isAfter(request.getValue().plannedDepartureAt()); + clearInvocations(subject); + when(facts.lockTask(42L)).thenReturn(new FleetPickupTask()); + subject.ensureApprovedPickup("C1","ZY1","OP2"); + verify(subject,never()).createPickup(anyString(),any()); + source.setDeliveryMethod("CUSTOMER_DELIVERY");clearInvocations(facts); + subject.ensureApprovedPickup("C1","ZY1","OP"); + verify(facts,never()).lockTask(anyLong()); + } /** 构造固定版本的测试执行命令。 */ private TransportTaskModels.Command command(String key,List files){return new TransportTaskModels.Command(3L,key,"结束运输",files);} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java index 11db2b00a..81001c2a6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java @@ -60,4 +60,12 @@ class FleetPickupMapperTest { db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','PICKED',CURRENT_TIMESTAMP)"); assertThat(mapper.unassign("TR-1")).isZero();assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1"); } + /** 联动取消保留原车次身份,已取货成员不能被取消SQL覆盖。 */ + @Test void cancellationKeepsSourceLinkAndDoesNotRewritePickedFacts() { + db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','ASSIGNED',CURRENT_TIMESTAMP)"); + db.update("INSERT INTO ll_fleet_pickup_task VALUES(6,'C6','WT-6','TR-1','PICKED',CURRENT_TIMESTAMP)"); + assertThat(mapper.cancelTasks("TR-1")).isEqualTo(1); + assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");assertThat(mapper.task(1L).getStatus()).isEqualTo("CANCELLED"); + assertThat(mapper.task(6L).getStatus()).isEqualTo("PICKED");assertThat(mapper.cancelTasks("TR-1")).isZero(); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java index 8fceea32d..cc0c64c15 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java @@ -15,11 +15,15 @@ import static org.mockito.ArgumentMatchers.*; class FleetPickupServiceTest { FleetPickupRepository repository;FleetTripService trips;FleetPickupService service; + PickupDepartureReview departureReview; + DriverService drivers;VehicleRepository vehicles; TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task; final Map commands=new HashMap<>(); @BeforeEach void setup() { repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class); - service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),mock(DriverService.class),mock(VehicleRepository.class),files,new ObjectMapper().findAndRegisterModules()); + departureReview=mock(PickupDepartureReview.class); + drivers=mock(DriverService.class);vehicles=mock(VehicleRepository.class); + service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),drivers,vehicles,files,new ObjectMapper().findAndRegisterModules(),departureReview); trip=new FleetTripEntity();trip.setId(9L);trip.setTripNo("TR-1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");trip.setWarehouseId(100L);trip.setRecordVersion(2L); source=source(1L,"C1","WT-1","100");task=new FleetPickupTask();task.setTransferOrderId(1L);task.setCustomerId("C1");task.setTransferNo("WT-1");task.setTripNo("TR-1");task.setStatus("ASSIGNED"); when(repository.trip("TR-1",true)).thenReturn(trip);when(repository.trip("TR-1",false)).thenReturn(trip); @@ -29,6 +33,29 @@ class FleetPickupServiceTest { doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString()); doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); } + /** 审核完成后直接在装货阶段上传取货照片,全车核齐自动进入派送。 */ + @Test void pickupPhotosStartDeliveryWithoutSeparateDeparture() { + trip.setStatus("PLANNED");trip.setVehicleId(20L);trip.setDriverId(30L);trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).plusDays(1)); + when(repository.ownsResources(9L,20L,30L)).thenReturn(true); + var vehicle=mock(VehicleRepository.VehicleSnapshot.class);when(vehicle.region()).thenReturn("OVERSEAS");when(vehicle.status()).thenReturn("IN_SERVICE");when(vehicles.lockById(20L)).thenReturn(Optional.of(vehicle)); + var driver=mock(DriverModels.Response.class);var details=mock(DriverModels.Draft.class);when(details.region()).thenReturn("OVERSEAS");when(driver.details()).thenReturn(details);when(drivers.requireDispatchable(eq(30L),any())).thenReturn(driver); + when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true); + when(files.metadata(9L,88L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("88","取货照片.jpg","image/jpeg",500)); + var request=new FleetPickupModels.Pickup(2L,"pickup-photo-key",5,List.of(88L),null); + service.picked("TR-1","C1","WT-1","OP",request); + assertThat(task.getStatus()).isEqualTo("PICKED");assertThat(trip.getStatus()).isEqualTo("IN_TRANSIT");assertThat(trip.getDepartedAt()).isNotNull(); + verify(departureReview).requireApproved(9L);verify(trips,times(1)).recordExecution(any(),eq(2L),eq("PICKUP_PICKED"),anyString(),eq("OP")); + service.picked("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task); + } + /** 原车队发车接口不能在两级审核未通过时写入实际发车事实。 */ + @Test void legacyDepartureCannotBypassReview() { + trip.setStatus("PLANNED"); + doThrow(new IllegalStateException("请先完成两级审核")).when(departureReview).requireApproved(9L); + assertThatThrownBy(()->service.depart("TR-1","OP",new FleetPickupModels.Command(2L,"depart-review-key","发车"))).hasMessageContaining("两级审核"); + assertThat(trip.getStatus()).isEqualTo("PLANNED");assertThat(trip.getDepartedAt()).isNull(); + verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); + assertThat(commands).isEmpty(); + } /** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */ @Test void pickupMustArriveBeforePda() { assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓"); @@ -79,10 +106,23 @@ class FleetPickupServiceTest { } @Test void arrivalOpensPdaAndCompletionKeepsWarehouseOrderInMeasurement() { task.setStatus("PICKED");task.setActualPackages(5);task.setPickedAt(LocalDateTime.now(ZoneOffset.UTC)); - service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台")); + when(repository.ownsUpload(1L,"TR-1",89L)).thenReturn(true); + when(files.metadata(9L,89L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("89","到仓照片.jpg","image/jpeg",500)); + var request=new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台",List.of(89L)); + service.arrive("TR-1","C1","WT-1","OP",request); assertThat(task.getStatus()).isEqualTo("ARRIVED");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); - service.complete("TR-1","OP",new FleetPickupModels.Command(3L,"complete-key-1","全部送仓")); - assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("complete-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); + assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("arrive-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); + assertThat(task.getEvidenceJson()).contains("ARRIVAL","到仓照片"); + service.arrive("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task); + } + /** 到仓必须上传独立的新照片,不能用取货照片冒充或提前开放清点。 */ + @Test void arrivalRequiresFreshPhotoEvidence() { + task.setStatus("PICKED"); + task.setEvidenceJson("[{\"fileId\":\"88\",\"fileName\":\"取货.jpg\",\"contentType\":\"image/jpeg\",\"sizeBytes\":1,\"phase\":\"PICKUP\"}]"); + assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"no-photo","送仓"))).hasMessageContaining("上传到仓照片"); + when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true); + assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"same-photo","送仓",List.of(88L)))).hasMessageContaining("不能复用取货照片"); + assertThat(task.getStatus()).isEqualTo("PICKED");verify(repository,never()).update(any()); } @Test void staleVersionAndWrongTripPurposeAreRejected() { assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(1L,"arrive-key-1","送仓"))).hasMessageContaining("车次已变化"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 4736446b7..5623e4aa3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -122,6 +122,8 @@ class FleetTripAuthorizationTest { @Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); } /** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */ @Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); } + /** 取消原单使用所属域替身,权限测试不写业务数据。 */ + @Bean com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation pickupCancellation() { return mock(com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation.class); } @Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); } @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort workDispatches() { return mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); } @Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java index 23c7e4aa7..b99ddced9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java @@ -75,7 +75,6 @@ class PdaOperationsOpenApiContractTest { "/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:", - "/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:", "/api/pda/v1/last-leg/outbound-tasks:", "/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:", "/api/pda/v1/last-leg/outbound-tasks/{taskNo}:", @@ -83,7 +82,8 @@ class PdaOperationsOpenApiContractTest { "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:", "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"); for (String path : paths) assertTrue(yaml.contains(path), path); - assertEquals(36, paths.size()); + assertEquals(35, paths.size()); + assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:")); assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:")); assertTrue(yaml.contains("enum: [LOCATION, PALLET]")); assertTrue(yaml.contains("enum: [APPROVE, REWORK]")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationPersistenceTest.java new file mode 100644 index 000000000..c6beb6bd5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationPersistenceTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.fleet.pickup.FleetPickupModels; +import com.baiyue.lastleg.transfer.mapper.TransferPickupCancellationMapper; +import com.baiyue.lastleg.transfer.repository.TransferPickupCancellationRepository; +import org.h2.jdbcx.JdbcDataSource; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +/** 通过真实SQL验证联动取消、客户隔离以及多票失败回滚。 */ +class TransferPickupCancellationPersistenceTest { + JdbcTemplate db;TransactionTemplate tx;TransferPickupCancellationService service; + /** 每例建立隔离库,保留取消所需真实字段和状态约束。 */ + @BeforeEach void setup() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_cancel_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); + db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_id BIGINT,inbound_order_id BIGINT,delivery_method VARCHAR(40),status VARCHAR(32),updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),current_status VARCHAR(40),sub_order_status VARCHAR(255),version INT,updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_inbound_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),status VARCHAR(40),updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),source_order_id BIGINT,creation_mode VARCHAR(40),status VARCHAR(40),version INT,updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_loading_order_status_history(customer_id VARCHAR(64),order_id BIGINT,from_status VARCHAR(40),to_status VARCHAR(40),event_id VARCHAR(64),remark VARCHAR(1000),created_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_warehouse_transfer_receipt(customer_id VARCHAR(64),transfer_order_id BIGINT)"); + db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR(64),transfer_order_id BIGINT,counted_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_warehouse_transfer_pallet(customer_id VARCHAR(64),transfer_order_id BIGINT)"); + for(int i=1;i<=2;i++) { + db.update("INSERT INTO ll_warehouse_transfer_order VALUES(?,? ,? ,? ,?,?,'WAREHOUSE_PICKUP','WAIT_PDA_RECEIVING',CURRENT_TIMESTAMP)",i,"C"+i,"WT"+i,"WT"+i,i,i); + db.update("INSERT INTO ll_order VALUES(?,?,'INBOUND_MEASURING','WAREHOUSE_TRANSFER:APPROVED',0,CURRENT_TIMESTAMP)",i,"C"+i); + db.update("INSERT INTO ll_inbound_order VALUES(?,?,'PENDING_INBOUND',CURRENT_TIMESTAMP)",i,"C"+i); + db.update("INSERT INTO ll_inbound_plan VALUES(?, ?,?,'WAREHOUSE_TRANSFER','PDA_PENDING',0,CURRENT_TIMESTAMP)",i,"C"+i,i); + } + var config=new MybatisConfiguration();config.addMapper(TransferPickupCancellationMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + service=new TransferPickupCancellationService(new TransferPickupCancellationRepository(new SqlSessionTemplate(factory.getObject()).getMapper(TransferPickupCancellationMapper.class))); + } + /** 释放本例数据库资源。 */ + @AfterEach void close(){db.execute("SHUTDOWN");} + /** 全车跨客户成员分别取消自己的原单与计划,不影响其他客户。 */ + @Test void cancelsOnlyTheLinkedBusinessAndKeepsAudit() { + tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1")),"TR1","OP","客户取消")); + for(String table:List.of("ll_warehouse_transfer_order","ll_inbound_order","ll_inbound_plan")) { + assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=1",String.class)).isEqualTo("CANCELLED"); + assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=2",String.class)).isNotEqualTo("CANCELLED"); + } + assertThat(db.queryForObject("SELECT current_status FROM ll_order WHERE id=1",String.class)).isEqualTo("CANCELLED"); + assertThat(db.queryForObject("SELECT version FROM ll_order WHERE id=1",Integer.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT remark FROM ll_loading_order_status_history",String.class)).contains("OP","客户取消"); + } + /** 后一票已经清点时,前一票取消及历史写入全部回滚。 */ + @Test void oneStartedMemberRollsBackAllOtherCancellations() { + db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('C2',2,CURRENT_TIMESTAMP)"); + assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1"),new FleetPickupModels.Source("C2","WT2")),"TR1","OP","取消"))).hasMessageContaining("不能取消"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_order WHERE status='CANCELLED'",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_loading_order_status_history",Integer.class)).isZero(); + assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C2","WT1")),"TR1","OP","取消"))).hasMessageContaining("不能取消"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index 1ce61ed87..01dc83420 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -27,7 +27,7 @@ class WarehouseTransferCartonCountTest { service = spy(new WarehouseTransferService()); ReflectionTestUtils.setField(service, "transfers", orders); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); - ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); + ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class))); ReflectionTestUtils.setField(service, "transferCartons", cartons); ReflectionTestUtils.setField(service, "transferItems", items); ReflectionTestUtils.setField(service, "transferPallets", palletMapper); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index ae4a41420..86125224d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -202,6 +202,7 @@ class WarehouseTransferServiceTest { @Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService; private WarehouseTransferService service; @Mock WarehouseTransferReceiptCoordinator receiptCoordinator; + @Mock com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks; @Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @@ -209,6 +210,7 @@ class WarehouseTransferServiceTest { void setUp() { compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new WarehouseTransferService(); + ReflectionTestUtils.setField(service, "transportTaskService", transportTasks); var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository(); ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons); ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository); @@ -233,7 +235,7 @@ class WarehouseTransferServiceTest { }); ReflectionTestUtils.setField(service, "transfers", transfers); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); - ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); + ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class))); ReflectionTestUtils.setField(service, "transferItems", transferItems); ReflectionTestUtils.setField(service, "transferCartons", transferCartons); ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); @@ -724,6 +726,7 @@ class WarehouseTransferServiceTest { assertEquals(5, line.getValue().getRequiredPackageCount()); assertEquals("PENDING_INBOUND", inbound.getStatus()); assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus()); + verify(transportTasks).ensureApprovedPickup("TENANT-A", "WTR-1", "USER-1"); } @Test