feat(selfop): reconstruct historical inventory and publish qualified turnover
This commit is contained in:
+42
@@ -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));
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
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));
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
+87
@@ -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");
|
||||
}
|
||||
}
|
||||
+22
-10
@@ -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() {
|
||||
@@ -40,10 +41,12 @@ class InventoryTurnoverContextTest {
|
||||
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() {
|
||||
@@ -53,14 +56,12 @@ class InventoryTurnoverContextTest {
|
||||
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(),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,false);
|
||||
assertThrows(BizException.class,()->contexts.resolve(another));
|
||||
@@ -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)));
|
||||
@@ -141,6 +142,17 @@ class InventoryTurnoverContextTest {
|
||||
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,false);}
|
||||
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
|
||||
|
||||
+23
-14
@@ -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 {
|
||||
@@ -285,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"));
|
||||
@@ -301,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 {
|
||||
@@ -409,7 +411,7 @@ 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,false);
|
||||
var p=input(q,30,30,true);
|
||||
@@ -434,7 +436,8 @@ 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));}
|
||||
@@ -475,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);
|
||||
@@ -541,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"));
|
||||
}
|
||||
@@ -568,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());
|
||||
@@ -622,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);
|
||||
@@ -636,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))) {
|
||||
@@ -668,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);
|
||||
@@ -687,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;
|
||||
@@ -742,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);}}
|
||||
|
||||
+19
@@ -29,6 +29,25 @@ class SelfopEgressServiceTest {
|
||||
private void success(EgressContext c,Operation op){service.authorize(new AuthorizationRequest(c,op,PROVIDER));service.complete(new CompletionRequest(c,op,PROVIDER,1,null,200,200,false,null));clock.advance(5);}
|
||||
private SelfopApiBudget budget(){return SelfopSupport.read(account.getApiBudgetJson(),SelfopApiBudget.class);}
|
||||
private SelfopValidationBudget rootBudget(){return SelfopSupport.read(task.getValidationBudgetJson(),SelfopValidationBudget.class);}
|
||||
@Test void 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));
|
||||
|
||||
Reference in New Issue
Block a user