生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #27
+29
-11
@@ -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<String,Object> 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) ";
|
||||
|
||||
+49
@@ -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<AnalyticsReadRows.Inventory> inventoryRows(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
|
||||
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",p,o);
|
||||
}
|
||||
@SuppressWarnings("unchecked") private List<AnalyticsReadMapper.RankingStat> rankingStats(AnalyticsSqlRequest p){
|
||||
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user