Merge remote-tracking branch 'origin/test' into test

# Conflicts:
#	deploy/sql/README.md
This commit is contained in:
2026-10-15 15:02:34 +08:00
65 changed files with 2866 additions and 144 deletions
@@ -0,0 +1,22 @@
package com.baiyue.lastleg.selfoperated.analytics.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.selfoperated.analytics.sync.InventoryFlowProbeService;
import org.junit.jupiter.api.Test;
import java.util.Set;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.security.access.prepost.PreAuthorize;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class PlatformInventoryFlowProbeControllerTest {
@Test void probeRequiresRealPlatformSyncPermission() throws Exception {
var service=mock(InventoryFlowProbeService.class);var c=new PlatformInventoryFlowProbeController(service);
var reader=new AuthenticatedUser(1L,0L,0L,1L,"reader","PLATFORM","WEB",Set.of("platform:self-operated-analytics:list"));
var customer=new AuthenticatedUser(1L,1L,1L,1L,"customer","CLIENT","WEB",Set.of("platform:self-operated-analytics:sync"));
assertThrows(ResponseStatusException.class,()->c.probe(null,null));assertThrows(ResponseStatusException.class,()->c.probe(reader,null));
assertThrows(ResponseStatusException.class,()->c.probe(customer,null));verifyNoInteractions(service);
var writer=new AuthenticatedUser(1L,0L,0L,1L,"operator","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync"));
c.probe(writer,null);verify(service).probe(null,"operator");
assertEquals("hasAuthority('platform:self-operated-analytics:sync')",PlatformInventoryFlowProbeController.class
.getMethod("probe",AuthenticatedUser.class,InventoryFlowProbeService.Request.class).getAnnotation(PreAuthorize.class).value());
}
}
@@ -43,10 +43,12 @@ class PlatformInventoryTurnoverControllerTest {
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover")
.param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12")
.param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT")
.param("customerCodes","A","B")
.param("coverageDaysThreshold","60").param("current","2").param("size","20")
.param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk());
.param("hideZeroInventory","false").param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk());
var capture=ArgumentCaptor.forClass(InventoryTurnoverRequest.class);verify(service).query(capture.capture());
var q=capture.getValue();assertEquals(LocalDate.of(2026,10,12),q.endDate());assertEquals(15,q.days());
assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertEquals("fixed-token",q.snapshotContext());
assertEquals(List.of("A","B"),q.customerCodes());
assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertFalse(q.hideZero());assertEquals("fixed-token",q.snapshotContext());
}
}
@@ -0,0 +1,42 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport;
import com.fasterxml.jackson.databind.JsonNode;
import org.junit.jupiter.api.Test;
import java.time.*;
import java.util.*;
import static org.junit.jupiter.api.Assertions.*;
class InventoryFlowSliceTest {
private final InventoryFlowSlice slice=new InventoryFlowSlice("W",LocalDateTime.parse("2026-10-14T00:00:00"),LocalDateTime.parse("2026-10-15T00:00:00"),1000);
JsonNode row(String id,String at){return SelfopSupport.read(SelfopSupport.json(Map.of("id",id,"customerCode","C","whCode","W","productSku","SKU","createTime",at)),JsonNode.class);}
JsonNode page(int current,int total,List<JsonNode> rows){return SelfopSupport.read(SelfopSupport.json(Map.of("current",current,"size",1000,"total",total,"pages",total==0?0:(total-1)/1000+1,"records",rows)),JsonNode.class);}
@Test void emptyRangeAndInclusiveMidnightArePreserved(){
assertEquals(0,slice.validate(page(1,0,List.of()),1,null,null).total());
assertEquals(1,slice.validate(page(1,1,List.of(row("1","2026-10-15 00:00:00"))),1,null,null).rows().size());
assertEquals(Instant.parse("2026-10-14T16:00:01Z"),slice.until());
}
@Test void totalDriftDuplicatesAndOrderingNeverProveCoverage(){
assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-14 12:00:00"))),1,2L,null));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,2,List.of(row("1","2026-10-14 12:00:00"),row("1","2026-10-14 12:00:00"))),1,null,null));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-14 12:00:00"))),1,null,LocalDateTime.parse("2026-10-14T13:00:00")));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,2,List.of(row("1","2026-10-14 12:00:00"))),1,null,null));
assertThrows(IllegalStateException.class,()->slice.validate(page(1,1,List.of(row("1","2026-10-15 00:00:01"))),1,null,null));
}
@Test void fixedPageHashCannotChangeWarehouseWindowOrPage(){
assertEquals(slice.requestHash(1),SelfopSupport.hash(slice.request(1)));
assertNotEquals(slice.requestHash(1),slice.requestHash(2));
assertThrows(IllegalArgumentException.class,()->new InventoryFlowSlice("W",slice.start(),slice.end().plusDays(1),1000));
assertThrows(IllegalArgumentException.class,()->new InventoryFlowSlice("W",slice.start(),slice.end(),100));
}
@Test void acceptsInternalLongStringButRejectsFractionalOrOverflowTotals(){
var response=(com.fasterxml.jackson.databind.node.ObjectNode)page(1,0,List.of());
response.put("total","0");
assertEquals(0,slice.validate(response,1,null,null).total());
for(String invalid:List.of("0.0","-1","9223372036854775808","1e3","")) {
response.put("total",invalid);
assertThrows(IllegalStateException.class,()->slice.validate(response,1,null,null));
}
response.put("total",0.5);
assertThrows(IllegalStateException.class,()->slice.validate(response,1,null,null));
}
}
@@ -0,0 +1,175 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import org.junit.jupiter.api.Test;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import static org.assertj.core.api.Assertions.assertThat;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
class InventoryHistoryCalculatorTest {
private static final Sku SKU=new Sku("OWNER","SKU");
private static final Key GOOD=new Key(SKU,0), BAD=new Key(SKU,1);
private static final LocalDate DAY=LocalDate.of(2026,10,14);
private static Instant at(String local) { return LocalDateTime.parse(local).toInstant(ZoneOffset.ofHours(8)); }
private static BigDecimal n(String n) { return new BigDecimal(n); }
private static Anchor anchor(Key k,String qty) { return new Anchor(k,n(qty),"PCS",at("2026-10-15T08:00:00"),at("2026-10-15T08:00:01"),100); }
private static Flow flow(String id,Key key,String type,String delta,String close,String time) {
return new Flow(id,key,"CELL","BATCH",type,n(delta),n(delta),n(close),at(time));
}
private static Input input(List<Flow> rows,List<Anchor> anchors) {
return new Input(DAY,DAY.plusDays(1),Set.of(SKU),rows,anchors,
List.of(new Interval(at("2026-10-14T00:00:00"),at("2026-10-15T08:00:02"))));
}
@Test void reversesTailAndAssignsMidnightToNewDay() {
var r=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","144","2026-10-15T00:00:00")),List.of(anchor(GOOD,"144"))));
assertThat(r.get(0).quantity()).isEqualByComparingTo("145");
}
@Test void missingTailIsUnknownEvenWhenYesterdayIsComplete() {
var i=input(List.of(),List.of(anchor(GOOD,"144")));
var r=reconstruct(new Input(i.from(),i.until(),i.candidates(),i.flows(),i.anchors(),List.of(new Interval(at("2026-10-14T00:00:00"),at("2026-10-15T00:00:00")))));
assertThat(r.get(0).reason()).isEqualTo("FLOW_COVERAGE_GAP");assertThat(r.get(0).quantity()).isNull();
}
@Test void keepsInactiveBatchInSkuAnchor() {
var r=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","10","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
assertThat(r.get(0).quantity()).isEqualByComparingTo("101");
}
@Test void sameIdAtOverlappingBoundaryIsCountedOnce() {
var f=flow("f",GOOD,"DO","-1","9","2026-10-15T01:00:00");
assertThat(reconstruct(input(List.of(f,f),List.of(anchor(GOOD,"9")))).get(0).quantity()).isEqualByComparingTo("10");
}
@Test void sameIdDifferentIdentityRevokesBoth() {
var f=flow("f",GOOD,"DO","-1","9","2026-10-15T01:00:00");
var g=flow("f",BAD,"IMO","1","1","2026-10-15T01:00:00");
assertThat(reconstruct(input(List.of(f,g),List.of(anchor(GOOD,"9"),anchor(BAD,"1"))))).allMatch(d->"FLOW_ID_CONFLICT".equals(d.reason()));
}
@Test void qualityConversionPreservesTotalWithoutDroppingEitherSide() {
var r=reconstruct(input(List.of(flow("a",GOOD,"IMO","-3","7","2026-10-15T01:00:00"),flow("b",BAD,"IMO","3","3","2026-10-15T01:00:00")),List.of(anchor(GOOD,"7"),anchor(BAD,"3"))));
assertThat(r.stream().map(Daily::quantity).reduce(BigDecimal.ZERO,BigDecimal::add)).isEqualByComparingTo("10");
assertThat(r.get(0).quantity()).isEqualByComparingTo("10");assertThat(r.get(1).quantity()).isZero();
}
@Test void absentAnchorNeverBecomesZero() {
assertThat(reconstruct(input(List.of(),List.of())).get(0).reason()).isEqualTo("ANCHOR_MISSING");
}
@Test void explicitZeroWithCompleteCoverageIsValid() {
var d=reconstruct(input(List.of(),List.of(anchor(GOOD,"0")))).get(0);
assertThat(d.status()).isEqualTo("VALID");assertThat(d.quantity()).isZero();
}
@Test void unverifiedTypeIsNotIgnored() {
assertThat(reconstruct(input(List.of(flow("f",GOOD,"BO","1","1","2026-10-15T01:00:00")),List.of(anchor(GOOD,"1")))).get(0).reason()).isEqualTo("FLOW_TYPE_UNVERIFIED");
}
@Test void eventInAnchorObservationIsAmbiguous() {
assertThat(reconstruct(input(List.of(flow("f",GOOD,"DO","-1","9","2026-10-15T08:00:00")),List.of(anchor(GOOD,"9")))).get(0).reason()).isEqualTo("ANCHOR_TIME_AMBIGUOUS");
}
@Test void sameSecondOrderUsesBalancesNotIdOrResponseOrder() {
var rows=List.of(flow("1",GOOD,"DO","-1","37","2026-10-15T01:00:00"),flow("9",GOOD,"DO","-1","38","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"37")))).get(0).quantity()).isEqualByComparingTo("39");
}
@Test void brokenChainDoesNotPublish() {
var rows=List.of(flow("1",GOOD,"DO","-1","38","2026-10-15T01:00:00"),flow("2",GOOD,"DO","-1","35","2026-10-15T02:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"35")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
}
@Test void restoresHundredSameSecondDecrementsAcrossSixtyDaysRegardlessOfSourceOrder() {
var group=new ArrayList<Flow>();
for(int i=0;i<100;i++)group.add(flow("F"+(100-i),GOOD,"DO","-1",String.valueOf(726-i),"2026-10-15T01:00:00"));
for(int seed=0;seed<10;seed++) {
Collections.shuffle(group,new Random(seed));
for(boolean hasPrior:List.of(false,true)) {
var rows=new ArrayList<>(group);
if(hasPrior)rows.add(flow("prior",GOOD,"DO","-1","727","2026-10-14T23:59:59"));
var i=input(rows,List.of(anchor(GOOD,"627")));
var result=reconstruct(new Input(DAY.minusDays(59),i.until(),i.candidates(),i.flows(),i.anchors(),
List.of(new Interval(DAY.minusDays(59).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),at("2026-10-15T08:00:02")))));
assertThat(result).hasSize(60).allMatch(d->"VALID".equals(d.status()));
assertThat(result.get(59).quantity()).isEqualByComparingTo("727");
assertThat(result.get(0).quantity()).isEqualByComparingTo(hasPrior?"728":"727");
}
}
}
@Test void forcedCycleBeforeExitIsUniqueDespiteMultipleOutgoingEdgesAndDecimalScales() {
// Taking the exit first is a dead end, not a second complete order.
var rows=List.of(flow("exit",GOOD,"DO","-1.00","0.0","2026-10-15T01:00:00"),
flow("up",GOOD,"PA","1","2.000","2026-10-15T01:00:00"),
flow("down",GOOD,"DO","-1","1.000000","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).status()).isEqualTo("VALID");
}
@Test void twoCyclesWithSameFinalBalanceRemainAmbiguous() {
var rows=List.of(flow("prior",GOOD,"PA","0","2","2026-10-15T00:59:59"),
flow("a",GOOD,"PA","1","3","2026-10-15T01:00:00"),flow("b",GOOD,"DO","-1","2","2026-10-15T01:00:00"),
flow("c",GOOD,"PA","2","4","2026-10-15T01:00:00"),flow("d",GOOD,"DO","-2","2","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_ORDER_AMBIGUOUS");
}
@Test void distinctIdsWithDuplicateEdgesAreNotDeduplicatedIntoAUniqueOrder() {
var rows=List.of(flow("a",GOOD,"PA","1","2","2026-10-15T01:00:00"),
flow("b",GOOD,"PA","1","2","2026-10-15T01:00:00"),flow("return",GOOD,"DO","-1","1","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_ORDER_AMBIGUOUS");
}
@Test void disconnectedCycleCannotBeIgnoredEvenWhenDegreesMatch() {
var rows=List.of(flow("path",GOOD,"DO","-1","1","2026-10-15T01:00:00"),
flow("loop",GOOD,"PA","0","3","2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"10")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
}
@Test void longChainWithMissingEdgeOrWrongPriorRemainsUnknown() {
var rows=new ArrayList<Flow>();
for(int i=0;i<100;i++)rows.add(flow("F"+i,GOOD,"DO","-1",String.valueOf(726-i),"2026-10-15T01:00:00"));
rows.add(flow("prior",GOOD,"PA","0","728","2026-10-15T00:59:59"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"627")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
rows.remove(rows.size()-1);rows.remove(50);
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"627")))).get(0).reason()).isEqualTo("CHAIN_DISCONTINUITY");
}
@Test void largeUniqueChainUsesIterativeTraversal() {
var rows=new ArrayList<Flow>();
for(int i=0;i<20_000;i++)rows.add(flow("F"+i,GOOD,"DO","-1",String.valueOf(20_000-i),"2026-10-15T01:00:00"));
assertThat(reconstruct(input(rows,List.of(anchor(GOOD,"1")))).get(0).quantity()).isEqualByComparingTo("20001");
}
@Test void graphSolverMatchesExhaustiveOrdersForAllSmallMultigraphs() {
// Independent permutation oracle: all multisets of up to five directed edges
// on three balances, including loops/parallel edges, with every possible prior.
for(int size=1;size<=5;size++)checkGraphs(new ArrayList<>(),0,size);
}
private void checkGraphs(List<Integer> edges,int minimum,int remaining) {
if(remaining>0) {
for(int edge=minimum;edge<9;edge++) {
edges.add(edge);checkGraphs(edges,edge,remaining-1);edges.remove(edges.size()-1);
}
return;
}
var group=new ArrayList<Flow>();
for(int i=0;i<edges.size();i++) {
int edge=edges.get(i),opening=edge/3,closing=edge%3;
group.add(flow("F"+i,GOOD,"PA",String.valueOf(closing-opening),String.valueOf(closing),"2026-10-15T01:00:00"));
}
for(int prior=-1;prior<3;prior++) {
var rows=new ArrayList<>(group);
if(prior>=0)rows.add(flow("prior",GOOD,"PA","0",String.valueOf(prior),"2026-10-15T00:59:59"));
int paths=countOrders(group,new boolean[group.size()],prior<0?null:BigDecimal.valueOf(prior),0);
String reason=paths==0?"CHAIN_DISCONTINUITY":paths==1?null:"CHAIN_ORDER_AMBIGUOUS";
var actual=reconstruct(input(rows,List.of(anchor(GOOD,"100")))).get(0);
assertThat(actual.reason()).as("edges=%s prior=%s",edges,prior).isEqualTo(reason);
}
}
private int countOrders(List<Flow> group,boolean[] used,BigDecimal balance,int depth) {
if(depth==group.size())return 1;
int count=0;
for(int i=0;i<group.size();i++)if(!used[i]) {
var f=group.get(i);
if(balance!=null&&balance.add(f.delta()).compareTo(f.closing())!=0)continue;
used[i]=true;count+=countOrders(group,used,f.closing(),depth+1);used[i]=false;
if(count>1)return 2;
}
return count;
}
@Test void laterAnchorRevisionChangesEarlierDay() {
var a=reconstruct(input(List.of(flow("f",GOOD,"DO","-1","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
var b=reconstruct(input(List.of(flow("f",GOOD,"DO","-2","100","2026-10-15T01:00:00")),List.of(anchor(GOOD,"100"))));
assertThat(a.get(0).quantity()).isEqualByComparingTo("101");assertThat(b.get(0).quantity()).isEqualByComparingTo("102");
}
@Test void negativeSkuBalanceCannotHideBehindPositiveBatchClosing() {
assertThat(reconstruct(input(List.of(flow("f",GOOD,"PA","5","5","2026-10-15T01:00:00")),List.of(anchor(GOOD,"3")))).get(0).reason()).isEqualTo("NEGATIVE_RECONSTRUCTED_STOCK");
}
@Test void mergingCoverageDoesNotBridgeGaps() {
var t=Instant.EPOCH;
assertThat(covered(List.of(new Interval(t,t.plusSeconds(2)),new Interval(t.plusSeconds(1),t.plusSeconds(4))),t,t.plusSeconds(4))).isTrue();
assertThat(covered(List.of(new Interval(t,t.plusSeconds(1)),new Interval(t.plusSeconds(2),t.plusSeconds(4))),t,t.plusSeconds(4))).isFalse();
}
}
@@ -0,0 +1,164 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.sync.SelfopLeaseService;
import com.fasterxml.jackson.databind.JsonNode;
import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.junit.jupiter.api.*;
import java.nio.file.*;
import java.time.*;
import java.math.BigDecimal;
import java.util.*;
import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** Actual mapper + SQL + transaction pipeline, isolated H2; provider is a bounded fixture. */
public class InventoryHistoryPipelineTest {
JdbcTemplate jdbc;TransactionTemplate tx;SelfopAnalyticsProperties config;SqlSessionTemplate session;
SelfopSyncTaskMapper tasks;SelfopSyncRunMapper runs;SelfopSyncStageMapper stages;SelfopCoordinationMapper locks;
SelfopLeaseService leases;RestLingxingAnalyticsClient client;InventoryHistoryVersions versions;InventoryFlowFacts facts;
InventoryHistoryBuilder builder;InventoryFlowCollector collector;
static final LocalDate DAY=LocalDate.of(2026,10,14);
@BeforeEach void setup()throws Exception {
var ds=new UnpooledDataSource("org.h2.Driver","jdbc:h2:mem:history_pipeline_"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));
String ddl=Files.readString(Path.of("src/main/resources/db/lastleg.sql"));
for(String table:List.of("ll_selfop_outbound_discovery_scope","ll_selfop_sync_scope","ll_selfop_sync_task","ll_selfop_sync_run","ll_selfop_sync_stage","ll_selfop_inventory_snapshot","ll_selfop_sku_daily_sales","ll_selfop_sku_profile","ll_selfop_inventory_flow","ll_selfop_inventory_day_version","ll_selfop_inventory_daily")) {
var m=java.util.regex.Pattern.compile("CREATE TABLE (?:IF NOT EXISTS )?"+table+" \\([\\s\\S]*?\\) ENGINE[^;]+;").matcher(ddl);assertTrue(m.find(),table);
jdbc.execute(m.group().replaceAll("\\) ENGINE[^;]+;",");").replace(" JSON "," VARCHAR(1000000) "));
}
jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR '"+getClass().getName()+".extract'");jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR '"+getClass().getName()+".unquote'");
var c=new MybatisConfiguration();c.setMapUnderscoreToCamelCase(true);
for(var mapper:List.of(SelfopSyncTaskMapper.class,SelfopSyncRunMapper.class,SelfopSyncStageMapper.class,SelfopCoordinationMapper.class,SelfopSyncScopeMapper.class,SelfopOutboundDiscoveryScopeMapper.class))c.addMapper(mapper);
var factory=new MybatisSqlSessionFactoryBean();factory.setConfiguration(c);factory.setDataSource(ds);session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject()));
tasks=session.getMapper(SelfopSyncTaskMapper.class);runs=session.getMapper(SelfopSyncRunMapper.class);stages=session.getMapper(SelfopSyncStageMapper.class);
var real=session.getMapper(SelfopCoordinationMapper.class);locks=mock(SelfopCoordinationMapper.class,org.mockito.AdditionalAnswers.delegatesTo(real));
doReturn(List.of()).when(locks).dueContinuous(any(),anyInt(),anyString());
config=new SelfopAnalyticsProperties();config.setSourceAccount("demo");config.setCollectionMode("BUSINESS");config.setContinuousBusinessCollectionEnabled(true);config.setInventoryHistoryEnabled(true);config.setSourceQueryTimezone("Asia/Shanghai");config.setQuantityUnit("PCS");config.getWarehouses().put("W",new SelfopAnalyticsProperties.Warehouse());
leases=new SelfopLeaseService(locks,session.getMapper(SelfopSyncScopeMapper.class),session.getMapper(SelfopOutboundDiscoveryScopeMapper.class),config);
client=mock(RestLingxingAnalyticsClient.class);versions=new InventoryHistoryVersions(jdbc,tx);facts=new InventoryFlowFacts(jdbc,tx,versions);
collector=newCollector();builder=new InventoryHistoryBuilder(jdbc,tx,versions,tasks,runs,stages);
jdbc.update("INSERT INTO ll_selfop_outbound_discovery_scope (id,created_at,updated_at,source_account,fence_token,request_budget,time_budget_seconds,min_request_interval_ms) VALUES(1,?,?,?,0,100,120,1200)",utcNow(),utcNow(),"demo");
jdbc.update("INSERT INTO ll_selfop_sync_scope (id,created_at,updated_at,source_account,warehouse_code,data_family,scope_json,scope_hash,business_timezone,source_timezone,schedule_cron,stale_after_seconds,fence_token,fact_revision,published_fact_revision,rebuild_required,review_request_budget,review_time_budget_seconds) VALUES(2,?,?,'demo','W','INVENTORY','{}',?,'Asia/Shanghai','Asia/Shanghai','0 0 8 * * *',93600,0,0,0,0,100,120)",utcNow(),utcNow(),"b".repeat(64));
}
@AfterEach void close(){jdbc.execute("SHUTDOWN");}
public static String extract(String value,String path){return json(read(value,JsonNode.class).path(path.substring(2)));}
public static String unquote(String value){return read(value,JsonNode.class).asText();}
InventoryFlowCollector newCollector(){return new InventoryFlowCollector(config,tasks,runs,stages,locks,leases,client,jdbc,tx);}
InventoryFlowSlice slice(){return new InventoryFlowSlice("W",DAY.atStartOfDay(),DAY.plusDays(1).atStartOfDay(),1000);}
JsonNode flow(String id,String delta,String close){return read("{\"id\":\""+id+"\",\"customerCode\":\"C\",\"whCode\":\"W\",\"productSku\":\"SKU\",\"productQuality\":0,\"cellNo\":\"CELL\",\"innerBatchNo\":\"BATCH\",\"billType\":\"DO\",\"bookQty\":"+delta+",\"bookChangQty\":\""+delta+"\",\"closeQty\":"+close+",\"createTime\":\"2026-10-14 12:00:00\"}",JsonNode.class);}
JsonNode page(int current,long total,List<JsonNode> rows){return read(json(Map.of("current",current,"size",1000,"total",total,"pages",total==0?0:(total-1)/1000+1,"records",rows)),JsonNode.class);}
void respond(JsonNode page){when(client.inventoryFlowPage(any(),eq("W"),any(),any(),anyInt(),eq(1000))).thenReturn(page);}
void collect(InventoryFlowSlice slice,LocalDate round,JsonNode page){collector.plan(1,slice,round);respond(page);collector.page();while(facts.mergeNext("demo")){} }
@Test void collectionReplaysDoNotDoubleCountAndRevisionInvalidatesAtomically() {
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class));
assertEquals("FACTS_APPLIED",jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run",String.class));
collect(slice(),DAY.plusDays(1),page(1,1,List.of(flow("F","-1","9"))));
assertEquals(1,jdbc.queryForObject("SELECT revision_no FROM ll_selfop_inventory_flow",Integer.class));
var build=new InventoryHistoryVersions.Build("demo","W","b".repeat(64),50,51,52,null,versions.factHash("demo","W"),"{}",Map.of());
var candidate=versions.create(build,DAY);versions.ready(candidate,0);versions.publish(build,List.of(candidate));
collect(slice(),DAY.plusDays(2),page(1,1,List.of(flow("F","-2","8"))));
assertEquals(2,jdbc.queryForObject("SELECT revision_no FROM ll_selfop_inventory_flow",Integer.class));
assertNotNull(jdbc.queryForObject("SELECT invalidated_at FROM ll_selfop_inventory_day_version",Object.class));
assertEquals(3,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_stage",Integer.class));
}
@Test void changedIdentityIsQuarantinedAndNeverMoved() {
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
var changed=(com.fasterxml.jackson.databind.node.ObjectNode)flow("F","-1","9");changed.put("customerCode","OTHER");
collect(slice(),DAY.plusDays(1),page(1,1,List.of(changed)));
assertEquals("C",jdbc.queryForObject("SELECT customer_code FROM ll_selfop_inventory_flow",String.class));
assertEquals("FLOW_IDENTITY_CONFLICT",jdbc.queryForObject("SELECT validation_reason FROM ll_selfop_inventory_flow",String.class));
}
@Test void missingPreviouslySeenFlowRemainsUnknownInsteadOfBeingDeleted() {
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
collect(slice(),DAY.plusDays(1),page(1,0,List.of()));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class));
assertEquals("SOURCE_FLOW_MISSING",jdbc.queryForObject("SELECT validation_reason FROM ll_selfop_inventory_flow",String.class));
}
@Test void restartedCollectorStartsNewAttemptAndDoesNotMixPages() {
collector.plan(1,slice(),DAY);var rows=new ArrayList<JsonNode>();for(int i=0;i<1000;i++)rows.add(flow("F"+i,"-1","9"));
respond(page(1,1001,rows));collector.page();assertEquals(1000,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_stage",Integer.class));
collector=newCollector();respond(page(1,1,List.of(flow("NEW","-1","9"))));collector.page();
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run",Integer.class));
assertEquals("ABORTED",jdbc.queryForObject("SELECT status FROM ll_selfop_sync_run WHERE attempt_no=1",String.class));
while(facts.mergeNext("demo")){}assertEquals("NEW",jdbc.queryForObject("SELECT source_flow_id FROM ll_selfop_inventory_flow",String.class));
}
@Test void malformedPageSplitsFixedWindowWithoutGrantingCoverage() {
collector.plan(1,slice(),DAY);respond(page(1,2,List.of(flow("F","-1","9"))));collector.page();
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE status='DEAD'",Integer.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE status='READY'",Integer.class));
assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_run WHERE coverage_status='COMPLETE'",Integer.class));
assertFalse(facts.mergeNext("demo"));
}
@Test void independentAnchorMismatchPublishesUnknownAndNeverInventsQuantity() {
stock(100,DAY,20);stock(101,DAY.plusDays(1),9);
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
collect(new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000),DAY,page(1,0,List.of()));
builder.build("demo","W",30);
assertEquals("INDEPENDENT_ANCHOR_MISMATCH",jdbc.queryForObject("SELECT quality_reason FROM ll_selfop_inventory_daily",String.class));
assertNull(jdbc.queryForObject("SELECT physical_quantity FROM ll_selfop_inventory_daily",BigDecimal.class));
}
@Test void localReconstructionPublishesDayAndKeepsRawSnapshotsUnchanged() {
stock(100,DAY,10);stock(101,DAY.plusDays(1),9);
collect(slice(),DAY,page(1,1,List.of(flow("F","-1","9"))));
var tail=new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000);
collect(tail,DAY,page(1,0,List.of()));builder.build("demo","W",30);
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE head_slot=1",Integer.class));
assertEquals(0,new BigDecimal("9").compareTo(jdbc.queryForObject("SELECT physical_quantity FROM ll_selfop_inventory_daily",BigDecimal.class)));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class));
builder.build("demo","W",30);assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class));
}
@Test void calculationRevisionRebuildsLegacyUnknownFromSavedFlowsExactlyOnce() {
stock(100,DAY,727);stock(101,DAY.plusDays(1),627);
var rows=new ArrayList<JsonNode>();
for(int i=0;i<100;i++)rows.add(flow("F"+i,"-1",String.valueOf(726-i)));
Collections.shuffle(rows,new Random(42));
collect(slice(),DAY,page(1,100,rows));
collect(new InventoryFlowSlice("W",DAY.plusDays(1).atStartOfDay(),DAY.plusDays(1).atTime(8,0,2),1000),DAY,page(1,0,List.of()));
builder.build("demo","W",30);
// Seed the pre-fix published state, including its exact former dedup signature.
var coverage=List.of(new InventoryHistoryCalculator.Interval(
DAY.atStartOfDay().toInstant(ZoneOffset.ofHours(8)),DAY.plusDays(1).atTime(8,0,2).toInstant(ZoneOffset.ofHours(8))));
String legacyHash=hash(Arrays.asList(versions.factHash("demo","W"),builder.anchor("demo","W",0),
builder.anchor("demo","W",1),DAY,DAY.plusDays(1),null,coverage));
var legacyTask=jdbc.queryForMap("SELECT id,request_hash FROM ll_selfop_sync_task WHERE task_type='INVENTORY_HISTORY_BUILD'");
assertNotEquals(legacyHash,legacyTask.get("request_hash"));
jdbc.update("UPDATE ll_selfop_sync_task SET request_hash=? WHERE id=?",legacyHash,legacyTask.get("id"));
jdbc.update("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='CHAIN_ORDER_AMBIGUOUS'");
jdbc.update("UPDATE ll_selfop_inventory_day_version SET evidence_json=?",json(Map.of("rule","FLOW_EOD_V1")));
var flowHash=versions.factHash("demo","W");
clearInvocations(client);
builder.build("demo","W",30);
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_sync_task WHERE task_type='INVENTORY_HISTORY_BUILD' AND status='SUCCEEDED'",Integer.class));
assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version WHERE head_slot=1",Integer.class));
var current=jdbc.queryForMap("SELECT d.physical_quantity,d.quality_status,v.rule_version,v.evidence_json FROM ll_selfop_inventory_daily d JOIN ll_selfop_inventory_day_version v ON v.id=d.day_version_id WHERE v.head_slot=1");
assertEquals(0,new BigDecimal("627").compareTo((BigDecimal)current.get("physical_quantity")));
assertEquals("VALID",current.get("quality_status"));
assertEquals("FLOW_EOD_V1",current.get("rule_version"));
var evidence=read(current.get("evidence_json").toString(),JsonNode.class);
assertEquals(InventoryHistoryCalculator.CALCULATION_REVISION,evidence.path("calculationRevision").asInt());
var runEvidence=read(jdbc.queryForObject("SELECT scope_snapshot_json FROM ll_selfop_sync_run WHERE status='FACTS_APPLIED' AND last_phase='HISTORY_PUBLISHED' ORDER BY id DESC LIMIT 1",String.class),JsonNode.class);
assertEquals(InventoryHistoryCalculator.CALCULATION_REVISION,runEvidence.path("calculationRevision").asInt());
builder.build("demo","W",30);
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_day_version",Integer.class));
assertEquals(flowHash,versions.factHash("demo","W"));
assertEquals(100,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_flow",Integer.class));
assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_snapshot",Integer.class));
verifyNoInteractions(client);
}
void stock(long id,LocalDate day,int quantity) {
var now=day.atTime(0,0);var r=new SelfopSyncRun();r.setId(id);r.setTaskId(id);r.setAttemptNo(1);r.setSourceAccount("demo");r.setWarehouseCode("W");r.setScopeId(2L);r.setRunRole("WAREHOUSE_SYNC");r.setExecutionPurpose("BUSINESS");r.setScopeHash("b".repeat(64));r.setScopeSnapshotJson("{}");r.setFenceToken(0L);r.setStatus("PUBLISHED");r.setLastPhase("PUBLISHED");r.setCoverageStatus("COMPLETE");r.setSourceTotalCount(1L);r.setUniqueObjectCount(1L);r.setPageCount(1);r.setCreatedAt(now);r.setUpdatedAt(now);r.setFetchStartedAtUtc(now);r.setFetchFinishedAtUtc(now.plusSeconds(1));r.setPublishedAtUtc(now.plusSeconds(2));runs.insert(r);
jdbc.update("INSERT INTO ll_selfop_inventory_snapshot(id,created_at,updated_at,snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality,physical_quantity,observed_at_utc,quality_status) VALUES(?,?,?,?,'demo','C','W','SKU',0,?,?,'VALID')",id,now,now,id,quantity,now.plusSeconds(1));
jdbc.update("INSERT INTO ll_selfop_sku_profile(id,created_at,updated_at,profile_run_id,source_account,customer_code,warehouse_code,sku_code,quantity_unit,evidence_type,first_seen_at_utc,last_seen_run_id) VALUES(?,?,?,?,'demo','C','W','SKU','PCS','INVENTORY_OBSERVATION',?,?)",id,now,now,id,now,id);
}
}
@@ -0,0 +1,87 @@
package com.baiyue.lastleg.selfoperated.analytics.history;
import org.junit.jupiter.api.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.*;
import static org.assertj.core.api.Assertions.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryCalculator.*;
import static com.baiyue.lastleg.selfoperated.analytics.history.InventoryHistoryVersions.*;
class InventoryHistoryVersionsTest {
JdbcTemplate jdbc;TransactionTemplate tx;InventoryHistoryVersions versions;
final LocalDate day=LocalDate.of(2026,10,1);
@BeforeEach void setup() throws Exception {
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1","sa","");
jdbc=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds));versions=new InventoryHistoryVersions(jdbc,tx);
jdbc.execute("CREATE TABLE ll_selfop_sync_scope(id BIGINT,source_account VARCHAR(64),warehouse_code VARCHAR(64),data_family VARCHAR(32),scope_hash VARCHAR(64))");
jdbc.update("INSERT INTO ll_selfop_sync_scope VALUES(1,'a','w','INVENTORY','scope')");
String ddl=new ClassPathResource("db/selfop-inventory-history.sql").getContentAsString(StandardCharsets.UTF_8)
.replaceAll("(?m)^--.*$","").replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin","");
for(String statement:ddl.split(";")) if(!statement.isBlank())jdbc.execute(statement);
}
Build build(long id,Map<LocalDate,Long> heads) {return new Build("a","w","scope",id,id,10,20L,versions.factHash("a","w"),"{}",heads);}
Candidate candidate(Build b,LocalDate date,String quantity) {
Candidate c=versions.create(b,date);
versions.append(c,List.of(new Daily(date,new Key(new Sku("o","s"),0),new BigDecimal(quantity),"VALID",null,10)),"evidence");
versions.ready(c,1);return c;
}
@Test void unpublishedRowsAreInvisibleAndGroupSwitchesAllDates() {
var b=build(1,Map.of());var first=candidate(b,day,"100");var second=candidate(b,day.plusDays(1),"100");
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEmpty();
versions.publish(b,List.of(first,second));
var old=versions.heads("a","w",day,day.plusDays(2));
var next=build(2,old);var n1=candidate(next,day,"110");var n2=candidate(next,day.plusDays(1),"110");
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(old);
versions.publish(next,List.of(n1,n2));
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(Map.of(day,n1.id(),day.plusDays(1),n2.id()));
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_inventory_daily",Integer.class)).isEqualTo(4);
}
@Test void failureAfterFirstHeadChangeRollsBackWholeGroup() {
var b=build(1,Map.of());var c1=candidate(b,day,"100");var c2=candidate(b,day.plusDays(1),"100");versions.publish(b,List.of(c1,c2));
var old=versions.heads("a","w",day,day.plusDays(2));var next=build(2,old);
var n1=candidate(next,day,"110");var n2=candidate(next,day.plusDays(1),"110");
jdbc.update("UPDATE ll_selfop_inventory_day_version SET invalidated_at=CURRENT_TIMESTAMP WHERE id=?",n2.id());
assertThatThrownBy(()->versions.publish(next,List.of(n1,n2))).hasMessage("HISTORY_CANDIDATE_CHANGED");
assertThat(versions.heads("a","w",day,day.plusDays(2))).isEqualTo(old);
assertThat(jdbc.queryForObject("SELECT status FROM ll_selfop_inventory_day_version WHERE id=?",String.class,n1.id())).isEqualTo("READY");
}
@Test void partialGroupAndIncompleteSkuSetsCannotPublish() {
var b=build(1,Map.of());var c1=candidate(b,day,"100");candidate(b,day.plusDays(1),"100");
assertThatThrownBy(()->versions.publish(b,List.of(c1))).hasMessage("HISTORY_GROUP_INCOMPLETE");
var c=versions.create(build(2,Map.of()),day.plusDays(2));
assertThatThrownBy(()->versions.ready(c,1)).hasMessage("HISTORY_CANDIDATE_INCOMPLETE");
}
@Test void unknownRowsAreRetainedAndPublishedVersionsCannotBeAppended() {
var b=build(1,Map.of());var c=versions.create(b,day);
versions.append(c,List.of(new Daily(day,new Key(new Sku("o","s"),0),null,"UNKNOWN","ANCHOR_MISSING",0)),"hash");versions.ready(c,1);versions.publish(b,List.of(c));
assertThat(jdbc.queryForObject("SELECT unknown_sku_count FROM ll_selfop_inventory_day_version WHERE id=?",Long.class,c.id())).isEqualTo(1);
assertThatThrownBy(()->versions.append(c,List.of(),"hash")).hasMessage("HISTORY_CANDIDATE_IMMUTABLE");
}
@Test void publicationIsIdempotentAfterCommit() {
var b=build(1,Map.of());var c=candidate(b,day,"100");versions.publish(b,List.of(c));versions.publish(b,List.of(c));
assertThat(versions.heads("a","w",day,day.plusDays(1))).hasSize(1);
}
@Test void aNewHeadRejectsStaleBuild() {
var stale=build(1,Map.of());var c=candidate(stale,day,"100");
var winner=build(2,Map.of());var c2=candidate(winner,day,"110");versions.publish(winner,List.of(c2));
assertThatThrownBy(()->versions.publish(stale,List.of(c))).hasMessage("HISTORY_HEAD_CHANGED");
}
@Test void changedWarehouseScopeRejectsFrozenCandidate() {
var b=build(1,Map.of());var c=candidate(b,day,"100");
jdbc.update("UPDATE ll_selfop_sync_scope SET scope_hash='new-scope'");
assertThatThrownBy(()->versions.publish(b,List.of(c))).hasMessage("HISTORY_SCOPE_CHANGED");
assertThat(versions.heads("a","w",day,day.plusDays(1))).isEmpty();
}
@Test void knownInvaliditySurvivesFailedRebuild() {
var b=build(1,Map.of());var c=candidate(b,day,"100");versions.publish(b,List.of(c));
tx.executeWithoutResult(s->{versions.lockWarehouse("a","w");versions.invalidate("a","w",2,"FLOW_REVISION");});
assertThat(jdbc.queryForObject("SELECT invalidation_reason FROM ll_selfop_inventory_day_version WHERE id=?",String.class,c.id())).isEqualTo("FLOW_REVISION");
}
}
@@ -0,0 +1,38 @@
package com.baiyue.lastleg.selfoperated.analytics.model;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.BindException;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.env.YamlPropertySourceLoader;
import org.springframework.core.env.MapPropertySource;
import org.springframework.core.env.StandardEnvironment;
import org.springframework.core.io.ClassPathResource;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
class SelfopAnalyticsPropertiesBindingTest {
StandardEnvironment environment() throws Exception {
var env=new StandardEnvironment();
for(var source:new YamlPropertySourceLoader().load("application",new ClassPathResource("application.yml")))env.getPropertySources().addLast(source);
env.getPropertySources().addFirst(new MapPropertySource("runtime",Map.of(
"baiyue.self-operated-analytics.collection-mode","BUSINESS",
"baiyue.self-operated-analytics.environment","test",
"baiyue.self-operated-analytics.inventory-history-enabled",true)));
return env;
}
@Test void refreshingRunningBusinessBeanBindsHistoryFlagWithoutDerivedExecutionInput() throws Exception {
var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");
var env=environment();
assertNull(env.getProperty("baiyue.self-operated-analytics.execution-enabled"));
Binder.get(env).bind("baiyue.self-operated-analytics",Bindable.ofInstance(config));
assertTrue(config.isInventoryHistoryEnabled());assertTrue(config.isExecutionEnabled());
Binder.get(env).bind("baiyue.self-operated-analytics",Bindable.ofInstance(config));
assertTrue(config.isInventoryHistoryEnabled());assertTrue(config.isExecutionEnabled());
}
@Test void obsoleteFalseInputReproducesTheRefreshFailure() throws Exception {
var config=new SelfopAnalyticsProperties();config.setCollectionMode("BUSINESS");
var env=environment();env.getPropertySources().addFirst(new MapPropertySource("obsolete",Map.of("baiyue.self-operated-analytics.execution-enabled",false)));
assertThrows(BindException.class,()->Binder.get(env).bind("baiyue.self-operated-analytics",Bindable.ofInstance(config)));
}
}
@@ -26,7 +26,8 @@ class InventoryTurnoverContextTest {
when(analytics.publishedRun(run.id())).thenReturn(run);
when(mapper.versionAt(eq(run.scopeId()),eq(run.scopeHash()),any())).thenReturn(run);
}
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.SampleRun(END,100L)));
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,100L)));
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,100L)));
when(mapper.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END);
}
@Test void sameContextFreezesAllThirtyDatesAndMissingSamples() {
@@ -35,34 +36,34 @@ class InventoryTurnoverContextTest {
assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate());
assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive());
assertEquals(first,at(NOW.plusSeconds(60)).resolve(request(first.snapshotContext())).response());
var page2=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext());
var page2=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext(),false);
var second=at(NOW.plusSeconds(90)).resolve(page2);
assertEquals(first,second.response());
assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(),
InventoryTurnoverSql.input(page2,second).days());
verify(mapper,times(4)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1));
// A subsequently published sample cannot appear in the old asOf query. If reconstruction
// ever differs, reject the context instead of mixing old charts and new rows.
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of());
verify(mapper,times(1)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1));
// Even a late commit bearing an older wall-clock timestamp cannot change the exact mapping.
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.DayVersion(END,101L)));
assertEquals(first,contexts.resolve(request(first.snapshotContext())).response());
// Explicit invalidation revokes the old evidence rather than silently switching its version.
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext())));
}
@Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() {
var q=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,null);
var q=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,null,false);
var first=contexts.resolve(q).response();
assertEquals(366,first.dailySamples().size());assertEquals(365,first.missingSampleDates().size());
assertTrue(first.snapshotContext().length()<2048);
var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext());
var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,20,false,first.snapshotContext(),false);
assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response());
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of());
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(next));
}
@Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception {
String token=contexts.resolve(request(null)).response().snapshotContext();
var evidence=json.readTree(Base64.getUrlDecoder().decode(token));
((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L);
String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence));
String tampered=(token.charAt(0)=='A'?"B":"A")+token.substring(1);
assertThrows(BizException.class,()->contexts.resolve(request(tampered)));
var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token);
var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false);
assertThrows(BizException.class,()->contexts.resolve(another));
assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).resolve(request(token)));
assertThrows(BizException.class,()->at(NOW.minusSeconds(1)).resolve(request(token)));
@@ -81,7 +82,7 @@ class InventoryTurnoverContextTest {
}
@Test void rejectsDuplicateRepresentativesAndScopeDrift() {
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(
new InventoryTurnoverReadMapper.SampleRun(END,100L),new InventoryTurnoverReadMapper.SampleRun(END,101L)));
new InventoryTurnoverReadMapper.DayVersion(END,100L),new InventoryTurnoverReadMapper.DayVersion(END,101L)));
assertThrows(BizException.class,()->contexts.resolve(request(null)));
when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH));
assertThrows(BizException.class,()->contexts.resolve(request(null)));
@@ -138,11 +139,22 @@ class InventoryTurnoverContextTest {
verify(mapper,never()).page(any());verify(mapper,never()).count(any());verify(mapper,never()).overview(any());verify(mapper,never()).owners(any());
clearInvocations(mapper);
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}")));
var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext());
var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext(),false);
var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any());
}
@Test void fullYearContextKeepsExactVersionsAndBoundedSignedToken() {
var versions=java.util.stream.IntStream.range(0,366).mapToObj(i->new InventoryTurnoverReadMapper.DayVersion(END.minusDays(365-i),2110000000000000000L+i*100000003L)).toList();
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(versions);
when(mapper.fixedSamples(eq("demo"),eq("W"),eq(IH),any())).thenReturn(versions);
var first=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false);
var frozen=contexts.resolve(first).response();
assertEquals(366,frozen.dailySamples().size());
assertTrue(frozen.snapshotContext().length()<7500);
var next=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,null,2,20,false,frozen.snapshotContext(),false);
assertEquals(frozen,contexts.resolve(next).response());
}
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);}
static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token,false);}
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);}
private static Run run(long id,long scope,String hash){
@@ -0,0 +1,84 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.export.ExportContracts.*;
import com.baiyue.lastleg.export.LastlegExportProviderService;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.web.server.ResponseStatusException;
import java.util.*;
import java.math.BigDecimal;
import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverResponses.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class InventoryTurnoverExportTest {
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
final InventoryTurnoverQueryService queries=mock(InventoryTurnoverQueryService.class);
final InventoryTurnoverExport export=new InventoryTurnoverExport(queries,json);
Map<String,Object> parameters() {
Map<String,Object> p=new LinkedHashMap<>();
p.put("sourceAccount","demo");p.put("warehouseCode","W");p.put("endDate","2026-10-14");p.put("baseWindowDays",30);
p.put("groupBy","SKU");p.put("customerCodes",List.of("A","B"));p.put("sortBy","TURNOVER_DESC");p.put("hideZeroInventory",true);
p.put("snapshotContext","fixed");p.put("expectedTotal",205);p.put("columns",List.of("quantityTurnoverRate","sku","inventoryVolumeM3"));return p;
}
ExportBatchRequest request(Map<String,Object> p,String cursor,String token) {return new ExportBatchRequest("task","0","PLATFORM","user",1,p,cursor,token,100);}
Row row(int i) {
Map<String,Object> r=new LinkedHashMap<>();r.put("sku",i==0?"=SUM(1,2)":"S"+i);r.put("customerCode","A");r.put("customerName","货主甲");
r.put("quantityTurnoverCount",i==1?null:i==2?0:new BigDecimal("1.25"));r.put("averagePhysicalQuantity",new BigDecimal("80"));
r.put("windows",List.of(Map.of("days",30,"outboundQuantity",100,"dataStatus","COMPLETE")));
r.put("qualifiedSampleDays",30);r.put("requiredSampleDays",30);r.put("sampleReasonCodes",List.of());
r.put("inventoryVolumeM3",Map.of("knownSubtotal",1,"missingDimensionSkuCount",2,"missingDimensionQuantity",3,"declaredDimensionQuantity",4));
return json.convertValue(r,Row.class);
}
Response response(InventoryTurnoverRequest q,long total,String token) {
Context c=mock(Context.class);when(c.snapshotContext()).thenReturn(token);
List<Row> rows=java.util.stream.IntStream.range((int)q.offset(),(int)Math.min(total,q.offset()+q.pageSize())).mapToObj(this::row).toList();
return new Response(c,null,null,rows,total);
}
LastlegExportProviderService provider() {
var p=new LastlegExportProviderService();ReflectionTestUtils.setField(p,"json",json);ReflectionTestUtils.setField(p,"turnover",export);
ReflectionTestUtils.setField(p,"signingKey","test-export-signing-key-with-32-bytes");return p;
}
@Test void batchesAllRowsWithVisibleColumnOrderPercentageNotesAndFrozenFilters() {
when(queries.query(any())).thenAnswer(c->response(c.getArgument(0),205,"fixed"));
var p=provider();var first=p.batch(InventoryTurnoverExport.TYPE,request(parameters(),null,null));
assertEquals(List.of("quantityTurnoverRate","sku","inventoryVolumeM3"),first.columns().subList(0,3).stream().map(ExportColumn::key).toList());
assertEquals("125%",first.rows().get(0).get("quantityTurnoverRate"));assertEquals("—",first.rows().get(1).get("quantityTurnoverRate"));assertEquals("0%",first.rows().get(2).get("quantityTurnoverRate"));
assertTrue(first.rows().get(0).get("inventoryVolumeM3").toString().contains("缺尺寸 2 SKU / 3 件"));
assertEquals("库存流水还原(日末实物库存)",first.rows().get(0).get("historySource"));
var second=p.batch(InventoryTurnoverExport.TYPE,request(parameters(),first.nextCursor(),first.snapshotToken()));
var third=p.batch(InventoryTurnoverExport.TYPE,request(parameters(),second.nextCursor(),second.snapshotToken()));
assertEquals(205,first.rows().size()+second.rows().size()+third.rows().size());assertFalse(third.hasMore());assertTrue(second.columns().isEmpty());assertEquals(first.schemaHash(),third.schemaHash());
verify(queries).query(argThat(q->q.page()==2&&q.pageSize()==100&&!q.overview()&&q.hideZero()&&q.customerCodes().equals(List.of("A","B"))&&q.sort().equals("TURNOVER_DESC")&&q.snapshotContext().equals("fixed")));
var changed=parameters();changed.put("columns",List.of("sku"));
assertThrows(ResponseStatusException.class,()->p.batch(InventoryTurnoverExport.TYPE,request(changed,"100",first.snapshotToken())));
assertThrows(ResponseStatusException.class,()->p.batch(InventoryTurnoverExport.TYPE,new ExportBatchRequest("task","0","PLATFORM","other",1,parameters(),"100",first.snapshotToken(),100)));
assertThrows(ResponseStatusException.class,()->p.batch(InventoryTurnoverExport.TYPE,new ExportBatchRequest("task","2","CLIENT","user",1,parameters(),null,null,100)));
}
@Test void rejectsStaleOrChangedDataWithoutReturningPartialFileAndSupportsEmptyResults() {
doAnswer(c->response(c.getArgument(0),204,"fixed")).when(queries).query(any());
assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(parameters(),null,null),0,"signed"));
doAnswer(c->response(c.getArgument(0),205,"other")).when(queries).query(any());
assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(parameters(),null,null),0,"signed"));
var empty=parameters();empty.put("expectedTotal",0);doAnswer(c->response(c.getArgument(0),0,"fixed")).when(queries).query(any());
var result=export.batch(request(empty,null,null),0,"signed");assertFalse(result.hasMore());assertEquals(0L,result.totalRows());assertFalse(result.columns().isEmpty());
doThrow(new com.baiyue.common.core.exception.BizException("库存周转上下文无效或已过期,请整体刷新")).when(queries).query(any());
assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(parameters(),null,null),0,"signed"));
}
@Test void validatesColumnsPaginationAndSnapshotBeforeQuerying() {
for(var columns:List.of(List.of("internalId"),List.of("sku","sku"))) {
var p=parameters();p.put("columns",columns);assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(p,null,null),0,"s"));
}
var owner=parameters();owner.put("groupBy","OWNER");assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(owner,null,null),0,"s"));
var noContext=parameters();noContext.remove("snapshotContext");assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(noContext,null,null),0,"s"));
var paged=parameters();paged.put("current",1);assertThrows(com.baiyue.common.core.exception.BizException.class,()->export.batch(request(paged,null,null),0,"s"));
verifyNoInteractions(queries);
assertEquals("<0.01",InventoryTurnoverExport.number(new BigDecimal("0.00001"),2));
}
@Test void ownerExportUsesTheServerAggregateWithoutAveragingSkuPercentages() {
var p=parameters();p.put("groupBy","OWNER");p.put("columns",List.of("customerCode","quantityTurnoverRate"));p.put("expectedTotal",1);
when(queries.query(any())).thenAnswer(c->response(c.getArgument(0),1,"fixed"));
var result=export.batch(request(p,null,null),0,"signed");assertEquals("货主甲",result.rows().get(0).get("customerCode"));assertEquals("125%",result.rows().get(0).get("quantityTurnoverRate"));
}
}
@@ -16,7 +16,16 @@ class InventoryTurnoverRequestTest {
}
@Test void defaultsToYesterdayThirtyDaysAndFirstPageOverview() {
var q=request(Map.of("includeOverview",false));q.validate(TODAY);
assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview());
assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview());assertTrue(q.hideZero());
assertFalse(request(Map.of("hideZeroInventory",false)).hideZero());
assertTrue(request(Map.of("hideZeroInventory",true)).hideZero());
}
@Test void validatesMultipleOwnersAndRejectsTheRemovedSingleOwnerParameter() {
request(Map.of("customerCodes",List.of("A","B"))).validate(TODAY);
request(Map.of("customerCodes",List.of())).validate(TODAY);
assertThrows(BizException.class,()->request(Map.of("customerCodes",List.of(""))).validate(TODAY));
assertThrows(BizException.class,()->request(Map.of("customerCodes",Collections.nCopies(101,"A"))).validate(TODAY));
assertThrows(BizException.class,()->InventoryTurnoverRequest.validateParameters(Set.of("customerCode")));
}
@Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() {
for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",367),
@@ -24,6 +24,8 @@ class InventoryTurnoverSqlTest {
execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)");
execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)");
execute("CREATE TABLE ll_selfop_inventory_day_version(id BIGINT PRIMARY KEY,source_account VARCHAR,warehouse_code VARCHAR,rule_version VARCHAR,invalidated_at TIMESTAMP)");
execute("CREATE TABLE ll_selfop_inventory_daily(day_version_id BIGINT,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)");
}
@AfterEach void close() throws Exception {db.close();}
private void master() throws Exception {
@@ -33,6 +35,49 @@ class InventoryTurnoverSqlTest {
private void product(String owner,String sku,String declared,String measured) throws Exception {
execute("INSERT INTO ll_selfop_product VALUES(901,'demo','"+owner+"','"+sku+"',"+declared+","+measured+")");
}
@Test void hidesOnlyKnownZeroPhysicalRowsBeforeCountAndPagingWithoutChangingOverview() throws Exception {
seed("A","POSITIVE",0);seed("A","ZERO",0);seed("B","ZERO",0);seed("C","UNKNOWN",0);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id=22 AND sku_code='ZERO'");
execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=0 WHERE snapshot_run_id=22 AND customer_code='A'");
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=NULL,quality_status='UNKNOWN' WHERE snapshot_run_id=22 AND customer_code='C'");
for(String group:List.of("SKU","OWNER")) {
var all=new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_ASC",1,20,true,"fixed",false);
var hidden=new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_ASC",1,20,true,"fixed",null);
var visible=reportRows(input(hidden,30,0,true));
assertEquals(List.of("A","C"),visible.stream().map(r->r.get("customer_code")).toList());
assertEquals(group.equals("SKU")?4:3,reportRows(input(all,30,0,true)).size());
var report=query("report",input(hidden,30,0,true));
assertEquals(2,payload(report.stream().filter(r->"TOTAL".equals(r.get("section"))).findFirst().orElseThrow()).path("total").asInt());
assertEquals(query("overview",input(all,30,0,true)),query("overview",input(hidden,30,0,true)));
for(int page=1;page<=2;page++) {
var paged=new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_ASC",page,1,false,"fixed",true);
assertEquals(visible.get(page-1).get("customer_code"),reportRows(input(paged,30,0,true)).get(0).get("customer_code"));
}
if(group.equals("SKU"))assertEquals(4,query("page",input(hidden,30,0,false)).size(),"Stale stock stays unknown, not zero");
}
var exact=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,"ZERO","ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",true);
assertTrue(reportRows(input(exact,30,0,true)).isEmpty());
}
@Test void filtersMultipleOwnersBeforeAggregationAndPaginationWithoutMergingSameSku() throws Exception {
seed("A","SAME",30);seed("B","SAME",30);seed("C","SAME",30);
for(String group:List.of("SKU","OWNER")) {
var selected=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("B","A","A"),null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false);
var p=input(selected,30,30,true);
var rows=reportRows(p);
assertEquals(List.of("A","B"),rows.stream().map(r->r.get("customer_code")).toList());
var all=input(new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false),30,30,true);
assertEquals(3,reportRows(all).size());
for(int page=1;page<=2;page++) {
var q=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("A","B"),null,"ALL",null,null,"PHYSICAL_DESC",page,1,false,"fixed",false);
assertEquals(rows.get(page-1).get("customer_code"),reportRows(input(q,30,30,true)).get(0).get("customer_code"));
}
var overview=query("overview",p);
assertFalse(overview.isEmpty());
assertNotEquals(query("overview",all),overview);
var unknown=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("' OR 1=1 --"),null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false);
assertTrue(reportRows(input(unknown,30,30,true)).isEmpty());
}
}
@Test void ownerVolumeChartRanksFullTotalsExcludesUnknownsAndIgnoresListFilters() throws Exception {
master();
for(int i=0;i<12;i++) {
@@ -43,7 +88,7 @@ class InventoryTurnoverSqlTest {
product("UNKNOWN","SAME","100,1,1,'m'","NULL,NULL,NULL,NULL");
execute("UPDATE ll_selfop_inventory_snapshot SET quality_status='INVALID' WHERE customer_code='UNKNOWN'");
for(int days:List.of(7,30)) {
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,"not-present","FROZEN",null,null,"PHYSICAL_DESC",2,1,true,"fixed");
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,"not-present","FROZEN",null,null,"PHYSICAL_DESC",2,1,true,"fixed",false);
var report=query("report",input(q,30,0,true));
var ranking=new ArrayList<com.fasterxml.jackson.databind.JsonNode>();
for(var part:report)if("OWNER_VOLUME".equals(part.get("section")))ranking.add(payload(part));
@@ -78,7 +123,7 @@ class InventoryTurnoverSqlTest {
: direction.equals("ASC")?List.of("A","D","B","C"):List.of("B","A","D","C");
var actual=new ArrayList<Object>();
for(int page=1;page<=2;page++) {
var q=new InventoryTurnoverRequest("demo","W",END,days,group,null,null,"ALL",null,null,metric+"_"+direction,page,2,true,"fixed");
var q=new InventoryTurnoverRequest("demo","W",END,days,group,null,null,"ALL",null,null,metric+"_"+direction,page,2,true,"fixed",false);
q.validate(END.plusDays(1));
actual.addAll(reportRows(input(q,30,0,true)).stream().map(r->r.get("customer_code")).toList());
}
@@ -142,7 +187,7 @@ class InventoryTurnoverSqlTest {
decimal("1",reportRows(later).get(0).get("unit_volume"));
}
static InventoryTurnoverRequest request(String group,String focus,String sort,String sku,Integer threshold,int page,int size) {
return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null);
return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null,false);
}
static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) {
List<InventoryTurnoverSql.Day> days=new ArrayList<>();
@@ -153,7 +198,7 @@ class InventoryTurnoverSqlTest {
seed("A","SKU",1);master();product("A","SKU","10,10,10,'cm'","NULL,NULL,NULL,NULL");
execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '2025-01-01 00:00:00'");
for(int n:List.of(3,45,366)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false);
var dates=new ArrayList<InventoryTurnoverSql.Day>();
int horizon=Math.max(30,n);
for(int i=0;i<horizon;i++) {
@@ -242,7 +287,7 @@ class InventoryTurnoverSqlTest {
}
@Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception {
seed("A","SKU",30);
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='UNIT_UNKNOWN' WHERE day_version_id=100");
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
var row=query("page",p).get(0);
decimal("300",row.get("w30_outbound"));decimal("60",row.get("coverage_days"));
@@ -258,7 +303,7 @@ class InventoryTurnoverSqlTest {
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
var row=query("page",p).get(0);
decimal("0",row.get("turnover"));decimal("1",row.get("no_outbound"));assertNull(row.get("coverage_days"));
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100");
row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover"));
}
@Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception {
@@ -311,7 +356,7 @@ class InventoryTurnoverSqlTest {
@Test void independentBaseWindowsUseMatchingHistoricalAverages() throws Exception {
seed("A","SKU",30);
for(int days:List.of(7,15)) {
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false);
var row=query("page",input(q,30,30,true)).get(0);
decimal("70",row.get("w7_outbound"));decimal("150",row.get("w15_outbound"));decimal("300",row.get("w30_outbound"));
decimal("60",row.get("coverage_days"));decimal(days==7?"0.14":"0.3",row.get("turnover"));decimal(String.valueOf(days),row.get("sample_count"));
@@ -353,7 +398,7 @@ class InventoryTurnoverSqlTest {
dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,present?100L:null));
if(i>=30-n&&!present)missing.add(date.toString());
}
var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null);
var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null,false);
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates),LocalDateTime.of(2026,10,13,0,0));
var row=query("page",p).get(0);
assertNotNull(row.get("sample_mask"));
@@ -366,9 +411,9 @@ class InventoryTurnoverSqlTest {
@Test void sparseSamplesKeepExactDateComplementAndFullOwnerDenominator() throws Exception {
seed("A","ONE",30);seed("A","TWO",30);
// Both edge bits and a middle bit, with different missing members per day.
execute("DELETE FROM ll_selfop_inventory_snapshot WHERE warehouse_code='W' AND ((sku_code='ONE' AND snapshot_run_id IN (100,114)) OR (sku_code='TWO' AND snapshot_run_id=129))");
execute("DELETE FROM ll_selfop_inventory_daily WHERE ((sku_code='ONE' AND day_version_id IN (100,114)) OR (sku_code='TWO' AND day_version_id=129))");
for(int n:List.of(7,15,30)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null);
var q=new InventoryTurnoverRequest("demo","W",END,n,"OWNER",null,null,"ALL",null,null,null,1,20,true,null,false);
var p=input(q,30,30,true);
var row=reportRows(p).get(0);
var expected=new ArrayList<LocalDate>();
@@ -378,7 +423,7 @@ class InventoryTurnoverSqlTest {
assertNull(row.get("average_physical"));assertNull(row.get("turnover"));
assertEquals(row.get("missing_dates"),query("page",p).get(0).get("missing_dates"));
}
var pageTwo=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,1,false,null);
var pageTwo=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,1,false,null,false);
var second=reportRows(input(pageTwo,30,30,true)).get(0);
assertEquals("TWO",second.get("sku_code"));decimal("29",second.get("sample_count"));assertEquals(END.toString(),second.get("missing_dates"));
// A stock/profile-only candidate without samples invalidates all owner days.
@@ -391,12 +436,13 @@ class InventoryTurnoverSqlTest {
long first=9_007_199_254_740_993L;
for(int i=0;i<30;i++) {
execute("UPDATE ll_selfop_sku_profile SET profile_run_id="+(first+i)+" WHERE profile_run_id="+(100+i));
execute("UPDATE ll_selfop_inventory_snapshot SET snapshot_run_id="+(first+i)+",physical_quantity=CASE WHEN product_quality=0 THEN "+((i+1)*10)+" ELSE 0 END WHERE snapshot_run_id="+(100+i));
execute("UPDATE ll_selfop_inventory_day_version SET id="+(first+i)+" WHERE id="+(100+i));
execute("UPDATE ll_selfop_inventory_daily SET day_version_id="+(first+i)+",physical_quantity=CASE WHEN product_quality=0 THEN "+((i+1)*10)+" ELSE 0 END WHERE day_version_id="+(100+i));
}
List<InventoryTurnoverSql.Day> days=new ArrayList<>();
for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));}
for(int n:List.of(7,15,30)) {
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null,false);
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0));
var row=reportRows(p).get(0);
decimal(String.valueOf(n),row.get("sample_count"));
@@ -412,12 +458,12 @@ class InventoryTurnoverSqlTest {
@Test void reportPaginationKeepsTotalAndCanOmitOverview() throws Exception {
seed("A","SKU",30);seed("B","SKU",30);
var first=reportRows(input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,30,true));
var secondQuery=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,1,false,"frozen");
var secondQuery=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",2,1,false,"frozen",false);
var second=query("report",input(secondQuery,30,30,true));
var row=second.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow();
assertNotEquals(first.get(0).get("customer_code"),payload(row).path("customer_code").asText());
assertEquals(2,second.size());
var beyond=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",3,1,false,"frozen");
var beyond=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",3,1,false,"frozen",false);
var report=query("report",input(beyond,30,30,true));assertEquals(1,report.size());
assertEquals("TOTAL",report.get(0).get("section"));assertEquals(2,payload(report.get(0)).path("total").asInt());
}
@@ -432,7 +478,7 @@ class InventoryTurnoverSqlTest {
for(String group:List.of("SKU","OWNER")) {
var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true);
var unknown=reportRows(p).get(0);assertNull(unknown.get("w30_outbound"));assertNull(unknown.get("w30_observed"));
var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.sampleRun())).toList();
var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.historyVersionId())).toList();
var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays,LocalDateTime.of(2026,10,13,0,0))).get(0);
assertNull(partial.get("w30_outbound"));decimal("0",partial.get("w30_observed"));decimal("30",partial.get("w30_partial"));
var complete=reportRows(input(p.query(),30,30,true)).get(0);
@@ -498,16 +544,16 @@ class InventoryTurnoverSqlTest {
}
@Test void presentInvalidSamplesNeverQualifyAndOwnerZeroNeedsEverySku() throws Exception {
seed("A","ONE",30);seed("A","TWO",30);
String key=" WHERE snapshot_run_id=100 AND sku_code='TWO' AND product_quality=0";
for(String mutation:List.of("physical_quantity=NULL","physical_quantity=-1","product_quality=9","quality_status='UNKNOWN',quality_reason='UNCONFIRMED'","profile")) {
if("profile".equals(mutation))execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100 AND sku_code='TWO'");
else execute("UPDATE ll_selfop_inventory_snapshot SET "+mutation+key);
String key=" WHERE day_version_id=100 AND sku_code='TWO' AND product_quality=0";
for(String mutation:List.of("physical_quantity=NULL","physical_quantity=-1","product_quality=9","quality_status='UNKNOWN',quality_reason='UNCONFIRMED'")) {
if("profile".equals(mutation))execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=NULL,quality_status='UNKNOWN',quality_reason='UNIT_UNKNOWN' WHERE day_version_id=100 AND sku_code='TWO'");
else execute("UPDATE ll_selfop_inventory_daily SET "+mutation+key);
var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0);
decimal("29",owner.get("sample_count"));assertNull(owner.get("turnover"));assertEquals(END.minusDays(29).toString(),owner.get("missing_dates"));
execute("DELETE FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=100 AND sku_code='TWO'");stock(100,"A","TWO",0,450,450);stock(100,"A","TWO",1,50,0);
execute("DELETE FROM ll_selfop_inventory_daily WHERE day_version_id=100 AND sku_code='TWO'");stock(100,"A","TWO",0,450,450);stock(100,"A","TWO",1,50,0);
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='PCS' WHERE profile_run_id=100 AND sku_code='TWO'");
}
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100");
var owner=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0);
decimal("30",owner.get("sample_count"));decimal("0",owner.get("average_physical"));assertNull(owner.get("turnover"));
}
@@ -525,6 +571,7 @@ class InventoryTurnoverSqlTest {
String amount="99999999999999.123456";var quantity=new BigDecimal(amount);
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity="+amount+" WHERE warehouse_code='W'");
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN product_quality=0 THEN "+amount+" ELSE 0 END");
var report=query("report",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true));
var row=payload(report.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow());
decimal(quantity.multiply(BigDecimal.valueOf(120)).toPlainString(),row.path("w30_outbound").decimalValue());
@@ -579,7 +626,7 @@ class InventoryTurnoverSqlTest {
}
@Test void tinyQualifiedDailySumUsesOneDivisionForSkuAndOwnerThroughProductionCtes() throws Exception {
seed("A","ONE",30);seed("A","TWO",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN snapshot_run_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE snapshot_run_id>=100");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN day_version_id=100 AND product_quality=0 THEN CASE WHEN sku_code='ONE' THEN 0.000001 ELSE 0.000002 END ELSE 0 END WHERE day_version_id>=100");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE WHEN sku_code='ONE' THEN 1 ELSE 2 END ELSE 0 END WHERE warehouse_code='W'");
for(String group:List.of("SKU","OWNER")) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);var p=input(q,30,30,true);
@@ -593,7 +640,7 @@ class InventoryTurnoverSqlTest {
}
for(var row:query("page",p))decimal("30000000",row.get("turnover"));
}
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=0 WHERE day_version_id>=100");
for(String group:List.of("SKU","OWNER")) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,1,20);
for(var row:reportRows(input(q,30,30,true))) {
@@ -625,7 +672,7 @@ class InventoryTurnoverSqlTest {
}
@Test void turnoverOrderingUsesUnroundedRatioBeforeStableSkuAndOwnerPages() throws Exception {
seed("Z","HIGH",30);seed("A","TIE",30);seed("B","TIE",30);seed("L","LOW",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE snapshot_run_id>=100");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE WHEN product_quality=0 THEN 10 ELSE 0 END WHERE day_version_id>=100");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE WHEN business_date=DATE '"+END+"' THEN CASE sku_code WHEN 'HIGH' THEN 10.000001 WHEN 'LOW' THEN 9.999999 ELSE 10 END ELSE 0 END WHERE warehouse_code='W'");
for(String group:List.of("SKU","OWNER")) for(int page=1;page<=4;page++) {
var q=request(group,"ALL","TURNOVER_DESC",null,null,page,1);
@@ -644,6 +691,7 @@ class InventoryTurnoverSqlTest {
void numericSortsUseFormalValuesInBothDirectionsAndKeepUnknownLast(String metric) throws Exception {
seed("L","SKU",30);seed("H","SKU",30);seed("U","SKU",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,available_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END,transit_quantity=CASE customer_code WHEN 'L' THEN 2 WHEN 'H' THEN 8 ELSE NULL END,frozen_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 9 ELSE NULL END");
execute("UPDATE ll_selfop_inventory_daily SET physical_quantity=CASE customer_code WHEN 'L' THEN 100 WHEN 'H' THEN 1000 ELSE NULL END");
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=CASE customer_code WHEN 'L' THEN 1 WHEN 'H' THEN 5 ELSE 9999 END,quantity_unit=CASE customer_code WHEN 'U' THEN 'UNKNOWN' ELSE 'PCS' END");
for(String group:List.of("SKU","OWNER")) {
if(group.equals("OWNER")&&metric.equals("COVERAGE")) continue;
@@ -665,7 +713,7 @@ class InventoryTurnoverSqlTest {
execute("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='UNKNOWN',outbound_quantity=9999 WHERE sku_code='UNKNOWN'");
for(int days:List.of(7,15,30)) {
for(int page=1;page<=3;page++) {
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,"AVERAGE_DAILY_DESC",page,1,true,"fixed-context");
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,"AVERAGE_DAILY_DESC",page,1,true,"fixed-context",false);
var expected=days==7?List.of("RECENT","OLDER","UNKNOWN"):List.of("OLDER","RECENT","UNKNOWN");
assertEquals(expected.get(page-1),reportRows(input(q,30,0,true)).get(0).get("sku_code"));
}
@@ -699,6 +747,10 @@ class InventoryTurnoverSqlTest {
execute("INSERT INTO ll_selfop_sku_profile VALUES("+run+",'demo','W','"+owner+"','"+sku+"','Fixture','PCS','INVENTORY_OBSERVATION',1,TIMESTAMP '2026-01-01 00:00:00')");
}
private void stock(long run,String owner,String sku,int quality,int physical,int available) throws Exception {
if(run>=100) {
execute("MERGE INTO ll_selfop_inventory_day_version KEY(id) VALUES("+run+",'demo','W','FLOW_EOD_V1',NULL)");
execute("INSERT INTO ll_selfop_inventory_daily VALUES("+run+",'"+owner+"','"+sku+"',"+quality+","+physical+",'VALID',NULL)");return;
}
execute("INSERT INTO ll_selfop_inventory_snapshot VALUES("+run+",'demo','W','"+owner+"','"+sku+"',"+quality+","+physical+","+available+",0,0,'VALID',NULL)");
}
private void execute(String sql) throws Exception {try(var s=db.createStatement()){s.execute(sql);}}
@@ -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 historyPagesRequireExactFrozenSliceCheckpointAndEnabledWorker(){
continuousBusinessRoot();cfg.setInventoryHistoryEnabled(true);
var slice=new com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice("A",now().minusDays(1).withNano(0),now().withNano(0),1000);
task.setTaskType(com.baiyue.lastleg.selfoperated.analytics.history.InventoryFlowSlice.TYPE);
task.setScopeHash(SelfopSupport.hash(slice));task.setRequestHash(task.getScopeHash());task.setQueryScopeJson(SelfopSupport.json(slice));
task.setPlannedAtUtc(now().toLocalDate().atStartOfDay());task.setQueryFromUtc(LocalDateTime.ofInstant(slice.from(),ZoneOffset.UTC));task.setQueryUntilUtc(LocalDateTime.ofInstant(slice.until(),ZoneOffset.UTC));
task.setRequestKey("continuous:history:"+task.getPlannedAtUtc().toLocalDate()+":"+task.getScopeHash());
run.setProgressSnapshotJson("{\"nextPage\":1}");
assertThat(SelfopCollectionPolicy.continuousBusinessRoot(task)).isTrue();
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"B",null,slice.requestHash(1))).hasMessage("EGRESS_HISTORY_INVALID");
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(2))).hasMessage("EGRESS_HISTORY_INVALID");
cfg.setInventoryHistoryEnabled(false);
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(1))).hasMessage("EGRESS_HISTORY_INVALID");
cfg.setInventoryHistoryEnabled(true);
var first=service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(1));success(first,Operation.INVENTORY_FLOW);
run.setProgressSnapshotJson("{\"nextPage\":2}");
var second=service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,slice.requestHash(2));success(second,Operation.INVENTORY_FLOW);
assertThat(businessBudget().http).isEqualTo(2);assertThat(budget().confirmedHttp).isEqualTo(2);
}
@Test void masterDataOperationsUseBoundBusinessTaskAndExistingSingleFlightGate(){
continuousBusinessRoot();task.setTaskType("MASTER_DATA");task.setQueryFromUtc(null);task.setQueryUntilUtc(null);
task.setPlannedAtUtc(now().plusHours(8).toLocalDate().atStartOfDay().minusHours(8));
@@ -44,6 +63,21 @@ class SelfopEgressServiceTest {
success(c,Operation.CUSTOMERS);success(reserve(Operation.PRODUCTS,100,null,null),Operation.PRODUCTS);
assertThat(budget().confirmedHttp).isEqualTo(2);
}
@Test void flowProbeIsTestOnlyBoundToFrozenHashAndExactlyOneAttempt(){
testBusiness(5,300);task.setTaskType("INVENTORY_FLOW_PROBE");task.setRequestHash(INTERNAL);
assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,100,null,null)).hasMessage("EGRESS_FLOW_PROBE_INVALID");
assertThatThrownBy(()->reserve(Operation.INVENTORY_FLOW,1000,"OTHER",null)).hasMessage("EGRESS_FLOW_PROBE_INVALID");
assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,PROVIDER)).hasMessage("EGRESS_FLOW_PROBE_INVALID");
var c=reserve(Operation.INVENTORY_FLOW,1000,"A",null);success(c,Operation.INVENTORY_FLOW);
assertThatThrownBy(()->reserve(Operation.INVENTORY_FLOW,1000,"A",null)).hasMessage("EGRESS_FLOW_PROBE_INVALID");
assertThat(businessBudget().http).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1);
}
@Test void flowPermissionFailureDoesNotPauseExistingSalesAccount(){
testBusiness(5,300);task.setTaskType("INVENTORY_FLOW_PROBE");task.setRequestHash(INTERNAL);
var c=reserve(Operation.INVENTORY_FLOW,1000,"A",null);service.authorize(new AuthorizationRequest(c,Operation.INVENTORY_FLOW,PROVIDER));
service.complete(new CompletionRequest(c,Operation.INVENTORY_FLOW,PROVIDER,1,"LINGXING_WMS_FLOW_PERMISSION_DENIED",200,11008,false,null));
assertThat(account.getApiPauseReason()).isNull();assertThat(budget().confirmedHttp).isEqualTo(1);
}
@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");
@@ -0,0 +1,55 @@
package com.baiyue.lastleg.selfoperated.analytics.sync;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDateTime;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;
import com.baiyue.lastleg.selfoperated.analytics.model.*;
import com.baiyue.lastleg.selfoperated.analytics.mapper.*;
import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient;
import org.springframework.transaction.support.TransactionTemplate;
class InventoryFlowProbeServiceTest {
@Test void successfulProbeIsAuditedReplayedAndNeverPublished() throws Exception {
var cfg=new SelfopAnalyticsProperties();cfg.setEnvironment("test");cfg.setCollectionMode("BUSINESS");cfg.setSourceAccount("demo");
cfg.getWarehouses().put("W",new SelfopAnalyticsProperties.Warehouse());
var tasks=mock(SelfopSyncTaskMapper.class);var runs=mock(SelfopSyncRunMapper.class);var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class);
var leases=mock(SelfopLeaseService.class);var client=mock(RestLingxingAnalyticsClient.class);var tx=mock(TransactionTemplate.class);
doAnswer(i->{((java.util.function.Consumer<org.springframework.transaction.TransactionStatus>)i.getArgument(0)).accept(null);return null;}).when(tx).executeWithoutResult(any());
var a=new SelfopOutboundDiscoveryScope();a.setId(1L);when(accounts.selectOne(any())).thenReturn(a);
var lease=new SelfopLeaseService.Lease(1L,"probe",2L,true);when(leases.acquireDiscovery(eq(1L),anyString())).thenReturn(lease);
var saved=new java.util.concurrent.atomic.AtomicReference<SelfopSyncTask>();
when(tasks.selectOne(any())).thenAnswer(i->saved.get());when(tasks.insert(any(SelfopSyncTask.class))).thenAnswer(i->{saved.set(i.getArgument(0));return 1;});
var page=new ObjectMapper().readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":1,\"records\":[{\"id\":\"1\"}]}");
when(client.inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any())).thenReturn(page);
var s=new InventoryFlowProbeService(cfg,tasks,runs,accounts,leases,client,tx);
var end=LocalDateTime.of(2026,1,1,0,0);var q=new InventoryFlowProbeService.Request("one","W","C","S",end.minusDays(30),end);
var first=s.probe(q,"operator");assertTrue(first.complete());assertEquals(first,s.probe(q,"operator"));
assertEquals("SAMPLE_DONE",saved.get().getStatus());assertNull(saved.get().getResultPublishedRunId());
var capture=org.mockito.ArgumentCaptor.forClass(SelfopSyncRun.class);verify(runs).insert(capture.capture());
assertEquals("SAMPLE_DONE",capture.getValue().getStatus());assertNull(capture.getValue().getPublishedAtUtc());assertNull(capture.getValue().getInventoryDailySampleDate());
verify(client,times(1)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any());verify(leases,times(2)).release(lease);
assertThrows(IllegalStateException.class,()->s.probe(new InventoryFlowProbeService.Request("one","W","C","OTHER",q.startTime(),q.endTime()),"operator"));
verify(client,times(1)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any());
saved.set(null);when(client.inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any())).thenThrow(new IllegalStateException("PROVIDER_FAILURE"));
assertThrows(IllegalStateException.class,()->s.probe(q,"operator"));assertEquals("DEAD",saved.get().getStatus());
assertThrows(IllegalStateException.class,()->s.probe(q,"operator"));
verify(client,times(2)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any());
}
@Test void refusesFutureAndOversizedWindow() {
var now=LocalDateTime.of(2026,10,15,12,0);
InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now.minusDays(30),now),now);
assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now.minusDays(32),now),now));
assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now,now.plusSeconds(1)),now));
}
@Test void refusesPartialPageAndDuplicateEvidence() throws Exception {
var mapper=new ObjectMapper();
assertTrue(InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":1,\"records\":[{\"id\":\"1\"}]}")));
assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":2,\"records\":[{\"id\":\"1\"}]}")));
assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":2,\"records\":[{\"id\":\"1\"},{\"id\":\"1\"}]}")));
var root=mapper.createObjectNode().put("current",1).put("size",1000).put("pages",2).put("total",1001);
var rows=root.putArray("records");for(int i=0;i<1000;i++)rows.addObject().put("id",String.valueOf(i));
assertFalse(InventoryFlowProbeService.validatePage(root));
}
}