diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java index 6616b91a5..09fb0048b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java @@ -5,9 +5,10 @@ import java.util.Map; /** Immutable, one-scope SQL. Qualities and days aggregate independently before joining SKU keys. */ public final class AnalyticsInventorySql { private AnalyticsInventorySql(){} - private static String scope(AnalyticsSqlRequest p){ - return " source_account=#{p.account} AND warehouse_code=#{p.warehouse}" - +(p.customer()==null?"":" AND customer_code=#{p.customer}")+(p.sku()==null?"":" AND sku_code=#{p.sku}"); + private static String scope(AnalyticsSqlRequest p){return scope(p,"");} + private static String scope(AnalyticsSqlRequest p,String prefix){ + return " "+prefix+"source_account=#{p.account} AND "+prefix+"warehouse_code=#{p.warehouse}" + +(p.customer()==null?"":" AND "+prefix+"customer_code=#{p.customer}")+(p.sku()==null?"":" AND "+prefix+"sku_code=#{p.sku}"); } private static String keys(AnalyticsSqlRequest p){return "SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.salesRun} AND"+scope(p) +" UNION SELECT customer_code,sku_code FROM ll_selfop_sku_profile WHERE profile_run_id=#{p.inventoryRun} AND"+scope(p);} @@ -15,7 +16,8 @@ public final class AnalyticsInventorySql { private static String quantity(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";} private static String defectiveQuantity(){return "CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) THEN 0 WHEN product_quality=1 THEN CASE WHEN "+valid("physical_quantity")+" THEN 1 ELSE 0 END ELSE 1 END)=1 " +"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END";} - static String cte(AnalyticsSqlRequest p){ + static String cte(AnalyticsSqlRequest p){return cte(p,false);} + private static String cte(AnalyticsSqlRequest p,boolean stockFirst){ return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name," +"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid," +"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid," @@ -28,15 +30,15 @@ public final class AnalyticsInventorySql { +quantity("transit_quantity")+" transit_quantity,MAX(CASE WHEN product_quality=0 AND "+valid("available_quantity")+" THEN available_quantity END) available_quantity," +defectiveQuantity()+" defective_physical_quantity,MAX(observed_at_utc) observed_at_utc " +"FROM ll_selfop_inventory_snapshot WHERE snapshot_run_id=#{p.inventoryRun} AND"+scope(p)+" GROUP BY customer_code,sku_code)," - +" sales AS (SELECT customer_code,sku_code,SUM(outbound_quantity) observed_quantity,SUM(outbound_order_count) order_count," - +"MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales " - +"WHERE sales_run_id=#{p.salesRun} AND"+scope(p)+" AND business_date>=#{p.from} AND business_date<#{p.until} GROUP BY customer_code,sku_code)," + +(stockFirst?stockPageKeys():"") + +salesCte(p,stockFirst) +" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid," +"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc," +stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc," +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.complete30}=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound," +"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN s.observed_quantity END observed_outbound,s.order_count " - +"FROM profiles c LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code)," + +"FROM profiles c "+(stockFirst?"JOIN stock_paged pk ON pk.customer_code=c.customer_code AND pk.sku_code=c.sku_code ":"") + +"LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code)," +" qualified AS (SELECT v.*,CASE WHEN evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN 1 ELSE 0 END observation_eligible," +"CASE WHEN unit_valid=1 AND evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN owner_outbound END outbound_quantity30d FROM values_by_sku v)," +" calculated AS (SELECT q.*,CASE WHEN available_quantity=0 THEN 0 WHEN available_quantity>0 AND outbound_quantity30d>0 THEN available_quantity*30/outbound_quantity30d END coverage_days," @@ -45,7 +47,20 @@ public final class AnalyticsInventorySql { +"WHEN physical_quantity>0 AND available_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d>0 AND available_quantity*30>outbound_quantity30d*#{o.coverageDaysThreshold} THEN 1 ELSE 0 END END above_threshold " +"FROM qualified q), result AS (SELECT c.*,CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL OR outbound_quantity30d IS NULL THEN 1 ELSE 0 END unassessable FROM calculated c) "; } - private static String stockValue(String field){return "CASE WHEN c.stock_unit_valid=1 AND #{p.stockFresh}=1 THEN st."+field+" END "+field;} + private static String salesCte(AnalyticsSqlRequest p,boolean stockFirst){String d=stockFirst?"d.":""; + return " sales AS (SELECT "+(stockFirst?"/*+ JOIN_ORDER(k,d) INDEX(d idx_selfop_daily_sku_cover) */ ":"") + +d+"customer_code,"+d+"sku_code,SUM("+d+"outbound_quantity) observed_quantity,SUM("+d+"outbound_order_count) order_count," + +"MIN(CASE WHEN "+d+"quantity_unit='PCS' AND "+d+"outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales " + +(stockFirst?"d JOIN stock_paged k ON k.customer_code=d.customer_code AND k.sku_code=d.sku_code ":"") + +"WHERE "+d+"sales_run_id=#{p.salesRun} AND"+scope(p,d)+" AND "+d+"business_date>=#{p.from} AND "+d+"business_date<#{p.until} GROUP BY "+d+"customer_code,"+d+"sku_code),"; + } + private static String stockAmount(String field){return "CASE WHEN c.stock_unit_valid=1 AND #{p.stockFresh}=1 THEN st."+field+" END";} + private static String stockValue(String field){return stockAmount(field)+" "+field;} + private static String stockPageKeys(){String amount=stockAmount("available_quantity"); + return " stock_paged AS (SELECT c.customer_code,c.sku_code FROM profiles c LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code " + +"ORDER BY "+amount+" IS NULL,"+amount+" DESC,c.sku_code ASC,c.customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}),";} + private static boolean stockFirst(AnalyticsInventoryOptions o){return "ALL".equals(o.attentionValue())&&"AVAILABLE_DESC".equals(o.sortValue()) + &&o.coverageBucket()==null&&o.coverageSelection()==null;} private static String filter(AnalyticsInventoryOptions o){ String distribution=CoverageDistributionSql.filter(o); if(!distribution.isEmpty())return " WHERE "+distribution; @@ -62,8 +77,11 @@ public final class AnalyticsInventorySql { public static String inventoryPage(Map args){var p=(AnalyticsSqlRequest)args.get("p");var o=(AnalyticsInventoryOptions)args.get("o"); // The full candidate set is filtered and sorted first. Historical dates enrich only // those immutable keys selected for this page and never participate in qualification. - String paged=cte(p)+", paged AS (SELECT * FROM result"+filter(o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset})," - +" last_sales AS (SELECT d.customer_code,d.sku_code,MAX(d.business_date) last_outbound FROM ll_selfop_sku_daily_sales d " + boolean stockFirst=stockFirst(o); + // ALL/available ordering depends only on stock; aggregate sales after selecting these keys. + String paged=cte(p,stockFirst)+", paged AS (SELECT * FROM result" + +(stockFirst?"":filter(o)+" ORDER BY "+order(o)+" LIMIT #{p.size} OFFSET #{p.offset}")+")," + +" last_sales AS (SELECT "+(stockFirst?"/*+ INDEX(d idx_selfop_daily_sku_cover) */ ":"")+"d.customer_code,d.sku_code,MAX(d.business_date) last_outbound FROM ll_selfop_sku_daily_sales d " +"JOIN paged k ON k.customer_code=d.customer_code AND k.sku_code=d.sku_code WHERE d.sales_run_id=#{p.salesRun} " +"AND d.source_account=#{p.account} AND d.warehouse_code=#{p.warehouse} AND d.business_date<=#{p.anchor} " +"AND d.outbound_quantity>0 AND d.quantity_unit='PCS' GROUP BY d.customer_code,d.sku_code) "; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java index 16cc56a8e..ef2df9f10 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java @@ -283,6 +283,55 @@ public class AnalyticsSqlSemanticTest { var second=inventory(null,30,"PHYSICAL_DESC",1,1);assertEquals("OLD-2",second.get(0).skuCode()); assertEquals(D.minusDays(60),second.get(0).lastOutbound()); } + @Test void stockFirstAllKeepsNullOrderingPageBoundariesThresholdsAndExactScope() throws Exception { + for(String owner:List.of("B","C")){ + profile(22,owner,"A","INVENTORY_OBSERVATION",utc(D.minusDays(90),0),"PCS"); + stock(owner,"A",0,"100");daily(11,"W",owner,"A",D.minusDays(1),owner.equals("C")?"5":"10"); + } + profile(22,"C","B","INVENTORY_OBSERVATION",utc(D.minusDays(90),0),"PCS");stock("C","B",0,"100"); + daily(11,"W","C","B",D.minusDays(40),"9"); + profile(22,"C","NO-SALES","NONE",null,"PCS");stock("C","NO-SALES",0,"80"); + profile(22,"C","UNIT","INVENTORY_OBSERVATION",utc(D.minusDays(90),0),null);stock("C","UNIT",0,"999"); + profile(22,"C","UNKNOWN","INVENTORY_OBSERVATION",utc(D.minusDays(90),0),"PCS"); + stockExact("UNKNOWN",0,"100",null,"0","0","UNKNOWN","FIELD_MISSING"); + daily(99,"W","C","A",D.minusDays(1),"9000");daily(11,"OTHER","C","A",D.minusDays(1),"8000"); + profile(22,"C","OTHER-WAREHOUSE","INVENTORY_OBSERVATION",utc(D.minusDays(90),0),"PCS"); + try(var statement=schema.createStatement()){statement.execute("UPDATE ll_selfop_sku_profile SET warehouse_code='OTHER' WHERE sku_code='OTHER-WAREHOUSE'");} + var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null); + var p=request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),0,2); + assertEquals(6L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue()); + var first=inventoryRows(p,options);assertEquals(List.of("B/A","C/A"),first.stream().map(r->r.customerCode()+"/"+r.skuCode()).toList()); + assertDecimal("5",first.get(1).outboundQuantity30d());assertDecimal("600",first.get(1).coverageDays());assertEquals(1,first.get(1).aboveThreshold()); + var second=inventoryRows(request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),2,2),options); + assertEquals(List.of("B","NO-SALES"),second.stream().map(AnalyticsReadRows.Inventory::skuCode).toList()); + assertEquals(D.minusDays(40),second.get(0).lastOutbound());assertDecimal("0",second.get(0).outboundQuantity30d()); + assertNull(second.get(1).outboundQuantity30d());assertTrue(second.get(1).unassessable()); + var third=inventoryRows(request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),4,2),options); + assertEquals(List.of("UNIT","UNKNOWN"),third.stream().map(AnalyticsReadRows.Inventory::skuCode).toList()); + assertTrue(third.stream().allMatch(r->r.availableQuantity()==null));assertNull(third.get(0).physicalQuantity());assertDecimal("100",third.get(1).physicalQuantity()); + assertTrue(inventoryRows(request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),6,2),options).isEmpty()); + assertEquals(List.of("C"),inventoryRows(request("INVENTORY",D.minusDays(30),D,"C","A",utc(D,8),0,20),options).stream().map(AnalyticsReadRows.Inventory::customerCode).toList()); + assertEquals(2,inventoryRows(request("INVENTORY",D.minusDays(30),D,null,"A",utc(D,8),0,20),options).size()); + var bucket=new AnalyticsInventoryOptions(null,null,null,null,"DAYS_GT_90","COVERAGE_DISTRIBUTION_V1",null,null); + assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",bucket)).contains("stock_paged")); + assertEquals(List.of("B/A","C/A"),inventoryRows(p,bucket).stream().map(r->r.customerCode()+"/"+r.skuCode()).toList()); + for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_30D_ASC")) + assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null))).contains("stock_paged")); + } + @Test void stockFirstDoesNotTurnUnavailableSalesOrExpiredInventoryIntoKnownValues() throws Exception { + profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(60),0),"PCS");stock("C","SKU",0,"10");daily(11,"W","C","SKU",D.minusDays(1),"3"); + var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null); + var stale=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D, + utc(D.minusDays(30),0),utc(D,8),true,true,true,true,false,0,20,"INVENTORY"); + var row=inventoryRows(stale,options).get(0);assertNull(row.physicalQuantity());assertNull(row.availableQuantity());assertDecimal("3",row.ownerOutbound());assertNull(row.coverageDays()); + var noSales=new AnalyticsSqlRequest("demo","W",null,null,0,22,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,true,0,20,"INVENTORY"); + row=inventoryRows(noSales,options).get(0);assertDecimal("10",row.availableQuantity());assertNull(row.ownerOutbound());assertNull(row.observedOutbound());assertNull(row.outboundQuantity30d());assertNull(row.lastOutbound()); + var empty=new AnalyticsSqlRequest("demo","OTHER",null,null,0,0,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,false,0,20,"INVENTORY"); + assertTrue(inventoryRows(empty,options).isEmpty());assertEquals(0L,((Number)literalQuery("inventoryCount",empty,options).get(0)).longValue()); + } + @SuppressWarnings("unchecked") private List inventoryRows(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){ + return (List)(List)literalQuery("inventoryPage",p,o); + } @SuppressWarnings("unchecked") private List rankingStats(AnalyticsSqlRequest p){ return (List)(List)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null)); }