feat(appointments): support scoped receiving and stable trip continuation

This commit is contained in:
2026-10-16 15:43:04 +08:00
parent 4e2ea89403
commit 30f48bd926
84 changed files with 4879 additions and 136 deletions
@@ -0,0 +1,69 @@
package com.baiyue.lastleg.inboundplan.receiving;
import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.*;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
class FbaReceivingAuthorizationTest {
static final String EDIT="platform:overseas-warehouse:inbound-order:fba-appointment", DELETE="platform:appointment:delete", READ="platform:appointment:list";
@Configuration @EnableMethodSecurity static class Config {
@Bean FbaReceivingService service(){return mock(FbaReceivingService.class);}
@Bean FbaReceivingController controller(FbaReceivingService service){return new FbaReceivingController(service);}
}
@Test void listOnlyCanReadButCannotChangeOrCancel() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var user=login(0L,"PLATFORM",READ);var controller=context.getBean(FbaReceivingController.class);var service=context.getBean(FbaReceivingService.class);
controller.context(user,"FBA_APPOINTMENT:1");verify(service).context("FBA_APPOINTMENT:1");
var query=new ExecutionQuery(List.of(new FbaAppointmentExecutionPort.Member("FBA_APPOINTMENT:1","A")));
controller.executionScope(user,query);verify(service).executionScope(query);
assertThrows(AccessDeniedException.class,()->controller.book(user,null));
assertThrows(AccessDeniedException.class,()->controller.plan(user,null));
assertThrows(AccessDeniedException.class,()->controller.refreshCargo(user,null));
assertThrows(AccessDeniedException.class,()->controller.cancel(user,"RA-1",null));
assertThrows(AccessDeniedException.class,()->controller.propose(user,null));
} finally {SecurityContextHolder.clearContext();}
}
@Test void editingCannotCancelThroughRescheduleOrRemovalAndDeleteDoesNotGrantEditing() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(FbaReceivingController.class);var service=context.getBean(FbaReceivingService.class);
var user=login(0L,"PLATFORM",EDIT);
assertThrows(AccessDeniedException.class,()->controller.cancel(user,"RA-1",null));
assertThrows(AccessDeniedException.class,()->controller.resolve(user,"CH-1",null));
assertEquals(400,assertThrows(ResponseStatusException.class,()->controller.reschedule(user,"RA-1",new GroupChange("key",0,"CANCEL",null,"仓库取消","t"))).getStatusCode().value());
var deleteUser=login(0L,"PLATFORM",DELETE);
assertThrows(AccessDeniedException.class,()->controller.plan(deleteUser,null));
assertThrows(AccessDeniedException.class,()->controller.refreshCargo(deleteUser,null));
assertThrows(AccessDeniedException.class,()->controller.reschedule(deleteUser,"RA-1",null));
assertThrows(AccessDeniedException.class,()->controller.propose(deleteUser,null));verifyNoInteractions(service);
} finally {SecurityContextHolder.clearContext();}
}
@Test void correctAuthoritiesStillRejectClientIdentityAndPermitExplicitScopeCommands() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var controller=context.getBean(FbaReceivingController.class);var service=context.getBean(FbaReceivingService.class);
var client=login(7L,"CLIENT",READ,EDIT,DELETE);
assertEquals(403,assertThrows(ResponseStatusException.class,()->controller.context(client,"FBA_APPOINTMENT:1")).getStatusCode().value());
assertEquals(403,assertThrows(ResponseStatusException.class,()->controller.plan(client,null)).getStatusCode().value());
assertEquals(403,assertThrows(ResponseStatusException.class,()->controller.refreshCargo(client,null)).getStatusCode().value());
assertEquals(403,assertThrows(ResponseStatusException.class,()->controller.executionScope(client,null)).getStatusCode().value());
var user=login(0L,"PLATFORM",EDIT,DELETE);var request=new Resolve("key",0,"CONFIRM","明确回复","t");
controller.resolve(user,"CH-1",request);verify(service).resolve("1","CH-1",request);
var editUser=login(0L,"PLATFORM",EDIT);
controller.plan(editUser,null);verify(service).plan("1",null);
controller.refreshCargo(editUser,null);verify(service).refreshCargo("1",null);
} finally {SecurityContextHolder.clearContext();}
}
static AuthenticatedUser login(long customer,String app,String...permissions) {
var user=new AuthenticatedUser(1L,customer,0L,1L,"operator",app,"WEB",Set.of(permissions));
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",Arrays.stream(permissions).map(SimpleGrantedAuthority::new).toList()));return user;
}
}
@@ -60,8 +60,8 @@ class AppointmentCargoVerificationPersistenceTest {
appointments.delete("operator",planned.appointmentNo(),new CancelFbaAppointmentRequest(1,"对方取消"));
var db=new JdbcTemplate(context.getBean(DataSource.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_appointment_cargo_verification",Integer.class));
assertEquals(0,context.getBean(AppointmentCargoVerificationMapper.class).allocated("A","ORDER:1").packages());
cargo.save("ORDER:1","A",save(2,7,"after-cancel","FBA-RECHECKED"),"operator","客服");
assertEquals(8,context.getBean(AppointmentCargoVerificationMapper.class).allocated("A","ORDER:1").packages());
assertThrows(ResponseStatusException.class,()->cargo.save("ORDER:1","A",save(2,7,"after-cancel","FBA-RECHECKED"),"operator","客服"));
}
}
@Test void incompleteVerificationCanBeSavedButCannotConfirmAndChangedSourceMustBeReverified() {
@@ -0,0 +1,78 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.baiyue.lastleg.inboundplan.receiving.*;
import com.baiyue.lastleg.inboundplan.work.*;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import javax.sql.DataSource;
import java.time.LocalDate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
class AppointmentWorkPreArrangementPersistenceTest {
@Test void siblingPlanChangeCannotGrantOrRemovePhysicalPreArrangementAndNeverAuthorizesDeparture() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var sources=context.getBean(AppointmentSourceService.class);
var cargo=List.of(source("B",1,60),source("D",2,40));
when(sources.resolveBatch(anyList())).thenAnswer(call->{List<AppointmentCoverageService.SourceRef> refs=call.getArgument(0);return refs.stream().map(ref->cargo.stream().filter(s->s.customerId().equals(ref.customerId()) && s.sourceKey().equals(ref.sourceKey()) && s.destinationKey().equals(ref.destinationKey())).findFirst().orElseThrow()).toList();});
when(sources.canonicalOrders(anyList())).thenAnswer(call->{List<AppointmentExternalSourceGateway.OrderRef> refs=call.getArgument(0);return refs.stream().map(ref->new AppointmentCoverageService.SourceRef(ref.customerId(),"ORDER:"+ref.orderId(),null)).toList();});
var verification=context.getBean(AppointmentCargoVerificationService.class);
for(var source:cargo) verification.save(source.sourceKey(),source.customerId(),new AppointmentCargoVerificationService.Save("FBA:ONT8",source.sourceVersion(),0,source.packages(),List.of("SHIP-"+source.customerId()),null,"verify-"+source.customerId()),"operator","客服");
var batches=context.getBean(InboundFbaAppointmentService.class);var receiving=context.getBean(FbaReceivingService.class);var store=context.getBean(FbaReceivingStore.class);
var batch=batches.createFromSources("operator",new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("B","ORDER:1","FBA:ONT8"),new SourceFbaAppointmentRequest.SourceRef("D","ORDER:2","FBA:ONT8")),"100","ONT8",plan("PENDING"),"create-bd"));
batch=receiving.plan("operator",new Plan("failed-b",new Selection(batch.batchKey(),batch.version(),"B"),plan("FAILED"),"transport-v1")).batches().get(0);
batch=receiving.plan("operator",new Plan("failed-d",new Selection(batch.batchKey(),batch.version(),"D"),plan("FAILED"),"transport-v1")).batches().get(0);
assertEquals("FAILED",batch.appointmentStatus());var originalB=store.plan(batch.batchKey(),"B");
var session=context.getBean(SqlSessionTemplate.class);session.getConfiguration().addMapper(AppointmentWorkDispatchMapper.class);
var mapper=spy(session.getMapper(AppointmentWorkDispatchMapper.class));
doReturn(List.of(new AppointmentWorkDispatchMapper.Candidate(101L,"P-B",11L,"IP-B","B","RECEIVED",false),new AppointmentWorkDispatchMapper.Candidate(102L,"P-D",12L,"IP-D","D","RECEIVED",false))).when(mapper).candidates(batch.id());
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,60))).when(mapper).physical(101L,"B");
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(2L,"D","FBA:ONT8",40,40))).when(mapper).physical(102L,"D");
var jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,customer_id,status) VALUES(20,'B','PENDING_DEPARTURE')");
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources);
var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store);
var tx=new TransactionTemplate(context.getBean(PlatformTransactionManager.class));
var before=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
assertTrue(before.blockReasons().isEmpty());assertEquals(60,before.packageCount());
assertBindable(tx,binding,mapper,before);
batch=receiving.plan("operator",new Plan("pending-d",new Selection(batch.batchKey(),batch.version(),"D"),plan("PENDING"),"transport-v1")).batches().get(0);
assertEquals("PENDING",batch.appointmentStatus());assertEquals(originalB,store.plan(batch.batchKey(),"B"));
var after=candidates.read(batch.batchKey()).candidates().stream().filter(c->c.customerId().equals("B")).findFirst().orElseThrow();
assertEquals(before,after);assertBindable(tx,binding,mapper,after);
var bound=new AppointmentWorkDispatchMapper.Bound(20L,201L,101L,batch.batchKey(),"B","ORDER:1","FBA:ONT8",60);
doReturn(List.of(bound)).when(mapper).bindings("DP-B");doReturn(List.of()).when(mapper).dispatchPhysical("DP-B");
assertTrue(assertThrows(ResponseStatusException.class,()->binding.requireDeparture("DP-B")).getReason().contains("尚无有效收货确认"));
// Unchanged real-source guards still reject an unverified pallet and an allocation owned by another customer.
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",60,null))).when(mapper).physical(101L,"B");
assertFalse(candidates.read(batch.batchKey()).candidates().get(0).blockReasons().isEmpty());
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),after.allocations())));
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("D",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),after.allocations())));
doReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"B","FBA:ONT8",61,61))).when(mapper).physical(101L,"B");
var excessive=candidates.read(batch.batchKey()).candidates().get(0);assertFalse(excessive.blockReasons().isEmpty());
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",61)),excessive.allocations())));
assertTrue(mapper.byDispatch(20).isEmpty());
}
}
private static void assertBindable(TransactionTemplate tx,AppointmentWorkDispatchBinding binding,AppointmentWorkDispatchMapper mapper,AppointmentWorkDispatchCandidates.Candidate candidate) {
tx.executeWithoutResult(status->{
binding.bind("B",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,101L,"RECEIVED",60)),candidate.allocations());
assertEquals(60,mapper.byDispatch(20).stream().mapToInt(AppointmentWorkDispatchMapper.Bound::packages).sum());
status.setRollbackOnly(); // Compare identical physical availability before and after the independent customer's plan command.
});
assertTrue(mapper.byDispatch(20).isEmpty());
}
private static FbaAppointmentDetails plan(String status) {return new FbaAppointmentDetails(LocalDate.of(2026,10,25),null,null,null,null,null,status,"仓库暂无确认",null,null);}
private static AppointmentSourceService.Source source(String customer,long id,int packages) {
return new AppointmentSourceService.Source("ORDER:"+id,customer,id,"IN-"+customer,null,null,"100","美国仓","FBA:ONT8","ONT8",packages,"ORDER:1",true,List.of());
}
}
@@ -7,6 +7,7 @@ import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.baiyue.lastleg.inboundplan.mapper.*;
import com.baiyue.lastleg.inboundplan.receiving.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.session.SqlSessionFactory;
@@ -101,17 +102,31 @@ class FbaAppointmentPersistenceTest {
} finally {executor.shutdownNow();}
}
}
@Test void cancellationReleasesGeneratedClaimsWhileReplayKeepsItsOriginalSnapshot() {
@Test void cancellationRetainsBatchQuantityWhileReplayKeepsItsOriginalSnapshot() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);var first=service.createFromSources("operator",request("first"));
service.delete("operator",first.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段"));
var second=service.createFromSources("operator",request("second"));assertNotEquals(first.appointmentNo(),second.appointmentNo());
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("second")));
assertEquals("CANCELLED",service.detail(first.appointmentNo()).appointmentStatus());
assertNull(service.detail(first.appointmentNo()).plannedDate());
assertEquals(1,service.unscheduled(1,20).total());
assertEquals(first,service.createFromSources("operator",request("first")));
var db=new JdbcTemplate(context.getBean(DataSource.class));assertEquals(4,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item",Integer.class));
var db=new JdbcTemplate(context.getBean(DataSource.class));assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item",Integer.class));
assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class));
}
}
@Test void unsuccessfulBookingRetainsTheSameAllocationAndCannotBeCreatedAgain() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);
var original=service.createFromSources("operator",request("failed-original"));
var failed=service.update("operator",original.appointmentNo(),new UpdateFbaAppointmentRequest(
new FbaAppointmentDetails(null,null,null,null,null,null,"FAILED","仓库无可用时段",null,null),0,"failed-command"));
assertEquals(original.items(),failed.items());
var db=new JdbcTemplate(context.getBean(DataSource.class));
assertEquals(30,db.queryForObject("SELECT SUM(package_count) FROM ll_inbound_fba_appointment_item WHERE active=1",Integer.class));
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("failed-duplicate")));
}
}
@Test void restoringCancelledAppointmentRechecksClaimsAndPreservesCancellationHistory() {
try(var context=new AnnotationConfigApplicationContext(Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);
@@ -144,8 +159,7 @@ class FbaAppointmentPersistenceTest {
var json=new ObjectMapper().findAndRegisterModules().writeValueAsString(page);
assertFalse(json.contains("internal-operator"));assertFalse(json.contains("responseJson"));assertFalse(json.contains("sourceKey"));
assertTrue(service.cancellationHistory(original.appointmentNo(),2,1).records().isEmpty());
var second=service.createFromSources("internal-operator",request("history-second"));
assertEquals(0,service.cancellationHistory(second.appointmentNo(),1,20).total());
assertThrows(ResponseStatusException.class,()->service.createFromSources("internal-operator",request("history-second")));
assertThrows(ResponseStatusException.class,()->service.cancellationHistory("AP-NOT-FOUND",1,20));
var db=new JdbcTemplate(context.getBean(DataSource.class));
var mapper=new ObjectMapper().findAndRegisterModules();
@@ -197,11 +211,13 @@ class FbaAppointmentPersistenceTest {
int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");assertTrue(start>=0);
String sql=baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";");statement.execute(sql);
}
statement.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,version INT DEFAULT 0)");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)");
statement.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,version INT DEFAULT 0,customer_id VARCHAR(128),customer_name VARCHAR(128))");statement.execute("INSERT INTO ll_order(id) VALUES(1),(2)");
statement.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT)");
statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(128),status VARCHAR(32))");
String work;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-work.sql")){work=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
for(var sql:work.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql);
String receiving;try(var input=FbaAppointmentPersistenceTest.class.getResourceAsStream("/db/appointment-receiving.sql")){receiving=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8);}
for(var sql:receiving.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql);
}return ds;
}
@Bean DataSourceTransactionManager transactionManager(DataSource dataSource){return new DataSourceTransactionManager(dataSource);}
@@ -209,6 +225,7 @@ class FbaAppointmentPersistenceTest {
var config=new MybatisConfiguration();config.setEnvironment(new Environment("test",new SpringManagedTransactionFactory(),dataSource));config.setMapUnderscoreToCamelCase(true);
config.addMapper(InboundFbaAppointmentMapper.class);config.addMapper(InboundFbaAppointmentItemMapper.class);config.addMapper(InboundFbaAppointmentQueryMapper.class);config.addMapper(FbaAppointmentCommandMapper.class);
config.addMapper(OrderVersionProbe.class);
config.addMapper(FbaReceivingMapper.class);
config.addMapper(AppointmentCargoVerificationMapper.class);
config.addMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);
return new MybatisSqlSessionFactoryBuilder().build(config);
@@ -218,6 +235,24 @@ class FbaAppointmentPersistenceTest {
@Bean InboundFbaAppointmentItemMapper itemMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentItemMapper.class);}
@Bean InboundFbaAppointmentQueryMapper queryMapper(SqlSessionTemplate session){return session.getMapper(InboundFbaAppointmentQueryMapper.class);}
@Bean FbaAppointmentCommandMapper commands(SqlSessionTemplate session){return session.getMapper(FbaAppointmentCommandMapper.class);}
@Bean FbaReceivingMapper receivingMapper(SqlSessionTemplate session){return session.getMapper(FbaReceivingMapper.class);}
@Bean FbaReceivingStore receivingStore(FbaReceivingMapper mapper,ObjectMapper json){return new FbaReceivingStore(mapper,json);}
@Bean FbaAppointmentTransportPort receivingTransport(){
var port=mock(FbaAppointmentTransportPort.class);var scope=new FbaAppointmentTransportPort.Scope("transport-v1",List.of(),List.of());
when(port.inspect(anyList())).thenReturn(scope);when(port.lockAndInspect(anyList(),anyString())).thenReturn(scope);
when(port.apply(any(),anyList(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port;
}
@Bean FbaAppointmentWorkPort receivingWork(){
var port=mock(FbaAppointmentWorkPort.class);
when(port.resolveFollowup(anyString(),nullable(java.time.LocalDate.class))).thenAnswer(c->new FbaAppointmentWorkPort.Followup(c.getArgument(0),"测试负责人",c.getArgument(1)));
return port;
}
@Bean FbaAppointmentExecutionPort receivingExecution(){
var port=mock(FbaAppointmentExecutionPort.class);
org.mockito.stubbing.Answer<FbaAppointmentExecutionPort.Scope> scope=c->{List<FbaAppointmentExecutionPort.Member> members=c.getArgument(0);return new FbaAppointmentExecutionPort.Scope("transport-v1",members.stream().map(m->new FbaAppointmentExecutionPort.Eligibility(m,false,true,null)).toList());};
when(port.inspect(anyList())).thenAnswer(scope);when(port.lockAndInspect(anyList(),anyString())).thenAnswer(scope);return port;
}
@Bean FbaReceivingService receivingService(FbaReceivingStore store,InboundFbaAppointmentService batches,FbaAppointmentTransportPort transport,FbaAppointmentCommandMapper commands,FbaAppointmentWorkPort work,FbaAppointmentExecutionPort execution){return new FbaReceivingService(store,batches,transport,commands,work,execution);}
@Bean AppointmentCargoVerificationMapper verifications(SqlSessionTemplate session){return session.getMapper(AppointmentCargoVerificationMapper.class);}
@Bean AppointmentCargoVerificationService verificationService(AppointmentSourceService sources,AppointmentCargoVerificationMapper mapper,ObjectMapper json){return new AppointmentCargoVerificationService(sources,mapper,json);}
@Bean AppointmentSourceService sources(){var mock=mock(AppointmentSourceService.class);when(mock.resolveBatch(anyList())).thenAnswer(call->{List<AppointmentCoverageService.SourceRef> refs=call.getArgument(0);return refs.stream().map(ref->cargo(20).stream().filter(s->s.sourceKey().equals(ref.sourceKey()) && s.customerId().equals(ref.customerId())).findFirst().orElseThrow()).toList();});when(mock.destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");return mock;}
@@ -231,6 +266,6 @@ class FbaAppointmentPersistenceTest {
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workInvalidation(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges(mapper);}
@Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService workCommitments(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper,ObjectMapper json){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService(mapper,json);}
@Bean com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges appointmentWorkChanges(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes,com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService commitments){return new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,changes);}
@Bean com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges appointmentWorkChanges(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes,com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService commitments,FbaReceivingStore receiving){return new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,changes,receiving);}
}
}
@@ -15,6 +15,39 @@ import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class FbaAppointmentRepartitionPersistenceTest {
@Test void allCurrentDestinationsSurvivePartialRedistributionAndConvergingMergePaths() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);
var original=service.createFromSources("operator",create("lineage-create"));
var first=service.repartition("operator",split(original,"lineage-first"));
var later=first.appointments().get(1);
var second=service.repartition("operator",new RepartitionFbaAppointmentsRequest(
List.of(new RepartitionFbaAppointmentsRequest.Original(later.appointmentNo(),later.version())),
List.of(batch(cargo("A","ORDER:1",4)),batch(cargo("A","ORDER:1",2))),"后送部分再排","lineage-second"));
var current=service.batchContext(original.batchKey()).currentBatches();
assertEquals(3,current.size());
assertEquals(List.of(2,4,4),current.stream().flatMap(a->a.items().stream()).filter(i->i.sourceKey().equals("ORDER:1")).map(FbaAppointmentResponse.Item::packageCount).sorted().toList());
assertEquals(2,service.batchContext(later.batchKey()).currentBatches().size());
var merged=service.repartition("operator",new RepartitionFbaAppointmentsRequest(
second.appointments().stream().map(a->new RepartitionFbaAppointmentsRequest.Original(a.appointmentNo(),a.version())).toList(),
List.of(batch(cargo("A","ORDER:1",6))),"后送重新合并","lineage-merge"));
var mergedCurrent=service.batchContext(original.batchKey()).currentBatches();
assertEquals(2,mergedCurrent.size());
assertEquals(1,mergedCurrent.stream().filter(a->a.batchKey().equals(merged.appointments().get(0).batchKey())).count());
assertEquals(List.of(merged.appointments().get(0)),service.batchContext(later.batchKey()).currentBatches());
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",create("lineage-duplicate")));
}
}
@Test void brokenOrCyclicReplacementRelationsAreNotReportedAsEmptyOrCompleted() {
try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) {
var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class));
var original=service.createFromSources("operator",create("broken-create"));
db.update("UPDATE ll_inbound_fba_appointment SET replacement_nos_json='[\"missing\"]',replacement_keys_json='[\"FBA_APPOINTMENT:999\"]' WHERE id=?",original.id());
assertEquals(404,assertThrows(ResponseStatusException.class,()->service.batchContext(original.batchKey())).getStatusCode().value());
db.update("UPDATE ll_inbound_fba_appointment SET replacement_keys_json=? WHERE id=?","[\""+original.batchKey()+"\"]",original.id());
assertEquals(409,assertThrows(ResponseStatusException.class,()->service.batchContext(original.batchKey())).getStatusCode().value());
}
}
private static SourceFbaAppointmentRequest create(String key) {
return new SourceFbaAppointmentRequest(List.of(new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1","FBA:ONT8"),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2","FBA:ONT8")),
"100","ONT8",pending(),key);
@@ -0,0 +1,818 @@
package com.baiyue.lastleg.inboundplan.service;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.baiyue.lastleg.inboundplan.receiving.*;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import javax.sql.DataSource;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real H2 core transactions; transport, execution and work ports are mocked, not physical/owner-domain acceptance. */
class FbaReceivingPersistenceTest {
private static final String OPERATOR="operator", TRANSPORT="transport-v1", DESTINATION="FBA:ONT8";
@Test void pendingChangePreservesConfirmedCargoAndOnlyMarksTheAffectedCustomer() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"pending");
assertEquals("WAITING",pending.status());
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));
assertTrue(f.store.hasPending(original.batchKey(),"B"));
assertFalse(f.store.hasPending(original.batchKey(),"A"));
assertEquals(1,f.receiving.context(original.batchKey()).pendingChanges().size());
f.assertAllocations(80,160,40);
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
var withdrawn=f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw",0,"WITHDRAW",null,TRANSPORT));
assertEquals("WITHDRAWN",withdrawn.status());
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
}
}
@Test void confirmedPartialMoveRetainsOtherCustomersAndLaterSixtyCanSplitIntoFortyAndTwenty() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var proposed=f.propose(original,100,"partial");
var result=f.confirm(proposed.changeKey(),"partial-confirm");
assertEquals("APPLIED",result.status());
var retained=result.batches().get(0);var later=result.batches().get(1);
assertEquals(original.batchKey(),retained.batchKey());
assertEquals(220,total(retained));assertEquals(60,total(later));
assertEquals(80,quantity(retained,"A"));assertEquals(100,quantity(retained,"B"));assertEquals(40,quantity(retained,"D"));
assertEquals(original.confirmationNo(),retained.confirmationNo());
assertNull(later.confirmationNo());assertEquals("PENDING",later.appointmentStatus());
var current=f.store.currentAgreement(original.batchKey(),"B");
assertEquals(group.agreementKey(),current.agreementKey());assertEquals(group.version()+1,current.version());
assertEquals(100,current.members().stream().filter(m->m.customerId().equals("B")).findFirst().orElseThrow().sources().get(0).packageCount());
assertFalse(f.store.hasPending(original.batchKey(),"B"));
var split=f.batches.repartition(OPERATOR,new RepartitionFbaAppointmentsRequest(
List.of(new RepartitionFbaAppointmentsRequest.Original(later.appointmentNo(),later.version())),
List.of(laterBatch(40),laterBatch(20)),"后送六十箱分两次送","split-later"));
assertEquals(List.of(20,40),split.appointments().stream().map(FbaReceivingPersistenceTest::total).sorted().toList());
var destinations=f.batches.batchContext(original.batchKey()).currentBatches();
assertEquals(List.of(20,40,100),destinations.stream().flatMap(b->b.items().stream()).filter(i->i.customerId().equals("B")).map(FbaAppointmentResponse.Item::packageCount).sorted().toList());
f.assertAllocations(80,160,40);
assertEquals(0,f.db.queryForObject("SELECT SUM(version) FROM ll_order",Integer.class));
verify(f.transport).apply(any(),eq(List.of(new FbaAppointmentTransportPort.Move(original.batchKey(),"B","ORDER:2",DESTINATION,160,100,later.batchKey()))),eq(OPERATOR),anyString());
}
}
@Test void allOfCustomerCanMoveOutWithoutLeavingZeroQuantityInCurrentBatch() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var result=f.confirm(f.propose(original,0,"all-out").changeKey(),"all-out-confirm");
assertEquals(120,total(result.batches().get(0)));assertEquals(160,total(result.batches().get(1)));
var reread=f.receiving.context(original.batchKey()).batch();
assertEquals(List.of("A","D"),reread.items().stream().map(FbaAppointmentResponse.Item::customerId).sorted().toList());
assertTrue(reread.items().stream().allMatch(i->i.packageCount()>0));
assertNull(f.store.currentAgreement(original.batchKey(),"B"));
var remaining=f.store.agreement(group.agreementKey(),false);
assertEquals(List.of("A","D"),remaining.members().stream().map(Member::customerId).sorted().toList());
assertEquals(original.confirmationNo(),reread.confirmationNo());
var destinations=f.batches.batchContext(original.batchKey());
assertTrue(destinations.originalSourceRefs().stream().anyMatch(s->s.customerId().equals("B")&&s.sourceKey().equals("ORDER:2")&&s.destinationKey().equals(DESTINATION)));
assertEquals(3,destinations.originalSourceRefs().size());
assertEquals(List.of(120,160),destinations.currentBatches().stream().map(FbaReceivingPersistenceTest::total).sorted().toList());
assertEquals(result.batches().get(1).batchKey(),destinations.currentBatches().stream().filter(b->quantity(b,"B")==160).findFirst().orElseThrow().batchKey());
f.assertAllocations(80,160,40);
}
}
@Test void identicalCommandsReplayAndDifferentContentCannotReuseTheirKeys() {
try(var f=new Fixture()) {
var original=f.create();var proposal=new Propose("same-proposal",intent(original,100));
var first=f.receiving.propose(OPERATOR,proposal);
assertEquals(first,f.receiving.propose(OPERATOR,proposal));
conflict(()->f.receiving.propose(OPERATOR,new Propose("same-proposal",intent(original,90))));
var resolve=new Resolve("same-resolve",0,"CONFIRM","仓库同意一百箱先送",TRANSPORT);
var applied=f.receiving.resolve(OPERATOR,first.changeKey(),resolve);
assertEquals(applied,f.receiving.resolve(OPERATOR,first.changeKey(),resolve));
conflict(()->f.receiving.resolve(OPERATOR,first.changeKey(),new Resolve("same-resolve",0,"WITHDRAW",null,TRANSPORT)));
assertEquals(2,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_change",Integer.class));
verify(f.transport,times(1)).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void transportFailureRollsBackAllocationAgreementAndCommandThenSameKeyCanRetry() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"rollback-propose");
when(f.transport.apply(any(),anyList(),anyString(),anyString())).thenThrow(new IllegalStateException("physical adjustment rejected"));
assertThrows(IllegalStateException.class,()->f.confirm(pending.changeKey(),"retry-resolve"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='retry-resolve'",Integer.class));
f.assertAllocations(80,160,40);
doReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of())).when(f.transport).apply(any(),anyList(),anyString(),anyString());
assertEquals("APPLIED",f.confirm(pending.changeKey(),"retry-resolve").status());
f.assertAllocations(80,160,40);
}
}
@Test void groupCancellationKeepsAllCargoAndRebookingCreatesANewEffectiveIdentity() {
try(var f=new Fixture()) {
var original=f.create();var old=f.store.currentAgreement(original.batchKey(),"A");
var cancelled=f.cancel(old);var batch=cancelled.batches().get(0);var group=cancelled.agreements().get(0);
assertEquals(original.batchKey(),batch.batchKey());assertEquals(280,total(batch));
assertEquals("CANCELLED",batch.appointmentStatus());assertNull(batch.confirmationNo());assertNull(batch.plannedDate());
assertEquals(old.details(),group.details());
assertEquals(3,f.receiving.groupContext(old.agreementKey()).eligibleMembers().size());
var request=new Book("rebook-group",List.of(new Booking(selections(batch,"A","B","D"),confirmed("NEW-GROUP"),"仓库确认新约覆盖三客户")),old.agreementKey(),group.version(),TRANSPORT);
var rebooked=f.receiving.book(OPERATOR,request);
assertNotEquals(old.agreementKey(),rebooked.agreements().get(0).agreementKey());
assertEquals("SUCCESS",rebooked.batches().get(0).appointmentStatus());
assertEquals("NEW-GROUP",rebooked.batches().get(0).confirmationNo());
assertEquals(rebooked,f.receiving.book(OPERATOR,request));
assertEquals("CANCELLED",f.store.agreement(old.agreementKey(),false).status());
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
f.assertAllocations(80,160,40);
}
}
@Test void independentlyRebookedCustomerIsExcludedFromOldGroupAndOthersCanRebookTogether() {
try(var f=new Fixture()) {
var original=f.create();var old=f.store.currentAgreement(original.batchKey(),"A");
var cancellation=f.cancel(old);var cancelled=cancellation.agreements().get(0);var batch=cancellation.batches().get(0);
var independent=f.receiving.book(OPERATOR,new Book("rebook-a",List.of(new Booking(selections(batch,"A"),confirmed("A-ONLY"),"A独立确认")),old.agreementKey(),cancelled.version(),TRANSPORT));
var group=f.receiving.groupContext(old.agreementKey());
assertEquals(List.of("B","D"),group.eligibleMembers().stream().map(Member::customerId).sorted().toList());
assertEquals(1,group.excludedMembers().size());assertEquals("A",group.excludedMembers().get(0).member().customerId());
assertEquals(independent.agreements().get(0).agreementKey(),group.excludedMembers().get(0).currentAgreementKey());
var current=independent.batches().get(0);
conflict(()->f.receiving.book(OPERATOR,new Book("try-include-a",List.of(new Booking(selections(current,"A","B","D"),confirmed("INVALID"),"旧范围")),old.agreementKey(),cancelled.version(),TRANSPORT)));
var others=f.receiving.book(OPERATOR,new Book("rebook-bd",List.of(new Booking(selections(current,"B","D"),confirmed("BD-ONLY"),"B及D共同确认")),old.agreementKey(),cancelled.version(),TRANSPORT));
assertNotEquals(independent.agreements().get(0).agreementKey(),others.agreements().get(0).agreementKey());
assertEquals(independent.agreements().get(0).agreementKey(),f.store.currentAgreement(original.batchKey(),"A").agreementKey());
f.assertAllocations(80,160,40);
}
}
@Test void staleBatchAndPendingVersionsNeverApplyQuantityChanges() {
try(var f=new Fixture()) {
var original=f.create();var pending=f.propose(original,100,"stale-propose");
conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("wrong-pending",1,"CONFIRM","同意",TRANSPORT)));
f.db.update("UPDATE ll_inbound_fba_appointment SET version=version+1 WHERE id=?",original.id());
conflict(()->f.confirm(pending.changeKey(),"stale-batch"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(280,total(f.batches.detail(original.appointmentNo())));f.assertAllocations(80,160,40);
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
}
}
@Test void changedAgreementScopeVersionInvalidatesEarlierWarehouseReply() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"scope-propose");
new TransactionTemplate(f.context.getBean(DataSourceTransactionManager.class)).execute(status->{
f.store.update(group,group.members(),confirmed("UPDATED-GROUP"),"新一轮范围确认");return null;
});
conflict(()->f.confirm(pending.changeKey(),"stale-agreement"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
}
}
@Test void changedOriginalSourceCannotBeAppliedUsingAnEarlierVerificationAndReply() {
try(var f=new Fixture()) {
var original=f.create();var pending=f.propose(original,100,"source-propose");
var changed=new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓",DESTINATION,"ONT8",160,"ORDER:2",true,List.of());
when(f.context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(changed));
conflict(()->f.confirm(pending.changeKey(),"stale-source"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
f.assertAllocations(80,160,40);
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
}
}
@Test void transportScopeConflictCannotCreatePendingChangeOrConsumeCommandKey() {
try(var f=new Fixture()) {
var original=f.create();
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"原车次范围已更新"));
conflict(()->f.propose(original,100,"stale-transport"));
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='stale-transport'",Integer.class));
assertEquals(original,f.batches.detail(original.appointmentNo()));
f.assertAllocations(80,160,40);
}
}
@Test void legacyEditCannotCancelOrExpandAnIndependentCustomerAgreement() {
try(var f=new Fixture()) {
var original=f.create();var old=f.store.currentAgreement(original.batchKey(),"A");
var cancellation=f.cancel(old);var cancelled=cancellation.agreements().get(0);var batch=cancellation.batches().get(0);
var independent=f.receiving.book(OPERATOR,new Book("only-a",List.of(new Booking(selections(batch,"A"),confirmed("A-PRIVATE"),"仅A已获仓库确认")),old.agreementKey(),cancelled.version(),TRANSPORT));
var current=independent.batches().get(0);var agreement=independent.agreements().get(0);
var failed=new FbaAppointmentDetails(null,null,null,null,null,null,"FAILED","其他客户暂时没有约到",null,null);
conflict(()->f.batches.update(OPERATOR,current.appointmentNo(),new UpdateFbaAppointmentRequest(failed,current.version(),"legacy-failed")));
conflict(()->f.batches.update(OPERATOR,current.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed("A-PRIVATE"),current.version(),"legacy-expand")));
assertEquals(agreement,f.store.currentAgreement(current.batchKey(),"A"));
assertEquals(List.of("A"),f.store.agreement(agreement.agreementKey(),false).members().stream().map(Member::customerId).toList());
assertNull(f.store.currentAgreement(current.batchKey(),"B"));assertNull(f.store.currentAgreement(current.batchKey(),"D"));
assertEquals(current,f.batches.detail(current.appointmentNo()));
var legacyContext=f.batches.batchContext(current.batchKey());
assertFalse(legacyContext.allowedActions().contains("SPLIT"));
assertFalse(legacyContext.allowedActions().contains("MERGE"));
assertTrue(legacyContext.blockReasons().stream().anyMatch(reason->reason.contains("部分客户已取得确认")));
f.assertAllocations(80,160,40);
}
}
@Test void fullyMovedSingleCustomerGroupExplainsExclusionWithoutReadingZeroQuantityAsCurrentCargo() {
try(var f=new Fixture()) {
var original=f.batches.createFromSources(OPERATOR,new SourceFbaAppointmentRequest(
List.of(new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2",DESTINATION)),"100","ONT8",confirmed("B-ORIGINAL"),"create-b"));
var old=f.store.currentAgreement(original.batchKey(),"B");
var moved=f.confirm(f.propose(original,0,"move-all-b").changeKey(),"confirm-all-b");
assertEquals(160,total(moved.batches().get(1)));
var context=f.receiving.groupContext(old.agreementKey());
assertTrue(context.eligibleMembers().isEmpty());assertEquals(1,context.excludedMembers().size());
var excluded=context.excludedMembers().get(0);
assertEquals("B",excluded.member().customerId());
assertTrue(excluded.reason().contains("转入")||excluded.reason().contains("移出"));
assertEquals(160,excluded.member().sources().stream().mapToInt(Source::packageCount).sum());
assertTrue(excluded.member().sources().stream().allMatch(source->source.packageCount()>0));
assertNull(f.store.currentAgreement(original.batchKey(),"B"));
assertEquals(List.of(moved.batches().get(1).batchKey()),f.batches.batchContext(original.batchKey()).currentBatches().stream().map(FbaAppointmentResponse::batchKey).toList());
}
}
@Test void unchangedLegacyConfirmationWithNewCommandKeyDoesNotChangeBatchAgreementOrHistory() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"A");
var revisions=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision WHERE agreement_key=?",Integer.class,agreement.agreementKey());
var request=new UpdateFbaAppointmentRequest(confirmed("ORIGINAL-GROUP"),original.version(),"unchanged-confirmation");
var saved=f.batches.update(OPERATOR,original.appointmentNo(),request);
assertEquals(original,saved);assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"A"));
assertEquals(revisions,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision WHERE agreement_key=?",Integer.class,agreement.agreementKey()));
assertEquals(saved,f.batches.update(OPERATOR,original.appointmentNo(),request));
f.assertAllocations(80,160,40);
}
}
@Test void pendingChangeCanBeWithdrawnAfterTransportBecomesImmutableButCannotBeConfirmed() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"before-picking");
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"已开始拣货,禁止调整运输货量"));
conflict(()->f.confirm(pending.changeKey(),"confirm-after-picking"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-wrong-version",1,"WITHDRAW",null,TRANSPORT)));
var request=new Resolve("withdraw-after-picking",0,"WITHDRAW",null,TRANSPORT);
var result=f.receiving.resolve(OPERATOR,pending.changeKey(),request);
assertEquals("WITHDRAWN",result.status());
assertEquals(1,f.store.change(pending.changeKey(),false).version());
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals(result,f.receiving.resolve(OPERATOR,pending.changeKey(),request));
conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-after-picking",0,"CONFIRM","修改请求内容",TRANSPORT)));
verify(f.transport,times(2)).lockAndInspect(anyList(),eq(TRANSPORT));
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void completedCustomerIsExcludedAndRemainingCustomersRebookWithoutTransportMutationLocks() {
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"A");
var cancelled=f.cancel(group);var batch=cancelled.batches().get(0);var cancelledGroup=cancelled.agreements().get(0);
org.mockito.stubbing.Answer<FbaAppointmentExecutionPort.Scope> scope=call->{
List<FbaAppointmentExecutionPort.Member> members=call.getArgument(0);
return new FbaAppointmentExecutionPort.Scope("execution-v1",members.stream().map(m->new FbaAppointmentExecutionPort.Eligibility(m,m.customerId().equals("A"),!m.customerId().equals("A"),m.customerId().equals("A")?"本客户货物已经全部签收":null)).toList());
};
when(f.execution.inspect(anyList())).thenAnswer(scope);
when(f.execution.lockAndInspect(anyList(),anyString())).thenAnswer(scope);
clearInvocations(f.transport);
var candidates=f.receiving.groupContext(group.agreementKey());
assertEquals(List.of("B","D"),candidates.eligibleMembers().stream().map(Member::customerId).sorted().toList());
assertEquals(1,candidates.excludedMembers().size());
assertEquals("A",candidates.excludedMembers().get(0).member().customerId());
assertFalse(candidates.excludedMembers().get(0).reason().isBlank());
var before=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class);
conflict(()->f.receiving.book(OPERATOR,new Book("completed-a-rejected",List.of(new Booking(selections(batch,"A"),confirmed("ILLEGAL-A"),"误选已签收客户")),group.agreementKey(),cancelledGroup.version(),"execution-v1")));
assertEquals(before,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
assertNull(f.store.currentAgreement(batch.batchKey(),"A"));
var rebooked=f.receiving.book(OPERATOR,new Book("remaining-bd",List.of(new Booking(selections(batch,"B","D"),confirmed("REMAINING-BD"),"仓库仅确认未完成客户")),group.agreementKey(),cancelledGroup.version(),candidates.executionScope().version()));
assertEquals(List.of("B","D"),rebooked.agreements().get(0).members().stream().map(Member::customerId).sorted().toList());
verify(f.execution).lockAndInspect(eq(List.of(new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"),new FbaAppointmentExecutionPort.Member(batch.batchKey(),"D"))),eq("execution-v1"));
verify(f.transport,never()).lockAndInspect(anyList(),anyString());
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void undatedLaterBatchReceivesVerifiedFollowupAndFurtherSplitInheritsExactCustomerMembers() {
try(var f=new Fixture()) {
var original=f.create();var base=intent(original,100);var followup=new FbaAppointmentWorkPort.Followup("42","王客服",LocalDate.of(2026,10,19));
when(f.work.resolveFollowup("42",followup.nextFollowupDate())).thenReturn(followup);
var input=new ChangeIntent(base.batchKey(),base.batchVersion(),base.customerId(),base.quantities(),null,"42",followup.nextFollowupDate(),base.reason(),base.transportVersion());
var pending=f.receiving.propose(OPERATOR,new Propose("undated-propose",input));
assertEquals(followup,f.store.change(pending.changeKey(),false).followup());
var applied=f.confirm(pending.changeKey(),"undated-confirm");var later=applied.batches().get(1);
assertNull(later.plannedDate());assertNull(later.plannedAt());assertNull(later.confirmationNo());
verify(f.work,times(2)).resolveFollowup("42",followup.nextFollowupDate());
verify(f.work).seedFollowup(new FbaAppointmentWorkPort.Member(original.batchKey(),"B"),new FbaAppointmentWorkPort.Member(later.batchKey(),"B"),followup,OPERATOR);
var split=f.batches.repartition(OPERATOR,new RepartitionFbaAppointmentsRequest(
List.of(new RepartitionFbaAppointmentsRequest.Original(later.appointmentNo(),later.version())),List.of(laterBatch(40),laterBatch(20)),"后送再拆分并继承跟进","inherit-split"));
verify(f.work).inheritFollowup(eq(List.of(new FbaAppointmentWorkPort.Member(later.batchKey(),"B"))),eq(split.appointments().stream().map(b->new FbaAppointmentWorkPort.Member(b.batchKey(),"B")).toList()),eq(OPERATOR));
assertEquals(List.of(20,40),split.appointments().stream().map(FbaReceivingPersistenceTest::total).sorted().toList());
f.assertAllocations(80,160,40);
}
}
@Test void inactiveResponsiblePersonPreventsProposalWithoutCreatingPendingOrQuantityChanges() {
try(var f=new Fixture()) {
var original=f.create();
when(f.work.resolveFollowup(anyString(),nullable(LocalDate.class))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"负责人已停用"));
conflict(()->f.propose(original,100,"inactive-propose"));
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='inactive-propose'",Integer.class));
assertEquals(original,f.batches.detail(original.appointmentNo()));
verify(f.work,never()).seedFollowup(any(),any(),any(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void ownerBecomingInactiveBeforeConfirmationPreservesPendingAndOriginalCargo() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"owner-active-propose");
when(f.work.resolveFollowup(anyString(),nullable(LocalDate.class))).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"负责人已停用"));
conflict(()->f.confirm(pending.changeKey(),"inactive-confirm"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='inactive-confirm'",Integer.class));
verify(f.work,never()).seedFollowup(any(),any(),any(),anyString());
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void followupSeedFailureRollsBackCreatedLaterBatchAndKeepsOriginalPendingRetryable() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B");
var pending=f.propose(original,100,"seed-propose");
doThrow(new IllegalStateException("work followup persistence unavailable")).when(f.work).seedFollowup(any(),any(),any(),anyString());
assertThrows(IllegalStateException.class,()->f.confirm(pending.changeKey(),"seed-retry"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals("WAITING",f.store.change(pending.changeKey(),false).status());
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='seed-retry'",Integer.class));
doNothing().when(f.work).seedFollowup(any(),any(),any(),anyString());
assertEquals("APPLIED",f.confirm(pending.changeKey(),"seed-retry").status());
f.assertAllocations(80,160,40);
}
}
@Test void movedCustomerCannotReuseConfirmationStillCoveringOtherCustomersButCanBookIndependently() {
try(var f=new Fixture()) {
var original=f.create();var moved=f.confirm(f.propose(original,0,"move-b-for-rebook").changeKey(),"move-b-for-rebook-confirm");
var retained=moved.batches().get(0);var later=moved.batches().get(1);
var remainingAgreement=f.store.currentAgreement(original.batchKey(),"A");
var before=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class);
conflict(()->f.receiving.book(OPERATOR,new Book("reuse-live-number",List.of(new Booking(selections(later,"B"),confirmed(original.confirmationNo()),"B错误沿用原约号")),null,null,TRANSPORT)));
assertEquals(before,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
assertEquals(remainingAgreement,f.store.currentAgreement(original.batchKey(),"A"));
assertEquals(remainingAgreement,f.store.currentAgreement(original.batchKey(),"D"));
assertEquals(retained,f.batches.detail(original.appointmentNo()));
assertEquals(later,f.batches.detail(later.appointmentNo()));assertEquals(160,total(later));
assertNull(f.store.currentAgreement(later.batchKey(),"B"));
var separate=f.receiving.book(OPERATOR,new Book("new-b-number",List.of(new Booking(selections(later,"B"),confirmed("B-NEW-CONFIRMATION"),"B已取得独立约号")),null,null,TRANSPORT));
assertEquals("B-NEW-CONFIRMATION",separate.agreements().get(0).details().confirmationNo());
assertNotEquals(remainingAgreement.agreementKey(),separate.agreements().get(0).agreementKey());
assertEquals(remainingAgreement,f.store.currentAgreement(original.batchKey(),"A"));
f.assertAllocations(80,160,40);
}
}
@Test void executionAssociationChangingBetweenLockAndBatchLockRejectsBookingAtomically() {
try(var f=new Fixture()) {
var original=f.create();var old=f.store.currentAgreement(original.batchKey(),"A");
var cancelled=f.cancel(old);var batch=cancelled.batches().get(0);var cancelledGroup=cancelled.agreements().get(0);
var member=new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B");
var eligibility=new FbaAppointmentExecutionPort.Eligibility(member,false,true,null);
when(f.execution.lockAndInspect(eq(List.of(member)),eq("execution-v1"))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v1",List.of(eligibility)));
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v2",List.of(eligibility)));
var before=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class);
conflict(()->f.receiving.book(OPERATOR,new Book("raced-execution",List.of(new Booking(selections(batch,"B"),confirmed("RACED-B"),"并发新关联")),old.agreementKey(),cancelledGroup.version(),"execution-v1")));
assertEquals(before,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='raced-execution'",Integer.class));
assertNull(f.store.currentAgreement(batch.batchKey(),"B"));
assertEquals(batch,f.batches.detail(batch.appointmentNo()));
f.assertAllocations(80,160,40);
}
}
@Test void transportAssociationChangingBeforeBatchLockRejectsProposalWithoutPendingOrCommand() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B");
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of()));
when(f.transport.inspect(anyList())).thenReturn(new FbaAppointmentTransportPort.Scope("transport-v2",List.of(),List.of()));
conflict(()->f.propose(original,100,"raced-proposal"));
assertFalse(f.store.hasPending(original.batchKey(),"B"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_change",Integer.class));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='raced-proposal'",Integer.class));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B"));
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
}
@Test void transportAssociationChangingBeforeBatchLockRejectsBothGroupCancellationAndRescheduling() {
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"A");
var revisionCount=f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision",Integer.class);
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT))).thenReturn(new FbaAppointmentTransportPort.Scope(TRANSPORT,List.of(),List.of()));
when(f.transport.inspect(anyList())).thenReturn(new FbaAppointmentTransportPort.Scope("transport-v2",List.of(),List.of()));
for(var action:List.of("CANCEL","RESCHEDULE")) {
var key="raced-group-"+action;
var request=new GroupChange(key,agreement.version(),action,action.equals("RESCHEDULE")?confirmed("CHANGED-CONFIRMATION"):null,"仓库回复期间新关联排车",TRANSPORT);
conflict(()->f.receiving.changeGroup(OPERATOR,agreement.agreementKey(),request));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key=?",Integer.class,key));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(agreement,f.store.agreement(agreement.agreementKey(),false));
for(var customer:List.of("A","B","D")) assertEquals(agreement,f.store.currentAgreement(original.batchKey(),customer));
assertEquals(revisionCount,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_revision",Integer.class));
f.assertAllocations(80,160,40);
}
verify(f.transport,times(2)).lockAndInspect(eq(List.of(original.batchKey())),eq(TRANSPORT));
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
}
}
@Test void customerPlanChangesOnlyUnconfirmedCustomerWhileAnotherConfirmationAndUnknownDateRemain() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var booked=f.receiving.book(OPERATOR,new Book("confirm-a-before-plan",List.of(new Booking(selections(original,"A"),confirmed("A-ONLY-PLAN"),"A取得确认")),null,null,TRANSPORT));
var batch=booked.batches().get(0);var agreement=booked.agreements().get(0);
var target=LocalDate.of(2026,11,3);
var result=f.plan(batch,"B",planned(target,"PENDING",null),"plan-b");
assertEquals("APPLIED",result.status());
assertEquals(agreement,f.store.currentAgreement(batch.batchKey(),"A"));
assertEquals(target,f.store.plan(batch.batchKey(),"B").details().plannedDate());
assertNull(f.store.plan(batch.batchKey(),"D").details().plannedDate());
assertNull(f.store.plan(batch.batchKey(),"A"));
assertNull(result.batches().get(0).plannedDate());assertNull(result.batches().get(0).confirmationNo());
assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
assertEquals(2,f.receiving.context(batch.batchKey()).plans().size());
assertTrue(f.receiving.context(batch.batchKey()).allowedActions().contains("PLAN_RECEIVING"));
var current=result.batches().get(0);
conflict(()->f.batches.update(OPERATOR,current.appointmentNo(),new UpdateFbaAppointmentRequest(planned(null,"PENDING",null),current.version(),"legacy-plan-overwrite")));
conflict(()->f.batches.delete(OPERATOR,current.appointmentNo(),new CancelFbaAppointmentRequest(current.version(),"不能整批取消客户计划")));
f.assertAllocations(80,160,40);
}
}
@Test void failedCustomerPlanMayHaveNoDateButCannotSmuggleFormalConfirmationFields() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var saved=f.plan(original,"B",planned(null,"FAILED","仓库暂无时段"),"failed-plan");
var current=saved.batches().get(0);var plan=f.store.plan(original.batchKey(),"B");
assertEquals("FAILED",plan.details().appointmentStatus());assertNull(plan.details().plannedDate());
assertTrue(saved.agreements().isEmpty());
var invalid=new java.util.ArrayList<FbaAppointmentDetails>();
invalid.add(confirmed("NOT-A-PLAN"));invalid.add(planned(null,"FAILED"," "));
for(var number:List.of(""," ","EXTERNAL")) invalid.add(new FbaAppointmentDetails(null,null,null,number,null,null,"PENDING",null,null,null));
invalid.add(new FbaAppointmentDetails(null,null,null,null,LocalDateTime.of(2026,10,20,10,0),null,"PENDING",null,null,null));
int index=0;
for(var details:invalid) {
var key="bad-plan-"+(index++);
assertEquals(400,assertThrows(ResponseStatusException.class,()->f.plan(current,"B",details,key)).getStatusCode().value());
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key=?",Integer.class,key));
}
assertEquals(plan,f.store.plan(original.batchKey(),"B"));assertEquals(current,f.batches.detail(original.appointmentNo()));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_agreement",Integer.class));
f.assertAllocations(80,160,40);
}
}
@Test void customerPlanReplayAndSemanticNoopKeepVersionAndStaleWritesAreRejected() {
try(var f=new Fixture()) {
var original=f.createPending(null);var details=planned(LocalDate.of(2026,11,4),"PENDING","等待回复");
var request=new Plan("plan-idempotent",new Selection(original.batchKey(),original.version(),"B"),details,TRANSPORT);
var saved=f.receiving.plan(OPERATOR,request);var current=saved.batches().get(0);
assertEquals(saved,f.receiving.plan(OPERATOR,request));
var noop=f.plan(current,"B",details,"plan-noop");
assertEquals("UNCHANGED",noop.status());assertEquals(current,noop.batches().get(0));
assertEquals(current,f.batches.detail(original.appointmentNo()));
assertEquals(3,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan",Integer.class));
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"stale-plan"));
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"plan-idempotent"));
assertEquals(details,f.store.plan(original.batchKey(),"B").details());
}
}
@Test void customerPlanRechecksExecutionScopeAndRejectsCurrentConfirmationOrPendingChange() {
try(var f=new Fixture()) {
var original=f.createPending(null);var member=new FbaAppointmentExecutionPort.Member(original.batchKey(),"B");
var eligibility=new FbaAppointmentExecutionPort.Eligibility(member,false,true,null);
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-v2",List.of(eligibility)));
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"raced-plan"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan",Integer.class));
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope(TRANSPORT,List.of(eligibility)));
f.propose(original,100,"pending-before-plan");
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"pending-plan"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan",Integer.class));
}
try(var f=new Fixture()) {
var original=f.create();var agreement=f.store.currentAgreement(original.batchKey(),"B");
conflict(()->f.plan(original,"B",planned(null,"PENDING",null),"confirmed-plan"));
assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
}
}
@Test void bookingFreezesOtherCustomersOriginalTargetAndDeletesOnlyBookedPlan() {
try(var f=new Fixture()) {
var target=LocalDate.of(2026,10,25);var original=f.createPending(target);
var a=f.receiving.book(OPERATOR,new Book("freeze-a",List.of(new Booking(selections(original,"A"),confirmed("FROZEN-A"),"A确认")),null,null,TRANSPORT));
assertNull(f.store.plan(original.batchKey(),"A"));
assertEquals(target,f.store.plan(original.batchKey(),"B").details().plannedDate());
assertEquals(target,f.store.plan(original.batchKey(),"D").details().plannedDate());
var updated=f.plan(a.batches().get(0),"B",planned(target.plusDays(1),"PENDING",null),"adjust-b-plan").batches().get(0);
var b=f.receiving.book(OPERATOR,new Book("freeze-b",List.of(new Booking(selections(updated,"B"),confirmed("FROZEN-B"),"B确认")),null,null,TRANSPORT));
assertNull(f.store.plan(original.batchKey(),"B"));
var d=f.store.plan(original.batchKey(),"D");assertEquals(target,d.details().plannedDate());
f.receiving.changeGroup(OPERATOR,a.agreements().get(0).agreementKey(),new GroupChange("cancel-a-only",a.agreements().get(0).version(),"CANCEL",null,"仅A取消",TRANSPORT));
assertEquals(d,f.store.plan(original.batchKey(),"D"));
assertEquals(b.agreements().get(0),f.store.currentAgreement(original.batchKey(),"B"));
f.assertAllocations(80,160,40);
}
}
@Test void repartitionUsesExplicitNewDatesAndDoesNotTreatHistoricalCustomerPlansAsCurrent() {
try(var f=new Fixture()) {
var original=f.createPending(LocalDate.of(2026,10,25));
var current=f.plan(original,"B",planned(LocalDate.of(2026,10,26),"PENDING",null),"before-split-plan").batches().get(0);
assertTrue(f.batches.batchContext(current.batchKey()).allowedActions().contains("SPLIT"));
var nextDate=LocalDate.of(2026,11,2);
var split=f.batches.repartition(OPERATOR,new RepartitionFbaAppointmentsRequest(List.of(new RepartitionFbaAppointmentsRequest.Original(current.appointmentNo(),current.version())),List.of(
new RepartitionFbaAppointmentsRequest.Batch(nextDate,null,List.of(new RepartitionFbaAppointmentsRequest.Allocation("A","ORDER:1",80,"ORDER:1",1),new RepartitionFbaAppointmentsRequest.Allocation("B","ORDER:2",100,"ORDER:1",1),new RepartitionFbaAppointmentsRequest.Allocation("D","ORDER:3",40,"ORDER:1",1))),laterBatch(60)),"拆分使用本次新目标","plan-repartition"));
assertEquals(nextDate,split.appointments().get(0).plannedDate());assertNull(split.appointments().get(1).plannedDate());
assertEquals(3,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan WHERE batch_key=?",Integer.class,current.batchKey()));
assertTrue(f.store.snapshots(List.of(current.batchKey())).get(0).plans().isEmpty());
for(var batch:split.appointments()) assertTrue(f.store.plans(batch.batchKey()).isEmpty());
f.assertAllocations(80,160,40);
}
}
@Test void partialMoveKeepsRemainingCustomerPlanAndFullMoveClearsOnlyItsOldPlan() {
try(var f=new Fixture()) {
var original=f.createPending(LocalDate.of(2026,10,25));
var current=f.plan(original,"B",planned(LocalDate.of(2026,10,27),"PENDING",null),"move-plan-b").batches().get(0);
var b=f.store.plan(current.batchKey(),"B");var a=f.store.plan(current.batchKey(),"A");var d=f.store.plan(current.batchKey(),"D");
var partial=f.confirm(f.propose(current,100,"plan-partial").changeKey(),"plan-partial-confirm");
assertEquals(b,f.store.plan(current.batchKey(),"B"));
assertEquals(LocalDate.of(2026,10,23),partial.batches().get(1).plannedDate());
var all=f.confirm(f.propose(partial.batches().get(0),0,"plan-full").changeKey(),"plan-full-confirm");
assertNull(f.store.plan(current.batchKey(),"B"));
assertEquals(a,f.store.plan(current.batchKey(),"A"));assertEquals(d,f.store.plan(current.batchKey(),"D"));
assertEquals(100,total(all.batches().get(1)));f.assertAllocations(80,160,40);
}
}
@Test void customerPlanRejectsStaleSourceWithoutChangingFrozenPlansOrQuantity() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var changed=new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓",DESTINATION,"ONT8",160,"ORDER:2",true,List.of());
when(f.context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(changed));
conflict(()->f.plan(original,"B",planned(LocalDate.of(2026,10,25),"PENDING",null),"stale-plan-source"));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_plan",Integer.class));
assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
}
@Test void emptyDatePlanAllowsMissingTimezoneButNewDateRequiresAuthoritativeIanaZone() {
try(var f=new Fixture()) {
var original=f.createPending(null);when(f.context.getBean(AppointmentSourceService.class).destinationTimezone("ONT8")).thenReturn(null);
assertEquals(400,assertThrows(ResponseStatusException.class,()->f.plan(original,"B",planned(LocalDate.of(2026,10,25),"PENDING",null),"missing-zone-date")).getStatusCode().value());
var saved=f.plan(original,"B",planned(null,"FAILED","待补仓库时区及回复"),"missing-zone-undated");
assertNull(f.store.plan(original.batchKey(),"B").timezone());
assertNull(saved.batches().get(0).plannedAt());
when(f.context.getBean(AppointmentSourceService.class).destinationTimezone("ONT8")).thenReturn("America/Los_Angeles");
var dated=f.plan(saved.batches().get(0),"B",planned(LocalDate.of(2026,10,25),"PENDING",null),"zone-restored-date");
assertNull(f.store.plan(original.batchKey(),"B").details().plannedAt());
assertEquals(LocalDate.of(2026,10,25),f.store.plan(original.batchKey(),"B").details().plannedDate());
assertEquals("APPLIED",dated.status());
}
}
@Test void customerPlanValidatesDstGapAmbiguityOffsetsAndCrossMidnightWithoutInventingTimes() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var gap=LocalDateTime.of(2026,3,8,2,30);var overlap=LocalDateTime.of(2026,11,1,1,30);
var invalid=List.of(new FbaAppointmentDetails(gap.toLocalDate(),gap,null,null,null,null,"PENDING",null,null,null),
new FbaAppointmentDetails(overlap.toLocalDate(),overlap,null,null,null,null,"PENDING",null,null,null),
new FbaAppointmentDetails(overlap.toLocalDate(),null,null,null,null,null,"PENDING",null,"-07:00",null));
int index=0;for(var details:invalid) {var key="invalid-plan-time-"+(index++);assertEquals(400,assertThrows(ResponseStatusException.class,()->f.plan(original,"B",details,key)).getStatusCode().value());}
var explicit=new FbaAppointmentDetails(overlap.toLocalDate(),overlap,overlap.plusMinutes(15),null,null,null,"PENDING",null,"-07:00","-07:00");
var saved=f.plan(original,"B",explicit,"explicit-plan-offset").batches().get(0);
assertEquals(explicit,f.store.plan(original.batchKey(),"B").details());
var start=LocalDateTime.of(2026,11,3,23,30);var across=new FbaAppointmentDetails(start.toLocalDate(),start,start.plusHours(2),null,null,null,"PENDING",null,null,null);
f.plan(saved,"B",across,"cross-midnight-plan");
assertEquals(start.plusHours(2),f.store.plan(original.batchKey(),"B").details().plannedEndAt());
}
}
@Test void adoptingNewVerificationRestoresOnlyUnconfirmedCustomerWithoutChangingOtherAgreementPlanOrQuantity() {
try(var f=new Fixture()) {
var original=f.createPending(LocalDate.of(2026,10,25));
var a=f.receiving.book(OPERATOR,new Book("a-before-refresh",List.of(new Booking(selections(original,"A"),confirmed("A-BEFORE-REFRESH"),"A确认")),null,null,TRANSPORT));
var batch=a.batches().get(0);var plans=f.store.plans(batch.batchKey());
var otherItems=batch.items().stream().filter(i->!i.customerId().equals("B")).toList();
f.verifyB(1,"verify-b-v2","B-NEW-SHIPMENT");
conflict(()->f.plan(batch,"B",planned(LocalDate.of(2026,11,5),"PENDING",null),"before-refresh-plan"));
conflict(()->f.receiving.book(OPERATOR,new Book("before-refresh-book",List.of(new Booking(selections(batch,"B"),confirmed("B-BEFORE-REFRESH"),"B确认")),null,null,TRANSPORT)));
var result=f.refresh(batch,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2)),"adopt-b-v2");var refreshed=result.batches().get(0);
assertEquals("APPLIED",result.status());assertEquals(batch.batchKey(),refreshed.batchKey());
assertEquals(batch.version()+1,refreshed.version());
assertEquals(otherItems,refreshed.items().stream().filter(i->!i.customerId().equals("B")).toList());
var b=refreshed.items().stream().filter(i->i.customerId().equals("B")).findFirst().orElseThrow();
assertEquals(2,b.cargoVerificationVersion());assertEquals(List.of("B-NEW-SHIPMENT"),b.shipmentReferences());assertEquals(160,b.packageCount());
assertEquals(plans,f.store.plans(batch.batchKey()));assertEquals(a.agreements().get(0),f.store.currentAgreement(batch.batchKey(),"A"));
var plannedBatch=f.plan(refreshed,"B",planned(LocalDate.of(2026,11,5),"PENDING",null),"after-refresh-plan").batches().get(0);
var booked=f.receiving.book(OPERATOR,new Book("after-refresh-book",List.of(new Booking(selections(plannedBatch,"B"),confirmed("B-AFTER-REFRESH"),"B确认")),null,null,TRANSPORT));
assertEquals("APPLIED",booked.status());assertEquals(a.agreements().get(0),f.store.currentAgreement(batch.batchKey(),"A"));
assertEquals(plans.stream().filter(p->p.customerId().equals("D")).findFirst().orElseThrow(),f.store.plan(batch.batchKey(),"D"));
f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionUsesExactExpectedVersionsAndPersistentIdempotencyWithoutRepeatedChanges() {
try(var f=new Fixture()) {
var original=f.createPending(null);f.verifyB(1,"v2-for-replay","B-V2");
var versions=List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2));
var request=new RefreshCargo("adopt-replay",new Selection(original.batchKey(),original.version(),"B"),versions,TRANSPORT);
var saved=f.receiving.refreshCargo(OPERATOR,request);var current=saved.batches().get(0);
assertEquals(saved,f.receiving.refreshCargo(OPERATOR,request));
var noop=f.refresh(current,"B",versions,"adopt-noop");assertEquals("UNCHANGED",noop.status());assertEquals(current,noop.batches().get(0));
conflict(()->f.refresh(current,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",1)),"stale-adopt-version"));
conflict(()->f.refresh(original,"B",versions,"stale-adopt-batch"));
conflict(()->f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",1)),"adopt-replay"));
for(var key:List.of("stale-adopt-version","stale-adopt-batch")) assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key=?",Integer.class,key));
assertEquals(current,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionRejectsMissingDuplicateForeignSourcesAndDestinationSubstitution() {
try(var f=new Fixture()) {
var original=f.createPending(null);f.verifyB(1,"v2-for-scope","B-V2");
List<List<CargoVersion>> invalid=List.of(List.of(),List.of(new CargoVersion("ORDER:1",DESTINATION,"ORDER:1",2)),
List.of(new CargoVersion("ORDER:2","FBA:LAX9","ORDER:1",2)),List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2),new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2)));
int index=0;for(var versions:invalid) {
var key="invalid-adopt-"+(index++);assertThrows(ResponseStatusException.class,()->f.refresh(original,"B",versions,key));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key=?",Integer.class,key));
}
assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionCannotReduceAllocationEvenWhenLatestEvidenceClaimsLessQuantity() {
try(var f=new Fixture()) {
var original=f.createPending(null);var verification=f.context.getBean(AppointmentCargoVerificationService.class);
conflict(()->verification.save("ORDER:2","B",new AppointmentCargoVerificationService.Save(DESTINATION,"ORDER:1",1,159,List.of("B-LOW"),null,"bad-lower-total"),OPERATOR,"客服"));
f.verifyB(1,"v2-for-count-guard","B-V2");
// Corrupt the fixture's latest authoritative count to prove the core independently guards allocation totals.
f.db.update("UPDATE ll_appointment_cargo_verification SET package_count=159 WHERE customer_id='B' AND version=2");
conflict(()->f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2)),"adopt-too-small"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='adopt-too-small'",Integer.class));
f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionAfterSourceChangeRequiresFreshVerificationAndRejectsChangedIdentity() {
try(var f=new Fixture()) {
var original=f.createPending(null);
var changed=new AppointmentSourceService.Source("ORDER:2","B",2L,"IN-B",null,null,"100","美国仓",DESTINATION,"ONT8",160,"ORDER:2",true,List.of());
when(f.context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(changed));
conflict(()->f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:2",1)),"adopt-before-reverify"));
f.context.getBean(AppointmentCargoVerificationService.class).save("ORDER:2","B",new AppointmentCargoVerificationService.Save(DESTINATION,"ORDER:2",1,160,List.of("B-SOURCE-V2"),null,"source-v2-verification"),OPERATOR,"客服");
var refreshed=f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:2",2)),"adopt-source-v2").batches().get(0);
assertEquals("ORDER:2",refreshed.items().stream().filter(i->i.customerId().equals("B")).findFirst().orElseThrow().sourceVersion());
var wrongCustomer=new AppointmentSourceService.Source("ORDER:2","X",2L,"IN-B",null,null,"100","美国仓",DESTINATION,"ONT8",160,"ORDER:2",true,List.of());
when(f.context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenReturn(List.of(wrongCustomer));
conflict(()->f.refresh(refreshed,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:2",null)),"adopt-wrong-customer"));
assertEquals(refreshed,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
}
@Test void cargoAdoptionRejectsExecutionRaceCompletedConfirmedAndPendingCustomer() {
try(var f=new Fixture()) {
var original=f.createPending(null);f.verifyB(1,"v2-execution-adopt","B-V2");
var versions=List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2));
var member=new FbaAppointmentExecutionPort.Member(original.batchKey(),"B");
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope("execution-raced",List.of(new FbaAppointmentExecutionPort.Eligibility(member,false,true,null))));
conflict(()->f.refresh(original,"B",versions,"raced-cargo-adopt"));
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope(TRANSPORT,List.of(new FbaAppointmentExecutionPort.Eligibility(member,true,false,"已完成"))));
conflict(()->f.refresh(original,"B",versions,"completed-cargo-adopt"));
when(f.execution.inspect(eq(List.of(member)))).thenReturn(new FbaAppointmentExecutionPort.Scope(TRANSPORT,List.of(new FbaAppointmentExecutionPort.Eligibility(member,false,true,null))));
f.propose(original,100,"pending-cargo-adopt");
conflict(()->f.refresh(original,"B",versions,"pending-cargo-refresh"));
assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40);
}
try(var f=new Fixture()) {
var original=f.create();var group=f.store.currentAgreement(original.batchKey(),"B");f.verifyB(1,"v2-confirmed-adopt","B-V2");
conflict(()->f.refresh(original,"B",List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2)),"confirmed-cargo-adopt"));
assertEquals(group,f.store.currentAgreement(original.batchKey(),"B"));assertEquals(original,f.batches.detail(original.appointmentNo()));
}
}
@Test void cargoAdoptionPersistenceFailureRollsBackItemAndCommandAndSameRequestCanRetry() {
try(var f=new Fixture()) {
var original=f.createPending(null);f.verifyB(1,"v2-adopt-failure","B-V2");
var versions=List.of(new CargoVersion("ORDER:2",DESTINATION,"ORDER:1",2));
f.db.execute("ALTER TABLE ll_inbound_fba_appointment_item ADD CONSTRAINT reject_adopt_v2 CHECK(cargo_verification_version<>2)");
assertThrows(RuntimeException.class,()->f.refresh(original,"B",versions,"adopt-db-retry"));
assertEquals(original,f.batches.detail(original.appointmentNo()));
assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='adopt-db-retry'",Integer.class));
f.db.execute("ALTER TABLE ll_inbound_fba_appointment_item DROP CONSTRAINT reject_adopt_v2");
assertEquals("APPLIED",f.refresh(original,"B",versions,"adopt-db-retry").status());f.assertAllocations(80,160,40);
}
}
private static void conflict(org.junit.jupiter.api.function.Executable action) {
assertEquals(409,assertThrows(ResponseStatusException.class,action).getStatusCode().value());
}
private static int total(FbaAppointmentResponse batch) {return batch.items().stream().mapToInt(FbaAppointmentResponse.Item::packageCount).sum();}
private static int quantity(FbaAppointmentResponse batch,String customer) {return batch.items().stream().filter(i->i.customerId().equals(customer)).mapToInt(FbaAppointmentResponse.Item::packageCount).sum();}
private static FbaAppointmentDetails confirmed(String number) {
var start=LocalDateTime.of(2026,10,20,10,0);
return new FbaAppointmentDetails(start.toLocalDate(),null,null,number,start,start.plusHours(1),"SUCCESS","仓库已确认",null,null);
}
private static FbaAppointmentDetails planned(LocalDate date,String status,String remark) {return new FbaAppointmentDetails(date,null,null,null,null,null,status,remark,null,null);}
private static ChangeIntent intent(FbaAppointmentResponse batch,int retained) {
var item=batch.items().stream().filter(i->i.customerId().equals("B")).findFirst().orElseThrow();
return new ChangeIntent(batch.batchKey(),batch.version(),"B",List.of(new Quantity(item.sourceKey(),item.destinationKey(),retained,item.sourceVersion(),item.cargoVerificationVersion())),LocalDate.of(2026,10,23),"42",LocalDate.of(2026,10,19),"B客户部分后送",TRANSPORT);
}
private static List<Selection> selections(FbaAppointmentResponse batch,String... customers) {return java.util.Arrays.stream(customers).map(c->new Selection(batch.batchKey(),batch.version(),c)).toList();}
private static RepartitionFbaAppointmentsRequest.Batch laterBatch(int amount) {
return new RepartitionFbaAppointmentsRequest.Batch(null,null,List.of(new RepartitionFbaAppointmentsRequest.Allocation("B","ORDER:2",amount,"ORDER:1",1)));
}
private static final class Fixture implements AutoCloseable {
final AnnotationConfigApplicationContext context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class);
final InboundFbaAppointmentService batches=context.getBean(InboundFbaAppointmentService.class);
final FbaReceivingService receiving=context.getBean(FbaReceivingService.class);
final FbaReceivingStore store=context.getBean(FbaReceivingStore.class);
final FbaAppointmentTransportPort transport=context.getBean(FbaAppointmentTransportPort.class);
final FbaAppointmentWorkPort work=context.getBean(FbaAppointmentWorkPort.class);
final FbaAppointmentExecutionPort execution=context.getBean(FbaAppointmentExecutionPort.class);
final JdbcTemplate db=new JdbcTemplate(context.getBean(DataSource.class));
Fixture() {
db.update("INSERT INTO ll_order(id) VALUES(3)");
var sources=List.of(source("A",1,80),source("B",2,160),source("D",3,40));
when(context.getBean(AppointmentSourceService.class).resolveBatch(anyList())).thenAnswer(call->{
List<AppointmentCoverageService.SourceRef> refs=call.getArgument(0);
return refs.stream().map(ref->sources.stream().filter(s->s.customerId().equals(ref.customerId())&&s.sourceKey().equals(ref.sourceKey())&&s.destinationKey().equals(ref.destinationKey())).findFirst().orElseThrow()).toList();
});
var verification=context.getBean(AppointmentCargoVerificationService.class);
for(var source:sources) verification.save(source.sourceKey(),source.customerId(),new AppointmentCargoVerificationService.Save(DESTINATION,source.sourceVersion(),0,source.packages(),List.of("SHIP-"+source.customerId()),null,"verify-"+source.customerId()),OPERATOR,"客服");
}
FbaAppointmentResponse create() {
return batches.createFromSources(OPERATOR,new SourceFbaAppointmentRequest(List.of(
new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1",DESTINATION),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2",DESTINATION),new SourceFbaAppointmentRequest.SourceRef("D","ORDER:3",DESTINATION)),"100","ONT8",confirmed("ORIGINAL-GROUP"),"create"));
}
FbaAppointmentResponse createPending(LocalDate date) {
return batches.createFromSources(OPERATOR,new SourceFbaAppointmentRequest(List.of(
new SourceFbaAppointmentRequest.SourceRef("A","ORDER:1",DESTINATION),new SourceFbaAppointmentRequest.SourceRef("B","ORDER:2",DESTINATION),new SourceFbaAppointmentRequest.SourceRef("D","ORDER:3",DESTINATION)),"100","ONT8",planned(date,"PENDING",null),"create-pending"));
}
Result plan(FbaAppointmentResponse batch,String customer,FbaAppointmentDetails details,String key) {return receiving.plan(OPERATOR,new Plan(key,new Selection(batch.batchKey(),batch.version(),customer),details,TRANSPORT));}
Result refresh(FbaAppointmentResponse batch,String customer,List<CargoVersion> versions,String key) {return receiving.refreshCargo(OPERATOR,new RefreshCargo(key,new Selection(batch.batchKey(),batch.version(),customer),versions,TRANSPORT));}
void verifyB(int version,String key,String shipment) {context.getBean(AppointmentCargoVerificationService.class).save("ORDER:2","B",new AppointmentCargoVerificationService.Save(DESTINATION,"ORDER:1",version,160,List.of(shipment),null,key),OPERATOR,"客服");}
Result propose(FbaAppointmentResponse batch,int retained,String key) {return receiving.propose(OPERATOR,new Propose(key,intent(batch,retained)));}
Result confirm(String changeKey,String key) {return receiving.resolve(OPERATOR,changeKey,new Resolve(key,0,"CONFIRM","仓库明确同意本次保留量和移出量",TRANSPORT));}
Result cancel(Agreement group) {return receiving.changeGroup(OPERATOR,group.agreementKey(),new GroupChange("cancel-group",group.version(),"CANCEL",null,"仓库取消本份预约",TRANSPORT));}
void assertAllocations(int a,int b,int d) {
for(var entry:java.util.Map.of("A",a,"B",b,"D",d).entrySet()) assertEquals(entry.getValue(),db.queryForObject("SELECT SUM(package_count) FROM ll_inbound_fba_appointment_item WHERE customer_id=? AND active=1",Integer.class,entry.getKey()));
}
@Override public void close() {context.close();}
private static AppointmentSourceService.Source source(String customer,long id,int packages) {
return new AppointmentSourceService.Source("ORDER:"+id,customer,id,"IN-"+customer,null,null,"100","美国仓",DESTINATION,"ONT8",packages,"ORDER:1",true,List.of());
}
}
}
@@ -33,6 +33,8 @@ class InboundFbaAppointmentServiceTest {
headers=mock(InboundFbaAppointmentMapper.class);items=mock(InboundFbaAppointmentItemMapper.class);query=mock(InboundFbaAppointmentQueryMapper.class);
commands=mock(FbaAppointmentCommandMapper.class);sources=mock(AppointmentSourceService.class);flags=mock(AppointmentBatchPreviewMapper.class);
service=new InboundFbaAppointmentService();
ReflectionTestUtils.setField(service,"receiving",mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
ReflectionTestUtils.setField(service,"receivingWork",mock(com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.class));
ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.class));
ReflectionTestUtils.setField(service,"verification",mock(AppointmentCargoVerificationService.class));
ReflectionTestUtils.setField(service,"appointmentMapper",headers);ReflectionTestUtils.setField(service,"itemMapper",items);ReflectionTestUtils.setField(service,"queryMapper",query);
@@ -141,6 +143,13 @@ class InboundFbaAppointmentServiceTest {
when(sources.destinationTimezone("ONT8")).thenReturn(null);
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("no-zone",confirmed(),"ORDER:1")));assertTrue(storedHeaders.isEmpty());
}
@Test void confirmedAppointmentCannotLoseItsConfirmationThroughOrdinaryFailedEdit() {
var original=service.createFromSources("operator",request("confirmed-before-failure",confirmed(),"ORDER:1"));
var attempt=new UpdateFbaAppointmentRequest(new FbaAppointmentDetails(null,null,null,null,null,null,"FAILED","仓库取消",null,null),original.version(),"bypass-cancel");
assertEquals(400,assertThrows(ResponseStatusException.class,()->service.update("operator",original.appointmentNo(),attempt)).getStatusCode().value());
assertEquals("SUCCESS",storedHeaders.get(0).getAppointmentStatus());assertEquals(original.confirmationNo(),storedHeaders.get(0).getConfirmationNo());
verify(headers,never()).update(any(InboundFbaAppointment.class),any());
}
@Test void confirmationRequiresActualShipmentReferencesAndKnownCargoWithoutFakingMaterialization() {
when(query.countMissingShipmentReferences(List.of(1L))).thenReturn(1L);
assertThrows(ResponseStatusException.class,()->service.createFromSources("operator",request("missing-materials",confirmed(),"ORDER:1")));
@@ -191,10 +200,14 @@ class InboundFbaAppointmentServiceTest {
verify(commands,never()).begin(anyString(),anyString(),anyString(),anyString(),any());
verify(headers,never()).update(any(InboundFbaAppointment.class),any());
}
@Test void cancellationPreservesConfirmedFactsAndHistoryButCannotRestoreOverAnotherActiveClaim() {
@Test void cancellationClearsEffectiveDatesButPreservesAllocationAndImmutableConfirmedHistory() throws Exception {
var result=service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1"));
service.delete("operator",result.appointmentNo(),new CancelFbaAppointmentRequest(0,"收货方取消时段"));
assertEquals("CANCELLED",storedHeaders.get(0).getAppointmentStatus());assertEquals(at,storedHeaders.get(0).getAppointmentAt());assertFalse(storedItems.get(0).getActive());
assertEquals("CANCELLED",storedHeaders.get(0).getAppointmentStatus());assertNull(storedHeaders.get(0).getAppointmentAt());assertNull(storedHeaders.get(0).getPlannedDate());assertTrue(storedItems.get(0).getActive());
var snapshot=journal.get("CANCEL:"+result.appointmentNo()+":0").responseJson();
var json=new ObjectMapper().findAndRegisterModules();
assertEquals("EXT-ONT8-1",json.readTree(snapshot).path("confirmationNo").asText());
assertEquals(json.valueToTree(at),json.readTree(snapshot).get("appointmentAt"));
assertEquals(result,service.createFromSources("operator",request("confirmed",confirmed(),"ORDER:1")));
when(query.allocation(anyString(),anyString(),nullable(Long.class),eq(result.id()))).thenReturn(new InboundFbaAppointmentQueryMapper.Allocation(10,1,0));
assertThrows(ResponseStatusException.class,()->service.update("operator",result.appointmentNo(),new UpdateFbaAppointmentRequest(confirmed(),1,"restore")));
@@ -6,6 +6,7 @@ import com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Resolutio
import com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
import org.junit.jupiter.api.Test;
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
import java.time.LocalDateTime;
import java.util.List;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
@@ -14,24 +15,169 @@ import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class AppointmentWorkDeliveryProviderTest {
@Test void confirmedCustomerIndependentFailedPlanAndUnplannedSiblingRemainSeparate() {
String key="FBA_APPOINTMENT:1",zone="America/Los_Angeles";
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);var receiving=receiving();
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn(key);when(batch.appointmentStatus()).thenReturn("SUCCESS");
when(batch.plannedDate()).thenReturn(java.time.LocalDate.of(2026,10,19));when(batch.timezone()).thenReturn(zone);
var a=item();when(a.customerId()).thenReturn("A");var b=item();when(b.customerId()).thenReturn("B");var d=item();when(d.customerId()).thenReturn("D");
when(batch.items()).thenReturn(List.of(a,b,d));when(appointments.batchSnapshots(List.of(key))).thenReturn(List.of(batch));
var confirmedAt=LocalDateTime.of(2026,10,20,10,0);
var agreement=new Agreement("RA-A",1,"ACTIVE","FBA:ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-A",confirmedAt,null,"SUCCESS",null,null,null),zone,
List.of(new Member(key,"AP-1",1,"A",List.of(),null)),"正式确认");
var failedDate=java.time.LocalDate.of(2026,10,22);
var failed=new CustomerPlan(key,"B",planDetails(failedDate,"FAILED"),zone);
var staleA=new CustomerPlan(key,"A",planDetails(failedDate,"FAILED"),zone);
when(receiving.snapshots(List.of(key))).thenReturn(List.of(new BatchReceiving(key,List.of(agreement),List.of(),List.of(staleA,failed))));
when(commitments.impactTokens(anyList())).thenAnswer(call->{List<String> keys=call.getArgument(0);return keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0"));});
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving);
var requests=List.of("A","B","D").stream().map(c->new FactRequest("W"+c,Type.DELIVERY,Flow.TRANSFER,"1",key+"|CUSTOMER:"+c,key,null,List.of(key),List.of(),List.of(new SourceReference(c,"ORDER:1","FBA:ONT8")))).toList();
var facts=provider.read(requests);var aFact=facts.get(0);var bFact=facts.get(1);var dFact=facts.get(2);
assertEquals(confirmedAt.toLocalDate(),aFact.confirmed().date());assertNull(aFact.target());assertEquals(PlanStatus.CONFIRMED,aFact.planStatus());assertTrue(aFact.blockReasons().isEmpty());
assertEquals(failedDate,bFact.target().date());assertNull(bFact.confirmed());assertEquals(PlanStatus.TARGET_SET,bFact.planStatus());assertEquals("沿原批次重新预约",bFact.action().label());
assertEquals(PlanStatus.UNPLANNED,dFact.planStatus());assertNull(dFact.target());assertNull(dFact.confirmed());assertTrue(dFact.blockReasons().isEmpty());
var state=new com.baiyue.lastleg.appointmentwork.AppointmentWorkState();state.setWorkItemKey("WB");state.setType(Type.DELIVERY);state.setFlow(Flow.TRANSFER);
var projected=com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.project(state,bFact,new Actor("reader","reader",java.util.Set.of()),java.time.Clock.systemUTC());
assertEquals(failedDate,projected.calendarDate());assertEquals(DateBasis.TARGET,projected.dateBasis());
var cleared=new CustomerPlan(key,"B",planDetails(null,"PENDING"),zone);
when(receiving.snapshots(List.of(key))).thenReturn(List.of(new BatchReceiving(key,List.of(agreement),List.of(),List.of(cleared))));
var updated=provider.read(requests);
assertEquals(aFact.fingerprint(),updated.get(0).fingerprint());assertEquals(dFact.fingerprint(),updated.get(2).fingerprint());
assertEquals(PlanStatus.UNPLANNED,updated.get(1).planStatus());assertNull(updated.get(1).target());assertTrue(updated.get(1).blockReasons().isEmpty());
// An explicitly cleared customer plan wins even when the original shared header has a date.
when(receiving.snapshots(List.of(key))).thenReturn(List.of(new BatchReceiving(key,List.of(),List.of(),List.of(cleared))));
assertNull(provider.read(List.of(requests.get(1))).get(0).target());
when(receiving.snapshots(List.of(key))).thenReturn(List.of(new BatchReceiving(key,List.of(),List.of(),List.of())));
assertEquals(batch.plannedDate(),provider.read(List.of(requests.get(1))).get(0).target().date());
}
private static com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails planDetails(java.time.LocalDate date,String status) {
return new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(date,null,null,null,null,null,status,null,null,null);
}
@Test void customerExecutionExcludesRebookingAndFullBatchReadLimitAppliesBeforeCustomerFiltering() {
String key="FBA_APPOINTMENT:1";var at=LocalDateTime.parse("2026-10-17T12:00:00");
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);var deliveries=mock(AppointmentWorkDeliveryMapper.class);
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
var own=item();var other=item();when(other.customerId()).thenReturn("D");when(other.sourceKey()).thenReturn("ORDER:2");
when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn(key);when(batch.items()).thenReturn(List.of(own,other));when(batch.appointmentStatus()).thenReturn("PENDING");
when(appointments.batchSnapshots(List.of(key))).thenReturn(List.of(batch));
when(commitments.impactTokens(List.of(key+"|CUSTOMER:C"))).thenReturn(java.util.Map.of(key+"|CUSTOMER:C","C:0"));
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,deliveries,mock(AppointmentWorkSourceMapper.class),receiving());
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1",key+"|CUSTOMER:C",key,null,List.of(key),List.of(),refs());
var signed=new AppointmentWorkDeliveryMapper.BatchDelivery(key,"C","ORDER:1","FBA:ONT8",100,"DEPARTED","DL-C","DELIVERED",100,100,at);
var waiting=new AppointmentWorkDeliveryMapper.BatchDelivery(key,"D","ORDER:2","FBA:ONT8",100,"PENDING_DEPARTURE","DL-D","WAIT_DELIVERY",100,null,null);
when(deliveries.batches(List.of(key))).thenReturn(List.of(signed,waiting));
var complete=provider.read(List.of(request)).get(0);
assertEquals(ExecutionStatus.COMPLETED,complete.executionStatus());assertEquals(at,complete.actual().localOccurredAt());assertNull(complete.action());
var inTransit=new AppointmentWorkDeliveryMapper.BatchDelivery(key,"C","ORDER:1","FBA:ONT8",100,"DEPARTED","DL-C","WAIT_DELIVERY",100,null,null);
when(deliveries.batches(List.of(key))).thenReturn(List.of(inTransit,waiting));
var executing=provider.read(List.of(request)).get(0);
assertEquals(ExecutionStatus.IN_PROGRESS,executing.executionStatus());assertNull(executing.action());assertNull(executing.actual());assertFalse(executing.blockReasons().isEmpty());
var unexpected=new AppointmentWorkDeliveryMapper.BatchDelivery(key,"C","ORDER:999","FBA:ONT8",5,"DEPARTED","DL-X","DELIVERED",5,5,at);
when(deliveries.batches(List.of(key))).thenReturn(List.of(signed,unexpected));
assertNotEquals(ExecutionStatus.COMPLETED,provider.read(List.of(request)).get(0).executionStatus());
var truncated=new java.util.ArrayList<>(java.util.Collections.nCopies(1000,waiting));truncated.add(signed);
when(deliveries.batches(List.of(key))).thenReturn(truncated);
assertTrue(assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.read(List.of(request))).getReason().contains("完整读取上限"));
}
@Test void independentDatesAndPendingScopeUseOneBulkReadWithoutHeaderConfirmationOrSiblingVersionImpact() {
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var deliveries=mock(AppointmentWorkDeliveryMapper.class);var receiving=receiving();
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(batch.appointmentStatus()).thenReturn("PENDING");
var a=item();when(a.customerId()).thenReturn("A");var b=item();when(b.customerId()).thenReturn("B");when(b.sourceKey()).thenReturn("ORDER:2");
when(batch.items()).thenReturn(List.of(a,b));when(appointments.batchSnapshots(List.of(batch.batchKey()))).thenReturn(List.of(batch));
var memberA=new Member(batch.batchKey(),"AP-1",1,"A",List.of(new Source("ORDER:1","FBA:ONT8",100,"v1",1,null)),null);
var memberB=new Member(batch.batchKey(),"AP-1",1,"B",List.of(new Source("ORDER:2","FBA:ONT8",100,"v1",1,null)),null);
var timeA=LocalDateTime.parse("2026-10-17T10:00:00");var timeB=timeA.plusDays(1);
var detailsA=new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-A",timeA,null,"SUCCESS",null,null,null);
var detailsB=new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-B",timeB,null,"SUCCESS",null,null,null);
var groupA=new Agreement("RA-A",1,"ACTIVE","FBA:ONT8",detailsA,"America/Los_Angeles",List.of(memberA),"确认A");
var groupB=new Agreement("RA-B",1,"ACTIVE","FBA:ONT8",detailsB,"America/Los_Angeles",List.of(memberB),"确认B");
var pending=new Pending("RC-B",0,"WAITING",new ChangeIntent(batch.batchKey(),1,"B",List.of(),timeB.toLocalDate().plusDays(1),"1",timeA.toLocalDate(),"后送", "scope"),List.of(memberB),List.of(groupB),new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Followup("1","负责人",timeA.toLocalDate()));
var batchKey=batch.batchKey();
when(receiving.snapshots(List.of(batchKey))).thenReturn(List.of(new BatchReceiving(batchKey,List.of(groupA,groupB),List.of(pending),List.of())));
var requestA=new FactRequest("WA",Type.DELIVERY,Flow.TRANSFER,"1","A",batch.batchKey(),null,List.of(batch.batchKey()),List.of(),List.of(new SourceReference("A","ORDER:1","FBA:ONT8")));
var requestB=new FactRequest("WB",Type.DELIVERY,Flow.TRANSFER,"1","B",batch.batchKey(),null,List.of(batch.batchKey()),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8")));
when(commitments.impactTokens(anyList())).thenAnswer(call->{List<String> keys=call.getArgument(0);return keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0"));});
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,deliveries,mock(AppointmentWorkSourceMapper.class),receiving);
var facts=provider.read(List.of(requestA,requestB));
assertEquals(timeA.toLocalDate(),facts.get(0).confirmed().date());assertEquals(PlanStatus.CONFIRMED,facts.get(0).planStatus());assertTrue(facts.get(0).blockReasons().isEmpty());
assertEquals(timeB.toLocalDate(),facts.get(1).confirmed().date());assertEquals(PlanStatus.AWAITING_REPLY,facts.get(1).planStatus());assertTrue(facts.get(1).arranged());
assertEquals(List.of(new SourceReference("B","ORDER:2","FBA:ONT8")),((AppointmentTarget)facts.get(1).action().target()).sources());
assertFalse(facts.get(1).blockReasons().isEmpty());verify(receiving).snapshots(List.of(batch.batchKey()));verifyNoMoreInteractions(receiving);
// A shared confirmation's revision changes when another member changes; A's fact stays semantic.
var shared=new Agreement("RA-SHARED",9,"ACTIVE","FBA:ONT8",detailsA,"America/Los_Angeles",List.of(memberA,memberB),"共同确认");
when(receiving.snapshots(List.of(batchKey))).thenReturn(List.of(new BatchReceiving(batchKey,List.of(shared),List.of(),List.of())));
var unchanged=provider.read(List.of(requestA)).get(0);
assertEquals(facts.get(0).fingerprint(),unchanged.fingerprint());assertEquals(facts.get(0).impactToken(),unchanged.impactToken());
when(receiving.snapshots(List.of(batchKey))).thenReturn(List.of(new BatchReceiving(batchKey,List.of(),List.of(),List.of())));
when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.appointmentAt()).thenReturn(timeA);
assertNull(provider.read(List.of(requestA)).get(0).confirmed());
}
private static List<SourceReference> refs() { return List.of(new SourceReference("C","ORDER:1","FBA:ONT8")); }
private static com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item item() {
var item=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item.class);
when(item.customerId()).thenReturn("C");when(item.sourceKey()).thenReturn("ORDER:1");when(item.destinationKey()).thenReturn("FBA:ONT8");when(item.packageCount()).thenReturn(100);
return item;
}
private static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving() {
var store=mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class);
when(store.snapshots(anyList())).thenAnswer(call->{List<String> keys=call.getArgument(0);return keys.stream().map(k->new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving(k,List.of(),List.of(),List.of())).toList();});
return store;
}
@Test void cancelledBookingClearsDatesAndGuidesRebookingTheSameAllocatedBatch() {
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving());
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
var ownItem=item();when(batch.items()).thenReturn(List.of(ownItem));when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");
when(batch.appointmentStatus()).thenReturn("CANCELLED");when(batch.appointmentAt()).thenReturn(LocalDateTime.parse("2026-10-15T11:00:00"));
when(appointments.batchSnapshots(List.of("FBA_APPOINTMENT:1"))).thenReturn(List.of(batch));
when(commitments.impactTokens(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"))).thenReturn(java.util.Map.of("FBA_APPOINTMENT:1|CUSTOMER:C","cancelled:1"));
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1",null,"FBA_APPOINTMENT:1",null,List.of(),List.of(),refs());
var fact=provider.read(List.of(request)).get(0);
assertTrue(fact.planExists());assertEquals(PlanStatus.UNPLANNED,fact.planStatus());
assertNull(fact.target());assertNull(fact.confirmed());assertNull(fact.actual());
assertEquals("沿原批次重新预约",fact.action().label());
assertEquals(new AppointmentTarget("1","FBA_APPOINTMENT:1",refs()),fact.action().target());
assertFalse(fact.blockReasons().isEmpty());
}
@Test void failedBookingKeepsItsTargetCalendarDateWithoutInventingConfirmation() {
var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving());
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
var ownItem=item();when(batch.items()).thenReturn(List.of(ownItem));when(batch.id()).thenReturn(1L);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(batch.appointmentStatus()).thenReturn("FAILED");
when(batch.plannedDate()).thenReturn(java.time.LocalDate.of(2026,10,18));when(batch.timezone()).thenReturn("America/Los_Angeles");
when(appointments.batchSnapshots(List.of("FBA_APPOINTMENT:1"))).thenReturn(List.of(batch));
when(commitments.impactTokens(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"))).thenReturn(java.util.Map.of("FBA_APPOINTMENT:1|CUSTOMER:C","failed:1"));
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1",null,"FBA_APPOINTMENT:1",null,List.of(),List.of(),refs());
var fact=provider.read(List.of(request)).get(0);
assertEquals(PlanStatus.TARGET_SET,fact.planStatus());assertNull(fact.confirmed());assertNull(fact.actual());
assertEquals("沿原批次重新预约",fact.action().label());assertFalse(fact.blockReasons().isEmpty());
var state=new com.baiyue.lastleg.appointmentwork.AppointmentWorkState();state.setWorkItemKey("W");state.setType(Type.DELIVERY);state.setFlow(Flow.TRANSFER);
var projected=com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.project(state,fact,new Actor("reader","reader",java.util.Set.of()),java.time.Clock.systemUTC());
assertEquals(java.time.LocalDate.of(2026,10,18),projected.calendarDate());assertEquals(DateBasis.TARGET,projected.dateBasis());
when(batch.plannedDate()).thenReturn(null);
assertEquals(PlanStatus.UNPLANNED,provider.read(List.of(request)).get(0).planStatus());
}
@Test void establishedFbaBatchesShareSnapshotsDeliveriesAndImpactReads() {
var appointments=mock(InboundFbaAppointmentService.class);var cargo=mock(AppointmentCargoVerificationService.class);
var commitments=mock(AppointmentWorkCommitmentService.class);var deliveries=mock(AppointmentWorkDeliveryMapper.class);var sources=mock(AppointmentWorkSourceMapper.class);
var requests=java.util.stream.IntStream.rangeClosed(1,200).mapToObj(i->new FactRequest("W"+i,Type.DELIVERY,Flow.TRANSFER,"1",null,"FBA_APPOINTMENT:"+i,null,List.of(),List.of(),List.of())).toList();
var requests=java.util.stream.IntStream.rangeClosed(1,200).mapToObj(i->new FactRequest("W"+i,Type.DELIVERY,Flow.TRANSFER,"1",null,"FBA_APPOINTMENT:"+i,null,List.of(),List.of(),refs())).toList();
var keys=requests.stream().map(FactRequest::batchKey).toList();
var snapshots=keys.stream().map(key->{
var snapshot=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
when(snapshot.batchKey()).thenReturn(key);when(snapshot.id()).thenReturn(Long.valueOf(key.substring(16)));when(snapshot.appointmentStatus()).thenReturn("PENDING");
var ownItem=item();when(snapshot.items()).thenReturn(List.of(ownItem));when(snapshot.batchKey()).thenReturn(key);when(snapshot.id()).thenReturn(Long.valueOf(key.substring(16)));when(snapshot.appointmentStatus()).thenReturn("PENDING");
return snapshot;
}).toList();
when(appointments.batchSnapshots(keys)).thenReturn(snapshots);
when(commitments.impactTokens(keys)).thenReturn(keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0")));
var result=new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources).read(requests);
var impactKeys=keys.stream().map(k->k+"|CUSTOMER:C").toList();when(commitments.impactTokens(impactKeys)).thenReturn(impactKeys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0")));
var result=new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources,receiving()).read(requests);
assertEquals(200,result.size());assertTrue(result.stream().allMatch(f->f.planExists() && f.executionStatus()==ExecutionStatus.PENDING));
verify(appointments).batchSnapshots(keys);verify(deliveries).batches(keys);verify(commitments).impactTokens(keys);
verify(appointments).batchSnapshots(keys);verify(deliveries).batches(keys);verify(commitments).impactTokens(impactKeys);
verifyNoMoreInteractions(appointments,deliveries,commitments);verifyNoInteractions(cargo,sources);
when(appointments.batchSnapshots(keys)).thenReturn(List.of());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources).read(requests));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->new AppointmentWorkDeliveryProvider(appointments,cargo,commitments,deliveries,sources,receiving()).read(requests));
}
@Test void handledPrerequisiteCommitsTheCallerTransactionThroughTheRealCargoProxy() {
var sourceService=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.class);
@@ -41,7 +187,7 @@ class AppointmentWorkDeliveryProviderTest {
var manager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds);
var proxy=new org.springframework.aop.framework.ProxyFactory(cargo);proxy.setProxyTargetClass(true);
proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager,new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
var provider=new AppointmentWorkDeliveryProvider(mock(InboundFbaAppointmentService.class),(AppointmentCargoVerificationService)proxy.getProxy(),mock(AppointmentWorkCommitmentService.class),mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class));
var provider=new AppointmentWorkDeliveryProvider(mock(InboundFbaAppointmentService.class),(AppointmentCargoVerificationService)proxy.getProxy(),mock(AppointmentWorkCommitmentService.class),mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving());
var jdbc=new org.springframework.jdbc.core.JdbcTemplate(ds);jdbc.execute("CREATE TABLE work_probe(id INT PRIMARY KEY)");
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1","INBOUND:1",null,null,List.of(),List.of(),List.of(new SourceReference("C","ORDER:11",null)));
new org.springframework.transaction.support.TransactionTemplate(manager).executeWithoutResult(tx->{
@@ -52,7 +198,7 @@ class AppointmentWorkDeliveryProviderTest {
}
@Test void knownSourcePrerequisiteIsVisibleAndCannotOfferBookingButOutagesStillFail() {
var cargo=mock(AppointmentCargoVerificationService.class);var sources=mock(AppointmentWorkSourceMapper.class);
var provider=new AppointmentWorkDeliveryProvider(mock(InboundFbaAppointmentService.class),cargo,mock(AppointmentWorkCommitmentService.class),mock(AppointmentWorkDeliveryMapper.class),sources);
var provider=new AppointmentWorkDeliveryProvider(mock(InboundFbaAppointmentService.class),cargo,mock(AppointmentWorkCommitmentService.class),mock(AppointmentWorkDeliveryMapper.class),sources,receiving());
var request=new FactRequest("W",Type.DELIVERY,Flow.TRANSFER,"1","INBOUND:1",null,null,List.of(),List.of(),List.of(new SourceReference("C","ORDER:11",null)));
when(cargo.inspectBatch(List.of(new SourceRef("C","ORDER:11",null)))).thenReturn(List.of(new AppointmentCargoVerificationService.Inspection(new SourceRef("C","ORDER:11",null),null,new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"接仓点不在启用的美国仓目录中"))));
var fact=provider.read(List.of(request)).get(0);
@@ -63,7 +209,7 @@ class AppointmentWorkDeliveryProviderTest {
}
@Test void privateCompletionCoversThisDispatchPerSourceWithoutCompletingTheWholeOrder() {
var appointments=mock(InboundFbaAppointmentService.class);var deliveries=mock(AppointmentWorkDeliveryMapper.class);var sources=mock(AppointmentWorkSourceMapper.class);
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),mock(AppointmentWorkCommitmentService.class),deliveries,sources);
var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),mock(AppointmentWorkCommitmentService.class),deliveries,sources,receiving());
when(sources.privateBatch("DISPATCH:20")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","DEPARTED",11,"C","ORDER:11","CUSTOMER:11",4),new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","DEPARTED",12,"C","ORDER:12","CUSTOMER:12",6)));
var time=LocalDateTime.parse("2026-10-15T11:00:00");
var one=new AppointmentWorkDeliveryMapper.CustomerDelivery(11,"C",4,"L-1","DELIVERED",10,10,time,time.minusDays(1));
@@ -11,9 +11,80 @@ import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
class AppointmentWorkDispatchBindingTest {
@Test void referencesCombinePhysicalRowsWithoutLosingBatchCustomerOrSourceIdentity() throws Exception {
var mapper=mock(AppointmentWorkDispatchMapper.class);
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
when(mapper.references(List.of(11L,12L))).thenReturn(List.of(
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",40),
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",20),
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:2",null,"A","ORDER:2","FBA:ONT8",10),
new AppointmentWorkDispatchMapper.ReferenceRow(12L,"DISPATCH:12","DP-12","B","ORDER:3","CUSTOMER:3",30)));
var result=service.references(List.of(12L,11L));assertEquals(2,result.get(11L).size());
assertEquals(60,result.get(11L).get(0).sources().get(0).packageCount());assertNull(result.get(11L).get(1).batchNo());
assertEquals("DP-12",result.get(12L).get(0).batchNo());assertEquals("B",result.get(12L).get(0).customerId());
verify(mapper).references(List.of(11L,12L));
var transaction=AppointmentWorkDispatchBinding.class.getMethod("references",List.class).getAnnotation(org.springframework.transaction.annotation.Transactional.class);
assertEquals(org.springframework.transaction.annotation.Propagation.SUPPORTS,transaction.propagation());assertTrue(transaction.readOnly());
when(mapper.references(List.of(11L))).thenReturn(java.util.Collections.nCopies(20001,new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1","A","ORDER:1","FBA:ONT8",1)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.references(List.of(11L)));
}
@Test void departureRechecksCurrentSourceAllocationAfterBatchQuantityOrMembershipChanges() {
var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);
var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
var receiving=mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving);
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("C",4));
var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
when(mapper.bindings("D-20")).thenReturn(List.of(row));
when(mapper.lockAppointment(1)).thenReturn(new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment());
var batch=mock(FbaAppointmentResponse.class);when(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");
when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.appointmentAt()).thenReturn(java.time.LocalDateTime.of(2026,10,18,10,0));
when(batch.replacementBatchKeys()).thenReturn(List.of());
when(appointments.batchContext("FBA_APPOINTMENT:1")).thenReturn(new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentBatchContext(batch,List.of(),List.of(),List.of(),List.of(batch),List.of()));
var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn("C");when(item.sourceKey()).thenReturn("ORDER:1");when(item.destinationKey()).thenReturn("FBA:ONT8");
when(item.packageCount()).thenReturn(3);when(batch.items()).thenReturn(List.of(item));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
when(item.customerId()).thenReturn("OTHER");
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(item.customerId()).thenReturn("C");when(item.destinationKey()).thenReturn("FBA:OTHER");
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
verify(mapper,never()).pickedPhysical(anyLong(),anyString());
when(item.destinationKey()).thenReturn("FBA:ONT8");
when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4)));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(item.packageCount()).thenReturn(5);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L);
assertTrue(service.departureBlockReason("D-20").contains("收货确认未完整覆盖")); // Batch has 5, but the current agreement covers only 4 across every dispatch.
when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L);
when(batch.appointmentStatus()).thenReturn("PENDING");
assertDoesNotThrow(()->service.requireDeparture("D-20")); // The current receiving agreement, not the old header, authorizes receipt.
when(receiving.hasPending("FBA_APPOINTMENT:1","OTHER")).thenReturn(true);
assertDoesNotThrow(()->service.requireDeparture("D-20"));
when(receiving.hasPending("FBA_APPOINTMENT:1","C")).thenReturn(true);
clearInvocations(mapper);
assertTrue(service.departureBlockReason("D-20").contains("待仓库确认"));verify(mapper,never()).lockAppointment(anyLong());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(receiving.hasPending("FBA_APPOINTMENT:1","C")).thenReturn(false);
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("OTHER",4));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(agreement("C",3));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(null);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20"));
verifyNoInteractions(changes);
}
private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement agreement(String customer,int packages) {
var details=new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-1",java.time.LocalDateTime.of(2026,10,18,10,0),null,"SUCCESS",null,null,null);
var source=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Source("ORDER:1","FBA:ONT8",packages,"v1",1,null);
var member=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member("FBA_APPOINTMENT:1","AP-1",1,customer,List.of(source),null);
return new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement("RA-1",1,"ACTIVE","FBA:ONT8",details,"America/Los_Angeles",List.of(member),"仓库已确认");
}
@Test void groupedReplayCannotRewriteBatchBindingsAfterTheOriginalDispatchHasMovedOn() {
var mapper=mock(AppointmentWorkDispatchMapper.class);var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),changes);
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
var row=new AppointmentWorkDispatchMapper.Bound(20,30,10,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
when(mapper.byDispatch(20)).thenReturn(List.of(row));
var allocation=new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",4);
@@ -23,7 +94,7 @@ class AppointmentWorkDispatchBindingTest {
}
@Test void privateDispatchBindsOriginalPhysicalSourceAndDepartureRequiresActualPickedQuantity() {
var mapper=mock(AppointmentWorkDispatchMapper.class);var appointments=mock(InboundFbaAppointmentService.class);var sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","CUSTOMER:1",4,4)));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of());
@@ -48,16 +119,19 @@ class AppointmentWorkDispatchBindingTest {
@Test void emptyAllocationCannotBypassAnExistingBatchAtArrangeOrDeparture() {
var mapper=mock(AppointmentWorkDispatchMapper.class); var appointments=mock(InboundFbaAppointmentService.class);
var sources=mock(AppointmentSourceService.class); var changes=mock(AppointmentWorkChanges.class);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
var part=new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4);
when(mapper.physical(10,"C")).thenReturn(List.of(part));
when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null)));
var batch=mock(FbaAppointmentResponse.class);when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.replacementBatchKeys()).thenReturn(List.of());
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of()));
when(mapper.bindings("D-1")).thenReturn(List.of());
when(mapper.dispatchPhysical("D-1")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1"));
for(var status:List.of("SUCCESS","CANCELLED","FAILED")) {
when(batch.appointmentStatus()).thenReturn(status);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.bind("C",20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,10L,"RECEIVED",4)),List.of()));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1"));
}
verify(mapper,never()).insert(any()); verifyNoInteractions(changes);
}
}
@@ -39,6 +39,7 @@ class AppointmentWorkDomainMapperTest {
var dispatch=session.getMapper(AppointmentWorkDispatchMapper.class);
assertTrue(dispatch.candidates(1).isEmpty());assertTrue(dispatch.physical(1,"C").isEmpty());assertTrue(dispatch.pickedPhysical(1,"C").isEmpty());
assertTrue(dispatch.dispatchPhysical("D").isEmpty());assertTrue(dispatch.bindings("D").isEmpty());assertTrue(dispatch.byDispatch(1).isEmpty());assertTrue(dispatch.trips("FBA_APPOINTMENT:1").isEmpty());
assertTrue(dispatch.references(List.of(1L)).isEmpty());
assertEquals(0,dispatch.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",1));
var deliveries=session.getMapper(AppointmentWorkDeliveryMapper.class);assertTrue(deliveries.batch("FBA_APPOINTMENT:1").isEmpty());assertTrue(deliveries.customer("1","1").isEmpty());
assertTrue(deliveries.privateBatch("DISPATCH:1").isEmpty());
@@ -61,9 +62,13 @@ class AppointmentWorkDomainMapperTest {
assertEquals(403L,sessions.stream().filter(s->"1".equals(s.getSourceContainerBookingId())).findFirst().orElseThrow().getId());
try(var sql=session.getConnection().createStatement()) {
sql.execute("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(501,'A','D-1','PENDING_DEPARTURE'),(502,'B','D-2','CANCELLED'),(503,'B','D-3','PENDING_DEPARTURE')");
sql.execute("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by) VALUES(1,'AP-1','W','ONT8','operator'),(2,'AP-2','W','ONT8','operator')");
sql.execute("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(501,601,701,'FBA_APPOINTMENT:1','A','ORDER:101','FBA:ONT8',4),(501,602,702,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',6),(502,603,703,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',2),(503,604,704,'FBA_APPOINTMENT:2','B','ORDER:102','FBA:ONT8',3)");
}
session.clearCache();
var references=dispatch.references(List.of(501L,503L));
assertEquals(2,references.size());assertEquals("A",references.get(0).customerId());assertEquals("AP-1",references.get(0).batchNo());assertEquals(4,references.get(0).packages());
assertEquals("FBA_APPOINTMENT:2",references.get(1).batchKey());assertEquals("AP-2",references.get(1).batchNo()); // Wrong-customer rows on dispatch 501 must not leak into current references.
var batchDeliveries=deliveries.batches(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:2"));assertEquals(3,batchDeliveries.size());
assertEquals(deliveries.batch("FBA_APPOINTMENT:1"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:1")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList());
assertEquals(deliveries.batch("FBA_APPOINTMENT:2"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:2")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList());
@@ -11,6 +11,35 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkRequirementProviderTest {
@Test void cancelledAndFailedBookingsRetainTheirAllocatedBatchAndOnlyAuthorityDefinesRemainder() {
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 batch=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(batch.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(batch.appointmentNo()).thenReturn("AP-1");
when(batch.fbaWarehouseCode()).thenReturn("ONT8");when(batch.items()).thenReturn(List.of(item));when(batch.replacementBatchKeys()).thenReturn(List.of());
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
for(var status:List.of("CANCELLED","FAILED")) {
when(batch.appointmentStatus()).thenReturn(status);
var result=provider.requirements(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());
}
when(allocation.availablePackages()).thenReturn(20L);
assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.requirements(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList());
when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2"));
assertEquals(List.of("INBOUND:1"),provider.requirements(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);
@@ -0,0 +1,62 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentExecutionPort.*;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class FbaAppointmentExecutionAdapterTest {
final String batch="FBA_APPOINTMENT:1";
final Member a=new Member(batch,"A"),b=new Member(batch,"B");
final AppointmentWorkDispatchMapper mapper=mock(AppointmentWorkDispatchMapper.class);
final FleetTripMapper trips=mock(FleetTripMapper.class);
final FleetDispatchGateway dispatches=mock(FleetDispatchGateway.class);
final FbaAppointmentExecutionAdapter adapter=new FbaAppointmentExecutionAdapter(mapper,trips,dispatches);
final Instant at=Instant.parse("2026-10-01T12:00:00Z");
FbaAppointmentExecutionAdapterTest() {
when(mapper.receivingSources(List.of(batch))).thenReturn(List.of(new AppointmentWorkDispatchMapper.ReceivingSource(batch,"A","ORDER:1","FBA:ONT8",100),new AppointmentWorkDispatchMapper.ReceivingSource(batch,"B","ORDER:2","FBA:ONT8",100)));
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("A","DEPARTED","COMPLETED"),row("B","PENDING_DEPARTURE","PLANNED")));
when(dispatches.completion("D-A",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-A","100",List.of(new FleetCompletionModels.Outcome("D-A","DL-A","A","ONT8","DELIVERED",100,at,100,0,0)),null));
when(dispatches.completion("D-B",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-B","100",List.of(),"排车出库尚未完成"));
when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(100,at,1,List.of()));
when(trips.lockByNo("T-B")).thenReturn(new FleetTripEntity());
}
@Test void sameBatchSubsetKeepsTokenAndOnlySelectedCustomerExecutionRowsAreLocked() {
var all=adapter.inspect(List.of(a,b));var selected=adapter.inspect(List.of(b));
assertEquals(all.version(),selected.version());assertTrue(all.members().get(0).completed());assertFalse(all.members().get(0).canRebook());assertTrue(selected.members().get(0).canRebook());
var locked=adapter.lockAndInspect(List.of(b),all.version());assertTrue(locked.members().get(0).canRebook());
var order=inOrder(trips,dispatches);order.verify(trips).lockByNo("T-B");order.verify(dispatches).completion("D-B",true);order.verify(dispatches).lockForPlanning("D-B");
verify(trips,never()).lockByNo("T-A");verify(dispatches,never()).completion("D-A",true);verify(dispatches,never()).lockForPlanning("D-A");
}
@Test void statusCannotReplaceRealProofAndPartialActualCoverageCannotCompleteWholeAllocation() {
when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(50,at,1,List.of()));
var partial=adapter.inspect(List.of(a)).members().get(0);assertFalse(partial.completed());assertFalse(partial.canRebook());
when(dispatches.terminalEvidence("D-A",false)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"缺少真实出库依据"));
var absent=adapter.inspect(List.of(a)).members().get(0);assertFalse(absent.completed());assertFalse(absent.canRebook());
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("B","PENDING_DEPARTURE","COMPLETED")));
var tripOnly=adapter.inspect(List.of(b)).members().get(0);assertFalse(tripOnly.completed());assertFalse(tripOnly.canRebook());
}
@Test void actualReturnCompletionExcludesMemberAndSiblingExecutionChangeInvalidatesToken() {
var prior=adapter.inspect(List.of(b));
when(dispatches.terminalEvidence("D-A",false)).thenReturn(proof(100,at.plusSeconds(60),2,List.of(new FleetDispatchGateway.ReturnedReceipt("11","101",100,at.plusSeconds(60)))));
var returned=adapter.inspect(List.of(a)).members().get(0);assertTrue(returned.completed());assertFalse(returned.canRebook());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(b),prior.version()));
verify(dispatches,never()).completion("D-A",true);
}
@Test void noCandidatesDoesNotGrantAnOperationOrReadOtherBusinessData() {
clearInvocations(mapper,trips,dispatches);assertTrue(adapter.inspect(List.of()).members().isEmpty());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(),"empty"));
verifyNoInteractions(mapper,trips,dispatches);
}
private AppointmentWorkDispatchMapper.TransportRow row(String customer,String status,String tripStatus) {
long id="A".equals(customer)?1:2;boolean executed="DEPARTED".equals(status);
return new AppointmentWorkDispatchMapper.TransportRow(id,"D-"+customer,customer,status,executed?100:0,executed?100:0,LocalDateTime.of(2026,10,1,12,0),id+10,id+100,100,"RECEIVED",batch,"ORDER:"+id,"FBA:ONT8",100,"T-"+customer,1L,tripStatus,id+200,executed?"OUTBOUNDED":"WAIT_OUTBOUND",executed?"COMPLETED":"PDA_PENDING");
}
private FleetDispatchGateway.TerminalEvidence proof(int packages,Instant terminal,long version,List<FleetDispatchGateway.ReturnedReceipt> returns) {
return new FleetDispatchGateway.TerminalEvidence("D-A","A","100",packages,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",packages,terminal,returns.isEmpty()?"DELIVERED":"RETURNED",version,returns)));
}
}
@@ -0,0 +1,103 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentTransportPort;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import org.mybatis.spring.SqlSessionTemplate;
import org.junit.jupiter.api.*;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.*;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Real mapper/transaction checks; original Fleet/Dispatch commands are represented by transactional SQL test doubles. */
class FbaAppointmentTransportAdapterTest {
private JdbcTemplate jdbc;
private TransactionTemplate tx;
private FbaAppointmentTransportPort adapter;
private FleetTripService fleet;
private FleetDispatchGateway dispatch;
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:transport-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);var manager=new DataSourceTransactionManager(ds);tx=new TransactionTemplate(manager);
jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(40),record_version BIGINT,status VARCHAR(30))");
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),customer_id VARCHAR(40),status VARCHAR(30),package_count INT,picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP)");
jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(40),plan_pallet_id BIGINT,source_stage VARCHAR(30),package_count INT)");
jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(60),customer_id VARCHAR(40),source_key VARCHAR(40),destination_key VARCHAR(40),package_count INT)");
jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT)");
jdbc.execute("CREATE TABLE ll_outbound_order(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(40),status VARCHAR(30))");
jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(40),status VARCHAR(30))");
jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'T-1',1,'PLANNED')");
seed(1,"A",80,List.of(80));seed(2,"B",160,List.of(100,60));seed(3,"C",40,List.of(40));
var configuration=new MybatisConfiguration();configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(AppointmentWorkDispatchMapper.class);configuration.addMapper(FleetTripMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
var session=new SqlSessionTemplate(factory.getObject());
fleet=mock(FleetTripService.class);dispatch=mock(FleetDispatchGateway.class);var gateway=dispatch;
when(gateway.lockForPlanning(anyString())).thenAnswer(c->{String number=c.getArgument(0);jdbc.queryForList("SELECT id FROM ll_dispatch_order WHERE dispatch_no=? FOR UPDATE",number);return null;});
when(fleet.unbindDispatch(anyString(),anyString(),any())).thenAnswer(c->{var command=c.getArgument(2,FleetTripModels.BindingCommand.class);jdbc.update("DELETE FROM ll_fleet_trip_dispatch WHERE dispatch_id=(SELECT id FROM ll_dispatch_order WHERE dispatch_no=?)",command.dispatchNo());bump();return null;});
when(fleet.bindDispatch(anyString(),anyString(),any())).thenAnswer(c->{var command=c.getArgument(2,FleetTripModels.BindingCommand.class);jdbc.update("INSERT INTO ll_fleet_trip_dispatch SELECT id,1 FROM ll_dispatch_order WHERE dispatch_no=?",command.dispatchNo());bump();return null;});
when(fleet.detail("T-1")).thenAnswer(c->{
var response=mock(FleetTripModels.Response.class);when(response.recordVersion()).thenReturn(jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class));
var bindings=jdbc.query("SELECT d.* FROM ll_dispatch_order d JOIN ll_fleet_trip_dispatch b ON b.dispatch_id=d.id ORDER BY d.id",(rs,n)->new FleetTripModels.DispatchBinding(rs.getString("dispatch_no"),rs.getString("customer_id"),rs.getInt("package_count"),"operator",null,null,List.of()));
return new FleetTripModels.Detail(response,null,bindings,null,List.of(),null);
});
when(fleet.cancel(anyString(),anyString(),any())).thenAnswer(c->{jdbc.update("UPDATE ll_fleet_trip SET status='CANCELLED',record_version=record_version+1 WHERE id=1");return null;});
doAnswer(c->{
long item=c.getArgument(1);String number=c.getArgument(0);
jdbc.update("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_item_id=?",item);jdbc.update("DELETE FROM ll_dispatch_item WHERE id=?",item);
jdbc.update("UPDATE ll_dispatch_order SET package_count=(SELECT SUM(package_count) FROM ll_dispatch_item WHERE dispatch_order_id=ll_dispatch_order.id) WHERE dispatch_no=?",number);return null;
}).when(dispatch).removeItem(anyString(),anyLong());
var target=new FbaAppointmentTransportAdapter(session.getMapper(AppointmentWorkDispatchMapper.class),session.getMapper(FleetTripMapper.class),gateway,fleet,mock(FleetBookingReservations.class),mock(AppointmentWorkChanges.class));
var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));adapter=(FbaAppointmentTransportPort)proxy.getProxy();
}
private void seed(long id,String customer,int total,List<Integer> amounts) {
jdbc.update("INSERT INTO ll_dispatch_order VALUES(?,?,?,'PENDING_DEPARTURE',?,0,0,CURRENT_TIMESTAMP)",id,"D-"+id,customer,total);
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(?,1)",id);
for(int i=0;i<amounts.size();i++) {
long item=id*10+i;
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,?, 'RECEIVED',?)",item,id,customer,item+100,amounts.get(i));
jdbc.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,?,?,?,?,?,?)",id,item,item+100,"FBA_APPOINTMENT:"+id,customer,"ORDER:"+id,"FBA:ONT8",amounts.get(i));
}
}
private void bump() { jdbc.update("UPDATE ll_fleet_trip SET record_version=record_version+1 WHERE id=1"); }
private FbaAppointmentTransportPort.Result move(int retained) {
var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2"));
return tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version());return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","B","ORDER:2","FBA:ONT8",160,retained,"FBA_APPOINTMENT:4")),"operator","客户明确调整");});
}
private int remaining(FbaAppointmentTransportPort.Result result) { return result.vehicles().stream().filter(v->"T-1".equals(v.tripNo())).mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum(); }
@Test void wholeCustomerExitLeavesOtherCustomers120AndReusesOriginalPhysicalDispatch() {
var other=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'B'");
var result=move(0);assertEquals(120,remaining(result));
assertEquals(other,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'B'"));
assertEquals(160,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation WHERE batch_key='FBA_APPOINTMENT:4'",Integer.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE dispatch_order_id=2",Integer.class));verify(dispatch,never()).removeItem(anyString(),anyLong());
}
@Test void wholePhysical60MovesOutLeavingOriginalVehicle220AndLaterScopeHasNoSyntheticVehicle() {
var result=move(100);assertEquals(220,remaining(result));verify(dispatch).removeItem("D-2",21L);
assertEquals(100,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=2",Integer.class));
var later=adapter.inspect(List.of("FBA_APPOINTMENT:4"));assertTrue(later.vehicles().isEmpty());
var next=tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:4"),later.version());return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:4","B","ORDER:2","FBA:ONT8",60,40,"FBA_APPOINTMENT:5")),"operator","后送60分40和20");});
assertTrue(next.vehicles().isEmpty());assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
}
@Test void unmatchedPartialQuantityAndStaleScopeNeverMutateOriginalPhysicalRows() {
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->move(110));
assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));verify(dispatch,never()).removeItem(anyString(),anyLong());
var old=adapter.inspect(List.of("FBA_APPOINTMENT:2"));bump();
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->tx.execute(status->adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),old.version())));
}
@Test void downstreamFailureRollsBackUnbindingPhysicalRemovalAndQuantityTogether() {
doThrow(new IllegalStateException("原车绑定失败")).when(fleet).bindDispatch(anyString(),anyString(),any());
assertThrows(IllegalStateException.class,()->move(100));
assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));
assertEquals(160,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=2",Integer.class));
assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Integer.class));
assertEquals(1L,jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class));
}
}