feat(selfop): hide known zero inventory before turnover pagination

This commit is contained in:
2026-10-15 10:29:42 +08:00
parent b7fb3a5054
commit 72d7ed6962
7 changed files with 52 additions and 24 deletions
@@ -10,12 +10,13 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod
@DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, Integer baseWindowDays, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, Integer baseWindowDays,
String groupBy, String customerCode, String sku, String focus, Integer coverageDaysThreshold, String groupBy, String customerCode, String sku, String focus, Integer coverageDaysThreshold,
String turnoverStatus, String sortBy, Integer current, Integer size, Boolean includeOverview, String turnoverStatus, String sortBy, Integer current, Integer size, Boolean includeOverview,
String snapshotContext) { String snapshotContext, Boolean hideZeroInventory) {
private static final Set<String> PARAMETERS=Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays", private static final Set<String> PARAMETERS=Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays",
"groupBy","customerCode","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext"); "groupBy","customerCode","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext","hideZeroInventory");
public static void validateParameters(Set<String> keys) { public static void validateParameters(Set<String> keys) {
if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数"); if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数");
} }
public boolean hideZero(){return !Boolean.FALSE.equals(hideZeroInventory);}
public int days(){return baseWindowDays==null?30:baseWindowDays;} public int days(){return baseWindowDays==null?30:baseWindowDays;}
public java.util.List<Integer> windows(){return java.util.stream.Stream.of(7,15,30,days()).distinct().sorted().toList();} public java.util.List<Integer> windows(){return java.util.stream.Stream.of(7,15,30,days()).distinct().sorted().toList();}
public String group(){return groupBy==null?"SKU":groupBy;} public String group(){return groupBy==null?"SKU":groupBy;}
@@ -209,6 +209,7 @@ public final class InventoryTurnoverSql {
case "UNASSESSABLE" -> "unassessable>0"; case "UNASSESSABLE" -> "unassessable>0";
default -> "1=1"; default -> "1=1";
}; };
if(p.query.hideZero()) predicate+=" AND (physical IS NULL OR physical<>0)";
if(p.query.turnoverStatus()!=null) predicate+=" AND sample_count<#{p.query.days}"; if(p.query.turnoverStatus()!=null) predicate+=" AND sample_count<#{p.query.days}";
if(!owner&&p.query.sku()!=null) predicate+=" AND sku_code=#{p.query.sku}"; if(!owner&&p.query.sku()!=null) predicate+=" AND sku_code=#{p.query.sku}";
return " WHERE "+predicate; return " WHERE "+predicate;
@@ -19,7 +19,7 @@ paths:
operationId: inventoryTurnover operationId: inventoryTurnover
summary: 单仓SKU或货主三窗口库存周转 summary: 单仓SKU或货主三窗口库存周转
description: >- description: >-
精确customerCode限定列表与概览;sku、focus及turnoverStatus只筛列表。 精确customerCode限定列表与概览;sku、focus、turnoverStatus及hideZeroInventory只筛列表。
按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。 按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。
OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。 OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。
SKU出库及无出库判断同样不受首次观察时间限制:可信完整覆盖的缺销量行可计零;实际异常单位或未覆盖日期保持未知。 SKU出库及无出库判断同样不受首次观察时间限制:可信完整覆盖的缺销量行可计零;实际异常单位或未覆盖日期保持未知。
@@ -34,6 +34,7 @@ paths:
- {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}} - {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}}
- {name: customerCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}} - {name: customerCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}}
- {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}} - {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}}
- {name: hideZeroInventory, in: query, description: 默认隐藏当前全品质实物件数明确为0的SKU或货主汇总行,保留未知;全量筛选后分页,不改变概览及图表, schema: {type: boolean, default: true}}
- name: focus - name: focus
in: query in: query
schema: {type: string, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL} schema: {type: string, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL}
@@ -44,9 +44,9 @@ class PlatformInventoryTurnoverControllerTest {
.param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12") .param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12")
.param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT") .param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT")
.param("coverageDaysThreshold","60").param("current","2").param("size","20") .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 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()); 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("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertFalse(q.hideZero());assertEquals("fixed-token",q.snapshotContext());
} }
} }
@@ -35,7 +35,7 @@ class InventoryTurnoverContextTest {
assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate()); assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate());
assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive()); assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive());
assertEquals(first,at(NOW.plusSeconds(60)).resolve(request(first.snapshotContext())).response()); 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); var second=at(NOW.plusSeconds(90)).resolve(page2);
assertEquals(first,second.response()); assertEquals(first,second.response());
assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(), assertEquals(InventoryTurnoverSql.input(request(first.snapshotContext()),contexts.resolve(request(first.snapshotContext()))).days(),
@@ -47,11 +47,11 @@ class InventoryTurnoverContextTest {
assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext()))); assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext())));
} }
@Test void fullYearContextRemainsSmallAndReconstructsEveryMissingDate() { @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(); var first=contexts.resolve(q).response();
assertEquals(366,first.dailySamples().size());assertEquals(365,first.missingSampleDates().size()); assertEquals(366,first.dailySamples().size());assertEquals(365,first.missingSampleDates().size());
assertTrue(first.snapshotContext().length()<2048); 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()); 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.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of());
assertThrows(BizException.class,()->contexts.resolve(next)); assertThrows(BizException.class,()->contexts.resolve(next));
@@ -62,7 +62,7 @@ class InventoryTurnoverContextTest {
((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L); ((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L);
String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence)); String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence));
assertThrows(BizException.class,()->contexts.resolve(request(tampered))); 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,()->contexts.resolve(another));
assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).resolve(request(token))); assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).resolve(request(token)));
assertThrows(BizException.class,()->at(NOW.minusSeconds(1)).resolve(request(token))); assertThrows(BizException.class,()->at(NOW.minusSeconds(1)).resolve(request(token)));
@@ -138,11 +138,11 @@ class InventoryTurnoverContextTest {
verify(mapper,never()).page(any());verify(mapper,never()).count(any());verify(mapper,never()).overview(any());verify(mapper,never()).owners(any()); verify(mapper,never()).page(any());verify(mapper,never()).count(any());verify(mapper,never()).overview(any());verify(mapper,never()).owners(any());
clearInvocations(mapper); clearInvocations(mapper);
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"))); 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()); 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),mock(SelfopOwnerDirectory.class));} 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 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 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){ private static Run run(long id,long scope,String hash){
@@ -16,7 +16,9 @@ class InventoryTurnoverRequestTest {
} }
@Test void defaultsToYesterdayThirtyDaysAndFirstPageOverview() { @Test void defaultsToYesterdayThirtyDaysAndFirstPageOverview() {
var q=request(Map.of("includeOverview",false));q.validate(TODAY); 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 rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() { @Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() {
for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",367), for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",367),
@@ -33,6 +33,29 @@ class InventoryTurnoverSqlTest {
private void product(String owner,String sku,String declared,String measured) throws Exception { 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+")"); 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 ownerVolumeChartRanksFullTotalsExcludesUnknownsAndIgnoresListFilters() throws Exception { @Test void ownerVolumeChartRanksFullTotalsExcludesUnknownsAndIgnoresListFilters() throws Exception {
master(); master();
for(int i=0;i<12;i++) { for(int i=0;i<12;i++) {
@@ -43,7 +66,7 @@ class InventoryTurnoverSqlTest {
product("UNKNOWN","SAME","100,1,1,'m'","NULL,NULL,NULL,NULL"); 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'"); execute("UPDATE ll_selfop_inventory_snapshot SET quality_status='INVALID' WHERE customer_code='UNKNOWN'");
for(int days:List.of(7,30)) { 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 report=query("report",input(q,30,0,true));
var ranking=new ArrayList<com.fasterxml.jackson.databind.JsonNode>(); var ranking=new ArrayList<com.fasterxml.jackson.databind.JsonNode>();
for(var part:report)if("OWNER_VOLUME".equals(part.get("section")))ranking.add(payload(part)); for(var part:report)if("OWNER_VOLUME".equals(part.get("section")))ranking.add(payload(part));
@@ -78,7 +101,7 @@ class InventoryTurnoverSqlTest {
: direction.equals("ASC")?List.of("A","D","B","C"):List.of("B","A","D","C"); : direction.equals("ASC")?List.of("A","D","B","C"):List.of("B","A","D","C");
var actual=new ArrayList<Object>(); var actual=new ArrayList<Object>();
for(int page=1;page<=2;page++) { 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)); q.validate(END.plusDays(1));
actual.addAll(reportRows(input(q,30,0,true)).stream().map(r->r.get("customer_code")).toList()); actual.addAll(reportRows(input(q,30,0,true)).stream().map(r->r.get("customer_code")).toList());
} }
@@ -142,7 +165,7 @@ class InventoryTurnoverSqlTest {
decimal("1",reportRows(later).get(0).get("unit_volume")); 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) { 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) { static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) {
List<InventoryTurnoverSql.Day> days=new ArrayList<>(); List<InventoryTurnoverSql.Day> days=new ArrayList<>();
@@ -153,7 +176,7 @@ class InventoryTurnoverSqlTest {
seed("A","SKU",1);master();product("A","SKU","10,10,10,'cm'","NULL,NULL,NULL,NULL"); 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'"); 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)) { 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>(); var dates=new ArrayList<InventoryTurnoverSql.Day>();
int horizon=Math.max(30,n); int horizon=Math.max(30,n);
for(int i=0;i<horizon;i++) { for(int i=0;i<horizon;i++) {
@@ -311,7 +334,7 @@ class InventoryTurnoverSqlTest {
@Test void independentBaseWindowsUseMatchingHistoricalAverages() throws Exception { @Test void independentBaseWindowsUseMatchingHistoricalAverages() throws Exception {
seed("A","SKU",30); seed("A","SKU",30);
for(int days:List.of(7,15)) { 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); 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("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")); 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 +376,7 @@ class InventoryTurnoverSqlTest {
dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,present?100L:null)); dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,present?100L:null));
if(i>=30-n&&!present)missing.add(date.toString()); 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 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); var row=query("page",p).get(0);
assertNotNull(row.get("sample_mask")); assertNotNull(row.get("sample_mask"));
@@ -368,7 +391,7 @@ class InventoryTurnoverSqlTest {
// Both edge bits and a middle bit, with different missing members per day. // 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_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))");
for(int n:List.of(7,15,30)) { 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 p=input(q,30,30,true);
var row=reportRows(p).get(0); var row=reportRows(p).get(0);
var expected=new ArrayList<LocalDate>(); var expected=new ArrayList<LocalDate>();
@@ -378,7 +401,7 @@ class InventoryTurnoverSqlTest {
assertNull(row.get("average_physical"));assertNull(row.get("turnover")); assertNull(row.get("average_physical"));assertNull(row.get("turnover"));
assertEquals(row.get("missing_dates"),query("page",p).get(0).get("missing_dates")); 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); 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")); 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. // A stock/profile-only candidate without samples invalidates all owner days.
@@ -396,7 +419,7 @@ class InventoryTurnoverSqlTest {
List<InventoryTurnoverSql.Day> days=new ArrayList<>(); 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 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)) { 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 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); var row=reportRows(p).get(0);
decimal(String.valueOf(n),row.get("sample_count")); decimal(String.valueOf(n),row.get("sample_count"));
@@ -412,12 +435,12 @@ class InventoryTurnoverSqlTest {
@Test void reportPaginationKeepsTotalAndCanOmitOverview() throws Exception { @Test void reportPaginationKeepsTotalAndCanOmitOverview() throws Exception {
seed("A","SKU",30);seed("B","SKU",30); 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 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 second=query("report",input(secondQuery,30,30,true));
var row=second.stream().filter(r->"ROW".equals(r.get("section"))).findFirst().orElseThrow(); 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()); assertNotEquals(first.get(0).get("customer_code"),payload(row).path("customer_code").asText());
assertEquals(2,second.size()); 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()); 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()); assertEquals("TOTAL",report.get(0).get("section"));assertEquals(2,payload(report.get(0)).path("total").asInt());
} }
@@ -665,7 +688,7 @@ class InventoryTurnoverSqlTest {
execute("UPDATE ll_selfop_sku_daily_sales SET quantity_unit='UNKNOWN',outbound_quantity=9999 WHERE sku_code='UNKNOWN'"); 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 days:List.of(7,15,30)) {
for(int page=1;page<=3;page++) { 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"); 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")); assertEquals(expected.get(page-1),reportRows(input(q,30,0,true)).get(0).get("sku_code"));
} }