From 760bf8daa1239e76a8e97483e3faf34ef3812d78 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 14:37:59 +0800 Subject: [PATCH 01/30] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppointmentWorkDispatchPort.java | 6 + .../DispatchAggregateController.java | 12 ++ .../dispatch/port/DispatchWorkModels.java | 8 ++ .../service/DispatchAggregateService.java | 49 +++++++ .../DispatchAppointmentAllocations.java | 8 ++ .../service/DispatchDraftService.java | 9 +- .../dispatch/service/DispatchService.java | 12 ++ .../AppointmentDispatchAllocationReader.java | 62 ++++++++ ...pointmentDispatchAllocationRepository.java | 20 +++ .../work/AppointmentDispatchAvailability.java | 40 ++++++ .../work/AppointmentWorkDispatchBinding.java | 13 +- .../AppointmentWorkDispatchCandidates.java | 15 +- .../work/AppointmentWorkDispatchMapper.java | 24 +++- .../openapi/overseas-business-v1.yaml | 135 ++++++++++++++++++ ...mentWorkPreArrangementPersistenceTest.java | 18 ++- ...ppointmentWorkDispatchPersistenceTest.java | 18 ++- scripts/acceptance-overview.mjs | 34 +++++ scripts/acceptance-overview.test.mjs | 28 +++- scripts/admin-test-env-acceptance.mjs | 12 +- scripts/admin-test-env-modules/README.md | 28 +++- ...argo-discard-fulfillment-contract.test.mjs | 89 ++++++++++++ .../cargo-discard-fulfillment.mjs | 114 +++++++++++++++ ...ient-relabel-fulfillment-contract.test.mjs | 90 ++++++++++++ .../client-relabel-fulfillment.mjs | 132 +++++++++++++++++ .../looseboard-fulfillment-contract.test.mjs | 68 +++++++++ .../looseboard-fulfillment.mjs | 85 +++++++++++ .../looseboard-pool-binding-contract.test.mjs | 87 +++++++++++ .../looseboard-pool-binding.mjs | 100 +++++++++++++ ...looseboard-pool-dispatch-contract.test.mjs | 75 ++++++++++ .../looseboard-pool-dispatch.mjs | 86 +++++++++++ ...seboard-pool-preparation-contract.test.mjs | 75 ++++++++++ .../looseboard-pool-preparation.mjs | 97 +++++++++++++ .../looseboard-pool-receipt-contract.test.mjs | 89 ++++++++++++ .../looseboard-pool-receipt.mjs | 108 ++++++++++++++ ...nt-qualification-fixture-contract.test.mjs | 6 +- ...phase4-overseas-business-contract.test.mjs | 74 ++++++++-- .../phase4-overseas-business.mjs | 13 +- ...overseas-client-services-contract.test.mjs | 33 ++++- .../phase4-overseas-client-services.mjs | 121 +++++++++++----- .../phase4-overseas-return-followups.mjs | 47 +++--- ...-overseas-return-receipt-contract.test.mjs | 74 +++++++++- .../phase4-overseas-return-receipt.mjs | 42 +++++- ...s-wallet-and-pallet-cost-contract.test.mjs | 77 +++++++++- ...phase4-overseas-wallet-and-pallet-cost.mjs | 35 +++++ ...se5-overseas-fulfillment-contract.test.mjs | 13 +- .../phase5-overseas-fulfillment.mjs | 16 ++- .../suppliers-contract.test.mjs | 56 ++++++++ scripts/admin-test-env-modules/suppliers.mjs | 38 +++++ .../us-truck-fulfillment.mjs | 76 ++++++++++ 49 files changed, 2450 insertions(+), 117 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java create mode 100644 scripts/admin-test-env-modules/cargo-discard-fulfillment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/cargo-discard-fulfillment.mjs create mode 100644 scripts/admin-test-env-modules/client-relabel-fulfillment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/client-relabel-fulfillment.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-fulfillment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-fulfillment.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-binding-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-binding.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-dispatch-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-dispatch.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-preparation-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-preparation.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-receipt-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-receipt.mjs create mode 100644 scripts/admin-test-env-modules/us-truck-fulfillment.mjs 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 188106dc0..6469b9ab6 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 @@ -16,6 +16,12 @@ public interface AppointmentWorkDispatchPort { record CandidateBatches(List appointmentBatches,List blockReasons,boolean occupied) { } /** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */ Map candidateBatches(List candidates); + /** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */ + Map> allocationOptions(long dispatchId,List items); + record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages, + String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { } + record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount, + List options,List blockReasons) { } /** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */ java.util.Map> references(List dispatchIds); /** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java index b0ea5d313..c27f77439 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -53,6 +53,18 @@ public class DispatchAggregateController { public ApiResponse items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo)); } + /** 本车货物逐票读取正式预约选项及当前分配。 */ + @GetMapping("/{dispatchNo}/appointment-allocations") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.appointmentAllocations(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 原车版本化关联正式预约,不新建排车或修改库存。 */ + @PutMapping("/{dispatchNo}/appointment-allocations") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocationCommand request) { + return ApiResponse.success(service.saveAppointmentAllocations(DispatchActorFactory.platform(user),dispatchNo,request)); + } /** 读取实际起点和卸货顺序。 */ @GetMapping("/{dispatchNo}/route") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java index d2f1b708b..3480b5e3c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java @@ -14,6 +14,14 @@ public final class DispatchWorkModels { public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List items) { } public record Stop(int index,String name,String address,String destinationKey) { } public record Route(String dispatchNo,long recordVersion,List stops,Gate edit) { } + public record AppointmentItem(Long dispatchItemId,Long planPalletId,String customerId,String customerName,String palletNo, + List sources,List allocations) { } + public record AppointmentAllocations(String dispatchNo,long recordVersion,List items,Gate edit) { } + /** 只替换明确选中的本车实物分配,未触及客户和实物保持原关联。 */ + public record AppointmentAllocationCommand(Long recordVersion,String idempotencyKey,List dispatchItemIds,List batchAllocations) { + /** 主版本零合法;保存失败不能用默认版本覆盖后续修改。 */ + public AppointmentAllocationCommand {recordVersion=DispatchCommands.requireVersion(recordVersion);} + } /** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */ public record RouteCommand(Long recordVersion,String idempotencyKey,List destinationKeys,String reason) { /** 显式零版本合法,缺版本不能覆盖并发变更。 */ 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 62e2b1bac..7ba70e6c5 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 @@ -177,6 +177,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId())); var references=new ArrayList();var reasons=new LinkedHashSet(); reasons.addAll(group.blockReasons()); + if("FBA".equals(destination.type()) && fact==null)reasons.add("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"); if(fact!=null) { reasons.addAll(fact.blockReasons()); // 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。 @@ -380,6 +381,53 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return saveCommand(row,actor,identity,request.reason()); } + /** 已有排车按当前真实客户托盘读取逐票正式预约,不将本车占用当成其他车占用。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchWorkModels.AppointmentAllocations appointmentAllocations(Actor actor,String number) { + requirePlatform(actor);var row=required(actor,number,false); + var items=appointmentItems(row);var options=appointments.options(row.getId(),items);var current=repository.batches(row.getId()); + var result=items.stream().map(i->new DispatchWorkModels.AppointmentItem(i.getId(),i.getPlanPalletId(),i.getCustomerId(),i.getCustomerName(),i.getPalletNo(), + options.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())), + current.stream().filter(a->Objects.equals(a.dispatchItemId(),i.getId())).map(a->new BatchAllocation(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount(),a.recordVersion())).toList())).toList(); + boolean permitted=hasPermission(actor,Action.EDIT);boolean editable=appointmentEditable(row) && !items.isEmpty(); + return new DispatchWorkModels.AppointmentAllocations(number,row.getRecordVersion(),result,new Gate(permitted && editable, + !permitted?"FORBIDDEN":editable?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":editable?null:"仅装车前、未扫码且无有效下游单据的FBA货物可关联预约")); + } + + /** 原车补选或改选正式预约,分配、主版本、审核失效、历史和命令结果同事务保存。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult saveAppointmentAllocations(Actor actor,String number,DispatchWorkModels.AppointmentAllocationCommand request) { + var row=lockPlatform(actor,number); + if(!hasPermission(actor,Action.EDIT))throw new BizException(ResultCode.FORBIDDEN,"当前无排车维护权限"); + var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + requireEditing(row,request.recordVersion()); + if(!appointmentEditable(row))throw conflict("本车已有实际扫码或有效下游单据,不能调整预约分配"); + var ids=request.dispatchItemIds(); + if(ids==null || ids.isEmpty() || ids.size()>1000 || ids.stream().anyMatch(Objects::isNull) || new HashSet<>(ids).size()!=ids.size())throw bad("请选择完整且不重复的本车货物明细"); + if(request.batchAllocations()==null || request.batchAllocations().size()>1000)throw bad("请明确本次正式预约分配"); + var touched=appointmentItems(row).stream().filter(i->ids.contains(i.getId())).toList(); + if(touched.size()!=ids.size())throw conflict("预约分配只能调整本车已有的真实FBA货物"); + // 先父车/资源/主单,再全部旧新预约,最后订单/实托/计划来源;不反向追加批次锁。 + appointments.lock(repository.batches(row.getId()),request.batchAllocations()); + sourceRules.lockAppointmentSources(row.getOverseasWarehouseId(),touched); + appointments.bind(row.getId(),touched,request.batchAllocations()); + String reason="正式预约分配已调整,请客服重新提交两级审核";invalidate(row,reason); + return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList())); + } + /** 只处理普通计划来源的FBA货物,其他来源与客户原分配保持不动。 */ + private List appointmentItems(DispatchAggregate row) { + return repository.cargo(row.getId()).stream().filter(i->i.getPlanPalletId()!=null && "FBA".equals(i.getDestinationType()) + && !"RELABEL_ORDER".equals(i.getSourceBusinessType())).toList(); + } + /** 显式查真实扫码流水,不以主单零计数覆盖实际执行事实。 */ + private boolean appointmentEditable(DispatchAggregate row) { + return (row.getTaskType()==null || "DELIVERY".equals(row.getTaskType())) && "ACTIVE".equals(row.getStatus()) && "BEFORE_LOADING".equals(row.getStage()) + && n(row.getPickedPackageCount())==0 && n(row.getShippedPackageCount())==0 && repository.realPicked(row.getId())==0 + && repository.outbounds(row.getId()).stream().allMatch(o->"CANCELLED".equals(o.status())) + && repository.deliveries(row.getId()).stream().allMatch(d->"CANCELLED".equals(d.status())); + } + /** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult update(Actor actor,String number,DispatchCommands.Update request) { @@ -811,6 +859,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var source:resolved) { if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入"); var c=source.candidate(); + if("FBA".equals(c.destinationType()) && c.planPalletId()==null)throw conflict("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"); sources.expire(c.customerId(),source.sourceKey(),at); if(!sources.claims(c.customerId(),source.sourceKey(),at).isEmpty())throw conflict("所选逻辑来源已被有效运输安排占用"); try {if(sources.claim(IdWorker.getId(),c.customerId(),source.sourceKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),c.warehousePalletId()==null?null:physical.get(c.warehousePalletId()),row.getId(),actor.operatorId(),at)!=1)throw conflict("来源占用未保存");} 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 34434c8af..d5223c1d1 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 @@ -22,6 +22,14 @@ public class DispatchAppointmentAllocations { /** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */ 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);} + /** 本车货物已经由主单范围核实,选项不重新选货或制造托盘占用。 */ + public Map> options(long dispatch,List items) { + var identities=items.stream().map(i->new AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())).distinct().toList(); + if(identities.isEmpty())return Map.of(); + var result=provider.allocationOptions(dispatch,identities); + if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"本车预约选项范围不完整,请重新读取"); + return result; + } /** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */ public Map candidates(List rows) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java index a6c4667e6..533dc145c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java @@ -113,7 +113,9 @@ public class DispatchDraftService implements DispatchPlanningPort { var resolved=lockedIndex.get(member.sourceKey()); if(!Objects.equals(resolved.selection(),member.selection()) || !Objects.equals(resolved.candidate().customerId(),member.customerId()) || resolved.candidate().packageCount()==null || resolved.candidate().packageCount().longValue()!=member.packages())throw conflict("锁内完整来源身份、版本或箱数已变化"); - next.add(candidate(resolved,group));keys.add(member.sourceKey()); + var projected=candidate(resolved,group); + if(!projected.blockReasons().isEmpty())throw conflict(String.join(";",projected.blockReasons())); + next.add(projected);keys.add(member.sourceKey()); } requireCapacity(container.getCapacity(),keys,index(next)); var at=now();var physical=physicalClaims.reserve(physicalOwner(row,container.getId()),members.values().stream() @@ -428,7 +430,10 @@ public class DispatchDraftService implements DispatchPlanningPort { } /** 显式映射原批次版本;本草稿有效占用不作为所选来源自身的阻断理由。 */ private Candidate projectCandidate(Candidate c,Map batches) { - if(c.selection().planPalletId()==null)return c; + if(c.selection().planPalletId()==null) { + if(!"FBA".equals(c.destination().type()))return c; + return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),List.of(),List.of("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"),c.selectionGroup()); + } var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())); var reasons=new LinkedHashSet(); if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 3d9e901ca..5b1527594 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -394,6 +394,18 @@ public class DispatchService { if(!pallets.isEmpty())palletSerial.lock(pallets); } + /** 关联预约只锁原订单、共同实托及计划托盘,不要求尚未发生的实收库存。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockAppointmentSources(String warehouse,List items) { + var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new)); + var orders=executionOrders(warehouse,items,pallets); + orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");}); + palletSerial.lock(List.copyOf(pallets)); + if(!orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"本车逐票来源已变化,请重新核对"); + for(var item:items.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) + if(wholeSources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车计划托盘已缺失或归属变化"); + } + /** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */ @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List items) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java new file mode 100644 index 000000000..cb62d2540 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*; +import org.springframework.stereotype.Service; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +/** 为本车已有货物提供逐票原预约选项,读取不会释放占用或修改预约。 */ +@Service +public class AppointmentDispatchAllocationReader { + private final AppointmentDispatchAllocationRepository repository; + private final AppointmentSourceService sources; + private final AppointmentDispatchAvailability availability; + /** 注入本域只读仓储、来源规范化和正式确认资格。 */ + public AppointmentDispatchAllocationReader(AppointmentDispatchAllocationRepository repository,AppointmentSourceService sources,AppointmentDispatchAvailability availability) { + this.repository=repository;this.sources=sources;this.availability=availability; + } + /** 本车来源身份由上层核对;只返回明确已确认且仍有可分配数量的原批次。 */ + public Map> read(long dispatch,List items) { + if(dispatch<=0 || items==null || items.size()>1000 || items.stream().anyMatch(Objects::isNull))throw bad("本车预约实物范围无效"); + if(items.isEmpty())return Map.of(); + var physical=repository.physical(dispatch,items); + if(physical.size()>20000)throw unavailable("本车逐票实物超过完整读取上限"); + var orders=physical.stream().map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).toList(); + var canonical=new HashMap(); + var distinct=orders.stream().filter(p->p.orderId()!=null).distinct().toList(); + for(int offset=0;offsetp.orderId()!=null && p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")) + .map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); + var batches=refs.isEmpty()?List.of():repository.batches(dispatch,refs,orders); + if(batches.size()>20000)throw unavailable("本车原预约选项超过完整读取上限"); + var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList()); + var result=new LinkedHashMap>(); + for(var identity:items) { + var rows=new ArrayList(); + for(var p:physical.stream().filter(p->identity.planPalletId().equals(p.planPalletId()) && identity.customerId().equals(p.customerId())).toList()) { + var reasons=new ArrayList();var options=new ArrayList(); + if(p.orderId()==null || p.destinationKey()==null || p.packages()==null || p.packages()<=0)throw conflict("本车逐票实物来源或箱数尚未核实"); + var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).sourceKey(); + if(p.destinationKey().startsWith("FBA:")) { + for(var b:batches)if(identity.customerId().equals(b.customerId()) && source.equals(b.sourceKey()) && p.destinationKey().equals(b.destinationKey())) { + int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,p.destinationKey()); + long remaining=b.packages()==null?0:Math.min(b.packages(),confirmed)-b.allocated(); + if(remaining>0 && b.recordVersion()!=null && b.recordVersion()>=0) { + var agreement=confirmations.get(b.batchKey()).agreements().stream().filter(a->"ACTIVE".equals(a.status()) + && a.members().stream().anyMatch(m->b.batchKey().equals(m.batchKey()) && b.customerId().equals(m.customerId()))).findFirst().orElseThrow(); + var details=agreement.details(); + options.add(new AllocationOption(b.batchKey(),b.batchNo(),b.recordVersion(),Math.toIntExact(remaining),details.confirmationNo(), + details.appointmentAt().toString(),details.appointmentEndAt()==null?null:details.appointmentEndAt().toString(),agreement.timezone())); + } + } + if(options.stream().mapToLong(AllocationOption::availablePackages).sum() physical(long dispatch,List items) {return mapper.allocationPhysical(dispatch,items);} + /** 同一原批次来源扣除其他有效车辆占用,本车原分配由替换事务重新核对。 */ + public List batches(long dispatch,List refs,List orders) { + return mapper.candidateBatches(refs,orders,dispatch); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java new file mode 100644 index 000000000..820e36542 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore; +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*; +import org.springframework.stereotype.Service; +import java.time.*; +import java.util.*; + +/** FBA选入排车前必须已有覆盖本客户、来源和目的仓的正式时间确认。 */ +@Service +public class AppointmentDispatchAvailability { + private final FbaReceivingStore receiving; + /** 只消费原收货确认权威,不使用车辆补充预约号。 */ + public AppointmentDispatchAvailability(FbaReceivingStore receiving) {this.receiving=receiving;} + /** 按既有200批上限分块批读,避免逐托查询预约。 */ + public Map snapshots(List keys) { + var unique=keys.stream().distinct().sorted().toList();var result=new HashMap(); + for(int offset=0;offset"WAITING".equals(p.status()) && customer.equals(p.intent().customerId())))return 0; + var agreements=snapshot.agreements().stream().filter(a->"ACTIVE".equals(a.status()) && destination.equals(a.destinationKey()) + && a.members().stream().anyMatch(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList(); + if(agreements.size()!=1)return 0; + var agreement=agreements.get(0);var details=agreement.details(); + if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null || !validTime(details.appointmentAt(),agreement.timezone(),details.startOffset()))return 0; + if(details.appointmentEndAt()!=null && (!validTime(details.appointmentEndAt(),agreement.timezone(),details.endOffset()) || details.appointmentEndAt().isBefore(details.appointmentAt())))return 0; + var members=agreement.members().stream().filter(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId())).toList(); + if(members.size()!=1)return 0; + var sources=members.get(0).sources().stream().filter(s->source.equals(s.sourceKey()) && destination.equals(s.destinationKey())).toList(); + return sources.size()==1?Math.max(0,sources.get(0).packageCount()):0; + } + /** 夏令时歧义须有明确偏移;不能把当地时间按服务器时区猜测。 */ + private boolean validTime(LocalDateTime at,String timezone,String offset) { + try {var offsets=ZoneId.of(timezone).getRules().getValidOffsets(at);return offset==null?offsets.size()==1:offsets.contains(ZoneOffset.of(offset));} + catch(DateTimeException|NullPointerException error){return false;} + } +} 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 6ad5a1e21..3694a66d6 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 @@ -38,6 +38,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo private final AppointmentWorkChanges changes; private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; private final AppointmentWorkDispatchCandidates candidates; + @jakarta.annotation.Resource private AppointmentDispatchAllocationReader allocationReader; private final Object prelockedBatchesKey=new Object(); private final Object pendingInvalidationKey=new Object(); /** 注入原预约、真实实物和收货确认权威。 */ @@ -48,6 +49,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo /** 候选批读沿唯一原实物Provider实现,不读取工作清单投影。 */ @Transactional(readOnly=true,propagation=Propagation.SUPPORTS) public Map candidateBatches(List input) {return candidates.batchReferences(input);} + /** 已有排车的选项读取不受本车原占用影响,也不会修改来源。 */ + @Transactional(readOnly=true,propagation=Propagation.SUPPORTS) + public Map> allocationOptions(long dispatchId,List items) {return allocationReader.read(dispatchId,items);} /** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */ public void lockBatches(Map expectedBatchVersions) { if(expectedBatchVersions==null || expectedBatchVersions.size()>1000) throw bad("预约批次版本范围无效"); @@ -115,9 +119,12 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo var appointment=appointments.batchContext(batch).appointment(); if(appointment.version()==null || appointment.version()<0 || versions.containsKey(batch) && appointment.version().longValue()!=versions.get(batch)) throw conflict("预约批次版本已变化,请重新读取实物候选"); if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次"); - for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) + for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) { requireCurrentAllocation(appointment,customer,dispatch,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())) .map(a->allocation(a,appointment.version().longValue())).toList()); + // FBA确认前不能把货物加入车辆;提交审核仍复验运输时间。 + requireReceiving(batch,customer,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())).toList()); + } } // 所有校验结束后才按明确item删除;没有整车清空操作。 for(var item:touched) mapper.removeItem(dispatch,item); @@ -230,7 +237,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(receiving.hasPending(batch,customer)) throw conflict("本客户有待仓库确认的变化,保留原安排但禁止发车,请先完成明确回复"); var agreement=receiving.currentAgreement(batch,customer); if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus()) - || agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先完成当前预约后发车"); + || agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先预约成功并明确送仓时间,再加入排车或发车"); + receivingInstant(agreement.details().appointmentAt(),agreement.timezone(),agreement.details().startOffset()); var members=agreement.members().stream().filter(m->batch.equals(m.batchKey()) && customer.equals(m.customerId())).toList(); if(members.size()!=1) throw conflict("当前收货确认未唯一覆盖本客户批次,请重新核对"); for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()))).values()) { @@ -260,6 +268,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty())) throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车"); + if(!physical.isEmpty())throw conflict("FBA货物须先预约成功并明确送仓时间,再加入排车"); } /** 调用方已锁主单与原批次,删除只作用于指定实物。 */ public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java index 46fbf5285..e4375e608 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java @@ -16,8 +16,9 @@ public class AppointmentWorkDispatchCandidates { private final AppointmentWorkDispatchMapper mapper; private final InboundFbaAppointmentService appointments; private final AppointmentSourceService sources; + private final AppointmentDispatchAvailability availability; /** 注入唯一原批次、实物和来源规范化边界。 */ - public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; } + public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentDispatchAvailability availability) { this.mapper=mapper; this.appointments=appointments; this.sources=sources;this.availability=availability; } /** 整页两次本地SQL;来源按已有100票上限分块规范化,不逐托或逐批读取上下文。 */ @Transactional(readOnly=true) public Map batchReferences(List input) { @@ -32,8 +33,9 @@ public class AppointmentWorkDispatchCandidates { var refs=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); if(refs.size()>20000) throw unavailable("候选逐票来源超过完整读取上限"); var orders=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).distinct().toList(); - var batches=refs.isEmpty()?List.of():mapper.candidateBatches(refs,orders); + var batches=refs.isEmpty()?List.of():mapper.candidateBatches(refs,orders,-1); if(batches.size()>20000) throw unavailable("候选原批次超过完整读取上限"); + var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList()); var output=new LinkedHashMap(); for(var identity:identities) { var selected=rows.stream().filter(r->r.planPalletId()==identity.planPalletId() && identity.customerId().equals(r.customerId())).toList(); @@ -46,15 +48,16 @@ public class AppointmentWorkDispatchCandidates { if(!row.destinationKey().startsWith("FBA:")) continue; var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(row.orderId(),row.customerId(),row.destinationKey(),row.plannedPackages(),row.countedPackages())).sourceKey(); var matched=batches.stream().filter(b->identity.customerId().equals(b.customerId()) && (source.equals(b.sourceKey()) || row.orderId().equals(b.orderId())) && row.destinationKey().equals(b.destinationKey())).toList(); - if(matched.isEmpty()) {unassigned=true;continue;} + if(matched.isEmpty()) {unassigned=true;reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车");continue;} related=true;long available=0;var seen=new HashSet(); for(var b:matched) { if(!source.equals(b.sourceKey())) {reasons.add("原预约逐票来源与当前真实规范不一致,请先核对原批次");continue;} if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {reasons.add("原预约批次版本、逐票分配或占用不完整");continue;} - int remaining=Math.toIntExact(b.packages()-b.allocated());available=Math.addExact(available,remaining); + int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,row.destinationKey()); + int remaining=Math.toIntExact(Math.max(0,Math.min(b.packages(),confirmed)-b.allocated()));available=Math.addExact(available,remaining); if(remaining>0) references.computeIfAbsent(List.of(b.batchKey(),b.batchNo(),b.recordVersion().toString(),b.customerId()),ignored->new ArrayList<>()).add(new BatchSource(source,row.destinationKey(),Math.min(remaining,count))); } - if(availablenew BatchReference(e.getKey().get(0),e.getKey().get(1),Long.valueOf(e.getKey().get(2)),e.getKey().get(3),List.copyOf(e.getValue()))).toList(); @@ -77,6 +80,7 @@ public class AppointmentWorkDispatchCandidates { // Receiving outcome does not release this batch's cargo or decide another customer's physical eligibility. if(!appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原批次已替代,不能选择实物")); var candidates=mapper.candidates(appointment.id()); + var confirmation=availability.snapshots(List.of(batch)).get(batch); if(candidates.size()>500) throw conflict("本批实物超过500托,请先按原预约分批"); var output=new ArrayList(); for(var candidate:candidates) { @@ -93,6 +97,7 @@ public class AppointmentWorkDispatchCandidates { if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; } long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1); if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托"); + if(used+count>availability.confirmedPackages(confirmation,part.customerId(),ref.sourceKey(),part.destinationKey()))reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车"); if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失"); allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index 1dd048ce0..a1cd445df 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -18,6 +18,26 @@ public interface AppointmentWorkDispatchMapper { record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { } record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { } + record AllocationPhysical(long planPalletId,String customerId,Long orderId,String orderNo,String inboundCode,String destinationKey,Integer packages) { } + /** 来源展示使用业务编号;逐票数量按本车原计划或实收阶段读取。 */ + @Select(""" + + """) + List allocationPhysical(@Param("dispatchId") long dispatchId,@Param("items") List items); /** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */ @Select(""" """) - List candidateBatches(@Param("refs") List refs,@Param("orders") List orders); + List candidateBatches(@Param("refs") List refs,@Param("orders") List orders,@Param("excludedDispatchId") long excludedDispatchId); @Select(""" ") + List receiptDispatches(@Param("ids") List planIds); + + record ReceiptDispatch(String dispatchNo, Long tripId, Long id) { } + /** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */ @Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id") List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index 80fff1894..5b045037b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -21,6 +21,24 @@ public interface DispatchSourceMapper { record OrderReference(Long orderId,String customerId) { } record ItemPicked(Long itemId,long packages) { } + /** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */ + @Select(""" + SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId, + p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo, + CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId, + MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode, + p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage + FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id + JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id + JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id + WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL + GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address + HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1 + AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1 + """) + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List confirmedPlan(@Param("customer") String customer,@Param("id") long id); + /** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */ @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p " + "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java index f98e11c32..26aaea617 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -42,6 +42,10 @@ public class DispatchAggregateRepository { public List summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); } /** 按整车读取全部真实客户明细。 */ public List cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); } + /** 只读取本次确认入库的原计划关联排车。 */ + public List receiptDispatches(List planIds) { + return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList(); + } /** 保存已核对真实客户与仓库的来源快照。 */ public int insertItem(DispatchItem item) { return items.insert(item); } /** 未执行明细才可移除,资格和根版本由应用层保证。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index bf2d59126..3e7f1328c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -66,6 +66,8 @@ public class DispatchSourceRepository { public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); } /** 取得已有普通计划候选,保留原来源资格SQL。 */ public List selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); } + /** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */ + public List confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); } /** 取得已有真实退回候选。 */ public List selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); } /** 同托真实来源订单组成。 */ 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 a2c6ba90e..d3781bfab 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 @@ -693,9 +693,30 @@ 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()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));} + else { + var batches=json.valueToTree(repository.batches(row.getId())); + // 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。 + for(var batch:batches)if(("DISPATCH:"+row.getId()).equals(batch.path("batchKey").asText())) + ((com.fasterxml.jackson.databind.node.ObjectNode)batch).remove("recordVersion"); + facts.put("batches",batches);facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo())); + } return hashes.source(facts); } + /** 交接前记住原审核依据是否仍有效,不借实收替资源或预约变化重新背书。 */ + boolean currentReceiptBasis(DispatchAggregate row) { + return row.getReviewBasisHash()!=null && Objects.equals(row.getReviewBasisHash(),basis(row)); + } + + /** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */ + void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) { + long version=row.getRecordVersion(); + if(preserveReview)row.setReviewBasisHash(basis(row)); + row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); + if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化"); + if(repository.history(IdWorker.getId(),row,"RECEIVING_COMPLETE",operator,"按确认入库的逐托实收交接原预排",write(row),at)!=1) + throw conflict("排车实收交接历史未保存"); + } + /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ private void invalidate(DispatchAggregate row,String reason) { row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java new file mode 100644 index 000000000..d07d57d93 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java @@ -0,0 +1,111 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.transaction.support.*; +import java.time.LocalDateTime; +import java.util.*; + +/** 确认入库时完成原预排实收交接,出货只执行已确认的逐托数量。 */ +@Service +@Transactional(propagation=Propagation.MANDATORY) +public class DispatchInboundReceiptService implements InboundDispatchReceiptGateway { + private final DispatchAggregateRepository repository; + private final DispatchSourceRepository sources; + private final DispatchPickingRepository picking; + private final DispatchPhysicalClaimService physicalClaims; + private final DispatchAggregateService workflow; + private final FleetTripExecutionService fleet; + private final InboundDispatchReceiptGateway.Appointments appointments; + private final Object locksKey=new Object(); + + /** 使用原排车、实物占用及预约边界,不新增库存出库动作。 */ + public DispatchInboundReceiptService(DispatchAggregateRepository repository, DispatchSourceRepository sources, + DispatchPickingRepository picking, + DispatchPhysicalClaimService physicalClaims, DispatchAggregateService workflow, FleetTripExecutionService fleet, + InboundDispatchReceiptGateway.Appointments appointments) { + this.repository=repository;this.sources=sources;this.picking=picking; + this.physicalClaims=physicalClaims;this.workflow=workflow;this.fleet=fleet;this.appointments=appointments; + } + + /** 先车辆父锁、排车主锁和预约锁,再由收货链路锁来源与库存。 */ + @Override public void lock(List planIds) { + var numbers=repository.receiptDispatches(planIds); + @SuppressWarnings("unchecked") var previous=(Set)TransactionSynchronizationManager.getResource(locksKey); + if(previous!=null) { + if(!previous.containsAll(numbers))throw conflict("收货期间关联排车已变化,请刷新后重新确认入库"); + return; + } + if(!numbers.isEmpty())fleet.lockExistingDispatchShells(numbers); + for(var row:repository.findMany(numbers).stream().sorted(Comparator.comparing(com.baiyue.lastleg.dispatch.entity.DispatchAggregate::getId)).toList())repository.lock(row.getDispatchNo()); + appointments.lock(numbers); + TransactionSynchronizationManager.bindResource(locksKey,Set.copyOf(numbers)); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 事务完成后清除本次收货持锁范围。 */ + @Override public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(locksKey); } + }); + } + + /** 实收确认、原排车数量及预约交接随同一收货事务提交或回滚。 */ + @Override public void complete(List planIds, String operator, LocalDateTime at) { + var numbers=repository.receiptDispatches(planIds); + @SuppressWarnings("unchecked") var locked=(Set)TransactionSynchronizationManager.getResource(locksKey); + if(locked==null || !locked.containsAll(numbers))throw conflict("收货前未锁齐原排车,请刷新后重新确认入库"); + for(var number:numbers) { + var row=repository.lock(number); + if(row==null || !"ACTIVE".equals(row.getStatus()))continue; + var items=repository.cargo(row.getId()); + var pending=items.stream().filter(i->planIds.contains(i.getPlanId()) && i.getWarehousePalletId()==null).toList(); + if(pending.isEmpty())continue; + if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接"); + boolean preserveReview=workflow.currentReceiptBasis(row); + var received=new LinkedHashMap(); + for(var item:pending) { + var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources) + .orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库")); + received.put(item,actual); + } + for(var entry:received.entrySet()) { + entry.getKey().setWarehousePalletId(entry.getValue().warehousePalletId()); + entry.getKey().setPackageCount(entry.getValue().packageCount()); + } + for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { + if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0)) + || !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId()) + || !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库"); + } + var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList(); + var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at); + for(var entry:received.entrySet()) { + var item=entry.getKey();var actual=entry.getValue(); + if(sources.materialize(row.getId(),item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1) + throw conflict("原排车托盘占用已变化,入库交接未保存"); + item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); + if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存"); + } + appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList()); + int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()); + if(total!=row.getPackageCount()) { + var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop( + i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key())) + .collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); + fleet.updateDispatchCargo(number,total,stops); + } + row.setPackageCount(total); + row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count())); + workflow.saveReceivedCargo(row,operator,at,preserveReview); + } + } + + /** 来源变化必须使收货交接整体失败,不留下半份运输事实。 */ + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); } +} 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 0272daf1a..e32cfe195 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 @@ -16,8 +16,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; @Service public class DispatchPdaQueryService { @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins; - @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources; - @jakarta.annotation.Resource private DispatchService sourceRules; private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow; /** 共用主流程资格,PDA不维护第二套版本和阶段。 */ public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;} @@ -30,7 +28,7 @@ public class DispatchPdaQueryService { require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0); } - /** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */ + /** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */ private List assemble(Actor actor,List roots) { if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号"); var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId)); @@ -61,8 +59,6 @@ public class DispatchPdaQueryService { var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet()); var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream() .map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList(); - if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null) - sourcePositions=receivedPlanPositions(actor,item,items); if(normalRelabel(item)) { var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList(); if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false; @@ -95,14 +91,6 @@ public class DispatchPdaQueryService { } return List.copyOf(result); } - /** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */ - private List receivedPlanPositions(Actor actor,DispatchItem item,List items) { - var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules); - if(received.isEmpty())return List.of(); - // 只修改本次投影对象,不在GET更新排车、版本或库存。 - item.setPackageCount(received.get().source().packageCount()); - return received.get().positions(); - } /** 仓和操作者均来自受信身份,不接收请求自报。 */ private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} /** 缺失计数不作为业务标识使用。 */ 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 79dd6bc11..9faad5cba 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 @@ -12,7 +12,6 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.*; import java.time.*; @@ -24,10 +23,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ @Service public class DispatchPickingService { - @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchService sourceRules; - @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet; private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution; private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns; @@ -53,7 +50,8 @@ public class DispatchPickingService { var currentItems=repository.cargo(row.getId()); var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在")); if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓"); - materialize(row,item,actor,currentItems); + if(item.getWarehousePalletId()==null)throw conflict("该托盘尚未完成收货交接,请先确认入库后再拣货"); + relabelSources.validateFrozen(row,currentItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),currentItems)); var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC); if(task==null) { task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId()); @@ -103,45 +101,6 @@ public class DispatchPickingService { new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false); commands.append(row.getId(),actor,identity,result);return result; } - /** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */ - private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List items) { - long dispatch=row.getId(); - if(item.getWarehousePalletId()!=null) { - relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return; - } - var received=new LinkedHashMap(); - for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules) - .ifPresent(fact->received.put(candidate,fact)); - if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对"); - // 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。 - for(var entry:received.entrySet()) { - entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId()); - entry.getKey().setPackageCount(entry.getValue().source().packageCount()); - } - relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items)); - for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { - if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化"); - var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId()); - if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0)) - || !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId()) - || !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码"); - } - var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC); - var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList(); - var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at); - for(var entry:received.entrySet()) { - var candidate=entry.getKey();var actual=entry.getValue().source(); - try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");} - catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");} - candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED"); - if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存"); - } - row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum())); - var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop( - i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key())) - .collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); - fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops); - } /** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */ private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());} /** 不可变扫码证据序列化失败随根命令回滚。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java index 2eb896b59..b23211c9d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java @@ -2,45 +2,23 @@ package com.baiyue.lastleg.dispatch.service; import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; import java.util.*; -import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType; -/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */ +/** 预排来源只冻结货物身份;自然实收的逐托箱数在确认入库时由真实库存提供。 */ final class DispatchReceivedPlan { - record Fact(DispatchPalletCandidate source, List positions) { } - - /** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */ - static Optional resolve(String warehouse, DispatchItem item, List items, - DispatchSourceRepository sources, DispatchService rules) { + /** 原客户、计划及目的地保持一致,数量直接取确认入库的逐票实收。 */ + static Optional resolve(String warehouse, DispatchItem item, DispatchSourceRepository sources) { if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null || !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty(); - if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty(); - var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + var rows=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId()); if(rows.size()!=1)return Optional.empty(); var actual=rows.get(0); if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty(); var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); if(original.isEmpty() || !new HashSet<>(original).containsAll( sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty(); - var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection( - SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null)); - if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty(); - for(var member:group.sources()) { - var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null); - if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty(); - // 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。 - if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null) - && (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty(); - } - var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); - if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse) - || !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0 - || p.allocatedPackages()!=0 || p.pickedPackages()!=0) - || stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty(); - return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0) - .map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList())); + return Optional.of(actual); } /** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java new file mode 100644 index 000000000..18257f5bd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import java.time.LocalDateTime; +import java.util.List; + +/** 收货确认在原事务内交接真实托盘,预排估算不进入出货执行。 */ +public interface InboundDispatchReceiptGateway { + /** 收货锁来源前,按固定顺序锁齐已有车辆、排车及预约。 */ + void lock(List planIds); + + /** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */ + void complete(List planIds, String operatorId, LocalDateTime at); + + /** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */ + record Item(long itemId, String customerId, long planPalletId, int packages) { } + + /** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */ + interface Appointments { + /** 在库存来源锁之前锁齐全部原预约。 */ + void lock(List dispatchNumbers); + + /** 逐票采用已确认实收,禁止对多批次来源猜测分配。 */ + void complete(long dispatchId, List items); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index ac1bc3d3a..dbd9b4615 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -37,6 +37,13 @@ public class PdaWorkflowRepository { @Resource private InboundPlanCartonMapper cartons; @Resource private LastlegOrderMapper orders; + /** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */ + public InboundPlan findPlan(String customerId, String planNo) { + return plans.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo)); + } + + /** 父运输锁完成后锁定原客户入仓计划。 */ public InboundPlan lockPlan(String customerId, String planNo) { return plans.selectOne(new LambdaQueryWrapper() .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE")); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 2f955b56b..bcd469f2a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -55,6 +55,7 @@ public class InboundPdaWorkflowService { @Resource private PdaWorkflowRepository repository; @Resource private WarehouseResourceGateway warehouse; @Resource private InboundStockReceiptService stockReceipts; + @Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private LastlegOrderMapper orders; @Resource private UnloadPhotoFileGateway unloadPhotoFiles; @@ -544,16 +545,21 @@ public class InboundPdaWorkflowService { return result; } + /** 单票确认入库也先锁运输父记录,再复用整柜实收交接事务。 */ @Transactional public PdaResponses.Task complete(String customerId, String planNo) { + InboundPlan original = repository.findPlan(customerId, planNo); + if (original == null) throw conflict("入仓计划不存在"); + dispatchReceipts.lock(List.of(original.getId())); InboundPlan plan = requirePlan(customerId, planNo); return completeContainer(List.of(plan)).get(0); } - /** Completes every plan of one physical container and emits exactly one container-level outbox event. */ + /** 整柜确认实收、库存及原预排交接,并且仅发布一次柜级卸货事件。 */ @Transactional public List completeContainer(List containerPlans) { if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); + dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList()); List orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList(); String containerNo = orderedPlans.get(0).getContainerNo(); if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan -> @@ -569,6 +575,8 @@ public class InboundPdaWorkflowService { LocalDateTime now = LocalDateTime.now(); for (CompletionContext context : contexts) completePlan(context.plan().getCustomerId(), context, now); + // 实收库存和原预排一起交接,出货时不再补写数量。 + dispatchReceipts.complete(orderedPlans.stream().map(InboundPlan::getId).toList(), contexts.get(0).task().getOperatorId(), now); String sourceContainerBookingId = sourceContainerBookingId(contexts); LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt()) .max(LocalDateTime::compareTo).orElseThrow(); @@ -587,6 +595,7 @@ public class InboundPdaWorkflowService { public void completeContainerWithDifferences(List containerPlans, List reconciledPlans) { if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); + dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList()); Set reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet()); LocalDateTime now = LocalDateTime.now(); List allTasks = new ArrayList<>(); @@ -606,6 +615,7 @@ public class InboundPdaWorkflowService { allTasks.add(task); } } + if (!reconciledPlans.isEmpty()) dispatchReceipts.complete(reconciledPlans.stream().map(InboundPlan::getId).toList(), allTasks.get(0).getOperatorId(), now); String containerNo = containerPlans.get(0).getContainerNo(); if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) { throw conflict("Inbound plans must belong to one physical container"); @@ -678,6 +688,7 @@ public class InboundPdaWorkflowService { return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons); } + /** 按确认的逐托实收入库,完成状态与运输交接属于同一事务。 */ private void completePlan(String customerId, CompletionContext context, LocalDateTime now) { InboundPlan plan = context.plan(); InboundPdaTask task = context.task(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index d1ffba223..949763110 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -84,6 +84,7 @@ public class ReceivingContainerService { "一个托盘最多只能收66箱,请拆分到其他托盘"; @Resource private ReceivingCandidateRepository candidateRepository; + @Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts; @Resource private InboundPlanMapper plans; @Resource private InboundPlanLineMapper lines; @Resource private InboundPlanPalletMapper pallets; @@ -594,11 +595,15 @@ public class ReceivingContainerService { return result; } + /** 确认拆柜入库前先锁原运输安排;实际数量在本次收货事务完成交接。 */ @Transactional(isolation = Isolation.READ_COMMITTED) public ReceivingContainerResponses.Session complete(String operatorKey, ReceivingContainerRequests.Complete request) { ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + // 在收货会话、订单及库存来源加锁之前,先锁齐原运输父记录。 + ReceivingContainerSession original = requireCurrent(operatorKey, false); + dispatchReceipts.lock(requirePlans(original.getSourceContainerBookingId(), original.getContainerNo()).stream().map(InboundPlan::getId).toList()); ReceivingContainerSession session = requireSigned(operatorKey); List containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo()); Totals totals = totals(session, containerPlans); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java new file mode 100644 index 000000000..144d78424 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.AppointmentSourceService; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.conflict; + +/** 实收沿原逐票预约归属更新数量,绝不按托数均分货物或新选批次。 */ +@Component +@Transactional(propagation=Propagation.MANDATORY) +public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceiptGateway.Appointments { + private final AppointmentReceiptRepository repository; + private final AppointmentWorkDispatchBinding binding; + private final InboundFbaAppointmentService appointments; + private final AppointmentSourceService sources; + + /** 复用原批次锁和真实来源;收货记录不依赖预约是否已满足出货资格。 */ + public AppointmentReceiptDispatchAdapter(AppointmentReceiptRepository repository, AppointmentWorkDispatchBinding binding, + InboundFbaAppointmentService appointments, AppointmentSourceService sources) { + this.repository=repository;this.binding=binding;this.appointments=appointments;this.sources=sources; + } + + /** 收货前按业务批次顺序锁齐原有效预约,避免库存与预约反向取锁。 */ + @Override public void lock(List numbers) { + var versions=new TreeMap(); + for(var number:numbers) for(var row:repository.bindings(number)) + if(!row.batchKey().startsWith("DISPATCH:"))versions.computeIfAbsent(row.batchKey(),this::version); + binding.lockBatches(versions); + } + + /** 实收来源必须与原绑定唯一对应,多预约分摊不明确时拒绝猜测。 */ + @Override public void complete(long dispatch, List items) { + var previous=repository.bindings(dispatch); + for(var item:items) { + var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList(); + var physical=repository.physical(item.planPalletId(),item.customerId()); + var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); + var quantities=new LinkedHashMap,Integer>(); + for(var part:physical) { + if(part.countedPackages()==null || part.countedPackages()<=0)throw conflict("收货逐票实物箱数尚未确认"); + var key=List.of(AppointmentWorkPhysicalSources.ref(canonical,part).sourceKey(),part.destinationKey()); + quantities.merge(key,part.countedPackages(),Math::addExact); + } + var originalKeys=new HashSet>(); + for(var row:original)originalKeys.add(List.of(row.sourceKey(),row.destinationKey())); + if(!quantities.keySet().equals(originalKeys))throw conflict("实收货物来源与原排车预约不一致,请核对原逐票安排"); + if(quantities.values().stream().mapToLong(Integer::longValue).sum()!=item.packages())throw conflict("逐票实收与托盘确认箱数不一致"); + for(var entry:quantities.entrySet()) { + var matches=original.stream().filter(r->r.sourceKey().equals(entry.getKey().get(0)) && r.destinationKey().equals(entry.getKey().get(1))).toList(); + if(matches.size()!=1)throw conflict("同一实收来源关联多个预约批次,请明确逐票分配,不能自动均分"); + var row=matches.get(0); + if(!row.customerId().equals(item.customerId()) || row.palletId()!=item.planPalletId())throw conflict("原预约客户或托盘归属已变化"); + if(row.packages()!=entry.getValue() && repository.receiveQuantity(row,entry.getValue())!=1)throw conflict("原预约逐托分配已变化,收货交接未保存"); + } + } + binding.receiptQuantitiesChanged(); + } + + /** 使用同事务已锁定的原预约版本,不沿用客户端旧快照。 */ + private long version(String batch) { + var value=appointments.batchContext(batch).appointment().version(); + if(value==null || value<0)throw conflict("原预约批次版本缺失,无法确认收货交接"); + return value.longValue(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptRepository.java new file mode 100644 index 000000000..974413588 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptRepository.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.work; + +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 收货交接只读取原排车预约绑定和已确认逐票来源。 */ +@Repository +public class AppointmentReceiptRepository { + private final AppointmentWorkDispatchMapper mapper; + + /** 复用权威预约与托盘分配Mapper。 */ + public AppointmentReceiptRepository(AppointmentWorkDispatchMapper mapper) { this.mapper=mapper; } + + /** 读取本车已选择的批次,不选择新预约。 */ + public List bindings(String number) { return mapper.bindings(number); } + + /** 读取原车逐托绑定,供实收来源逐项匹配。 */ + public List bindings(long id) { return mapper.byDispatch(id); } + + /** 真实逐票数量以收货确认的分配记录为准。 */ + public List physical(long pallet, String customer) { return mapper.physical(pallet,customer); } + + /** 原绑定身份及旧数量同时参与更新,防止覆盖并发改量。 */ + public int receiveQuantity(AppointmentWorkDispatchMapper.Bound original,int actual) { return mapper.receiveQuantity(original,actual); } +} 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 3694a66d6..aa4a8f040 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 @@ -270,6 +270,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车"); if(!physical.isEmpty())throw conflict("FBA货物须先预约成功并明确送仓时间,再加入排车"); } + /** 实收只更新原逐票绑定后,仍在业务提交末尾刷新工作视图。 */ + void receiptQuantitiesChanged() { invalidateAtBusinessEnd(); } + /** 调用方已锁主单与原批次,删除只作用于指定实物。 */ public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); } /** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index a1cd445df..bf0d22b64 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -135,5 +135,14 @@ public interface AppointmentWorkDispatchMapper { @Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List trips(String batch); @Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}") List bindings(String number); + + /** 实收只改原绑定箱数,原客户、实物、来源及预约批次必须全部一致。 */ + @Update(""" + UPDATE ll_dispatch_appointment_allocation SET package_count=#{actual} + WHERE dispatch_id=#{row.dispatchId} AND dispatch_item_id=#{row.itemId} AND plan_pallet_id=#{row.palletId} + AND batch_key=#{row.batchKey} AND customer_id=#{row.customerId} AND source_key=#{row.sourceKey} + AND destination_key=#{row.destinationKey} AND package_count=#{row.packages} + """) + int receiveQuantity(@Param("row") Bound original,@Param("actual") int actual); @Select("SELECT dispatch_id,dispatch_item_id item_id,plan_pallet_id pallet_id,batch_key,customer_id,source_key,destination_key,package_count packages FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") List byDispatch(long id); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java new file mode 100644 index 000000000..b51261679 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java @@ -0,0 +1,211 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchPickingRepository; +import com.baiyue.lastleg.inboundplan.dto.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import com.baiyue.lastleg.inboundplan.receiving.*; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.inboundplan.work.*; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */ +class DispatchInboundReceiptPersistenceTest { + /** 直接读取9+11实收,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */ + @Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception { + var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var db=new org.springframework.jdbc.core.JdbcTemplate(ds); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,status VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,pallet_no VARCHAR,delivery_address VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT)"); + db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'COMPLETED')"); + db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8')"); + for(int n=1;n<=2;n++) { + db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,1,'101',?,?,NULL)",n,500+n,"P-"+n); + db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?)",n,n==1?9:11); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchSourceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet(); + var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class); + assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9); + assertThat(mapper.confirmedPlan("101",2).get(0).packageCount()).isEqualTo(11); + assertThat(mapper.confirmedPlan("other",1)).isEmpty(); + db.update("UPDATE ll_inbound_plan SET status='RECEIVING'"); + assertThat(mapper.confirmedPlan("101",1)).isEmpty(); + } + + /** 入库确认立即将10+10交接为9+11,尚未扫码也已核平预约;出货不再补改数量。 */ + @Test void receiptConfirmsNineAndElevenBeforeAnyPickingAndBothPalletsCanLeave() throws Exception { + var f=new Fixture();f.confirm(); + assertThat(f.quantities()).containsExactly(9,11); + assertThat(f.allocations()).containsExactly(9,11); + assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero(); + assertThat(f.p.f.db.queryForObject("SELECT picked_package_count FROM ll_dispatch_order",Integer.class)).isZero(); + verifyNoInteractions(f.p.inventory); + var before=f.version(); + var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(before,"receipt-nine",11L,9,0,null,null))); + assertThatCode(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).doesNotThrowAnyException(); + var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"receipt-eleven",12L,11,0,null,null))); + assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue(); + assertThat(f.binding.departureBlockReason(f.p.number)).isNull(); + assertThat(f.quantities()).containsExactly(9,11); + assertThat(f.allocations()).containsExactly(9,11); + verify(f.p.inventory,times(2)).ship(eq("101"),eq("picker"),any()); + } + + /** 重复交接不修改版本、重复占用托盘或新增收货历史。 */ + @Test void confirmedReceiptIsIdempotent() throws Exception { + var f=new Fixture();f.confirm();long version=f.version(); + f.p.f.tx.executeWithoutResult(s->{f.receipt.lock(List.of(1L,2L));f.receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now());}); + assertThat(f.version()).isEqualTo(version); + assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='RECEIVING_COMPLETE'",Integer.class)).isEqualTo(1); + } + + /** 已因其他事实变化失效的审核,不能在确认入库时被重新放行。 */ + @Test void receiptDoesNotApproveAnAlreadyStaleReview() throws Exception { + var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash='stale-review'"); + f.confirm(); + assertThat(f.p.f.db.queryForObject("SELECT review_basis_hash FROM ll_dispatch_order",String.class)).isEqualTo("stale-review"); + assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("审核依据失效"); + } + + /** 仓库预约回复尚待处理不改变实收数量,收货成功后出货门禁仍明确阻止发运。 */ + @Test void receivingConfirmsFactsWhileAppointmentStillBlocksShipping() throws Exception { + var f=new Fixture();when(f.receiving.hasPending("FBA_APPOINTMENT:1","101")).thenReturn(true); + f.confirm(); + assertThat(f.quantities()).containsExactly(9,11); + assertThat(f.allocations()).containsExactly(9,11); + assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("待仓库确认"); + } + + /** 未关联有效排车的正常收货也可完成,不能要求存在车辆或预约。 */ + @Test void receiptWithoutActiveDispatchDoesNotRequireTransportResources() throws Exception { + var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET status='CANCELLED'"); + assertThatCode(f::confirm).doesNotThrowAnyException(); + assertThat(f.quantities()).containsExactly(10,10); + } + + /** 客户仓沿本车逐票绑定交接9+11,首托扫码递增公共版本不能使第二托审核失效。 */ + @Test void privateWarehouseReceiptAndBothPicksKeepTheSameReview() throws Exception { + var f=new Fixture();long dispatch=f.p.f.repository.find(f.p.number).getId(); + f.p.f.db.update("UPDATE ll_dispatch_item SET destination_type='PRIVATE'"); + f.p.f.db.update("UPDATE ll_inbound_plan_line SET destination_type='PRIVATE'"); + f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET batch_key=?,destination_key='CUSTOMER:9001'","DISPATCH:"+dispatch); + f.p.f.candidates.values().forEach(c->c.destinationType("PRIVATE")); + f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number))); + f.confirm(); + var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(f.version(),"private-nine",11L,9,0,null,null))); + var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"private-eleven",12L,11,0,null,null))); + assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue(); + assertThat(f.allocations()).containsExactly(9,11); + } + + /** 预约逐票交接失败时,收货数量、库存及排车实托绑定必须整体回滚。 */ + @Test void ambiguousBatchAllocationRollsBackTheWholeReceipt() throws Exception { + var f=new Fixture(); + f.p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_id,dispatch_item_id,plan_pallet_id,'FBA_APPOINTMENT:2',customer_id,source_key,destination_key,1 FROM ll_dispatch_appointment_allocation WHERE plan_pallet_id=1"); + f.p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(2,0,'AP-2')"); + when(f.appointments.batchContext("FBA_APPOINTMENT:2")).thenReturn(new FbaAppointmentBatchContext(f.snapshot,List.of(),List.of(),List.of(),List.of(),List.of())); + assertThatThrownBy(f::confirm).hasMessageContaining("不能自动均分"); + assertThat(f.quantities()).containsExactly(10,10); + assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Integer.class)).isZero(); + assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); + assertThat(f.p.f.db.queryForObject("SELECT SUM(counted_package_count) FROM ll_inbound_plan_pallet_allocation",Integer.class)).isZero(); + } + + /** 不能借收货确认接管其他排车已经占用的实托。 */ + @Test void occupiedPhysicalPalletRejectsReceiptWithoutPartialUpdates() throws Exception { + var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501"); + assertThatThrownBy(f::confirm).hasMessageContaining("占用"); + assertThat(f.quantities()).containsExactly(10,10); + assertThat(f.allocations()).containsExactly(10,10); + } + + /** 单客户同票两托的真实预约提供者,避免用恒定放行的预约Mock掩盖数量冲突。 */ + private static final class Fixture { + final DispatchPickingPersistenceTest.Picking p=new DispatchPickingPersistenceTest.Picking(true); + final InboundFbaAppointmentService appointments=mock(InboundFbaAppointmentService.class); + final FbaAppointmentResponse snapshot=mock(FbaAppointmentResponse.class); + final FbaReceivingStore receiving=mock(FbaReceivingStore.class); + final AppointmentWorkDispatchBinding binding; + final DispatchInboundReceiptService receipt; + + /** 预排估算为10+10,收货输入固定9+11,来源及预约均保持同一票。 */ + Fixture() throws Exception { + p.receivedPlans(9,11); + p.f.db.update("UPDATE ll_dispatch_item SET customer_id='101',order_id=9001,destination_type='FBA',fba_warehouse_code='ONT8'"); + p.f.db.update("UPDATE ll_inventory_position SET customer_id='101',available_packages=0"); + p.f.db.update("UPDATE ll_inbound_stock_receipt SET customer_id='101'"); + p.f.db.update("UPDATE ll_dispatch_order SET estimated_arrival_at=DATEADD('HOUR',1,planned_departure_at)"); + p.f.candidates.get(2L).customerId("101");p.f.candidates.get(2L).orderId(9001L);p.f.candidates.get(2L).fbaWarehouseCode("ONT8"); + p.f.candidates.get(2L).destinationWarehouseCode("ONT8"); + doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L); + doReturn(true).when(p.sources).receivedPlanCompleted("101",2L); + for(long id=1;id<=2;id++) { + doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id); + doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id); + doReturn(id).when(p.sources).lockPlan("101",id); + doReturn(List.of(p.f.candidates.get(id))).when(p.sources).confirmedPlan("101",id); + doReturn(1).when(p.sources).materialize(anyLong(),eq("101"),eq(id),eq(500+id),anyLong(),any()); + } + p.f.db.execute("ALTER TABLE ll_inbound_plan_line ADD (destination_type VARCHAR(20),fba_warehouse_code VARCHAR(20))"); + p.f.db.execute("ALTER TABLE ll_inbound_plan_pallet_allocation ADD (customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)"); + p.f.db.update("INSERT INTO ll_inbound_plan_line(id,order_id,customer_id,destination_type,fba_warehouse_code) VALUES(1,9001,'101','FBA','ONT8')"); + for(long id=1;id<=2;id++) { + p.f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,0)",id); + p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_order_id,id,plan_pallet_id,'FBA_APPOINTMENT:1','101','ORDER:9001','FBA:ONT8',10 FROM ll_dispatch_item WHERE plan_pallet_id=?",id); + } + p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(1,0,'AP-1')"); + // 与生产一致,排车和预约Mapper共用同一SqlSession,更新后一起失效本地查询缓存。 + p.f.session.getConfiguration().addMapper(AppointmentWorkDispatchMapper.class); + var mapper=p.f.session.getMapper(AppointmentWorkDispatchMapper.class); + var sources=mock(AppointmentSourceService.class); + when(sources.canonicalOrders(anyList())).thenAnswer(call->{List refs=call.getArgument(0);return refs.stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList();}); + when(snapshot.version()).thenReturn(0);when(snapshot.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(snapshot.replacementBatchKeys()).thenReturn(List.of()); + var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn("101");when(item.sourceKey()).thenReturn("ORDER:9001");when(item.destinationKey()).thenReturn("FBA:ONT8");when(item.packageCount()).thenReturn(20);when(snapshot.items()).thenReturn(List.of(item)); + when(appointments.batchContext("FBA_APPOINTMENT:1")).thenReturn(new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.of(),List.of(),List.of())); + var details=new FbaAppointmentDetails(null,null,null,"CONF-1",LocalDateTime.now(ZoneOffset.UTC).plusDays(3),null,"SUCCESS",null,null,null); + var member=new FbaReceivingContracts.Member("FBA_APPOINTMENT:1","AP-1",0,"101",List.of(new FbaReceivingContracts.Source("ORDER:9001","FBA:ONT8",20,"v1",1,"IN-1")),null); + when(receiving.currentAgreement("FBA_APPOINTMENT:1","101")).thenReturn(new FbaReceivingContracts.Agreement("AG-1",0,"ACTIVE","FBA:ONT8",details,"UTC",List.of(member),"确认收货")); + binding=new AppointmentWorkDispatchBinding(mapper,appointments,sources,mock(AppointmentWorkChanges.class),receiving,null); + var bridge=new AppointmentReceiptDispatchAdapter(new AppointmentReceiptRepository(mapper),binding,appointments,sources); + var picking=(DispatchPickingRepository)ReflectionTestUtils.getField(p.service,"picking"); + receipt=new DispatchInboundReceiptService(p.f.repository,p.sources,picking,(DispatchPhysicalClaimService)ReflectionTestUtils.getField(p.f.service,"physicalClaims"),p.f.service,p.f.fleet,bridge); + ReflectionTestUtils.setField(p.f.service,"appointments",new DispatchAppointmentAllocations(binding)); + p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(p.f.service,"basis",p.f.repository.find(p.number))); + doAnswer(call->{var pick=(com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick)call.getArgument(1);long receiptId=pick.getInventoryPositionId()==11?1:2; + p.f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,'101',?,9001,?,?,0)",pick.getId(),pick.getId(),receiptId,pick.getPackages());return List.of(new com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService.OrderPackages(9001L,pick.getPackages())); + }).when(p.attribution).attribute(eq("101"),any(),isNull()); + } + + /** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */ + void confirm() { + p.f.tx.executeWithoutResult(s->{ + receipt.lock(List.of(1L,2L)); + p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END"); + p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END"); + receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC)); + }); + } + + /** 读取持久化实收箱数,不能只检查响应投影。 */ + List quantities() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY plan_pallet_id",Integer.class); } + /** 读取实际预约分配,覆盖本次遗漏的第二套账本。 */ + List allocations() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_appointment_allocation ORDER BY plan_pallet_id",Integer.class); } + /** 后续扫码必须使用收货交接后的公共版本。 */ + long version() { return p.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index 0efae0f2d..ed245b2eb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -259,7 +259,7 @@ class DispatchMergedRelabelPersistenceTest { ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway.class)); ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins); picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json); - ReflectionTestUtils.setField(picking,"physicalClaims",ReflectionTestUtils.getField(f.service,"physicalClaims"));ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots); + ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots); pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins); balance(601,"101",5);position(101,601,"101",crossCustomer?2:5); if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);} 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 6eab729cc..7a124ba99 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 @@ -34,44 +34,16 @@ class DispatchPickingPersistenceTest { 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 { + /** 未在收货事务完成交接的原计划不能在出货时临时修数量。 */ + @Test void pickingCannotMaterializeAnUnconfirmedReceipt() throws Exception { var fixture=new Picking(true);fixture.receivedPlans(9,11); - fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501"); assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, - new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null)))) - .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用"); - verify(fixture.inventory,never()).ship(any(),any(),any()); + new DispatchCommands.Pick(fixture.version,"receipt-not-confirmed",11L,9,0,null,null)))) + .hasMessageContaining("收货交接"); + verifyNoInteractions(fixture.inventory); assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); } - /** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */ - @Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception { - var fixture=new Picking(true);fixture.receivedPlans(9,11); - var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null); - var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest)); - assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11); - assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20); - assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED"); - var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest)); - assertThat(replay.replayed()).isTrue(); - var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem, - new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null))); - assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue(); - verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any()); - assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); - } - /** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */ - @Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception { - var fixture=new Picking(true);fixture.receivedPlans(8,11); - doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any()); - assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, - new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class); - assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); - assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero(); - assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20); - assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version); - } /** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */ @Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception { var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码"); @@ -113,7 +85,7 @@ class DispatchPickingPersistenceTest { } /** 真主单和实际MyBatis写入;仅模拟两个独立应用命令提供者,未连接真实环境。 */ - private static final class Picking { + static final class Picking { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class); final DispatchPickingService service;final String number;final long item,version;final Long secondItem; @@ -147,8 +119,7 @@ class DispatchPickingPersistenceTest { sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class))); var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json); - for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); - org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet); + for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); } /** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */ void receivedPlans(int first,int second) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index 9fcc6d86a..d116164cb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -33,62 +33,30 @@ class DispatchPlanPdaReadPersistenceTest { assertThat(mapper.receivedPlanCompleted("101",3L)).isZero(); } } - /** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */ - @Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception { - var f=new Fixture();var before=f.dbState(); - var task=f.read(); - assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L); - assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3); - assertThat(f.dbState()).isEqualTo(before); - verify(f.sources).stock("101",501L,false); - verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any()); - verify(f.rules,never()).lockExecutionSources(anyString(),anyList()); - } - /** 在途和来源身份变化均不成为可拣库存。 */ - @Test void unreceivedOrChangedPlanHasNoPositions() throws Exception { - var f=new Fixture();var row=f.f.candidates.get(1L); - row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty(); - row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty(); - row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty(); - verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); - } - /** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */ - @Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception { + /** PDA查询只读取确认入库时已经交接的事实,不在GET补写或投影修正计划数量。 */ + @Test void pendingReceiptDoesNotExposeStockOrReplaceEstimates() throws Exception { var f=new Fixture(true);var before=f.dbState(); var task=f.read(); + assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(10L,10L); + assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isEmpty()); + assertThat(f.dbState()).isEqualTo(before); + verifyNoInteractions(f.sources,f.rules); + } + + /** 收货已保存9+11时,PDA直接显示9+11并提供实际托盘仓位。 */ + @Test void confirmedReceiptUsesPersistedNineAndEleven() throws Exception { + var f=new Fixture(true); + for(int n=1;n<=2;n++) { + int count=n==1?9:11; + f.f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=?,source_stage='RECEIVED',package_count=? WHERE plan_pallet_id=?",500+n,count,n); + f.f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages) VALUES(?,'101',?,?,'9',?,0,0,0)",10+n,20+n,500+n,count); + } + var before=f.dbState();var task=f.read(); assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L); - assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty()); - assertThat(task.counts().packageCount()).isEqualTo(20); + assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).hasSize(1)); assertThat(f.dbState()).isEqualTo(before); } - /** 原计划不能吸收新的订单或未经原车审核的同托来源。 */ - @Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception { - var f=new Fixture(); - when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L)); - assertThat(f.read().items().get(0).positions()).isEmpty(); - when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L)); - when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup( - "group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( - null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of())); - assertThat(f.read().items().get(0).positions()).isEmpty(); - verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); - } - /** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */ - @Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception { - var f=new Fixture(); - for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1), - new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1), - new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) { - when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock)); - assertThat(f.read().items().get(0).positions()).isEmpty(); - } - } - /** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */ - @Test void receivingPlanDoesNotExposePartialPallet() throws Exception { - var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false); - assertThat(f.read().items().get(0).positions()).isEmpty(); - verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); - } + /** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */ private static final class Fixture { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); @@ -109,7 +77,6 @@ class DispatchPlanPdaReadPersistenceTest { var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet(); queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service); - ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules); when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L))); when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true); when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java index ac10851af..99ee3f048 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java @@ -33,11 +33,13 @@ class InboundPdaContainerCompletionTest { @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService outbox; @Mock LastlegOrderMapper orders; + @Mock com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts; private InboundPdaWorkflowService service; @BeforeEach void setUp() { service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "dispatchReceipts", dispatchReceipts); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); @@ -74,6 +76,14 @@ class InboundPdaContainerCompletionTest { any(Instant.class), any(Instant.class), anyLong()); verify(repository, times(2)).update(any(InboundPlan.class)); verify(repository, times(2)).update(any(InboundPdaTask.class)); + // 运输父锁必须早于收货来源锁;两票库存落账后才交接原排车,并且只交接一次。 + var sequence=org.mockito.Mockito.inOrder(dispatchReceipts,repository,stockReceipts); + sequence.verify(dispatchReceipts).lock(List.of(2L,1L)); + sequence.verify(repository).lockTask("100",1L); + sequence.verify(stockReceipts).stockIn(eq("100"),any(),any(),any(),any(),any(),any()); + sequence.verify(stockReceipts).stockIn(eq("200"),any(),any(),any(),any(),any(),any()); + sequence.verify(dispatchReceipts).complete(eq(List.of(1L,2L)),org.mockito.ArgumentMatchers.isNull(),any()); + verify(dispatchReceipts,times(1)).complete(any(),any(),any()); } private static InboundPlan plan(long id, String planNo) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 2e002b88a..dc8fe4a36 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -71,6 +71,7 @@ class InboundPdaWorkflowServiceTest { @BeforeEach void setUp() { service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class)); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); @@ -91,6 +92,7 @@ class InboundPdaWorkflowServiceTest { source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2); lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan); + lenient().when(repository.findPlan("100", "LIP1")).thenReturn(plan); LastlegOrder sourceOrder = new LastlegOrder(); sourceOrder.setId(1001L); sourceOrder.setSourceContainerBookingId("BOOKING-1"); lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 4f2d8ac9e..e35c6aef8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -83,6 +83,7 @@ class ReceivingContainerServiceBehaviorTest { @BeforeEach void setUp() { service = new ReceivingContainerService(); + ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class)); ReflectionTestUtils.setField(service, "candidateRepository", candidateRepository); org.mockito.Mockito.lenient().when(candidateRepository.currentSession(any())).thenAnswer(invocation -> sessions.findActiveByOperator(invocation.getArgument(0))); @@ -1166,6 +1167,7 @@ class ReceivingContainerServiceBehaviorTest { order.setContainerNo(containerNo); order.setSourceContainerBookingId("BOOKING-A"); order.setInboundCode("IN-1"); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); + org.mockito.Mockito.lenient().when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(line)); org.mockito.Mockito.lenient().when(orders.selectList(any())).thenReturn(List.of(order)); From 9e0266b7c356049596a62bd4c5beb173c3ad8bd0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 16:43:46 +0800 Subject: [PATCH 07/30] fix(booking): allow release without estimated arrival --- firstleg/docs/business-index.md | 2 +- .../service/OceanBookingService.java | 3 ++- .../resources/openapi/ocean-bookings-v1.yaml | 2 +- .../service/OceanBookingServiceTest.java | 20 +++++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index e4ef56b22..da93dbc67 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -80,7 +80,7 @@ - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 -- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 +- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港为可选计划,不作为放舱必填项;填写时不得早于预计开船时间。 - 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index f55a9770b..c7153d1ec 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -262,12 +262,13 @@ public class OceanBookingService { } } private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } + /** 放舱核对必要运输资料;预计到港为可选计划,不阻断放舱。 */ private static void validateRelease(OceanBookingEntity booking, List parties, List requirements) { if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null || !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName()) || !StringUtils.hasText(booking.getVoyageNo()) || booking.getEstimatedSailingAt() == null - || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null + || booking.getShippingCompanyId() == null || (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode())) || (!StringUtils.hasText(booking.getDestinationPortName()) && !StringUtils.hasText(booking.getDestinationPortCode())) || !StringUtils.hasText(booking.getPlaceOfDelivery()) diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 187ad0953..48ef6a28b 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -200,7 +200,7 @@ components: destinationPortName: {type: string, nullable: true} placeOfDelivery: {type: string, nullable: true} estimatedSailingAt: {type: string, format: date-time, nullable: true} - estimatedArrivalAt: {type: string, format: date-time, nullable: true} + estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 可选预计到港时间;放舱不要求填写,填写时不得早于预计开船时间} remark: {type: string, nullable: true} freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID} serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index d40fb7097..edfa3e646 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -231,6 +231,26 @@ class OceanBookingServiceTest { verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin"); } + /** 不录预计到港仍可放舱,其余资料及附件门禁保持有效。 */ + @Test + void releasesWithoutEstimatedArrival() { + stubMasterData(); + OceanBookingEntity draft = completeBooking("DRAFT", 3); + OceanBookingEntity released = completeBooking("RELEASED", 4); + draft.setEstimatedArrivalAt(null); + released.setEstimatedArrivalAt(null); + when(repository.find(1L)).thenReturn(draft, released); + when(repository.parties(1L)).thenReturn(parties()); + when(repository.requirements(1L)).thenReturn(List.of(requirement())); + when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1); + + Detail result = service.release(1L, 9L, "admin", "release-no-arrival", new ReleaseRequest(3, "确认放舱")); + + assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED"); + assertThat(result.oceanBooking().getEstimatedArrivalAt()).isNull(); + verify(documents).requireActiveValidDocument(draft); + } + @Test void rejectsCarrierSnapshotMismatchAndUnassociatedBookingAgent() { stubMasterData(); From bfeeef130e85381f9f38941f5b09f4b3c6a5af60 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 18:03:45 +0800 Subject: [PATCH 08/30] fix(appointments): expose dispatch cabinet short numbers --- .../trip/FleetTripCalendarRepository.java | 6 +-- .../fleet/trip/FleetTripCalendarService.java | 52 ++++++++++++------- .../lastleg/fleet/trip/FleetTripMapper.java | 8 +-- .../resources/openapi/fleet-trips-v1.yaml | 7 ++- .../trip/FleetTripCalendarMapperTest.java | 17 ++++-- .../trip/FleetTripCalendarServiceTest.java | 37 +++++++++---- 6 files changed, 88 insertions(+), 39 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java index 3e877bc27..21f0e00c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Repository; /** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */ @Repository public class FleetTripCalendarRepository { - public record Source(String dispatchNo, String sourceSnapshotJson) { } + public record Source(String dispatchNo, String sourceSnapshotJson, String loadingPayloadJson) { } private final FleetTripMapper mapper; /** 装配现有车次Mapper,日历服务不直接访问SQL。 */ @@ -18,10 +18,10 @@ public class FleetTripCalendarRepository { return mapper.calendar(from, to); } - /** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */ + /** 一次读取指定主单的全部货物与原装柜快照,空窗口不发送空IN查询。 */ public List sourceSnapshots(List dispatchNumbers) { if (dispatchNumbers.isEmpty()) return List.of(); return mapper.calendarSources(dispatchNumbers).stream() - .map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList(); + .map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson(), row.loadingPayloadJson())).toList(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java index caeabfde1..8c70177db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java @@ -19,8 +19,9 @@ public class FleetTripCalendarService { String originWarehouseCode, String originWarehouseName, String originCountryCode, String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, - List stops, List containerNumbers, List incompleteReasons) { } + List stops, List containerNumbers, List cabinetNumbers, List incompleteReasons) { } public record Result(List records, List unresolvedRecords) { } + private record ContainerReferences(List containerNumbers, List cabinetNumbers) { } private static final Set COUNTRIES = Set.of(Locale.getISOCountries()); private final FleetTripCalendarRepository trips; private final FleetWarehouseClient warehouses; @@ -41,7 +42,7 @@ public class FleetTripCalendarService { var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC)); if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围"); if (rows.isEmpty()) return new Result(List.of(),List.of()); - var containers=containerNumbers(rows); + var containers=containerReferences(rows); var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList(); var metadata=metadata(ids); var records=new ArrayList(); var unresolved=new ArrayList(); @@ -64,35 +65,50 @@ public class FleetTripCalendarService { throw new IllegalArgumentException("incomplete route"); stops=List.copyOf(stops); } catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); } + var references=containers.getOrDefault(row.getDispatchNo(),new ContainerReferences(List.of(),List.of())); var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(), warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(), country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()), - instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),List.copyOf(reasons)); + instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,references.containerNumbers(),references.cabinetNumbers(),List.copyOf(reasons)); (unresolvedOrigin?unresolved:records).add(entry); } return new Result(List.copyOf(records),List.copyOf(unresolved)); } - /** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */ - private Map> containerNumbers(List rows) { + /** 完整柜号取排车货物快照,柜次短号取同客户真实来源的原装柜事件,分别去空去重。 */ + private Map containerReferences(List rows) { var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList(); - var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap>(); + var requested=new HashSet<>(numbers); + var containers=new LinkedHashMap>(); + var cabinets=new LinkedHashMap>(); for(var source:trips.sourceSnapshots(numbers)) { if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank()) throw sourceUnavailable(); - try { - var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) - .readTree(source.sourceSnapshotJson()); - if(snapshot==null || !snapshot.isObject())throw sourceUnavailable(); - var value=snapshot.get("containerNo"); - if(value==null || value.isNull())continue; - if(!value.isTextual())throw sourceUnavailable(); - var number=value.textValue().strip(); - if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number); - } catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); } + var container=snapshotReference(source.sourceSnapshotJson(),"containerNo"); + if(container!=null)containers.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(container); + // 非头程来源没有装柜事件,保持短号为空,不从完整柜号或内部ID猜测。 + if(source.loadingPayloadJson()!=null) { + var cabinet=snapshotReference(source.loadingPayloadJson(),"cabinetNo"); + if(cabinet!=null)cabinets.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(cabinet); + } } - var result=new LinkedHashMap>(); - grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result); + var result=new LinkedHashMap(); + for(var number:numbers)result.put(number,new ContainerReferences( + List.copyOf(containers.getOrDefault(number,new LinkedHashSet<>())),List.copyOf(cabinets.getOrDefault(number,new LinkedHashSet<>())))); + return Map.copyOf(result); + } + + /** 只接受正式JSON对象中的文本业务标识,缺失留空,损坏资料明确报错。 */ + private String snapshotReference(String payload,String field) { + try { + var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(payload); + if(snapshot==null || !snapshot.isObject())throw sourceUnavailable(); + var value=snapshot.get(field); + if(value==null || value.isNull())return null; + if(!value.isTextual())throw sourceUnavailable(); + var number=value.textValue().strip(); + return number.isEmpty()?null:number; + } catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); } } /** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java index 391401a36..d335449ba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java @@ -19,12 +19,14 @@ public interface FleetTripMapper extends BaseMapper { ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001 """) java.util.List calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); - record CalendarSource(String dispatchNo, String sourceSnapshotJson) { } - /** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */ + record CalendarSource(String dispatchNo, String sourceSnapshotJson, String loadingPayloadJson) { } + /** 主查询限额后批读货物快照及同客户原装柜事件,柜次短号只沿真实订单关联读取。 */ @Select("""