fix: finalize inventory turnover queries and reuse snapshot aggregation

This commit is contained in:
2026-10-14 01:51:51 +08:00
parent d029f9a19e
commit 16d0b9c80f
10 changed files with 527 additions and 28 deletions
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.selfoperated.analytics.controller;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.lastleg.selfoperated.analytics.query.*;
import java.time.LocalDate;
import java.util.*;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.core.MethodParameter;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.*;
import org.springframework.web.server.ResponseStatusException;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class PlatformInventoryTurnoverControllerTest {
private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));}
@Test void enforcesIndependentPlatformReadPermissionAndStrictParameterNames() {
var service=mock(InventoryTurnoverQueryService.class);var controller=new PlatformInventoryTurnoverController(service);
var customer=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));
var writer=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync"));
assertThrows(ResponseStatusException.class,()->controller.query(null,null,Map.of()));
assertThrows(ResponseStatusException.class,()->controller.query(customer,null,Map.of()));
assertThrows(ResponseStatusException.class,()->controller.query(writer,null,Map.of()));
assertThrows(BizException.class,()->controller.query(platform(),null,Map.of("attention","ALL")));
assertThrows(BizException.class,()->controller.query(platform(),null,Map.of("salesRunId","11")));
verifyNoInteractions(service);
assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformInventoryTurnoverController.class.getAnnotation(PreAuthorize.class).value());
}
@Test void httpBindsFrozenTurnoverContractWithoutTouchingCollection() throws Exception {
var service=mock(InventoryTurnoverQueryService.class);
var identity=new HandlerMethodArgumentResolver(){
public boolean supportsParameter(MethodParameter p){return p.getParameterType()==AuthenticatedUser.class;}
public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory b){return platform();}
};
var mvc=MockMvcBuilders.standaloneSetup(new PlatformInventoryTurnoverController(service)).setCustomArgumentResolvers(identity).build();
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("coverageDaysThreshold","60").param("current","2").param("size","20")
.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());
}
}
@@ -0,0 +1,102 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.*;
import java.util.*;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
class InventoryTurnoverContextTest {
private static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z");
private static final LocalDate END=LocalDate.of(2026,10,12);
private static final String SH="a".repeat(64),IH="b".repeat(64);
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
private AnalyticsReadMapper analytics;
private InventoryTurnoverReadMapper mapper;
private InventoryTurnoverContext contexts;
@BeforeEach void setup() {
analytics=mock(AnalyticsReadMapper.class);mapper=mock(InventoryTurnoverReadMapper.class);
contexts=at(NOW);
when(analytics.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L)));
for(var run:List.of(run(11,1,SH),run(22,2,IH))) {
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.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END);
}
@Test void sameContextFreezesAllThirtyDatesAndMissingSamples() {
var first=contexts.resolve(request(null)).response();
assertEquals(30,first.dailySamples().size());assertEquals(29,first.missingSampleDates().size());
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());
verify(mapper,times(2)).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());
assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext())));
}
@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));
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);
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)));
assertThrows(BizException.class,()->contexts.resolve(request("not-a-token")));
}
@Test void absentVersionsStayAbsentAndUnconfiguredWarehouseIsRejected() {
when(mapper.versionAt(anyLong(),anyString(),any())).thenReturn(null);
var c=contexts.resolve(request(null)).response();
assertEquals(0L,c.analyticsContext().salesRunId());assertEquals(0L,c.analyticsContext().inventoryRunId());
assertEquals("UNKNOWN",c.analyticsContext().inventoryStatus());
assertEquals(c,contexts.resolve(request(c.snapshotContext())).response());
when(analytics.scopes("demo","W")).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(request(null)));
}
@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)));
assertThrows(BizException.class,()->contexts.resolve(request(null)));
when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH));
assertThrows(BizException.class,()->contexts.resolve(request(null)));
}
@Test void localHistoryCoverageSurvivesAgeButRebuildDoesNotAuthorizeFormalSales() {
var old=at(NOW.plusSeconds(200000)).resolve(request(null));
assertEquals("STALE",old.response().analyticsContext().inventoryStatus());
assertTrue(InventoryTurnoverSql.input(request(null),old).days().stream().allMatch(d->d.coverage()==1));
when(analytics.applyBarriers("demo","W")).thenReturn(1L);
var rebuilding=contexts.resolve(request(null));
assertTrue(InventoryTurnoverSql.input(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==0));
}
@Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() {
var service=new InventoryTurnoverQueryService(contexts,mapper,json);
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"),
Map.of("section","OVERVIEW","payload","{\"sku_count\":0,\"physical\":0,\"available\":0,\"no_outbound_count\":0,\"unassessable\":0}")));
var first=service.query(request(null));assertEquals(0,first.total());assertNotNull(first.summary());assertNotNull(first.charts());
verify(mapper).report(any());verify(mapper).chartWindows(any());
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 next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any());
}
private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC));}
static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token);}
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
private static Scope scope(long id,String family,String hash,long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,null,null,7);}
private static Run run(long id,long scope,String hash){
return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash,
"{\"businessTimezone\":\"Asia/Shanghai\",\"sourceTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600}","PUBLISHED","COMPLETE",
END.minusDays(29).atStartOfDay().minusHours(8),END.plusDays(1).atStartOfDay().minusHours(8),END.minusDays(29),END.plusDays(1),
utc(NOW.minusSeconds(3660)),utc(NOW.minusSeconds(3600)),utc(NOW.minusSeconds(3500)),1L,null,null,null,"BUSINESS",true);
}
}
@@ -0,0 +1,31 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import com.baiyue.common.core.exception.BizException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.LocalDate;
import java.util.*;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class InventoryTurnoverRequestTest {
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
private static final LocalDate TODAY=LocalDate.of(2026,10,13);
private InventoryTurnoverRequest request(Map<String,Object> override) {
Map<String,Object> values=new HashMap<>();values.put("sourceAccount","demo");values.put("warehouseCode","W");values.putAll(override);
return json.convertValue(values,InventoryTurnoverRequest.class);
}
@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());
}
@Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() {
for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",10),
Map.<String,Object>of("groupBy","OWNER","sku","S"),Map.<String,Object>of("groupBy","OWNER","sortBy","COVERAGE_DESC"),
Map.<String,Object>of("sortBy","NO_OUTBOUND_PHYSICAL_DESC"),Map.<String,Object>of("current",2),
Map.<String,Object>of("size",101),Map.<String,Object>of("coverageDaysThreshold",0),Map.<String,Object>of("focus","COVERAGE_GT"),
Map.<String,Object>of("snapshotContext"," "),Map.<String,Object>of("turnoverStatus","VALID"))) {
assertThrows(BizException.class,()->request(invalid).validate(TODAY),invalid.toString());
}
request(Map.of("groupBy","OWNER","sortBy","NO_OUTBOUND_PHYSICAL_DESC","current",2,"snapshotContext","evidence")).validate(TODAY);
}
}
@@ -74,6 +74,69 @@ class InventoryTurnoverSqlTest {
var row=query("page",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),7,0,true)).get(0);
decimal("0",row.get("coverage_days"));decimal("1",row.get("unassessable"));
}
@Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception {
seed("A","SKU",30);
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_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"));
decimal("29",row.get("sample_count"));assertNull(row.get("turnover"));
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=11");
assertNull(query("page",p).get(0).get("w30_outbound"));
var owner=query("page",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0);
decimal("300",owner.get("w30_outbound"));
}
@Test void zeroSalesAndZeroAverageHaveDifferentTurnoverResults() throws Exception {
seed("A","SKU",30);
execute("DELETE FROM ll_selfop_sku_daily_sales WHERE warehouse_code='W'");
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");
row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover"));
}
@Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception {
var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20);
var covered=query("chartWindows",input(q,30,0,true)).get(0);
decimal("0",covered.get("w30_outbound"));decimal("30",covered.get("w30_complete"));
var absent=query("chartWindows",input(q,0,0,false)).get(0);
assertNull(absent.get("w30_outbound"));assertNull(absent.get("w30_observed"));
decimal("0",query("overview",input(q,30,0,true)).get(0).get("no_outbound_count"));
}
@Test void fullScopeFilterPrecedesPaginationAndKeepsSoldOutHistoricalCandidates() throws Exception {
seed("A","LOW",30);seed("A","HIGH",30);seed("B","SOLD",30);
execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=3000,physical_quantity=3000 WHERE sku_code='HIGH' AND snapshot_run_id=22 AND product_quality=0");
execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=0,physical_quantity=0 WHERE sku_code='SOLD' AND snapshot_run_id=22");
var p=input(request("SKU","COVERAGE_GT","COVERAGE_DESC",null,100,1,1),30,30,true);
var selected=query("page",p);assertEquals(1,selected.size());assertEquals("HIGH",selected.get(0).get("sku_code"));
decimal("1",query("count",p).get(0).values().iterator().next());
decimal("3",query("overview",p).get(0).get("sku_count"));
var owners=query("page",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true));
var sold=owners.stream().filter(r->"B".equals(r.get("customer_code"))).findFirst().orElseThrow();
decimal("0",sold.get("physical"));decimal("300",sold.get("w30_outbound"));decimal("0.6",sold.get("turnover"));
}
@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 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"));
}
}
@Test void sharedReportMatchesStandaloneValuesAndKeepsPageOrder() throws Exception {
seed("A","SKU",30);seed("B","SKU",30);
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=900 WHERE customer_code='B' AND snapshot_run_id=22 AND product_quality=0");
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,30,true);
var report=query("report",p);var json=new com.fasterxml.jackson.databind.ObjectMapper();
var page=report.stream().filter(r->"ROW".equals(r.get("section"))).toList();assertEquals(1,page.size());
Object raw=page.get(0).get("payload");var row=raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString());
assertEquals("B",row.path("customer_code").asText());decimal("300",row.path("w30_outbound").decimalValue());decimal("0.6",row.path("turnover").decimalValue());
var total=report.stream().filter(r->"TOTAL".equals(r.get("section"))).findFirst().orElseThrow().get("payload");
var count=total instanceof byte[] bytes?json.readTree(bytes):json.readTree(total.toString());assertEquals(2,count.path("total").asInt());
assertEquals(2,report.stream().filter(r->"OWNER".equals(r.get("section"))).count());
assertEquals(1,report.stream().filter(r->"OVERVIEW".equals(r.get("section"))).count());
}
private void seed(String owner,String sku,int samples) throws Exception {
profile(11,owner,sku);profile(22,owner,sku);stock(22,owner,sku,0,600,600);stock(22,owner,sku,1,10,0);
for(int i=0;i<30;i++) {