diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index a5af12d9e..62e2b1bac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -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()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 09aa03622..3d9e901ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -382,6 +382,18 @@ public class DispatchService { }); } + /** 取消只锁本车待释放的申请与原实托,不以当前库存、实收或组托完整性作为撤销安排的资格。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockCancellationSources(String warehouse,List 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 items) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index f717ac4e1..e55866488 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -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()))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 9962d2043..4db465dd5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -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); }