fix(appointment): exclude cancelled batches without active allocations
This commit is contained in:
+14
-6
@@ -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<Requirement> 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());
|
||||
}
|
||||
}
|
||||
|
||||
+7
-2
@@ -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());
|
||||
}
|
||||
|
||||
+41
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user