perf(selfop): build versioned warehouse daily sales summaries

This commit is contained in:
2026-10-13 09:40:55 +08:00
parent ca4e7086e9
commit 25db631ff7
24 changed files with 320 additions and 31 deletions
@@ -24,10 +24,10 @@ class SelfoperatedAnalyticsSchemaContractTest {
}
@Test
void sourceAndEmptyDatabaseBaselineContainIdenticalTenIndependentTables() throws Exception {
void sourceAndEmptyDatabaseBaselineContainIdenticalElevenIndependentTables() throws Exception {
var source = tables(Files.readString(Path.of("src/main/resources/db/lastleg.sql")));
var deployment = tables(Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")));
assertEquals(10, source.size());
assertEquals(11, source.size());
assertEquals(source, deployment);
for (var entry : source.entrySet()) {
var ddl = entry.getValue();
@@ -13,7 +13,7 @@ import static org.junit.jupiter.api.Assertions.*;
/** Actual mapper SQL against an offline synthetic database, not a mocked aggregate. */
public class OutboundAggregateMapperTest {
JdbcDataSource data; SqlSession session; OutboundAggregateMapper mapper;
JdbcDataSource data; SqlSessionFactory factory; SqlSession session; OutboundAggregateMapper mapper;
@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");
sql("CREATE TABLE ll_selfop_outbound_order (id BIGINT PRIMARY KEY,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sales_eligible INT,outbound_at_utc TIMESTAMP,business_date DATE,source_timezone VARCHAR(64),business_timezone VARCHAR(64),last_observation_sequence BIGINT)");
@@ -26,9 +26,10 @@ public class OutboundAggregateMapperTest {
sql("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.selfoperated.analytics.outbound.OutboundAggregateMapperTest.jsonUnquote'");
sql("CREATE TABLE ll_selfop_sku_daily_sales (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,sales_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))");
sql("CREATE TABLE ll_selfop_sku_profile (id BIGINT PRIMARY KEY,created_at TIMESTAMP,updated_at TIMESTAMP,profile_run_id BIGINT,source_account VARCHAR(64),customer_code VARCHAR(64),warehouse_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),first_seen_at_utc TIMESTAMP,earliest_evidence_at_utc TIMESTAMP,evidence_type VARCHAR(24),evidence_run_id BIGINT,last_seen_run_id BIGINT)");
sql("CREATE TABLE ll_selfop_warehouse_daily_sales (sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),business_date DATE,observed_quantity DECIMAL(38,6),invalid_row_count BIGINT NOT NULL,source_row_count BIGINT NOT NULL,created_at TIMESTAMP,updated_at TIMESTAMP,PRIMARY KEY(sales_run_id,source_account,warehouse_code,business_date))");
Configuration config=new Configuration(new Environment("offline",new JdbcTransactionFactory(),data));
config.setMapUnderscoreToCamelCase(true); config.setLocalCacheScope(LocalCacheScope.STATEMENT); config.addMapper(OutboundAggregateMapper.class);
session=new SqlSessionFactoryBuilder().build(config).openSession(true); mapper=session.getMapper(OutboundAggregateMapper.class);
factory=new SqlSessionFactoryBuilder().build(config);session=factory.openSession(true); mapper=session.getMapper(OutboundAggregateMapper.class);
}
@AfterEach void close() { if(session!=null) session.close(); }
void sql(String query) throws Exception {try(Connection c=data.getConnection();Statement s=c.createStatement()){s.execute(query);}}
@@ -39,6 +40,47 @@ public class OutboundAggregateMapperTest {
sql("INSERT INTO ll_selfop_outbound_order VALUES ("+id+",'"+account+"','"+customer+"','"+warehouse+"',"+eligible+",'"+time+"','2026-10-01','Asia/Shanghai','Asia/Shanghai',"+id+")");
sql("INSERT INTO ll_selfop_outbound_item VALUES ("+id+","+id+",'"+sku+"','item','PCS',"+quantity+")");
}
@Test void warehouseDailyAddsMultipleBatchesAndPreservesUnknownsAndFractionalQuantities() throws Exception {
var invalid=List.of(dailyRow(101,null,"PCS"),dailyRow(102,"-1","PCS"),dailyRow(103,"7","BOX"),dailyRow(104,"9",null));
mapper.insertDaily(invalid);mapper.insertWarehouseDaily(invalid);
assertWarehouse(null,4,4);
var first=List.of(dailyRow(105,"0.4","PCS"));mapper.insertDaily(first);mapper.insertWarehouseDaily(first);
var second=List.of(dailyRow(106,"0.4","PCS"));mapper.insertDaily(second);mapper.insertWarehouseDaily(second);
assertWarehouse("0.8",4,6);
assertEquals(6,mapper.warehouseDailySourceRows(900L,"demo","A"));assertEquals(0,mapper.warehouseDailySourceRows(900L,"demo","B"));
var moreInvalid=List.of(dailyRow(107,null,"PCS"));mapper.insertDaily(moreInvalid);mapper.insertWarehouseDaily(moreInvalid);
assertWarehouse("0.8",5,7);
}
@Test void nullOnlyBatchesRemainUnknownUntilValidZeroAppears() throws Exception {
for(long id:List.of(301L,302L)){var rows=List.of(dailyRow(id,null,"PCS"));mapper.insertDaily(rows);mapper.insertWarehouseDaily(rows);}
assertWarehouse(null,2,2);
var zero=List.of(dailyRow(303,"0","PCS"));mapper.insertDaily(zero);mapper.insertWarehouseDaily(zero);
assertWarehouse("0",2,3);
var invalid=List.of(dailyRow(304,"-1","PCS"));mapper.insertDaily(invalid);mapper.insertWarehouseDaily(invalid);
assertWarehouse("0",3,4);
}
@Test void failedBatchRollsBackBothTablesAndRetryCountsRowsOnce() throws Exception {
session.close();session=factory.openSession(false);mapper=session.getMapper(OutboundAggregateMapper.class);
var rows=List.of(dailyRow(201,"2.125","PCS"),dailyRow(202,null,"PCS"));
mapper.insertDaily(rows);mapper.insertWarehouseDaily(rows);session.rollback();
assertEquals(0,mapper.warehouseDailySourceRows(900L,"demo","A"));
mapper.insertDaily(rows);mapper.insertWarehouseDaily(rows);session.commit();
assertWarehouse("2.125",1,2);
assertThrows(RuntimeException.class,()->mapper.insertDaily(rows));session.rollback();
assertWarehouse("2.125",1,2);
}
private SelfopSkuDailySales dailyRow(long id,String quantity,String unit) {
var row=new SelfopSkuDailySales();row.setId(id);row.setSalesRunId(900L);row.setSourceAccount("demo");row.setWarehouseCode("A");
row.setCustomerCode("customer");row.setSkuCode("SKU-"+id);row.setBusinessDate(LocalDate.of(2026,10,1));
row.setOutboundQuantity(quantity==null?null:new java.math.BigDecimal(quantity));row.setQuantityUnit(unit);row.setOutboundOrderCount(1L);
row.setCreatedAt(LocalDateTime.of(2026,10,13,0,0));row.setUpdatedAt(row.getCreatedAt());return row;
}
private void assertWarehouse(String quantity,long invalid,long source) throws Exception {
try(var c=data.getConnection();var statement=c.createStatement();var result=statement.executeQuery("SELECT observed_quantity,invalid_row_count,source_row_count FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=900 AND source_account='demo' AND warehouse_code='A'")) {
assertTrue(result.next());if(quantity==null)assertNull(result.getBigDecimal(1));else assertEquals(0,new java.math.BigDecimal(quantity).compareTo(result.getBigDecimal(1)));
assertEquals(invalid,result.getLong(2));assertEquals(source,result.getLong(3));assertFalse(result.next());
}
}
@Test void aggregatesDistinctOwnersAndRetractsCancelledMigratedAndCorrectedEvidence() throws Exception {
order(1,"demo","C1","A","SKU",1,"2026-10-01 00:00:00",2);
order(2,"demo","C1","A","SKU",1,"2026-10-02 00:00:00",3);
@@ -113,6 +113,10 @@ class OutboundCandidateBuildMySqlAcceptanceTest {
var cp=read(json(progress.get("candidateBuild")),OutboundCandidateTransaction.Checkpoint.class);
assertEquals("DONE",cp.phase());assertEquals((long)skus*days,cp.dailyRows());assertEquals(skus,cp.profileRows());
assertEquals(cp.dailyRows(),count("ll_selfop_sku_daily_sales","sales_run_id",runId));
assertEquals(Boolean.TRUE,saved.getWarehouseDailyReady());
assertEquals(days,count("ll_selfop_warehouse_daily_sales","sales_run_id",runId));
assertEquals(cp.dailyRows(),jdbc.queryForObject("SELECT COALESCE(SUM(source_row_count),0) FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=?",Long.class,runId));
assertEquals(0L,jdbc.queryForObject("SELECT COALESCE(SUM(invalid_row_count),0) FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=?",Long.class,runId));
assertEquals(cp.profileRows(),count("ll_selfop_sku_profile","profile_run_id",runId));
assertEquals((skus*(long)days+499)/500+(skus+499)/500,cp.committedBatches());
assertEquals(pointerBefore,jdbc.queryForMap("SELECT published_run_id,published_fact_revision FROM ll_selfop_sync_scope WHERE id=?",scopeId));
@@ -120,6 +124,7 @@ class OutboundCandidateBuildMySqlAcceptanceTest {
assertTrue(transactionEvidence.inserts>0);assertFalse(transactionEvidence.nonTransactional);
BigDecimal quantity=jdbc.queryForObject("SELECT SUM(outbound_quantity) FROM ll_selfop_sku_daily_sales WHERE sales_run_id=?",BigDecimal.class,runId);
assertEquals(0,BigDecimal.valueOf((long)skus*days).compareTo(quantity));
assertEquals(0,quantity.compareTo(jdbc.queryForObject("SELECT SUM(observed_quantity) FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=?",BigDecimal.class,runId)));
report.put("dailyRows",cp.dailyRows());report.put("profileRows",cp.profileRows());report.put("committedBatches",cp.committedBatches());
report.put("aggregateInsertStatements",transactionEvidence.inserts);report.put("allAggregateWritesTransactional",true);
report.put("publishedPointerUnchanged",true);report.put("publishedPointerInitiallyNull",true);report.put("candidateStatus",saved.getStatus());report.put("success",true);
@@ -65,7 +65,8 @@ class OutboundCandidateBuilderTest {
builder.build(1L,10L,"worker",2);
verify(profiles).delete(any()); verify(aggregates,never()).insertProfiles(any());
verify(aggregates,never()).insertDaily(any()); verify(facts,atLeastOnce()).assertNoBarrier(scope);
assertEquals(5L,run.getCalculatedFactRevision()); assertEquals("READY",run.getStatus());
assertEquals(5L,run.getCalculatedFactRevision()); assertEquals("READY",run.getStatus());assertEquals(Boolean.TRUE,run.getWarehouseDailyReady());
verify(aggregates).warehouseDailySourceRows(10L,"demo","A");
assertNotNull(run.getBuildStartedAtUtc()); assertEquals(LocalDate.of(2026,9,13),run.getCompleteDayFrom());
assertEquals(LocalDate.of(2026,10,12),run.getCompleteDayUntil()); assertNull(scope.getPublishedRunId());
}
@@ -76,16 +77,17 @@ class OutboundCandidateBuilderTest {
when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(rows);
when(aggregates.dailyBatch("demo","A","c","499",tail.getBusinessDate())).thenReturn(List.of(tail));
when(aggregates.dailyBatch("demo","A","c","500",tail.getBusinessDate())).thenReturn(List.of());
when(daily.selectCount(any())).thenReturn(501L);
when(daily.selectCount(any())).thenReturn(501L);when(aggregates.warehouseDailySourceRows(10L,"demo","A")).thenReturn(501L);
builder.build(1L,10L,"worker",2);
verify(aggregates).insertDaily(rows); verify(aggregates).insertDaily(List.of(tail));
verify(aggregates).insertWarehouseDaily(rows);verify(aggregates).insertWarehouseDaily(List.of(tail));
verify(daily,never()).insert(any(SelfopSkuDailySales.class));
assertTrue(rows.stream().allMatch(r->r.getSalesRunId().equals(10L) && r.getId()!=null));
}
@Test void unchangedFactsButAdvancedCoverageCannotReuseZeroSales() {
scope.setPublishedRunId(9L); scope.setPublishedFactRevision(5L);
SelfopSyncRun published=new SelfopSyncRun(); published.setId(9L); published.setScopeId(1L); published.setScopeHash("scope");
published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L);
published.setWarehouseDailyReady(true);published.setStatus("PUBLISHED"); published.setExecutionPurpose("BUSINESS"); published.setCalculatedFactRevision(5L);
published.setBuildStartedAtUtc(SelfopSupport.utcNow().minusHours(1));
published.setScopeSnapshotJson(scope.getScopeJson()); published.setCoveredFromUtc(scope.getInitialFromUtc());
published.setCoveredUntilUtc(task.getQueryUntilUtc().minusDays(1)); when(runs.selectById(9L)).thenReturn(published);
@@ -135,4 +137,23 @@ class OutboundCandidateBuilderTest {
task.setTaskType("REBUILD"); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now));
task.setTaskType("FIRST_COLLECTION"); task.setAttemptCount(2); assertTrue(OutboundCandidateBuilder.regularBuildDue(task,now,now));
}
@Test void missingWarehouseSummaryPreventsReadyAndOldReadyCannotBypassBuild() {
transaction.begin(1L,10L,"worker",2);
var row=new SelfopSkuDailySales();row.setCustomerCode("c");row.setSkuCode("sku");row.setBusinessDate(LocalDate.of(2026,10,1));
when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(List.of(row));transaction.batch(1L,10L,"worker",2);
transaction.batch(1L,10L,"worker",2);transaction.batch(1L,10L,"worker",2);
when(daily.selectCount(any())).thenReturn(1L);
assertEquals("WAREHOUSE_DAILY_ROWS_INCOMPLETE",assertThrows(IllegalStateException.class,()->transaction.finish(1L,10L,"worker",2)).getMessage());
assertEquals("BUILDING",run.getStatus());assertEquals(Boolean.FALSE,run.getWarehouseDailyReady());
run.setStatus("READY");assertEquals("WAREHOUSE_DAILY_NOT_READY",assertThrows(IllegalStateException.class,()->transaction.begin(1L,10L,"worker",2)).getMessage());
}
@Test void warehouseBatchFailureCannotAdvanceCheckpointAndRetryUsesSameCursor() {
transaction.begin(1L,10L,"worker",2);String before=task.getProgressJson();
var row=new SelfopSkuDailySales();row.setCustomerCode("c");row.setSkuCode("sku");row.setBusinessDate(LocalDate.of(2026,10,1));
when(aggregates.dailyBatch("demo","A",null,null,null)).thenReturn(List.of(row));
when(aggregates.insertWarehouseDaily(any())).thenThrow(new IllegalStateException("SYNTHETIC_WRITE_FAILURE")).thenReturn(1);
assertThrows(IllegalStateException.class,()->transaction.batch(1L,10L,"worker",2));assertEquals(before,task.getProgressJson());
var resumed=transaction.batch(1L,10L,"worker",2);assertEquals(1,resumed.dailyRows());assertEquals(1,resumed.committedBatches());
}
}
@@ -122,7 +122,7 @@ class AnalyticsQueryServiceTest {
@Test void missingOrReversedContinuousCoverageNeverAuthorizesFormalThirtyDayZeros(){
Run r=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai");
for(LocalDateTime from:Arrays.asList(null,r.coveredUntilUtc().plusDays(1))){
when(mapper.publishedRun(11)).thenReturn(new Run(r.id(),r.taskId(),r.scopeId(),r.sourceAccount(),r.warehouseCode(),r.runRole(),r.scopeHash(),r.scopeSnapshotJson(),r.status(),r.coverageStatus(),from,r.coveredUntilUtc(),r.completeDayFrom(),r.completeDayUntil(),r.fetchStartedAtUtc(),r.fetchFinishedAtUtc(),r.publishedAtUtc(),r.calculatedFactRevision(),r.errorCode(),r.errorMessage(),r.supersededAtUtc(),r.executionPurpose()));
when(mapper.publishedRun(11)).thenReturn(new Run(r.id(),r.taskId(),r.scopeId(),r.sourceAccount(),r.warehouseCode(),r.runRole(),r.scopeHash(),r.scopeSnapshotJson(),r.status(),r.coverageStatus(),from,r.coveredUntilUtc(),r.completeDayFrom(),r.completeDayUntil(),r.fetchStartedAtUtc(),r.fetchFinishedAtUtc(),r.publishedAtUtc(),r.calculatedFactRevision(),r.errorCode(),r.errorMessage(),r.supersededAtUtc(),r.executionPurpose(),r.warehouseDailyReady()));
var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).outboundQuantity30d());
}
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper,times(2)).inventoryPage(cap.capture(),any());
@@ -281,7 +281,7 @@ class AnalyticsQueryServiceTest {
private static Run run(long id,long scope,String hash,Instant observed,Instant superseded,String status,String purpose,String source){
String config="{\"businessTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600,\"sourceTimezone\":"+(source==null?"null":"\""+source+"\"")+"}";
return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash,config,status,"COMPLETE",time(D.minusDays(40)),utc(observed),D.minusDays(30),D,
utc(observed.minusSeconds(60)),utc(observed),utc(NOW.minusSeconds(3600)),1L,null,null,superseded==null?null:utc(superseded),purpose);
utc(observed.minusSeconds(60)),utc(observed),utc(NOW.minusSeconds(3600)),1L,null,null,superseded==null?null:utc(superseded),purpose,true);
}
private static Profile profile(String unit,String type,LocalDateTime evidence){return new Profile("C","SKU","Product",unit,evidence,evidence,type,1L,1L);}
private static Aggregate aggregate(boolean unit,String available,String quality,LocalDateTime evidence){return new Aggregate("C","SKU","Product","PCS",evidence,unit,n("5"),2L,2,1,0,n("150"),10L,n("150"),n("3"),n("120"),n(available),n("20"),n("30"),utc(NOW.minusSeconds(3600)),quality,null);}
@@ -18,7 +18,7 @@ class DashboardQueryServiceTest {
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);
var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
var snapshot=new AnalyticsQueryService.Snapshot(q,null,null,true,true,day.minusDays(30),day,range,context);
var snapshot=new AnalyticsQueryService.Snapshot(q,salesRun(true),null,true,true,day.minusDays(30),day,range,context);
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD");
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p);
@@ -35,4 +35,33 @@ class DashboardQueryServiceTest {
var json=new ObjectMapper().registerModule(new JavaTimeModule()).valueToTree(result);assertTrue(json.path("context").has("salesRunId"));assertFalse(json.path("context").has("versionContext"));
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);
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);
var range=SalesRangePolicy.resolve("TODAY",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
for(Boolean ready:Arrays.asList(false,null)){
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(new AnalyticsQueryService.Snapshot(q,salesRun(ready),null,true,true,day.minusDays(30),day,range,context));
var error=assertThrows(com.baiyue.common.core.exception.BizException.class,()->service.dashboard(q));
assertEquals("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询",error.getMessage());
}
verifyNoInteractions(mapper);
when(mapper.daily(any(),any(),any())).thenReturn(List.of());
when(mapper.report(any(),any())).thenReturn(List.of(Map.of("total_skus",0L,"hot_eligible",0L,"hot_unknown",0L)));
for(boolean salesPresent:List.of(true,false)){
var snapshot=new AnalyticsQueryService.Snapshot(q,salesPresent?salesRun(true):null,null,salesPresent,salesPresent,day.minusDays(30),day,range,context);
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day,day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,
day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD"));
var result=service.dashboard(q);
if(salesPresent){assertEquals(0,result.kpis().averageDailyOutboundQuantity30d().value().signum());assertEquals("COMPLETE",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
else {assertNull(result.kpis().averageDailyOutboundQuantity30d().value());assertEquals("UNKNOWN",result.kpis().averageDailyOutboundQuantity30d().dataStatus());}
}
}
private static AnalyticsReadRows.Run salesRun(Boolean ready){
LocalDate day=DashboardPolicyTest.DAY;LocalDateTime now=LocalDateTime.ofInstant(DashboardPolicyTest.NOW,ZoneOffset.UTC);
return new AnalyticsReadRows.Run(11L,101L,1L,"demo","W","WAREHOUSE_SYNC","a".repeat(64),"{}","PUBLISHED","COMPLETE",
day.minusDays(30).atStartOfDay().minusHours(8),now,day.minusDays(30),day,now.minusMinutes(2),now.minusMinutes(1),now,1L,null,null,null,"BUSINESS",ready);
}
}
@@ -21,20 +21,22 @@ class DashboardSqlSemanticTest {
s.execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'");
s.execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_name VARCHAR(255),quantity_unit VARCHAR(32),evidence_type VARCHAR(24),evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
s.execute("CREATE TABLE ll_selfop_sku_daily_sales(id BIGINT,sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),business_date DATE,outbound_quantity DECIMAL(20,6),outbound_order_count BIGINT,quantity_unit VARCHAR(32))");
s.execute("CREATE TABLE ll_selfop_warehouse_daily_sales(sales_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),business_date DATE,observed_quantity DECIMAL(38,6),invalid_row_count BIGINT,source_row_count BIGINT,PRIMARY KEY(sales_run_id,source_account,warehouse_code,business_date))");
s.execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),customer_code VARCHAR(64),sku_code VARCHAR(128),product_quality INTEGER,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),observed_at_utc TIMESTAMP,quality_status VARCHAR(16),quality_reason VARCHAR(64))");
}
config=new Configuration(new Environment("synthetic",new JdbcTransactionFactory(),ds));config.setMapUnderscoreToCamelCase(true);config.addMapper(DashboardReadMapper.class);config.addMapper(AnalyticsReadMapper.class);
session=new SqlSessionFactoryBuilder().build(config).openSession();
}
@AfterEach void close()throws Exception{if(session!=null)session.close();if(connection!=null)connection.close();}
@Test void dailyBoundSqlPinsDateCoverWithoutChangingChartOrFixedWindowBindings(){
@Test void dailyBoundSqlUsesOnlyPinnedWarehouseSummaryWithChartAndFixedWindowBindings(){
var statement=config.getMappedStatement(DashboardReadMapper.class.getName()+".daily");
for(int days:List.of(1,30,366)){
var bound=statement.getBoundSql(Map.of("p",request(),"chartFrom",D.minusDays(days-1),"chartUntil",D.plusDays(1)));
assertTrue(bound.getSql().startsWith("SELECT /*+ INDEX(ll_selfop_sku_daily_sales idx_selfop_daily_window_cover) */"));
assertTrue(bound.getSql().startsWith("SELECT business_date,observed_quantity,invalid_row_count FROM ll_selfop_warehouse_daily_sales"));
assertFalse(bound.getSql().contains("ll_selfop_sku_daily_sales"));assertFalse(bound.getSql().contains("GROUP BY"));
assertEquals(List.of("p.salesRun","p.account","p.warehouse","chartFrom","chartUntil","p.from","p.until"),
bound.getParameterMappings().stream().map(ParameterMapping::getProperty).toList());
assertTrue(bound.getSql().endsWith("GROUP BY business_date ORDER BY business_date"));
assertTrue(bound.getSql().endsWith("ORDER BY business_date"));
}
}
@Test void combinedReportKeepsStatisticsWhenAllThreeRankingsAreEmpty()throws Exception{
@@ -52,6 +54,8 @@ class DashboardSqlSemanticTest {
daily("A","SAME",D.minusDays(1),"7","PCS");daily("B","SAME",D.minusDays(1),"3","BOX");
profile(22,"C","STOCKONLY","PCS");stock("STOCKONLY","4","4");
daily("C","SOLD",D.minusDays(2),"-1","PCS");
warehouseDaily(11,"demo","W",D.minusDays(1),"37",1,3);
warehouseDaily(11,"demo","W",D.minusDays(2),null,1,1);
for(boolean complete:List.of(false,true))for(boolean fresh:List.of(false,true)){
var p=request(complete,fresh);var args=Map.<String,Object>of("p",p,"o",options());
String sql=DashboardReadSql.report(args);
@@ -114,14 +118,73 @@ class DashboardSqlSemanticTest {
}
@Test void warehouseDailyIncludesBothOwnersWithoutSkuObservationAndNoQualityMultiplication()throws Exception{
profile(11,"A","SAME","PCS");profile(11,"B","SAME","PCS");daily("A","SAME",D.minusDays(1),"2","PCS");daily("B","SAME",D.minusDays(1),"5","PCS");
warehouseDaily(11,"demo","W",D.minusDays(1),"7",0,2);
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(2),"chartUntil",D.plusDays(1)));
assertEquals(1,rows.size());var row=(DashboardReadMapper.Day)rows.get(0);assertEquals(0,new BigDecimal("7").compareTo(row.observedQuantity()));assertEquals(0L,row.invalidRowCount());
}
@Test void warehouseSummaryPinsAccountWarehouseVersionAndUnionOfBothHalfOpenWindows()throws Exception{
warehouseDaily(11,"demo","W",D.minusDays(31),"1",0,1);
warehouseDaily(11,"demo","W",D.minusDays(30),"2",0,1);
warehouseDaily(11,"demo","W",D.minusDays(1),"3",0,1);
warehouseDaily(11,"demo","W",D,"4",0,1);
warehouseDaily(11,"demo","W",D.plusDays(1),"999",0,1);
warehouseDaily(11,"other","W",D.minusDays(1),"999",0,1);
warehouseDaily(11,"demo","OTHER",D.minusDays(1),"999",0,1);
warehouseDaily(99,"demo","W",D.minusDays(1),"999",0,1);
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D,"chartUntil",D.plusDays(1)))
.stream().map(DashboardReadMapper.Day.class::cast).toList();
assertEquals(List.of(D.minusDays(30),D.minusDays(1),D),rows.stream().map(DashboardReadMapper.Day::businessDate).toList());
assertEquals(List.of("2","3","4"),rows.stream().map(row->row.observedQuantity().stripTrailingZeros().toPlainString()).toList());
daily("C","SKU-ONLY",D.minusDays(2),"123","PCS");
assertEquals(rows,query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D,"chartUntil",D.plusDays(1))),"Missing summary rows must never fall back to SKU facts");
}
@Test void warehouseSummaryPreservesBadRowEvidenceAndCannotProveCompleteDays()throws Exception{
daily("A","VALID",D.minusDays(3),"7","PCS");daily("B","BOX",D.minusDays(3),"3","BOX");daily("C","NEGATIVE",D.minusDays(3),"-1","PCS");
daily("A","NO-QUANTITY",D.minusDays(2),null,"PCS");daily("B","NO-UNIT",D.minusDays(2),"5",null);
daily("A","TODAY",D,"4","PCS");
warehouseDaily(11,"demo","W",D.minusDays(3),"7",2,3);
warehouseDaily(11,"demo","W",D.minusDays(2),null,2,2);
warehouseDaily(11,"demo","W",D,"4",0,1);
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(3),"chartUntil",D.plusDays(1)))
.stream().map(DashboardReadMapper.Day.class::cast).toList();
assertEquals(originalDailyOracle(D.minusDays(3),D.plusDays(1)),rows,"Published summaries must retain the former SKU aggregation's valid subtotal and invalid count");
var covered=DashboardPolicyTest.context(D.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),DashboardPolicyTest.NOW,true);
var points=DashboardSalesPolicy.points(covered,true,D.minusDays(3),D.plusDays(1),rows);
assertEquals("PARTIAL",points.get(0).dataStatus());assertEquals(0,new BigDecimal("7").compareTo(points.get(0).observedQuantity()));assertNull(points.get(0).outboundQuantity());
assertTrue(points.get(0).reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID"));
assertEquals("UNKNOWN",points.get(1).dataStatus());assertNull(points.get(1).observedQuantity());
assertEquals("COMPLETE",points.get(2).dataStatus());assertEquals(BigDecimal.ZERO,points.get(2).outboundQuantity());
assertEquals("PARTIAL",points.get(3).dataStatus());assertEquals(0,new BigDecimal("4").compareTo(points.get(3).observedQuantity()));assertNull(points.get(3).outboundQuantity());
var unproved=DashboardSalesPolicy.points(DashboardPolicyTest.context(null,null,true),true,D.minusDays(3),D.plusDays(1),rows);
assertTrue(unproved.stream().allMatch(point->point.outboundQuantity()==null));
assertNull(unproved.get(2).observedQuantity());assertEquals("UNKNOWN",unproved.get(2).dataStatus());
}
@Test void warehouseSummaryReadsFractionalContributionsWithoutRounding(){
assertDoesNotThrow(()->{
daily("A","BATCH-1",D.minusDays(1),"0.4","PCS");daily("B","BATCH-2",D.minusDays(1),"0.4","PCS");
warehouseDaily(11,"demo","W",D.minusDays(1),"0.8",0,2);
var rows=query(DashboardReadMapper.class,"daily",Map.of("p",request(),"chartFrom",D.minusDays(1),"chartUntil",D));
assertEquals(originalDailyOracle(D.minusDays(1),D),rows);
assertEquals(0,new BigDecimal("0.8").compareTo(((DashboardReadMapper.Day)rows.get(0)).observedQuantity()));
});
}
/** Independent pre-summary query, retained only as a fixture oracle, never a runtime fallback. */
private List<DashboardReadMapper.Day> originalDailyOracle(LocalDate from,LocalDate until)throws Exception{
var rows=new ArrayList<DashboardReadMapper.Day>();
try(var p=connection.prepareStatement("SELECT business_date,SUM(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN outbound_quantity END) observed_quantity,COUNT(CASE WHEN quantity_unit IS NULL OR quantity_unit<>'PCS' OR outbound_quantity IS NULL OR outbound_quantity<0 THEN 1 END) invalid_row_count FROM ll_selfop_sku_daily_sales WHERE sales_run_id=11 AND source_account='demo' AND warehouse_code='W' AND business_date>=? AND business_date<? GROUP BY business_date ORDER BY business_date")){
p.setObject(1,from);p.setObject(2,until);try(var r=p.executeQuery()){while(r.next())rows.add(new DashboardReadMapper.Day(r.getObject(1,LocalDate.class),r.getBigDecimal(2),r.getLong(3)));}
}return rows;
}
private void warehouseDaily(long run,String account,String warehouse,LocalDate day,String observed,long invalid,long sourceRows)throws Exception{
try(var p=connection.prepareStatement("INSERT INTO ll_selfop_warehouse_daily_sales VALUES(?,?,?,?,?,?,?)")){
p.setLong(1,run);p.setString(2,account);p.setString(3,warehouse);p.setObject(4,day);p.setBigDecimal(5,observed==null?null:new BigDecimal(observed));p.setLong(6,invalid);p.setLong(7,sourceRows);p.executeUpdate();
}
}
private void fixture(String sku,String physical,String available,String sold)throws Exception{profile(11,"C",sku,"PCS");profile(22,"C",sku,"PCS");stock(sku,physical,available);if(sold!=null)daily("C",sku,D.minusDays(1),sold,"PCS");}
private void profile(long run,String owner,String sku,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_profile VALUES(?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,run);p.setString(2,"demo");p.setString(3,"W");p.setString(4,owner);p.setString(5,sku);p.setString(6,sku);p.setString(7,unit);p.setString(8,run==11?"VALID_OUTBOUND":"INVENTORY_OBSERVATION");p.setLong(9,run);p.setObject(10,D.minusDays(40).atStartOfDay().minusHours(8));p.executeUpdate();}}
private void daily(String owner,String sku,LocalDate day,String value,String unit)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_sku_daily_sales VALUES(?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,++sequence);p.setLong(2,11);p.setString(3,"demo");p.setString(4,"W");p.setString(5,owner);p.setString(6,sku);p.setObject(7,day);p.setBigDecimal(8,new BigDecimal(value));p.setLong(9,1);p.setString(10,unit);p.executeUpdate();}}
p.setLong(1,++sequence);p.setLong(2,11);p.setString(3,"demo");p.setString(4,"W");p.setString(5,owner);p.setString(6,sku);p.setObject(7,day);p.setBigDecimal(8,value==null?null:new BigDecimal(value));p.setLong(9,1);p.setString(10,unit);p.executeUpdate();}}
private void stock(String sku,String physical,String available)throws Exception{try(var p=connection.prepareStatement("INSERT INTO ll_selfop_inventory_snapshot VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)")){
p.setLong(1,22);p.setString(2,"demo");p.setString(3,"W");p.setString(4,"C");p.setString(5,sku);p.setInt(6,0);p.setBigDecimal(7,physical==null?null:new BigDecimal(physical));p.setBigDecimal(8,available==null?null:new BigDecimal(available));p.setBigDecimal(9,BigDecimal.ZERO);p.setBigDecimal(10,BigDecimal.ZERO);p.setObject(11,D.atStartOfDay());p.setString(12,physical==null?"UNKNOWN":"VALID");p.setString(13,physical==null?"FIELD_MISSING":null);p.executeUpdate();}}
private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");}
@@ -26,10 +26,12 @@ class SalesRetentionServiceTest {
old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(6));assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
clearInvocations(mapper);
old.setSupersededAtUtc(SelfopSupport.utcNow().minusDays(8));when(mapper.deleteDaily(10L,500)).thenReturn(500);when(mapper.deleteProfiles(10L,500)).thenReturn(8);
assertEquals(new SalesRetentionService.Deleted(10L,500,8),service.deleteBatch(1L,10L));
when(mapper.deleteWarehouseDaily(10L,500)).thenReturn(30);
assertEquals(new SalesRetentionService.Deleted(10L,500,8,30),service.deleteBatch(1L,10L));
InOrder order=inOrder(mapper);order.verify(mapper).scope(1L);order.verify(mapper).run(10L);
order.verify(mapper).previous(1L,30L);order.verify(mapper).activeReferences("demo","A",10L);
order.verify(mapper).deleteDaily(10L,500);order.verify(mapper).deleteProfiles(10L,500);
order.verify(mapper).deleteWarehouseDaily(10L,500);
// Actual deletion follows scope acquisition, current-pointer/reference checks and fixed bounded predicates.
verify(mapper).deleteDaily(10L,500);verify(mapper).deleteProfiles(10L,500);
}
@@ -40,6 +42,7 @@ class SalesRetentionServiceTest {
when(mapper.previous(1L,30L)).thenReturn(20L);when(mapper.activeReferences("demo","A",10L)).thenReturn(1L);assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
when(mapper.activeReferences("demo","A",10L)).thenReturn(0L);old.setStatus("FAILED");assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
verify(mapper,never()).deleteDaily(anyLong(),anyInt());verify(mapper,never()).deleteProfiles(anyLong(),anyInt());
verify(mapper,never()).deleteWarehouseDaily(anyLong(),anyInt());
}
@Test void pointerAndReferencesRecheckedForEveryBatch() {
config.setRetentionEnabled(true);service.deleteBatch(1L,10L);
@@ -47,6 +50,7 @@ class SalesRetentionServiceTest {
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
verify(mapper,times(2)).scope(1L);verify(mapper,times(2)).activeReferences("demo","A",10L);
verify(mapper,times(1)).deleteDaily(10L,500);
verify(mapper,times(1)).deleteWarehouseDaily(10L,500);
}
@Test void inventoryScopeAndDailyRepresentativeCannotBeCleanedAsSalesCopies() {
config.setRetentionEnabled(true);
@@ -58,5 +62,16 @@ class SalesRetentionServiceTest {
assertThrows(IllegalStateException.class,()->service.deleteBatch(1L,10L));
verify(mapper,never()).deleteDaily(anyLong(),anyInt());
verify(mapper,never()).deleteProfiles(anyLong(),anyInt());
verify(mapper,never()).deleteWarehouseDaily(anyLong(),anyInt());
}
@Test void warehouseDailyRemainderStaysEligibleAndHasItsOwnBoundedDeletionCount() {
var configuration=new org.apache.ibatis.session.Configuration();configuration.addMapper(SalesRetentionMapper.class);
var parameters=Map.<String,Object>of("scopeId",1L,"current",30L,"previous",20L,"cutoff",SelfopSupport.utcNow(),"id",10L,"batchSize",500);
String eligible=configuration.getMappedStatement(SalesRetentionMapper.class.getName()+".eligible").getBoundSql(parameters).getSql();
assertTrue(eligible.contains("OR EXISTS (SELECT 1 FROM ll_selfop_warehouse_daily_sales w WHERE w.sales_run_id=r.id)"));
String deletion=configuration.getMappedStatement(SalesRetentionMapper.class.getName()+".deleteWarehouseDaily").getBoundSql(parameters).getSql();
assertEquals("DELETE FROM ll_selfop_warehouse_daily_sales WHERE sales_run_id=? LIMIT ?",deletion);
config.setRetentionEnabled(true);when(mapper.deleteWarehouseDaily(10L,500)).thenReturn(7);
assertEquals(new SalesRetentionService.Deleted(10L,0,0,7),service.deleteBatch(1L,10L));
}
}
@@ -30,7 +30,7 @@ class SelfopSalesPublisherTest {
scope.setLeaseOwner("worker"); scope.setFenceToken(3L); scope.setLeaseUntilUtc(SelfopSupport.utcNow().plusHours(1));
scope.setFactRevision(7L); scope.setPublishedFactRevision(6L); scope.setPublishedRunId(9L); scope.setRebuildRequired(1);
run=new SelfopSyncRun(); run.setExecutionPurpose("BUSINESS"); run.setAttemptNo(1); run.setSourceAccount("synthetic-account"); run.setScopeSnapshotJson("{\"quantityUnit\":\"PCS\",\"sourceTimezone\":\"UTC\"}"); run.setId(10L); run.setScopeId(1L); run.setTaskId(20L); run.setScopeHash(scope.getScopeHash());
run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L);
run.setFenceToken(3L); run.setRunRole("WAREHOUSE_SYNC"); run.setStatus("READY"); run.setCalculatedFactRevision(7L);run.setWarehouseDailyReady(true);
run.setBuildStartedAtUtc(SelfopSupport.utcNow().minusSeconds(1));run.setFetchStartedAtUtc(SelfopSupport.utcNow().minusSeconds(2));
run.setCoverageStatus("COMPLETE"); run.setCoveredFromUtc(LocalDateTime.of(2026,9,12,8,0)); run.setCoveredUntilUtc(LocalDateTime.of(2026,10,12,8,0));
task=new SelfopSyncTask(); task.setExecutionPurpose("BUSINESS"); task.setTaskNo("LXCOL-20261012-00000020"); task.setId(20L); task.setApplyState("APPLIED"); task.setStatus("RUNNING"); task.setTaskType("SCHEDULED");
@@ -48,6 +48,13 @@ class SelfopSalesPublisherTest {
enableSyntheticPublication(); scope.setLeaseUntilUtc(SelfopSupport.utcNow().minusSeconds(1));
assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)); noWrites();
}
@Test void missingWarehouseDailyBuildCannotPublishOrPassIdempotentReturn() {
enableSyntheticPublication();run.setWarehouseDailyReady(false);
assertEquals("SELFOP_WAREHOUSE_DAILY_NOT_READY",assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)).getMessage());
noWrites();scope.setPublishedRunId(10L);run.setStatus("PUBLISHED");
assertEquals("SELFOP_WAREHOUSE_DAILY_NOT_READY",assertThrows(IllegalStateException.class,()->publisher.publish(1,10,"worker",3)).getMessage());
verifyNoInteractions(scopes,runs,tasks);
}
@Test void deadHalfAppliedTaskStillBlocksUnrelatedReadyCandidate() {
enableSyntheticPublication(); task.setApplyState("BLOCKED"); task.setStatus("DEAD");
when(locks.barriers(scope.getSourceAccount(),scope.getWarehouseCode())).thenReturn(1L);