优化
This commit is contained in:
+2
-1
@@ -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);
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
|
||||
+12
-9
@@ -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("共同预约范围已变化,请重新核对全部客户");
|
||||
|
||||
+116
-53
@@ -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("预约批次键无效"); }
|
||||
}
|
||||
}
|
||||
|
||||
+57
-1
@@ -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()));
|
||||
|
||||
+52
-13
@@ -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<>'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<>'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}")
|
||||
|
||||
+16
-9
@@ -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,"本客户运输已开始执行,不能普通重新预约");
|
||||
}
|
||||
|
||||
+38
-39
@@ -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); }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user