fix(lastleg): preserve source validation during partial dispatch picking

This commit is contained in:
2026-10-23 04:42:16 +08:00
parent f946285bcc
commit f6c83125c9
2 changed files with 33 additions and 3 deletions
@@ -133,8 +133,10 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo
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)
:received?mapper.receivedPhysical(item.planPalletId(),customer):mapper.physical(item.planPalletId(),customer);
var physical=new ArrayList<>(picked?mapper.pickedPhysical(item.itemId(),customer)
:received?mapper.receivedPhysical(item.planPalletId(),customer):mapper.physical(item.planPalletId(),customer));
// 扫码已从库位扣减;审核与后续扫码须合并本明细已拣逐票归属,发车仍只认真实已拣。
if(received && !picked) physical.addAll(mapper.pickedPhysical(item.itemId(),customer));
if(physical.isEmpty()) throw conflict("托盘缺少真实逐票分配,不能按整票或整托总数猜测批次");
var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources);
var remaining=new HashMap<String,Long>();
@@ -59,14 +59,16 @@ class AppointmentWorkDispatchBindingTest {
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(mapper.receivedPhysical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4)));
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of());
var departure=java.time.Instant.parse("2026-10-18T12:00:00Z");
var arrival=java.time.Instant.parse("2026-10-18T17:00:00Z");
clearInvocations(mapper);
assertNull(service.reviewBlockReason("D-20",departure,arrival));
verify(mapper,never()).pickedPhysical(anyLong(),anyString());
verify(mapper).pickedPhysical(30,"C");
assertEquals(arrival,service.reviewEvidence("D-20").get(0).startAt(),"10点洛杉矶预约应按当地时区解释");
// 目的仓编号及时间由整车入口校验;此端口只核实真实来源分配。
assertNull(service.reviewBlockReason("D-20",departure,null));
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4)));
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertTrue(service.departureBlockReason("D-20").contains("分配量"));
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
@@ -85,6 +87,7 @@ class AppointmentWorkDispatchBindingTest {
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(null);
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(mapper.receivedPhysical(10,"C")).thenReturn(List.of());
assertNull(service.reviewBlockReason("D-20",departure,arrival));
verifyNoInteractions(changes);
}
@@ -122,6 +125,31 @@ class AppointmentWorkDispatchBindingTest {
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",null,4)));
assertDoesNotThrow(()->service.requireDeparture("D-20"));verifyNoInteractions(appointments);
}
@Test void partialPickingKeepsReviewEligibleButDepartureStillRequiresAllOriginalSources() {
var mapper=mock(AppointmentWorkDispatchMapper.class);var sources=mock(AppointmentSourceService.class);
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),sources,mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.class));
when(mapper.dispatchPhysical("D")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
when(mapper.bindings("D")).thenReturn(List.of(
new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","FBA:A",1),
new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:2","FBA:A",3)));
when(sources.canonicalOrders(anyList())).thenAnswer(call->((List<AppointmentExternalSourceGateway.OrderRef>)call.getArgument(0)).stream()
.map(ref->new AppointmentCoverageService.SourceRef(ref.customerId(),"ORDER:"+ref.orderId(),null)).toList());
var first=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:A",null,1);
var second=new AppointmentWorkDispatchMapper.Physical(2L,"C","FBA:A",null,3);
when(mapper.receivedPhysical(10,"C")).thenReturn(List.of(second));
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(first));
assertNull(service.reviewBlockReason("D",null,null));
assertNotNull(service.departureBlockReason("D"));
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(3L,"C","FBA:A",null,1)));
assertNotNull(service.reviewBlockReason("D",null,null),"同箱数的其他来源不能补足原分配");
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of());
assertNotNull(service.reviewBlockReason("D",null,null),"缺失真实扫码事实不能推算补齐");
when(mapper.receivedPhysical(10,"C")).thenReturn(List.of());
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(first,second));
assertNull(service.reviewBlockReason("D",null,null));
assertNull(service.departureBlockReason("D"));
verify(mapper,atLeastOnce()).pickedPhysical(30,"C");
}
@Test void repeatedPhysicalRowsForSameOrderResolveByIdentityWithoutIndexingDistinctResults() {
var sources=mock(AppointmentSourceService.class);
var one=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:A",4,4);