feat(appointments): support scoped receiving and stable trip continuation
This commit is contained in:
+1
-1
@@ -28,7 +28,7 @@ class AppointmentWorkContractTest {
|
||||
var options=new org.yaml.snakeyaml.LoaderOptions();options.setAllowDuplicateKeys(false);
|
||||
Map<String,Object> root; try(var input=getClass().getResourceAsStream("/openapi/appointment-work-v1.yaml")) { assertNotNull(input); root=new Yaml(new org.yaml.snakeyaml.constructor.SafeConstructor(options)).load(input); }
|
||||
var paths=(Map<?,?>)root.get("paths"); var prefix="/api/platform/v1/last-leg/appointment-work";
|
||||
for(var path:List.of("/calendar","/summary","/items","/items/{workItemKey}","/items/{workItemKey}/commands")) assertTrue(paths.containsKey(prefix+path));
|
||||
for(var path:List.of("/calendar","/summary","/items","/items/resolve","/items/{workItemKey}","/items/{workItemKey}/commands")) assertTrue(paths.containsKey(prefix+path));
|
||||
var schemas=(Map<?,?>)((Map<?,?>)root.get("components")).get("schemas");
|
||||
var command=(Map<?,?>)schemas.get("CommandRequest"); assertEquals(6,((List<?>)command.get("oneOf")).size());
|
||||
var item=(Map<?,?>)((Map<?,?>)schemas.get("WorkItem")).get("properties"); assertTrue(item.containsKey("containers")); assertFalse(item.containsKey("containerBookingId"));
|
||||
|
||||
+1
-1
@@ -23,6 +23,6 @@ class AppointmentWorkDirectTransportProviderTest {
|
||||
assertFalse(provider.supports(Type.TRANSPORT,Flow.TRANSFER));
|
||||
}
|
||||
private Fact fact(String key,ExecutionStatus execution,Actual actual) {
|
||||
return new Fact(key,"v1","impact",false,PlanStatus.UNPLANNED,execution,null,null,actual,null,"CNT1",null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of("SOURCE_TIMEZONE"),List.of(),null);
|
||||
return new Fact(key,"v1","impact",false,PlanStatus.UNPLANNED,execution,null,null,actual,null,"CNT1",null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of("SOURCE_TIMEZONE"),List.of(),null,null);
|
||||
}
|
||||
}
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.baiyue.lastleg.appointmentwork;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFollowupInheritance.*;
|
||||
|
||||
class AppointmentWorkFollowupInheritanceTest {
|
||||
@Test void repartitionSupportsTwentyBatchesWithOneHundredCustomerSources() {
|
||||
var f=new WorkTestFixture();var service=new AppointmentWorkFollowupInheritance(f.store,mock(OwnerDirectory.class));
|
||||
var originals=new ArrayList<Member>();var successors=new ArrayList<Member>();
|
||||
for(int batch=1;batch<=20;batch++) for(int customer=1;customer<=100;customer++) {
|
||||
originals.add(new Member("FBA_APPOINTMENT:"+batch,"C"+customer));
|
||||
successors.add(new Member("FBA_APPOINTMENT:"+(batch+20),"C"+customer));
|
||||
}
|
||||
assertDoesNotThrow(()->service.inherit(originals,successors,"operator"));
|
||||
successors.add(new Member("FBA_APPOINTMENT:41","C1"));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.inherit(originals,successors,"operator"));
|
||||
}
|
||||
@Test void missingFollowupDateStaysMissingAndMergeCannotSilentlyDiscardAnUnassignedSource() {
|
||||
var f=new WorkTestFixture();var directory=mock(OwnerDirectory.class);when(directory.activePlatformOwner("17")).thenReturn(new Owner("17","负责人"));
|
||||
var service=new AppointmentWorkFollowupInheritance(f.store,directory);var source=new Member("FBA_APPOINTMENT:1","B");var later=new Member("FBA_APPOINTMENT:2","B");
|
||||
var followup=service.resolve("17",null);service.seed(source,later,followup,"operator");generate(f,later);
|
||||
var state=f.store.get(workKey(later));assertNull(state.getFollowupDate());assertEquals("17",state.getOwner().id());
|
||||
var item=project(state,WorkTestFixture.fact(state,null,null,ExecutionStatus.PENDING,null),WorkTestFixture.ACTOR,WorkTestFixture.CLOCK);
|
||||
assertTrue(item.missingFields().contains("下次跟进日期待安排"));
|
||||
var unassigned=new Member("FBA_APPOINTMENT:3","B");
|
||||
var empty=f.state("1",Type.DELIVERY,Flow.TRANSFER,"empty");empty.setWorkItemKey(workKey(unassigned));f.add(empty);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.inherit(List.of(later,unassigned),List.of(new Member("FBA_APPOINTMENT:4","B")),"operator"));
|
||||
}
|
||||
@Test void appliedFollowupSurvivesBeforeDiscoveryAndLaterRepartitionUsesLatestSavedWork() {
|
||||
var f=new WorkTestFixture();var directory=mock(OwnerDirectory.class);
|
||||
when(directory.activePlatformOwner("17")).thenReturn(new Owner("17","真实平台负责人"));
|
||||
var service=new AppointmentWorkFollowupInheritance(f.store,directory);
|
||||
var original=new Member("FBA_APPOINTMENT:1","B");var later=new Member("FBA_APPOINTMENT:2","B");
|
||||
var date=LocalDate.of(2026,10,22);var followup=service.resolve("17",date);
|
||||
service.seed(original,later,followup,"operator");
|
||||
assertTrue(f.store.all().isEmpty()); // An audit seed does not invent a sailed source or physical batch.
|
||||
var forty=new Member("FBA_APPOINTMENT:3","B");var twenty=new Member("FBA_APPOINTMENT:4","B");
|
||||
service.inherit(List.of(later),List.of(forty,twenty),"operator");
|
||||
assertEquals(followup,f.store.inheritedFollowup(workKey(forty)));assertEquals(followup,f.store.inheritedFollowup(workKey(twenty)));
|
||||
generate(f,forty);var state=f.store.get(workKey(forty));
|
||||
assertEquals(new Owner("17","真实平台负责人"),state.getOwner());assertEquals(date,state.getFollowupDate());
|
||||
state.setOwner(new Owner("18","新负责人"));state.setFollowupDate(date.plusDays(1));f.store.save(state);
|
||||
var next=new Member("FBA_APPOINTMENT:5","B");service.inherit(List.of(forty),List.of(next),"operator");generate(f,next);
|
||||
assertEquals(state.getOwner(),f.store.get(workKey(next)).getOwner());assertEquals(date.plusDays(1),f.store.get(workKey(next)).getFollowupDate());
|
||||
verify(directory).activePlatformOwner("17");
|
||||
}
|
||||
@Test void freeTextOwnerCannotBecomeAnAccountAndConflictingMergeDoesNotChooseFirst() {
|
||||
var f=new WorkTestFixture();var directory=mock(OwnerDirectory.class);var service=new AppointmentWorkFollowupInheritance(f.store,directory);
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.resolve("客服张三",LocalDate.now()));
|
||||
when(directory.activePlatformOwner("1")).thenReturn(new Owner("2","不匹配"));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.resolve("1",LocalDate.now()));
|
||||
var first=new Member("FBA_APPOINTMENT:2","B");var second=new Member("FBA_APPOINTMENT:3","B");var merged=new Member("FBA_APPOINTMENT:4","B");
|
||||
service.seed(new Member("FBA_APPOINTMENT:1","B"),first,new AppointmentWorkStore.Followup(new Owner("1","甲"),LocalDate.of(2026,10,20)),"operator");
|
||||
service.seed(new Member("FBA_APPOINTMENT:1","B"),second,new AppointmentWorkStore.Followup(new Owner("2","乙"),LocalDate.of(2026,10,21)),"operator");
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.inherit(List.of(first,second),List.of(merged),"operator"));
|
||||
assertNull(f.store.inheritedFollowup(workKey(merged)));
|
||||
}
|
||||
private static String workKey(Member m) {return key("BATCH:"+m.batchKey(),Type.DELIVERY,customerRequirement(m.batchKey(),m.customerId()));}
|
||||
private static void generate(WorkTestFixture f,Member member) {
|
||||
var requirement=new Requirement(customerRequirement(member.batchKey(),member.customerId()),member.batchKey(),"AP","FBA","仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference(member.customerId(),"ORDER:2","FBA:ONT8")));
|
||||
var source=new ContainerSource("1","柜",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement));
|
||||
f.sync.generate(source,Flow.TRANSFER,List.of(),WorkTestFixture.ACTOR);
|
||||
}
|
||||
}
|
||||
+109
@@ -11,11 +11,120 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.sql.Connection;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
|
||||
|
||||
class AppointmentWorkPersistenceTest {
|
||||
@Test void independentCustomerPlanChangesOnlyItsPersistedSemanticImpact() throws Exception {
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),database()));config.addMapper(AppointmentWorkMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
|
||||
var mapper=session.getMapper(AppointmentWorkMapper.class);var json=new ObjectMapper().findAndRegisterModules();
|
||||
var commitments=new AppointmentWorkCommitmentService(mapper,json);
|
||||
var receiving=mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class);
|
||||
var changes=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,mock(AppointmentWorkChanges.class),receiving);
|
||||
String key="FBA_APPOINTMENT:1",zone="America/Los_Angeles";
|
||||
var batch=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
|
||||
when(batch.batchKey()).thenReturn(key);when(batch.appointmentStatus()).thenReturn("SUCCESS");when(batch.plannedDate()).thenReturn(LocalDate.of(2026,10,19));
|
||||
var items=List.of("A","B","D").stream().map(customer->{
|
||||
var item=mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item.class);
|
||||
when(item.customerId()).thenReturn(customer);when(item.sourceKey()).thenReturn("ORDER:1");when(item.destinationKey()).thenReturn("FBA:ONT8");when(item.packageCount()).thenReturn(100);return item;
|
||||
}).toList();when(batch.items()).thenReturn(items);
|
||||
var agreement=new Agreement("RA-A",1,"ACTIVE","FBA:ONT8",new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(null,null,null,"CONF-A",LocalDateTime.of(2026,10,20,10,0),null,"SUCCESS",null,null,null),zone,List.of(new Member(key,"AP-1",1,"A",List.of(),null)),"正式确认");
|
||||
var plan=new java.util.concurrent.atomic.AtomicReference<>(new CustomerPlan(key,"B",customerPlanDetails(LocalDate.of(2026,10,21),"PENDING",null),zone));
|
||||
when(receiving.snapshots(List.of(key))).thenAnswer(call->List.of(new BatchReceiving(key,List.of(agreement),List.of(),List.of(plan.get()))));
|
||||
changes.changed(batch);
|
||||
var beforeA=mapper.commitment(key+"|CUSTOMER:A").getStateJson();var beforeD=mapper.commitment(key+"|CUSTOMER:D").getStateJson();
|
||||
plan.set(new CustomerPlan(key,"B",customerPlanDetails(LocalDate.of(2026,10,22),"FAILED","未约成"),zone));changes.changed(batch);
|
||||
assertEquals(key+"|CUSTOMER:B:1",commitments.impactToken(key+"|CUSTOMER:B"));
|
||||
changes.changed(batch);assertEquals(key+"|CUSTOMER:B:1",commitments.impactToken(key+"|CUSTOMER:B"));
|
||||
plan.set(new CustomerPlan(key,"B",customerPlanDetails(LocalDate.of(2026,10,22),"PENDING","继续联系"),zone));changes.changed(batch);
|
||||
assertEquals(key+"|CUSTOMER:B:1",commitments.impactToken(key+"|CUSTOMER:B")); // Status/notes do not invent a changed promised date.
|
||||
plan.set(new CustomerPlan(key,"B",customerPlanDetails(null,"PENDING",null),zone));changes.changed(batch);changes.changed(batch);
|
||||
assertEquals(key+"|CUSTOMER:B:2",commitments.impactToken(key+"|CUSTOMER:B"));
|
||||
assertNull(json.readValue(mapper.commitment(key+"|CUSTOMER:B").getStateJson(),AppointmentWorkCommitmentService.Commitment.class).target());
|
||||
assertEquals(key+"|CUSTOMER:A:0",commitments.impactToken(key+"|CUSTOMER:A"));assertEquals(beforeA,mapper.commitment(key+"|CUSTOMER:A").getStateJson());
|
||||
assertEquals(key+"|CUSTOMER:D:0",commitments.impactToken(key+"|CUSTOMER:D"));assertEquals(beforeD,mapper.commitment(key+"|CUSTOMER:D").getStateJson());
|
||||
}
|
||||
}
|
||||
private static com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails customerPlanDetails(LocalDate date,String status,String remark) {
|
||||
return new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(date,null,null,null,null,null,status,remark,null,null);
|
||||
}
|
||||
@Test void followupPortPersistsFutureSeedAndRollsBackWithOriginalTransaction() throws Exception {
|
||||
var ds=database();var jdbc=new org.springframework.jdbc.core.JdbcTemplate(ds);
|
||||
jdbc.execute("CREATE TABLE core_probe(id INT PRIMARY KEY,quantity INT)");jdbc.update("INSERT INTO core_probe VALUES(1,160)");
|
||||
var configuration=new MybatisConfiguration();configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(AppointmentWorkMapper.class);
|
||||
var factory=new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration);
|
||||
var session=new org.mybatis.spring.SqlSessionTemplate(factory.getObject());var json=new ObjectMapper().findAndRegisterModules();
|
||||
var store=new AppointmentWorkStore(session.getMapper(AppointmentWorkMapper.class),json);
|
||||
var directory=org.mockito.Mockito.mock(AppointmentWorkFactGateway.OwnerDirectory.class);
|
||||
org.mockito.Mockito.when(directory.activePlatformOwner("17")).thenReturn(new Owner("17","平台负责人"));
|
||||
var manager=new org.springframework.jdbc.datasource.DataSourceTransactionManager(ds);
|
||||
var proxy=new org.springframework.aop.framework.ProxyFactory(new AppointmentWorkFollowupInheritance(store,directory));proxy.setProxyTargetClass(true);
|
||||
proxy.addAdvice(new org.springframework.transaction.interceptor.TransactionInterceptor(manager,new org.springframework.transaction.annotation.AnnotationTransactionAttributeSource()));
|
||||
var port=new com.baiyue.lastleg.inboundplan.work.FbaAppointmentWorkAdapter((AppointmentWorkFollowupInheritance)proxy.getProxy());
|
||||
var original=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:1","B");
|
||||
var later=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:2","B");
|
||||
var followup=port.resolveFollowup("17",LocalDate.of(2026,10,22));
|
||||
var key=AppointmentWorkPolicy.key("BATCH:"+later.batchKey(),Type.DELIVERY,AppointmentWorkPolicy.customerRequirement(later.batchKey(),later.customerId()));
|
||||
var tx=new org.springframework.transaction.support.TransactionTemplate(manager);
|
||||
assertThrows(IllegalStateException.class,()->tx.executeWithoutResult(status->{jdbc.update("UPDATE core_probe SET quantity=100 WHERE id=1");port.seedFollowup(original,later,followup,"operator");throw new IllegalStateException("后续原域命令失败");}));
|
||||
assertNull(store.inheritedFollowup(key));assertEquals(160,jdbc.queryForObject("SELECT quantity FROM core_probe WHERE id=1",Integer.class));
|
||||
tx.executeWithoutResult(status->port.seedFollowup(original,later,followup,"operator"));
|
||||
var persisted=new AppointmentWorkStore(session.getMapper(AppointmentWorkMapper.class),json).inheritedFollowup(key);
|
||||
assertEquals(new Owner("17","平台负责人"),persisted.owner());assertEquals(followup.nextFollowupDate(),persisted.nextFollowupDate());
|
||||
var requirement=new AppointmentWorkFactGateway.Requirement(AppointmentWorkPolicy.customerRequirement(later.batchKey(),"B"),later.batchKey(),"AP-2","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8")));
|
||||
var source=new AppointmentWorkFactGateway.ContainerSource("1","CNT",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement));
|
||||
var facts=org.mockito.Mockito.mock(AppointmentWorkFactGateway.class);
|
||||
org.mockito.Mockito.when(facts.requirements(source,Flow.TRANSFER)).thenReturn(List.of(requirement));
|
||||
tx.executeWithoutResult(status->new AppointmentWorkSyncService(store,facts).generate(source,Flow.TRANSFER,List.of(),ACTOR));
|
||||
assertEquals(persisted.owner(),store.get(key).getOwner());assertEquals(persisted.nextFollowupDate(),store.get(key).getFollowupDate());
|
||||
var third=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:3","B");
|
||||
tx.executeWithoutResult(status->port.seedFollowup(original,third,new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Followup("17","平台负责人",null),"operator"));
|
||||
var thirdKey=AppointmentWorkPolicy.key("BATCH:"+third.batchKey(),Type.DELIVERY,AppointmentWorkPolicy.customerRequirement(third.batchKey(),"B"));
|
||||
assertNull(store.inheritedFollowup(thirdKey).nextFollowupDate());
|
||||
org.mockito.Mockito.verify(directory).activePlatformOwner("17"); // Seeding trusted core output never repeats IAM under cargo locks.
|
||||
}
|
||||
@Test void failedBookingPersistsTargetAndClearsConfirmationWhileCancellationClearsBoth() throws Exception {
|
||||
var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);
|
||||
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),database()));config.addMapper(AppointmentWorkMapper.class);
|
||||
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
|
||||
var mapper=session.getMapper(AppointmentWorkMapper.class);var json=new ObjectMapper().findAndRegisterModules();
|
||||
var commitments=new AppointmentWorkCommitmentService(mapper,json);
|
||||
var receiving=org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class);
|
||||
var changes=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,org.mockito.Mockito.mock(AppointmentWorkChanges.class),receiving);
|
||||
var appointment=org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.class);
|
||||
var groupRevision=new java.util.concurrent.atomic.AtomicInteger(1);
|
||||
var item=org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.dto.FbaAppointmentResponse.Item.class);
|
||||
org.mockito.Mockito.when(item.customerId()).thenReturn("C");org.mockito.Mockito.when(item.sourceKey()).thenReturn("ORDER:1");
|
||||
org.mockito.Mockito.when(appointment.items()).thenReturn(List.of(item));
|
||||
org.mockito.Mockito.when(receiving.snapshots(List.of("FBA_APPOINTMENT:1"))).thenAnswer(call->{
|
||||
var details=new com.baiyue.lastleg.inboundplan.dto.FbaAppointmentDetails(appointment.plannedDate(),null,null,"CONF",appointment.appointmentAt(),null,"SUCCESS",null,null,null);
|
||||
var member=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Member(appointment.batchKey(),"AP-1",0,"C",List.of(),null);
|
||||
var group=new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement("RA-1",groupRevision.get(),"ACTIVE","FBA:ONT8",details,appointment.timezone(),List.of(member),"confirmed");
|
||||
return List.of(new com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving(appointment.batchKey(),"SUCCESS".equals(appointment.appointmentStatus())?List.of(group):List.of(),List.of(),List.of()));
|
||||
});
|
||||
org.mockito.Mockito.when(appointment.batchKey()).thenReturn("FBA_APPOINTMENT:1");
|
||||
org.mockito.Mockito.when(appointment.plannedDate()).thenReturn(LocalDate.of(2026,10,18));
|
||||
org.mockito.Mockito.when(appointment.timezone()).thenReturn("America/Los_Angeles");
|
||||
org.mockito.Mockito.when(appointment.appointmentAt()).thenReturn(LocalDateTime.of(2026,10,18,10,0));
|
||||
org.mockito.Mockito.when(appointment.appointmentStatus()).thenReturn("SUCCESS");changes.changed(appointment);
|
||||
groupRevision.set(8);changes.changed(appointment);
|
||||
assertEquals("FBA_APPOINTMENT:1|CUSTOMER:C:0",commitments.impactToken("FBA_APPOINTMENT:1|CUSTOMER:C"));
|
||||
org.mockito.Mockito.when(appointment.appointmentStatus()).thenReturn("FAILED");changes.changed(appointment);
|
||||
var failed=json.readValue(mapper.commitment("FBA_APPOINTMENT:1|CUSTOMER:C").getStateJson(),AppointmentWorkCommitmentService.Commitment.class);
|
||||
assertEquals(LocalDate.of(2026,10,18),failed.target().date());assertNull(failed.confirmed());assertFalse(failed.cancelled());
|
||||
assertEquals("FBA_APPOINTMENT:1|CUSTOMER:C:1",commitments.impactToken("FBA_APPOINTMENT:1|CUSTOMER:C"));
|
||||
changes.changed(appointment);assertEquals("FBA_APPOINTMENT:1|CUSTOMER:C:1",commitments.impactToken("FBA_APPOINTMENT:1|CUSTOMER:C"));
|
||||
org.mockito.Mockito.when(appointment.appointmentStatus()).thenReturn("CANCELLED");changes.changed(appointment);
|
||||
var cancelled=json.readValue(mapper.commitment("FBA_APPOINTMENT:1|CUSTOMER:C").getStateJson(),AppointmentWorkCommitmentService.Commitment.class);
|
||||
assertNull(cancelled.target());assertNull(cancelled.confirmed());assertTrue(cancelled.cancelled());
|
||||
assertEquals("FBA_APPOINTMENT:1|CUSTOMER:C:2",commitments.impactToken("FBA_APPOINTMENT:1|CUSTOMER:C"));
|
||||
}
|
||||
}
|
||||
@Test void ddlAndMapperEnforceStableIdentityVersionAndGlobalCommandKey() throws Exception {
|
||||
var ds=database();
|
||||
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
|
||||
|
||||
+25
@@ -12,6 +12,31 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||
|
||||
class AppointmentWorkQueryServiceTest {
|
||||
@Test void sameBatchRequiresExplicitCustomerAndNeverChoosesTheFirstCandidate() {
|
||||
var f=new WorkTestFixture();var expected=new HashMap<String,String>();
|
||||
for(var customer:List.of("A","B")) {
|
||||
var state=f.state("1",Type.DELIVERY,Flow.TRANSFER,"batch-"+customer);state.setBatchKey("FBA_APPOINTMENT:1");
|
||||
state.setSources(List.of(new SourceReference(customer,"ORDER:"+customer,"FBA:ONT8")));f.add(state);expected.put(customer,state.getWorkItemKey());
|
||||
}
|
||||
var selection=f.queries.resolveBatch("FBA_APPOINTMENT:1",null,ACTOR);
|
||||
assertNull(selection.detail());assertNull(selection.customerId());assertEquals(List.of("A","B"),selection.candidates().stream().map(BatchWorkCandidate::customerId).toList());
|
||||
var chosen=f.queries.resolveBatch("FBA_APPOINTMENT:1","B",ACTOR);
|
||||
assertEquals("B",chosen.customerId());assertEquals(expected.get("B"),chosen.detail().item().workItemKey());
|
||||
assertEquals(404,assertThrows(ResponseStatusException.class,()->f.queries.resolveBatch("FBA_APPOINTMENT:1","C",ACTOR)).getStatusCode().value());
|
||||
}
|
||||
@Test void exactBatchResolutionSelectsCurrentFortyAndTwentyWithoutReturningOldSixty() {
|
||||
var f=new WorkTestFixture();
|
||||
var old=f.state("1",Type.DELIVERY,Flow.TRANSFER,"old-sixty");old.setBatchKey("FBA_APPOINTMENT:60");old.setRetired(true);f.add(old);
|
||||
var forty=f.state("1",Type.DELIVERY,Flow.TRANSFER,"new-forty");forty.setBatchKey("FBA_APPOINTMENT:40");forty.setSources(List.of(new SourceReference("C","ORDER:1","FBA:ONT8")));f.add(forty);
|
||||
var twenty=f.state("1",Type.DELIVERY,Flow.TRANSFER,"new-twenty");twenty.setBatchKey("FBA_APPOINTMENT:20");twenty.setSources(List.of(new SourceReference("C","ORDER:1","FBA:ONT8")));f.add(twenty);
|
||||
old.setReplacementKeys(List.of(forty.getWorkItemKey(),twenty.getWorkItemKey()));f.store.save(old);
|
||||
assertEquals(forty.getWorkItemKey(),f.queries.resolveBatch("FBA_APPOINTMENT:40",null,ACTOR).detail().item().workItemKey());
|
||||
assertEquals(twenty.getWorkItemKey(),f.queries.resolveBatch("FBA_APPOINTMENT:20",null,ACTOR).detail().item().workItemKey());
|
||||
assertEquals(404,assertThrows(ResponseStatusException.class,()->f.queries.resolveBatch("FBA_APPOINTMENT:60",null,ACTOR)).getStatusCode().value());
|
||||
assertEquals(403,assertThrows(ResponseStatusException.class,()->f.queries.resolveBatch("FBA_APPOINTMENT:40",null,new Actor("no-source","只读",Set.of("platform:appointment:list")))).getStatusCode().value());
|
||||
f.guard.setRequirementVersion(2);f.guard.setSynchronizedRequirementVersion(1);
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.queries.resolveBatch("FBA_APPOINTMENT:40",null,ACTOR)).getStatusCode().value());
|
||||
}
|
||||
@Test void followTodayPageOneSizeEightDoesNotReadUnrelatedRuntimeFactsOrPaginateBeforeCompletionChecks() {
|
||||
var f=new WorkTestFixture();
|
||||
for(int index=0;index<1000;index++) f.add(f.state("unrelated-"+index,Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null));
|
||||
|
||||
+19
-4
@@ -12,6 +12,21 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||
|
||||
class AppointmentWorkSyncServiceTest {
|
||||
@Test void onePhysicalBatchHasIndependentCustomerDeliveriesAndOneVehicleWithAllCustomerEdges() {
|
||||
var f=new WorkTestFixture();String batch="FBA_APPOINTMENT:1";
|
||||
var requirements=List.of("A","B").stream().map(customer->new Requirement(AppointmentWorkPolicy.customerRequirement(batch,customer),batch,"AP-1","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference(customer,"ORDER:"+customer,"FBA:ONT8")))).toList();
|
||||
var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,requirements);
|
||||
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
|
||||
f.sync.synchronize(new SyncRequest(null,10));
|
||||
var deliveries=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList();
|
||||
var vehicles=f.store.all().stream().filter(s->s.getType()==Type.TRANSPORT && !s.isRetired()).toList();
|
||||
assertEquals(2,deliveries.size());assertEquals(1,vehicles.size());
|
||||
var vehicle=vehicles.get(0);assertTrue(vehicle.getPrerequisites().containsAll(deliveries.stream().map(AppointmentWorkState::getWorkItemKey).toList()));
|
||||
assertEquals(2,vehicle.getSources().size());assertTrue(deliveries.stream().allMatch(s->batch.equals(s.getBatchKey()) && s.getSources().size()==1 && s.getPrerequisites().contains(vehicle.getWorkItemKey())));
|
||||
var keys=deliveries.stream().map(AppointmentWorkState::getWorkItemKey).sorted().toList();
|
||||
f.sync.synchronize(new SyncRequest(null,10));
|
||||
assertEquals(keys,f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).map(AppointmentWorkState::getWorkItemKey).sorted().toList());
|
||||
}
|
||||
@Test void removingPrivateCargoDetachesOldContainerAndRestoresDemandWithoutRetiringOtherCargo() {
|
||||
var f=new WorkTestFixture();
|
||||
var source1=new SourceReference("C1","ORDER:1","CUSTOMER:1");var source2=new SourceReference("C2","ORDER:2","CUSTOMER:2");
|
||||
@@ -48,7 +63,7 @@ class AppointmentWorkSyncServiceTest {
|
||||
@Test void cancellationReopensOriginalDemandAndRestoringTheSameAppointmentDoesNotDuplicateWork() {
|
||||
var f=new WorkTestFixture();
|
||||
var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of("FBA_APPOINTMENT:1"),List.of());
|
||||
var batch=new Requirement("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1","AP-1","FBA",null,"ONT8",null,List.of(),List.of("INBOUND:1"),List.of());
|
||||
var batch=new Requirement("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1","AP-1","FBA",null,"ONT8",null,List.of(),List.of("INBOUND:1"),List.of(new SourceReference("C","ORDER:1","FBA:ONT8")));
|
||||
for(var requirement:List.of(root,batch,root,batch)) {
|
||||
var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(requirement));
|
||||
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
|
||||
@@ -85,10 +100,10 @@ class AppointmentWorkSyncServiceTest {
|
||||
var f=new WorkTestFixture(); var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null); receipt.setTarget(slot("2026-10-15")); receipt.setObservedFingerprint("old"); receipt.setObservedImpactToken("same-impact"); receipt.setObservedArranged(true); f.add(receipt);
|
||||
var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); delivery.setTarget(slot("2026-10-16")); delivery.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(delivery);
|
||||
var unscheduled=f.state("1",Type.DELIVERY,Flow.TRANSFER,"r2"); unscheduled.setPrerequisites(List.of(receipt.getWorkItemKey())); f.add(unscheduled);
|
||||
var first=new Fact(receipt.getWorkItemKey(),"first-confirm","same-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-15"),slot("2026-10-15"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null);
|
||||
var first=new Fact(receipt.getWorkItemKey(),"first-confirm","same-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-15"),slot("2026-10-15"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null);
|
||||
var facts=new HashMap<String,Fact>(); facts.put(receipt.getWorkItemKey(),first); facts.put(delivery.getWorkItemKey(),fact(delivery,null,null,ExecutionStatus.PENDING,null)); facts.put(unscheduled.getWorkItemKey(),fact(unscheduled,null,null,ExecutionStatus.PENDING,null));
|
||||
f.sync.observe(f.store.all(),facts,ACTOR); assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
|
||||
facts.put(receipt.getWorkItemKey(),new Fact(receipt.getWorkItemKey(),"rescheduled","new-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-18"),slot("2026-10-18"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null));
|
||||
facts.put(receipt.getWorkItemKey(),new Fact(receipt.getWorkItemKey(),"rescheduled","new-impact",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,slot("2026-10-18"),slot("2026-10-18"),null,"America/Los_Angeles",null,null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null));
|
||||
f.sync.observe(f.store.all(),facts,ACTOR);
|
||||
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); assertFalse(f.store.get(unscheduled.getWorkItemKey()).isReviewRequired());
|
||||
int version=f.store.get(delivery.getWorkItemKey()).getReviewVersion(); f.sync.observe(f.store.all(),facts,ACTOR); assertEquals(version,f.store.get(delivery.getWorkItemKey()).getReviewVersion());
|
||||
@@ -108,7 +123,7 @@ class AppointmentWorkSyncServiceTest {
|
||||
var values=new HashMap<String,Fact>();
|
||||
for(var state:call.<List<AppointmentWorkState>>getArgument(0)) if(state.getType()!=Type.ROUTE) {
|
||||
if(state.getType()==Type.TRANSPORT) values.put(state.getWorkItemKey(),new Fact(state.getWorkItemKey(),"trip-v1","trip-impact-v1",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING,
|
||||
slot("2026-10-16"),slot("2026-10-16"),null,"America/Los_Angeles","TR-1","trip-1",List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null));
|
||||
slot("2026-10-16"),slot("2026-10-16"),null,"America/Los_Angeles","TR-1","trip-1",List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null));
|
||||
else values.put(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null));
|
||||
}
|
||||
return values;
|
||||
|
||||
+175
@@ -0,0 +1,175 @@
|
||||
package com.baiyue.lastleg.appointmentwork;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
|
||||
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
|
||||
|
||||
class AppointmentWorkV13WorkflowTest {
|
||||
private record Shared(WorkTestFixture fixture,AppointmentWorkState a,AppointmentWorkState b,
|
||||
AppointmentWorkState waiting,AppointmentWorkState trip) { }
|
||||
|
||||
private Fact planned(AppointmentWorkState state,String revision) {
|
||||
return new Fact(state.getWorkItemKey(),"fact-"+revision,"impact-"+revision,true,PlanStatus.CONFIRMED,
|
||||
ExecutionStatus.PENDING,slot("2026-10-17"),slot("2026-10-17"),null,"America/Los_Angeles",
|
||||
null,state.getTripKey(),List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null);
|
||||
}
|
||||
private Shared shared() {
|
||||
var f=new WorkTestFixture();
|
||||
var a=f.state("1",Type.DELIVERY,Flow.TRANSFER,"customer-a");
|
||||
var b=f.state("1",Type.DELIVERY,Flow.TRANSFER,"customer-b");
|
||||
var waiting=f.state("1",Type.DELIVERY,Flow.TRANSFER,"not-arranged");
|
||||
var trip=f.state("1",Type.TRANSPORT,Flow.TRANSFER,"shared-trip");trip.setTripKey("TRIP-1");
|
||||
a.setSources(List.of(new SourceReference("A","ORDER:1","CODE:ONT8")));
|
||||
b.setSources(List.of(new SourceReference("B","ORDER:2","CODE:ONT8")));
|
||||
// Real graph after canonical shared-vehicle reconciliation: appointments and vehicle
|
||||
// are mutually dependent, but sharing a vehicle does not change another appointment.
|
||||
for(var delivery:List.of(a,b,waiting)) delivery.setPrerequisites(List.of(trip.getWorkItemKey()));
|
||||
trip.setPrerequisites(List.of(a.getWorkItemKey(),b.getWorkItemKey(),waiting.getWorkItemKey()));
|
||||
for(var state:List.of(a,b,trip)) { f.add(state);f.facts.put(state.getWorkItemKey(),planned(state,"1")); }
|
||||
f.add(waiting);f.facts.put(waiting.getWorkItemKey(),fact(waiting,null,null,ExecutionStatus.PENDING,null));
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
return new Shared(f,a,b,waiting,trip);
|
||||
}
|
||||
|
||||
@Test void oneCustomerChangeReviewsItsVehicleWithoutReviewingOtherCustomersOrUnarrangedWork() {
|
||||
var s=shared();var f=s.fixture();
|
||||
f.facts.put(s.a().getWorkItemKey(),planned(s.a(),"2"));
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
assertTrue(f.store.get(s.a().getWorkItemKey()).isReviewRequired());
|
||||
assertTrue(f.store.get(s.trip().getWorkItemKey()).isReviewRequired());
|
||||
assertFalse(f.store.get(s.b().getWorkItemKey()).isReviewRequired());
|
||||
assertFalse(f.store.get(s.waiting().getWorkItemKey()).isReviewRequired());
|
||||
assertEquals(List.of(s.trip().getWorkItemKey()),AppointmentWorkSyncService.impactPrerequisites(s.a(),f.store.all())
|
||||
.stream().map(AppointmentWorkState::getWorkItemKey).toList());
|
||||
var version=f.store.get(s.trip().getWorkItemKey()).getReviewVersion();
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
assertEquals(version,f.store.get(s.trip().getWorkItemKey()).getReviewVersion());
|
||||
}
|
||||
|
||||
@Test void realVehicleChangeStillReviewsEveryCurrentlyArrangedMember() {
|
||||
var s=shared();var f=s.fixture();
|
||||
f.facts.put(s.trip().getWorkItemKey(),planned(s.trip(),"2"));
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
for(var state:List.of(s.a(),s.b(),s.trip())) assertTrue(f.store.get(state.getWorkItemKey()).isReviewRequired());
|
||||
assertFalse(f.store.get(s.waiting().getWorkItemKey()).isReviewRequired());
|
||||
}
|
||||
|
||||
private Fact vehicleImpact(AppointmentWorkState state,String own,String shared) {
|
||||
return new Fact(state.getWorkItemKey(),"fact-"+own,"impact-"+own,true,PlanStatus.CONFIRMED,
|
||||
ExecutionStatus.PENDING,slot("2026-10-17"),slot("2026-10-17"),null,"America/Los_Angeles",
|
||||
null,state.getTripKey(),List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,shared);
|
||||
}
|
||||
@Test void simultaneousCustomerAndVehicleMembershipChangeDoesNotReviewUnchangedCustomers() throws Exception {
|
||||
var s=shared();var f=s.fixture();
|
||||
f.facts.put(s.trip().getWorkItemKey(),vehicleImpact(s.trip(),"1","plan-1"));
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
f.facts.put(s.a().getWorkItemKey(),planned(s.a(),"2"));
|
||||
f.facts.put(s.trip().getWorkItemKey(),vehicleImpact(s.trip(),"2","plan-1"));
|
||||
var unchanged=f.queries.detail(s.b().getWorkItemKey(),ACTOR).item();
|
||||
assertFalse(unchanged.reviewRequired());assertTrue(unchanged.blockReasons().isEmpty());
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
assertTrue(f.store.get(s.a().getWorkItemKey()).isReviewRequired());
|
||||
assertTrue(f.store.get(s.trip().getWorkItemKey()).isReviewRequired());
|
||||
assertFalse(f.store.get(s.b().getWorkItemKey()).isReviewRequired());
|
||||
// A later cargo-only change must not invalidate B's review of a different impact.
|
||||
var b=f.store.get(s.b().getWorkItemKey());b.setReviewRequired(true);b.setReviewVersion(1);f.store.save(b);
|
||||
f.facts.put(s.trip().getWorkItemKey(),vehicleImpact(s.trip(),"3","plan-1"));
|
||||
assertTrue(f.queries.detail(b.getWorkItemKey(),ACTOR).item().allowedActions().stream().anyMatch(a->a.code()==ActionCode.RESOLVE_REVIEW));
|
||||
var review=f.command(Command.RESOLVE_REVIEW,b.getVersion(),"review-b","{\"reviewVersion\":1,\"note\":\"本客户时段不变\"}");
|
||||
assertFalse(f.commands.execute(b.getWorkItemKey(),review,ACTOR).item().reviewRequired());
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
assertFalse(f.store.get(b.getWorkItemKey()).isReviewRequired());
|
||||
f.facts.put(s.trip().getWorkItemKey(),vehicleImpact(s.trip(),"4","plan-2"));
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
assertTrue(f.store.get(b.getWorkItemKey()).isReviewRequired());
|
||||
}
|
||||
|
||||
@Test void retiredTransportAliasKeepsTheSameCustomerImpactBoundary() {
|
||||
var s=shared();var f=s.fixture();
|
||||
var alias=f.state("1",Type.TRANSPORT,Flow.TRANSFER,"old-a-transport");
|
||||
alias.setRetired(true);alias.setRetirementReason("TRIP_LINKED");alias.setReplacementKeys(List.of(s.trip().getWorkItemKey()));f.add(alias);
|
||||
var a=f.store.get(s.a().getWorkItemKey());a.setPrerequisites(List.of(alias.getWorkItemKey()));f.store.save(a);
|
||||
var affected=new LinkedHashSet<String>();AppointmentWorkSyncService.expandDependents(a.getWorkItemKey(),f.store.all(),affected);
|
||||
assertTrue(affected.contains(s.trip().getWorkItemKey()));assertFalse(affected.contains(s.b().getWorkItemKey()));
|
||||
assertEquals(List.of(s.trip().getWorkItemKey()),AppointmentWorkSyncService.impactPrerequisites(a,f.store.all())
|
||||
.stream().map(AppointmentWorkState::getWorkItemKey).toList());
|
||||
}
|
||||
|
||||
@Test void unsynchronizedNewUpstreamImpactCannotBeClearedByAnOldReviewAndReplayCannotClearLaterChanges() throws Exception {
|
||||
var f=new WorkTestFixture();
|
||||
var receipt=f.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null);f.add(receipt);
|
||||
var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"batch-a");delivery.setPrerequisites(List.of(receipt.getWorkItemKey()));f.add(delivery);
|
||||
f.facts.put(receipt.getWorkItemKey(),planned(receipt,"1"));f.facts.put(delivery.getWorkItemKey(),planned(delivery,"1"));
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
f.facts.put(receipt.getWorkItemKey(),planned(receipt,"2"));f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
var reviewing=f.store.get(delivery.getWorkItemKey());
|
||||
var old=f.command(Command.RESOLVE_REVIEW,reviewing.getVersion(),"old-review","{\"reviewVersion\":1,\"note\":\"核对旧影响\"}");
|
||||
f.facts.put(receipt.getWorkItemKey(),planned(receipt,"3"));
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(delivery.getWorkItemKey(),old,ACTOR)).getStatusCode().value());
|
||||
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
var current=f.store.get(delivery.getWorkItemKey());assertEquals(2,current.getReviewVersion());
|
||||
var staleImpact=f.command(Command.RESOLVE_REVIEW,current.getVersion(),"old-impact","{\"reviewVersion\":1,\"note\":\"旧版本\"}");
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(delivery.getWorkItemKey(),staleImpact,ACTOR)).getStatusCode().value());
|
||||
var accepted=f.command(Command.RESOLVE_REVIEW,current.getVersion(),"current-review","{\"reviewVersion\":2,\"note\":\"核对最新安排\"}");
|
||||
var response=f.commands.execute(delivery.getWorkItemKey(),accepted,ACTOR);assertFalse(response.item().reviewRequired());
|
||||
f.sync.observe(f.store.all(),f.facts,ACTOR);assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
|
||||
f.facts.put(receipt.getWorkItemKey(),planned(receipt,"4"));f.sync.observe(f.store.all(),f.facts,ACTOR);
|
||||
assertEquals(response,f.commands.execute(delivery.getWorkItemKey(),accepted,ACTOR));
|
||||
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
|
||||
assertEquals(3,f.store.get(delivery.getWorkItemKey()).getReviewVersion());
|
||||
}
|
||||
|
||||
@Test void outstandingRequirementChangesPreventReviewUntilRelationshipsAreSynchronized() throws Exception {
|
||||
var s=shared();var f=s.fixture();
|
||||
var state=f.store.get(s.a().getWorkItemKey());state.setReviewRequired(true);state.setReviewVersion(1);f.store.save(state);
|
||||
f.guard.setRequirementVersion(2);f.guard.setSynchronizedRequirementVersion(1);
|
||||
var command=f.command(Command.RESOLVE_REVIEW,state.getVersion(),"dirty-scope","{\"reviewVersion\":1,\"note\":\"不能略过关系变化\"}");
|
||||
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),command,ACTOR)).getStatusCode().value());
|
||||
assertTrue(f.store.get(state.getWorkItemKey()).isReviewRequired());
|
||||
}
|
||||
|
||||
@Test void reminderDateIsNotWorkDateAndAnUnsetReminderRemainsAnExplicitMissingField() {
|
||||
var f=new WorkTestFixture();var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"later-batch");
|
||||
delivery.setFollowupDate(LocalDate.of(2026,10,16));f.add(delivery);
|
||||
var item=f.queries.items(View.UNDATED,1,8,null,null,null,null,null,null,ACTOR).records().get(0);
|
||||
assertNull(item.calendarDate());assertEquals(LocalDate.of(2026,10,16),item.followupDate());
|
||||
assertEquals(1,f.queries.summary(ACTOR).all());assertEquals(1,f.queries.summary(ACTOR).undated());
|
||||
delivery=f.store.get(delivery.getWorkItemKey());delivery.setFollowupDate(null);f.store.save(delivery);
|
||||
assertTrue(f.queries.detail(delivery.getWorkItemKey(),ACTOR).item().missingFields().contains("下次跟进日期待安排"));
|
||||
assertEquals(1,f.queries.summary(ACTOR).all());
|
||||
}
|
||||
|
||||
private Fact actionable(AppointmentWorkState state,boolean confirmed,Action action,List<String> permissions) {
|
||||
return new Fact(state.getWorkItemKey(),"fact-1","impact-1",true,confirmed?PlanStatus.CONFIRMED:PlanStatus.UNPLANNED,
|
||||
ExecutionStatus.PENDING,null,confirmed?slot("2026-10-17"):null,null,"America/Los_Angeles",null,state.getTripKey(),
|
||||
List.of(ORDER_READ),permissions,action,List.of(),List.of(),List.of(),null,null);
|
||||
}
|
||||
@Test void confirmedAppointmentGuidesExistingVehicleWhileUnconfirmedAppointmentKeepsBookingStep() {
|
||||
var f=new WorkTestFixture();var delivery=f.state("1",Type.DELIVERY,Flow.TRANSFER,"batch-a");
|
||||
var transport=f.state("1",Type.TRANSPORT,Flow.TRANSFER,"batch-a");
|
||||
delivery.setPrerequisites(List.of(transport.getWorkItemKey()));f.add(delivery);f.add(transport);
|
||||
var booking=new Action(ActionCode.CHANGE_BOOKING,"维护预约",delivery.getWorkItemKey(),new AppointmentTarget("1","batch-a",List.of()));
|
||||
var vehicle=new Action(ActionCode.ARRANGE_TRANSPORT,"维护车辆",transport.getWorkItemKey(),new TripTarget(null,List.of("batch-a")));
|
||||
f.facts.put(delivery.getWorkItemKey(),actionable(delivery,true,booking,List.of(MANAGE)));
|
||||
f.facts.put(transport.getWorkItemKey(),actionable(transport,false,vehicle,List.of(MANAGE)));
|
||||
var next=f.queries.detail(delivery.getWorkItemKey(),ACTOR).item().nextAction();
|
||||
assertEquals(ActionCode.ARRANGE_TRANSPORT,next.code());assertEquals("继续安排本批车辆",next.label());
|
||||
assertEquals(transport.getWorkItemKey(),next.targetWorkItemKey());assertEquals(vehicle.target(),next.target());
|
||||
var existing=new Action(ActionCode.ARRANGE_TRANSPORT,"维护车辆",transport.getWorkItemKey(),new TripTarget("TRIP-1",List.of("batch-a")));
|
||||
f.facts.put(transport.getWorkItemKey(),actionable(transport,true,existing,List.of(MANAGE)));
|
||||
assertEquals("核对本车发车安排",f.queries.detail(delivery.getWorkItemKey(),ACTOR).item().nextAction().label());
|
||||
var rebook=new Action(ActionCode.RECORD_CONFIRMATION,"重新约仓",delivery.getWorkItemKey(),booking.target());
|
||||
f.facts.put(delivery.getWorkItemKey(),actionable(delivery,false,rebook,List.of(MANAGE)));
|
||||
assertEquals(rebook,f.queries.detail(delivery.getWorkItemKey(),ACTOR).item().nextAction());
|
||||
f.facts.put(delivery.getWorkItemKey(),actionable(delivery,true,booking,List.of(MANAGE)));
|
||||
var reader=new Actor("read","只读",Set.of(LIST,ORDER_READ));
|
||||
assertEquals(ActionCode.VIEW_DETAIL,f.queries.detail(delivery.getWorkItemKey(),reader).item().nextAction().code());
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -33,6 +33,7 @@ final class WorkTestFixture {
|
||||
when(mapper.update(any())).thenAnswer(c->{ var row=c.getArgument(0,AppointmentWorkMapper.Row.class); var old=rows.get(row.getWorkItemKey()); if(old==null || old.getVersion()!=row.getVersion()-1) return 0; rows.put(row.getWorkItemKey(),row); return 1; });
|
||||
when(mapper.insertHistory(any(AppointmentWorkMapper.History.class))).thenAnswer(c->{history.add(c.getArgument(0)); return 1;});
|
||||
when(mapper.selectHistory(anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0))).toList());
|
||||
when(mapper.inheritedFollowup(anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0)) && "FOLLOWUP_INHERITED".equals(h.getAction())).reduce((a,b)->b).orElse(null));
|
||||
when(mapper.command(anyString(),anyString())).thenAnswer(c->history.stream().filter(h->h.getWorkItemKey().equals(c.getArgument(0)) && Objects.equals(h.getCommandKey(),c.getArgument(1))).findFirst().orElse(null));
|
||||
when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap<String,Fact>(); for(var state:c.<List<AppointmentWorkState>>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; });
|
||||
when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope"));
|
||||
@@ -49,6 +50,6 @@ final class WorkTestFixture {
|
||||
static Slot slot(String date) { return new Slot(LocalDate.parse(date),null,null,null,"America/Los_Angeles"); }
|
||||
static Fact fact(AppointmentWorkState state,Slot target,Slot confirmed,ExecutionStatus execution,Actual actual) {
|
||||
return new Fact(state.getWorkItemKey(),"fact-v1","impact-v1",target!=null || confirmed!=null,confirmed!=null?PlanStatus.CONFIRMED:target!=null?PlanStatus.TARGET_SET:PlanStatus.UNPLANNED,
|
||||
execution,target,confirmed,actual,"America/Los_Angeles",null,null,List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null);
|
||||
execution,target,confirmed,actual,"America/Los_Angeles",null,null,List.of(ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null);
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -7,6 +7,19 @@ import java.util.regex.Pattern;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class AppointmentWorkSchemaContractTest {
|
||||
@Test void receivingCoverageSchemaMatchesBothInitializationEntriesWithoutLegacyHeaderUniqueness() throws Exception {
|
||||
String source=Files.readString(Path.of("src/main/resources/db/appointment-receiving.sql"));
|
||||
for(String path:List.of("src/main/resources/db/lastleg.sql","../deploy/sql/海外区域统一增量-MySQL8.sql")) {
|
||||
String target=Files.readString(Path.of(path));
|
||||
for(String table:List.of("ll_fba_receiving_agreement","ll_fba_receiving_member","ll_fba_receiving_change","ll_fba_receiving_revision","ll_fba_receiving_plan"))
|
||||
assertEquals(definition(source,table),definition(target,table),path+" differs for "+table);
|
||||
assertFalse(target.contains("uk_ll_fba_external_confirmation"),"An aggregate header cannot own independently confirmed customer appointments");
|
||||
}
|
||||
String reset=Files.readString(Path.of("../deploy/sql/reset-test-fba-appointments.sql"));
|
||||
int guardEnd=reset.indexOf("CALL assert_test_appointment_reset_preconditions()");
|
||||
for(String table:List.of("ll_fba_receiving_agreement","ll_fba_receiving_member","ll_fba_receiving_change","ll_fba_receiving_revision","ll_fba_receiving_plan"))
|
||||
assertTrue(reset.substring(0,guardEnd).contains("'"+table+"'"),"Old reset must refuse receiving history: "+table);
|
||||
}
|
||||
@Test void completeWorkAndPhysicalAllocationSchemaMatchesBothInitializationEntries() throws Exception {
|
||||
String source=Files.readString(Path.of("src/main/resources/db/appointment-work.sql"));
|
||||
for(String path:List.of("src/main/resources/db/lastleg.sql","../deploy/sql/海外区域统一增量-MySQL8.sql")) {
|
||||
|
||||
+2
-2
@@ -55,7 +55,7 @@ class DispatchBolPrintServiceTest {
|
||||
null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE",
|
||||
1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(),
|
||||
List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2",
|
||||
"NYEXX2609110214", null, null, 1)), null);
|
||||
"NYEXX2609110214", null, null, 1)), null, List.of());
|
||||
var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga",
|
||||
null, "91730", "10767 7th Street", null);
|
||||
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
|
||||
@@ -87,7 +87,7 @@ class DispatchBolPrintServiceTest {
|
||||
new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse",
|
||||
"NYEXX2609110214", null, null, 1),
|
||||
new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse",
|
||||
"NYEXX2609110215", null, null, 1)), null);
|
||||
"NYEXX2609110215", null, null, 1)), null, List.of());
|
||||
var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L,
|
||||
"US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null);
|
||||
when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail);
|
||||
|
||||
+12
@@ -51,6 +51,18 @@ import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class DispatchServiceTest {
|
||||
@Test void detailReturnsEveryOriginalBatchSourceWithoutLegacyAppointmentNumber() {
|
||||
var dispatch=new DispatchOrder();dispatch.setId(1L);dispatch.setCustomerId("C");dispatch.setDispatchNo("DP-1");
|
||||
when(orders.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch);
|
||||
var allocations=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);ReflectionTestUtils.setField(service,"appointmentAllocations",allocations);
|
||||
var references=List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:1","AP-1","C",
|
||||
List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:10","FBA:ONT8",40))),
|
||||
new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:2","AP-2","C",
|
||||
List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:11","FBA:ONT8",20))));
|
||||
when(allocations.references(List.of(1L))).thenReturn(java.util.Map.of(1L,references));
|
||||
var result=service.platformDetail("DP-1");assertNull(result.fbaAppointmentNo());assertEquals(references,result.appointmentBatches());
|
||||
verify(allocations).references(List.of(1L));
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.NullAndEmptySource
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings = {"PICKUP", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "UNKNOWN"})
|
||||
|
||||
+15
@@ -17,6 +17,12 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetTransitEvidenceTest {
|
||||
@Test void removingOriginalPhysicalItemDelegatesToDispatchAuthority() {
|
||||
var service=mock(com.baiyue.lastleg.dispatch.service.DispatchService.class);
|
||||
org.springframework.test.util.ReflectionTestUtils.setField(adapter,"dispatchService",service);
|
||||
adapter.removeItem("PC-1",41L);
|
||||
verify(service).removeItem("PC-1",41L);verifyNoMoreInteractions(service);
|
||||
}
|
||||
@BeforeAll static void initializeDeliveryMetadata() {
|
||||
com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo(
|
||||
new org.apache.ibatis.builder.MapperBuilderAssistant(new com.baomidou.mybatisplus.core.MybatisConfiguration(), "fleet-transit-evidence"),
|
||||
@@ -72,11 +78,20 @@ class FleetTransitEvidenceTest {
|
||||
@Test void unsignedReturnRequiresImmutableReturnOperation() {
|
||||
delivery.setStatus("RETURNED"); delivery.setUpdatedAt(LocalDateTime.now());
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.transitEvidence("PC-1", false));
|
||||
assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> adapter.terminalEvidence("PC-1", false));
|
||||
var returned = LocalDateTime.of(2026, 10, 3, 10, 0);
|
||||
when(operations.returnedAt("CUSTOMER-A", 41L)).thenReturn(returned);
|
||||
when(operations.returnReceipts("CUSTOMER-A", 41L)).thenReturn(List.of(new com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact(1L, 51L, 61L, 10, returned)));
|
||||
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),
|
||||
adapter.transitEvidence("PC-1", false).deliveries().get(0).cargo().terminalAt());
|
||||
var terminal=adapter.terminalEvidence("PC-1",false);
|
||||
assertEquals("CUSTOMER-A",terminal.customerId());assertEquals(10,terminal.shippedPackages());
|
||||
var returnedDelivery=terminal.deliveries().get(0);
|
||||
assertEquals("FBA:ONT8",returnedDelivery.destinationKey());assertEquals(10,returnedDelivery.packages());
|
||||
assertEquals(returned.atZone(ZoneId.systemDefault()).toInstant(),returnedDelivery.terminalAt());
|
||||
assertEquals("RETURNED",returnedDelivery.status());assertEquals(0L,returnedDelivery.recordVersion());
|
||||
assertEquals(List.of(new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.ReturnedReceipt("51","61",10,
|
||||
returned.atZone(ZoneId.systemDefault()).toInstant())),returnedDelivery.receipts());
|
||||
}
|
||||
|
||||
@Test void crossCustomerAndUnbalancedFactsFailClosed() {
|
||||
|
||||
+34
@@ -54,6 +54,40 @@ import static org.mockito.ArgumentMatchers.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class FleetTripPersistenceTest {
|
||||
@Test void originalBatchReferencesSurviveRemovalReentryAndLaterDispatchReassignment() throws Exception {
|
||||
String number=assignedTrip();dispatch(11,"CLIENT-A","92");dispatch(12,"CLIENT-B","92");
|
||||
var allocations=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);
|
||||
ReflectionTestUtils.setField(service,"appointmentAllocations",allocations);
|
||||
var a=List.of(batchReference("1","CLIENT-A",6),batchReference("2","CLIENT-A",4));
|
||||
var originalB=List.of(batchReference("3","CLIENT-B",10));
|
||||
var current=new java.util.HashMap<Long,List<com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference>>();current.put(11L,a);current.put(12L,originalB);
|
||||
when(allocations.references(anyList())).thenAnswer(call->{List<Long> ids=call.getArgument(0);return ids.stream().collect(java.util.stream.Collectors.toMap(id->id,id->current.getOrDefault(id,List.of())));});
|
||||
service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(1L,"DP-11","加入甲"));
|
||||
var both=service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(2L,"DP-12","加入乙"));
|
||||
assertThat(both.dispatches().get(0).appointmentBatches()).containsExactlyElementsOf(a);assertThat(both.previousDispatches()).isEmpty();
|
||||
service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(3L,"DP-12","乙后送"));
|
||||
current.put(12L,List.of(batchReference("4","CLIENT-B",4),batchReference("5","CLIENT-B",6)));
|
||||
var reopened=service.detail(number);
|
||||
assertThat(reopened.dispatches()).extracting(FleetTripModels.DispatchBinding::customerId).containsExactly("CLIENT-A");
|
||||
assertThat(reopened.previousDispatches()).hasSize(1);
|
||||
var previous=reopened.previousDispatches().get(0);assertThat(previous.dispatchNo()).isEqualTo("DP-12");assertThat(previous.customerId()).isEqualTo("CLIENT-B");
|
||||
assertThat(previous.recordVersion()).isEqualTo(3);assertThat(previous.recordedAt()).isNotNull();assertThat(previous.appointmentBatches()).isEqualTo(originalB);
|
||||
var rebound=service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(4L,"DP-12","按当前后送批重入"));
|
||||
assertThat(rebound.dispatches().get(1).appointmentBatches()).isEqualTo(current.get(12L));
|
||||
assertThat(rebound.previousDispatches()).hasSize(1);assertThat(rebound.previousDispatches().get(0).appointmentBatches()).isEqualTo(originalB);
|
||||
var audit=service.history(number,1,20).records().stream().filter(h->h.recordVersion()==3).findFirst().orElseThrow();
|
||||
assertThat(audit.snapshot().dispatches().get(1).appointmentBatches()).isEqualTo(originalB);
|
||||
assertThat(rebound.appointmentHistoryUnavailableReason()).isNull();
|
||||
var json=new ObjectMapper().findAndRegisterModules();
|
||||
var incomplete=json.valueToTree(audit.snapshot());((com.fasterxml.jackson.databind.node.ObjectNode)incomplete.path("dispatches").get(1)).remove("appointmentBatches");
|
||||
jdbc.update("UPDATE ll_fleet_trip_history SET snapshot_json=? WHERE trip_id=(SELECT id FROM ll_fleet_trip WHERE trip_no=?) AND record_version=3",json.writeValueAsString(incomplete),number);
|
||||
var readable=service.detail(number);assertThat(readable.dispatches()).hasSize(2);assertThat(readable.dispatches().get(1).appointmentBatches()).isEqualTo(current.get(12L));
|
||||
assertThat(readable.previousDispatches()).isEmpty();assertThat(readable.appointmentHistoryUnavailableReason()).contains("追踪不完整");
|
||||
}
|
||||
private static com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference batchReference(String id,String customer,int packages) {
|
||||
return new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:"+id,"AP-"+id,customer,
|
||||
List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:"+customer,"FBA:ONT8",packages)));
|
||||
}
|
||||
private JdbcTemplate jdbc;
|
||||
private FleetTripService service;
|
||||
private FleetTripPreparationService preparation;
|
||||
|
||||
+9
@@ -11,6 +11,15 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentWorkTransportProviderTest {
|
||||
@Test void linkedVehicleCarriesIndependentCargoAndCommonPlanVersions() {
|
||||
var mapper=mock(AppointmentWorkTripMapper.class);var commitments=mock(AppointmentWorkCommitmentService.class);
|
||||
var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setId(10L);trip.setTripNo("TR-10");
|
||||
when(mapper.trip(10)).thenReturn(trip);when(commitments.impactToken("TRIP:10")).thenReturn("TRIP:10:2");
|
||||
when(commitments.impactToken("TRIP_PLAN:10")).thenReturn("TRIP_PLAN:10:0");
|
||||
var request=new FactRequest("W",Type.TRANSPORT,Flow.TRANSFER,"C",null,null,"10",List.of(),List.of(),List.of());
|
||||
var fact=new AppointmentWorkTransportProvider(mapper,mock(FleetWarehouseClient.class),commitments,"token").read(List.of(request)).get(0);
|
||||
assertEquals("TRIP:10:2",fact.impactToken());assertEquals("TRIP_PLAN:10:0",fact.dependentImpactToken());
|
||||
}
|
||||
@Test void unassignedTransportWorkDoesNotQueryTripsOrWarehousesPerSource() {
|
||||
var mapper=mock(AppointmentWorkTripMapper.class);var warehouses=mock(FleetWarehouseClient.class);var commitments=mock(AppointmentWorkCommitmentService.class);
|
||||
var requests=IntStream.range(0,200).mapToObj(i->new FactRequest("W"+i,Type.TRANSPORT,Flow.TRANSFER,"C"+i,"INBOUND:"+i,null,null,List.of(),List.of(),List.of())).toList();
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.baiyue.lastleg.fleet.trip.work;
|
||||
|
||||
import com.baiyue.lastleg.appointmentwork.*;
|
||||
import com.baiyue.lastleg.fleet.trip.FleetTripEntity;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class AppointmentWorkTripChangesTest {
|
||||
@Test void membershipAndRouteChangesReviewVehicleWhileOnlyCommonPlanChangesAffectOtherAppointments() {
|
||||
var rows=new HashMap<String,AppointmentWorkMapper.CommitmentRow>();
|
||||
var persistence=mock(AppointmentWorkMapper.class);
|
||||
when(persistence.lockCommitment(anyString())).thenAnswer(c->rows.get(c.getArgument(0)));
|
||||
when(persistence.commitment(anyString())).thenAnswer(c->rows.get(c.getArgument(0)));
|
||||
when(persistence.insertCommitment(anyString(),anyString())).thenAnswer(c->{
|
||||
var row=new AppointmentWorkMapper.CommitmentRow();row.setEntityKey(c.getArgument(0));row.setStateJson(c.getArgument(1));
|
||||
rows.put(row.getEntityKey(),row);return 1;
|
||||
});
|
||||
when(persistence.updateCommitment(anyString(),anyString(),anyLong())).thenAnswer(c->{
|
||||
var row=rows.get(c.getArgument(0));row.setStateJson(c.getArgument(1));row.setImpactVersion(c.getArgument(2));return 1;
|
||||
});
|
||||
var commitments=new AppointmentWorkCommitmentService(persistence,new ObjectMapper().findAndRegisterModules());
|
||||
var mapper=mock(AppointmentWorkTripMapper.class);
|
||||
var changes=new AppointmentWorkTripChanges(mock(AppointmentWorkChanges.class),commitments,mapper);
|
||||
var trip=new FleetTripEntity();trip.setId(10L);trip.setVehicleId(20L);trip.setDriverId(30L);trip.setStatus("PLANNED");
|
||||
trip.setStopsJson("[A,B,C]");trip.setPlannedDepartureAt(LocalDateTime.parse("2026-10-17T10:00:00"));
|
||||
trip.setExpectedDriverEndAt(LocalDateTime.parse("2026-10-17T20:00:00"));
|
||||
when(mapper.batches(10)).thenReturn(List.of("A","B","C"));changes.changed(trip);
|
||||
assertEquals("TRIP:10:0",commitments.impactToken("TRIP:10"));assertEquals("TRIP_PLAN:10:0",commitments.impactToken("TRIP_PLAN:10"));
|
||||
when(mapper.batches(10)).thenReturn(List.of("C","B","A"));changes.changed(trip);
|
||||
assertEquals("TRIP:10:0",commitments.impactToken("TRIP:10"));
|
||||
when(mapper.batches(10)).thenReturn(List.of("A","C"));trip.setStopsJson("[A,C]");changes.changed(trip);
|
||||
assertEquals("TRIP:10:1",commitments.impactToken("TRIP:10"));assertEquals("TRIP_PLAN:10:0",commitments.impactToken("TRIP_PLAN:10"));
|
||||
changes.changed(trip);assertEquals("TRIP:10:1",commitments.impactToken("TRIP:10"));
|
||||
trip.setVehicleId(21L);changes.changed(trip);assertEquals("TRIP_PLAN:10:1",commitments.impactToken("TRIP_PLAN:10"));
|
||||
trip.setDriverId(31L);changes.changed(trip);assertEquals("TRIP_PLAN:10:2",commitments.impactToken("TRIP_PLAN:10"));
|
||||
trip.setPlannedDepartureAt(trip.getPlannedDepartureAt().plusHours(1));changes.changed(trip);
|
||||
assertEquals("TRIP_PLAN:10:3",commitments.impactToken("TRIP_PLAN:10"));
|
||||
trip.setStatus("CANCELLED");changes.changed(trip);assertEquals("TRIP_PLAN:10:4",commitments.impactToken("TRIP_PLAN:10"));
|
||||
}
|
||||
}
|
||||
+69
@@ -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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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() {
|
||||
|
||||
+78
@@ -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());
|
||||
}
|
||||
}
|
||||
+42
-7
@@ -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);}
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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);
|
||||
|
||||
+818
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
-2
@@ -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")));
|
||||
|
||||
+155
-9
@@ -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));
|
||||
|
||||
+79
-5
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -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());
|
||||
|
||||
+29
@@ -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);
|
||||
|
||||
+62
@@ -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)));
|
||||
}
|
||||
}
|
||||
+103
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user