diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index 80182e0a5..188106dc0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -25,6 +25,11 @@ public interface AppointmentWorkDispatchPort { void requireDeparture(String dispatchNo); /** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */ String departureBlockReason(String dispatchNo); + record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo, + java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { } + /** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */ + String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt); + List reviewEvidence(String dispatchNo); void removeItem(long dispatchId,long itemId); void requireTripBinding(String dispatchNo,long tripId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 3cbaacdc3..9b9c232b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -68,7 +68,8 @@ public final class DispatchViews { public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status, Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts, - boolean pickingReconciled, List items, Map actions) { } + boolean pickingReconciled, List items, Map actions, + Instant plannedDepartureAt, Instant estimatedArrivalAt, List fbaAppointments) { } public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo, Destination destination, long scheduledPackages, long pickedPackages, 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 f63a97c40..ab3304907 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 @@ -434,8 +434,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name(); if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化"); if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空"); - if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交"); - if(loading) { + if(request.decision()==Decision.APPROVED && !Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交"); + if(loading && request.decision()==Decision.APPROVED) { var dispatch=reviews(row).dispatchReview(); if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效"); } @@ -562,6 +562,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平"); lockExecutionSources(row,items); var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); + String appointmentBlock=appointments.departureBlockReason(number); + if(appointmentBlock!=null)throw conflict(appointmentBlock); if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线"); // 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。 @@ -640,7 +642,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA 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()); 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()));} + else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));} return hashes.source(facts); } /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ @@ -667,6 +669,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ void verifyReviewResources(DispatchAggregate row) { if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;} + requireReviewAppointment(row); 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("请补齐车辆真实托位、载重及容积"); @@ -679,6 +682,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA 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,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); } + /** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */ + void requireReviewAppointment(DispatchAggregate row) { + String reason=reviewAppointmentBlock(row); + if(reason!=null)throw conflict(reason); + } + private String reviewAppointmentBlock(DispatchAggregate row) { + String reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt())); + if(reason==null && row.getReviewBasisHash()!=null && !canSubmitReview(row.getReviewState()) && !Objects.equals(row.getReviewBasisHash(),basis(row))) + return "预约或运输安排已变化,原审核依据失效,请重新核对审核"; + return reason; + } /** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */ private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List ids) { if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请补齐本车有效的车辆及司机证据"); @@ -736,6 +750,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA statistics(repository.summaries(filter)),Instant.now()); } + /** Admin与PDA审核读取相同的当前有效预约,未确认成员不能被已确认成员覆盖。 */ + List reviewAppointments(DispatchAggregate row) { + String number=row.getDispatchNo();var batches=repository.batches(row.getId()); + var confirmedAppointments=this.appointments.reviewEvidence(number); + return batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap( + b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> { + var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null); + return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(), + confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null); + }).toList(); + } + /** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public DispatchViews.Detail detail(Actor actor, String number) { @@ -746,9 +772,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b)); var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(), group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList(); - var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap( - b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream() - .map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList(); + var appointments=reviewAppointments(row); var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(), d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()), d.status(),d.packageCount(),d.palletCount())).toList(); @@ -880,6 +904,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA } var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState()); + String reviewBlock=active && (stage==Stage.BEFORE_LOADING || stage==Stage.LOADING) + ?reviewAppointmentBlock(row):null; for(var action:Action.values()) { if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue; boolean state=switch(action) { @@ -897,6 +923,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA }; boolean permitted=hasPermission(actor,action); gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件")); + if(state && permitted && reviewBlock!=null && Set.of(Action.SUBMIT_REVIEW,Action.DISPATCH_REVIEW,Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE).contains(action)) { + boolean rejectionAllowed=action==Action.DISPATCH_REVIEW || action==Action.LOADING_REVIEW; + gates.put(action,new Gate(rejectionAllowed,rejectionAllowed?"APPROVAL_BLOCKED":"APPOINTMENT_NOT_READY",reviewBlock)); + } + if(action==Action.LOADING_COMPLETE && state && permitted) { + String reason=appointments.departureBlockReason(row.getDispatchNo()); + if(reason!=null)gates.put(action,new Gate(false,"APPOINTMENT_NOT_READY",reason)); + } } return Collections.unmodifiableMap(gates); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java index 158c648d6..34434c8af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java @@ -17,6 +17,12 @@ public class DispatchAppointmentAllocations { private final AppointmentWorkDispatchPort provider; public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;} + /** 装车按钮提前展示原预约的真实阻塞,提交仍由发车事务加锁复验。 */ + public String departureBlockReason(String dispatchNo){return provider.departureBlockReason(dispatchNo);} + /** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */ + public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);} + public List reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);} + /** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */ public Map candidates(List rows) { var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 33799a454..0272daf1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -89,7 +89,9 @@ public class DispatchPdaQueryService { else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 - result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); + result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions), + root.getPlannedDepartureAt()==null?null:root.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC), + root.getEstimatedArrivalAt()==null?null:root.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC),workflow.reviewAppointments(root))); } return List.copyOf(result); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 72da6cc6f..79dd6bc11 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -47,6 +47,7 @@ public class DispatchPickingService { var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING || context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码"); + workflow.requireReviewAppointment(row); if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法"); try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");} var currentItems=repository.cargo(row.getId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java index 86288be8b..6ad5a1e21 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -141,15 +141,61 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(remaining.values().stream().anyMatch(n->n!=0)) throw conflict("批次逐票分配必须与所选托盘实际货物完全一致"); if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致"); } - public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true); } + public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true,true); } public String departureBlockReason(String dispatchNo) { - try { checkDeparture(dispatchNo,false);return null; } + try { checkDeparture(dispatchNo,false,true);return null; } catch(org.springframework.web.server.ResponseStatusException error) { if(error.getStatusCode().value()!=409) throw error; return error.getReason(); } } - private void checkDeparture(String dispatchNo,boolean locked) { + /** 审核使用计划/实收分配,不要求尚未发生的拣货;发车继续核对真实已拣来源。 */ + public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival) { + try { + checkDeparture(dispatchNo,false,false); + var windows=reviewEvidence(dispatchNo); + if(!windows.isEmpty()) { + if(departure==null || arrival==null)return "请先明确计划来车/发车时间和预计送达时间,再提交审核"; + if(arrival.isBefore(departure))return "预计送达时间不能早于计划发车时间"; + var first=windows.stream().min(Comparator.comparing(ReceivingWindow::startAt)).orElseThrow(); + if(!departure.isBefore(first.startAt()))return "计划发车时间须早于本车最早的FBA预约送达时间"; + if(arrival.isAfter(first.endAt()==null?first.startAt():first.endAt()))return "预计送达时间晚于本车首个FBA预约收货时间,请调度重新核对"; + } + return null; + } catch(org.springframework.web.server.ResponseStatusException error) { + if(error.getStatusCode().value()!=409)throw error; + return error.getReason(); + } + } + /** 审核依据取当前有效收货确认,不能用旧批次头的计划时间替代。 */ + public List reviewEvidence(String dispatchNo) { + var result=new ArrayList();var seen=new HashSet>(); + for(var row:mapper.bindings(dispatchNo)) { + if(row.batchKey().startsWith("DISPATCH:") || !seen.add(List.of(row.batchKey(),row.customerId(),row.destinationKey())))continue; + var agreement=receiving.currentAgreement(row.batchKey(),row.customerId()); + if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus()) || agreement.details().appointmentAt()==null)continue; + var member=agreement.members().stream().filter(m->row.batchKey().equals(m.batchKey()) && row.customerId().equals(m.customerId())).findFirst().orElse(null); + if(member==null)continue; + var details=agreement.details(); + result.add(new ReceivingWindow(row.batchKey(),member.appointmentNo(),row.customerId(),row.destinationKey(),details.confirmationNo(), + receivingInstant(details.appointmentAt(),agreement.timezone(),details.startOffset()), + details.appointmentEndAt()==null?null:receivingInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset()),agreement.agreementKey(),agreement.version())); + } + return result.stream().sorted(Comparator.comparing(ReceivingWindow::batchKey).thenComparing(ReceivingWindow::customerId).thenComparing(ReceivingWindow::destinationKey)).toList(); + } + /** 保留仓库当地时区及明确偏移,不把美国预约时间按服务器时区解释。 */ + private java.time.Instant receivingInstant(java.time.LocalDateTime at,String timezone,String offset) { + try { + if(timezone==null)throw conflict("预约收货时间的时区尚未明确"); + var zone=java.time.ZoneId.of(timezone); + var valid=zone.getRules().getValidOffsets(at); + if(valid.isEmpty() || offset==null && valid.size()!=1)throw conflict("预约收货时间的时区偏移尚未明确"); + var selected=offset==null?valid.get(0):java.time.ZoneOffset.of(offset); + if(!valid.contains(selected))throw conflict("预约收货时间的时区偏移无效"); + return at.toInstant(selected); + } catch(java.time.DateTimeException error) {throw conflict("预约收货时间的时区或偏移无效");} + } + private void checkDeparture(String dispatchNo,boolean locked,boolean picked) { var rows=mapper.bindings(dispatchNo); var physical=mapper.dispatchPhysical(dispatchNo); for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId()))) @@ -158,6 +204,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(locked) for(var batch:batches) lock(batch); for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) { requireNoBatch(item.customerId(),item.planPalletId()); + if(!picked && mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")))throw conflict("本车FBA货物尚未关联已确认预约,请客服核对送达时间后提交审核"); if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车"); } for(var batch:batches) { @@ -175,8 +222,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.customerId(),Long.toString(r.itemId()),Long.toString(r.palletId())))).values()) { var first=group.get(0); var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow(); - validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),"RECEIVED",item.packages()), - group.stream().map(a->allocation(a,null)).toList(),true,true); + validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),item.sourceStage(),item.packages()), + group.stream().map(a->allocation(a,null)).toList(),picked || "RECEIVED".equals(item.sourceStage()),picked); } } private void requireReceiving(String batch,String customer,List rows) { 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 f895b4d25..befd48f10 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -6644,6 +6644,21 @@ components: allOf: - $ref: '#/components/schemas/DispatchCounts' nullable: true + plannedDepartureAt: + type: string + format: date-time + nullable: true + description: 当前排车计划发车时间,审核必须核对。 + estimatedArrivalAt: + type: string + format: date-time + nullable: true + description: 当前排车预计送达时间。 + fbaAppointments: + type: array + items: + $ref: '#/components/schemas/DispatchFbaAppointment' + description: 按本车各客户批次读取当前有效收货确认;缺失确认显示PENDING。 pickingReconciled: type: boolean items: 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 c18f12569..5b367fa19 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 @@ -454,8 +454,14 @@ public class DispatchAggregatePersistenceTest { f.db.update("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",dispatch,item); var detail=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())); assertThat(detail.orderDetails()).hasSize(2); assertThat(detail.dispatchInfo().appointmentBatches()).hasSize(1); - assertThat(detail.dispatchInfo().fbaAppointments().get(0).appointmentNo()).isEqualTo("CONFIRM-REAL"); - assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isEqualTo(Instant.parse("2026-10-19T10:00:00Z")); + // 批次头写SUCCESS不代表存在当前有效收货确认,不再把旧头时间作为审批依据。 + assertThat(detail.dispatchInfo().fbaAppointments().get(0).status()).isEqualTo("PENDING"); + assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isNull(); + var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); + when(provider.reviewEvidence(original.dispatchNo())).thenReturn(List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-EXISTING","101","FBA:ONT8","CURRENT-CONFIRM",Instant.parse("2026-10-20T17:00:00Z"),null,"AGR-1",1))); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider)); + var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().get(0); + assertThat(current.appointmentNo()).isEqualTo("CURRENT-CONFIRM");assertThat(current.confirmedAt()).isEqualTo(Instant.parse("2026-10-20T17:00:00Z")); var c=f.source(3,"101",503,"ONT8",2); f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of()))); var restored=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b)))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java index c83496c6a..5c41b43b0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java @@ -19,6 +19,64 @@ import static org.mockito.Mockito.*; /** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */ class DispatchLoadingEligibilityPersistenceTest { + @org.junit.jupiter.api.BeforeEach void authorize() { + org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator","", + "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete", + "platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + } + @org.junit.jupiter.api.AfterEach void clearIdentity(){org.springframework.security.core.context.SecurityContextHolder.clearContext();} + /** 允许预排;客服提交及两级批准均复验,缺预约时仍能驳回客服而不能开始拣货。 */ + @org.junit.jupiter.api.Test void preArrangementRemainsAllowedButReviewsCannotApproveUnconfirmedAppointments() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("review-appointment",List.of(f.source(1,"101",501,"ONT8",3))))); + var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); + ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider)); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"blocked-submit",null)))).hasMessageContaining("尚未确认"); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null); + var submitted=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + var gate=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.DISPATCH_REVIEW); + assertThat(gate.allowed()).isTrue();assertThat(gate.blockCode()).isEqualTo("APPROVAL_BLOCKED"); + assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"bad-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认"); + // 预约依据变化后仍可驳回,不让失效审核把客服修正入口锁死。 + var changedWindow=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","YY1","101","FBA:ONT8","CONF1",java.time.Instant.parse("2026-12-01T10:00:00Z"),null,"agreement-1",2); + when(provider.reviewEvidence(anyString())).thenReturn(List.of(changedWindow)); + var rejected=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"reject",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.REJECTED,"请客服核对预约",null))); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null); + var submittedAgain=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"resubmit",null))); + var reviewed=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submittedAgain.recordVersion(),"dispatch-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null))); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"bad-loading-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认"); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null); + f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null))); + when(provider.reviewEvidence(anyString())).thenReturn(List.of()); + assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("原审核依据失效"); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + assertThat(f.tx.execute(t->f.service.detail(pda,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.PICK).allowed()).isFalse(); + assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("尚未确认"); + } + /** 照片或终端不改变预约门槛;显示与正式提交均拒绝未准备好的预约。 */ + @org.junit.jupiter.api.Test void appointmentBlockIsVisibleBeforeLoadingSubmission() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("appointment-gate",List.of(f.source(1,"101",501,"ONT8",3))))); + var approved=f.approve(created);f.picked(1,"101",501,3); + f.db.update("UPDATE ll_dispatch_order SET picked_package_count=3"); + var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); + when(provider.departureBlockReason(created.dispatchNo())).thenReturn("请先约仓成功再排车或发车"); + ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider)); + var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo())); + assertThat(preview.pickingReconciled()).isTrue(); + assertThat(preview.loadingComplete().allowed()).isFalse(); + assertThat(preview.loadingComplete().blockCode()).isEqualTo("APPOINTMENT_NOT_READY"); + assertThat(f.tx.execute(t->f.service.detail(actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.LOADING_COMPLETE).allowed()).isFalse(); + assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"blocked-loading",preview.manifestHash(),null)))) + .hasMessageContaining("先约仓成功"); + assertThat(f.repository.find(created.dispatchNo()).getStage()).isEqualTo("LOADING"); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion()); + } /** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */ @ParameterizedTest @ValueSource(booleans={false,true}) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java index 2792c8cf7..c5734b2bb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java @@ -31,6 +31,10 @@ class DispatchPdaReadPersistenceTest { var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,null))); var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1); var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING); + var platform=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo())); + assertThat(task.plannedDepartureAt()).isEqualTo(platform.dispatchInfo().plannedDepartureAt()); + assertThat(task.estimatedArrivalAt()).isEqualTo(platform.dispatchInfo().estimatedArrivalAt()); + assertThat(task.fbaAppointments()).isEqualTo(platform.dispatchInfo().fbaAppointments()); assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse(); assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机"); f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符",null))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index 940f5870d..6eab729cc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -23,6 +23,17 @@ import static org.mockito.Mockito.*; /** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */ class DispatchPickingPersistenceTest { + /** 即使旧记录已经审核通过,真实扫码仍复验预约;拒绝时不物化、不扣库存。 */ + @Test void unconfirmedAppointmentBlocksPickingBeforeInventoryWrites() throws Exception { + var fixture=new Picking(true);fixture.receivedPlans(9,11); + var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + org.springframework.test.util.ReflectionTestUtils.setField(fixture.f.service,"appointments",new DispatchAppointmentAllocations(provider)); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, + new DispatchCommands.Pick(fixture.version,"appointment-blocked",11L,9,0,null,null)))).hasMessageContaining("尚未确认"); + verify(fixture.inventory,never()).ship(any(),any(),any()); + assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); + } /** 数量可自然同步不代表能接管其他车的实际托盘。 */ @Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception { var fixture=new Picking(true);fixture.receivedPlans(9,11); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java index e2b18a5d3..ed681cca9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java @@ -58,6 +58,16 @@ class AppointmentWorkDispatchBindingTest { when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4))); when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null))); assertDoesNotThrow(()->service.requireDeparture("D-20")); + when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4))); + var departure=java.time.Instant.parse("2026-10-18T12:00:00Z"); + var arrival=java.time.Instant.parse("2026-10-18T17:00:00Z"); + clearInvocations(mapper); + assertNull(service.reviewBlockReason("D-20",departure,arrival)); + verify(mapper,never()).pickedPhysical(anyLong(),anyString()); + assertEquals(arrival,service.reviewEvidence("D-20").get(0).startAt(),"10点洛杉矶预约应按当地时区解释"); + assertNotNull(service.reviewBlockReason("D-20",departure,null)); + assertNotNull(service.reviewBlockReason("D-20",arrival.plusSeconds(60),arrival.plusSeconds(120))); + assertNotNull(service.reviewBlockReason("D-20",departure,arrival.plusSeconds(60))); when(item.packageCount()).thenReturn(5);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L); assertTrue(service.departureBlockReason("D-20").contains("收货确认未完整覆盖")); // Batch has 5, but the current agreement covers only 4 across every dispatch. when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L); @@ -76,6 +86,7 @@ class AppointmentWorkDispatchBindingTest { assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20")); when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(null); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20")); + assertTrue(service.reviewBlockReason("D-20",departure,arrival).contains("尚无有效收货确认")); verifyNoInteractions(changes); } private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement agreement(String customer,int packages) {