feat(appointment): integrate work calendar and stable cargo batches

This commit is contained in:
2026-10-15 16:55:57 +08:00
parent dbb93a06c2
commit a1d5ebdab2
171 changed files with 7898 additions and 396 deletions
@@ -0,0 +1,76 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
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 AppointmentWorkCommandServiceTest {
@Test void sameCommandReplaysItsOriginalSnapshotAfterLaterChangesAndRejectsChangedContent() throws Exception {
var f=new WorkTestFixture(); var state=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(state);
var first=f.command(Command.SAVE_FOLLOWUP,0,"first","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"已联系\"}");
var saved=f.commands.execute(state.getWorkItemKey(),first,ACTOR);
f.commands.execute(state.getWorkItemKey(),f.command(Command.SAVE_FOLLOWUP,1,"second","{\"ownerId\":\"1\",\"followupDate\":\"2026-10-16\",\"note\":\"等待回复\"}"),ACTOR);
assertEquals(saved,f.commands.execute(state.getWorkItemKey(),first,ACTOR));
assertEquals(2,f.history.size()); assertNull(saved.item().followupDate());
var changed=f.command(Command.SAVE_FOLLOWUP,0,"first","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"不同内容\"}");
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),changed,ACTOR)).getStatusCode().value());
var other=new Actor("2","运营乙",ACTOR.permissions());
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),first,other)).getStatusCode().value());
when(f.gateway.facts(anyList())).thenThrow(new IllegalStateException("source temporarily offline"));
assertEquals(saved,f.commands.execute(state.getWorkItemKey(),first,ACTOR));
var revoked=new Actor("1","运营甲",Set.of(LIST,FOLLOWUP,MANAGE));
assertEquals(403,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),first,revoked)).getStatusCode().value());
}
@Test void directRouteGeneratesFourTypesOnceAndDoesNotCreateWarehouseReceipt() throws Exception {
var f=new WorkTestFixture(); var route=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(route);
var requirement=new Requirement("requirement-1",null,null,"CUSTOMER","洛杉矶港","客户仓","America/Los_Angeles",List.of(),List.of(),List.of());
when(f.gateway.container("1")).thenReturn(new ContainerSource("1","柜1",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,"America/Los_Angeles",List.of(requirement)));
var request=f.command(Command.CONFIRM_ROUTE,0,"route","{\"flow\":\"DIRECT\",\"reason\":\"按客户直送要求\"}");
var result=f.commands.execute(route.getWorkItemKey(),request,ACTOR);
assertEquals(5,f.rows.size()); assertEquals(5,result.affectedWorkItemKeys().size());
assertTrue(f.store.get(route.getWorkItemKey()).isRetired());
assertFalse(f.store.all().stream().anyMatch(s->s.getType()==Type.WAREHOUSE_RECEIPT));
assertEquals(result,f.commands.execute(route.getWorkItemKey(),request,ACTOR)); assertEquals(5,f.rows.size());
assertThrows(ResponseStatusException.class,()->f.commands.execute(route.getWorkItemKey(),f.command(Command.CONFIRM_ROUTE,1,"switch","{\"flow\":\"TRANSFER\"}"),ACTOR));
}
@Test void externalHandoffRequiresExplicitAcceptanceAndOrdinaryReplyCannotClearIt() throws Exception {
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); f.add(state);
f.commands.execute(state.getWorkItemKey(),f.command(Command.HANDOFF_RETURN,0,"handoff","{\"recipient\":{\"kind\":\"EXTERNAL\",\"id\":null,\"name\":\"新车队\"},\"reason\":\"原承运另有任务\"}"),ACTOR);
f.commands.execute(state.getWorkItemKey(),f.command(Command.SAVE_FOLLOWUP,1,"reply","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"等待新车队回复\"}"),ACTOR);
assertTrue(pendingHandoff(f.store.get(state.getWorkItemKey())));
assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),f.command(Command.ACCEPT_RETURN,2,"empty","{\"handoffVersion\":1,\"note\":\"\"}"),ACTOR));
var accepted=f.commands.execute(state.getWorkItemKey(),f.command(Command.ACCEPT_RETURN,2,"accept","{\"handoffVersion\":1,\"note\":\"已电话确认接手,联系人张先生\"}"),ACTOR);
assertEquals(HandoffStatus.ACCEPTED,f.store.get(state.getWorkItemKey()).getReturnHandoff().status());
assertEquals(ExecutionStatus.PENDING,accepted.item().executionStatus()); assertNull(accepted.item().actual());
assertEquals("1",f.history.get(f.history.size()-1).getOperatorId());
}
@Test void firstRequirementDoesNotReviewDownstreamButRealRescheduleDoes() 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,"r1"); delivery.setPrerequisites(List.of(receipt.getWorkItemKey())); delivery.setTarget(slot("2026-10-17")); f.add(delivery);
var payload="{\"target\":{\"date\":\"2026-10-15\",\"startTime\":null,\"endDate\":null,\"endTime\":null,\"timezone\":\"America/Los_Angeles\"},\"bookingRequired\":true,\"receivingRequirements\":\"先接仓\",\"reason\":\"首次目标\"}";
f.commands.execute(receipt.getWorkItemKey(),f.command(Command.SAVE_REQUIREMENT,0,"first",payload),ACTOR);
assertFalse(f.store.get(delivery.getWorkItemKey()).isReviewRequired());
f.commands.execute(receipt.getWorkItemKey(),f.command(Command.SAVE_REQUIREMENT,1,"second",payload.replace("2026-10-15","2026-10-18").replace("首次目标","改期")),ACTOR);
assertTrue(f.store.get(delivery.getWorkItemKey()).isReviewRequired()); assertEquals(1,f.store.get(delivery.getWorkItemKey()).getReviewVersion());
assertThrows(ResponseStatusException.class,()->f.commands.execute(delivery.getWorkItemKey(),f.command(Command.RESOLVE_REVIEW,1,"old","{\"reviewVersion\":0,\"note\":\"旧影响\"}"),ACTOR));
var resolved=f.commands.execute(delivery.getWorkItemKey(),f.command(Command.RESOLVE_REVIEW,1,"review","{\"reviewVersion\":1,\"note\":\"已核对安排\"}"),ACTOR);
assertFalse(resolved.item().reviewRequired()); assertFalse(f.store.get(receipt.getWorkItemKey()).isReviewRequired());
}
@Test void unsupportedPayloadAndReadOnlyActorCannotWrite() throws Exception {
var f=new WorkTestFixture(); var state=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); f.add(state);
var readonly=new Actor("1","只读",Set.of(LIST,ORDER_READ));
var request=f.command(Command.SAVE_FOLLOWUP,0,"one","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"回复\"}");
assertEquals(403,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),request,readonly)).getStatusCode().value());
var invalid=f.command(Command.SAVE_FOLLOWUP,0,"two","{\"ownerId\":\"1\",\"followupDate\":null,\"note\":\"回复\",\"executionStatus\":\"COMPLETED\"}");
assertEquals(400,assertThrows(ResponseStatusException.class,()->f.commands.execute(state.getWorkItemKey(),invalid,ACTOR)).getStatusCode().value());
assertEquals(0,f.store.get(state.getWorkItemKey()).getVersion()); assertTrue(f.history.isEmpty());
}
}
@@ -0,0 +1,20 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.slot;
class AppointmentWorkCommitmentServiceTest {
@Test void firstTargetSameDayConfirmationAndFirstAssignmentAreNotImpacts() {
var empty=new Commitment(null,null,null,"cargo",false);
var planned=new Commitment(slot("2026-10-16"),null,null,"cargo",false);
var confirmed=new Commitment(planned.target(),slot("2026-10-16"),"vehicle-1","cargo",false);
assertFalse(impacts(empty,planned)); assertFalse(impacts(planned,confirmed));
assertFalse(impacts(planned,new Commitment(null,confirmed.confirmed(),null,"cargo",false)));
assertTrue(impacts(planned,new Commitment(planned.target(),slot("2026-10-17"),null,"cargo",false)));
assertTrue(impacts(confirmed,new Commitment(confirmed.target(),confirmed.confirmed(),"vehicle-2","cargo",false)));
assertTrue(impacts(confirmed,new Commitment(confirmed.target(),confirmed.confirmed(),confirmed.assignment(),"changed",false)));
assertTrue(impacts(confirmed,new Commitment(null,null,null,"cargo",true)));
}
}
@@ -0,0 +1,36 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.yaml.snakeyaml.Yaml;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkContractTest {
@Test void actionTargetsSerializeWithDiscriminatorAndSlotsNeverInventSeconds() throws Exception {
var json=new ObjectMapper().findAndRegisterModules();
var action=new Action(ActionCode.VERIFY_CARGO,"核实资料","work-1",new CargoTarget(new SourceReference("12","ORDER:1","FBA:2")));
var tree=json.readTree(json.writeValueAsString(action));
assertEquals("CARGO_VERIFICATION",tree.path("target").path("kind").asText());
assertEquals("12",tree.path("target").path("source").path("customerId").asText());
assertEquals(action,json.treeToValue(tree,Action.class));
var slot=new Slot(LocalDate.of(2026,10,15),LocalTime.of(10,30),null,null,"America/Los_Angeles");
assertEquals("10:30",json.readTree(json.writeValueAsString(slot)).path("startTime").asText());
var precise=new Slot(slot.date(),LocalTime.of(10,30,45,123000000),null,null,null);
assertEquals("10:30:45.123",json.readTree(json.writeValueAsString(precise)).path("startTime").asText());
assertEquals(precise,json.readValue(json.writeValueAsString(precise),Slot.class));
assertDoesNotThrow(()->AppointmentWorkPolicy.validateSourceSlot(precise));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->AppointmentWorkPolicy.validateSlot(precise));
}
@Test void openApiDefinesFrozenPathsAndDiscriminatedCommands() throws Exception {
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));
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"));
}
}
@@ -0,0 +1,28 @@
package com.baiyue.lastleg.appointmentwork;
import com.baiyue.common.core.api.ApiResponse;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.List;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
class AppointmentWorkDirectTransportProviderTest {
@Test void pickupStartsDirectTransportButOnlyOriginalDestinationUnloadingCompletesIt() {
var client=mock(AppointmentWorkFirstlegClient.class);var provider=new AppointmentWorkDirectTransportProvider(client,"internal-test");
var request=new FactRequest("W",Type.TRANSPORT,Flow.DIRECT,"1","INBOUND:1",null,null,List.of(),List.of(),List.of());
var time=LocalDateTime.parse("2026-10-15T11:00:00");
var pickup=fact("W/carrier",ExecutionStatus.COMPLETED,new Actual(null,time,null));
when(client.facts(eq("internal-test"),anyList())).thenReturn(ApiResponse.success(List.of(pickup,fact("W/delivery",ExecutionStatus.PENDING,null))));
var moving=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.IN_PROGRESS,moving.executionStatus());assertNull(moving.actual());assertNull(moving.tripKey());
when(client.facts(eq("internal-test"),anyList())).thenReturn(ApiResponse.success(List.of(pickup,fact("W/delivery",ExecutionStatus.COMPLETED,new Actual(null,time.plusHours(2),null)))));
var done=provider.read(List.of(request)).get(0);assertEquals(ExecutionStatus.COMPLETED,done.executionStatus());assertEquals(time.plusHours(2),done.actual().localOccurredAt());
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);
}
}
@@ -0,0 +1,25 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
class AppointmentWorkFactAdapterTest {
@Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() {
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of());
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),List.of()));
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),null));
var target=new HashMap<String,Fact>(); AppointmentWorkFactAdapter.append(target,List.of(request),List.of(fact(state,null,null,ExecutionStatus.PENDING,null)));
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(target,List.of(request),List.of(fact(state,null,null,ExecutionStatus.PENDING,null))));
}
@Test void completedFactRequiresActualEvidence() {
var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of());
assertThrows(ResponseStatusException.class,()->AppointmentWorkFactAdapter.append(new HashMap<>(),List.of(request),List.of(fact(state,null,null,ExecutionStatus.COMPLETED,null))));
}
}
@@ -0,0 +1,57 @@
package com.baiyue.lastleg.appointmentwork;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.ibatis.mapping.Environment;
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.time.*;
import java.util.*;
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 ddlAndMapperEnforceStableIdentityVersionAndGlobalCommandKey() throws Exception {
var ds=database();
var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(AppointmentWorkMapper.class);
try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(true)) {
var mapper=session.getMapper(AppointmentWorkMapper.class); var store=new AppointmentWorkStore(mapper,new ObjectMapper().findAndRegisterModules());
assertNotNull(store.lock()); assertThrows(org.springframework.web.server.ResponseStatusException.class,store::requireInitialized);
var guard=store.lock(); guard.setInitialized(true); store.checkpoint(guard); assertDoesNotThrow(store::requireInitialized);
mapper.invalidateRequirements(); assertThrows(org.springframework.web.server.ResponseStatusException.class,store::requireInitialized);
guard=store.lock(); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); store.checkpoint(guard); assertDoesNotThrow(store::requireInitialized);
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); store.insert(state);
assertThrows(RuntimeException.class,()->store.insert(state));
var stale=store.get(state.getWorkItemKey()); state.setOwner(new Owner("1","运营甲")); store.save(state);
assertEquals(1,store.get(state.getWorkItemKey()).getVersion());
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->store.save(stale));
store.history(state,ACTOR,"SAVE_FOLLOWUP","等待回复","same-key","hash",Map.of("version",1));
assertEquals("运营甲",store.replay(state.getWorkItemKey(),"same-key").getOperatorName());
assertThrows(RuntimeException.class,()->store.history(state,new Actor("2","运营乙",ACTOR.permissions()),"SAVE_FOLLOWUP","其他人重复键","same-key","other",Map.of("version",2)));
assertEquals(1,store.history(state.getWorkItemKey()).size());
}
}
@Test void transactionRollbackCannotLeaveHalfGeneratedRoute() throws Exception {
var ds=database(); var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true);
config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(AppointmentWorkMapper.class);
var factory=new MybatisSqlSessionFactoryBuilder().build(config);
try(var session=factory.openSession(false)) {
var store=new AppointmentWorkStore(session.getMapper(AppointmentWorkMapper.class),new ObjectMapper().findAndRegisterModules());
var state=new WorkTestFixture().state("1",Type.PICKUP,Flow.TRANSFER,null); store.insert(state);
store.history(state,ACTOR,"GENERATED","路线生成",null,null,null); session.rollback();
}
try(var session=factory.openSession(true)) { assertTrue(session.getMapper(AppointmentWorkMapper.class).all(10).isEmpty()); }
}
private JdbcDataSource database() throws Exception {
var ds=new JdbcDataSource(); ds.setURL("jdbc:h2:mem:work-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1");
String ddl; try(var stream=getClass().getResourceAsStream("/db/appointment-work.sql")) { assertNotNull(stream); ddl=new String(stream.readAllBytes(),StandardCharsets.UTF_8); }
try(Connection connection=ds.getConnection(); var statement=connection.createStatement()) { for(var sql:ddl.replaceAll("(?m)^--.*$","").replaceAll(" ENGINE=InnoDB[^;]*;",";").split(";")) if(!sql.isBlank()) statement.execute(sql); }
return ds;
}
}
@@ -0,0 +1,70 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
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.AppointmentWorkPolicy.*;
class AppointmentWorkPolicyTest {
@Test void originalDeliveryScheduleDoesNotUndoTheRecordedNoBookingRequirement() {
var fixture=new WorkTestFixture();var state=fixture.state("1",Type.DELIVERY,Flow.TRANSFER,"DISPATCH:1");state.setDestinationKind("CUSTOMER");state.setBookingRequired(false);
var item=project(state,fact(state,null,slot("2026-10-16"),ExecutionStatus.PENDING,null),ACTOR,CLOCK);
assertEquals(PlanStatus.NOT_REQUIRED,item.planStatus());assertEquals(LocalDate.of(2026,10,16),item.calendarDate());assertEquals(ExecutionStatus.PENDING,item.executionStatus());
}
@Test void localActualWithoutTimezoneRemainsCompletedOnItsRecordedDate() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
var actual=new Actual(null,LocalDateTime.parse("2026-10-12T23:45:00"),null);
var item=project(state,fact(state,null,null,ExecutionStatus.COMPLETED,actual),ACTOR,CLOCK);
assertEquals(LocalDate.of(2026,10,12),item.calendarDate()); assertEquals(ExecutionStatus.COMPLETED,item.executionStatus());
assertNull(item.timezone()); assertTrue(item.missingFields().contains("SOURCE_TIMEZONE"));
assertThrows(org.springframework.web.server.ResponseStatusException.class,()->validateActual(new Actual(null,null,null)));
}
@Test void sourcePlanWithoutTimezoneIsVisibleButNewRequirementRejectsIt() {
var source=new Slot(LocalDate.of(2026,10,16),LocalTime.of(10,30),null,null,null);
assertDoesNotThrow(()->validateSourceSlot(source)); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->validateSlot(source));
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.PICKUP,Flow.TRANSFER,null);
var item=project(state,fact(state,null,source,ExecutionStatus.PENDING,null),ACTOR,CLOCK);
assertEquals(source.date(),item.calendarDate()); assertTrue(item.missingFields().contains("SOURCE_TIMEZONE"));
}
@Test void followupDateNeverBecomesAnAppointmentAndUnknownTimezoneUsesExplicitBusinessClock() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.ROUTE,Flow.UNKNOWN,null); state.setTimezone(null); state.setFollowupDate(LocalDate.of(2026,10,14));
var item=project(state,null,ACTOR,CLOCK);
assertNull(item.calendarDate()); assertEquals(DateBasis.NONE,item.dateBasis()); assertTrue(followToday(item,CLOCK));
assertTrue(item.missingFields().stream().anyMatch(s->s.contains("America/Los_Angeles")));
assertEquals(ActionCode.CONFIRM_ROUTE,item.nextAction().code());
}
@Test void actualDateWinsInItsOwnZoneAndBucketsAreExclusive() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.DELIVERY,Flow.TRANSFER,"r1"); state.setReviewRequired(true);
var actual=new Actual(Instant.parse("2026-10-16T01:00:00Z"),null,"America/Los_Angeles");
var fact=fact(state,slot("2026-10-12"),slot("2026-10-13"),ExecutionStatus.COMPLETED,actual);
var item=project(state,fact,ACTOR,CLOCK);
assertEquals(LocalDate.of(2026,10,15),item.calendarDate()); assertEquals(DateBasis.ACTUAL,item.dateBasis());
assertEquals(Bucket.COMPLETED,bucket(item)); assertEquals(new BucketCounts(0,0,0,0,1),counts(List.of(item))); assertTrue(item.overdueReasons().isEmpty());
assertEquals(ActionCode.VIEW_HISTORY,item.nextAction().code());
}
@Test void nonexistentAndAmbiguousLocalTimesAreRejectedButDateOnlyStaysDateOnly() {
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,3,8),LocalTime.of(2,30),null,null,"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,11,1),LocalTime.of(1,30),null,null,"America/Los_Angeles")));
assertDoesNotThrow(()->validateSlot(slot("2026-11-01")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,10,15),null,null,null,"-08:00")));
}
@Test void overnightSlotRequiresExplicitEndDateAndValidEndLocalTime() {
var day=LocalDate.of(2026,10,15);
assertDoesNotThrow(()->validateSlot(new Slot(day,LocalTime.of(23,0),day.plusDays(1),LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,LocalTime.of(23,0),null,LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,LocalTime.of(23,0),day,LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(day,null,day.plusDays(1),LocalTime.of(1,0),"America/Los_Angeles")));
assertThrows(ResponseStatusException.class,()->validateSlot(new Slot(LocalDate.of(2026,3,7),LocalTime.of(23,0),LocalDate.of(2026,3,8),LocalTime.of(2,30),"America/Los_Angeles")));
}
@Test void pendingAcceptanceSurvivesAnEmptyReminderAndDoesNotGrantExecution() {
var fixture=new WorkTestFixture(); var state=fixture.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null);
state.setReturnHandoff(new ReturnHandoff(2,new Responsibility(ResponsibilityKind.EXTERNAL,null,"原车队"),new Responsibility(ResponsibilityKind.EXTERNAL,null,"新车队"),HandoffStatus.PENDING_ACCEPTANCE,"原车次结束"));
var item=project(state,fact(state,null,null,ExecutionStatus.PENDING,null),ACTOR,CLOCK);
assertTrue(followToday(item,CLOCK)); assertEquals(ActionCode.ACCEPT_RETURN,item.nextAction().code());
assertEquals(ExecutionStatus.PENDING,item.executionStatus()); assertNull(item.actual());
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkQueryServiceTest {
@Test void globalSummaryRetainsUnknownRoutesAndReturnAfterDeliveryAcrossCalendarFilters() {
var f=new WorkTestFixture(); var route=f.state("1",Type.ROUTE,Flow.UNKNOWN,null); route.setFollowupDate(LocalDate.of(2026,10,14)); f.add(route);
var returns=f.state("2",Type.EMPTY_RETURN,Flow.DIRECT,null); returns.setTarget(slot("2026-09-01")); f.add(returns);
var delivered=f.state("2",Type.DELIVERY,Flow.DIRECT,"r1"); f.add(delivered);
f.facts.put(delivered.getWorkItemKey(),fact(delivered,null,null,ExecutionStatus.COMPLETED,new Actual(Instant.parse("2026-10-14T20:00:00Z"),null,"America/Los_Angeles")));
assertEquals(new Summary(2,1,1,1,0),f.queries.summary(ACTOR));
assertEquals(0,f.queries.calendar(LocalDate.of(2026,10,1),LocalDate.of(2026,10,31),Flow.TRANSFER,Set.of(),ACTOR).total());
assertEquals(2,f.queries.summary(ACTOR).all());
var day=f.queries.items(View.ALL,1,20,LocalDate.of(2026,10,14),null,null,null,Bucket.COMPLETED,null,ACTOR);
assertEquals(1,day.total()); assertEquals(delivered.getWorkItemKey(),day.records().get(0).workItemKey());
assertEquals(2,f.queries.items(View.ALL,1,20,null,null,null,null,null,null,ACTOR).total());
}
@Test void canonicalTripIsOneWorkItemAndMatchesEveryAssociatedContainer() {
var f=new WorkTestFixture(); var trip=f.state("1",Type.TRANSPORT,Flow.TRANSFER,null); trip.setTripKey("trip-1");
trip.setContainers(List.of(new ContainerReference("1","CB-1","A"),new ContainerReference("2","CB-2","B"))); f.add(trip);
assertEquals(1,f.queries.summary(ACTOR).all());
assertEquals(1,f.queries.items(View.ALL,1,20,null,null,null,"2",null,"B",ACTOR).total());
assertEquals(2,f.queries.detail(trip.getWorkItemKey(),ACTOR).item().containers().size());
}
@Test void incompleteBootstrapScopeDriftAndAuthorityFailureCannotProduceZero() {
var f=new WorkTestFixture(); f.guard.setInitialized(false);
assertEquals(503,assertThrows(ResponseStatusException.class,()->f.queries.summary(ACTOR)).getStatusCode().value());
f.guard.setInitialized(true); when(f.gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"new-scope"));
assertEquals(409,assertThrows(ResponseStatusException.class,()->f.queries.summary(ACTOR)).getStatusCode().value());
when(f.gateway.discover(isNull(),anyInt())).thenThrow(new IllegalStateException("offline"));
assertThrows(IllegalStateException.class,()->f.queries.summary(ACTOR));
}
}
@@ -0,0 +1,15 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
class AppointmentWorkRouteServiceTest {
@Test void onlyCurrentCommittedItemsDefineRouteAndNoGuardOrRemoteReadIsRequired() {
var fixture=new WorkTestFixture();var old=fixture.state("1",Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null);old.setRetired(true);fixture.add(old);
var current=fixture.state("1",Type.DELIVERY,Flow.DIRECT,"INBOUND:1");fixture.add(current);
assertEquals(Flow.DIRECT,new AppointmentWorkRouteService(fixture.store).route("1").flow());
org.mockito.Mockito.verify(fixture.mapper,org.mockito.Mockito.never()).lockGuard();
org.mockito.Mockito.verifyNoInteractions(fixture.gateway);
}
}
@@ -0,0 +1,121 @@
package com.baiyue.lastleg.appointmentwork;
import org.junit.jupiter.api.Test;
import org.springframework.web.server.ResponseStatusException;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
class AppointmentWorkSyncServiceTest {
@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");
var batch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,"客户仓",null,List.of(),List.of("INBOUND:1","INBOUND:2"),List.of(source1,source2));
var first=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch));
var second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
var root=new Requirement("INBOUND:1",null,null,"CUSTOMER",null,"客户仓",null,List.of(),List.of(),List.of(source1));
first=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root));
var remainingBatch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,"客户仓",null,List.of(),List.of("INBOUND:2"),List.of(source2));
second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(remainingBatch));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
var active=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList();assertEquals(2,active.size());
var kept=active.stream().filter(s->"DISPATCH:1".equals(s.getBatchKey())).findFirst().orElseThrow();
assertEquals(List.of("2"),kept.getContainers().stream().map(ContainerReference::containerBookingId).toList());assertEquals("2",kept.getContainerBookingId());
assertFalse(kept.getPrerequisites().contains(AppointmentWorkPolicy.key("1",Type.WAREHOUSE_RECEIPT,null)));
assertFalse(f.store.container("1").stream().anyMatch(s->"DISPATCH:1".equals(s.getBatchKey())));
assertEquals(List.of(source2),kept.getSources());
var root2=new Requirement("INBOUND:2",null,null,"CUSTOMER",null,"客户仓",null,List.of(),List.of(),List.of(source2));
second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root2));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
assertEquals(2,f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired() && s.getBatchKey()==null).count());
}
@Test void removedPrivateBatchCannotBeReplacedByAnotherCustomersUnrelatedDemand() {
var f=new WorkTestFixture();var oldSource=new SourceReference("C1","ORDER:1","CUSTOMER:1");
var batch=new Requirement("DISPATCH:1","DISPATCH:1","D-1","CUSTOMER",null,null,null,List.of(),List.of(),List.of(oldSource));
var container=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(container),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10));
var unrelated=new Requirement("INBOUND:2",null,null,"CUSTOMER",null,null,null,List.of(),List.of(),List.of(new SourceReference("C2","ORDER:2","CUSTOMER:2")));
container=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(unrelated));
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(container),null,true,"scope"));
assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,10)));
}
@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());
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"));
f.sync.synchronize(new SyncRequest(null,10));
var deliveries=f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).toList();
assertEquals(1,deliveries.size()); assertEquals(requirement.requirementKey(),deliveries.get(0).getRequirementKey());
}
}
@Test void localRequirementVersionChangingBetweenPagesCannotBePublishedComplete() {
var f=new WorkTestFixture();f.guard.setScanCursor("10");f.guard.setScanScopeVersion("scope");f.guard.setScanRequirementVersion(1);f.guard.setRequirementVersion(2);
when(f.gateway.discover("10",10)).thenReturn(new SourcePage(List.of(),null,true,"scope"));
assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest("10",10)));
assertEquals(0,f.guard.getSynchronizedRequirementVersion());
}
@Test void serverDiscoveryCreatesUnknownRouteWithoutWarehouseBindingAndRetainsOldUnreturnedWork() {
var f=new WorkTestFixture(); var returns=f.state("old",Type.EMPTY_RETURN,Flow.DIRECT,null); f.add(returns);
var source=new ContainerSource("new","柜new",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of());
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
assertEquals(1,f.sync.synchronize(new SyncRequest(null,10)).created());
assertEquals(0,f.sync.synchronize(new SyncRequest(null,10)).created());
assertEquals(2,f.rows.size()); assertNotNull(f.store.get(returns.getWorkItemKey()));
assertTrue(f.store.all().stream().anyMatch(s->s.getFlow()==Flow.UNKNOWN && s.getType()==Type.ROUTE));
}
@Test void threeTransferRequirementsGenerateOnlyOnePickupReceiptAndReturn() {
var f=new WorkTestFixture();
var requirements=List.of("one","two","three").stream().map(id->new Requirement(id,null,null,"FBA","海外仓","ONT8","America/Los_Angeles",List.of(),List.of(),List.of())).toList();
var source=new ContainerSource("1","柜1",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,"America/Los_Angeles",requirements);
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope"));
f.sync.synchronize(new SyncRequest(null,10)); f.sync.synchronize(new SyncRequest(null,10));
assertEquals(9,f.rows.size());
for(var type:List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN)) assertEquals(1,f.store.all().stream().filter(s->s.getType()==type).count());
}
@Test void firstConfirmationUsesBaselineWhileSubsequentImpactMarksOnlyArrangedRelatedWork() {
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 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));
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());
}
@Test void changingDiscoveryScopeDuringPaginationIsNotMarkedComplete() {
var f=new WorkTestFixture(); f.guard.setInitialized(false); f.guard.setScanCursor("10"); f.guard.setScanScopeVersion("before");
when(f.gateway.discover("10",10)).thenReturn(new SourcePage(List.of(),null,true,"after"));
assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest("10",10)));
assertFalse(f.guard.isInitialized());
}
@Test void sharedTripAcrossTwoContainersIsPersistedOnceAndKeepsBothContainers() {
var f=new WorkTestFixture();
var sources=List.of("1","2").stream().map(id->new ContainerSource(id,"柜"+id,Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,"America/Los_Angeles",
List.of(new Requirement("R"+id,"B"+id,"AP"+id,"FBA","海外仓","ONT8","America/Los_Angeles",List.of(),List.of(),List.of())))).toList();
when(f.gateway.discover(null,10)).thenReturn(new SourcePage(sources,null,true,"scope"));
when(f.gateway.facts(anyList())).thenAnswer(call->{
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));
else values.put(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null));
}
return values;
});
f.sync.synchronize(new SyncRequest(null,10)); f.sync.synchronize(new SyncRequest(null,10));
var transports=f.store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.TRANSPORT).toList();
assertEquals(1,transports.size()); assertEquals(2,transports.get(0).getContainers().size()); assertEquals(List.of("B1","B2"),transports.get(0).getBatchKeys());
assertEquals(9,f.store.all().stream().filter(s->!s.isRetired()).count());
}
}
@@ -0,0 +1,54 @@
package com.baiyue.lastleg.appointmentwork;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.*;
import java.util.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*;
import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*;
final class WorkTestFixture {
static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-15T04:00:00Z"),ZoneOffset.UTC);
static final Actor ACTOR=new Actor("1","运营甲",Set.of(LIST,ORDER_READ,FOLLOWUP,MANAGE));
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
final AppointmentWorkMapper mapper=mock(AppointmentWorkMapper.class);
final AppointmentWorkFactGateway gateway=mock(AppointmentWorkFactGateway.class);
final Map<String,AppointmentWorkMapper.Row> rows=new TreeMap<>();
final Map<String,Fact> facts=new HashMap<>();
final List<AppointmentWorkMapper.History> history=new ArrayList<>();
final AppointmentWorkMapper.Guard guard=new AppointmentWorkMapper.Guard();
final AppointmentWorkStore store=new AppointmentWorkStore(mapper,json);
final AppointmentWorkSyncService sync=new AppointmentWorkSyncService(store,gateway);
final AppointmentWorkQueryService queries=new AppointmentWorkQueryService(store,gateway,CLOCK);
final AppointmentWorkCommandService commands=new AppointmentWorkCommandService(store,gateway,sync,queries);
WorkTestFixture() {
guard.setId(1); guard.setInitialized(true); guard.setScopeVersion("scope");
when(mapper.guard()).thenReturn(guard); when(mapper.lockGuard()).thenReturn(guard);
when(mapper.get(anyString())).thenAnswer(c->rows.get(c.getArgument(0)));
when(mapper.all(anyInt())).thenAnswer(c->rows.values().stream().limit(c.getArgument(0,Integer.class)).toList());
when(mapper.container(anyString())).thenAnswer(c->rows.values().stream().filter(r->r.getContainerBookingId().equals(c.getArgument(0))).toList());
when(mapper.insert(any())).thenAnswer(c->{ var row=c.getArgument(0,AppointmentWorkMapper.Row.class); if(rows.putIfAbsent(row.getWorkItemKey(),row)!=null) throw new org.springframework.dao.DuplicateKeyException("duplicate"); return 1; });
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.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"));
when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲"));
when(gateway.requirements(any(),any())).thenAnswer(c->c.getArgument(0,ContainerSource.class).requirements());
}
AppointmentWorkState state(String container,Type type,Flow flow,String requirement) {
var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container);
state.setContainers(List.of(new ContainerReference(container,"CB-"+container,"柜"+container))); state.setType(type); state.setFlow(flow); state.setRequirementKey(requirement); state.setSourceVersion("v1"); state.setTimezone("America/Los_Angeles");
return state;
}
void add(AppointmentWorkState state) { store.insert(state); }
CommandRequest command(Command command,int version,String id,String payload) throws Exception { return new CommandRequest(command,version,id,json.readTree(payload)); }
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);
}
}