This commit is contained in:
2026-10-19 17:56:36 +08:00
parent bb917c954c
commit dcfba1053c
232 changed files with 15655 additions and 6005 deletions
@@ -44,7 +44,8 @@ public interface InboundFbaAppointmentQueryMapper {
@Select("""
SELECT COUNT(DISTINCT d.id) FROM ll_dispatch_appointment_allocation a
JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id
JOIN ll_dispatch_order d ON d.id=a.dispatch_id
JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id AND i.plan_pallet_id=a.plan_pallet_id
WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',#{appointmentId}) AND d.status<>'CANCELLED'
""")
long countActiveDispatches(@Param("appointmentId") Long appointmentId);
@@ -9,5 +9,5 @@ public interface FbaAppointmentExecutionPort {
record Scope(String version,List<Eligibility> members) { }
Scope inspect(List<Member> members);
/** Locks original trips and selected customers' dispatch/execution rows before core batch locks. */
Scope lockAndInspect(List<Member> members,String expectedVersion);
Scope lockAndInspect(List<Member> members,String expectedVersion,String operator);
}
@@ -11,8 +11,8 @@ public interface FbaAppointmentTransportPort {
int originalPackages, int retainedPackages, String laterBatchKey) { }
record Result(List<Vehicle> vehicles, List<String> nextActions) { }
Scope inspect(List<String> batchKeys);
/** Lock order: trip -> dispatch -> outbound/PDA -> appointment; called BEFORE core locks batches. */
Scope lockAndInspect(List<String> batchKeys, String expectedVersion);
/** 锁全范围trip→车辆/司机资源→dispatch;必须先于核心批次与来源锁。 */
Scope lockAndInspect(List<String> batchKeys, String expectedVersion,String operator);
/** Same transaction as core allocation. Throw on failure so physical/allocation changes roll back together. */
Result apply(Scope locked, List<Move> moves, String operator, String reason);
Result apply(Scope locked, List<Move> moves, String operator, String reason,String parentCommandKey);
}
@@ -72,7 +72,7 @@ public class FbaReceivingService {
public Result propose(String operator,Propose request) {
return command("RECEIVING_PROPOSE",operator,request.idempotencyKey(),request,()->{
var intent=request.intent();var followup=work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate());
var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion());requireMutable(scope);
var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion(),operator);requireMutable(scope);
var batch=batches.lockReceivingBatch(intent.batchKey());requireVersion(batch,intent.batchVersion());
recheckTransport(List.of(intent.batchKey()),scope);
if(store.hasPending(intent.batchKey(),intent.customerId())) throw conflict("本客户已有待确认变化,请继续原记录");
@@ -86,6 +86,7 @@ public class FbaReceivingService {
return new Result(pending.changeKey(),"WAITING",List.of(batch),pending.originalAgreements(),List.of(new Step("RECORD_CHANGE","SUCCESS",pending.changeKey(),"原安排及数量保留,待确认变化已阻止相关发车")),List.of("CONTINUE_WAREHOUSE_CONFIRMATION"));
});
}
/** 原批次、运输、收货组和跟进在同一父命令事务内处理,跟进guard最后进入。 */
public Result resolve(String operator,String changeKey,Resolve request) {
return command("RECEIVING_RESOLVE:"+changeKey,operator,request.idempotencyKey(),request,()->{
var initial=store.change(changeKey,false);var intent=initial.intent();
@@ -93,7 +94,7 @@ public class FbaReceivingService {
// picking starts, without editing cargo, the appointment, or vehicle facts.
var withdrawing="WITHDRAW".equals(request.decision());
var followup=withdrawing?null:work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate());
var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion());
var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion(),operator);
if(!withdrawing) requireMutable(scope);
var batch=batches.lockReceivingBatch(intent.batchKey());var pending=store.change(changeKey,true);
if(pending.version()!=request.version() || !"WAITING".equals(pending.status())) throw conflict("变化已被处理,请重新读取当前结果");
@@ -112,13 +113,12 @@ public class FbaReceivingService {
var currentKey=store.currentAgreement(intent.batchKey(),intent.customerId());
if(!Objects.equals(currentKey==null?null:currentKey.agreementKey(),original==null?null:original.agreementKey())) throw conflict("本客户有效预约关系已变化");
var changed=batches.moveReceivingCustomer(operator,intent);var retained=changed.get(0);var later=changed.get(1);
work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator);
var movements=new ArrayList<FbaAppointmentTransportPort.Move>();
for(var source:pending.originalMembers().get(0).sources()) {
var q=intent.quantities().stream().filter(v->v.sourceKey().equals(source.sourceKey()) && v.destinationKey().equals(source.destinationKey())).findFirst().orElseThrow();
movements.add(new FbaAppointmentTransportPort.Move(intent.batchKey(),intent.customerId(),source.sourceKey(),source.destinationKey(),source.packageCount(),q.retainedPackages(),later.batchKey()));
}
var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason());
var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason(),request.idempotencyKey());
var agreements=new ArrayList<Agreement>();
if(original!=null) {
var members=new ArrayList<>(original.members());members.removeIf(m->m.batchKey().equals(intent.batchKey()) && m.customerId().equals(intent.customerId()));
@@ -130,13 +130,16 @@ public class FbaReceivingService {
var steps=List.of(new Step("ALLOCATE_CARGO","SUCCESS",later.batchKey(),"原来源总量不变,后送量已落在新批次"),new Step("ADJUST_TRANSPORT","SUCCESS",intent.batchKey(),"原车辆按实际保留货物调整"));
var actions=new ArrayList<String>();actions.add("BOOK_LATER_BATCH");actions.addAll(physical.nextActions());
var result=new Result(changeKey,"APPLIED",List.of(projected,later),List.copyOf(agreements),steps,List.copyOf(actions));
store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches());return result;
store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches());
// 原运输、批次、来源和收货组全部写完后才获取跟进guard。
work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator);
return result;
});
}
public Result refreshCargo(String operator,RefreshCargo request) {
return command("RECEIVING_REFRESH_CARGO",operator,request.idempotencyKey(),request,()->{
var selection=request.member();var selected=List.of(new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId()));
var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked);
var locked=execution.lockAndInspect(selected,request.executionVersion(),operator);requireRebookable(selected,locked);
var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion());
var current=execution.inspect(selected);
if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围");
@@ -151,7 +154,7 @@ public class FbaReceivingService {
public Result plan(String operator,Plan request) {
return command("RECEIVING_PLAN",operator,request.idempotencyKey(),request,()->{
var selection=request.member();var memberKey=new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId());
var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked);
var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion(),operator);requireRebookable(selected,locked);
var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion());
var current=execution.inspect(selected);
if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围");
@@ -173,7 +176,7 @@ public class FbaReceivingService {
var selections=request.bookings().stream().flatMap(b->b.members().stream()).toList();
var keys=selections.stream().map(Selection::batchKey).distinct().sorted().toList();
var executionMembers=selections.stream().map(s->new FbaAppointmentExecutionPort.Member(s.batchKey(),s.customerId())).distinct().toList();
var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion());
var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion(),operator);
requireRebookable(executionMembers,executionScope);
var snapshots=lock(keys);
// An unbound customer can acquire a new dispatch before we obtain the batch lock.
@@ -209,7 +212,7 @@ public class FbaReceivingService {
public Result changeGroup(String operator,String key,GroupChange request) {
return command("RECEIVING_GROUP:"+key,operator,request.idempotencyKey(),request,()->{
var initial=store.agreement(key,false);var keys=initial.members().stream().map(Member::batchKey).distinct().sorted().toList();
var scope=transport.lockAndInspect(keys,request.transportVersion());requireMutable(scope);var snapshots=lock(keys);
var scope=transport.lockAndInspect(keys,request.transportVersion(),operator);requireMutable(scope);var snapshots=lock(keys);
recheckTransport(keys,scope);
var group=store.agreement(key,true);requireActive(group);
if(group.version()!=request.version()) throw conflict("共同预约范围已变化,请重新核对全部客户");
@@ -5,6 +5,7 @@ import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.*;
import org.springframework.transaction.support.*;
import java.util.*;
import java.util.stream.Collectors;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@@ -18,16 +19,16 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
if(dispatchIds.stream().anyMatch(id->id==null || id<=0)) throw bad("排车引用缺少真实排车身份");
var rows=mapper.references(dispatchIds.stream().distinct().sorted().toList());
if(rows.size()>20000) throw unavailable("排车批次引用超过完整读取上限");
record Group(long dispatchId,String batchKey,String batchNo,String customerId) { }
record Group(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId) { }
var groups=new LinkedHashMap<Group,Map<List<String>,Integer>>();
for(var row:rows) {
if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0)
if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.recordVersion()==null || row.recordVersion()<0 || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0)
throw unavailable("原排车逐票批次引用不完整");
groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.customerId()),ignored->new LinkedHashMap<>())
groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.recordVersion(),row.customerId()),ignored->new LinkedHashMap<>())
.merge(List.of(row.sourceKey(),row.destinationKey()),row.packages(),Math::addExact);
}
var result=new LinkedHashMap<Long,List<BatchReference>>();dispatchIds.forEach(id->result.put(id,new ArrayList<>()));
groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.customerId(),
groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.recordVersion(),group.customerId(),
sources.entrySet().stream().map(e->new BatchSource(e.getKey().get(0),e.getKey().get(1),e.getValue())).toList())));
result.replaceAll((id,refs)->List.copyOf(refs));return Collections.unmodifiableMap(result);
}
@@ -36,53 +37,95 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
private final AppointmentSourceService sources;
private final AppointmentWorkChanges changes;
private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving;
private final AppointmentWorkDispatchCandidates candidates;
private final Object prelockedBatchesKey=new Object();
private final Object pendingInvalidationKey=new Object();
/** 注入原预约、真实实物和收货确认权威。 */
public AppointmentWorkDispatchBinding(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentWorkChanges changes,
com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving) {
this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving;
com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving,AppointmentWorkDispatchCandidates candidates) {
this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving;this.candidates=candidates;
}
public void bind(String customer,long dispatch,List<PhysicalItem> items,List<Allocation> allocations) {
if(allocations==null) throw bad("批次实物分配清单必填");
if(allocations.isEmpty()) {
if(mapper.byDispatch(dispatch).stream().anyMatch(a->a.batchKey().startsWith("FBA_APPOINTMENT:"))) throw conflict("已关联预约批次,修改实物时须同时提交完整批次分配");
for(var item:items) if(item.planPalletId()!=null) requireNoBatch(customer,item.planPalletId());
var privateRows=new ArrayList<AppointmentWorkDispatchMapper.Bound>();
for(var item:items) if(item.planPalletId()!=null) privateRows.addAll(privateAllocation(customer,dispatch,item));
if(!privateRows.isEmpty() || mapper.count(dispatch)>0) { mapper.remove(dispatch);privateRows.forEach(mapper::insert);changes.requirementsChanged(); }
return;
/** 候选批读沿唯一原实物Provider实现,不读取工作清单投影。 */
@Transactional(readOnly=true,propagation=Propagation.SUPPORTS)
public Map<CandidatePhysical,CandidateBatches> candidateBatches(List<CandidatePhysical> input) {return candidates.batchReferences(input);}
/** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */
public void lockBatches(Map<String,Long> expectedBatchVersions) {
if(expectedBatchVersions==null || expectedBatchVersions.size()>1000) throw bad("预约批次版本范围无效");
var expected=new TreeMap<String,Long>();
for(var entry:expectedBatchVersions.entrySet()) {
batchId(entry.getKey());
if(entry.getValue()==null || entry.getValue()<0) throw bad("预约批次版本必填且不能小于零");
expected.put(entry.getKey(),entry.getValue());
}
if(allocations.size()>1000) throw bad("批次实物分配最多1000条");
var byPallet=items.stream().filter(i->i.planPalletId()!=null).collect(Collectors.toMap(PhysicalItem::planPalletId,i->i));
var seen=new HashSet<String>();
for(var row:allocations) {
if(row==null || row.packageCount()<=0 || !customer.equals(row.customerId()) || !byPallet.containsKey(row.planPalletId())) throw conflict("批次分配必须属于本客户已选择的实物托盘");
if(!seen.add(row.planPalletId()+"/"+row.batchKey()+"/"+row.sourceKey()+"/"+row.destinationKey())) throw bad("批次实物分配重复");
@SuppressWarnings("unchecked") var locked=(Set<String>)TransactionSynchronizationManager.getResource(prelockedBatchesKey);
if(locked!=null && !locked.containsAll(expected.keySet())) throw conflict("预约批次须在来源锁前一次锁齐");
for(var entry:expected.entrySet()) {
var row=mapper.lockAppointment(batchId(entry.getKey()));
if(row==null || row.getVersion()==null || row.getVersion().longValue()!=entry.getValue()) throw conflict("预约批次版本已变化,请重新读取实物候选");
}
var batches=new TreeSet<>(allocations.stream().map(Allocation::batchKey).toList());
for(var batch:batches) lock(batch);
var persisted=new ArrayList<AppointmentWorkDispatchMapper.Bound>();
for(var item:byPallet.values()) {
var selected=allocations.stream().filter(a->item.planPalletId().equals(a.planPalletId())).toList();
if(locked==null && TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.bindResource(prelockedBatchesKey,Set.copyOf(expected.keySet()));
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/** 事务结束后释放本地预锁证明。 */
public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(prelockedBatchesKey); }
});
}
}
/** 逐项复核真实明细,只替换本次触及实物;完整有效集合参与同车及跨车余量核对。 */
public void bindItems(long dispatch,List<PhysicalItem> items,List<Allocation> allocations) {
if(dispatch<=0 || items==null || items.isEmpty() || items.size()>1000 || allocations==null || allocations.size()>1000) throw bad("批次实物分配范围无效");
var actual=mapper.dispatchPhysicalById(dispatch).stream().collect(Collectors.toMap(AppointmentWorkDispatchMapper.DispatchPhysical::itemId,i->i));
var byPallet=new HashMap<List<String>,PhysicalItem>();var touched=new TreeSet<Long>();
for(var item:items) {
if(item==null || item.itemId()==null || !touched.add(item.itemId())) throw bad("受影响实物身份缺失或重复");
var row=actual.get(item.itemId());
if(row==null || !Objects.equals(item.customerId(),row.customerId()) || !Objects.equals(item.planPalletId(),row.planPalletId())
|| !Objects.equals(item.sourceStage(),row.sourceStage()) || !Objects.equals(item.packages(),row.packages()) || row.packages()==null || row.packages()<=0)
throw conflict("受影响实物与本车真实客户、托盘或数量不一致");
if(item.planPalletId()!=null && byPallet.put(List.of(item.customerId(),item.planPalletId().toString()),item)!=null) throw conflict("同客户托盘存在重复明细");
}
var versions=new TreeMap<String,Long>();var seen=new HashSet<List<String>>();
for(var a:allocations) {
if(a==null || a.customerId()==null || a.planPalletId()==null || a.sourceKey()==null || a.destinationKey()==null || a.packageCount()<=0
|| !byPallet.containsKey(List.of(a.customerId(),a.planPalletId().toString()))) throw conflict("批次分配必须属于本次已核实的真实客户实物");
batchId(a.batchKey());
if(a.recordVersion()==null || a.recordVersion()<0) throw bad("预约批次版本必填且不能小于零");
var previous=versions.putIfAbsent(a.batchKey(),a.recordVersion());
if(previous!=null && !previous.equals(a.recordVersion())) throw conflict("同一预约批次不能提交不同版本");
if(!seen.add(List.of(a.customerId(),a.planPalletId().toString(),a.batchKey(),a.sourceKey(),a.destinationKey()))) throw bad("批次实物分配重复");
}
var previous=mapper.byDispatch(dispatch);
var effective=new ArrayList<>(previous.stream().filter(a->!touched.contains(a.itemId())).toList());
var next=new ArrayList<AppointmentWorkDispatchMapper.Bound>();
for(var item:items) if(item.planPalletId()!=null) {
var selected=allocations.stream().filter(a->item.customerId().equals(a.customerId()) && item.planPalletId().equals(a.planPalletId())).toList();
if(selected.isEmpty()) {
var privateRows=privateAllocation(customer,dispatch,item);
if(privateRows.isEmpty()) throw conflict("批次排车须明确每个托盘的逐票实物分配");
persisted.addAll(privateRows);continue;
requireNoBatch(item.customerId(),item.planPalletId());
next.addAll(privateAllocation(item.customerId(),dispatch,item));
} else {
validatePhysical(item.customerId(),item,selected,"RECEIVED".equals(item.sourceStage()),false);
for(var a:selected) next.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),item.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount()));
}
validatePhysical(customer,item,selected,"RECEIVED".equals(item.sourceStage()),false);
for(var a:selected) persisted.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),customer,a.sourceKey(),a.destinationKey(),a.packageCount()));
}
effective.addAll(next);
var batches=effective.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).collect(Collectors.toCollection(TreeSet::new));
@SuppressWarnings("unchecked") var prelocked=(Set<String>)TransactionSynchronizationManager.getResource(prelockedBatchesKey);
if(TransactionSynchronizationManager.isSynchronizationActive() && (prelocked==null || !prelocked.containsAll(batches))) throw conflict("预约批次未在来源锁前完成预锁");
for(var batch:batches) {
var context=appointments.batchContext(batch); var appointment=context.appointment();
// Pre-arrangement reserves real cargo only. requireReceiving separately gates actual departure per customer.
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("不能绑定已被替代的预约批次");
requireCurrentAllocation(appointment,customer,dispatch,allocations.stream().filter(a->batch.equals(a.batchKey())).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());
}
mapper.remove(dispatch); persisted.forEach(mapper::insert); changes.requirementsChanged();
// 所有校验结束后才按明确item删除;没有整车清空操作。
for(var item:touched) mapper.removeItem(dispatch,item);
next.forEach(mapper::insert);invalidateAtBusinessEnd();
}
public void requireReplay(String customer,long dispatch,List<Allocation> allocations) {
if(allocations==null || allocations.stream().anyMatch(a->a==null || !customer.equals(a.customerId()))) throw conflict("重放排车的逐票批次分配不一致");
var original=mapper.byDispatch(dispatch).stream().filter(a->!a.batchKey().startsWith("DISPATCH:"))
.map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList();
if(allocations.size()!=original.size() || !new HashSet<>(allocations).equals(new HashSet<>(original))) throw conflict("原排车已存在,不能通过重放请求改写批次分配");
/** 从原分配构造当前权威版本视图,不写入第三套版本账本。 */
private Allocation allocation(AppointmentWorkDispatchMapper.Bound row,Long version) {
return new Allocation(row.palletId(),row.batchKey(),version,row.customerId(),row.sourceKey(),row.destinationKey(),row.packages());
}
private void validatePhysical(String customer,PhysicalItem item,List<Allocation> selected,boolean received,boolean picked) {
var physical=picked?mapper.pickedPhysical(item.itemId(),customer):mapper.physical(item.planPalletId(),customer);
@@ -108,14 +151,16 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
}
private void checkDeparture(String dispatchNo,boolean locked) {
var rows=mapper.bindings(dispatchNo);
var linked=rows.stream().map(AppointmentWorkDispatchMapper.Bound::palletId).collect(Collectors.toSet());
for(var item:mapper.dispatchPhysical(dispatchNo)) if(item.planPalletId()!=null && !linked.contains(item.planPalletId())) {
var physical=mapper.dispatchPhysical(dispatchNo);
for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId())))
throw conflict("预约分配与本车真实客户实物不一致");
var batches=rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).distinct().sorted().toList();
if(locked) for(var batch:batches) lock(batch);
for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) {
requireNoBatch(item.customerId(),item.planPalletId());
if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车");
}
for(var batch:rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) {
if(batch.startsWith("DISPATCH:")) continue;
if(locked) lock(batch);
for(var batch:batches) {
var a=appointments.batchContext(batch).appointment();
if(!a.replacementBatchKeys().isEmpty()) throw conflict("关联批次已被替代,请重新核对实物批次后发车");
var allocated=rows.stream().filter(row->batch.equals(row.batchKey())).toList();
@@ -124,13 +169,14 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
for(var customer:allocated.stream().map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().toList()) {
requireReceiving(batch,customer,allocated.stream().filter(row->customer.equals(row.customerId())).toList());
requireCurrentAllocation(a,customer,dispatchIds.get(0),allocated.stream().filter(row->customer.equals(row.customerId()))
.map(row->new Allocation(row.palletId(),row.batchKey(),row.customerId(),row.sourceKey(),row.destinationKey(),row.packages())).toList());
.map(row->allocation(row,a.version()==null?null:a.version().longValue())).toList());
}
}
for(var group:rows.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.Bound::palletId)).values()) {
for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.customerId(),Long.toString(r.itemId()),Long.toString(r.palletId())))).values()) {
var first=group.get(0);
validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.palletId(),"RECEIVED",null),
group.stream().map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(),true,true);
var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow();
validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),"RECEIVED",item.packages()),
group.stream().map(a->allocation(a,null)).toList(),true,true);
}
}
private void requireReceiving(String batch,String customer,List<AppointmentWorkDispatchMapper.Bound> rows) {
@@ -168,7 +214,20 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty()))
throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车");
}
public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId); changes.requirementsChanged(); }
/** 调用方已锁主单与原批次,删除只作用于指定实物。 */
public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); }
/** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */
private void invalidateAtBusinessEnd() {
if(!TransactionSynchronizationManager.isSynchronizationActive()) {changes.requirementsChanged();return;}
if(TransactionSynchronizationManager.hasResource(pendingInvalidationKey)) return;
TransactionSynchronizationManager.bindResource(pendingInvalidationKey,Boolean.TRUE);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
/** 所有业务写入后获取guard;失败仍使整个原事务回滚。 */
public void beforeCommit(boolean readOnly) {changes.requirementsChanged();}
/** 清除本事务本地标记,不复用于下一个请求。 */
public void afterCompletion(int status) {TransactionSynchronizationManager.unbindResourceIfPossible(pendingInvalidationKey);}
});
}
public void requireTripBinding(String dispatchNo,long tripId) {
for(var batch:mapper.bindings(dispatchNo).stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) {
if(batch.startsWith("DISPATCH:")) continue;
@@ -190,8 +249,12 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
return rows;
}
private void lock(String batch) {
if(batch==null || !batch.startsWith("FBA_APPOINTMENT:")) throw bad("预约批次键无效");
try { long id=Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); if(!batch.equals("FBA_APPOINTMENT:"+id) || mapper.lockAppointment(id)==null) throw conflict("预约批次不存在"); }
catch(NumberFormatException | IndexOutOfBoundsException error) { throw bad("预约批次键无效"); }
if(mapper.lockAppointment(batchId(batch))==null) throw conflict("预约批次不存在");
}
/** 批次键只接受权威业务键,拒绝前导零、负数和溢出。 */
private long batchId(String batch) {
if(batch==null || !batch.matches("FBA_APPOINTMENT:[1-9][0-9]*")) throw bad("预约批次键无效");
try { return Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); }
catch(NumberFormatException error) { throw bad("预约批次键无效"); }
}
}
@@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -15,8 +16,62 @@ public class AppointmentWorkDispatchCandidates {
private final AppointmentWorkDispatchMapper mapper;
private final InboundFbaAppointmentService appointments;
private final AppointmentSourceService sources;
/** 注入唯一原批次、实物和来源规范化边界。 */
public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; }
/** 整页两次本地SQL;来源按已有100票上限分块规范化,不逐托或逐批读取上下文。 */
@Transactional(readOnly=true)
public Map<CandidatePhysical,CandidateBatches> batchReferences(List<CandidatePhysical> input) {
if(input==null || input.size()>1000 || input.stream().anyMatch(i->i==null || i.customerId()==null || i.customerId().isBlank() || i.planPalletId()==null || i.planPalletId()<=0)) throw bad("候选批读须提供最多1000个真实客户托盘");
var identities=input.stream().distinct().sorted(Comparator.comparing(CandidatePhysical::customerId).thenComparing(CandidatePhysical::planPalletId)).toList();
if(identities.isEmpty()) return Map.of();
var rows=mapper.candidateSources(identities);
if(rows.size()>20000) throw unavailable("候选逐票实物超过完整读取上限");
var physical=rows.stream().filter(r->r.orderId()!=null && r.destinationKey()!=null)
.map(r->new AppointmentWorkDispatchMapper.Physical(r.orderId(),r.customerId(),r.destinationKey(),r.plannedPackages(),r.countedPackages())).toList();
var canonical=canonical(physical);
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);
if(batches.size()>20000) throw unavailable("候选原批次超过完整读取上限");
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();
var reasons=new LinkedHashSet<String>();var references=new LinkedHashMap<List<String>,List<BatchSource>>();
boolean related=false,unassigned=false;boolean occupied=selected.stream().anyMatch(AppointmentWorkDispatchMapper.CandidateSource::locked);
if(selected.isEmpty()) reasons.add("客户实物托盘不存在或归属已变化");
for(var row:selected) {
var count="RECEIVED".equals(row.sourceStage())?row.countedPackages():row.plannedPackages();
if(row.orderId()==null || row.destinationKey()==null || count==null || count<=0) {reasons.add("逐票实物来源或箱数尚未核实");continue;}
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;}
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);
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(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();
output.put(identity,new CandidateBatches(result,List.copyOf(reasons),occupied));
}
return Collections.unmodifiableMap(output);
}
/** 保持原来源接口100票边界,按不同订单分块,重复目的地不增加远程调用。 */
private Map<AppointmentExternalSourceGateway.OrderRef,AppointmentCoverageService.SourceRef> canonical(List<AppointmentWorkDispatchMapper.Physical> physical) {
var unique=new LinkedHashMap<AppointmentExternalSourceGateway.OrderRef,AppointmentWorkDispatchMapper.Physical>();
for(var row:physical) unique.putIfAbsent(new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.orderId()),row);
var values=new ArrayList<>(unique.values());var result=new LinkedHashMap<AppointmentExternalSourceGateway.OrderRef,AppointmentCoverageService.SourceRef>();
for(int offset=0;offset<values.size();offset+=100) result.putAll(AppointmentWorkPhysicalSources.canonical(values.subList(offset,Math.min(offset+100,values.size())),sources));
return result;
}
@Transactional(readOnly=true)
/** 返回原批次当前版本及BOOKING/ORDER逐票实物,读候选不清理过期占用。 */
public Result read(String batch) {
var appointment=appointments.batchContext(batch).appointment();
// Receiving outcome does not release this batch's cargo or decide another customer's physical eligibility.
@@ -38,7 +93,8 @@ 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("本托货量超过该来源本批剩余可排量,请先核对分批或拆托");
allocations.add(new Allocation(candidate.planPalletId(),batch,part.customerId(),ref.sourceKey(),part.destinationKey(),count));
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));
}
if(physical.isEmpty()) { packages=null; reasons.add("缺少逐票实物分配"); }
output.add(new Candidate(candidate.planPalletId(),candidate.palletNo(),candidate.planId(),candidate.planNo(),candidate.customerId(),candidate.sourceStage(),packages,List.copyOf(allocations),reasons.stream().distinct().toList()));
@@ -10,17 +10,55 @@ public interface AppointmentWorkDispatchMapper {
record Bound(long dispatchId,long itemId,long palletId,String batchKey,String customerId,String sourceKey,String destinationKey,int packages) { }
record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,boolean locked) { }
record DispatchPhysical(long itemId,Long planPalletId,String customerId,String sourceStage,Integer packages) { }
record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,Integer pickedPackages,Integer shippedPackages,
record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,String dispatchStage,Long dispatchVersion,Integer pickedPackages,Integer shippedPackages,
java.time.LocalDateTime updatedAt,long itemId,long palletId,Integer itemPackages,String sourceStage,
String batchKey,String sourceKey,String destinationKey,int packages,String tripNo,Long tripVersion,String tripStatus,
Long outboundId,String outboundStatus,String taskStatus) { }
record ReceivingSource(String batchKey,String customerId,String sourceKey,String destinationKey,Integer packages) { }
record ReferenceRow(long dispatchId,String batchKey,String batchNo,String customerId,String sourceKey,String destinationKey,int packages) { }
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) { }
/** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */
@Select("""
<script>
SELECT p.id plan_pallet_id,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,
EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id))
AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked,
l.order_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,
CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,
CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages
FROM ll_inbound_plan_pallet p
LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id
LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id
WHERE <foreach collection='items' item='i' open='(' separator=' OR ' close=')'>(p.id=#{i.planPalletId} AND p.customer_id=#{i.customerId})</foreach>
GROUP BY p.id,p.customer_id,p.warehouse_pallet_id,l.order_id,l.destination_type,l.fba_warehouse_code
ORDER BY p.id,l.order_id,l.destination_type,l.fba_warehouse_code LIMIT 20001
</script>
""")
List<CandidateSource> candidateSources(@Param("items") List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical> items);
/** 原有效批次版本与跨车占用由同次查询提供,不逐候选读取batchContext。 */
@Select("""
<script>
SELECT CONCAT('FBA_APPOINTMENT:',p.id) batch_key,p.appointment_no batch_no,p.version record_version,
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
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>
OR <foreach collection='orders' item='o' separator=' OR '>(i.customer_id=#{o.customerId} AND i.order_id=#{o.orderId} AND CONCAT('FBA:',i.fba_warehouse_code)=#{o.destinationKey})</foreach>)
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);
@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,
CASE WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.record_version ELSE p.version END record_version,
a.customer_id,a.source_key,a.destination_key,a.package_count packages
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id
JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id AND i.plan_pallet_id=a.plan_pallet_id
LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id)
WHERE a.dispatch_id IN <foreach collection='ids' item='id' open='(' separator=',' close=')'>#{id}</foreach>
ORDER BY a.dispatch_id,a.batch_key,a.customer_id,a.source_key,a.destination_key,a.dispatch_item_id LIMIT 20001
@@ -41,15 +79,15 @@ public interface AppointmentWorkDispatchMapper {
List<ReceivingSource> receivingSources(@Param("batches") List<String> batches);
@Select("""
<script>
SELECT a.dispatch_id,d.dispatch_no,a.customer_id,d.status dispatch_status,d.picked_package_count picked_packages,
SELECT a.dispatch_id,d.dispatch_no,a.customer_id,d.status dispatch_status,d.stage dispatch_stage,d.record_version dispatch_version,d.picked_package_count picked_packages,
d.shipped_package_count shipped_packages,d.updated_at,a.dispatch_item_id item_id,a.plan_pallet_id pallet_id,
i.package_count item_packages,i.source_stage,a.batch_key,a.source_key,a.destination_key,a.package_count packages,
t.trip_no,t.record_version trip_version,t.status trip_status,o.id outbound_id,o.status outbound_status,p.status task_status
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id
JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=d.customer_id
FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id
JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id AND i.plan_pallet_id=a.plan_pallet_id
LEFT JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=d.id LEFT JOIN ll_fleet_trip t ON t.id=b.trip_id
LEFT JOIN ll_outbound_order o ON o.dispatch_order_id=d.id AND o.customer_id=d.customer_id
LEFT JOIN ll_dispatch_pda_task p ON p.dispatch_order_id=d.id AND p.customer_id=d.customer_id
LEFT JOIN ll_outbound_order o ON o.dispatch_order_id=d.id AND o.customer_id=a.customer_id
LEFT JOIN ll_dispatch_pda_task p ON p.dispatch_order_id=d.id
WHERE d.status&lt;&gt;'CANCELLED' AND (
a.dispatch_id IN (SELECT selected.dispatch_id FROM ll_dispatch_appointment_allocation selected WHERE selected.batch_key IN <foreach collection='batches' item='key' open='(' separator=',' close=')'>#{key}</foreach>)
<if test='trips != null and !trips.isEmpty()'>OR t.trip_no IN <foreach collection='trips' item='trip' open='(' separator=',' close=')'>#{trip}</foreach></if>
@@ -59,19 +97,20 @@ public interface AppointmentWorkDispatchMapper {
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
List<TransportRow> transportRows(@Param("batches") List<String> batches,@Param("trips") List<String> trips);
@Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List<DispatchPhysical> dispatchPhysical(String number);
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.plan_pallet_id=p.id AND k.status='ACTIVE') locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
@Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List<DispatchPhysical> dispatchPhysicalById(long id);
@Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501")
List<Candidate> candidates(long id);
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,SUM(a.planned_package_count) planned_packages,SUM(a.counted_package_count) counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
@Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code")
List<Physical> physical(@Param("pallet") long pallet,@Param("customer") String customer);
@Select("SELECT a.order_id,a.customer_id,CASE WHEN i.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',a.order_id) ELSE CONCAT('FBA:',i.fba_warehouse_code) END destination_key,NULL planned_packages,SUM(a.packages) counted_packages FROM ll_dispatch_pda_pick_order a JOIN ll_dispatch_pda_pick p ON p.id=a.pick_id AND p.customer_id=a.customer_id JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.id=#{item} AND a.customer_id=#{customer} GROUP BY a.order_id,a.customer_id,i.destination_type,i.fba_warehouse_code")
List<Physical> pickedPhysical(@Param("item") long item,@Param("customer") String customer);
@Select("SELECT * FROM ll_inbound_fba_appointment WHERE id=#{id} FOR UPDATE") InboundFbaAppointment lockAppointment(long id);
@Select("SELECT * FROM ll_inbound_fba_appointment WHERE id=#{id} FOR UPDATE")
@Options(flushCache=Options.FlushCachePolicy.TRUE,useCache=false)
InboundFbaAppointment lockAppointment(long id);
@Select("SELECT COALESCE(SUM(b.package_count),0) FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE b.batch_key=#{batch} AND b.customer_id=#{customer} AND b.source_key=#{source} AND b.destination_key=#{destination} AND b.dispatch_id<>#{dispatch} AND d.status<>'CANCELLED'")
long allocated(@Param("batch") String batch,@Param("customer") String customer,@Param("source") String source,@Param("destination") String destination,@Param("dispatch") long dispatch);
@Delete("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{dispatch}") int remove(long dispatch);
@Insert("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(#{dispatchId},#{itemId},#{palletId},#{batchKey},#{customerId},#{sourceKey},#{destinationKey},#{packages})")
int insert(Bound row);
@Select("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") int count(long id);
@Delete("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{dispatch} AND dispatch_item_id=#{item}") int removeItem(@Param("dispatch") long dispatch,@Param("item") long item);
@Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List<Long> trips(String batch);
@Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}")
@@ -2,6 +2,10 @@ package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentExecutionPort;
import com.baiyue.lastleg.dispatch.port.DispatchAppointmentAmendmentPort;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@@ -14,25 +18,28 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class FbaAppointmentExecutionAdapter implements FbaAppointmentExecutionPort {
private final AppointmentWorkDispatchMapper allocations;
private final FleetTripMapper trips;
private final DispatchAppointmentAmendmentPort amendments;
private final FleetDispatchGateway dispatches;
private record Facts(List<String> batches,List<AppointmentWorkDispatchMapper.ReceivingSource> sources,
List<AppointmentWorkDispatchMapper.TransportRow> rows,
Map<String,FleetCompletionModels.DispatchCompletion> outcomes,
Map<String,FleetDispatchGateway.TerminalEvidence> evidence,Map<String,String> invalid) { }
public FbaAppointmentExecutionAdapter(AppointmentWorkDispatchMapper allocations,FleetTripMapper trips,FleetDispatchGateway dispatches) {
this.allocations=allocations;this.trips=trips;this.dispatches=dispatches;
/** 复用整车统一资源锁入口,避免逐车交错获取资源。 */
public FbaAppointmentExecutionAdapter(AppointmentWorkDispatchMapper allocations,@Lazy DispatchAppointmentAmendmentPort amendments,FleetDispatchGateway dispatches) {
this.allocations=allocations;this.amendments=amendments;this.dispatches=dispatches;
}
@Transactional(readOnly=true)
public Scope inspect(List<Member> input) {
var members=members(input);return scope(members,read(batches(members)));
}
@Transactional(propagation=Propagation.MANDATORY)
public Scope lockAndInspect(List<Member> input,String expectedVersion) {
/** 使用真实操作人先锁全范围资源,再核对同一完整事实摘要。 */
public Scope lockAndInspect(List<Member> input,String expectedVersion,String operator) {
if(operator==null || operator.isBlank()) throw bad("客户执行锁定缺少真实操作人");
var members=members(input);if(members.isEmpty()) throw bad("请选择需要重新预约的客户");
var before=read(batches(members));var selected=selected(before,members);
var tripNos=tripNos(selected);var numbers=dispatchNos(selected);
for(var trip:tripNos) if(trips.lockByNo(trip)==null) throw conflict("原车次关系已变化,请重新核对");
amendments.lockExistingAppointmentDispatches(new Actor(operator,ActorMode.PLATFORM,null),numbers);
for(var number:numbers) {
dispatches.completion(number,true);
dispatches.lockForPlanning(number);
@@ -52,7 +59,7 @@ public class FbaAppointmentExecutionAdapter implements FbaAppointmentExecutionPo
var completion=dispatches.completion(number,false);
if(completion==null || !number.equals(completion.dispatchNo()) || completion.outcomes()==null) throw unavailable("原派送执行事实读取不完整");
outcomes.put(number,completion);
if(rows.stream().anyMatch(r->number.equals(r.dispatchNo()) && "DEPARTED".equals(r.dispatchStatus()))) {
if(rows.stream().anyMatch(r->number.equals(r.dispatchNo()) && "ACTIVE".equals(r.dispatchStatus()) && Set.of("DELIVERING","DELIVERED").contains(Objects.toString(r.dispatchStage(),"")))) {
try {
var fact=dispatches.terminalEvidence(number,false);
if(fact==null || !number.equals(fact.dispatchNo()) || fact.deliveries()==null) throw unavailable("原发运签收退回事实缺失");
@@ -84,7 +91,7 @@ public class FbaAppointmentExecutionAdapter implements FbaAppointmentExecutionPo
if(!expectedAmounts.equals(allocated)) complete=false;
for(var row:rows) {
var proof=facts.evidence().get(row.dispatchNo());
if(proof==null || !member.customerId().equals(proof.customerId())) { complete=false;continue; }
if(proof==null) { complete=false;continue; }
var delivery=proof.deliveries().stream().filter(d->member.customerId().equals(d.customerId()) && row.destinationKey().equals(d.destinationKey())).toList();
long dispatched=facts.rows().stream().filter(r->row.dispatchNo().equals(r.dispatchNo()) && row.customerId().equals(r.customerId()) && row.destinationKey().equals(r.destinationKey())).mapToLong(AppointmentWorkDispatchMapper.TransportRow::packages).sum();
if(delivery.size()!=1 || delivery.get(0).packages()!=dispatched || delivery.get(0).terminalAt()==null || delivery.get(0).terminalAt().isAfter(Instant.now())) complete=false;
@@ -93,9 +100,9 @@ public class FbaAppointmentExecutionAdapter implements FbaAppointmentExecutionPo
for(var row:rows) {
if(facts.invalid().containsKey(row.dispatchNo())) return denied(member,false,facts.invalid().get(row.dispatchNo()));
var outcomes=facts.outcomes().get(row.dispatchNo()).outcomes();
if(outcomes.stream().anyMatch(o->o.terminalAt()!=null || o.signedPackages()>0 || o.returnedPackages()>0 || Set.of("DELIVERING","DELIVERED","REJECTED","RETURN_PENDING","RETURNED").contains(Objects.toString(o.status(),""))))
if(outcomes.stream().filter(o->member.customerId().equals(o.customerId())).anyMatch(o->o.terminalAt()!=null || o.signedPackages()>0 || o.returnedPackages()>0 || Set.of("DELIVERING","DELIVERED","REJECTED","RETURN_PENDING","RETURNED").contains(Objects.toString(o.status(),""))))
return denied(member,false,"本客户已有实际派送执行,请从原运输异常处理");
if(!"PENDING_DEPARTURE".equals(row.dispatchStatus()) || positive(row.pickedPackages()) || positive(row.shippedPackages())
if(!"ACTIVE".equals(row.dispatchStatus()) || !"BEFORE_LOADING".equals(row.dispatchStage()) || row.dispatchVersion()==null || row.dispatchVersion()<0 || positive(row.pickedPackages()) || positive(row.shippedPackages())
|| row.taskStatus()!=null && !"PDA_PENDING".equals(row.taskStatus()) || row.outboundStatus()!=null && !"WAIT_OUTBOUND".equals(row.outboundStatus())
|| row.tripNo()!=null && !"PLANNED".equals(row.tripStatus())) return denied(member,false,"本客户运输已开始执行,不能普通重新预约");
}
@@ -1,6 +1,10 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.dispatch.port.DispatchAppointmentAmendmentPort;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.dispatch.port.DispatchCommands.ReasonCommand;
import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentTransportPort;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Component;
@@ -14,18 +18,13 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
@Component
public class FbaAppointmentTransportAdapter implements FbaAppointmentTransportPort {
private final AppointmentWorkDispatchMapper allocations;
private final FleetTripMapper trips;
private final FleetDispatchGateway dispatches;
private final FleetTripService fleet;
private final FleetBookingReservations reservations;
private final com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes;
private final DispatchAppointmentAmendmentPort amendments;
private final Object lockContextKey=new Object();
private record Locked(List<String> batches,List<String> tripNos,List<AppointmentWorkDispatchMapper.TransportRow> rows,Scope scope) { }
private record Locked(List<String> batches,List<String> tripNos,List<AppointmentWorkDispatchMapper.TransportRow> rows,Scope scope,String operator) { }
private record Source(String batch,String customer,String source,String destination) { }
public FbaAppointmentTransportAdapter(AppointmentWorkDispatchMapper allocations,FleetTripMapper trips,
FleetDispatchGateway dispatches,@Lazy FleetTripService fleet,FleetBookingReservations reservations,
com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes) {
this.allocations=allocations;this.trips=trips;this.dispatches=dispatches;this.fleet=fleet;this.reservations=reservations;this.changes=changes;
/** 原运输变更只委托整车窄端口,资源与主单仍由原主流程拥有。 */
public FbaAppointmentTransportAdapter(AppointmentWorkDispatchMapper allocations,@Lazy DispatchAppointmentAmendmentPort amendments) {
this.allocations=allocations;this.amendments=amendments;
}
@Transactional(readOnly=true)
public Scope inspect(List<String> batchKeys) {
@@ -33,29 +32,31 @@ public class FbaAppointmentTransportAdapter implements FbaAppointmentTransportPo
return scope(batches,read(batches,tripNos(first)));
}
@Transactional(propagation=Propagation.MANDATORY)
public Scope lockAndInspect(List<String> batchKeys,String expectedVersion) {
/** 全范围资源锁复用整车入口,锁后再次核对范围和权威版本。 */
public Scope lockAndInspect(List<String> batchKeys,String expectedVersion,String operator) {
if(operator==null || operator.isBlank()) throw bad("运输调整缺少真实操作人");
var batches=keys(batchKeys);var initial=read(batches,List.of());var tripNos=tripNos(initial);
for(var trip:tripNos) if(trips.lockByNo(trip)==null) throw conflict("原车次已变化,请重新核对");
var all=read(batches,tripNos);
var targets=all.stream().filter(r->batches.contains(r.batchKey())).map(AppointmentWorkDispatchMapper.TransportRow::dispatchNo).distinct().sorted().toList();
for(var number:targets) dispatches.lockForPlanning(number);
amendments.lockExistingAppointmentDispatches(actor(operator),targets);
var lockedRows=read(batches,tripNos);
if(!tripNos(lockedRows).equals(tripNos)) throw conflict("排车车次关联已变化,请重新核对");
if(!tripNos(lockedRows).equals(tripNos) || !targets.equals(lockedRows.stream().filter(r->batches.contains(r.batchKey())).map(AppointmentWorkDispatchMapper.TransportRow::dispatchNo).distinct().sorted().toList())) throw conflict("排车车次关联已变化,请重新核对");
var scope=scope(batches,lockedRows);
if(expectedVersion==null || !expectedVersion.equals(scope.version())) throw conflict("运输范围或版本已变化,请重新读取调整范围");
if(!scope.blockReasons().isEmpty()) throw conflict(String.join(";",scope.blockReasons()));
if(TransactionSynchronizationManager.hasResource(lockContextKey)) throw conflict("同一事务不能重复打开运输调整范围");
TransactionSynchronizationManager.bindResource(lockContextKey,new Locked(batches,tripNos,lockedRows,scope));
TransactionSynchronizationManager.bindResource(lockContextKey,new Locked(batches,tripNos,lockedRows,scope,operator));
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(lockContextKey); }
});
return scope;
}
@Transactional(propagation=Propagation.MANDATORY)
public Result apply(Scope locked,List<Move> moves,String operator,String reason) {
/** 原父键驱动完整实物移出;不直接写原分配或手工解绑整车。 */
public Result apply(Scope locked,List<Move> moves,String operator,String reason,String parentCommandKey) {
var context=(Locked)TransactionSynchronizationManager.getResource(lockContextKey);
if(context==null || !context.scope().equals(locked)) throw conflict("运输调整必须使用本事务锁定的权威范围");
if(operator==null || operator.isBlank() || reason==null || reason.isBlank()) throw bad("运输调整须提供操作人和原因");
if(!Objects.equals(context.operator(),operator) || reason==null || reason.isBlank() || parentCommandKey==null || parentCommandKey.isBlank() || parentCommandKey.length()>64) throw bad("运输调整须沿用真实操作人、父预约命令键和原因");
var current=read(context.batches(),context.tripNos());
if(!scope(context.batches(),current).version().equals(locked.version())) throw conflict("锁定后的排车事实已变化,请重新核对");
var intents=new LinkedHashMap<Source,Move>();
@@ -88,30 +89,26 @@ public class FbaAppointmentTransportAdapter implements FbaAppointmentTransportPo
throw conflict("逐票批次分配未完整核平原排车实物,不能调整");
if(!whole && rows.stream().anyMatch(r->r.outboundId()!=null)) throw conflict("部分移出前须先在原业务处理未执行出库单");
}
// expected仅包含touchedItems的原分配;next为空表示完整实物移出本车,后送批次等待真实重选。
var next=new LinkedHashSet<String>();
var versions=new HashMap<String,Long>();
if(!affected.isEmpty()) for(var row:allocations.references(affected.stream().map(n->current.stream().filter(r->n.equals(r.dispatchNo())).findFirst().orElseThrow().dispatchId()).toList())) {
if(row.recordVersion()==null || row.recordVersion()<0) throw conflict("原预约权威版本缺失");
var prior=versions.putIfAbsent(row.batchKey(),row.recordVersion());
if(prior!=null && !prior.equals(row.recordVersion())) throw conflict("同一批次权威版本不一致");
}
for(var number:affected) {
var rows=current.stream().filter(r->number.equals(r.dispatchNo())).toList();var first=rows.get(0);
boolean whole=rows.stream().allMatch(r->selected.contains(r.itemId()));
if(first.tripNo()!=null) fleet.unbindDispatch(first.tripNo(),operator,new FleetTripModels.BindingCommand(trips.findByNo(first.tripNo()).getRecordVersion(),number,reason));
if(whole) {
allocations.remove(first.dispatchId());
for(var row:rows) allocations.insert(new AppointmentWorkDispatchMapper.Bound(row.dispatchId(),row.itemId(),row.palletId(),intents.get(source(row)).laterBatchKey(),row.customerId(),row.sourceKey(),row.destinationKey(),row.packages()));
next.add("沿原排车重新安排后送车辆");
} else {
for(var item:rows.stream().filter(r->selected.contains(r.itemId())).map(AppointmentWorkDispatchMapper.TransportRow::itemId).distinct().sorted().toList()) dispatches.removeItem(number,item);
if(first.tripNo()!=null) fleet.bindDispatch(first.tripNo(),operator,new FleetTripModels.BindingCommand(trips.findByNo(first.tripNo()).getRecordVersion(),number,reason));
next.add("按后送批次重新选择已移出的实物");
}
var physical=allocations.dispatchPhysical(number).stream().filter(p->selected.contains(p.itemId()))
.map(p->new DispatchAppointmentAmendmentPort.PhysicalItem(p.itemId(),p.customerId(),p.planPalletId(),p.sourceStage(),p.packages())).toList();
var expected=rows.stream().filter(r->selected.contains(r.itemId())).map(r->{
var version=versions.get(r.batchKey());if(version==null) throw conflict("原预约批次权威引用缺失");
return new BatchAllocation(r.palletId(),r.batchKey(),r.customerId(),r.sourceKey(),r.destinationKey(),r.packages(),version);
}).toList();
amendments.applyExistingAppointmentChange(actor(operator),number,new ReasonCommand(first.dispatchVersion(),parentCommandKey,reason),physical,expected,List.of(),true);
next.add("按后送批次重新选择已移出的实物");
next.add("核对原车剩余货物及发车安排");
}
for(var number:context.tripNos()) {
var detail=fleet.detail(number);
if(detail.dispatches().isEmpty() && !reservations.hasConfirmed(trips.findByNo(number).getId())) {
// The original cancellation command also checks external reservations and releases vehicle/driver resources.
fleet.cancel(number,operator,new FleetTripModels.Cancel(detail.trip().recordVersion(),reason));
next.add("原车无有效货物,已按车辆业务取消");
} else next.add("核对原车剩余货物及发车安排");
}
if(!affected.isEmpty()) changes.requirementsChanged();
var follow=new LinkedHashSet<>(context.batches());intents.values().stream().map(Move::laterBatchKey).filter(Objects::nonNull).forEach(follow::add);
return new Result(vehicles(read(List.copyOf(follow),context.tripNos())),List.copyOf(next));
}
@@ -144,7 +141,7 @@ public class FbaAppointmentTransportAdapter implements FbaAppointmentTransportPo
private Scope scope(List<String> batches,List<AppointmentWorkDispatchMapper.TransportRow> rows) {
var reasons=new LinkedHashSet<String>();
for(var row:rows) if(batches.contains(row.batchKey())) {
if(!"PENDING_DEPARTURE".equals(row.dispatchStatus()) || row.pickedPackages()!=null && row.pickedPackages()>0 || row.shippedPackages()!=null && row.shippedPackages()>0
if(!"ACTIVE".equals(row.dispatchStatus()) || !"BEFORE_LOADING".equals(row.dispatchStage()) || row.dispatchVersion()==null || row.dispatchVersion()<0 || row.pickedPackages()!=null && row.pickedPackages()>0 || row.shippedPackages()!=null && row.shippedPackages()>0
|| row.taskStatus()!=null && !"PDA_PENDING".equals(row.taskStatus()) || row.outboundStatus()!=null && !"WAIT_OUTBOUND".equals(row.outboundStatus())) reasons.add("排车已开始执行,不能普通移出;请进入原运输异常处理");
if(row.tripNo()!=null && !"PLANNED".equals(row.tripStatus())) reasons.add("原车次已开始执行,不能普通移出");
if(row.itemPackages()==null || row.packages()<=0) reasons.add("原排车实物数量不完整");
@@ -152,8 +149,10 @@ public class FbaAppointmentTransportAdapter implements FbaAppointmentTransportPo
return new Scope(digest(batches+":"+rows),vehicles(rows),List.copyOf(reasons));
}
private List<Vehicle> vehicles(List<AppointmentWorkDispatchMapper.TransportRow> rows) {
return rows.stream().collect(Collectors.groupingBy(r->List.of(r.dispatchNo(),r.batchKey()),LinkedHashMap::new,Collectors.toList())).values().stream().map(group->{
return rows.stream().collect(Collectors.groupingBy(r->List.of(r.dispatchNo(),r.batchKey(),r.customerId()),LinkedHashMap::new,Collectors.toList())).values().stream().map(group->{
var r=group.get(0);return new Vehicle(r.tripNo(),r.tripVersion()==null?null:Math.toIntExact(r.tripVersion()),r.dispatchNo(),r.customerId(),r.batchKey(),r.dispatchStatus(),group.stream().mapToInt(AppointmentWorkDispatchMapper.TransportRow::packages).sum());
}).toList();
}
/** 操作者来自内部接收流程,不从分配DTO接受平台身份。 */
private Actor actor(String operator) { return new Actor(operator,ActorMode.PLATFORM,null); }
}