This commit is contained in:
2026-10-22 14:37:59 +08:00
parent de96e47d74
commit 760bf8daa1
49 changed files with 2450 additions and 117 deletions
@@ -16,6 +16,12 @@ public interface AppointmentWorkDispatchPort {
record CandidateBatches(List<BatchReference> appointmentBatches,List<String> blockReasons,boolean occupied) { }
/** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */
Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> candidates);
/** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */
Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> 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<AllocationOption> options,List<String> blockReasons) { }
/** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */
java.util.Map<Long,List<BatchReference>> references(List<Long> dispatchIds);
/** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */
@@ -53,6 +53,18 @@ public class DispatchAggregateController {
public ApiResponse<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.Items> 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<com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocations> 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<CommandResult> 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')")
@@ -14,6 +14,14 @@ public final class DispatchWorkModels {
public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List<CargoItem> items) { }
public record Stop(int index,String name,String address,String destinationKey) { }
public record Route(String dispatchNo,long recordVersion,List<Stop> stops,Gate edit) { }
public record AppointmentItem(Long dispatchItemId,Long planPalletId,String customerId,String customerName,String palletNo,
List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.AllocationSource> sources,List<BatchAllocation> allocations) { }
public record AppointmentAllocations(String dispatchNo,long recordVersion,List<AppointmentItem> items,Gate edit) { }
/** 只替换明确选中的本车实物分配,未触及客户和实物保持原关联。 */
public record AppointmentAllocationCommand(Long recordVersion,String idempotencyKey,List<Long> dispatchItemIds,List<BatchAllocation> batchAllocations) {
/** 主版本零合法;保存失败不能用默认版本覆盖后续修改。 */
public AppointmentAllocationCommand {recordVersion=DispatchCommands.requireVersion(recordVersion);}
}
/** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */
public record RouteCommand(Long recordVersion,String idempotencyKey,List<String> destinationKeys,String reason) {
/** 显式零版本合法,缺版本不能覆盖并发变更。 */
@@ -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<BatchReference>();var reasons=new LinkedHashSet<String>();
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<DispatchItem> 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("来源占用未保存");}
@@ -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<AppointmentWorkDispatchPort.ReceivingWindow> reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);}
/** 本车货物已经由主单范围核实,选项不重新选货或制造托盘占用。 */
public Map<AppointmentWorkDispatchPort.CandidatePhysical,List<AppointmentWorkDispatchPort.AllocationSource>> options(long dispatch,List<DispatchItem> 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<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> candidates(List<com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate> rows) {
@@ -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<AppointmentWorkDispatchPort.CandidatePhysical,AppointmentWorkDispatchPort.CandidateBatches> 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<String>();
if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons());
@@ -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<DispatchItem> 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<DispatchItem> items) {
@@ -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<CandidatePhysical,List<AllocationSource>> read(long dispatch,List<CandidatePhysical> 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<AppointmentExternalSourceGateway.OrderRef,AppointmentCoverageService.SourceRef>();
var distinct=orders.stream().filter(p->p.orderId()!=null).distinct().toList();
for(int offset=0;offset<distinct.size();offset+=100)canonical.putAll(AppointmentWorkPhysicalSources.canonical(distinct.subList(offset,Math.min(offset+100,distinct.size())),sources));
var refs=orders.stream().filter(p->p.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.<AppointmentWorkDispatchMapper.CandidateBatch>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<CandidatePhysical,List<AllocationSource>>();
for(var identity:items) {
var rows=new ArrayList<AllocationSource>();
for(var p:physical.stream().filter(p->identity.planPalletId().equals(p.planPalletId()) && identity.customerId().equals(p.customerId())).toList()) {
var reasons=new ArrayList<String>();var options=new ArrayList<AllocationOption>();
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()<p.packages())reasons.add("请先在预约列表确认本票FBA送仓时间及覆盖箱数,再返回刷新");
}
rows.add(new AllocationSource(source,p.orderNo(),p.inboundCode(),p.destinationKey(),p.packages(),List.copyOf(options),List.copyOf(reasons)));
}
if(rows.isEmpty())throw conflict("本车托盘缺少真实逐票货物来源");
result.put(identity,List.copyOf(rows));
}
return result;
}
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical;
import org.springframework.stereotype.Repository;
import java.util.List;
/** 已有排车关联预约的本域只读持久化边界。 */
@Repository
public class AppointmentDispatchAllocationRepository {
private final AppointmentWorkDispatchMapper mapper;
/** 复用权威实物与预约Mapper,不新增分配存储。 */
public AppointmentDispatchAllocationRepository(AppointmentWorkDispatchMapper mapper) {this.mapper=mapper;}
/** 按服务层已核实的本车实物读取逐票业务标识及数量。 */
public List<AppointmentWorkDispatchMapper.AllocationPhysical> physical(long dispatch,List<CandidatePhysical> items) {return mapper.allocationPhysical(dispatch,items);}
/** 同一原批次来源扣除其他有效车辆占用,本车原分配由替换事务重新核对。 */
public List<AppointmentWorkDispatchMapper.CandidateBatch> batches(long dispatch,List<SourceRef> refs,List<AppointmentWorkDispatchMapper.Physical> orders) {
return mapper.candidateBatches(refs,orders,dispatch);
}
}
@@ -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<String,BatchReceiving> snapshots(List<String> keys) {
var unique=keys.stream().distinct().sorted().toList();var result=new HashMap<String,BatchReceiving>();
for(int offset=0;offset<unique.size();offset+=200) for(var snapshot:receiving.snapshots(unique.subList(offset,Math.min(offset+200,unique.size()))))result.put(snapshot.batchKey(),snapshot);
return result;
}
/** 未确认、待仓库回复、时间无效或覆盖身份不唯一时均不可作为排车依据。 */
public int confirmedPackages(BatchReceiving snapshot,String customer,String source,String destination) {
if(snapshot==null || snapshot.pendingChanges().stream().anyMatch(p->"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;}
}
}
@@ -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<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> input) {return candidates.batchReferences(input);}
/** 已有排车的选项读取不受本车原占用影响,也不会修改来源。 */
@Transactional(readOnly=true,propagation=Propagation.SUPPORTS)
public Map<CandidatePhysical,List<AllocationSource>> allocationOptions(long dispatchId,List<CandidatePhysical> items) {return allocationReader.read(dispatchId,items);}
/** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */
public void lockBatches(Map<String,Long> 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(); }
@@ -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<CandidatePhysical,CandidateBatches> batchReferences(List<CandidatePhysical> 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.<AppointmentWorkDispatchMapper.CandidateBatch>of():mapper.candidateBatches(refs,orders);
var batches=refs.isEmpty()?List.<AppointmentWorkDispatchMapper.CandidateBatch>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<CandidatePhysical,CandidateBatches>();
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<String>();
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(available<count) reasons.add("关联批次逐票剩余量不足以覆盖本托实物");
if(available<count) reasons.add("FBA已确认预约及逐票剩余量不足,请先确认送仓时间和本票箱数再排车");
}
if(related && unassigned) reasons.add("托盘含未归属当前预约批次的货物,请先核对逐票分配");
var result=references.entrySet().stream().map(e->new 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<Candidate>();
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));
}
@@ -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("""
<script>
SELECT i.plan_pallet_id,i.customer_id,l.order_id,o.order_no,o.inbound_code,
CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
CASE WHEN i.source_stage='PLAN' THEN
CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END
ELSE CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END END packages
FROM ll_dispatch_item i
JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=i.plan_pallet_id AND a.customer_id=i.customer_id
JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=l.customer_id
WHERE i.dispatch_order_id=#{dispatchId} AND
(<foreach collection='items' item='p' separator=' OR '>(i.plan_pallet_id=#{p.planPalletId} AND i.customer_id=#{p.customerId})</foreach>)
GROUP BY i.plan_pallet_id,i.customer_id,i.source_stage,l.order_id,o.order_no,o.inbound_code,l.destination_type,l.fba_warehouse_code
ORDER BY i.plan_pallet_id,l.order_id LIMIT 20001
</script>
""")
List<AllocationPhysical> allocationPhysical(@Param("dispatchId") long dispatchId,@Param("items") List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical> items);
/** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */
@Select("""
<script>
@@ -43,7 +63,7 @@ public interface AppointmentWorkDispatchMapper {
i.customer_id,i.order_id,i.source_key,CONCAT('FBA:',i.fba_warehouse_code) destination_key,i.package_count packages,
(SELECT COALESCE(SUM(a.package_count),0) FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id
WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id) AND a.customer_id=i.customer_id AND a.source_key=i.source_key
AND a.destination_key=CONCAT('FBA:',i.fba_warehouse_code) AND d.status&lt;&gt;'CANCELLED') allocated
AND a.destination_key=CONCAT('FBA:',i.fba_warehouse_code) AND d.status&lt;&gt;'CANCELLED' AND d.id&lt;&gt;#{excludedDispatchId}) allocated
FROM ll_inbound_fba_appointment_item i JOIN ll_inbound_fba_appointment p ON p.id=i.appointment_id
WHERE i.active=1 AND (p.replacement_keys_json IS NULL OR p.replacement_keys_json='[]') AND
(<foreach collection='refs' item='r' separator=' OR '>(i.customer_id=#{r.customerId} AND i.source_key=#{r.sourceKey} AND CONCAT('FBA:',i.fba_warehouse_code)=#{r.destinationKey})</foreach>
@@ -51,7 +71,7 @@ public interface AppointmentWorkDispatchMapper {
ORDER BY p.id,i.customer_id,i.source_key,i.fba_warehouse_code LIMIT 20001
</script>
""")
List<CandidateBatch> candidateBatches(@Param("refs") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> refs,@Param("orders") List<Physical> orders);
List<CandidateBatch> candidateBatches(@Param("refs") List<com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef> refs,@Param("orders") List<Physical> orders,@Param("excludedDispatchId") long excludedDispatchId);
@Select("""
<script>
SELECT a.dispatch_id,a.batch_key,CASE WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.dispatch_no ELSE p.appointment_no END batch_no,
@@ -1798,6 +1798,53 @@ paths:
required: true
schema:
type: string
/api/platform/v1/last-leg/dispatches/{dispatchNo}/appointment-allocations:
parameters:
- name: dispatchNo
in: path
required: true
schema:
type: string
get:
summary: 读取本车逐票正式预约选项及当前分配
description: 权限 platform:overseas-warehouse:dispatch:list。只读本车真实货物;已确认时段和覆盖来自正式收货预约。本车原占用不扣减本车可重分配量,其他有效车辆占用仍扣减。
security:
- bearerAuth: []
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
$ref: '#/components/schemas/DispatchAppointmentAllocations'
put:
summary: 在原排车关联或调整正式预约分配
description: 权限 platform:overseas-warehouse:dispatch:edit。主单及原批次版本必填,0合法;每个触及实物必须按客户/来源/目的仓完整分配。仅装车前零真实扫码且无有效下游单据,成功增加主版本并使两级审核失效,不新建排车、不改变货物或库存。失败原子回滚;同键同载荷恢复原结果,同键异载荷拒绝。
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/DispatchAppointmentAllocationCommand'
responses:
'200':
description: 统一ApiResponse;业务失败通过code返回。
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data:
$ref: '#/components/schemas/DispatchCommandResult'
/api/platform/v1/last-leg/dispatches/{dispatchNo}/route:
get:
summary: 读取实际起点与既有卸货站序
@@ -6951,6 +6998,94 @@ components:
destinationKey:
type: string
nullable: true
DispatchAppointmentAllocations:
type: object
x-java-record: DispatchWorkModels.AppointmentAllocations
required: [dispatchNo, recordVersion, items, edit]
properties:
dispatchNo: {type: string}
recordVersion:
type: string
pattern: ^[0-9]+$
items:
type: array
items:
$ref: '#/components/schemas/DispatchAppointmentItem'
edit:
$ref: '#/components/schemas/DispatchGate'
DispatchAppointmentItem:
type: object
x-java-record: DispatchWorkModels.AppointmentItem
required: [dispatchItemId, planPalletId, customerId, palletNo, sources, allocations]
properties:
dispatchItemId: {type: string}
planPalletId: {type: string}
customerId: {type: string}
customerName: {type: string, nullable: true}
palletNo: {type: string}
sources:
type: array
items:
$ref: '#/components/schemas/DispatchAppointmentSource'
allocations:
type: array
items:
$ref: '#/components/schemas/DispatchBatchAllocation'
DispatchAppointmentSource:
type: object
required: [sourceKey, destinationKey, options, blockReasons]
properties:
sourceKey: {type: string}
orderNo: {type: string, nullable: true}
inboundCode: {type: string, nullable: true}
destinationKey: {type: string}
packageCount: {type: integer, nullable: true, minimum: 1}
options:
type: array
items:
$ref: '#/components/schemas/DispatchAppointmentOption'
blockReasons:
type: array
items: {type: string}
DispatchAppointmentOption:
type: object
required: [batchKey, batchNo, recordVersion, availablePackages, appointmentAt, timezone]
properties:
batchKey: {type: string}
batchNo: {type: string}
recordVersion:
type: string
pattern: ^[0-9]+$
availablePackages: {type: integer, minimum: 1}
confirmationNo: {type: string, nullable: true}
appointmentAt:
type: string
description: 目的仓当地确认开始时间,须结合timezone解释,不按浏览器时区转换。
appointmentEndAt: {type: string, nullable: true}
timezone: {type: string}
DispatchAppointmentAllocationCommand:
type: object
x-java-record: DispatchWorkModels.AppointmentAllocationCommand
required: [recordVersion, idempotencyKey, dispatchItemIds, batchAllocations]
properties:
recordVersion:
oneOf:
- type: string
pattern: ^[0-9]+$
- type: integer
minimum: 0
idempotencyKey: {type: string}
dispatchItemIds:
type: array
minItems: 1
maxItems: 1000
uniqueItems: true
items: {type: string}
batchAllocations:
type: array
maxItems: 1000
items:
$ref: '#/components/schemas/DispatchBatchAllocation'
DispatchRoute:
type: object
x-java-record: DispatchWorkModels.Route
@@ -20,7 +20,7 @@ import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
class AppointmentWorkPreArrangementPersistenceTest {
@Test void siblingPlanChangeCannotGrantOrRemovePhysicalPreArrangementAndNeverAuthorizesDeparture() {
@Test void siblingPlanChangeNeverAuthorizesUnconfirmedFbaCargoToEnterDispatch() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var sources=context.getBean(AppointmentSourceService.class);
var cargo=List.of(source("B",1,60),source("D",2,40));
@@ -40,17 +40,17 @@ class AppointmentWorkPreArrangementPersistenceTest {
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D");
var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,record_version,status) VALUES(20,'DP-B',0,'ACTIVE')");
jdbc.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,source_stage,package_count) VALUES(201,'B',20,101,'RECEIVED',60)");
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources);
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources,new AppointmentDispatchAvailability(store));
var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store,candidates);
var tx=new TransactionTemplate(context.getBean(PlatformTransactionManager.class));
var before=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
assertTrue(before.blockReasons().isEmpty());assertEquals(60,before.packageCount());
assertBindable(tx,binding,mapper,before);
assertFalse(before.blockReasons().isEmpty());assertEquals(60,before.packageCount());
assertNotBindable(tx,binding,mapper,before);
batch=receiving.plan("operator",new Plan("pending-d",new Selection(batch.batchKey(),batch.version(),"D"),plan("PENDING"),"transport-v1")).batches().get(0);
assertEquals("PENDING",batch.appointmentStatus());assertEquals(originalB,store.plan(batch.batchKey(),"B"));
var after=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
assertEquals(before.packageCount(),after.packageCount());assertEquals(before.blockReasons(),after.blockReasons());
assertNotEquals(before.allocations().get(0).recordVersion(),after.allocations().get(0).recordVersion());assertBindable(tx,binding,mapper,after);
assertNotEquals(before.allocations().get(0).recordVersion(),after.allocations().get(0).recordVersion());assertNotBindable(tx,binding,mapper,after);
var bound=new AppointmentWorkDispatchMapper.Bound(20L,201L,101L,batch.batchKey(),"B","ORDER:1","FBA:ONT8",60);
doReturn(List.of(bound)).when(mapper).bindings("DP-B");
assertTrue(assertThrows(ResponseStatusException.class,()->binding.requireDeparture("DP-B")).getReason().contains("尚无有效收货确认"));
@@ -65,13 +65,11 @@ class AppointmentWorkPreArrangementPersistenceTest {
assertTrue(mapper.byDispatch(20).isEmpty());
}
}
private static void assertBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) {
tx.executeWithoutResult(status->{
private static void assertNotBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) {
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(status->{
binding.lockBatches(Map.of(candidate.allocations().get(0).batchKey(),candidate.allocations().get(0).recordVersion()));
binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",60)),candidate.allocations());
assertEquals(60,mapper.byDispatch(20).stream().mapToInt(AppointmentWorkDispatchMapper.Bound::packages).sum());
status.setRollbackOnly(); // Compare identical physical availability before and after the independent customer's plan command.
});
}));
assertTrue(mapper.byDispatch(20).isEmpty());
}
private static FbaAppointmentDetails plan(String status) {return new FbaAppointmentDetails(LocalDate.of(2026,10,25),null,null,null,null,null,status,"仓库暂无确认",null,null);}
@@ -51,6 +51,7 @@ class AppointmentWorkDispatchPersistenceTest {
private AppointmentWorkDispatchCandidates candidates;
private InboundFbaAppointmentService appointments;
private AppointmentSourceService sources;
private FbaReceivingStore receiving;
private final String batch="FBA_APPOINTMENT:1";
/** 无客户主单,多个客户可在同车同批;真实计划逐票数量来自各自明细。 */
@@ -78,8 +79,21 @@ class AppointmentWorkDispatchPersistenceTest {
when(appointments.batchContext(batch)).thenAnswer(c->new FbaAppointmentBatchContext(snapshot(),List.of(),List.of(),List.of(),List.of(),List.of()));
when(sources.canonicalOrders(anyList())).thenAnswer(c->{List<AppointmentExternalSourceGateway.OrderRef> orders=c.getArgument(0);return orders.stream().map(o->new AppointmentCoverageService.SourceRef(o.customerId(),o.orderId()==2?"BOOKING:real-2":"ORDER:"+o.orderId(),null)).toList();});
doAnswer(c->{jdbc.update("UPDATE change_guard SET version=version+1 WHERE id=1");return null;}).when(changes).requirementsChanged();
candidates=new AppointmentWorkDispatchCandidates(mapper,appointments,sources);
var proxy=new ProxyFactory(new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(FbaReceivingStore.class),candidates));proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));binding=(AppointmentWorkDispatchPort)proxy.getProxy();
receiving=mock(FbaReceivingStore.class);
when(receiving.currentAgreement(eq(batch),anyString())).thenAnswer(call->confirmed(call.getArgument(1)));
when(receiving.snapshots(anyList())).thenAnswer(call->List.of(new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving(batch,List.of(confirmed("101"),confirmed("202")),List.of(),List.of())));
var availability=new AppointmentDispatchAvailability(receiving);
candidates=new AppointmentWorkDispatchCandidates(mapper,appointments,sources,availability);
var target=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving,candidates);
org.springframework.test.util.ReflectionTestUtils.setField(target,"allocationReader",new AppointmentDispatchAllocationReader(new AppointmentDispatchAllocationRepository(mapper),sources,availability));
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));binding=(AppointmentWorkDispatchPort)proxy.getProxy();
}
/** 成功场景明确提供本客户原来源的正式收货时间,不借其他客户确认通过。 */
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement confirmed(String customer) {
var details=new FbaAppointmentDetails(null,null,null,"CONF-"+customer,java.time.LocalDateTime.of(2026,10,26,10,0),null,"SUCCESS",null,null,null);
var source=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Source("101".equals(customer)?"ORDER:1":"BOOKING:real-2","FBA:ONT8","101".equals(customer)?80:160,"v1",1,"IN-"+customer);
var member=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member(batch,"AP-1",0,customer,List.of(source),null);
return new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement("RA-"+customer,0,"ACTIVE","FBA:ONT8",details,"America/Los_Angeles",List.of(member),"仓库明确确认");
}
/** 插入真实客户、托盘和逐票数量,不从请求猜测。 */
private void seed(long item,String customer,long order,int count,String destination) {