fix(dispatch): confirm scanned loading without repeated measurements

This commit is contained in:
2026-10-21 18:44:26 +08:00
parent 6088ff10c2
commit 538a63c635
20 changed files with 105 additions and 73 deletions
@@ -283,10 +283,8 @@ public class DispatchAggregatePersistenceTest {
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
assertThat(preview.pallets().get(0).weightKg()).isEqualByComparingTo("100");
assertThat(preview.pallets().get(0).evidence()).allMatch(DispatchPlanningModels.MeasurementEvidence::actual);
assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm","palletSpaces");
var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1,
p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList();
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对");
assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm");
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),"实际核对");
// 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。
org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing()
.when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class),
@@ -298,6 +296,10 @@ public class DispatchAggregatePersistenceTest {
assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero();
var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class);
var facts=((DispatchResults.Loaded)loaded.result()).confirmedLoad();
assertThat(facts.pallets()).hasSize(2);
assertThat(facts.pallets().get(0).weightKg()).isEqualByComparingTo("100");
assertThat(facts.pallets().get(1).weightKg()).isNull();assertThat(facts.pallets().get(1).volumeCbm()).isNull();
assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED");
var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request));
assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result());
@@ -316,7 +318,7 @@ public class DispatchAggregatePersistenceTest {
f.db.update("UPDATE ll_dispatch_pda_pick_order SET packages=2");
var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo()));
assertThat(preview.pickingReconciled()).isFalse();assertThat(preview.loadingComplete().allowed()).isFalse();
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平");
assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING");
}
/** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */
@@ -64,7 +64,7 @@ class DispatchDeparturePersistenceTest {
var created=f.tx.execute(s->f.service.create(f.actor,f.configured("depart-create",List.of(a,b))));number=created.dispatchNo();var approved=f.approve(created);
f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8");
var loading=f.tx.execute(s->f.service.loading(f.actor,number));var measurements=loading.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList();
f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),measurements,"实际实载")));
f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),"实际实载")));
var context=f.tx.execute(s->f.service.preview(f.actor,number));
outbounds=List.of(new OutboundReference("101","测试客户101","OUT-A","WAIT_OUTBOUND",3,1),new OutboundReference("202","测试客户202","OUT-B","WAIT_OUTBOUND",5,1));
deliveryRefs=List.of(new DeliveryReference("GROUP-A","101","测试客户101","OUT-A","DL-A",context.cargo().get(0).destination(),"WAIT_DELIVERY",3,1),new DeliveryReference("GROUP-B","202","测试客户202","OUT-B","DL-B",context.cargo().get(1).destination(),"WAIT_DELIVERY",5,1));
@@ -84,7 +84,7 @@ class DispatchLoadingDeparturePersistenceTest {
@Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception {
authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9");
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓");
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null);
var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",null);
assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化");
verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());
var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING);
@@ -243,7 +243,7 @@ class DispatchLoadingDeparturePersistenceTest {
return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index))));
}
/** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");}
DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,"实装发车");}
/** 两客户子出库保持独立业务号和箱数。 */
List<OutboundReference> outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));}
/** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */
@@ -50,7 +50,7 @@ class DispatchLoadingEligibilityPersistenceTest {
"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(),"未实收负例");
var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),"未实收负例");
assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request)))
.isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT))
.hasMessage("逐项真实拣货尚未核平");
@@ -96,7 +96,7 @@ class DispatchMergedRelabelPersistenceTest {
var preview=f.f.service.loading(f.f.actor,created.dispatchNo());assertThat(preview.pallets()).hasSize(1);assertThat(preview.pallets().get(0).scheduledPackages()).isEqualTo(5);
var pallet=preview.pallets().get(0);
var loaded=f.f.tx.execute(s->f.f.service.loadingComplete(f.picker,created.dispatchNo(),new DispatchCommands.LoadingComplete(last.recordVersion(),"load",preview.manifestHash(),
List.of(new DispatchContracts.LoadingMeasurement(501L,1,new BigDecimal("10"),BigDecimal.ONE,pallet.deliveryIndex()==null?1:pallet.deliveryIndex())),null)));
null)));
assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);
assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2);
}
@@ -53,4 +53,18 @@ class TripCapacityPolicyTest {
@Test void emptyTripRetainsCapacityOnEachSegment() {
assertThat(calculate(3, capacity, List.of())).allSatisfy(segment -> assertThat(segment.remaining()).isEqualTo(capacity));
}
@Test void thirtyActualPalletsWithoutMeasurementsDoNotBecomeZeroWeightOrSellableCapacity() {
var pallets=java.util.stream.IntStream.range(0,30).mapToObj(i->new Booking("P"+i,0,1,new Load(1,null,null))).toList();
var segment=calculateActual(2,capacity,pallets).get(0);
assertThat(segment.occupied().palletSpaces()).isEqualTo(30);
assertThat(segment.occupied().weightKg()).isNull();assertThat(segment.remaining().volumeCbm()).isNull();
assertThatThrownBy(()->calculate(2,capacity,pallets)).hasMessageContaining("重量和体积");
var tooMany=new java.util.ArrayList<>(pallets);tooMany.add(new Booking("EXTRA",0,1,new Load(1,null,null)));
assertThatThrownBy(()->calculateActual(2,capacity,tooMany)).hasMessageContaining("超过");
}
@Test void unknownPalletDoesNotHideKnownOverweight() {
assertThatThrownBy(()->calculateActual(2,capacity,List.of(new Booking("UNKNOWN",0,1,new Load(1,null,null)),
new Booking("KNOWN",0,1,load(1,"18001","1"))))).hasMessageContaining("超过");
}
}