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 1601804d2..f29a998b4 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 @@ -524,7 +524,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车"); - lockExecutionSources(row,repository.cargo(row.getId())); + var items=repository.cargo(row.getId()); + // 未实收/未首拣的PLAN尚未物化实托,先按未核平拒绝,不能将空实托集合传给执行锁。 + if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平"); + lockExecutionSources(row,items); var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java new file mode 100644 index 000000000..fa43bf205 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */ +class DispatchLoadingEligibilityPersistenceTest { + /** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */ + @ParameterizedTest + @ValueSource(booleans={false,true}) + void unmaterializedPlanRejectsLoadingBeforePhysicalSourceLock(boolean mixed) throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var plan=f.source(1,"101",501,"ONT8",3); + var selections=mixed?List.of(plan,f.source(2,"202",502,"LAX9",2)):List.of(plan); + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("unreceived-loading",selections))); + var approved=f.approve(created); + f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN' WHERE plan_pallet_id=1"); + if(mixed) { + f.picked(2,"202",502,2); + f.db.update("UPDATE ll_dispatch_order SET picked_package_count=2"); + } + var providerRows=mock(RelabelDispatchSourceRepository.class); + var provider=spy(new RelabelDispatchSourceService(providerRows,f.json)); + var sources=mock(DispatchSourceRepository.class); + var rules=spy(new DispatchService()); + ReflectionTestUtils.setField(rules,"relabelSources",provider); + ReflectionTestUtils.setField(rules,"wholeSources",sources); + ReflectionTestUtils.setField(f.service,"sourceRules",rules); + var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo())); + assertThat(preview.pickingReconciled()).isFalse(); + assertThat(preview.loadingComplete().allowed()).isFalse(); + assertThat(preview.loadingComplete().blockCode()).isEqualTo("PICKING_INCOMPLETE"); + assertThat(preview.pallets()).hasSize(mixed?1:0); + var tables=List.of("ll_dispatch_order","ll_dispatch_item","ll_dispatch_command","ll_dispatch_history", + "ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock", + "ll_fleet_trip","ll_fleet_trip_history"); + var before=snapshot(f,tables); + var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例"); + assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request))) + .isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)) + .hasMessage("逐项真实拣货尚未核平"); + assertThat(snapshot(f,tables)).isEqualTo(before); + verify(rules,never()).lockExecutionSources(anyString(),anyList()); + verifyNoInteractions(provider,providerRows,sources,f.ownedBusinessOwnership); + } + + /** 按实际列值读取CLOB及时间,避免比较JDBC临时对象身份。 */ + private Object snapshot(DispatchAggregatePersistenceTest.Fixture f,List tables) { + return tables.stream().map(table->f.db.query("SELECT * FROM "+table+" ORDER BY id",(row,index)->{ + var values=new java.util.ArrayList(); + for(int column=1;column<=row.getMetaData().getColumnCount();column++)values.add(row.getString(column)); + return values; + })).toList(); + } +}