diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java index 027305808..8d37dbb0e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java @@ -20,6 +20,7 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) { this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping; } + /** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */ public List requirements(ContainerSource container,Flow effectiveFlow) { var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); if(effectiveFlow==Flow.DIRECT) return original; @@ -53,9 +54,14 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa // Unknown remaining quantity is still work, never a zero or an absent requirement. boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList(); - if(remainder) result.put(requirement.requirementKey(),new Requirement(requirement.requirementKey(),null,null,requirement.destinationKind(), - requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),replaced, - complete.stream().map(r->new SourceReference(r.customerId(),r.sourceKey(),r.destinationKey())).toList())); + if(remainder) { + var restored=new LinkedHashSet<>(replaced); + // 回到未分配需求时,同时承接原批次运输事项和按客户拆分的送仓事项。 + complete.forEach(ref->replaced.forEach(key->restored.add(customerRequirement(key,ref.customerId())))); + result.put(requirement.requirementKey(),new Requirement(requirement.requirementKey(),null,null,requirement.destinationKind(), + requirement.originName(),requirement.destinationName(),requirement.timezone(),requirement.references(),List.copyOf(restored), + complete.stream().map(r->new SourceReference(r.customerId(),r.sourceKey(),r.destinationKey())).toList())); + } for(var batch:active) { var replacementKeys=new LinkedHashSet<>(replaced); replacementKeys.add(requirement.requirementKey()); history.stream().filter(a->a.replacementBatchKeys()!=null && a.replacementBatchKeys().contains(batch.batchKey())).map(FbaAppointmentResponse::batchKey).forEach(replacementKeys::add); @@ -118,9 +124,11 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING"); return result.stream().distinct().toList(); } + /** 已取消且没有当前实物分配的记录只属历史;取消但仍有货物的批次继续产生需求。 */ private static boolean active(FbaAppointmentResponse batch) { - // Cancelling a booking clears its schedule, not its physical allocation. Only an - // authoritative replacement retires the batch and its delivery/vehicle obligations. - return batch.replacementBatchKeys()==null || batch.replacementBatchKeys().isEmpty(); + if(batch.replacementBatchKeys()!=null && !batch.replacementBatchKeys().isEmpty()) return false; + // 客户来源取当前活动分配;空取消记录不能制造无货送仓事项,也不能阻断整份清单。 + // 剩余货量仍由 cargo.inspect 核算,有货的 CANCELLED/FAILED 批次不因预约状态退役。 + return !("CANCELLED".equals(batch.appointmentStatus()) && batch.items().isEmpty()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java index 3e54de54c..6a17d68cf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -60,14 +60,19 @@ class AppointmentWorkSyncServiceTest { when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(container),null,true,"scope")); assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,10))); } + /** 空取消批次回到原货源时退役客户送仓及运输事项,并使版本门禁恢复可读。 */ @Test void cancellationReopensOriginalDemandAndRestoringTheSameAppointmentDoesNotDuplicateWork() { var f=new WorkTestFixture(); - var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of("FBA_APPOINTMENT:1"),List.of()); - var batch=new Requirement("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1","AP-1","FBA",null,"ONT8",null,List.of(),List.of("INBOUND:1"),List.of(new SourceReference("C","ORDER:1","FBA:ONT8"))); + var refs=List.of(new SourceReference("C","ORDER:1","FBA:ONT8")); + var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1|CUSTOMER:C"),refs); + var batch=new Requirement("FBA_APPOINTMENT:1|CUSTOMER:C","FBA_APPOINTMENT:1","AP-1","FBA",null,"ONT8",null,List.of(),List.of("INBOUND:1"),refs); for(var requirement:List.of(root,batch,root,batch)) { + f.guard.setRequirementVersion(f.guard.getRequirementVersion()+1); + assertThrows(ResponseStatusException.class,f.store::requireInitialized); var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(requirement)); when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.sync.synchronize(new SyncRequest(null,10)); + assertDoesNotThrow(f.store::requireInitialized); var deliveries=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList(); assertEquals(1,deliveries.size()); assertEquals(requirement.requirementKey(),deliveries.get(0).getRequirementKey()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java index fa52a43d7..7a4ccc676 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java @@ -11,6 +11,47 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; class AppointmentWorkRequirementProviderTest { + /** 取消历史没有活动货物时退役旧事项,仍按权威分配保留新批次和未分配余量。 */ + @Test void cancelledEmptyHistoryDoesNotBlockCurrentBatchOrRestoreAlreadyAllocatedCargo() { + var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); + var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); + var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); + var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); + when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); + when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:11",null))); + var source=new AppointmentSourceService.Source("ORDER:11","C",11L,"IN-11",null,"CNT",null,null,"FBA:ONT8","ONT8",60,"v1",true,List.of()); + when(sources.resolveBatch(anyList())).thenReturn(List.of(source)); + var allocation=mock(AppointmentCargoVerificationService.Context.class); + when(cargo.inspect(source)).thenReturn(allocation);when(allocation.availablePackages()).thenReturn(0L); + var cancelled=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class); + when(cancelled.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(cancelled.appointmentStatus()).thenReturn("CANCELLED"); + when(cancelled.items()).thenReturn(List.of());when(cancelled.replacementBatchKeys()).thenReturn(List.of()); + var current=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class); + var item=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item.class); + when(item.customerId()).thenReturn("C");when(item.sourceKey()).thenReturn("ORDER:11");when(item.destinationKey()).thenReturn("FBA:ONT8"); + when(current.batchKey()).thenReturn("FBA_APPOINTMENT:2");when(current.appointmentNo()).thenReturn("AP-2"); + when(current.fbaWarehouseCode()).thenReturn("ONT8");when(current.items()).thenReturn(List.of(item));when(current.replacementBatchKeys()).thenReturn(List.of()); + when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled,current)); + var actual=provider.requirements(container,Flow.TRANSFER); + assertEquals(List.of("FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); + assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); + when(allocation.availablePackages()).thenReturn(20L); + actual=provider.requirements(container,Flow.TRANSFER); + assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1|CUSTOMER:C"),actual.get(0).replacesRequirementKeys()); + when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled)); + when(allocation.availablePackages()).thenReturn(60L); + actual=provider.requirements(container,Flow.TRANSFER); + assertEquals(List.of("INBOUND:1"),actual.stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); + assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); + // 非取消的空批次仍属缺失事实,不能静默跳过。 + when(cancelled.appointmentStatus()).thenReturn("PENDING"); + assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.requirements(container,Flow.TRANSFER)).getStatusCode().value()); + } + @Test void cancelledAndFailedBookingsRetainTheirAllocatedBatchAndOnlyAuthorityDefinesRemainder() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);