This commit is contained in:
2026-10-19 17:56:36 +08:00
parent bb917c954c
commit dcfba1053c
232 changed files with 15655 additions and 6005 deletions
@@ -38,9 +38,10 @@ class AppointmentWorkPreArrangementPersistenceTest {
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 jdbc=new JdbcTemplate(context.getBean(DataSource.class));jdbc.update("INSERT INTO ll_dispatch_order(id,dispatch_no,record_version,status) VALUES(20,'DP-B',0,'ACTIVE')");
jdbc.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,source_stage,package_count) VALUES(201,'B',20,101,'RECEIVED',60)");
var candidates=new AppointmentWorkDispatchCandidates(mapper,batches,sources);
var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store);
var binding=new AppointmentWorkDispatchBinding(mapper,batches,sources,mock(AppointmentWorkChanges.class),store,candidates);
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());
@@ -48,24 +49,26 @@ class AppointmentWorkPreArrangementPersistenceTest {
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);
assertEquals(before.packageCount(),after.packageCount());assertEquals(before.blockReasons(),after.blockReasons());
assertNotEquals(before.allocations().get(0).recordVersion(),after.allocations().get(0).recordVersion());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");
doReturn(List.of(bound)).when(mapper).bindings("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())));
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",101L,"RECEIVED",60)),after.allocations())));
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"D",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())));
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",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());
binding.lockBatches(Map.of(candidate.allocations().get(0).batchKey(),candidate.allocations().get(0).recordVersion()));
binding.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(201L,"B",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.
});
@@ -212,8 +212,8 @@ class FbaAppointmentPersistenceTest {
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,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))");
statement.execute("CREATE TABLE ll_dispatch_item(id BIGINT,order_id BIGINT,customer_id VARCHAR(128),dispatch_order_id BIGINT,plan_pallet_id BIGINT,source_stage VARCHAR(32),package_count INT)");
statement.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR(64),record_version BIGINT,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);}
@@ -239,7 +239,7 @@ class FbaAppointmentPersistenceTest {
@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.inspect(anyList())).thenReturn(scope);when(port.lockAndInspect(anyList(),anyString(),anyString())).thenReturn(scope);
when(port.apply(any(),anyList(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port;
}
@Bean FbaAppointmentWorkPort receivingWork(){
@@ -250,7 +250,7 @@ class FbaAppointmentPersistenceTest {
@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;
when(port.inspect(anyList())).thenAnswer(scope);when(port.lockAndInspect(anyList(),anyString(),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);}
@@ -111,7 +111,7 @@ class FbaReceivingPersistenceTest {
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"));
when(f.transport.apply(any(),anyList(),anyString(),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"));
@@ -205,7 +205,7 @@ class FbaReceivingPersistenceTest {
@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,"原车次范围已更新"));
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).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));
@@ -272,7 +272,7 @@ class FbaReceivingPersistenceTest {
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,"已开始拣货,禁止调整运输货量"));
when(f.transport.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).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)));
@@ -285,7 +285,7 @@ class FbaReceivingPersistenceTest {
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,times(2)).lockAndInspect(anyList(),eq(TRANSPORT),anyString());
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
@@ -300,7 +300,7 @@ class FbaReceivingPersistenceTest {
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);
when(f.execution.lockAndInspect(anyList(),anyString(),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());
@@ -313,8 +313,8 @@ class FbaReceivingPersistenceTest {
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.execution).lockAndInspect(eq(List.of(new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"),new FbaAppointmentExecutionPort.Member(batch.batchKey(),"D"))),eq("execution-v1"),anyString());
verify(f.transport,never()).lockAndInspect(anyList(),anyString(),anyString());
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
f.assertAllocations(80,160,40);
}
@@ -331,6 +331,9 @@ class FbaReceivingPersistenceTest {
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 businessOrder=inOrder(f.transport,f.work);
businessOrder.verify(f.transport).apply(any(),anyList(),eq(OPERATOR),eq(base.reason()),eq("undated-confirm"));
businessOrder.verify(f.work).seedFollowup(any(),any(),eq(followup),eq(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));
@@ -413,7 +416,7 @@ class FbaReceivingPersistenceTest {
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.lockAndInspect(eq(List.of(member)),eq("execution-v1"),anyString())).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")));
@@ -428,7 +431,7 @@ class FbaReceivingPersistenceTest {
@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.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).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"));
@@ -445,7 +448,7 @@ class FbaReceivingPersistenceTest {
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.lockAndInspect(anyList(),eq(TRANSPORT),anyString())).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;
@@ -458,7 +461,7 @@ class FbaReceivingPersistenceTest {
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,times(2)).lockAndInspect(eq(List.of(original.batchKey())),eq(TRANSPORT),anyString());
verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString());
}
}
@@ -13,29 +13,31 @@ 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));
var service=new AppointmentWorkDispatchBinding(mapper,mock(InboundFbaAppointmentService.class),mock(AppointmentSourceService.class),mock(AppointmentWorkChanges.class),mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.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)));
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1",0L,"A","ORDER:1","FBA:ONT8",40),
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1",0L,"A","ORDER:1","FBA:ONT8",20),
new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:2",null,2L,"A","ORDER:2","FBA:ONT8",10),
new AppointmentWorkDispatchMapper.ReferenceRow(12L,"DISPATCH:12","DP-12",3L,"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());
assertEquals(0L,result.get(11L).get(0).recordVersion());
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)));
when(mapper.references(List.of(11L))).thenReturn(java.util.Collections.nCopies(20001,new AppointmentWorkDispatchMapper.ReferenceRow(11L,"FBA_APPOINTMENT:1","AP-1",0L,"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);
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,receiving,mock(AppointmentWorkDispatchCandidates.class));
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.dispatchPhysical("D-20")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
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));
@@ -82,22 +84,26 @@ class AppointmentWorkDispatchBindingTest {
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,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);
assertDoesNotThrow(()->service.requireReplay("C",20,List.of(allocation)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireReplay("C",20,List.of(new AppointmentWorkDispatchPort.Allocation(10L,"FBA_APPOINTMENT:2","C","ORDER:1","FBA:ONT8",4))));
verify(mapper,never()).remove(anyLong());verify(mapper,never()).insert(any());verifyNoInteractions(changes);
@Test void batchPrelockUsesCanonicalSortedKeysAcceptsZeroAndRejectsStaleVersion() {
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),mock(AppointmentWorkDispatchCandidates.class));
var zero=new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment();zero.setVersion(0);
var two=new com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointment();two.setVersion(2);
when(mapper.lockAppointment(10)).thenReturn(zero);when(mapper.lockAppointment(2)).thenReturn(two);
service.lockBatches(Map.of("FBA_APPOINTMENT:2",2L,"FBA_APPOINTMENT:10",0L));
var order=inOrder(mapper);order.verify(mapper).lockAppointment(10);order.verify(mapper).lockAppointment(2);
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.lockBatches(Map.of("FBA_APPOINTMENT:10",1L)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.lockBatches(Map.of("FBA_APPOINTMENT:01",0L)));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.lockBatches(Collections.singletonMap("FBA_APPOINTMENT:10",null)));
}
@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,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.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());
var physical=new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4);
when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(physical));when(mapper.dispatchPhysical("D-20")).thenReturn(List.of(physical));
service.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of());
var expected=new AppointmentWorkDispatchMapper.Bound(20,30,10,"DISPATCH:20","C","ORDER:1","CUSTOMER:1",4);
verify(mapper).insert(expected);verify(changes).requirementsChanged();
when(mapper.bindings("D-20")).thenReturn(List.of(expected));
@@ -119,7 +125,7 @@ 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,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class));
var service=new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class),mock(AppointmentWorkDispatchCandidates.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)));
@@ -127,9 +133,10 @@ class AppointmentWorkDispatchBindingTest {
when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch));
when(mapper.bindings("D-1")).thenReturn(List.of());
when(mapper.dispatchPhysical("D-1")).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
when(mapper.dispatchPhysicalById(20)).thenReturn(List.of(new AppointmentWorkDispatchMapper.DispatchPhysical(30,10L,"C","RECEIVED",4)));
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.bindItems(20,List.of(new AppointmentWorkDispatchPort.PhysicalItem(30L,"C",10L,"RECEIVED",4)),List.of()));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-1"));
}
verify(mapper,never()).insert(any()); verifyNoInteractions(changes);
@@ -0,0 +1,143 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentplanning.*;
import com.baiyue.lastleg.appointmentwork.*;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*;
import com.baiyue.lastleg.inboundplan.dto.*;
import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper;
import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore;
import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService;
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.TransactionDefinition;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.util.concurrent.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** 真实MyBatis、批次行锁、逐客户分配与回滚;原批次和来源读取边界由当前DB事实供给。 */
class AppointmentWorkDispatchPersistenceTest {
private JdbcTemplate jdbc;
private TransactionTemplate tx;
private AppointmentWorkDispatchPort binding;
private AppointmentWorkDispatchMapper mapper;
private InboundFbaAppointmentQueryMapper query;
private AppointmentWorkDispatchCandidates candidates;
private InboundFbaAppointmentService appointments;
private AppointmentSourceService sources;
private final String batch="FBA_APPOINTMENT:1";
/** 无客户主单,多个客户可在同车同批;真实计划逐票数量来自各自明细。 */
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:appointment-dispatch-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa","");
jdbc=new JdbcTemplate(ds);var manager=new DataSourceTransactionManager(ds);tx=new TransactionTemplate(manager);tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED);
jdbc.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),record_version BIGINT,status VARCHAR(30))");
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_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(40),version INT,replacement_keys_json VARCHAR(200))");
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(40),source_key VARCHAR(60),fba_warehouse_code VARCHAR(40),package_count INT,active INT)");
jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(40))");
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT)");
jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,order_id BIGINT,customer_id VARCHAR(40),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40))");
jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)");
jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),dispatch_order_id BIGINT,expires_at TIMESTAMP)");
jdbc.execute("CREATE TABLE change_guard(id BIGINT PRIMARY KEY,version INT)");jdbc.update("INSERT INTO change_guard VALUES(1,0)");
jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'D-1',0,'ACTIVE')");jdbc.update("INSERT INTO ll_inbound_fba_appointment VALUES(1,'AP-1',0,NULL)");jdbc.update("INSERT INTO ll_inbound_plan VALUES(1,'PLAN-1')");
seed(11,"101",1,80,"FBA");seed(21,"202",2,100,"FBA");seed(22,"202",2,60,"FBA");seed(31,"303",3,40,"PRIVATE");
jdbc.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,1,'101','ORDER:1','ONT8',80,1)");jdbc.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,2,'202','BOOKING:real-2','ONT8',160,1)");
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentWorkDispatchMapper.class);config.addMapper(InboundFbaAppointmentQueryMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);var session=new SqlSessionTemplate(factory.getObject());
mapper=session.getMapper(AppointmentWorkDispatchMapper.class);query=session.getMapper(InboundFbaAppointmentQueryMapper.class);
appointments=mock(InboundFbaAppointmentService.class);sources=mock(AppointmentSourceService.class);var changes=mock(AppointmentWorkChanges.class);
when(appointments.batchContext(batch)).thenAnswer(c->new FbaAppointmentBatchContext(snapshot(),List.of(),List.of(),List.of(),List.of(),List.of()));
when(sources.canonicalOrders(anyList())).thenAnswer(c->{List<AppointmentExternalSourceGateway.OrderRef> orders=c.getArgument(0);return orders.stream().map(o->new AppointmentCoverageService.SourceRef(o.customerId(),o.orderId()==2?"BOOKING:real-2":"ORDER:"+o.orderId(),null)).toList();});
doAnswer(c->{jdbc.update("UPDATE change_guard SET version=version+1 WHERE id=1");return null;}).when(changes).requirementsChanged();
candidates=new AppointmentWorkDispatchCandidates(mapper,appointments,sources);
var proxy=new ProxyFactory(new AppointmentWorkDispatchBinding(mapper,appointments,sources,changes,mock(FbaReceivingStore.class),candidates));proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));binding=(AppointmentWorkDispatchPort)proxy.getProxy();
}
/** 插入真实客户、托盘和逐票数量,不从请求猜测。 */
private void seed(long item,String customer,long order,int count,String destination) {
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?)",item,customer,item+100,count);
jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000);
jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination);
jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count);
}
/** 测试中的原批次边界每次读取当前事务真实行。 */
private FbaAppointmentResponse snapshot() {
var response=mock(FbaAppointmentResponse.class);when(response.batchKey()).thenReturn(batch);when(response.id()).thenReturn(1L);when(response.version()).thenReturn(jdbc.queryForObject("SELECT version FROM ll_inbound_fba_appointment WHERE id=1",Integer.class));when(response.replacementBatchKeys()).thenReturn(List.of());
var items=jdbc.query("SELECT * FROM ll_inbound_fba_appointment_item WHERE appointment_id=1 AND active=1",(rs,n)->{
var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn(rs.getString("customer_id"));when(item.sourceKey()).thenReturn(rs.getString("source_key"));when(item.destinationKey()).thenReturn("FBA:"+rs.getString("fba_warehouse_code"));when(item.packageCount()).thenReturn(rs.getInt("package_count"));return item;
});when(response.items()).thenReturn(items);return response;
}
/** 从本车真实明细构造Provider输入。 */
private PhysicalItem item(long id) {return mapper.dispatchPhysicalById(1).stream().filter(i->i.itemId()==id).map(i->new PhysicalItem(i.itemId(),i.customerId(),i.planPalletId(),i.sourceStage(),i.packages())).findFirst().orElseThrow();}
/** 选择对应真实来源的完整数量,版本零必须显式传递。 */
private Allocation allocation(long id) {var i=item(id);return new Allocation(i.planPalletId(),batch,0L,i.customerId(),"202".equals(i.customerId())?"BOOKING:real-2":"ORDER:1","FBA:ONT8",i.packages());}
/** 在来源锁前预锁,再增量绑定指定实物。 */
private void bind(long... ids) {binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,Arrays.stream(ids).mapToObj(this::item).toList(),Arrays.stream(ids).mapToObj(this::allocation).toList());}
@Test void incrementallyAddingOtherCustomersAndPrivateCargoPreservesOriginalAllocations() {
tx.executeWithoutResult(s->bind(11));var original=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY dispatch_item_id");
tx.executeWithoutResult(s->bind(21,22));tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,List.of(item(31)),List.of());});
assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id='101' ORDER BY dispatch_item_id"));
assertEquals(280,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
assertEquals(1L,query.countActiveDispatches(1L));var references=binding.references(List.of(1L));assertEquals(3,references.get(1L).size());assertTrue(references.get(1L).stream().allMatch(r->r.recordVersion()==0));
assertEquals("BOOKING:real-2",references.get(1L).stream().filter(r->r.customerId().equals("202")).findFirst().orElseThrow().sources().get(0).sourceKey());
}
@Test void untouchedSameCustomerAllocationParticipatesInCurrentBatchQuota() {
tx.executeWithoutResult(s->bind(21,22));var original=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY dispatch_item_id");
jdbc.update("UPDATE ll_inbound_fba_appointment_item SET package_count=150 WHERE customer_id='202'");
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(21)));
assertEquals(original,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY dispatch_item_id"));
}
@Test void forgedCustomerAndStaleOrMissingVersionCannotWritePhysicalBindings() {
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,List.of(new PhysicalItem(11L,"202",111L,"RECEIVED",80)),List.of());}));
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(1,List.of(item(11)),List.of(new Allocation(111L,batch,null,"101","ORDER:1","FBA:ONT8",80)));}));
jdbc.update("UPDATE ll_inbound_fba_appointment SET version=1 WHERE id=1");assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->bind(11)));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));
}
@Test void bindingCannotAcquireUnannouncedBatchAfterSourcePhase() {
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->binding.bindItems(1,List.of(item(11)),List.of(allocation(11)))));
assertThrows(ResponseStatusException.class,()->tx.executeWithoutResult(s->{binding.lockBatches(Map.of());binding.lockBatches(Map.of(batch,0L));}));
}
@Test void expiredDraftDoesNotBlockCandidateButPhysicalAliasAndFormalClaimDo() {
jdbc.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,111,NULL,'ACTIVE',NULL,DATEADD('SECOND',-30,CURRENT_TIMESTAMP))");
var result=candidates.read(batch);assertEquals(0,result.version());assertFalse(result.candidates().stream().filter(c->c.planPalletId()==111).findFirst().orElseThrow().blockReasons().contains("托盘已被其他排车或草稿占用"));
jdbc.update("UPDATE ll_dispatch_pallet_lock SET plan_pallet_id=NULL,warehouse_pallet_id=1011,expires_at=DATEADD('SECOND',30,CURRENT_TIMESTAMP) WHERE id=1");
assertTrue(mapper.candidates(1).stream().filter(c->c.planPalletId()==111).findFirst().orElseThrow().locked());
jdbc.update("UPDATE ll_dispatch_pallet_lock SET dispatch_order_id=1,expires_at=NULL WHERE id=1");assertTrue(mapper.candidates(1).stream().filter(c->c.planPalletId()==111).findFirst().orElseThrow().locked());
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class));
assertTrue(result.candidates().stream().flatMap(c->c.allocations().stream()).allMatch(a->a.recordVersion()==0));
}
@Test void laterFailureRollsBackEarlierCustomerAllocationAndGuardWithinSameTransaction() {
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(s->{bind(11);bind(21,22);throw new IllegalStateException("后续业务失败");}));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT version FROM change_guard WHERE id=1",Integer.class));
}
@Test void pageBatchReadUsesRealCustomerCanonicalSourceVersionsAndEffectiveOccupancy() {
jdbc.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,NULL,1021,'ACTIVE',NULL,DATEADD('SECOND',30,CURRENT_TIMESTAMP))");
var inputs=List.of(new CandidatePhysical("101",111L),new CandidatePhysical("202",121L),new CandidatePhysical("wrong",111L));
var result=binding.candidateBatches(inputs);var a=result.get(inputs.get(0));var b=result.get(inputs.get(1));
assertEquals(0L,a.appointmentBatches().get(0).recordVersion());assertFalse(a.occupied());assertTrue(b.occupied());
assertEquals("BOOKING:real-2",b.appointmentBatches().get(0).sources().get(0).sourceKey());assertEquals(100,b.appointmentBatches().get(0).sources().get(0).packageCount());
assertFalse(result.get(inputs.get(2)).blockReasons().isEmpty());verify(appointments,never()).batchContext(anyString());
jdbc.update("UPDATE ll_inbound_fba_appointment SET version=3 WHERE id=1");assertEquals(3L,binding.candidateBatches(inputs).get(inputs.get(0)).appointmentBatches().get(0).recordVersion());
}
@Test void twoDispatchesCannotConsumeTheSameBatchQuotaConcurrently() throws Exception {
jdbc.update("INSERT INTO ll_dispatch_order VALUES(2,'D-2',0,'ACTIVE')");jdbc.update("INSERT INTO ll_dispatch_item VALUES(12,2,'101',111,'RECEIVED',80)");
var start=new CyclicBarrier(2);var pool=Executors.newFixedThreadPool(2);
try {
var results=new ArrayList<Future<Boolean>>();
for(long dispatch:List.of(1L,2L)) results.add(pool.submit(()->{start.await(5,TimeUnit.SECONDS);try {tx.executeWithoutResult(s->{binding.lockBatches(Map.of(batch,0L));binding.bindItems(dispatch,List.of(new PhysicalItem(dispatch==1?11L:12L,"101",111L,"RECEIVED",80)),List.of(new Allocation(111L,batch,0L,"101","ORDER:1","FBA:ONT8",80)));});return true;}catch(ResponseStatusException conflict){return false;}}));
int applied=0;for(var result:results) if(result.get(15,TimeUnit.SECONDS)) applied++;assertEquals(1,applied);assertEquals(80,jdbc.queryForObject("SELECT SUM(package_count) FROM ll_dispatch_appointment_allocation",Integer.class));
} finally {pool.shutdownNow();}
}
}
@@ -1,6 +1,7 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.dispatch.port.DispatchAppointmentAmendmentPort;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentExecutionPort.*;
import org.junit.jupiter.api.Test;
@@ -13,50 +14,60 @@ 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 DispatchAppointmentAmendmentPort amendments=mock(DispatchAppointmentAmendmentPort.class);
final FleetDispatchGateway dispatches=mock(FleetDispatchGateway.class);
final FbaAppointmentExecutionAdapter adapter=new FbaAppointmentExecutionAdapter(mapper,trips,dispatches);
final FbaAppointmentExecutionAdapter adapter=new FbaAppointmentExecutionAdapter(mapper,amendments,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(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("A","DELIVERING","COMPLETED"),row("B","BEFORE_LOADING","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");
var locked=adapter.lockAndInspect(List.of(b),all.version(),"operator");assertTrue(locked.members().get(0).canRebook());
var order=inOrder(amendments,dispatches);order.verify(amendments).lockExistingAppointmentDispatches(any(),eq(List.of("D-B")));order.verify(dispatches).completion("D-B",true);order.verify(dispatches).lockForPlanning("D-B");
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")));
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(row("B","BEFORE_LOADING","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()));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(b),prior.version(),"operator"));
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);
clearInvocations(mapper,amendments,dispatches);assertTrue(adapter.inspect(List.of()).members().isEmpty());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(),"empty","operator"));
verifyNoInteractions(mapper,amendments,dispatches);
}
/** 同车总发运量不能替代本客户的真实签收数量。 */
@Test void onePhysicalDispatchUsesCustomerEvidenceInsteadOfTheWholeCarShipmentTotal() {
var rows=List.of(new AppointmentWorkDispatchMapper.TransportRow(1,"D-ONE","A","ACTIVE","DELIVERING",2L,200,200,LocalDateTime.of(2026,10,1,12,0),11,101,100,"RECEIVED",batch,"ORDER:1","FBA:ONT8",100,"T-ONE",2L,"IN_TRANSIT",201L,"OUTBOUNDED","COMPLETED"),
new AppointmentWorkDispatchMapper.TransportRow(1,"D-ONE","B","ACTIVE","DELIVERING",2L,200,200,LocalDateTime.of(2026,10,1,12,0),12,102,100,"RECEIVED",batch,"ORDER:2","FBA:ONT8",100,"T-ONE",2L,"IN_TRANSIT",202L,"OUTBOUNDED","COMPLETED"));
when(mapper.transportRows(List.of(batch),List.of())).thenReturn(rows);
when(dispatches.completion("D-ONE",false)).thenReturn(new FleetCompletionModels.DispatchCompletion("D-ONE","100",List.of(new FleetCompletionModels.Outcome("D-ONE","DL-A","A","ONT8","DELIVERED",100,at,100,0,0),new FleetCompletionModels.Outcome("D-ONE","DL-B","B","ONT8","DELIVERING",100,null,0,0,0)),null));
when(dispatches.terminalEvidence("D-ONE",false)).thenReturn(new FleetDispatchGateway.TerminalEvidence("D-ONE","100",200,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",100,at,"DELIVERED",1,List.of()),new FleetDispatchGateway.TerminalDelivery("B","DL-B","FBA:ONT8",100,null,"DELIVERING",1,List.of()))));
var scope=adapter.inspect(List.of(a,b));assertTrue(scope.members().get(0).completed());assertFalse(scope.members().get(1).completed());assertFalse(scope.members().get(1).canRebook());
when(dispatches.terminalEvidence("D-ONE",false)).thenReturn(new FleetDispatchGateway.TerminalEvidence("D-ONE","100",200,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",50,at,"DELIVERED",1,List.of()),new FleetDispatchGateway.TerminalDelivery("B","DL-B","FBA:ONT8",150,at,"DELIVERED",1,List.of()))));
assertFalse(adapter.inspect(List.of(a)).members().get(0).completed());
}
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");
long id="A".equals(customer)?1:2;boolean executed="DELIVERING".equals(status);
return new AppointmentWorkDispatchMapper.TransportRow(id,"D-"+customer,customer,"ACTIVE",status,0L,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)));
return new FleetDispatchGateway.TerminalEvidence("D-A","100",packages,List.of(new FleetDispatchGateway.TerminalDelivery("A","DL-A","FBA:ONT8",packages,terminal,returns.isEmpty()?"DELIVERED":"RETURNED",version,returns)));
}
}
@@ -1,7 +1,7 @@
package com.baiyue.lastleg.inboundplan.work;
import com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges;
import com.baiyue.lastleg.fleet.trip.*;
import com.baiyue.lastleg.dispatch.port.*;
import com.baiyue.lastleg.dispatch.port.DispatchContracts.*;
import com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentTransportPort;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
@@ -17,87 +17,80 @@ 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. */
/** 真实Mapper和事务;主流程窄端口使用同事务SQL替身,不代替主流程集成验收。 */
class FbaAppointmentTransportAdapterTest {
private JdbcTemplate jdbc;
private TransactionTemplate tx;
private FbaAppointmentTransportPort adapter;
private FleetTripService fleet;
private FleetDispatchGateway dispatch;
private DispatchAppointmentAmendmentPort amendments;
/** 一车280箱、三个真实客户、唯一PDA;主单与PDA均无customer_id。 */
@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_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(40),status VARCHAR(30),stage VARCHAR(30),record_version BIGINT,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.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT,status VARCHAR(30))");
jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(40),version INT)");
jdbc.execute("CREATE TABLE adjustment_history(parent_key VARCHAR(64),dispatch_no VARCHAR(40),item_id BIGINT)");
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();
jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'D-1','ACTIVE','BEFORE_LOADING',0,280,0,0,CURRENT_TIMESTAMP)");
jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(1,1)");jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(1,1,'PDA_PENDING')");
seed(11,"101",1,80);seed(21,"202",2,100);seed(22,"202",2,60);seed(31,"303",3,40);
for(int id=1;id<=3;id++) jdbc.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,0)",id,"AP-"+id);
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(AppointmentWorkDispatchMapper.class);
var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);
var session=new SqlSessionTemplate(factory.getObject());amendments=mock(DispatchAppointmentAmendmentPort.class);
doAnswer(c->{assertEquals("operator",c.getArgument(0,Actor.class).operatorId());for(var number:c.<List<String>>getArgument(1)) jdbc.queryForList("SELECT id FROM ll_dispatch_order WHERE dispatch_no=? FOR UPDATE",number);return null;}).when(amendments).lockExistingAppointmentDispatches(any(),anyList());
when(amendments.applyExistingAppointmentChange(any(),anyString(),any(),anyList(),anyList(),anyList(),eq(true))).thenAnswer(this::applyOriginal);
var proxy=new ProxyFactory(new FbaAppointmentTransportAdapter(session.getMapper(AppointmentWorkDispatchMapper.class),amendments));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));
/** 只替身主端口;复核真实item、原分配、父键和版本后在当前事务写入。 */
private CommandResult applyOriginal(org.mockito.invocation.InvocationOnMock call) {
var command=call.getArgument(2,DispatchCommands.ReasonCommand.class);assertEquals("parent-command",command.idempotencyKey());assertEquals(0L,command.recordVersion());
List<DispatchAppointmentAmendmentPort.PhysicalItem> touched=call.getArgument(3);List<BatchAllocation> expected=call.getArgument(4);List<BatchAllocation> next=call.getArgument(5);
assertTrue(next.isEmpty());assertEquals(touched.size(),expected.size());
for(var item:touched) {
assertEquals("202",item.customerId());assertTrue(expected.stream().anyMatch(a->a.planPalletId().equals(item.planPalletId()) && a.customerId().equals(item.customerId()) && a.packageCount()==item.packages() && a.recordVersion()==0));
jdbc.update("DELETE FROM ll_dispatch_appointment_allocation WHERE dispatch_id=1 AND dispatch_item_id=?",item.itemId());jdbc.update("DELETE FROM ll_dispatch_item WHERE id=? AND dispatch_order_id=1",item.itemId());
jdbc.update("INSERT INTO adjustment_history VALUES(?,?,?)",command.idempotencyKey(),"D-1",item.itemId());
}
jdbc.update("UPDATE ll_dispatch_order SET package_count=(SELECT SUM(package_count) FROM ll_dispatch_item WHERE dispatch_order_id=1),record_version=record_version+1 WHERE id=1");return null;
}
private void bump() { jdbc.update("UPDATE ll_fleet_trip SET record_version=record_version+1 WHERE id=1"); }
/** 插入逐客户真实实物;批次与整车归属独立。 */
private void seed(long item,String customer,long batch,int count) {
jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?, 'RECEIVED',?)",item,customer,item+100,count);
jdbc.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(1,?,?,?,?,?,?,?)",item,item+100,"FBA_APPOINTMENT:"+batch,customer,"ORDER:"+batch,"FBA:ONT8",count);
}
/** 沿原父预约命令执行一次完整实物调整。 */
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","客户明确调整");});
var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2"));return tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version(),"operator");return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","202","ORDER:2","FBA:ONT8",160,retained,"FBA_APPOINTMENT:4")),"operator","客户明确调整","parent-command");});
}
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 wholeCustomerExitLeavesOtherCustomers120OnTheSamePhysicalDispatch() {
var other=jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'202' ORDER BY dispatch_item_id");var result=move(0);
assertEquals(120,result.vehicles().stream().mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum());assertEquals(other,jdbc.queryForList("SELECT * FROM ll_dispatch_appointment_allocation WHERE customer_id<>'202' ORDER BY dispatch_item_id"));
assertEquals(120,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=1",Integer.class));assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch WHERE dispatch_id=1",Integer.class));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task WHERE dispatch_order_id=1",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE batch_key='FBA_APPOINTMENT:4'",Integer.class));assertEquals(1L,jdbc.queryForObject("SELECT record_version FROM ll_fleet_trip WHERE id=1",Long.class));
}
@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 wholePhysical60MovesOutLeavingOriginalVehicle220AndNoSyntheticLaterVehicle() {
var result=move(100);assertEquals(220,result.vehicles().stream().mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum());assertEquals(220,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=1",Integer.class));
assertEquals(List.of(11L,21L,31L),jdbc.queryForList("SELECT id FROM ll_dispatch_item ORDER BY id",Long.class));assertTrue(adapter.inspect(List.of("FBA_APPOINTMENT:4")).vehicles().isEmpty());
}
@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 sameCarSameBatchHasIndependentCustomerVehiclesAndWholeCarVersionInvalidatesScope() {
jdbc.update("UPDATE ll_dispatch_appointment_allocation SET batch_key='FBA_APPOINTMENT:1'");var scope=adapter.inspect(List.of("FBA_APPOINTMENT:1"));
assertEquals(Set.of("101","202","303"),scope.vehicles().stream().map(FbaAppointmentTransportPort.Vehicle::customerId).collect(java.util.stream.Collectors.toSet()));assertEquals(280,scope.vehicles().stream().mapToInt(FbaAppointmentTransportPort.Vehicle::packageCount).sum());
jdbc.update("UPDATE ll_dispatch_order SET record_version=record_version+1 WHERE id=1");assertThrows(org.springframework.web.server.ResponseStatusException.class,()->tx.execute(status->adapter.lockAndInspect(List.of("FBA_APPOINTMENT:1"),scope.version(),"operator")));
}
@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));
@Test void unmatchedPartialQuantityNeverMutatesOriginalPhysicalRows() {
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->move(110));assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));verify(amendments,never()).applyExistingAppointmentChange(any(),anyString(),any(),anyList(),anyList(),anyList(),anyBoolean());
}
@Test void downstreamFailureRollsBackCoreQuantityPhysicalAllocationAndHistoryTogether() {
when(amendments.applyExistingAppointmentChange(any(),anyString(),any(),anyList(),anyList(),anyList(),eq(true))).thenAnswer(c->{applyOriginal(c);throw new IllegalStateException("原主流程末尾失败");});var scope=adapter.inspect(List.of("FBA_APPOINTMENT:2"));
assertThrows(IllegalStateException.class,()->tx.execute(status->{var locked=adapter.lockAndInspect(List.of("FBA_APPOINTMENT:2"),scope.version(),"operator");jdbc.update("UPDATE ll_inbound_fba_appointment SET appointment_no='CHANGED' WHERE id=2");return adapter.apply(locked,List.of(new FbaAppointmentTransportPort.Move("FBA_APPOINTMENT:2","202","ORDER:2","FBA:ONT8",160,100,"FBA_APPOINTMENT:4")),"operator","客户明确调整","parent-command");}));
assertEquals(4,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class));assertEquals(280,jdbc.queryForObject("SELECT package_count FROM ll_dispatch_order WHERE id=1",Integer.class));assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM adjustment_history",Integer.class));assertEquals("AP-2",jdbc.queryForObject("SELECT appointment_no FROM ll_inbound_fba_appointment WHERE id=2",String.class));
}
}