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("""