优化
This commit is contained in:
+6
@@ -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);
|
||||
/** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */
|
||||
|
||||
+12
@@ -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) {
|
||||
/** 显式零版本合法,缺版本不能覆盖并发变更。 */
|
||||
|
||||
+49
@@ -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("来源占用未保存");}
|
||||
|
||||
+8
@@ -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) {
|
||||
|
||||
+7
-2
@@ -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());
|
||||
|
||||
+12
@@ -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) {
|
||||
|
||||
+62
@@ -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;
|
||||
}
|
||||
}
|
||||
+20
@@ -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);
|
||||
}
|
||||
}
|
||||
+40
@@ -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;}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -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(); }
|
||||
|
||||
+10
-5
@@ -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));
|
||||
}
|
||||
|
||||
+22
-2
@@ -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<>'CANCELLED') allocated
|
||||
AND a.destination_key=CONCAT('FBA:',i.fba_warehouse_code) AND d.status<>'CANCELLED' AND d.id<>#{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,
|
||||
|
||||
Reference in New Issue
Block a user