Merge remote-tracking branch 'origin/test' into test
# Conflicts: # deploy/sql/README.md
This commit is contained in:
+22
@@ -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());
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
+175
@@ -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();
|
||||
}
|
||||
}
|
||||
+164
@@ -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);
|
||||
}
|
||||
}
|
||||
+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");
|
||||
}
|
||||
}
|
||||
+38
@@ -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)));
|
||||
}
|
||||
}
|
||||
+28
-16
@@ -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){
|
||||
|
||||
+84
@@ -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"));
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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),
|
||||
|
||||
+78
-26
@@ -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);}}
|
||||
|
||||
+34
@@ -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");
|
||||
|
||||
+55
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user