feat(selfop): sync versioned owner and product directories
This commit is contained in:
+14
@@ -34,6 +34,20 @@ class RestLingxingAnalyticsClientTest {
|
||||
properties.setSourceAccount("synthetic-account");
|
||||
properties.setInternalServiceToken("synthetic-token");
|
||||
}
|
||||
@Test void masterPagesKeepNamesAndSeparateDeclaredAndMeasuredDimensions() {
|
||||
enable();
|
||||
expect("/customers/query", "{\"current\":1,\"size\":1000}", """
|
||||
{"code":"200","data":{"current":1,"size":1000,"pages":1,"total":"1","records":[{"customerCode":"C","customerName":"客户","status":"0"}]}}
|
||||
""");
|
||||
expect("/products/query", "{\"current\":1,\"size\":100}", """
|
||||
{"code":"200","data":{"current":1,"size":100,"pages":1,"total":"1","records":[{"customerCode":"C","sku":"SKU","productName":"商品","length":5,"width":11,"height":5,"sizeUnit":"cm","wmsLength":null,"wmsWidth":null,"wmsHeight":null,"wmsSizeUnit":null}]}}
|
||||
""");
|
||||
assertThat(client.customers(context("synthetic-account"),1,1000).records().get(0).customerName()).isEqualTo("客户");
|
||||
var product=client.products(context("synthetic-account"),1,100).records().get(0);
|
||||
assertThat(product.length()).isEqualByComparingTo("5");assertThat(product.sizeUnit()).isEqualTo("cm");assertThat(product.wmsLength()).isNull();assertThat(product.wmsSizeUnit()).isNull();
|
||||
verify(egress).reserve(any(),eq(SelfopEgressDtos.Operation.CUSTOMERS),eq(1000),isNull(),isNull(),any());
|
||||
verify(egress).reserve(any(),eq(SelfopEgressDtos.Operation.PRODUCTS),eq(100),isNull(),isNull(),any());server.verify();
|
||||
}
|
||||
@Test void disabledByDefaultAndAccountBindingFailBeforeTransport() {
|
||||
assertThatThrownBy(() -> client.warehouses(context("synthetic-account"), 1, 100)).hasMessage("EXECUTION_DISABLED");
|
||||
enable();
|
||||
|
||||
+4
-1
@@ -26,6 +26,7 @@ class PlatformAnalyticsControllerTest {
|
||||
var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));
|
||||
var user=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));
|
||||
assertThrows(ResponseStatusException.class,()->controller.inventoryCoverage(user,null,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.ownerDirectory(user,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.syncTaskDetail(user,"LXCOL-DEMO",null));
|
||||
verifyNoInteractions(service,collection);
|
||||
}
|
||||
@@ -34,6 +35,7 @@ class PlatformAnalyticsControllerTest {
|
||||
var controller=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));
|
||||
var user=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync"));
|
||||
assertThrows(ResponseStatusException.class,()->controller.hotSkus(user,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.ownerDirectory(user,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.syncOverview(user,null));
|
||||
assertThrows(ResponseStatusException.class,()->controller.slowSkus(null,null));
|
||||
verifyNoInteractions(service,collection);
|
||||
@@ -42,7 +44,8 @@ class PlatformAnalyticsControllerTest {
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var c=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));var user=platform();
|
||||
c.salesSummary(user,null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null);
|
||||
c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null);
|
||||
c.ownerDirectory(user,null);c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null);
|
||||
verify(service).ownerDirectory(null);
|
||||
verify(service).salesSummary(null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);verify(service).dailySales(null);verify(service).hotSkus(null);
|
||||
verify(service).slowSkus(null);verify(service).inventoryCoverage(null,null);
|
||||
verify(service).owners(null,null);verify(service).ownerOverview(null,null);verify(collection).overview(null);verify(collection).tasks(null);verify(collection).taskDetail("LXCOL-DEMO",null);verify(collection).runs(null);
|
||||
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.mapper;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads;
|
||||
import org.apache.ibatis.mapping.Environment;
|
||||
import org.apache.ibatis.session.*;
|
||||
import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory;
|
||||
import org.h2.jdbcx.JdbcDataSource;
|
||||
import org.junit.jupiter.api.*;
|
||||
import java.sql.*;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDateTime;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.UUID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** Real SQL, source DDL and transaction rollback against an offline synthetic database. */
|
||||
class SelfopMasterDataMapperTest {
|
||||
JdbcDataSource data;SqlSessionFactory factory;SqlSession session;SelfopMasterDataMapper mapper;
|
||||
final LocalDateTime now=LocalDateTime.of(2026,10,14,8,0);
|
||||
@BeforeEach void setup() throws Exception {
|
||||
data=new JdbcDataSource();data.setURL("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1");
|
||||
try(var stream=getClass().getResourceAsStream("/db/selfop-master-data.sql")) {
|
||||
String ddl=new String(stream.readAllBytes(),StandardCharsets.UTF_8).replace(" COLLATE utf8mb4_bin","").replaceAll(" ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='[^']*'","");
|
||||
for(String statement:ddl.split(";"))if(!statement.isBlank())sql(statement);
|
||||
}
|
||||
sql("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,source_account VARCHAR(64),task_type VARCHAR(32),task_role VARCHAR(32),execution_purpose VARCHAR(32))");
|
||||
sql("CREATE TABLE ll_selfop_sync_run(id BIGINT PRIMARY KEY,task_id BIGINT,source_account VARCHAR(64),execution_purpose VARCHAR(32),status VARCHAR(32),coverage_status VARCHAR(32),published_at_utc TIMESTAMP)");
|
||||
var config=new Configuration(new Environment("offline",new JdbcTransactionFactory(),data));
|
||||
config.setMapUnderscoreToCamelCase(true);config.setLocalCacheScope(LocalCacheScope.STATEMENT);config.addMapper(SelfopMasterDataMapper.class);
|
||||
factory=new SqlSessionFactoryBuilder().build(config);session=factory.openSession(true);mapper=session.getMapper(SelfopMasterDataMapper.class);
|
||||
}
|
||||
@AfterEach void close(){session.close();}
|
||||
void sql(String query)throws Exception{try(var c=data.getConnection();var s=c.createStatement()){s.execute(query);}}
|
||||
SelfopPayloads.Product product(String owner){return new SelfopPayloads.Product(owner,"SKU","商品",new BigDecimal("5"),new BigDecimal("11"),new BigDecimal("5"),"cm",null,null,null,null);}
|
||||
@Test void namesAndProductsAreIsolatedByAccountOwnerAndBatch()throws Exception{
|
||||
mapper.insertOwner(1,10,"A",new SelfopPayloads.Owner("C","客户","0"),now);
|
||||
mapper.insertOwner(2,10,"B",new SelfopPayloads.Owner("C","另一账号","1"),now);
|
||||
mapper.insertProduct(3,10,"A",product("C"),now);mapper.insertProduct(4,10,"A",product("D"),now);
|
||||
assertEquals("客户",mapper.owners("A",10).get(0).customerName());assertEquals("0",mapper.owners("A",10).get(0).status());
|
||||
assertEquals(1,mapper.ownerCount(10,"A"));assertEquals(2,mapper.productCount(10,"A"));assertEquals(0,mapper.productCount(11,"A"));
|
||||
assertThrows(RuntimeException.class,()->mapper.insertProduct(5,10,"A",product("C"),now));
|
||||
try(var c=data.getConnection();var s=c.createStatement();var rs=s.executeQuery("SELECT declared_length,declared_size_unit,measured_length,measured_size_unit FROM ll_selfop_product WHERE id=3")){
|
||||
assertTrue(rs.next());assertEquals(0,new BigDecimal("5").compareTo(rs.getBigDecimal(1)));assertEquals("cm",rs.getString(2));assertNull(rs.getBigDecimal(3));assertNull(rs.getString(4));
|
||||
}
|
||||
}
|
||||
@Test void onlyCompleteBusinessMasterBatchesPublishedBeforeAsOfAreVisible()throws Exception{
|
||||
sql("INSERT INTO ll_selfop_sync_task VALUES(1,'A','MASTER_DATA','ACCOUNT_DISCOVERY','BUSINESS'),(2,'A','BACKFILL','ACCOUNT_DISCOVERY','BUSINESS'),(3,'B','MASTER_DATA','ACCOUNT_DISCOVERY','BUSINESS')");
|
||||
sql("INSERT INTO ll_selfop_sync_run VALUES(10,1,'A','BUSINESS','PUBLISHED','COMPLETE','2026-10-14 07:00:00'),(11,1,'A','BUSINESS','PUBLISHED','COMPLETE','2026-10-14 09:00:00'),(12,1,'A','BUSINESS','FETCHING','COMPLETE','2026-10-14 07:59:00'),(13,1,'A','BUSINESS','PUBLISHED','PARTIAL','2026-10-14 07:59:00'),(14,2,'A','BUSINESS','PUBLISHED','COMPLETE','2026-10-14 07:59:00'),(15,3,'B','BUSINESS','PUBLISHED','COMPLETE','2026-10-14 07:59:00')");
|
||||
assertEquals(10L,mapper.publishedAt("A",now));assertEquals(11L,mapper.publishedAt("A",now.plusHours(2)));assertNull(mapper.publishedAt("A",now.minusHours(2)));assertEquals(15L,mapper.publishedAt("B",now));
|
||||
}
|
||||
@Test void failedPageTransactionRollsBackEveryRow(){
|
||||
try(var transaction=factory.openSession(false)){
|
||||
var writer=transaction.getMapper(SelfopMasterDataMapper.class);
|
||||
writer.insertOwner(1,10,"A",new SelfopPayloads.Owner("C","客户","0"),now);
|
||||
assertThrows(RuntimeException.class,()->writer.insertOwner(2,10,"A",new SelfopPayloads.Owner("C","重复","0"),now));
|
||||
transaction.rollback();
|
||||
}
|
||||
assertEquals(0,mapper.ownerCount(10,"A"));
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -16,6 +16,15 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
/** Offline contracts: SQL supplies bounded pages, never an in-memory warehouse history. */
|
||||
class AnalyticsQueryServiceTest {
|
||||
@Test void namesUseFrozenLocalDirectoryWithoutChangingSkuIdentityOrQuantities(){
|
||||
var directory=mock(SelfopOwnerDirectory.class);when(directory.names("demo",NOW)).thenReturn(Map.of("C","货主名称"));
|
||||
var named=new AnalyticsQueryService(mapper,new ObjectMapper(),Clock.fixed(NOW,ZoneOffset.UTC),directory);
|
||||
var before=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
var after=named.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertEquals("货主名称",after.customerName());assertEquals(before.customerCode(),after.customerCode());assertEquals(before.sku(),after.sku());
|
||||
assertEquals(before.physicalQuantity(),after.physicalQuantity());assertEquals(before.windowOutboundQuantity(),after.windowOutboundQuantity());
|
||||
assertEquals("货主名称",named.ownerDirectory(metrics(null)).get(0).customerName());
|
||||
}
|
||||
@Test void frozenHistoryStartExistsBeforeContinuousAnchorAndIncludesEarlierDisconnectedDays(){
|
||||
when(mapper.publishedRun(11)).thenReturn(frozenRun(null,null,
|
||||
new com.baiyue.lastleg.selfoperated.analytics.model.SelfopSalesCoverage.Interval(time(D.minusDays(1)),time(D))));
|
||||
@@ -398,7 +407,7 @@ class AnalyticsQueryServiceTest {
|
||||
return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null,
|
||||
fresh?n("0"):null,utc(NOW.minusSeconds(3600)),out,out,10L,D.minusDays(1),evidence!=null,out,null,0,0,out==null||stock==null);
|
||||
}
|
||||
private AnalyticsQueryService at(Instant now){return new AnalyticsQueryService(mapper,new ObjectMapper(),Clock.fixed(now,ZoneOffset.UTC));}
|
||||
private AnalyticsQueryService at(Instant now){return new AnalyticsQueryService(mapper,new ObjectMapper(),Clock.fixed(now,ZoneOffset.UTC),org.mockito.Mockito.mock(SelfopOwnerDirectory.class));}
|
||||
private static AnalyticsQuery chart(String range,LocalDate from,LocalDate until,String owner,String sku){return new AnalyticsQuery("demo","W",owner,sku,range,from,until,"DAY",null,1,20,null,null,null,null,null);}
|
||||
private static AnalyticsQuery metrics(Integer days){return new AnalyticsQuery("demo","W",null,null,null,null,null,null,days,1,20,null,null,null,null,null);}
|
||||
private static AnalyticsQuery pinned(String range,long sales,long inventory,String sh,String ih,Instant asOf,int page){return new AnalyticsQuery("demo","W",null,null,range,null,null,range==null?null:"DAY",null,page,20,sales,inventory,sh,ih,asOf);}
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ import static org.mockito.Mockito.*;
|
||||
|
||||
class DashboardQueryServiceTest {
|
||||
@Test void selectedWindowKeepsHotAvailableButDoesNotInventAverageWhenIncomplete()throws Exception{
|
||||
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper);
|
||||
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper,org.mockito.Mockito.mock(SelfopOwnerDirectory.class));
|
||||
LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW;
|
||||
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
|
||||
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
|
||||
@@ -38,7 +38,7 @@ class DashboardQueryServiceTest {
|
||||
assertTrue(DashboardQueryService.class.getAnnotation(Transactional.class).readOnly());
|
||||
}
|
||||
@Test void unreadyPublishedVersionIsRejectedWhileReadyEmptyWarehouseProvesZeroAndAbsentSalesStayUnknown(){
|
||||
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper);
|
||||
var contexts=mock(AnalyticsQueryService.class);var mapper=mock(DashboardReadMapper.class);var service=new DashboardQueryService(contexts,mapper,org.mockito.Mockito.mock(SelfopOwnerDirectory.class));
|
||||
LocalDate day=DashboardPolicyTest.DAY;Instant now=DashboardPolicyTest.NOW;
|
||||
var q=new AnalyticsQuery("demo","W",null,null,"TODAY",null,null,null,null,null,null,null,null,null,null,null);
|
||||
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false,day.minusDays(15),day);
|
||||
|
||||
+3
-3
@@ -107,7 +107,7 @@ class InventoryTurnoverContextTest {
|
||||
when(analytics.applyBarriers("demo","W")).thenReturn(1L);
|
||||
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"),
|
||||
Map.of("section","OVERVIEW","payload","{\"sku_count\":0}")));
|
||||
var result=new InventoryTurnoverQueryService(contexts,mapper,json).query(request(null));
|
||||
var result=new InventoryTurnoverQueryService(contexts,mapper,json,org.mockito.Mockito.mock(SelfopOwnerDirectory.class)).query(request(null));
|
||||
assertTrue(result.context().analyticsContext().rebuildRequired());
|
||||
verify(mapper).report(argThat(p->p.days().stream().allMatch(d->d.coverage()==0)));
|
||||
verify(mapper).chartWindows(argThat(p->p.salesRun()==11L&&p.inventoryRun()==22L&&p.salesKnown()
|
||||
@@ -116,7 +116,7 @@ class InventoryTurnoverContextTest {
|
||||
&&p.days().subList(15,30).stream().allMatch(d->d.coverage()==1)));
|
||||
}
|
||||
@Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() {
|
||||
var service=new InventoryTurnoverQueryService(contexts,mapper,json);
|
||||
var service=new InventoryTurnoverQueryService(contexts,mapper,json,org.mockito.Mockito.mock(SelfopOwnerDirectory.class));
|
||||
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"),
|
||||
Map.of("section","OVERVIEW","payload","{\"sku_count\":0,\"physical\":0,\"available\":0,\"no_outbound_count\":0,\"unassessable\":0}")));
|
||||
var first=service.query(request(null));assertEquals(0,first.total());assertNotNull(first.summary());assertNotNull(first.charts());
|
||||
@@ -127,7 +127,7 @@ class InventoryTurnoverContextTest {
|
||||
var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext());
|
||||
var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any());
|
||||
}
|
||||
private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC));}
|
||||
private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC),mock(SelfopOwnerDirectory.class));}
|
||||
static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token);}
|
||||
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
|
||||
private static Scope scope(long id,String family,String hash,long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,null,null,7);}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.util.*;
|
||||
import java.time.Instant;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
class InventoryTurnoverQueryServiceTest {
|
||||
@Test void totalsWithoutCustomerIdentityRemainReadableBeforeFirstDirectoryPublication(){
|
||||
var contexts=mock(InventoryTurnoverContext.class);var mapper=mock(InventoryTurnoverReadMapper.class);var directory=mock(SelfopOwnerDirectory.class);
|
||||
var request=mock(InventoryTurnoverRequest.class);when(request.sourceAccount()).thenReturn("A");
|
||||
var context=mock(InventoryTurnoverResponses.Context.class);var analytics=mock(AnalyticsResponses.Context.class);
|
||||
var snapshot=mock(AnalyticsQueryService.Snapshot.class);var asOf=Instant.parse("2026-10-14T08:00:00Z");
|
||||
when(context.queryAsOf()).thenReturn(asOf);when(context.analyticsContext()).thenReturn(analytics);when(context.dailySamples()).thenReturn(List.of());
|
||||
when(contexts.resolve(request)).thenReturn(new InventoryTurnoverContext.Frozen(context,snapshot));
|
||||
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}")));
|
||||
var service=new InventoryTurnoverQueryService(contexts,mapper,new ObjectMapper(),directory);
|
||||
when(directory.names("A",asOf)).thenReturn(Map.of());assertEquals(0,service.query(request).total());
|
||||
when(directory.names("A",asOf)).thenReturn(Map.of("C","货主名称"));assertEquals(0,service.query(request).total());
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.SelfopMasterDataMapper;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopPayloads;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
class SelfopOwnerDirectoryTest {
|
||||
@Test void onlyPublishedBatchAtFrozenTimeSuppliesNames(){
|
||||
var mapper=mock(SelfopMasterDataMapper.class);var time=Instant.parse("2026-10-14T08:00:00Z");
|
||||
when(mapper.publishedAt("A",LocalDateTime.ofInstant(time,ZoneOffset.UTC))).thenReturn(11L);
|
||||
when(mapper.owners("A",11L)).thenReturn(List.of(new SelfopPayloads.Owner("C","客户","0"),new SelfopPayloads.Owner("D","",null)));
|
||||
var names=new SelfopOwnerDirectory(mapper).names("A",time);assertEquals("客户",names.get("C"));assertTrue(names.containsKey("D"));assertNull(names.get("D"));
|
||||
assertEquals(Map.of(),new SelfopOwnerDirectory(mapper).names("B",time));verify(mapper,never()).owners("B",11L);
|
||||
}
|
||||
}
|
||||
+19
@@ -29,6 +29,25 @@ class SelfopEgressServiceTest {
|
||||
private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);}
|
||||
private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);}
|
||||
private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);}
|
||||
@Test void masterDataOperationsUseBoundBusinessTaskAndExistingSingleFlightGate(){
|
||||
continuousBusinessRoot();task.setTaskType("MASTER_DATA");task.setQueryFromUtc(null);task.setQueryUntilUtc(null);
|
||||
task.setPlannedAtUtc(now().plusHours(8).toLocalDate().atStartOfDay().minusHours(8));
|
||||
var definition=new TreeMap<String,Object>();definition.put("dataFamily","MASTER_DATA");definition.put("sourceAccount","synthetic");
|
||||
task.setQueryScopeJson(SelfopSupport.json(definition));task.setScopeHash(SelfopSupport.hash(definition));
|
||||
task.setRequestKey("continuous:master:"+task.getPlannedAtUtc().plusHours(8).toLocalDate()+":"+task.getScopeHash().substring(0,12));
|
||||
assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isTrue();
|
||||
assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,100,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
|
||||
assertThatThrownBy(()->reserve(Operation.CUSTOMERS,1001,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
|
||||
assertThatThrownBy(()->reserve(Operation.PRODUCTS,101,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
|
||||
var c=reserve(Operation.CUSTOMERS,1000,null,null);
|
||||
assertThatThrownBy(()->reserve(Operation.PRODUCTS,100,null,null)).isInstanceOf(EgressRejectedException.class);
|
||||
success(c,Operation.CUSTOMERS);success(reserve(Operation.PRODUCTS,100,null,null),Operation.PRODUCTS);
|
||||
assertThat(budget().confirmedHttp).isEqualTo(2);
|
||||
}
|
||||
@Test void ordinaryTasksCannotUseMasterDataOperations(){
|
||||
business();assertThatThrownBy(()->reserve(Operation.CUSTOMERS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
|
||||
assertThatThrownBy(()->reserve(Operation.PRODUCTS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH");
|
||||
}
|
||||
@Test void offUnknownModeUnknownEnvironmentBusinessAndUnverifiedHandoffFailBeforeAnyLock(){
|
||||
cfg.setCollectionMode("OFF");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("EGRESS_MODE_REJECTED");
|
||||
cfg.setCollectionMode("BUSINESS");cfg.setEnvironment("unknown");assertThatThrownBy(()->reserve(Operation.WAREHOUSES,1,null,null)).hasMessage("BUSINESS_ENVIRONMENT_REJECTED");
|
||||
|
||||
+6
@@ -44,6 +44,12 @@ class SelfopContinuousDueSqlTest {
|
||||
try(var rows=p.executeQuery()){var result=new ArrayList<Long>();while(rows.next())result.add(rows.getLong("id"));return result;}
|
||||
}
|
||||
}
|
||||
@Test void masterCatalogIsDispatchableAfterInventoryAndHistoricalSales() throws Exception {
|
||||
inventory(2,"READY",0,MIDNIGHT,null);
|
||||
execute("INSERT INTO ll_selfop_sync_task VALUES(1,1,'SYNTH-ACCOUNT','BUSINESS',NULL,'READY','continuous:master:day','SELFOP_CONTINUOUS_PLANNER','ACCOUNT_DISCOVERY','MASTER_DATA',NULL,?,NULL,0)",MIDNIGHT.minusDays(1));
|
||||
execute("INSERT INTO ll_selfop_sync_task VALUES(3,3,'SYNTH-ACCOUNT','BUSINESS',NULL,'READY','continuous:outbound:day','SELFOP_CONTINUOUS_PLANNER','ACCOUNT_DISCOVERY','BACKFILL',NULL,NULL,?,0)",MIDNIGHT.minusDays(10));
|
||||
assertEquals(List.of(2L,3L,1L),due(MIDNIGHT));
|
||||
}
|
||||
@Test void retryAfterBeijingMidnightWaitsUntilBackoffThenRemainsDispatchable() throws Exception {
|
||||
// Failure at 23:59:55 + the first 10-second backoff lands on the next Beijing day.
|
||||
inventory(1,"RETRY_WAIT",1,MIDNIGHT.minusHours(16),MIDNIGHT.plusSeconds(5));
|
||||
|
||||
+2
-2
@@ -20,7 +20,7 @@ class SelfopExecutionServiceTest {
|
||||
when(leases.acquireScopes(any(),anyString())).thenReturn(java.util.Map.of(3L,lease));when(taskService.begin(1L,lease)).thenReturn(run);
|
||||
var candidates=mock(OutboundCandidateBuilder.class);var publisher=mock(SelfopSalesPublisher.class);var runs=mock(SelfopSyncRunMapper.class);
|
||||
when(candidates.reusable(anyLong(),anyLong(),anyString(),anyLong())).thenReturn(null);
|
||||
var service=new SelfopExecutionService(config,taskService,leases,mock(SelfopDiscoveryService.class),mock(SelfopInventoryCollector.class),mock(InventorySnapshotService.class),mock(OutboundFactService.class),candidates,publisher,scopes,tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class),mock(SelfopReferenceRecovery.class),mock(SelfopValidationRunner.class),runs);
|
||||
var service=new SelfopExecutionService(config,taskService,leases,mock(SelfopDiscoveryService.class),mock(SelfopInventoryCollector.class),mock(InventorySnapshotService.class),mock(OutboundFactService.class),candidates,publisher,scopes,tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class),mock(SelfopReferenceRecovery.class),mock(SelfopValidationRunner.class),runs,org.mockito.Mockito.mock(SelfopMasterDataCollector.class));
|
||||
service.execute(1L);
|
||||
var order=inOrder(candidates,publisher);order.verify(candidates).build(eq(3L),eq(2L),anyString(),eq(8L));order.verify(publisher).publish(eq(3L),eq(2L),anyString(),eq(8L));
|
||||
verify(taskService,never()).waitForBudget(anyLong(),anyLong(),any(),any());verifyNoInteractions(runs);
|
||||
@@ -48,7 +48,7 @@ class SelfopExecutionServiceTest {
|
||||
when(taskService.begin(1L,lease)).thenReturn(run);doThrow(failure).when(validation).run(task,run,lease);
|
||||
var service=new SelfopExecutionService(config,taskService,leases,discovery,stock,snapshots,facts,candidates,publisher,
|
||||
mock(SelfopSyncScopeMapper.class),tasks,mock(SelfopSyncStageMapper.class),mock(SelfopOutboundOrderMapper.class),
|
||||
recovery,validation,mock(SelfopSyncRunMapper.class));
|
||||
recovery,validation,mock(SelfopSyncRunMapper.class),org.mockito.Mockito.mock(SelfopMasterDataCollector.class));
|
||||
service.execute(1L);
|
||||
verify(validation).fail(task,run,lease,code);verify(leases).release(lease);
|
||||
verify(taskService,never()).fail(anyLong(),anyLong(),anyString(),anyBoolean(),any());
|
||||
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.sync;
|
||||
|
||||
import com.baiyue.lastleg.selfoperated.analytics.client.LingxingAnalyticsClient;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.*;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import java.util.*;
|
||||
import java.util.function.Consumer;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.mockito.ArgumentMatchers.*;
|
||||
|
||||
class SelfopMasterDataCollectorTest {
|
||||
final LingxingAnalyticsClient client=mock(LingxingAnalyticsClient.class);
|
||||
final SelfopMasterDataMapper data=mock(SelfopMasterDataMapper.class);
|
||||
final SelfopSyncTaskMapper tasks=mock(SelfopSyncTaskMapper.class);
|
||||
final SelfopSyncRunMapper runs=mock(SelfopSyncRunMapper.class);
|
||||
final SelfopTaskService service=mock(SelfopTaskService.class);
|
||||
final SelfopLeaseService leases=mock(SelfopLeaseService.class);
|
||||
final SelfopAnalyticsProperties config=new SelfopAnalyticsProperties();
|
||||
final TransactionTemplate tx=mock(TransactionTemplate.class);
|
||||
final SelfopSyncTask task=new SelfopSyncTask();final SelfopSyncRun run=new SelfopSyncRun();
|
||||
final SelfopLeaseService.Lease lease=new SelfopLeaseService.Lease(1L,"owner",2L,true);
|
||||
SelfopMasterDataCollector collector;
|
||||
@BeforeEach void setup(){
|
||||
config.setEnvironment("production");task.setId(10L);task.setTaskType("MASTER_DATA");task.setExecutionPurpose("BUSINESS");task.setSourceAccount("A");task.setTaskNo("LXCOL-DEMO");
|
||||
run.setId(20L);run.setTaskId(10L);run.setExecutionPurpose("BUSINESS");run.setStatus("FETCHING");run.setAttemptNo(1);
|
||||
when(service.checkpoint(task,run,lease)).thenReturn(new SelfopTaskService.CollectionCheckpoint(task,run));
|
||||
doAnswer(i->{Consumer<TransactionStatus> c=i.getArgument(0);c.accept(mock(TransactionStatus.class));return null;}).when(tx).executeWithoutResult(any());
|
||||
when(client.customers(any(),eq(1),eq(1000))).thenReturn(new LingxingAnalyticsClient.Page<>(List.of(new SelfopPayloads.Owner("C","客户","0")),1,1000,1L,1));
|
||||
when(data.ownerCount(20L,"A")).thenReturn(1L);
|
||||
collector=new SelfopMasterDataCollector(client,data,tasks,runs,service,leases,config,tx);
|
||||
}
|
||||
SelfopPayloads.Product product(String code,String sku){return new SelfopPayloads.Product(code,sku,"商品",null,null,null,"cm",null,null,null,null);}
|
||||
@Test void publishesBothDirectoriesOnlyAfterBothCountsMatch(){
|
||||
when(client.products(any(),eq(1),eq(100))).thenReturn(new LingxingAnalyticsClient.Page<>(List.of(product("C","SKU"),product("D","SKU")),1,100,2L,1));
|
||||
when(data.productCount(20L,"A")).thenReturn(2L);
|
||||
collector.collect(task,run,lease);
|
||||
assertEquals("PUBLISHED",run.getStatus());assertEquals("SUCCEEDED",task.getStatus());assertEquals(3L,run.getUniqueObjectCount());
|
||||
verify(data,times(2)).insertProduct(anyLong(),eq(20L),eq("A"),any(),any());verify(service,never()).yield(anyLong(),anyLong(),any());
|
||||
}
|
||||
@Test void countMismatchCannotPublish(){
|
||||
when(client.products(any(),eq(1),eq(100))).thenReturn(new LingxingAnalyticsClient.Page<>(List.of(product("C","SKU")),1,100,1L,1));
|
||||
assertEquals("SELFOP_MASTER_COUNT_MISMATCH",assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease)).getMessage());
|
||||
assertNotEquals("PUBLISHED",run.getStatus());
|
||||
}
|
||||
@Test void duplicateFailsInsteadOfDeduplicatingPartialCatalog(){
|
||||
doThrow(new DuplicateKeyException("duplicate")).when(data).insertOwner(anyLong(),anyLong(),anyString(),any(),any());
|
||||
assertEquals("SELFOP_MASTER_DUPLICATE",assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease)).getMessage());
|
||||
verify(client,never()).products(any(),anyInt(),anyInt());
|
||||
}
|
||||
@Test void lostFenceCannotPersistLateResponse(){
|
||||
when(service.checkpoint(task,run,lease)).thenThrow(new IllegalStateException("FENCE_LOST"));
|
||||
assertThrows(IllegalStateException.class,()->collector.collect(task,run,lease));verifyNoInteractions(data);
|
||||
}
|
||||
@Test void yieldsAndResumesSameBatchWithoutRepeatingCustomerPage(){
|
||||
for(int page=1;page<=6;page++){
|
||||
var rows=new ArrayList<SelfopPayloads.Product>();for(int n=0;n<100;n++)rows.add(product("C","SKU-"+page+"-"+n));
|
||||
when(client.products(any(),eq(page),eq(100))).thenReturn(new LingxingAnalyticsClient.Page<>(rows,page,100,600L,6));
|
||||
}
|
||||
when(data.productCount(20L,"A")).thenReturn(600L);
|
||||
collector.collect(task,run,lease);verify(service).yield(10L,20L,lease);assertNotEquals("PUBLISHED",run.getStatus());
|
||||
collector.collect(task,run,lease);assertEquals("PUBLISHED",run.getStatus());verify(client,times(1)).customers(any(),eq(1),eq(1000));
|
||||
verify(data,times(600)).insertProduct(anyLong(),eq(20L),eq("A"),any(),any());
|
||||
}
|
||||
@Test void changedTotalRejectsBeforePersistence(){
|
||||
var manifest=new SelfopMasterDataCollector.Manifest();
|
||||
var rows=Collections.nCopies(100,product("C","SKU"));
|
||||
SelfopMasterDataCollector.validatePage(manifest,new LingxingAnalyticsClient.Page<>(rows,1,100,201L,3),100);
|
||||
manifest.setNextPage(2);
|
||||
assertEquals("SELFOP_PAGE_TOTAL_CHANGED",assertThrows(IllegalStateException.class,()->SelfopMasterDataCollector.validatePage(manifest,new LingxingAnalyticsClient.Page<>(rows,2,100,202L,3),100)).getMessage());
|
||||
}
|
||||
}
|
||||
+23
-7
@@ -16,6 +16,17 @@ import static org.assertj.core.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class SelfopSchedulePlannerTest {
|
||||
@Test void masterCatalogIsOneAccountBatchAndAnUnfinishedBatchSpansDays(){
|
||||
var state=continuousState();
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,13,4,0));
|
||||
var master=state.stream().filter(t->"MASTER_DATA".equals(t.getTaskType())).findFirst().orElseThrow();
|
||||
assertThat(SelfopCollectionPolicy.continuousBusinessRoot(master)).isTrue();assertThat(master.getWarehouseCode()).isNull();
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,14,4,0));
|
||||
assertThat(state.stream().filter(t->"MASTER_DATA".equals(t.getTaskType()))).hasSize(1);
|
||||
master.setStatus("SUCCEEDED");planner.planContinuous(LocalDateTime.of(2026,10,14,4,1));
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,14,4,2));
|
||||
assertThat(state.stream().filter(t->"MASTER_DATA".equals(t.getTaskType()))).hasSize(2);
|
||||
}
|
||||
@Test void continuousInventoryWaitsForTodaysSlotAndNeverBackfillsPreviousDays(){
|
||||
var state=continuousState();
|
||||
var manual7=dayRoot("RUNNING",7,"manual:7");var manual6=dayRoot("READY",6,"manual:6");
|
||||
@@ -50,15 +61,15 @@ class SelfopSchedulePlannerTest {
|
||||
@Test void continuousPoolPersistsCompleteDaysNewestFirstAndNeverChangesCoverageAnchor(){
|
||||
var state=continuousState();
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,13,4,0));
|
||||
assertThat(state).hasSize(62);
|
||||
assertThat(state).hasSize(63);
|
||||
assertThat(state.get(0).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,11,16,0));
|
||||
assertThat(state.get(60).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,8,12,16,0));
|
||||
assertThat(state).allMatch(SelfopCollectionPolicy::continuousBusinessRoot);
|
||||
verify(scopes,never()).updateById(any(SelfopSyncScope.class));verify(taskService,never()).initialize();
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(62);
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,13,16,4));assertThat(state).hasSize(62);
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,13,16,5));assertThat(state).hasSize(63);
|
||||
assertThat(state.get(62).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,12,16,0));
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(63);
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,13,16,4));assertThat(state).hasSize(63);
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,13,16,5));assertThat(state).hasSize(64);
|
||||
assertThat(state.get(63).getQueryFromUtc()).isEqualTo(LocalDateTime.of(2026,10,12,16,0));
|
||||
assertThat(state).anyMatch(t->LocalDateTime.of(2026,8,12,16,0).equals(t.getQueryFromUtc()));
|
||||
}
|
||||
@Test void deadOldManualRootDoesNotMaskSuccessfulOrActiveDayAndContinuousFailureIsNotRecreated(){
|
||||
@@ -67,7 +78,7 @@ class SelfopSchedulePlannerTest {
|
||||
state.add(dayRoot("RUNNING",11,"manual:running"));
|
||||
state.add(dayRoot("DEAD",10,"manual:failed-only"));
|
||||
planner.planContinuous(LocalDateTime.of(2026,10,13,4,0));
|
||||
assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).hasSize(60);
|
||||
assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).hasSize(61);
|
||||
assertThat(state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot)).noneMatch(t->LocalDateTime.of(2026,10,11,16,0).equals(t.getQueryFromUtc()));
|
||||
var root=state.stream().filter(SelfopCollectionPolicy::continuousBusinessRoot).findFirst().orElseThrow();root.setStatus("DEAD");
|
||||
int count=state.size();planner.planContinuous(LocalDateTime.of(2026,10,13,4,1));assertThat(state).hasSize(count);
|
||||
@@ -93,7 +104,12 @@ class SelfopSchedulePlannerTest {
|
||||
return query.getParamNameValuePairs().containsValue("INVENTORY")?List.of(stock):List.of(scope);});
|
||||
var account=new SelfopOutboundDiscoveryScope();account.setId(30L);when(taskService.initializeAccount()).thenReturn(account);
|
||||
var state=new ArrayList<SelfopSyncTask>();when(tasks.selectList(any())).thenAnswer(call->new ArrayList<>(state));
|
||||
when(taskService.create(any())).thenAnswer(call->{SelfopSyncTask task=call.getArgument(0);state.add(task);return task;});return state;
|
||||
when(tasks.selectCount(any())).thenAnswer(call->{
|
||||
com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<?> query=call.getArgument(0);query.getSqlSegment();
|
||||
var values=query.getParamNameValuePairs().values();
|
||||
return state.stream().filter(t->"MASTER_DATA".equals(t.getTaskType())).filter(t->values.contains("MASTER_DATA")?List.of("READY","RUNNING","RETRY_WAIT").contains(t.getStatus()):values.contains(t.getRequestKey())).count();
|
||||
});
|
||||
when(taskService.create(any())).thenAnswer(call->{SelfopSyncTask task=call.getArgument(0);task.setStatus("READY");state.add(task);return task;});return state;
|
||||
}
|
||||
private SelfopSyncTask dayRoot(String status,int day,String key){
|
||||
var root=controlled(100L+day,"ACCOUNT_DISCOVERY","BACKFILL",key);root.setDiscoveryScopeId(30L);root.setScopeHash("a".repeat(64));root.setStatus(status);
|
||||
|
||||
Reference in New Issue
Block a user