perf(lastleg): batch appointment requirement generation reads

This commit is contained in:
2026-10-17 12:54:39 +08:00
parent 662dead053
commit b0acbfbb34
21 changed files with 747 additions and 66 deletions
@@ -259,6 +259,12 @@ class FbaAppointmentPersistenceTest {
@Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});return mock;}
@Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();}
@Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);}
/** 新增只读原料端口仍接实际 Mapper;本测试未涉及船节点查询。 */
@Bean AppointmentSourceReadRepository sourceReads(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,AppointmentCoverageMapper facts){return new AppointmentSourceReadRepository(orders,mock(com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper.class),facts);}
/** 核实原料沿真实持久 Mapper 读取,不替代事务断言。 */
@Bean AppointmentCargoReadRepository cargoReads(AppointmentCargoVerificationMapper mapper){return new AppointmentCargoReadRepository(mapper);}
/** 历史预约原料沿本测试真实主单、明细及查询 Mapper 读取。 */
@Bean com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads(InboundFbaAppointmentQueryMapper query,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items){return new com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository(mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class),query,appointments,items);}
@Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;}
@Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;}
@Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);}
@@ -0,0 +1,244 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper.RequirementSource;
import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;
import org.apache.ibatis.session.SqlSession;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
import java.util.ArrayList;
import java.util.Collections;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class AppointmentRequirementReadRepositoryTest {
/** 关联依据是原客户、来源及目的仓,不能因失活、取消或响应明细变空而消失。 */
@Test
void historyKeepsInactiveAndReplacedAssociationsWithoutCrossCustomerOrDestinationLeak() throws Exception {
try (var fixture=new Fixture()) {
fixture.appointment(10,"CANCELLED",null,null);
fixture.appointment(20,"CANCELLED","[\"AP-30\"]","[\"FBA_APPOINTMENT:30\"]");
fixture.appointment(30,"PENDING",null,null);
fixture.item(101,10,"A","ORDER:1","ABE4",false);
fixture.item(201,20,"A","ORDER:1","ABE4",false);
fixture.item(301,30,"A","ORDER:1","ABE4",true);
fixture.item(302,30,"B","ORDER:2","ABE4",true);
fixture.item(401,40,"B","ORDER:1","ABE4",true);
fixture.item(501,50,"A","ORDER:1","ONT8",true);
var refs=List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"),
new RequirementSource(0,"A","ORDER:1","FBA:ABE4"),
new RequirementSource(1,"B","ORDER:2","FBA:ABE4"),
new RequirementSource(2,"B","ORDER:1","FBA:ABE4"));
var history=fixture.repository.history(refs);
assertThat(history).filteredOn(row->row.groupOrdinal()==0).extracting(row->row.appointmentId()).containsExactly(30L,20L,10L);
assertThat(history).filteredOn(row->row.groupOrdinal()==1).extracting(row->row.appointmentId()).containsExactly(30L);
assertThat(history).filteredOn(row->row.groupOrdinal()==2).extracting(row->row.appointmentId()).containsExactly(40L);
assertThat(fixture.repository.items(List.of(10L,20L,30L))).hasSize(4)
.filteredOn(row->!Boolean.TRUE.equals(row.getActive())).extracting(InboundFbaAppointmentItem::getId).containsExactly(101L,201L);
assertThat(fixture.repository.appointments(List.of(20L))).singleElement().satisfies(row->{
assertThat(row.getReplacementNosJson()).isEqualTo("[\"AP-30\"]");
assertThat(row.getReplacementKeysJson()).isEqualTo("[\"FBA_APPOINTMENT:30\"]");
});
var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of(
List.of(new SourceRef("A","ORDER:1","FBA:ABE4")),List.of(new SourceRef("B","ORDER:2","FBA:ABE4"))));
assertThat(prepared.apply(0)).extracting(row->row.id()).containsExactly(10L,20L,30L);
assertThat(prepared.apply(0)).filteredOn(row->row.id()==10).singleElement().satisfies(row->{
assertThat(row.appointmentStatus()).isEqualTo("CANCELLED");
assertThat(row.items()).isEmpty();
});
assertThat(prepared.apply(0)).filteredOn(row->row.id()==20).singleElement().satisfies(row->{
assertThat(row.items()).hasSize(1);
assertThat(row.replacementBatchKeys()).containsExactly("FBA_APPOINTMENT:30");
});
assertThat(prepared.apply(1)).singleElement().satisfies(row->{
assertThat(row.id()).isEqualTo(30L);
assertThat(row.items()).extracting(item->item.customerId()).containsExactly("A","B");
});
}
}
/** SQL 对每组各保留第1001条哨兵,不能以所有组总数截断历史。 */
@Test
void windowLimitIsPerRequirementGroupAndDuplicateReferencesDoNotUseUpTheLimit() throws Exception {
try (var fixture=new Fixture()) {
for(int i=1;i<=1002;i++)fixture.item(i,i,"A","ORDER:1","ABE4",i%2==0);
for(int i=1;i<=1000;i++)fixture.item(10000+i,20000+i,"B","ORDER:2","ABE4",true);
var rows=fixture.repository.history(List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"),
new RequirementSource(0,"A","ORDER:1","FBA:ABE4"),new RequirementSource(1,"B","ORDER:2","FBA:ABE4")));
assertThat(rows).hasSize(2001);
assertThat(rows).filteredOn(row->row.groupOrdinal()==0).hasSize(1001)
.extracting(row->row.appointmentId()).startsWith(1002L).endsWith(2L).doesNotContain(1L);
assertThat(rows).filteredOn(row->row.groupOrdinal()==1).hasSize(1000);
}
}
/** 空输入直接结束,不向数据库提交无效IN列表。 */
@Test
void emptyInputsReturnNoRows() throws Exception {
try (var fixture=new Fixture()) {
assertThat(fixture.repository.history(List.of())).isEmpty();
assertThat(fixture.repository.appointments(List.of())).isEmpty();
assertThat(fixture.repository.items(List.of())).isEmpty();
assertThat(fixture.repository.sources(List.of())).isEmpty();
}
}
/** 100个来源和200个预约的分块不会退化成逐组SQL,预备结果重读不再访问数据库。 */
@Test
void manyGroupsUseBoundedRealQueriesAndPreparedResponsesDoNotReadAgain() throws Exception {
try(var fixture=new Fixture()) {
var groups=new ArrayList<List<SourceRef>>();
for(int i=1;i<=201;i++) {
fixture.appointment(i,"PENDING",null,null);
fixture.item(i,i,"A","ORDER:"+i,"ABE4",true);
groups.add(List.of(new SourceRef("A","ORDER:"+i,"FBA:ABE4")));
}
var prepared=fixture.service.prepareRelatedBatchSnapshots(groups);
for(int i=0;i<201;i++)assertThat(prepared.apply(i)).extracting(row->row.id()).containsExactly((long)i+1);
assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(3);
assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(2);
assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(2);
int before=fixture.counter.statements.size();
assertThat(prepared.apply(0)).hasSize(1);
assertThat(fixture.counter.statements).hasSize(before);
}
}
/** 同一需求的来源跨SQL分块时,相同预约只归属一次,空来源组仍独立。 */
@Test
void oneGroupCrossingTheSourceChunkBoundaryDeduplicatesItsOriginalAppointment() throws Exception {
try(var fixture=new Fixture()) {
fixture.appointment(30,"PENDING",null,null);
fixture.item(301,30,"A","ORDER:1","ABE4",true);
fixture.item(302,30,"A","ORDER:2","ABE4",true);
var first=new ArrayList<SourceRef>();
for(int i=0;i<99;i++)first.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4"));
var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of(first,List.of(
new SourceRef("A","ORDER:1","FBA:ABE4"),new SourceRef("A","ORDER:2","FBA:ABE4")),List.of()));
assertThat(prepared.apply(0)).isEmpty();
assertThat(prepared.apply(1)).singleElement().satisfies(row->{
assertThat(row.id()).isEqualTo(30L);assertThat(row.items()).hasSize(2);
});
assertThat(prepared.apply(2)).isEmpty();
assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2);
assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(1);
}
}
/** 每组1000条允许,1001条和101个原来源仅在访问本组时拒绝,不拖垮其他组。 */
@Test
void groupLimitsAreCheckedAfterCompleteMergeAndErrorsStayWithTheirOriginalGroup() throws Exception {
try(var fixture=new Fixture()) {
for(int i=1;i<=1000;i++) {
fixture.appointment(i,"PENDING",null,null);
fixture.item(i,i,"A","ORDER:1","ABE4",true);
}
for(int i=1;i<=1001;i++)fixture.item(10000+i,20000+i,"B",i<=600?"ORDER:2":"ORDER:3","ABE4",false);
var validGroup=new ArrayList<SourceRef>();
validGroup.add(new SourceRef("A","ORDER:1","FBA:ABE4"));
for(int i=0;i<98;i++)validGroup.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4"));
// 超限组的600条与401条来自两个SQL块,必须合并后才能准确拒绝。
var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of(
validGroup,
List.of(new SourceRef("B","ORDER:2","FBA:ABE4"),new SourceRef("B","ORDER:3","FBA:ABE4")),
Collections.nCopies(101,new SourceRef("A","ORDER:1","FBA:ABE4"))));
assertThat(prepared.apply(0)).hasSize(1000);
assertThatThrownBy(()->prepared.apply(1)).isInstanceOfSatisfying(ResponseStatusException.class,
error->assertThat(error.getStatusCode().value()).isEqualTo(503));
assertThatThrownBy(()->prepared.apply(2)).isInstanceOfSatisfying(ResponseStatusException.class,
error->assertThat(error.getStatusCode().value()).isEqualTo(400));
assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(5);
assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(5);
assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2);
}
}
@Intercepts(@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class}))
private static final class QueryCounter implements Interceptor {
final List<String> statements=new ArrayList<>();
/** 记录真实Mapper执行入口,调用仍继续进入原SQL执行器。 */
@Override public Object intercept(Invocation invocation) throws Throwable {
statements.add(((MappedStatement)invocation.getArgs()[0]).getId());return invocation.proceed();
}
/** 按具体Mapper语句核对批量读取次数。 */
long count(String suffix) { return statements.stream().filter(id->id.endsWith(suffix)).count(); }
}
private static final class Fixture implements AutoCloseable {
final JdbcTemplate jdbc;
final SqlSession session;
final AppointmentRequirementReadRepository repository;
final InboundFbaAppointmentService service;
final QueryCounter counter=new QueryCounter();
/** 只在独立H2内使用基线表,并装配真实生产Mapper。 */
Fixture() throws Exception {
var datasource=new JdbcDataSource();
datasource.setURL("jdbc:h2:mem:requirement-history-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
jdbc=new JdbcTemplate(datasource);
String baseline;
try(var input=getClass().getResourceAsStream("/db/lastleg.sql")) {
baseline=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);
}
for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) {
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");
assertThat(start).isGreaterThanOrEqualTo(0);
jdbc.execute(baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";"));
}
var config=new MybatisConfiguration();
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),datasource));
config.setMapUnderscoreToCamelCase(true);
config.addInterceptor(counter);
config.addMapper(AppointmentWorkSourceMapper.class);
config.addMapper(InboundFbaAppointmentQueryMapper.class);
config.addMapper(InboundFbaAppointmentMapper.class);
config.addMapper(InboundFbaAppointmentItemMapper.class);
session=new MybatisSqlSessionFactoryBuilder().build(config).openSession();
repository=new AppointmentRequirementReadRepository(session.getMapper(AppointmentWorkSourceMapper.class),
session.getMapper(InboundFbaAppointmentQueryMapper.class),session.getMapper(InboundFbaAppointmentMapper.class),
session.getMapper(InboundFbaAppointmentItemMapper.class));
service=new InboundFbaAppointmentService();
ReflectionTestUtils.setField(service,"requirementReads",repository);
ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules());
}
/** 建立真实预约头,取消和替代分别保留原状态与去向。 */
void appointment(long id,String status,String replacementNos,String replacementKeys) {
jdbc.update("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by,appointment_status,replacement_nos_json,replacement_keys_json) VALUES(?,?,'WH','ABE4','TEST',?,?,?)",
id,"AP-"+id,status,replacementNos,replacementKeys);
}
/** 建立原来源历史,不以活动标记代替来源归属。 */
void item(long id,long appointment,String customer,String source,String destination,boolean active) {
jdbc.update("INSERT INTO ll_inbound_fba_appointment_item(id,appointment_id,customer_id,source_key,fba_warehouse_code,active,package_count) VALUES(?,?,?,?,?,?,1)",
id,appointment,customer,source,destination,active?1:0);
}
/** 测试结束关闭本地会话,不接触任何共享数据库。 */
@Override public void close() { session.close(); }
}
}
@@ -11,11 +11,32 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkRequirementProviderTest {
/** 一组前置未就绪只保留自己的原义务,同页其他客户的需求与独立读取一致。 */
@Test void pagePreparationKeepsUnresolvedGroupsLocalAndMatchesIndependentReads() {
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);
var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
scoped(sources);var provider=provider(sources,cargo,appointments,mapping);
when(mapping.containerSources(anyString())).thenAnswer(c->{String id=c.getArgument(0);return List.of(new AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",10));});
when(sources.canonicalOrders(anyList())).thenAnswer(c->c.<List<AppointmentExternalSourceGateway.OrderRef>>getArgument(0).stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList());
when(sources.resolveBatch(anyList())).thenAnswer(c->{
var ref=c.<List<AppointmentCoverageService.SourceRef>>getArgument(0).get(0);
if(ref.sourceKey().equals("ORDER:1")) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"货物尚无已开船或后续实际船节点");
return List.of(new AppointmentSourceService.Source(ref.sourceKey(),ref.customerId(),2L,"IN2",null,"CNT2","100","仓","FBA:ONT8","ONT8",10,"v1",true,List.of()));
});
var available=mock(AppointmentCargoVerificationService.Context.class);when(available.availablePackages()).thenReturn(10L);when(cargo.inspect(any(AppointmentSourceService.Source.class))).thenReturn(available);
var containers=List.of("1","2").stream().map(id->new ContainerSource(id,"CNT"+id,null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"v1",Flow.TRANSFER,null,List.of(new Requirement("INBOUND:"+id,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList();
var page=provider.openRequirements(containers.stream().map(c->new RequirementInput(c,Flow.TRANSFER)).toList());
var first=page.read(containers.get(0),Flow.TRANSFER);var second=page.read(containers.get(1),Flow.TRANSFER);
assertEquals(List.of(new SourceReference("C1","ORDER:1",null)),first.get(0).sources());
assertEquals(List.of(new SourceReference("C2","ORDER:2","FBA:ONT8")),second.get(0).sources());
assertEquals(first,provider.openRequirements(List.of()).read(containers.get(0),Flow.TRANSFER));
assertEquals(second,provider.openRequirements(List.of()).read(containers.get(1),Flow.TRANSFER));
}
/** 取消历史没有活动货物时退役旧事项,仍按权威分配保留新批次和未分配余量。 */
@Test void cancelledEmptyHistoryDoesNotBlockCurrentBatchOrRestoreAlreadyAllocatedCargo() {
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);
var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
scoped(sources);var provider=provider(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)));
@@ -33,29 +54,29 @@ class AppointmentWorkRequirementProviderTest {
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.openRequirements().read(container,Flow.TRANSFER);
var actual=provider.openRequirements(java.util.List.of()).read(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.openRequirements().read(container,Flow.TRANSFER);
actual=provider.openRequirements(java.util.List.of()).read(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.openRequirements().read(container,Flow.TRANSFER);
actual=provider.openRequirements(java.util.List.of()).read(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.openRequirements().read(container,Flow.TRANSFER)).getStatusCode().value());
assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(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);
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
scoped(sources);var provider=provider(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)));
@@ -72,73 +93,87 @@ class AppointmentWorkRequirementProviderTest {
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
for(var status:List.of("CANCELLED","FAILED")) {
when(batch.appointmentStatus()).thenReturn(status);
var result=provider.openRequirements().read(container,Flow.TRANSFER);
var result=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER);
assertEquals(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"),result.stream().map(Requirement::requirementKey).toList());
assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),result.get(0).sources());
assertTrue(result.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1"));
}
when(allocation.availablePackages()).thenReturn(20L);
assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2"));
assertEquals(List.of("INBOUND:1"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
assertEquals(List.of("INBOUND:1"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
}
@Test void unbookableSourceKeepsItsPhysicalRequirementWhileAuthorityFailuresStillAbort() {
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);
var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
scoped(sources);var provider=provider(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",4)));
var refs=List.of(new AppointmentCoverageService.SourceRef("C","ORDER:11",null));
when(sources.canonicalOrders(anyList())).thenReturn(refs);
when(sources.resolveBatch(refs)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约"));
var actual=provider.openRequirements().read(container,Flow.TRANSFER);
var actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER);
assertEquals(1,actual.size());assertEquals("INBOUND:1",actual.get(0).requirementKey());
assertEquals(List.of(new SourceReference("C","ORDER:11",null)),actual.get(0).sources());
verifyNoInteractions(cargo,appointments);
doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对")).when(sources).resolveBatch(refs);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER));
}
@Test void privateDispatchKeepsPartialRemainderAndRestoresCancelledQuantity() {
var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class);
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
scoped(sources);var provider=provider(sources,cargo,appointments,mapping);
var requirement=new Requirement("INBOUND:1",null,null,"PRIVATE",null,"客户仓",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","PRIVATE",null,10)));
var batch=new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","PRE_ARRANGED",11,"C","ORDER:11","CUSTOMER:11",4);
when(mapping.privateDispatches("1")).thenReturn(List.of(batch));when(mapping.privateBatch("DISPATCH:20")).thenReturn(List.of(batch));
var manifest=provider.openRequirements().read(container,Flow.TRANSFER);
var manifest=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER);
assertEquals(List.of("INBOUND:1","DISPATCH:20"),manifest.stream().map(Requirement::requirementKey).toList());
assertEquals("D-20",manifest.get(1).batchNo());assertEquals("CUSTOMER:11",manifest.get(1).sources().get(0).destinationKey());
when(mapping.privateDispatches("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","CANCELLED",11,"C","ORDER:11","CUSTOMER:11",4)));
var restored=provider.openRequirements().read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys());
var restored=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys());
verifyNoInteractions(sources,cargo,appointments);
}
@Test void chosenDirectRoutePreservesInboundKeyBeforeAnyActualUnloading() {
var sources=mock(AppointmentSourceService.class); var cargo=mock(AppointmentCargoVerificationService.class);
var appointments=mock(InboundFbaAppointmentService.class); var mapping=mock(AppointmentWorkSourceMapper.class);
scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
scoped(sources);var provider=provider(sources,cargo,appointments,mapping);
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.UNKNOWN,null,List.of(requirement));
assertEquals(List.of(requirement),provider.openRequirements().read(source,Flow.DIRECT));
assertEquals(List.of(requirement),provider.openRequirements(java.util.List.of()).read(source,Flow.DIRECT));
verifyNoInteractions(sources,cargo,appointments,mapping);
}
/** 无尾程映射时保留原需求,不读取或依赖仓库目录。 */
@Test void missingLastlegSourcesDoNotOpenDirectoryScope() {
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 provider=provider(sources,cargo,appointments,mapping);
var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of());
var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement));
var reader=provider.openRequirements();verifyNoInteractions(sources,cargo,appointments,mapping);
var reader=provider.openRequirements(java.util.List.of());verifyNoInteractions(sources,cargo,appointments,mapping);
assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));verifyNoInteractions(sources,cargo,appointments);
}
/** 旧业务场景继续模拟相同原事实,查询形状由真实批量集成回归单独验证。 */
private static AppointmentWorkRequirementProvider provider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper mapping) {
var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping);
var reads=mock(AppointmentRequirementReadRepository.class);
when(reads.sources(anyList())).thenAnswer(c->c.<List<String>>getArgument(0).stream().flatMap(id->mapping.containerSources(id).stream().map(row->new AppointmentWorkSourceMapper.ContainerSource(id,row.id(),row.customerId(),row.sourceOrderId(),row.sourceInboundId(),row.destinationType(),row.fbaWarehouseCode(),row.packageCount()))).toList());
org.springframework.test.util.ReflectionTestUtils.setField(provider,"reads",reads);
when(cargo.prepareInspection(anyList())).thenReturn(cargo::inspect);
when(appointments.prepareRelatedBatchSnapshots(anyList())).thenAnswer(c->{var groups=c.<List<List<AppointmentCoverageService.SourceRef>>>getArgument(0);return (java.util.function.IntFunction<List<com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse>>)(i->appointments.relatedBatchSnapshots(groups.get(i)));});
return provider;
}
/** 保留原来源模拟,显式接入本次读取作用域。 */
private static void scoped(AppointmentSourceService sources) {
when(sources.openReadScope()).thenAnswer(call->{
var scope=mock(AppointmentSourceService.ReadScope.class);
when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0)));
when(scope.resolveOrderGroups(anyList())).thenAnswer(c->c.<List<List<AppointmentExternalSourceGateway.OrderRef>>>getArgument(0).stream().map(group->{
var refs=sources.canonicalOrders(group);
try { return new AppointmentSourceService.OrderGroup(refs,sources.resolveBatch(refs),null); }
catch(RuntimeException error) { return new AppointmentSourceService.OrderGroup(refs,List.of(),error); }
}).toList());
return scope;
});
}