feat(appointment): integrate work calendar and stable cargo batches
This commit is contained in:
+76
@@ -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());
|
||||
}
|
||||
}
|
||||
+20
@@ -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)));
|
||||
}
|
||||
}
|
||||
+36
@@ -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"));
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+25
@@ -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))));
|
||||
}
|
||||
}
|
||||
+57
@@ -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;
|
||||
}
|
||||
}
|
||||
+70
@@ -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());
|
||||
}
|
||||
}
|
||||
+42
@@ -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));
|
||||
}
|
||||
}
|
||||
+15
@@ -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);
|
||||
}
|
||||
}
|
||||
+121
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user