diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 5729d61f6..f55d4aed9 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4189,7 +4189,8 @@ CREATE TABLE ll_selfop_sku_daily_sales ( quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位', PRIMARY KEY (id), UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date), - KEY idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code), + KEY idx_selfop_daily_window_cover (sales_run_id,source_account,warehouse_code,business_date,customer_code,sku_code,outbound_quantity,outbound_order_count,quantity_unit), + KEY idx_selfop_daily_sku_cover (sales_run_id,source_account,warehouse_code,customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit), CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java index 6277a2f3f..05ac48fb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java @@ -28,7 +28,8 @@ public final class AnalyticsReadSql { } return sql; } - private static String cte(String kind){ + private static String cte(AnalyticsSqlRequest request){ + String kind=request.kind(); if("HOT".equals(kind))return hotSalesCte(); if("SLOW".equals(kind))return rankingCte(); String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')"; @@ -91,7 +92,11 @@ public final class AnalyticsReadSql { // Aggregate facts before joining SKU evidence. Repeating boundary expressions // inside every fact's SUM dominated the long-window MySQL execution profile. int rangeStart=sql.indexOf(" ranged AS (");int rangeEnd=sql.indexOf(" metric_sales AS (",rangeStart); - sql=sql.substring(0,rangeStart)+" ranged AS (SELECT d.customer_code,d.sku_code,SUM(d.outbound_quantity) observed_sum," + // Only the measured maximum chart window uses SKU-ordered coverage; short windows retain date-range access. + String rangedHint=request.from()!=null&&request.until()!=null + &&java.time.temporal.ChronoUnit.DAYS.between(request.from(),request.until())==366 + ?"/*+ INDEX(d idx_selfop_daily_sku_cover) */ ":""; + sql=sql.substring(0,rangeStart)+" ranged AS (SELECT "+rangedHint+"d.customer_code,d.sku_code,SUM(d.outbound_quantity) observed_sum," +"COUNT(*) actual_days,SUM(d.outbound_order_count) order_sum,MIN(d.quantity_unit) min_unit,MAX(d.quantity_unit) max_unit," +"SUM(CASE WHEN d.business_date>=#{p.completeFrom} AND d.business_date<#{p.completeUntil} AND d.business_date<#{p.anchor} THEN 1 ELSE 0 END) actual_complete " +"FROM ll_selfop_sku_daily_sales d WHERE d.sales_run_id=#{p.salesRun} AND"+KEY_FILTER @@ -168,7 +173,7 @@ public final class AnalyticsReadSql { public static String page(java.util.Map parameters){ var p=(AnalyticsSqlRequest)parameters.get("p"); if("HOT".equals(p.kind()))return typed(hotPage(),p); - return typed(cte(p.kind())+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count," + return typed(cte(p)+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count," +"complete_day_count,partial_day_count,unknown_day_count,window_quantity,window_sku_order_count,sales30_quantity,today_quantity," +"physical_quantity,available_quantity,frozen_quantity,transit_quantity,inventory_observed_at_utc,inventory_quality,inventory_reason " +"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+order(p.kind())+" LIMIT #{p.size} OFFSET #{p.offset}",p); @@ -236,7 +241,7 @@ public final class AnalyticsReadSql { "CASE WHEN inventory_quality<>'VALID' THEN CONCAT('INVENTORY_',inventory_quality) WHEN available_quantity<=0 THEN 'NO_AVAILABLE_INVENTORY' " +"WHEN window_quantity IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' ELSE 'OBSERVATION_INSUFFICIENT' END"; String eligible=filter(p.kind()).replace(" WHERE ","").strip(); - return typed(cte(p.kind())+", classified AS (SELECT CASE WHEN "+eligible+" THEN 1 ELSE 0 END eligible," + return typed(cte(p)+", classified AS (SELECT CASE WHEN "+eligible+" THEN 1 ELSE 0 END eligible," +reason+" reason FROM result), counts AS (SELECT eligible,CASE WHEN eligible=1 THEN NULL ELSE reason END reason,COUNT(*) amount " +"FROM classified GROUP BY eligible,CASE WHEN eligible=1 THEN NULL ELSE reason END) " +"SELECT LEAST(SUM(CASE WHEN eligible=1 THEN amount ELSE 0 END) OVER (),100) total,reason," diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8f73597aa..fe085941e 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -3902,7 +3902,8 @@ CREATE TABLE ll_selfop_sku_daily_sales ( quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位', PRIMARY KEY (id), UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date), - KEY idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code), + KEY idx_selfop_daily_window_cover (sales_run_id,source_account,warehouse_code,business_date,customer_code,sku_code,outbound_quantity,outbound_order_count,quantity_unit), + KEY idx_selfop_daily_sku_cover (sales_run_id,source_account,warehouse_code,customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit), CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java index fa2a3cefb..9cb6904e9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java @@ -80,7 +80,11 @@ class SelfoperatedAnalyticsSchemaContractTest { } assertTrue(run.contains("uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date)")); assertTrue(run.contains("CONSTRAINT chk_selfop_inventory_daily CHECK")); - assertTrue(ddl.get("ll_selfop_sku_daily_sales").contains("idx_selfop_daily_window (sales_run_id,business_date,customer_code,sku_code)")); + var daily = ddl.get("ll_selfop_sku_daily_sales"); + assertTrue(daily.contains("KEY idx_selfop_daily_window_cover (sales_run_id,source_account,warehouse_code,business_date,customer_code,sku_code,outbound_quantity,outbound_order_count,quantity_unit)")); + assertTrue(daily.contains("KEY idx_selfop_daily_sku_cover (sales_run_id,source_account,warehouse_code,customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit)")); + assertFalse(daily.contains("KEY idx_selfop_daily_window (")); + assertTrue(daily.contains("UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date)")); assertTrue(ddl.get("ll_selfop_outbound_order").contains("idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id)")); assertTrue(ddl.get("ll_selfop_sync_stage").contains("stage_batch_id,object_type,object_key_hash")); assertTrue(ddl.get("ll_selfop_sync_stage").contains("observation_sequence IS NOT NULL")); 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 ef5843649..16cc56a8e 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 @@ -42,6 +42,42 @@ public class AnalyticsSqlSemanticTest { public static LocalDateTime convertZone(LocalDateTime value,String from,String to) { return value==null?null:value.atOffset(ZoneOffset.of(from)).withOffsetSameInstant(ZoneOffset.of(to)).toLocalDateTime(); } + @Test void skuOrderedCoverHintIsLimitedToExactly366DaySummary() { + for(int days:List.of(1,7,30,365,366,367)) { + var p=request("SUMMARY",D.minusDays(days),D,null,null,utc(D,8),0,20); + String sql=AnalyticsReadSql.page(Map.of("p",p)); + String hint="/*+ INDEX(d idx_selfop_daily_sku_cover) */"; + assertEquals(days==366,sql.contains(hint),"window="+days); + if(days==366) { + assertTrue(sql.contains("ranged AS (SELECT "+hint+" d.customer_code")); + assertEquals(sql.indexOf(hint),sql.lastIndexOf(hint)); + } + assertFalse(AnalyticsReadSql.count(Map.of("p",p)).contains("INDEX(")); + } + for(String kind:List.of("HOT","SLOW","INVENTORY")) { + var p=request(kind,D.minusDays(366),D,null,null,utc(D,8),0,20); + assertFalse(AnalyticsReadSql.page(Map.of("p",p)).contains("INDEX("),kind); + } + } + @Test void hinted366DaySummaryPreservesSparseZeroUnknownAndDateBoundaries() throws Exception { + LocalDate from=D.minusDays(365),until=D.plusDays(1); + profile(11,"C","SOLD","VALID_OUTBOUND",utc(from,0),"PCS"); + profile(22,"C","ZERO","INVENTORY_OBSERVATION",utc(from,0),"PCS"); + profile(22,"C","UNKNOWN","NONE",null,"PCS"); + daily(11,"W","C","SOLD",from,"2");daily(11,"W","C","SOLD",D,"3"); + daily(11,"W","C","SOLD",from.minusDays(1),"999");daily(11,"W","C","SOLD",until,"999"); + daily(33,"W","C","SOLD",from,"999");daily(11,"OTHER","C","SOLD",from,"999"); + var p=new AnalyticsSqlRequest("demo","W",null,null,11,22,from,until,D,D.minusDays(30),from,D, + utc(from,0),utc(D,8),true,true,false,true,true,0,20,"SUMMARY"); + assertEquals(3,mapper.count(p));var rows=page(p);assertEquals(3,rows.size()); + var sold=rows.get(0);assertEquals("SOLD",sold.skuCode());assertDecimal("5",sold.observedQuantity()); + assertEquals(2L,sold.skuOrderCount());assertEquals(365,sold.completeDayCount()); + assertEquals(1,sold.partialDayCount());assertEquals(0,sold.unknownDayCount()); + var zero=rows.get(1);assertEquals("ZERO",zero.skuCode());assertDecimal("0",zero.observedQuantity()); + assertEquals(365,zero.completeDayCount());assertEquals(1,zero.partialDayCount()); + var unknown=rows.get(2);assertEquals("UNKNOWN",unknown.skuCode());assertNull(unknown.observedQuantity()); + assertEquals(0,unknown.completeDayCount());assertEquals(0,unknown.partialDayCount());assertEquals(366,unknown.unknownDayCount()); + } @Test void sqlReturnsTwoCompleteDaysAndTodayPartialObservedFive() throws Exception { profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS"); profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(35),0),"PCS");