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

This commit is contained in:
2026-10-16 15:43:04 +08:00
parent 4e2ea89403
commit 30f48bd926
84 changed files with 4879 additions and 136 deletions
@@ -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"));
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
@@ -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));
@@ -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;
@@ -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());
}
}
@@ -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);
}
}