fix(dispatch): cancel planned cargo without loading readiness

This commit is contained in:
2026-10-22 09:08:26 +08:00
parent 5783610154
commit 0c05c15e5a
4 changed files with 88 additions and 1 deletions
@@ -373,7 +373,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA
var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion());
if(trim(request.reason())==null)throw bad("取消原因不能为空");
if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消");
var items=repository.cargo(row.getId());lockExecutionSources(row,items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
// 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。
var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items);
releaseSources(row,actor,items,now());
fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消");
return saveCommand(row,actor,identity,request.reason());
@@ -382,6 +382,18 @@ public class DispatchService {
});
}
/** 取消只锁本车待释放的申请与原实托,不以当前库存、实收或组托完整性作为撤销安排的资格。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public void lockCancellationSources(String warehouse,List<DispatchItem> items) {
if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("取消排车仍须明确起运仓库");
var applications=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType()))
.map(DispatchItem::getSourceBusinessNo).collect(Collectors.toCollection(TreeSet::new));
// 先锁实际需要恢复预占的换标申请,再取得共同实托串行点;计划来源不伪造实托或查询库存。
if(!applications.isEmpty())lockRelabelApplications(applications);
var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList();
if(!pallets.isEmpty())palletSerial.lock(pallets);
}
/** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */
@Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY)
public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List<DispatchItem> items) {
@@ -201,6 +201,51 @@ public class DispatchAggregatePersistenceTest {
verifyNoInteractions(facts,sources,serial);
}
/** 有货取消只释放原安排;未实收计划和已有实托都不重验当前货物资格,也不扣减或恢复库存。 */
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(booleans={true,false})
void cancellationReleasesOriginalClaimsWithoutRequiringCurrentCargoReadiness(boolean planned) throws Exception {
var f=new Fixture();var a=f.source(1,"101",501,"ONT8",10);var b=f.source(2,"101",502,"ONT8",10);
if(planned)for(var row:f.candidates.values())row.warehousePalletId(null).sourceStage("PLAN");
var created=f.tx.execute(t->f.service.create(f.actor,f.create("cargo-cancel",List.of(a,b))));
var root=f.repository.find(created.dispatchNo());
var originalCargo=f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id");
var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class);
var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class);
org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts);
org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources);
org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial);
when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("当前货物不满足装车条件"));
org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules);
var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-cargo-original","仅撤销运输安排");
var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));
assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);
assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue();
assertThat(f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id")).isEqualTo(originalCargo);
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='cancel-cargo-original'",Long.class)).isOne();
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip WHERE id=?",String.class,root.getTripId())).isEqualTo("CANCELLED");
verifyNoInteractions(facts,sources);
if(planned)verifyNoInteractions(serial);else {verify(serial).lock(List.of(501L,502L));verifyNoMoreInteractions(serial);}
}
/** 撤销安排不校验装车就绪,但真实扫码和旧版本仍阻断且不释放原占用。 */
@Test void cancellationStillRejectsStaleVersionAndRealPickWithZeroHeaderCounts() throws Exception {
var f=new Fixture();var source=f.source(1,"101",501,"ONT8",10);
var created=f.tx.execute(t->f.service.create(f.actor,f.create("guarded-cancel",List.of(source))));
var stale=new DispatchCommands.ReasonCommand(created.recordVersion()+1,"stale-cancel","旧版本不能释放占用");
assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),stale))).hasMessageContaining("版本已变化");
long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item",Long.class);
f.db.update("INSERT INTO ll_dispatch_pda_pick(customer_id,dispatch_item_id,packages)VALUES('101',?,1)",item);
var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"picked-cancel","不能隐去真实扫码");
assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request))).hasMessageContaining("真实执行");
assertThat(f.db.queryForObject("SELECT status FROM ll_dispatch_order",String.class)).isEqualTo("ACTIVE");
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isOne();
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isZero();
}
/** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */
@Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception {
var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of())));
@@ -57,6 +57,35 @@ class DispatchServiceTest {
assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("完整实托来源已变化");
verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial);
}
/** 未实收计划撤销不依赖实托、当前货源资格或库存,执行链仍保留独立严格校验。 */
@Test void cancellationOfPlannedCargoNeedsNoPhysicalOrInventoryFacts(){
var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem();item.setPlanPalletId(1L);item.setSourceStage("PLAN");
service.lockCancellationSources("9",List.of(item));
service.lockCancellationSources("9",List.of());
verifyNoInteractions(facts,sources,palletSerial,relabels,measurements);
}
/** 已有实托只按原身份取得升序串行锁,不因货物数量或新来源变化阻断释放本车占用。 */
@Test void physicalCancellationLocksOriginalPalletsWithoutReadingCurrentStock(){
var a=new com.baiyue.lastleg.dispatch.entity.DispatchItem();a.setWarehousePalletId(502L);
var b=new com.baiyue.lastleg.dispatch.entity.DispatchItem();b.setWarehousePalletId(501L);
service.lockCancellationSources("9",List.of(a,b,a));
verify(palletSerial).lock(List.of(501L,502L));verifyNoMoreInteractions(palletSerial);
verifyNoInteractions(facts,sources,relabels,measurements);
}
/** 换标恢复预占仍先锁齐本车申请再锁原实托,不锁入后来新增的其他申请。 */
@Test void relabelCancellationLocksSelectedApplicationsBeforeOriginalPallets(){
var a=new com.baiyue.lastleg.dispatch.entity.DispatchItem();a.setSourceBusinessType("RELABEL_ORDER");a.setSourceBusinessNo("RL-2");a.setWarehousePalletId(502L);
var b=new com.baiyue.lastleg.dispatch.entity.DispatchItem();b.setSourceBusinessType("RELABEL_ORDER");b.setSourceBusinessNo("RL-1");b.setWarehousePalletId(501L);
org.springframework.transaction.support.TransactionSynchronizationManager.initSynchronization();
try {
service.lockCancellationSources("9",List.of(a,b));
var order=inOrder(facts,palletSerial);order.verify(facts).lockApplications(List.of("RL-1","RL-2"));order.verify(palletSerial).lock(List.of(501L,502L));
verifyNoMoreInteractions(facts,palletSerial);verifyNoInteractions(sources,relabels,measurements);
} finally {
for(var synchronization:org.springframework.transaction.support.TransactionSynchronizationManager.getSynchronizations())synchronization.afterCompletion(0);
org.springframework.transaction.support.TransactionSynchronizationManager.clearSynchronization();
}
}
@Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){
assertThatThrownBy(()->service.candidatePage("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign",1,20,"ALL"))).hasMessageContaining("草稿");verifyNoInteractions(sources);
}