feat(self-operated): support selected-day analytics and global quantity sorting
This commit is contained in:
+3
-2
@@ -30,8 +30,9 @@ public class PlatformAnalyticsController {
|
||||
@GetMapping("/sku-sales-summary")
|
||||
@Operation(summary="五周期或历史自定义范围SKU累计清单")
|
||||
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.SalesSummary>> salesSummary(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query){
|
||||
requirePlatform(user);return ApiResponse.success(service.salesSummary(query));
|
||||
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,
|
||||
@RequestParam(defaultValue="QUANTITY_DESC") SalesSort salesSort){
|
||||
requirePlatform(user);return ApiResponse.success(service.salesSummary(query,salesSort));
|
||||
}
|
||||
@GetMapping("/sku-daily-sales")
|
||||
@Operation(summary="精确货主与SKU的完整日趋势轴")
|
||||
|
||||
+4
-2
@@ -15,12 +15,14 @@ public record AnalyticsInventoryOptions(String keyword,String attention,String s
|
||||
CoverageDistributionPolicy.validate(this);
|
||||
if("DASHBOARD".equals(drilldownFrom)&&(salesWindowDays!=null||salesWindowEndDate!=null||!"CUSTOM".equals(q.rangePreset())||q.startDate()==null||q.endDate()==null))
|
||||
throw new BizException("总览库存下钻须携带原图起止日期,不能混用库存窗口");
|
||||
if(salesDays()!=10&&salesDays()!=30)throw new BizException("库存销量窗口仅支持10或30天");
|
||||
if(salesDays()<1||salesDays()>366)throw new BizException("库存销量窗口须为1至366个完整自然日");
|
||||
if(!"INVENTORY".equals(endpoint)&&(salesWindowDays!=null||salesWindowEndDate!=null))throw new BizException("销量窗口仅用于库存明细");
|
||||
if(!"INVENTORY".equals(endpoint)&&(coverageBucket!=null||coverageSelection!=null||drilldownFrom!=null))throw new BizException("分布下钻条件仅用于库存明细");
|
||||
if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0)throw new BizException("可售天数阈值必须为正整数");
|
||||
if(!Set.of("ALL","NO_OUTBOUND_IN_WINDOW","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效");
|
||||
if(!Set.of("AVAILABLE_DESC","COVERAGE_DAYS_DESC","PHYSICAL_DESC","OUTBOUND_ASC").contains(sortValue()))throw new BizException("库存排序无效");
|
||||
if(!Set.of("AVAILABLE_ASC","AVAILABLE_DESC","COVERAGE_DAYS_ASC","COVERAGE_DAYS_DESC",
|
||||
"PHYSICAL_ASC","PHYSICAL_DESC","FROZEN_ASC","FROZEN_DESC","DEFECTIVE_ASC","DEFECTIVE_DESC",
|
||||
"TRANSIT_ASC","TRANSIT_DESC","OUTBOUND_ASC","OUTBOUND_DESC","AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC").contains(sortValue()))throw new BizException("库存排序无效");
|
||||
if("COVERAGE_GT_THRESHOLD".equals(attentionValue())&&coverageDaysThreshold==null)throw new BizException("超过可售天数筛选必须指定阈值");
|
||||
if("OWNERS".equals(endpoint)){
|
||||
if(keyword!=null&&(keyword.isBlank()||keyword.length()>64))throw new BizException("货主编码前缀不能为空或超长");
|
||||
|
||||
+15
-7
@@ -46,7 +46,8 @@ public final class AnalyticsInventorySql {
|
||||
+"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),"
|
||||
// Complete source coverage proves actual window sales independently of SKU observation age.
|
||||
+"CASE WHEN unit_valid=1 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*"+days+"/outbound_quantity30d END coverage_days,"
|
||||
+"CASE WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d=0 THEN 1 ELSE 0 END END no_outbound,"
|
||||
+"CASE WHEN #{o.coverageDaysThreshold} IS NULL THEN NULL WHEN physical_quantity=0 THEN 0 WHEN physical_quantity>0 AND available_quantity=0 THEN 0 "
|
||||
@@ -77,9 +78,18 @@ public final class AnalyticsInventorySql {
|
||||
case "DEFECTIVE_STOCK" -> " WHERE defective_physical_quantity>0";
|
||||
case "UNASSESSABLE" -> " WHERE unassessable=1";
|
||||
default -> "";};}
|
||||
private static String order(AnalyticsInventoryOptions o){String field=switch(o.sortValue()){
|
||||
case "PHYSICAL_DESC" -> "physical_quantity";case "COVERAGE_DAYS_DESC" -> "coverage_days";case "OUTBOUND_ASC" -> "outbound_quantity30d";default -> "available_quantity";};
|
||||
return field+" IS NULL,"+field+("OUTBOUND_ASC".equals(o.sortValue())?" ASC":" DESC")+",sku_code ASC,customer_code ASC";}
|
||||
private static String order(AnalyticsInventoryOptions o){return order(o,"");}
|
||||
private static String order(AnalyticsInventoryOptions o,String prefix){String field=prefix+switch(o.sortValue()){
|
||||
case "AVAILABLE_ASC","AVAILABLE_DESC" -> "available_quantity";
|
||||
case "PHYSICAL_ASC","PHYSICAL_DESC" -> "physical_quantity";
|
||||
case "FROZEN_ASC","FROZEN_DESC" -> "frozen_quantity";
|
||||
case "DEFECTIVE_ASC","DEFECTIVE_DESC" -> "defective_physical_quantity";
|
||||
case "TRANSIT_ASC","TRANSIT_DESC" -> "transit_quantity";
|
||||
case "COVERAGE_DAYS_ASC","COVERAGE_DAYS_DESC" -> "coverage_days";
|
||||
// Every row has the same window denominator; sort the exact numerator.
|
||||
case "OUTBOUND_ASC","OUTBOUND_DESC","AVERAGE_DAILY_ASC","AVERAGE_DAILY_DESC" -> "outbound_quantity30d";
|
||||
default -> throw new IllegalArgumentException("Unsupported inventory sort");};
|
||||
return field+" IS NULL,"+field+(o.sortValue().endsWith("_ASC")?" ASC":" DESC")+","+prefix+"sku_code ASC,"+prefix+"customer_code ASC";}
|
||||
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.
|
||||
@@ -91,9 +101,7 @@ public final class AnalyticsInventorySql {
|
||||
+"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) ");
|
||||
String outerOrder=order(o).replace("physical_quantity","k.physical_quantity").replace("coverage_days","k.coverage_days")
|
||||
.replace("outbound_quantity30d","k.outbound_quantity30d").replace("available_quantity","k.available_quantity")
|
||||
.replace("sku_code","k.sku_code").replace("customer_code","k.customer_code");
|
||||
String outerOrder=order(o,"k.");
|
||||
return typed(paged+"SELECT k.customer_code,k.sku_code,k.product_name,k.unit_valid,k.evidence_at_utc,k.physical_quantity,k.available_quantity,k.frozen_quantity,k.transit_quantity,k.defective_physical_quantity,k.observed_at_utc,k.owner_outbound,k.observed_outbound,k.order_count,"+(stockFirst?lastOutboundLookup():"l.last_outbound")+" last_outbound,k.observation_eligible,k.outbound_quantity30d,k.coverage_days,k.no_outbound,k.above_threshold,k.unassessable FROM paged k "+(stockFirst?"":"LEFT JOIN last_sales l ON l.customer_code=k.customer_code AND l.sku_code=k.sku_code ")+"ORDER BY "+outerOrder);}
|
||||
private static String lastOutboundLookup(){
|
||||
// Repeat the correlated equality keys in ORDER BY so MySQL can traverse the covering index in reverse.
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@ public record AnalyticsQuery(String sourceAccount, String warehouseCode, String
|
||||
if(chart){
|
||||
if(windowDays!=null)throw new BizException("SKU周期查询不接受windowDays");
|
||||
if(bucket!=null&&!"DAY".equals(bucket))throw new BizException("仅支持DAY日粒度");
|
||||
}else if(hot&&"CUSTOM".equals(rangePreset)){
|
||||
if(startDate==null||endDate==null||windowDays!=null||bucket!=null)throw new BizException("自定义热销窗口须提供起止日期,不接受windowDays或bucket");
|
||||
}else if("CUSTOM".equals(rangePreset)){
|
||||
if(startDate==null||endDate==null||windowDays!=null||bucket!=null)throw new BizException("自定义榜单窗口须提供起止日期,不接受windowDays或bucket");
|
||||
}else{
|
||||
if(rangePreset!=null||startDate!=null||endDate!=null||bucket!=null)throw new BizException("指标不接受图表周期参数");
|
||||
if(hot?(rankingDays()!=7&&rankingDays()!=30):(windowDays!=null))throw new BizException("热销仅支持7/30天;其他指标固定30天");
|
||||
|
||||
+12
-10
@@ -28,10 +28,10 @@ public class AnalyticsQueryService {
|
||||
record Snapshot(AnalyticsQuery q,Run sales,Run inventory,boolean sourceKnown,boolean trusted,
|
||||
LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){}
|
||||
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q){
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q,SalesSort salesSort){
|
||||
Snapshot s=context(q,true,false,false,null); AnalyticsSqlRequest p=request(s,"SUMMARY");
|
||||
long count=mapper.count(p);
|
||||
var records=mapper.page(p).stream().map(r->summary(s,r)).toList();
|
||||
var records=mapper.salesPage(p,salesSort).stream().map(r->summary(s,r)).toList();
|
||||
return page(s,records,count,Map.of());
|
||||
}
|
||||
public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){
|
||||
@@ -97,7 +97,7 @@ public class AnalyticsQueryService {
|
||||
boolean observed=Boolean.TRUE.equals(r.observationEligible());String quality=!"VALID".equals(s.context.inventoryStatus())?s.context.inventoryStatus():r.availableQuantity()==null?"UNKNOWN":"VALID";
|
||||
String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":r.ownerOutbound()!=null?"COMPLETE":"COVERAGE_INSUFFICIENT";
|
||||
var calculated=InventoryWindowPolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted,
|
||||
complete(s,s.range.startDate(),s.range.endDateExclusive()),observed,Boolean.TRUE.equals(r.unitValid()),s.range.dayCount());
|
||||
complete(s,s.range.startDate(),s.range.endDateExclusive()),Boolean.TRUE.equals(r.unitValid()),s.range.dayCount());
|
||||
List<String> attention=new ArrayList<>();if(Integer.valueOf(1).equals(r.noOutbound()))attention.add("NO_OUTBOUND_IN_WINDOW");
|
||||
if(Integer.valueOf(1).equals(r.aboveThreshold()))attention.add("COVERAGE_GT_THRESHOLD");
|
||||
if(positive(r.frozenQuantity()))attention.add("FROZEN_STOCK");if(positive(r.defectivePhysicalQuantity()))attention.add("DEFECTIVE_STOCK");
|
||||
@@ -109,11 +109,13 @@ public class AnalyticsQueryService {
|
||||
Map<String,String> statuses=new LinkedHashMap<>();String unknown="STALE".equals(s.context.inventoryStatus())?"STALE":"UNKNOWN";
|
||||
statuses.put("physicalQuantity",r.physicalQuantity()==null?unknown:"VALID");statuses.put("availableQuantity",r.availableQuantity()==null?unknown:"VALID");
|
||||
statuses.put("frozenQuantity",r.frozenQuantity()==null?unknown:"VALID");statuses.put("transitQuantity",r.transitQuantity()==null?unknown:"VALID");
|
||||
statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID");statuses.put("windowOutboundQuantity",r.outboundQuantity30d()==null?"UNKNOWN":"VALID");
|
||||
statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID");
|
||||
statuses.put("windowOutboundQuantity",r.outboundQuantity30d()!=null?"VALID":!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":
|
||||
!s.trusted?"SALES_UNTRUSTED":!"COMPLETE".equals(coverage)?"COVERAGE_INSUFFICIENT":"UNKNOWN");
|
||||
return new AnalyticsResponses.InventoryMetric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null,
|
||||
r.orderCount(),r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()),
|
||||
r.outboundQuantity30d()==null?null:r.outboundQuantity30d().divide(BigDecimal.valueOf(s.range.dayCount()),12,java.math.RoundingMode.HALF_UP),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)),
|
||||
null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses));
|
||||
null,r.defectivePhysicalQuantity(),r.outboundQuantity30d(),r.observedOutbound(),r.lastOutbound(),historyStart(s),List.copyOf(attention),Map.copyOf(statuses));
|
||||
}
|
||||
private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;}
|
||||
private static LocalDate historyStart(Snapshot s){return SalesQueryCoverage.intervals(s.sales).stream()
|
||||
@@ -155,12 +157,12 @@ public class AnalyticsQueryService {
|
||||
}
|
||||
private AnalyticsResponses.Metric metric(Snapshot s,Aggregate r,String kind){
|
||||
Instant evidence=utc(r.evidenceAtUtc());
|
||||
boolean observed=evidence!=null&&!evidence.isAfter(s.range.anchorDate().minusDays(30).atStartOfDay(BUSINESS_ZONE).toInstant());
|
||||
boolean observed=evidence!=null&&!evidence.isAfter(s.range.startDate().atStartOfDay(BUSINESS_ZONE).toInstant());
|
||||
boolean stockValid="VALID".equals(r.inventoryQuality());
|
||||
String coverage=!Boolean.TRUE.equals(r.unitValid())?"UNIT_UNKNOWN_OR_CONFLICT":!s.trusted?"SALES_UNTRUSTED":
|
||||
complete(s,s.range.startDate(),s.range.endDateExclusive())?"COMPLETE":"COVERAGE_INSUFFICIENT";
|
||||
var result=InventoryCoveragePolicy.calculate(r.inventoryQuality(),stockValid?r.availableQuantity():null,r.sales30Quantity(),
|
||||
s.trusted,complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),observed,Boolean.TRUE.equals(r.unitValid()));
|
||||
var result=InventoryWindowPolicy.calculate(r.inventoryQuality(),stockValid?r.availableQuantity():null,r.windowQuantity(),
|
||||
s.trusted,complete(s,s.range.startDate(),s.range.endDateExclusive())&&observed,Boolean.TRUE.equals(r.unitValid()),s.range.dayCount());
|
||||
List<String> reasons=new ArrayList<>(s.context.reasonCodes());
|
||||
if(!"COMPLETE".equals(coverage))reasons.add(coverage);
|
||||
if(!"VALID".equals(result.status()))reasons.add(result.status());
|
||||
@@ -205,7 +207,7 @@ public class AnalyticsQueryService {
|
||||
LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate();
|
||||
SalesRangePolicy.Range range;
|
||||
try{
|
||||
range=inventoryOptions!=null&&!dashboardWindow?InventoryWindowPolicy.range(inventoryOptions,anchor):(chart||dashboardWindow||(hot&&"CUSTOM".equals(q.rangePreset())))?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(),
|
||||
range=inventoryOptions!=null&&!dashboardWindow?InventoryWindowPolicy.range(inventoryOptions,anchor):(chart||dashboardWindow||("CUSTOM".equals(q.rangePreset())))?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(),
|
||||
q.bucket()==null?"DAY":q.bucket(),asOf,BUSINESS_ZONE,maxQueryDays):
|
||||
new SalesRangePolicy.Range(anchor,anchor.minusDays(hot?q.rankingDays():30),anchor,hot?q.rankingDays():30,
|
||||
anchor.minusDays(hot?q.rankingDays():30).atStartOfDay(BUSINESS_ZONE).toInstant(),anchor.atStartOfDay(BUSINESS_ZONE).toInstant());
|
||||
@@ -244,7 +246,7 @@ public class AnalyticsQueryService {
|
||||
AnalyticsSqlRequest request(Snapshot s,String kind){
|
||||
int size=s.q.pageSize(); if("HOT".equals(kind)||"SLOW".equals(kind))size=(int)Math.max(0,Math.min(size,100-s.q.offset()));
|
||||
return new AnalyticsSqlRequest(s.q.sourceAccount(),s.q.warehouseCode(),s.q.customerCode(),s.q.sku(),s.context.salesRunId(),
|
||||
s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.anchorDate().minusDays(s.q.rankingDays()),
|
||||
s.context.inventoryRunId(),s.range.startDate(),s.range.endDateExclusive(),s.range.anchorDate(),s.range.startDate(),
|
||||
s.fullFrom,s.fullUntil,s.sales==null?null:s.sales.coveredFromUtc(),s.sales==null?null:s.sales.coveredUntilUtc(),s.sourceKnown,s.sourceKnown,
|
||||
s.trusted&&complete(s,s.range.startDate(),s.range.endDateExclusive()),
|
||||
s.trusted&&complete(s,s.range.anchorDate().minusDays(30),s.range.anchorDate()),"VALID".equals(s.context.inventoryStatus()),s.q.offset(),size,kind,SalesQueryCoverage.intervals(s.sales));
|
||||
|
||||
+2
@@ -18,6 +18,8 @@ public interface AnalyticsReadMapper {
|
||||
AnalyticsReadRows.Run publishedRun(@Param("id") long id);
|
||||
@SelectProvider(type=AnalyticsReadSql.class,method="page")
|
||||
List<AnalyticsReadRows.Aggregate> page(@Param("p") AnalyticsSqlRequest request);
|
||||
@SelectProvider(type=AnalyticsReadSql.class,method="page")
|
||||
List<AnalyticsReadRows.Aggregate> salesPage(@Param("p") AnalyticsSqlRequest request,@Param("salesSort") SalesSort salesSort);
|
||||
@SelectProvider(type=AnalyticsReadSql.class,method="count")
|
||||
long count(@Param("p") AnalyticsSqlRequest request);
|
||||
record RankingStat(Long total,String reason,Long excludedCount){}
|
||||
|
||||
+13
-12
@@ -28,9 +28,10 @@ public final class AnalyticsReadSql {
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
private static String cte(AnalyticsSqlRequest request){
|
||||
private static String cte(AnalyticsSqlRequest request){return cte(request,SalesSort.QUANTITY_DESC);}
|
||||
private static String cte(AnalyticsSqlRequest request,SalesSort salesSort){
|
||||
String kind=request.kind();
|
||||
if("SUMMARY".equals(kind)&&!request.coverage().isEmpty())return coveredSummary(request);
|
||||
if("SUMMARY".equals(kind)&&!request.coverage().isEmpty())return coveredSummary(request,salesSort);
|
||||
if("HOT".equals(kind))return hotSalesCte();
|
||||
if("SLOW".equals(kind))return rankingCte();
|
||||
String localEvidence="CONVERT_TZ(evidence_at_utc,'+00:00','+08:00')";
|
||||
@@ -113,8 +114,7 @@ public final class AnalyticsReadSql {
|
||||
+"AND #{p.salesKnown}=1 AND (COALESCE(s.actual_days,0)>0 OR ("+observedIntersection+")) "
|
||||
+"THEN COALESCE(s.observed_sum,0) END sort_observed_quantity "
|
||||
+"FROM evidence e LEFT JOIN sort_sales s ON s.customer_code=e.customer_code AND s.sku_code=e.sku_code),"
|
||||
+" paged_keys AS (SELECT * FROM rankable ORDER BY sort_observed_quantity IS NULL,sort_observed_quantity DESC,"
|
||||
+"sku_code ASC,customer_code ASC LIMIT #{p.size} OFFSET #{p.offset}),";
|
||||
+" paged_keys AS (SELECT * FROM rankable ORDER BY "+salesSort.sql("sort_observed_quantity")+" LIMIT #{p.size} OFFSET #{p.offset}),";
|
||||
sql=sql.substring(0,rangeStart)+" ranged AS (SELECT 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 "
|
||||
@@ -153,7 +153,7 @@ public final class AnalyticsReadSql {
|
||||
}
|
||||
return sql;
|
||||
}
|
||||
private static String coveredSummary(AnalyticsSqlRequest p){
|
||||
private static String coveredSummary(AnalyticsSqlRequest p,SalesSort salesSort){
|
||||
var intervals=new java.util.ArrayList<String>();
|
||||
for(int i=0;i<p.coverage().size();i++)intervals.add("SELECT CAST(#{p.coverage["+i+"].from} AS DATETIME) lo,CAST(#{p.coverage["+i+"].until} AS DATETIME) hi");
|
||||
var dates=new java.util.ArrayList<String>();
|
||||
@@ -169,7 +169,7 @@ public final class AnalyticsReadSql {
|
||||
+"CASE WHEN #{p.salesKnown}=1 AND e.profile_unit_valid=1 AND (s.min_unit IS NULL OR (s.min_unit=e.quantity_unit AND s.max_unit=e.quantity_unit AND s.min_qty>=0))"
|
||||
+" AND (s.observed_sum IS NOT NULL OR EXISTS (SELECT 1 FROM coverage v WHERE GREATEST(v.lo,e.evidence_at_utc,TIMESTAMPADD(HOUR,-8,CAST(#{p.from} AS DATETIME)))<LEAST(v.hi,TIMESTAMPADD(HOUR,-8,CAST(#{p.until} AS DATETIME))))) THEN COALESCE(s.observed_sum,0) END observed_quantity"
|
||||
+" FROM evidence e LEFT JOIN sort_sales s ON s.customer_code=e.customer_code AND s.sku_code=e.sku_code),"
|
||||
+" paged_keys AS (SELECT * FROM rankable ORDER BY observed_quantity IS NULL,observed_quantity DESC,sku_code,customer_code LIMIT #{p.size} OFFSET #{p.offset}),"
|
||||
+" paged_keys AS (SELECT * FROM rankable ORDER BY "+salesSort.sql("observed_quantity")+" LIMIT #{p.size} OFFSET #{p.offset}),"
|
||||
+" day_counts AS (SELECT b.customer_code,b.sku_code,SUM(d.outbound_order_count) sku_order_count,"
|
||||
+"SUM(CASE WHEN #{p.formalSales}=1 AND "+whole+" THEN 1 ELSE 0 END) complete_count,"
|
||||
+"SUM(CASE WHEN #{p.salesKnown}=1 AND (d.id IS NOT NULL OR "+observed+") THEN 1 ELSE 0 END) observed_count"
|
||||
@@ -187,12 +187,12 @@ public final class AnalyticsReadSql {
|
||||
private static String rankingCte(){
|
||||
String valid="c.profile_unit_valid=1 AND (m.min_unit IS NULL OR (m.min_unit=c.quantity_unit AND m.max_unit=c.quantity_unit))";
|
||||
return candidates(false)+" metric_sales AS (SELECT customer_code,sku_code,"
|
||||
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) window_quantity,"
|
||||
+"SUM(CASE WHEN business_date>=#{p.windowFrom} AND business_date<#{p.anchor} THEN outbound_order_count ELSE 0 END) window_orders,"
|
||||
+"SUM(CASE WHEN business_date>=#{p.from} AND business_date<#{p.until} THEN outbound_quantity ELSE 0 END) window_quantity,"
|
||||
+"SUM(CASE WHEN business_date>=#{p.from} AND business_date<#{p.until} THEN outbound_order_count ELSE 0 END) window_orders,"
|
||||
+"SUM(CASE WHEN business_date<#{p.anchor} THEN outbound_quantity ELSE 0 END) sales30_quantity,"
|
||||
+"SUM(CASE WHEN business_date=#{p.anchor} THEN outbound_quantity ELSE NULL END) today_quantity,MIN(quantity_unit) min_unit,MAX(quantity_unit) max_unit "
|
||||
+"FROM ll_selfop_sku_daily_sales WHERE sales_run_id=#{p.salesRun} AND"+KEY_FILTER
|
||||
+" AND business_date>=TIMESTAMPADD(DAY,-30,#{p.anchor}) AND business_date<TIMESTAMPADD(DAY,1,#{p.anchor}) GROUP BY customer_code,sku_code),"
|
||||
+" AND business_date>=LEAST(#{p.from},TIMESTAMPADD(DAY,-30,#{p.anchor})) AND business_date<TIMESTAMPADD(DAY,1,#{p.anchor}) GROUP BY customer_code,sku_code),"
|
||||
+" result AS (SELECT c.customer_code,c.sku_code,c.product_name,c.quantity_unit,c.evidence_at_utc,CASE WHEN "+valid+" THEN 1 ELSE 0 END unit_valid,"
|
||||
+"CAST(NULL AS DECIMAL(20,6)) observed_quantity,CAST(NULL AS SIGNED) sku_order_count,0 complete_day_count,0 partial_day_count,0 unknown_day_count,"
|
||||
+"CASE WHEN "+valid+" AND #{p.completeWindow}=1 THEN COALESCE(m.window_quantity,0) END window_quantity,"
|
||||
@@ -211,7 +211,7 @@ public final class AnalyticsReadSql {
|
||||
private static String filter(String kind){
|
||||
if("HOT".equals(kind))return " WHERE window_quantity>0 ";
|
||||
if("SLOW".equals(kind))return " WHERE window_quantity IS NOT NULL AND inventory_quality='VALID' AND available_quantity>0 "
|
||||
+"AND evidence_at_utc<=CONVERT_TZ(CAST(TIMESTAMPADD(DAY,-30,#{p.anchor}) AS DATETIME),'+08:00','+00:00') ";
|
||||
+"AND evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') ";
|
||||
return "";
|
||||
}
|
||||
private static String order(String kind){
|
||||
@@ -223,10 +223,11 @@ public final class AnalyticsReadSql {
|
||||
public static String page(java.util.Map<String,Object> parameters){
|
||||
var p=(AnalyticsSqlRequest)parameters.get("p");
|
||||
if("HOT".equals(p.kind()))return typed(hotPage(),p);
|
||||
return typed(cte(p)+"SELECT customer_code,sku_code,product_name,quantity_unit,evidence_at_utc,unit_valid,observed_quantity,sku_order_count,"
|
||||
SalesSort salesSort=(SalesSort)parameters.getOrDefault("salesSort",SalesSort.QUANTITY_DESC);
|
||||
return typed(cte(p,salesSort)+"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())
|
||||
+"FROM result"+filter(p.kind()).stripTrailing()+" ORDER BY "+("SUMMARY".equals(p.kind())?salesSort.sql("observed_quantity"):order(p.kind()))
|
||||
+("SUMMARY".equals(p.kind())?"":" LIMIT #{p.size} OFFSET #{p.offset}"),p);
|
||||
}
|
||||
/** Rank on sales alone. Inventory enrichment and its quality checks touch only the
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ public final class AnalyticsResponses {
|
||||
Instant inventoryObservedAt,BigDecimal averageDailyOutboundQuantity,BigDecimal inventoryCoverageDays,
|
||||
String inventoryQuality,String salesCoverage,boolean observationEligible,Instant earliestReliableEvidenceAt,
|
||||
String status,List<String> reasonCodes,String customerName,BigDecimal defectivePhysicalQuantity,
|
||||
BigDecimal windowOutboundQuantity,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate,
|
||||
BigDecimal windowOutboundQuantity,BigDecimal observedWindowOutboundQuantity,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate,
|
||||
List<String> attentionReasons,Map<String,String> quantityStatuses){}
|
||||
public record OwnerOption(String customerCode,String customerName){}
|
||||
public record QuantityTotal(BigDecimal overallTotal,BigDecimal knownSubtotal,long unknownSkuCount,String dataStatus,List<String> reasonCodes){}
|
||||
|
||||
+1
-1
@@ -13,7 +13,7 @@ public final class CoverageDistributionSql {
|
||||
+"WHEN available_quantity*"+days+"<=outbound_quantity30d*90 THEN 'DAYS_60_90' ELSE 'DAYS_GT_90' END";}
|
||||
public static String unknownReason(){return "CASE WHEN physical_quantity IS NULL THEN 'PHYSICAL_INVENTORY_UNKNOWN' WHEN available_quantity IS NULL THEN 'AVAILABLE_INVENTORY_UNKNOWN' "
|
||||
+"WHEN unit_valid=0 THEN 'UNIT_UNKNOWN_OR_CONFLICT' WHEN owner_outbound IS NULL THEN 'SALES_WINDOW_UNAVAILABLE' "
|
||||
+"WHEN observation_eligible=0 THEN 'OBSERVATION_INSUFFICIENT' ELSE 'METRIC_DEPENDENCY_UNKNOWN' END";}
|
||||
+"ELSE 'METRIC_DEPENDENCY_UNKNOWN' END";}
|
||||
public static String filter(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
|
||||
if(o.coverageBucket()!=null)return "#{p.inventoryRun}>0 AND "+candidate()+" AND ("+bucket(p)+")=#{o.coverageBucket}";
|
||||
if(o.coverageSelection()!=null)return finitePositive();
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ public final class DashboardReadSql {
|
||||
.append(",SUM(CASE WHEN bucket_code='").append(code).append("' THEN physical_quantity END) physical_").append(alias)
|
||||
.append(",COUNT(CASE WHEN bucket_code='").append(code).append("' AND physical_quantity IS NULL THEN 1 END) unknown_").append(alias);
|
||||
}
|
||||
for(String reason:List.of("PHYSICAL_INVENTORY_UNKNOWN","AVAILABLE_INVENTORY_UNKNOWN","UNIT_UNKNOWN_OR_CONFLICT","SALES_WINDOW_UNAVAILABLE","OBSERVATION_INSUFFICIENT","METRIC_DEPENDENCY_UNKNOWN"))
|
||||
for(String reason:List.of("PHYSICAL_INVENTORY_UNKNOWN","AVAILABLE_INVENTORY_UNKNOWN","UNIT_UNKNOWN_OR_CONFLICT","SALES_WINDOW_UNAVAILABLE","METRIC_DEPENDENCY_UNKNOWN"))
|
||||
sql.append(",COUNT(CASE WHEN bucket_code='UNKNOWN' AND unknown_reason='").append(reason).append("' THEN 1 END) reason_").append(reason.toLowerCase(java.util.Locale.ROOT));
|
||||
sql.append(" FROM classified)")
|
||||
.append(", hot_rank AS (SELECT * FROM dashboard_result WHERE owner_outbound>0 ORDER BY owner_outbound DESC,order_count DESC,sku_code ASC,customer_code ASC LIMIT 10)")
|
||||
|
||||
+4
-3
@@ -21,14 +21,15 @@ public final class DashboardSalesPolicy {
|
||||
Instant upper=interval==null?null:(interval.until().toInstant(ZoneOffset.UTC).isBefore(end)?interval.until().toInstant(ZoneOffset.UTC):end);
|
||||
boolean intersects=lower!=null&&upper!=null&&lower.isBefore(upper);
|
||||
boolean invalid=actual!=null&&actual.invalidRowCount()!=null&&actual.invalidRowCount()>0;
|
||||
// Global recency describes how far collection has progressed, not whether an
|
||||
// already published historical day has complete coverage. Keep STALE on context.
|
||||
boolean trusted=("VALID".equals(c.salesStatus())||"STALE".equals(c.salesStatus()))&&!c.rebuildRequired();
|
||||
// Recency and pending rebuild describe the live scope, not the frozen version's
|
||||
// historical coverage. Inventory assessment retains its separate rebuild guard.
|
||||
boolean trusted="VALID".equals(c.salesStatus())||"STALE".equals(c.salesStatus())||"REBUILD_REQUIRED".equals(c.salesStatus());
|
||||
boolean complete=sourceKnown&&trusted&&!invalid&&day.isBefore(c.anchorDate())&&SalesQueryCoverage.complete(intervals,day,day.plusDays(1));
|
||||
BigDecimal known=sourceKnown&&actual!=null?actual.observedQuantity():null;
|
||||
if(known==null&&sourceKnown&&!invalid&&(complete||intersects))known=BigDecimal.ZERO;
|
||||
String status=complete?"COMPLETE":known!=null?"PARTIAL":"UNKNOWN";List<String> reasons=new ArrayList<>();
|
||||
if(!sourceKnown)reasons.add("SALES_NOT_COVERED");if(!trusted)reasons.add("SALES_"+c.salesStatus());
|
||||
if(c.rebuildRequired()||"REBUILD_REQUIRED".equals(c.salesStatus()))reasons.add("SALES_REBUILD_REQUIRED");
|
||||
if(invalid)reasons.add("SALES_QUANTITY_OR_UNIT_INVALID");if(!complete)reasons.add(day.equals(c.anchorDate())?"TODAY_INCOMPLETE":"SALES_DAY_INCOMPLETE");
|
||||
if(known==null)reasons.add("SALES_NOT_COVERED");
|
||||
result.add(new SalesBucketPolicy.Point(day,complete?known:null,known,null,status,List.copyOf(new LinkedHashSet<>(reasons)),intersects?lower:null,intersects?upper:null));
|
||||
|
||||
+12
-6
@@ -26,7 +26,7 @@ public class InventoryTurnoverContext {
|
||||
}
|
||||
public record Evidence(String account,String warehouse,LocalDate end,int days,Instant asOf,
|
||||
Long salesRun,Long inventoryRun,String salesHash,String inventoryHash,boolean rebuild,
|
||||
List<Sample> samples,LocalDate accumulationStartedOn) {}
|
||||
String samplesHash,LocalDate accumulationStartedOn) {}
|
||||
record Frozen(Context response,AnalyticsQueryService.Snapshot snapshot) {}
|
||||
Frozen resolve(InventoryTurnoverRequest q) {
|
||||
Instant now=clock.instant(); q.validate(now.atZone(ZONE).toLocalDate());
|
||||
@@ -38,22 +38,22 @@ public class InventoryTurnoverContext {
|
||||
||!end.equals(supplied.end())||q.days()!=supplied.days())) throw refresh();
|
||||
var scopes=analytics.scopes(q.sourceAccount(),q.warehouseCode());
|
||||
var sales=version(scopes,"OUTBOUND",asOf); var stock=version(scopes,"INVENTORY",asOf);
|
||||
var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(29),end,"DAY",null,1,20,
|
||||
var aq=new AnalyticsQuery(q.sourceAccount(),q.warehouseCode(),null,null,"CUSTOM",end.minusDays(Math.max(30,q.days())-1),end,"DAY",null,1,20,
|
||||
sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf);
|
||||
// Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway.
|
||||
var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC)).context(aq,true,false,false,null);
|
||||
String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null);
|
||||
var mapping=new TreeMap<LocalDate,Long>();
|
||||
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(29),end.plusDays(1))) {
|
||||
if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(29))||sample.sampleDate().isAfter(end)
|
||||
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(Math.max(30,q.days())-1),end.plusDays(1))) {
|
||||
if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(Math.max(30,q.days())-1))||sample.sampleDate().isAfter(end)
|
||||
||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh();
|
||||
}
|
||||
List<Sample> samples=new ArrayList<>();
|
||||
for(LocalDate date=end.minusDays(29);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date)));
|
||||
for(LocalDate date=end.minusDays(Math.max(30,q.days())-1);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date)));
|
||||
var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf));
|
||||
var c=snapshot.context();
|
||||
var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(),
|
||||
c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),List.copyOf(samples),began);
|
||||
c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),samplesHash(samples),began);
|
||||
if(supplied!=null&&!supplied.equals(evidence)) throw refresh();
|
||||
return new Frozen(new Context(encode(evidence),q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.queryExpiresAt(),c,
|
||||
c.inventoryObservedFrom(),c.inventoryObservedUntil(),RULE,List.copyOf(samples),samples.stream().filter(s->s.runId()==null).map(Sample::date).toList(),began),snapshot);
|
||||
@@ -68,6 +68,12 @@ public class InventoryTurnoverContext {
|
||||
private String encode(Evidence evidence) {
|
||||
try{return Base64.getUrlEncoder().withoutPadding().encodeToString(json.writeValueAsBytes(evidence));}catch(Exception e){throw new IllegalStateException("Cannot encode turnover context",e);}
|
||||
}
|
||||
private String samplesHash(List<Sample> samples) {
|
||||
// Reconstruct the entire ordered calendar at asOf before comparing its digest.
|
||||
// Keep the GET pagination token bounded even for a full year of samples.
|
||||
try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(samples)));}
|
||||
catch(Exception e){throw new IllegalStateException("Cannot encode turnover samples",e);}
|
||||
}
|
||||
private static LocalDateTime utc(Instant value){return LocalDateTime.ofInstant(value,ZoneOffset.UTC);}
|
||||
private static BizException refresh(){return new BizException("库存周转上下文无效或已过期,请整体刷新");}
|
||||
}
|
||||
|
||||
+4
-4
@@ -21,9 +21,9 @@ public final class InventoryTurnoverPolicy {
|
||||
static String text(Map<String,Object> row,String key){Object value=row.get(key);return value==null?null:value.toString();}
|
||||
static BigDecimal divide(BigDecimal a,int n){return a==null?null:a.divide(BigDecimal.valueOf(n),6,RoundingMode.HALF_UP);}
|
||||
private static BigDecimal scale(BigDecimal value){return value==null?null:value.setScale(6,RoundingMode.HALF_UP);}
|
||||
static List<Window> windows(Map<String,Object> row,LocalDate until) {
|
||||
static List<Window> windows(Map<String,Object> row,LocalDate until, java.util.List<Integer> sizes) {
|
||||
List<Window> result=new ArrayList<>();
|
||||
for(int n:List.of(7,15,30)) {
|
||||
for(int n:sizes) {
|
||||
String key="w"+n+"_";var formal=decimal(row,key+"outbound");var observed=decimal(row,key+"observed");
|
||||
int complete=count(row,key+"complete"),partial=count(row,key+"partial");
|
||||
result.add(new Window(n,until.minusDays(n),until,formal,observed,divide(formal,n),complete,partial,
|
||||
@@ -48,7 +48,7 @@ public final class InventoryTurnoverPolicy {
|
||||
static Row row(Map<String,Object> raw,InventoryTurnoverRequest q,Context context) {
|
||||
var r=normalized(raw);boolean owner="OWNER".equals(q.group());
|
||||
Total physical=total(r,"physical",owner),available=total(r,"available",owner);
|
||||
var windows=windows(r,context.endDate().plusDays(1));var base=windows.stream().filter(w->w.days()==q.days()).findFirst().orElseThrow();
|
||||
var windows=windows(r,context.endDate().plusDays(1),q.windows());var base=windows.stream().filter(w->w.days()==q.days()).findFirst().orElseThrow();
|
||||
var coverage=owner?null:scale(decimal(r,"coverage_days"));var rawAverage=decimal(r,"average_physical");var average=scale(rawAverage);
|
||||
var turnover=scale(decimal(r,"turnover"));int sampleCount=count(r,"sample_count");
|
||||
String coverageStatus=owner?"NOT_APPLICABLE":available.value()==null?"INVENTORY_UNKNOWN_OR_STALE":available.value().signum()==0?"NO_AVAILABLE_STOCK":
|
||||
@@ -59,7 +59,7 @@ public final class InventoryTurnoverPolicy {
|
||||
List<LocalDate> missing=new ArrayList<>();String dates=text(r,"missing_dates");
|
||||
if(dates!=null&&!dates.isBlank())for(String date:dates.split(","))missing.add(LocalDate.parse(date.substring(0,10)));
|
||||
List<String> attention=new ArrayList<>();
|
||||
if(count(r,owner?"no_outbound_count":"no_outbound")>0)attention.add("NO_OUTBOUND_30D");
|
||||
if(count(r,owner?"no_outbound_count":"no_outbound")>0)attention.add("NO_OUTBOUND_IN_WINDOW");
|
||||
if(count(r,owner?"slow_count":"slow")>0)attention.add("COVERAGE_GT_THRESHOLD");
|
||||
if(decimal(r,"frozen")!=null&&decimal(r,"frozen").signum()>0)attention.add("FROZEN_STOCK");
|
||||
if(decimal(r,"defective")!=null&&decimal(r,"defective").signum()>0)attention.add("DEFECTIVE_STOCK");
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ public class InventoryTurnoverQueryService {
|
||||
}
|
||||
if(!hasTotal||request.overview()&&summary==null)throw new IllegalStateException("Missing turnover query section");
|
||||
if(request.overview()) {
|
||||
charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(InventoryTurnoverSql.chartInput(request,frozen))),context.endDate().plusDays(1)));
|
||||
charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(InventoryTurnoverSql.chartInput(request,frozen))),context.endDate().plusDays(1),request.windows()));
|
||||
}
|
||||
return new Response(context,summary,charts,List.copyOf(rows),total);
|
||||
}
|
||||
|
||||
+3
-2
@@ -17,6 +17,7 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod
|
||||
if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数");
|
||||
}
|
||||
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 String group(){return groupBy==null?"SKU":groupBy;}
|
||||
public String attention(){return focus==null?"ALL":focus;}
|
||||
public String sort(){return sortBy==null?"PHYSICAL_DESC":sortBy;}
|
||||
@@ -29,11 +30,11 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod
|
||||
AnalyticsQuery.text(sourceAccount,64,"来源账号"); AnalyticsQuery.text(warehouseCode,64,"仓库");
|
||||
if(customerCode!=null) AnalyticsQuery.text(customerCode,64,"货主");
|
||||
if(sku!=null) AnalyticsQuery.text(sku,128,"SKU");
|
||||
if(!Set.of(7,15,30).contains(days())) throw new BizException("基准窗口仅支持7、15、30日");
|
||||
if(days()<1||days()>366) throw new BizException("统计区间须为1至366个完整自然日");
|
||||
if(!end(today).isBefore(today)) throw new BizException("截止日必须是北京时间已结束的日期");
|
||||
if(!Set.of("SKU","OWNER").contains(group())) throw new BizException("分组仅支持SKU或OWNER");
|
||||
if("OWNER".equals(group())&&sku!=null) throw new BizException("货主汇总不接受SKU筛选");
|
||||
if(!Set.of("ALL","NO_OUTBOUND_30D","COVERAGE_GT","COVERAGE_LE","FROZEN","DEFECTIVE","UNASSESSABLE").contains(attention()))
|
||||
if(!Set.of("ALL","NO_OUTBOUND_IN_WINDOW","COVERAGE_GT","COVERAGE_LE","FROZEN","DEFECTIVE","UNASSESSABLE").contains(attention()))
|
||||
throw new BizException("库存关注条件无效");
|
||||
if(coverageDaysThreshold!=null&&coverageDaysThreshold<=0 || attention().startsWith("COVERAGE_")&&coverageDaysThreshold==null)
|
||||
throw new BizException("可售天数阈值须为正整数");
|
||||
|
||||
+1
-1
@@ -18,7 +18,7 @@ public final class InventoryTurnoverResponses {
|
||||
BigDecimal inventoryCoverageDays,String coverageStatus,BigDecimal averagePhysicalQuantity,
|
||||
BigDecimal quantityTurnoverCount,String turnoverStatus,int qualifiedSampleDays,int requiredSampleDays,
|
||||
List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate,
|
||||
List<String> attentionReasons,Risk noOutbound30d,Risk slowCoverage,int unassessableSkuCount) {}
|
||||
List<String> attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount) {}
|
||||
public record Sample(LocalDate date,Long runId) {}
|
||||
public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate,
|
||||
int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext,
|
||||
|
||||
+31
-29
@@ -9,12 +9,13 @@ public final class InventoryTurnoverSql {
|
||||
public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {}
|
||||
public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh,
|
||||
boolean salesKnown,List<Day> days) {
|
||||
public LocalDate until(){return days.get(29).date().plusDays(1);}
|
||||
public LocalDate until(){return days.get(days.size()-1).date().plusDays(1);}
|
||||
public LocalDate from(){return days.get(0).date();}
|
||||
public LocalDate fromBase(){return baseFrom();}
|
||||
public LocalDate baseFrom(){return until().minusDays(query.days());}
|
||||
public LocalDate from7(){return until().minusDays(7);}
|
||||
public LocalDate from15(){return until().minusDays(15);}
|
||||
public LocalDate from30(){return from();}
|
||||
public LocalDate from30(){return until().minusDays(30);}
|
||||
public LocalDateTime baseFromUtc(){return baseFrom().atStartOfDay().minusHours(8);}
|
||||
public LocalDateTime fromUtc(){return from().atStartOfDay().minusHours(8);}
|
||||
}
|
||||
@@ -34,6 +35,7 @@ public final class InventoryTurnoverSql {
|
||||
return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(),
|
||||
"VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days);
|
||||
}
|
||||
private static String windowStart(int n){return "#{p.from"+(java.util.Set.of(7,15,30).contains(n)?Integer.toString(n):"Base")+"}";}
|
||||
private static String scope(String alias) {
|
||||
return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}"
|
||||
+" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})";
|
||||
@@ -73,33 +75,33 @@ public final class InventoryTurnoverSql {
|
||||
.append("COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.salesRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END)) unit_valid,")
|
||||
.append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ")
|
||||
.append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code)")
|
||||
.append(skuWindows()).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ")
|
||||
.append(skuWindows(p)).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ")
|
||||
.append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date<CAST(#{p.until} AS DATE) AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)");
|
||||
sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid");
|
||||
for(int n:List.of(7,15,30)) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix);
|
||||
for(int n:p.query().windows()) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix);
|
||||
sql.append(",w.sample_count,w.sample_total,w.average_physical,w.sample_mask,ls.last_outbound");
|
||||
for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN #{p.stockFresh}=1 AND ip.quantity_unit='PCS' THEN st.").append(field).append(" END ").append(field);
|
||||
sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ")
|
||||
.append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,")
|
||||
.append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 AND evidence<=#{p.baseFromUtc} THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,")
|
||||
.append("CASE WHEN sample_total>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,")
|
||||
.append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w30_outbound IS NOT NULL AND evidence<=#{p.fromUtc} THEN CASE WHEN w30_outbound=0 THEN 1 ELSE 0 END END no_outbound,")
|
||||
.append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w"+p.query.days()+"_outbound IS NOT NULL AND evidence<=#{p.baseFromUtc} THEN CASE WHEN w"+p.query.days()+"_outbound=0 THEN 1 ELSE 0 END END no_outbound,")
|
||||
.append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL OR evidence IS NULL OR evidence>#{p.baseFromUtc} THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (")
|
||||
.append("SELECT b.*,CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w")
|
||||
.append(p.query.days()).append("_outbound=0 AND unassessable=0 THEN 0 END slow FROM sku_calculated b) ");
|
||||
return sql.toString();
|
||||
}
|
||||
private static String skuWindows() {
|
||||
private static String skuWindows(Input p) {
|
||||
// One shared suffix table qualifies absent sales dates. Millisecond evidence
|
||||
// after a midnight selects the next midnight, never the truncated same date.
|
||||
var sql=new StringBuilder(", evidence_boundaries AS (SELECT start_utc,LAG(start_utc) OVER (ORDER BY start_utc) previous_start");
|
||||
for(int n:List.of(7,15,30)) for(String kind:List.of("complete","known","partial")) {
|
||||
for(int n:p.query().windows()) for(String kind:List.of("complete","known","partial")) {
|
||||
String coverage=switch(kind){case "complete"->"coverage=1";case "known"->"coverage>0";default->"coverage=2";};
|
||||
sql.append(",SUM(CASE WHEN business_date>=CAST(#{p.from").append(n).append("} AS DATE) AND ").append(coverage)
|
||||
sql.append(",SUM(CASE WHEN business_date>=CAST(#{p.from").append(java.util.Set.of(7,15,30).contains(n)?Integer.toString(n):"Base").append("} AS DATE) AND ").append(coverage)
|
||||
.append(" THEN 1 ELSE 0 END) OVER (ORDER BY start_utc ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) w").append(n).append('_').append(kind);
|
||||
}
|
||||
sql.append(" FROM calendar), sku_zero_counts AS (SELECT c.customer_code,c.sku_code");
|
||||
for(int n:List.of(7,15,30)) for(String kind:List.of("complete","known","partial"))
|
||||
for(int n:p.query().windows()) for(String kind:List.of("complete","known","partial"))
|
||||
sql.append(",CASE WHEN c.unit_valid=1").append("complete".equals(kind)?"":" AND #{p.salesKnown}=1")
|
||||
.append(" THEN COALESCE(b.w").append(n).append('_').append(kind).append(",0) ELSE 0 END w").append(n).append('_').append(kind);
|
||||
sql.append(" FROM profiles c LEFT JOIN evidence_boundaries b ON c.evidence<=b.start_utc AND (b.previous_start IS NULL OR c.evidence>b.previous_start)), actual_sku_days AS (")
|
||||
@@ -109,8 +111,8 @@ public final class InventoryTurnoverSql {
|
||||
.append("CASE WHEN #{p.salesKnown}=1 AND c.unit_valid=1 AND d.coverage>0 AND c.evidence<=d.start_utc THEN 1 ELSE 0 END zero_known,")
|
||||
.append("CASE WHEN #{p.salesKnown}=1 AND c.unit_valid=1 AND d.coverage=2 AND c.evidence<=d.start_utc THEN 1 ELSE 0 END zero_partial ")
|
||||
.append("FROM sales_days s JOIN profiles c ON c.customer_code=s.customer_code AND c.sku_code=s.sku_code JOIN calendar d ON d.business_date=s.business_date), sku_actual_counts AS (SELECT customer_code,sku_code");
|
||||
for(int n:List.of(7,15,30)) {
|
||||
String range="business_date>=CAST(#{p.from"+n+"} AS DATE)";
|
||||
for(int n:p.query().windows()) {
|
||||
String range="business_date>=CAST("+windowStart(n)+" AS DATE)";
|
||||
sql.append(",SUM(CASE WHEN ").append(range).append(" THEN COALESCE(observed,0) ELSE 0 END) w").append(n).append("_sum")
|
||||
.append(",SUM(CASE WHEN ").append(range).append(" THEN complete-zero_complete ELSE 0 END) w").append(n).append("_complete")
|
||||
.append(",SUM(CASE WHEN ").append(range).append(" THEN (CASE WHEN observed IS NOT NULL THEN 1 ELSE 0 END)-zero_known ELSE 0 END) w").append(n).append("_known")
|
||||
@@ -126,40 +128,40 @@ public final class InventoryTurnoverSql {
|
||||
// Keep the qualified sum exact; do not divide again by a rounded daily mean.
|
||||
.append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,")
|
||||
.append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,")
|
||||
.append(sampleMask()).append(" sample_mask FROM sku_sample_days GROUP BY customer_code,sku_code), sku_window_counts AS (SELECT z.customer_code,z.sku_code,COALESCE(s.sample_count,0) sample_count,s.sample_total,s.average_physical,COALESCE(s.sample_mask,0) sample_mask");
|
||||
for(int n:List.of(7,15,30)) sql.append(",CASE WHEN z.w").append(n).append("_known+COALESCE(a.w").append(n).append("_known,0)>0 THEN COALESCE(a.w").append(n).append("_sum,0) END w").append(n).append("_observed")
|
||||
.append(sampleMask()).append(" sample_mask FROM sku_sample_days GROUP BY customer_code,sku_code), sku_window_counts AS (SELECT z.customer_code,z.sku_code,COALESCE(s.sample_count,0) sample_count,s.sample_total,s.average_physical,COALESCE(s.sample_mask,'') sample_mask");
|
||||
for(int n:p.query().windows()) sql.append(",CASE WHEN z.w").append(n).append("_known+COALESCE(a.w").append(n).append("_known,0)>0 THEN COALESCE(a.w").append(n).append("_sum,0) END w").append(n).append("_observed")
|
||||
.append(",z.w").append(n).append("_complete+COALESCE(a.w").append(n).append("_complete,0) w").append(n).append("_complete")
|
||||
.append(",z.w").append(n).append("_partial+COALESCE(a.w").append(n).append("_partial,0) w").append(n).append("_partial");
|
||||
return sql.append(" FROM sku_zero_counts z LEFT JOIN sku_actual_counts a ON a.customer_code=z.customer_code AND a.sku_code=z.sku_code ")
|
||||
.append("LEFT JOIN sku_sample_counts s ON s.customer_code=z.customer_code AND s.sku_code=z.sku_code), sku_windows AS (SELECT w.*")
|
||||
.append(formalWindows()).append(" FROM sku_window_counts w)").toString();
|
||||
.append(formalWindows(p)).append(" FROM sku_window_counts w)").toString();
|
||||
}
|
||||
private static String sampleMask() {
|
||||
// Exactly 30 bits, all exactly representable even by POWER's numeric result.
|
||||
return "SUM(CASE WHEN sample_physical IS NOT NULL THEN CAST(POWER(2,TIMESTAMPDIFF(DAY,CAST(#{p.from} AS DATE),business_date)) AS SIGNED) ELSE 0 END)";
|
||||
// Qualified date membership supports the full selected window without numeric bit overflow.
|
||||
return "GROUP_CONCAT(CASE WHEN sample_physical IS NOT NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',')";
|
||||
}
|
||||
private static String pageMissingDates() {
|
||||
// Only the selected page expands dates. Numeric qualification and filtering
|
||||
// already used every candidate and every frozen sample before pagination.
|
||||
return ", page_missing AS (SELECT r.*, (SELECT GROUP_CONCAT(CAST(business_date AS CHAR) ORDER BY business_date SEPARATOR ',') FROM calendar d "
|
||||
+"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND MOD(FLOOR(r.sample_mask/POWER(2,TIMESTAMPDIFF(DAY,CAST(#{p.from} AS DATE),d.business_date))),2)=0) missing_dates FROM page_result r)";
|
||||
+"WHERE d.business_date>=CAST(#{p.baseFrom} AS DATE) AND LOCATE(CAST(d.business_date AS CHAR),COALESCE(r.sample_mask,''))=0) missing_dates FROM page_result r)";
|
||||
}
|
||||
private static String windowAggregates() {
|
||||
private static String windowAggregates(Input p) {
|
||||
StringBuilder sql=new StringBuilder();
|
||||
for(int n:List.of(7,15,30)) {
|
||||
String range="business_date>=CAST(#{p.from"+n+"} AS DATE)";
|
||||
for(int n:p.query().windows()) {
|
||||
String range="business_date>=CAST("+windowStart(n)+" AS DATE)";
|
||||
sql.append(",SUM(CASE WHEN ").append(range).append(" THEN observed END) w").append(n).append("_observed")
|
||||
.append(",SUM(CASE WHEN ").append(range).append(" THEN complete ELSE 0 END) w").append(n).append("_complete")
|
||||
.append(",SUM(CASE WHEN ").append(range).append(" AND complete=0 AND observed IS NOT NULL THEN 1 ELSE 0 END) w").append(n).append("_partial");
|
||||
}
|
||||
return sql.toString();
|
||||
}
|
||||
private static String formalWindows() {
|
||||
private static String formalWindows(Input p) {
|
||||
// Derive formal values once per SKU/owner, reusing the same complete count and
|
||||
// observed sum. A complete all-null observation sum retains the prior zero;
|
||||
// partial/unknown windows remain null and never shorten the denominator.
|
||||
var sql=new StringBuilder();
|
||||
for(int n:List.of(7,15,30)) sql.append(",CASE WHEN w.w").append(n).append("_complete=").append(n)
|
||||
for(int n:p.query().windows()) sql.append(",CASE WHEN w.w").append(n).append("_complete=").append(n)
|
||||
.append(" THEN COALESCE(w.w").append(n).append("_observed,0) END w").append(n).append("_outbound");
|
||||
return sql.toString();
|
||||
}
|
||||
@@ -176,13 +178,13 @@ public final class InventoryTurnoverSql {
|
||||
+"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN a.valid_count>0 THEN a.observed WHEN d.coverage>0 AND c.candidate_count>COALESCE(a.actual_count,0) THEN 0 END END observed,"
|
||||
+"s.sample_physical FROM owner_candidates c CROSS JOIN calendar d LEFT JOIN owner_actual_days a ON a.customer_code=c.customer_code AND a.business_date=d.business_date "
|
||||
+"LEFT JOIN owner_sample_days s ON s.customer_code=c.customer_code AND s.business_date=d.business_date), owner_window_counts AS (SELECT customer_code"
|
||||
+windowAggregates()+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,"
|
||||
+windowAggregates(p)+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,"
|
||||
+"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,"
|
||||
+"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,"
|
||||
+sampleMask()+" sample_mask FROM owner_days GROUP BY customer_code), owner_windows AS (SELECT w.*"+formalWindows()+" FROM owner_window_counts w), owner_totals AS ("
|
||||
+sampleMask()+" sample_mask FROM owner_days GROUP BY customer_code), owner_windows AS (SELECT w.*"+formalWindows(p)+" FROM owner_window_counts w), owner_totals AS ("
|
||||
+totals("customer_code",true)+" FROM sku_result GROUP BY customer_code), owner_result AS (SELECT t.*,w.sample_count,w.average_physical,w.sample_mask,"
|
||||
+"CASE WHEN w.sample_total>0 THEN w.w"+p.query.days()+"_outbound*#{p.query.days}/w.sample_total END turnover,"
|
||||
+String.join(",",List.of(7,15,30).stream().flatMap(n->List.of("outbound","observed","complete","partial").stream().map(s->"w.w"+n+"_"+s)).toList())
|
||||
+String.join(",",p.query().windows().stream().flatMap(n->List.of("outbound","observed","complete","partial").stream().map(s->"w.w"+n+"_"+s)).toList())
|
||||
+" FROM owner_totals t JOIN owner_windows w ON w.customer_code=t.customer_code) ";
|
||||
}
|
||||
private static String totals(String key,boolean withFocus) {
|
||||
@@ -196,7 +198,7 @@ public final class InventoryTurnoverSql {
|
||||
}
|
||||
private static String filter(Input p,boolean owner) {
|
||||
String predicate=switch(p.query.attention()) {
|
||||
case "NO_OUTBOUND_30D" -> owner?"no_outbound_count>0":"no_outbound=1";
|
||||
case "NO_OUTBOUND_IN_WINDOW" -> owner?"no_outbound_count>0":"no_outbound=1";
|
||||
case "COVERAGE_GT" -> owner?"slow_count>0":"slow=1";
|
||||
case "COVERAGE_LE" -> owner?"fast_count>0":"available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold}";
|
||||
case "FROZEN" -> owner?"frozen_count>0":"frozen>0";
|
||||
@@ -247,7 +249,7 @@ public final class InventoryTurnoverSql {
|
||||
Input p=(Input)args.get("p");return "WITH calendar AS ("+calendar(p)+"), sales_days AS ("+salesDays()+"), warehouse_days AS (SELECT d.business_date,"
|
||||
+"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete,"
|
||||
+"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed "
|
||||
+"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage), warehouse_window_counts AS (SELECT COUNT(*) day_count"+windowAggregates()+" FROM warehouse_days) SELECT w.*"+formalWindows()+" FROM warehouse_window_counts w";
|
||||
+"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage), warehouse_window_counts AS (SELECT COUNT(*) day_count"+windowAggregates(p)+" FROM warehouse_days) SELECT w.*"+formalWindows(p)+" FROM warehouse_window_counts w";
|
||||
}
|
||||
private static String jsonFields(List<String> fields) {
|
||||
return "JSON_OBJECT("+String.join(",",fields.stream().map(f->"'"+f+"',"+f).toList())+")";
|
||||
@@ -257,7 +259,7 @@ public final class InventoryTurnoverSql {
|
||||
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview();
|
||||
String source=owner?"owner_result":"sku_result";
|
||||
List<String> fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable"));
|
||||
for(int n:List.of(7,15,30))for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix);
|
||||
for(int n:p.query().windows())for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix);
|
||||
if(owner) {
|
||||
fields.add("physical_skus");
|
||||
for(String field:List.of("physical","available","frozen","defective"))for(String suffix:List.of("known","unknown"))fields.add(field+"_"+suffix);
|
||||
@@ -270,7 +272,7 @@ public final class InventoryTurnoverSql {
|
||||
if(overview)sql+=", summary_result AS ("+totals(null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)";
|
||||
// Statement-local only: share intermediate relations across report branches;
|
||||
// do not change connection/global settings or any result/filter semantics.
|
||||
sql+=" SELECT /*+ SET_VAR(optimizer_switch='derived_merge=off') SET_VAR(optimizer_search_depth=4) */ 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_missing"
|
||||
sql+=" SELECT /*+ SET_VAR(group_concat_max_len=16384) SET_VAR(optimizer_switch='derived_merge=off') SET_VAR(optimizer_search_depth=4) */ 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_missing"
|
||||
+" UNION ALL SELECT 'TOTAL' section,0 ordinal,JSON_OBJECT('total',COUNT(*)) payload FROM "+source+filter(p,owner);
|
||||
if(overview) {
|
||||
List<String> summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown"));
|
||||
|
||||
+3
-4
@@ -12,7 +12,7 @@ final class InventoryWindowPolicy {
|
||||
private InventoryWindowPolicy() {}
|
||||
static SalesRangePolicy.Range range(AnalyticsInventoryOptions o,LocalDate anchor) {
|
||||
int days=o.salesDays();
|
||||
if(days!=10&&days!=30)throw new BizException("库存销量窗口仅支持10或30天");
|
||||
if(days<1||days>366)throw new BizException("库存销量窗口须为1至366个完整自然日");
|
||||
LocalDate end=o.salesWindowEndDate()==null?anchor.minusDays(1):o.salesWindowEndDate();
|
||||
if(!end.isBefore(anchor))throw new BizException("库存销量截止日必须早于查询当日");
|
||||
LocalDate until=end.plusDays(1),from=until.minusDays(days);var zone=ZoneId.of("Asia/Shanghai");
|
||||
@@ -20,18 +20,17 @@ final class InventoryWindowPolicy {
|
||||
}
|
||||
record Result(BigDecimal average,BigDecimal days,String status) {}
|
||||
static Result calculate(String quality,BigDecimal available,BigDecimal sales,boolean trusted,
|
||||
boolean complete,boolean observed,boolean unit,int days) {
|
||||
boolean complete,boolean unit,int days) {
|
||||
if(days<1||days>366)throw new IllegalArgumentException("Inventory window must be 1 to 366 days");
|
||||
if(!"VALID".equals(quality)||available==null)return new Result(null,null,"INVENTORY_"+quality);
|
||||
if(available.signum()<0)return new Result(null,null,"INVENTORY_INVALID");
|
||||
BigDecimal divisor=BigDecimal.valueOf(days);
|
||||
BigDecimal average=unit&&trusted&&complete&&observed&&sales!=null&&sales.signum()>=0
|
||||
BigDecimal average=unit&&trusted&&complete&&sales!=null&&sales.signum()>=0
|
||||
?sales.divide(divisor,12,RoundingMode.HALF_UP):null;
|
||||
if(available.signum()==0)return new Result(average,BigDecimal.ZERO.setScale(1),"NO_AVAILABLE_INVENTORY");
|
||||
if(!unit)return new Result(null,null,"UNIT_UNKNOWN_OR_CONFLICT");
|
||||
if(!trusted)return new Result(null,null,"SALES_UNTRUSTED");
|
||||
if(!complete)return new Result(null,null,"COVERAGE_INSUFFICIENT");
|
||||
if(!observed)return new Result(null,null,"OBSERVATION_INSUFFICIENT");
|
||||
if(sales==null||sales.signum()<0)return new Result(null,null,"SALES_UNKNOWN");
|
||||
if(sales.signum()==0)return new Result(average,null,"NO_OUTBOUND_IN_WINDOW");
|
||||
return new Result(average,available.multiply(divisor).divide(sales,1,RoundingMode.HALF_UP),"VALID");
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
/** Whitelisted global quantity ordering; unknown quantities always follow known values. */
|
||||
public enum SalesSort {
|
||||
QUANTITY_ASC, QUANTITY_DESC;
|
||||
public String sql(String column) {
|
||||
return column + " IS NULL," + column + (this == QUANTITY_ASC ? " ASC" : " DESC") + ",sku_code ASC,customer_code ASC";
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ paths:
|
||||
summary: 单仓固定版本经营总览
|
||||
description: >-
|
||||
平台独立只读权限platform:self-operated-analytics:list。一次一致快照固定销量和库存版本。
|
||||
默认LAST_15_DAYS统一用于趋势、区间出库、日均、互斥分桶及三个Top10;热销按已采集有效量排序并标记覆盖状态,库存评估要求所选区间完整销量及观察资格。
|
||||
默认LAST_15_DAYS统一用于趋势、区间出库、日均、互斥分桶及三个Top10;热销按已采集有效量排序并标记覆盖状态,库存评估要求所选区间完整可信销量和有效库存;商品观察期仅提示。
|
||||
不接受货主、SKU、分页、windowDays、topN或其他额外筛选。仓级趋势不套单SKU观察期。
|
||||
库存严格下钻须携带原版本和drilldownFrom=DASHBOARD;截止时间由服务端根据库存真实观测重算。
|
||||
parameters:
|
||||
@@ -57,7 +57,7 @@ paths:
|
||||
/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary:
|
||||
get:
|
||||
summary: SKU五周期或历史累计清单
|
||||
description: '候选为两版profile并集,SQL按选定日期聚合后按observedQuantity降序空值末尾、SKU及货主升序,再分页。未知日不填零。 权限platform:self-operated-analytics:list。'
|
||||
description: '候选为两版profile并集,SQL按选定日期聚合后按salesSort指定observedQuantity升降序(默认降序)空值末尾、SKU及货主升序,再分页。未知日不填零。 权限platform:self-operated-analytics:list。'
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/sourceAccount'}
|
||||
- {$ref: '#/components/parameters/warehouseCode'}
|
||||
@@ -74,6 +74,11 @@ paths:
|
||||
- {$ref: '#/components/parameters/bucket'}
|
||||
- {$ref: '#/components/parameters/current'}
|
||||
- {$ref: '#/components/parameters/size'}
|
||||
- name: salesSort
|
||||
in: query
|
||||
required: false
|
||||
description: 全量数量排序,默认降序;未知值始终末尾,同值按SKU及货主升序。
|
||||
schema: {type: string, enum: [QUANTITY_ASC, QUANTITY_DESC], default: QUANTITY_DESC}
|
||||
responses:
|
||||
'200':
|
||||
description: '统一ApiResponse,业务失败HTTP200且code非200。'
|
||||
@@ -153,8 +158,8 @@ paths:
|
||||
'403': {description: 非平台身份或缺少独立只读权限}
|
||||
/api/platform/v1/last-leg/self-operated-analytics/slow-skus:
|
||||
get:
|
||||
summary: 固定完整30日有库存低出库Top100
|
||||
description: '固定30天,不接受windowDays或rangePreset。正品有效新鲜可售量大于零、完整窗口且起点前可靠存在。数量升序、可售量降序、SKU及货主升序。 权限platform:self-operated-analytics:list。'
|
||||
summary: 所选完整时段有库存低出库Top100
|
||||
description: '支持CUSTOM起止日期,1至366天,前端统一为截至昨日的3/7/15/30天或自定义;不接受windowDays。正品有效新鲜可售量大于零、完整窗口且起点前可靠存在。数量升序、可售量降序、SKU及货主升序。 权限platform:self-operated-analytics:list。'
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/sourceAccount'}
|
||||
- {$ref: '#/components/parameters/warehouseCode'}
|
||||
@@ -165,6 +170,9 @@ paths:
|
||||
- {$ref: '#/components/parameters/salesScopeHash'}
|
||||
- {$ref: '#/components/parameters/inventoryScopeHash'}
|
||||
- {$ref: '#/components/parameters/queryAsOf'}
|
||||
- {$ref: '#/components/parameters/rangePreset'}
|
||||
- {$ref: '#/components/parameters/startDate'}
|
||||
- {$ref: '#/components/parameters/endDate'}
|
||||
- {$ref: '#/components/parameters/current'}
|
||||
- {$ref: '#/components/parameters/size'}
|
||||
responses:
|
||||
@@ -183,7 +191,7 @@ paths:
|
||||
/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage:
|
||||
get:
|
||||
summary: 正品库存与所选窗口可售天数
|
||||
description: '普通库存明细使用10或30天;总览严格下钻须携带drilldownFrom=DASHBOARD及原图CUSTOM起止日期,不得同时携带salesWindowDays/salesWindowEndDate。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。'
|
||||
description: '普通库存明细使用1至366个完整自然日,由最近3/7/15/30天或自定义区间计算;总览严格下钻须携带drilldownFrom=DASHBOARD及原图CUSTOM起止日期,不得同时携带salesWindowDays/salesWindowEndDate。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位及完整可信窗口;观察期仅提示,不阻断统计;零销量天数为空。 权限platform:self-operated-analytics:list。'
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/rangePreset'}
|
||||
- {$ref: '#/components/parameters/startDate'}
|
||||
@@ -195,7 +203,7 @@ paths:
|
||||
- {$ref: '#/components/parameters/attention'}
|
||||
- {$ref: '#/components/parameters/sortBy'}
|
||||
- {$ref: '#/components/parameters/coverageDaysThreshold'}
|
||||
- {name: salesWindowDays, in: query, schema: {type: integer, enum: [10, 30], default: 30}, description: 仅库存明细使用的完整自然日窗口}
|
||||
- {name: salesWindowDays, in: query, schema: {type: integer, minimum: 1, maximum: 366, default: 30}, description: 仅库存明细使用的完整自然日窗口}
|
||||
- {name: salesWindowEndDate, in: query, schema: {type: string, format: date}, description: 含当日的窗口截止日,默认固定查询基准的北京时间昨日;必须早于查询当日,分页须携带原窗口}
|
||||
|
||||
- {$ref: '#/components/parameters/sourceAccount'}
|
||||
@@ -393,7 +401,7 @@ components:
|
||||
drilldownFrom: {name: drilldownFrom, in: query, description: 必须固定原版本,服务端在真实库存有效期与查询到期较早者阻断并要求刷新总览;不增加权限, schema: {type: string, enum: [DASHBOARD]}}
|
||||
keyword: {name: keyword, in: query, schema: {type: string, minLength: 1, maxLength: 64}, description: 区分大小写的货主编码字面前缀,先过滤去重再分页}
|
||||
attention: {name: attention, in: query, schema: {type: string, default: ALL, enum: [ALL, NO_OUTBOUND_IN_WINDOW, COVERAGE_GT_THRESHOLD, FROZEN_STOCK, DEFECTIVE_STOCK, UNASSESSABLE]}}
|
||||
sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_DESC, COVERAGE_DAYS_DESC, PHYSICAL_DESC, OUTBOUND_ASC]}, description: 完整候选先筛选后按未舍入值排序分页,未知末尾,同值SKU和货主升序}
|
||||
sortBy: {name: sortBy, in: query, schema: {type: string, default: AVAILABLE_DESC, enum: [AVAILABLE_ASC, AVAILABLE_DESC, COVERAGE_DAYS_ASC, COVERAGE_DAYS_DESC, PHYSICAL_ASC, PHYSICAL_DESC, FROZEN_ASC, FROZEN_DESC, DEFECTIVE_ASC, DEFECTIVE_DESC, TRANSIT_ASC, TRANSIT_DESC, OUTBOUND_ASC, OUTBOUND_DESC, AVERAGE_DAILY_ASC, AVERAGE_DAILY_DESC]}, description: 完整候选先筛选后按正式未舍入值排序分页,未知末尾,同值SKU和货主升序;出库和日均按本次实际窗口,已采集小计不参与正式值排序}
|
||||
coverageDaysThreshold: {name: coverageDaysThreshold, in: query, schema: {type: integer, minimum: 1}, description: COVERAGE_GT_THRESHOLD必填,严格大于且未舍入比较,不设置默认经营政策}
|
||||
sourceAccount: {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
|
||||
warehouseCode: {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
|
||||
@@ -658,16 +666,17 @@ components:
|
||||
status: {type: string, nullable: true}
|
||||
reasonCodes: {type: array, items: {type: string}}
|
||||
InventoryMetric:
|
||||
description: 库存窗口响应。windowOutboundQuantity为所选完整窗口销量:普通明细10或30日,总览严格下钻使用原图CUSTOM区间;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。无出库关注、出库排序及分组均使用实际窗口。
|
||||
description: 库存窗口响应。windowOutboundQuantity为所选完整窗口销量:普通明细10或30日,总览严格下钻使用原图CUSTOM区间;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。无出库关注、出库排序及分组均使用实际窗口;商品观察期仅提示,不阻断完整窗口销量及可售天数。完整可信窗口中无出库行可记零,覆盖不足或数量异常不可补零。
|
||||
type: object
|
||||
properties:
|
||||
customerName: {type: string, nullable: true}
|
||||
defectivePhysicalQuantity: {type: number, nullable: true}
|
||||
windowOutboundQuantity: {type: number, nullable: true}
|
||||
observedWindowOutboundQuantity: {type: number, nullable: true, description: 同一固定销量版及实际窗口中单位有效的已采集出库小计;不代替完整窗口出库,不用于推算日均或可售天数,未知不补零}
|
||||
lastObservedOutboundDate: {type: string, format: date, nullable: true}
|
||||
observedHistoryStartDate: {type: string, format: date, nullable: true}
|
||||
attentionReasons: {type: array, items: {type: string}}
|
||||
quantityStatuses: {type: object, additionalProperties: {type: string, enum: [VALID, UNKNOWN, STALE]}}
|
||||
quantityStatuses: {type: object, additionalProperties: {type: string, enum: [VALID, UNKNOWN, STALE, UNIT_UNKNOWN_OR_CONFLICT, SALES_UNTRUSTED, COVERAGE_INSUFFICIENT]}}
|
||||
sourceAccount: {type: string, nullable: true}
|
||||
customerCode: {type: string, nullable: true}
|
||||
warehouseCode: {type: string, nullable: true}
|
||||
@@ -684,7 +693,7 @@ components:
|
||||
inventoryCoverageDays: {type: number, nullable: true}
|
||||
inventoryQuality: {type: string, nullable: true}
|
||||
salesCoverage: {type: string, nullable: true}
|
||||
observationEligible: {type: boolean, nullable: true}
|
||||
observationEligible: {type: boolean, nullable: true, description: 是否已有不晚于窗口起点的可靠商品记录;false仅用于观察提示,不阻断完整可信窗口出库、日均和可售天数}
|
||||
earliestReliableEvidenceAt: {type: string, format: date-time, nullable: true}
|
||||
status: {type: string, nullable: true}
|
||||
reasonCodes: {type: array, items: {type: string}}
|
||||
|
||||
+12
-12
@@ -5,10 +5,10 @@ info:
|
||||
description: >-
|
||||
仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。
|
||||
仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。
|
||||
三窗为截至endDate的7/15/30个完整北京时间自然日;当前库存独立固定版本。
|
||||
所选窗口为截至endDate的1~366个完整北京时间自然日,并提供7/15/30天辅助对比;当前库存独立固定版本。
|
||||
每次请求使用REPEATABLE_READ。snapshotContext为服务端重建验证的版本与日代表证据,
|
||||
不作为授权凭据;账号、仓库、截止日、基准、queryAsOf、版本对、范围摘要、
|
||||
重建状态及全部30日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。
|
||||
重建状态及全部max(30,baseWindowDays)日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。
|
||||
历史代表须同范围、正式计划采集、已发布且DAILY_SCHEDULED_FIRST_V1合格标记。
|
||||
上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。
|
||||
历史销量完整性不依赖当前库存新鲜度;缺样本不补零、不缩短周转分母。
|
||||
@@ -28,13 +28,13 @@ paths:
|
||||
- {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
|
||||
- {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
|
||||
- {name: endDate, in: query, description: '含截止日,默认北京时间昨天,不接受今日或未来', schema: {type: string, format: date}}
|
||||
- {name: baseWindowDays, in: query, schema: {type: integer, enum: [7, 15, 30], default: 30}}
|
||||
- {name: baseWindowDays, in: query, schema: {type: integer, minimum: 1, maximum: 366, default: 30}}
|
||||
- {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}}
|
||||
- {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: focus
|
||||
in: query
|
||||
schema: {type: string, enum: [ALL, NO_OUTBOUND_30D, 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}
|
||||
- {name: coverageDaysThreshold, in: query, description: 'GT/LE必填,其余可选;无默认业务阈值', schema: {type: integer, minimum: 1}}
|
||||
- {name: turnoverStatus, in: query, schema: {type: string, enum: [INSUFFICIENT]}}
|
||||
- name: sortBy
|
||||
@@ -91,7 +91,7 @@ components:
|
||||
type: object
|
||||
required: [days, startDate, endDateExclusive, outboundQuantity, observedQuantity, averageDailyOutboundQuantity, completeDayCount, partialDayCount, unknownDayCount, dataStatus, reasonCodes]
|
||||
properties:
|
||||
days: {type: integer, enum: [7, 15, 30]}
|
||||
days: {type: integer, minimum: 1, maximum: 366}
|
||||
startDate: {type: string, format: date}
|
||||
endDateExclusive: {type: string, format: date}
|
||||
outboundQuantity: {$ref: '#/components/schemas/Quantity'}
|
||||
@@ -110,7 +110,7 @@ components:
|
||||
sourceAccount: {type: string}
|
||||
warehouseCode: {type: string}
|
||||
endDate: {type: string, format: date}
|
||||
baseWindowDays: {type: integer, enum: [7, 15, 30]}
|
||||
baseWindowDays: {type: integer, minimum: 1, maximum: 366}
|
||||
queryAsOf: {type: string, format: date-time}
|
||||
queryExpiresAt: {type: string, format: date-time}
|
||||
analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'}
|
||||
@@ -120,7 +120,7 @@ components:
|
||||
dailySamples:
|
||||
type: array
|
||||
minItems: 30
|
||||
maxItems: 30
|
||||
maxItems: 366
|
||||
items:
|
||||
type: object
|
||||
required: [date, runId]
|
||||
@@ -131,7 +131,7 @@ components:
|
||||
accumulationStartedOn: {type: string, format: date, nullable: true}
|
||||
Row:
|
||||
type: object
|
||||
required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutbound30d, slowCoverage, unassessableSkuCount]
|
||||
required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutboundInWindow, slowCoverage, unassessableSkuCount]
|
||||
properties:
|
||||
customerCode: {type: string}
|
||||
customerName: {type: string, nullable: true}
|
||||
@@ -142,7 +142,7 @@ components:
|
||||
frozenQuantity: {$ref: '#/components/schemas/Total'}
|
||||
defectiveQuantity: {$ref: '#/components/schemas/Total'}
|
||||
physicalSkuCount: {$ref: '#/components/schemas/Quantity'}
|
||||
windows: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}}
|
||||
windows: {type: array, minItems: 3, maxItems: 4, items: {$ref: '#/components/schemas/Window'}}
|
||||
averageDailyOutboundQuantity: {$ref: '#/components/schemas/Quantity'}
|
||||
inventoryCoverageDays: {$ref: '#/components/schemas/Quantity'}
|
||||
coverageStatus: {type: string, enum: [VALID, NOT_APPLICABLE, INVENTORY_UNKNOWN_OR_STALE, NO_AVAILABLE_STOCK, UNASSESSABLE, NO_OUTBOUND_IN_WINDOW]}
|
||||
@@ -150,12 +150,12 @@ components:
|
||||
quantityTurnoverCount: {$ref: '#/components/schemas/Quantity'}
|
||||
turnoverStatus: {type: string, enum: [VALID, INSUFFICIENT, ZERO_AVERAGE_STOCK, SALES_WINDOW_UNAVAILABLE]}
|
||||
qualifiedSampleDays: {type: integer, minimum: 0, maximum: 30}
|
||||
requiredSampleDays: {type: integer, enum: [7, 15, 30]}
|
||||
requiredSampleDays: {type: integer, minimum: 1, maximum: 366}
|
||||
missingSampleDates: {type: array, items: {type: string, format: date}}
|
||||
sampleReasonCodes: {$ref: '#/components/schemas/Reasons'}
|
||||
lastObservedOutboundDate: {type: string, format: date, nullable: true}
|
||||
attentionReasons: {$ref: '#/components/schemas/Reasons'}
|
||||
noOutbound30d: {$ref: '#/components/schemas/Risk'}
|
||||
noOutboundInWindow: {$ref: '#/components/schemas/Risk'}
|
||||
slowCoverage: {$ref: '#/components/schemas/Risk'}
|
||||
unassessableSkuCount: {type: integer, minimum: 0}
|
||||
Overview:
|
||||
@@ -183,7 +183,7 @@ components:
|
||||
customerName: {type: string, nullable: true}
|
||||
physicalQuantity: {$ref: '#/components/schemas/Quantity'}
|
||||
unknownOwnerCount: {type: integer, minimum: 0}
|
||||
averageDaily: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}}
|
||||
averageDaily: {type: array, minItems: 3, maxItems: 4, items: {$ref: '#/components/schemas/Window'}}
|
||||
Response:
|
||||
type: object
|
||||
required: [context, summary, charts, rows, total]
|
||||
|
||||
+12
-3
@@ -41,9 +41,9 @@ class PlatformAnalyticsControllerTest {
|
||||
@Test void allElevenReadEndpointsShareTheIndependentGuard(){
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var c=new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class));var user=platform();
|
||||
c.salesSummary(user,null);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null);
|
||||
c.salesSummary(user,null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);c.dailySales(user,null);c.hotSkus(user,null);c.slowSkus(user,null);c.inventoryCoverage(user,null,null);
|
||||
c.owners(user,null,null);c.ownerOverview(user,null,null);c.syncOverview(user,null);c.syncTasks(user,null);c.syncTaskDetail(user,"LXCOL-DEMO",null);c.syncRuns(user,null);
|
||||
verify(service).salesSummary(null);verify(service).dailySales(null);verify(service).hotSkus(null);
|
||||
verify(service).salesSummary(null,com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);verify(service).dailySales(null);verify(service).hotSkus(null);
|
||||
verify(service).slowSkus(null);verify(service).inventoryCoverage(null,null);
|
||||
verify(service).owners(null,null);verify(service).ownerOverview(null,null);verify(collection).overview(null);verify(collection).tasks(null);verify(collection).taskDetail("LXCOL-DEMO",null);verify(collection).runs(null);
|
||||
assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformAnalyticsController.class.getAnnotation(PreAuthorize.class).value());
|
||||
@@ -62,10 +62,19 @@ class PlatformAnalyticsControllerTest {
|
||||
.param("endDate","2026-10-12").param("current","2").param("size","20").param("salesRunId","11").param("inventoryRunId","22")
|
||||
.param("salesScopeHash","a".repeat(64)).param("inventoryScopeHash","b".repeat(64))
|
||||
.param("queryAsOf","2026-10-12T04:00:00Z")).andExpect(status().isOk());
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(service).salesSummary(cap.capture());
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsQuery.class);verify(service).salesSummary(cap.capture(),eq(com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC));
|
||||
assertEquals(LocalDate.of(2026,10,1),cap.getValue().startDate());assertEquals(Instant.parse("2026-10-12T04:00:00Z"),cap.getValue().queryAsOf());
|
||||
assertEquals(11L,cap.getValue().salesRunId());assertEquals(2,cap.getValue().current());
|
||||
}
|
||||
@Test void salesSortBindsAscendingAndRejectsUnknownValues() throws Exception {
|
||||
var service=mock(AnalyticsQueryService.class);
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,mock(CollectionQueryService.class),mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary").param("salesSort","QUANTITY_ASC")).andExpect(status().isOk());
|
||||
verify(service).salesSummary(any(),eq(com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_ASC));
|
||||
clearInvocations(service);
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/sku-sales-summary").param("salesSort","invalid")).andExpect(status().isBadRequest());
|
||||
verifyNoInteractions(service);
|
||||
}
|
||||
@Test void collectionUsesBusinessTaskNumberAndIndependentLightweightDto() throws Exception{
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
|
||||
|
||||
+38
-9
@@ -93,6 +93,7 @@ class AnalyticsQueryServiceTest {
|
||||
when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"));
|
||||
when(mapper.count(any())).thenReturn(1L);
|
||||
when(mapper.page(any())).thenReturn(List.of(aggregate(true,"300","VALID",time(D.minusDays(40)))));
|
||||
when(mapper.salesPage(any(),any())).thenAnswer(invocation -> mapper.page(invocation.getArgument(0)));
|
||||
when(mapper.inventoryCount(any(),any())).thenReturn(1L);
|
||||
doAnswer(inv->List.of(inventory(true,"300",inv.getArgument(0,AnalyticsSqlRequest.class).stockFresh(),time(D.minusDays(40)),inv.getArgument(0,AnalyticsSqlRequest.class).completeWindow()?"150":null))).when(mapper).inventoryPage(any(),any());
|
||||
when(mapper.rankingStats(any())).thenReturn(List.of(new AnalyticsReadMapper.RankingStat(1L,null,0L)));
|
||||
@@ -108,9 +109,21 @@ class AnalyticsQueryServiceTest {
|
||||
assertFalse(cap.getValue().completeWindow());assertEquals(D.minusDays(44),result.context().rangeStartDate());
|
||||
assertThrows(com.baiyue.common.core.exception.BizException.class,()->new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(2),D,null,7,1,20,null,null,null,null,null).validate(false,false,true));
|
||||
}
|
||||
@Test void customSlowRangeUsesSelectedDaysAndPreservesIncompleteLongWindow(){
|
||||
for(int days:List.of(3,45)){
|
||||
clearInvocations(mapper);
|
||||
var q=new AnalyticsQuery("demo","W",null,null,"CUSTOM",D.minusDays(days),D.minusDays(1),null,null,1,20,null,null,null,null,null);
|
||||
var result=service.slowSkus(q);
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).page(cap.capture());
|
||||
assertEquals(D.minusDays(days),cap.getValue().from());assertEquals(D,cap.getValue().until());
|
||||
assertEquals(days,result.context().calculationWindowDays());
|
||||
if(days==3)assertEquals(0,n("50").compareTo(result.records().get(0).averageDailyOutboundQuantity()));
|
||||
else assertNull(result.records().get(0).averageDailyOutboundQuantity());
|
||||
}
|
||||
}
|
||||
@Test void summaryUsesCountAndBoundPageWithoutLoadingProfilesOrHistory(){
|
||||
when(mapper.count(any())).thenReturn(240L);
|
||||
var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null));
|
||||
var response=service.salesSummary(chart("LAST_3_DAYS",null,null,null,null),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);
|
||||
verify(mapper).count(cap.capture()); verify(mapper).page(cap.capture());
|
||||
assertEquals(cap.getAllValues().get(0),cap.getAllValues().get(1));
|
||||
@@ -134,7 +147,7 @@ class AnalyticsQueryServiceTest {
|
||||
assertEquals(1,response.summary().partialDayCount()); assertEquals("PCS",response.summary().quantityUnit());
|
||||
verify(mapper).daily(11,"demo","W","C","SKU",D.minusDays(2),D.plusDays(1));
|
||||
verify(mapper,never()).page(any()); verify(mapper,never()).count(any());
|
||||
var summary=service.salesSummary(chart("LAST_3_DAYS",null,null,"C","SKU")).records().get(0);
|
||||
var summary=service.salesSummary(chart("LAST_3_DAYS",null,null,"C","SKU"),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC).records().get(0);
|
||||
assertEquals(response.summary().observedQuantity(),summary.observedQuantity());
|
||||
assertEquals(response.summary().outboundQuantity(),summary.outboundQuantity()); assertEquals(response.summary().dataStatus(),summary.dataStatus());
|
||||
}
|
||||
@@ -203,9 +216,9 @@ class AnalyticsQueryServiceTest {
|
||||
}
|
||||
@Test void pinnedDateSurvivesMidnightAndNewPublishedPointers(){
|
||||
Instant before=Instant.parse("2026-10-12T15:55:00Z"),after=before.plusSeconds(600);
|
||||
var first=at(before).salesSummary(chart("TODAY",null,null,null,null));
|
||||
var first=at(before).salesSummary(chart("TODAY",null,null,null,null),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,33L),scope(2,"INVENTORY",IH,44L)));
|
||||
var second=at(after).salesSummary(pinned("TODAY",11,22,SH,IH,first.context().queryAsOf(),2));
|
||||
var second=at(after).salesSummary(pinned("TODAY",11,22,SH,IH,first.context().queryAsOf(),2),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);
|
||||
assertEquals(D,second.context().anchorDate()); assertEquals(D,second.context().rangeStartDate());
|
||||
assertEquals(D.plusDays(1),second.context().rangeEndDateExclusive()); assertEquals(11L,second.context().salesRunId());
|
||||
verify(mapper,never()).publishedRun(33); verify(mapper,never()).publishedRun(44);
|
||||
@@ -213,9 +226,9 @@ class AnalyticsQueryServiceTest {
|
||||
}
|
||||
@Test void missingVersionZeroStaysMissingAfterNewPointerAppears(){
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,0L),scope(2,"INVENTORY",IH,0L)));
|
||||
var first=service.salesSummary(chart("TODAY",null,null,null,null)); assertEquals(0L,first.context().salesRunId());
|
||||
var first=service.salesSummary(chart("TODAY",null,null,null,null),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC); assertEquals(0L,first.context().salesRunId());
|
||||
when(mapper.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L)));
|
||||
var second=service.salesSummary(pinned("TODAY",0,0,null,null,NOW,2));
|
||||
var second=service.salesSummary(pinned("TODAY",0,0,null,null,NOW,2),com.baiyue.lastleg.selfoperated.analytics.query.SalesSort.QUANTITY_DESC);
|
||||
assertEquals(0L,second.context().inventoryRunId()); assertEquals(0L,second.context().salesRunId()); verify(mapper,never()).publishedRun(anyLong());
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class); verify(mapper,times(2)).page(cap.capture());
|
||||
assertEquals(0,cap.getValue().salesRun()); assertEquals(0,cap.getValue().inventoryRun()); assertFalse(cap.getValue().salesKnown());
|
||||
@@ -280,11 +293,11 @@ class AnalyticsQueryServiceTest {
|
||||
var row=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertNull(row.inventoryCoverageDays());assertNull(row.availableQuantity());assertEquals("UNKNOWN",row.inventoryQuality());
|
||||
}
|
||||
@Test void noObservationEvidenceCannotBecomeThirtyDayDenominatorAndNoSalesIsNotInfinity(){
|
||||
@Test void unknownSalesStayUnknownAndConfirmedZeroNeedsNoObservationAge(){
|
||||
doReturn(List.of(inventory(true,"300",true,null,null))).when(mapper).inventoryPage(any(),any());
|
||||
var unknown=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertNull(unknown.earliestReliableEvidenceAt());assertFalse(unknown.observationEligible());assertEquals("OBSERVATION_INSUFFICIENT",unknown.status());
|
||||
doReturn(List.of(inventory(true,"300",true,time(D.minusDays(40)),"0"))).when(mapper).inventoryPage(any(),any());
|
||||
assertNull(unknown.earliestReliableEvidenceAt());assertFalse(unknown.observationEligible());assertEquals("SALES_UNKNOWN",unknown.status());
|
||||
doReturn(List.of(inventory(true,"300",true,null,"0"))).when(mapper).inventoryPage(any(),any());
|
||||
var zero=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertEquals("NO_OUTBOUND_IN_WINDOW",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum());
|
||||
}
|
||||
@@ -363,6 +376,22 @@ class AnalyticsQueryServiceTest {
|
||||
// Normal inventory diagnostics still return the same fixed but stale version.
|
||||
assertEquals("STALE",service.inventoryCoverage(pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1),options()).context().inventoryStatus());
|
||||
}
|
||||
@Test void completeInventorySalesCalculateWithoutObservationAgeAndRetainTheWarning(){
|
||||
var base=inventory(true,"300",true,time(D.minusDays(10)),"92");
|
||||
var partial=new Inventory(base.customerCode(),base.skuCode(),base.productName(),true,base.evidenceAtUtc(),base.physicalQuantity(),base.availableQuantity(),
|
||||
base.frozenQuantity(),base.transitQuantity(),base.defectivePhysicalQuantity(),base.observedAtUtc(),n("92"),n("92"),base.orderCount(),base.lastOutbound(),false,n("92"),n("97.8"),0,0,false);
|
||||
doReturn(List.of(partial)).when(mapper).inventoryPage(any(),any());
|
||||
var row=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertEquals(n("92"),row.observedWindowOutboundQuantity());assertEquals(n("92"),row.windowOutboundQuantity());
|
||||
assertEquals(n("3.066666666667"),row.averageDailyOutboundQuantity());assertEquals(n("97.8"),row.inventoryCoverageDays());
|
||||
assertEquals("VALID",row.status());assertEquals("VALID",row.quantityStatuses().get("windowOutboundQuantity"));
|
||||
assertFalse(row.observationEligible());assertTrue(row.reasonCodes().contains("OBSERVATION_INSUFFICIENT"));assertFalse(row.attentionReasons().contains("UNASSESSABLE"));
|
||||
var json=new ObjectMapper().findAndRegisterModules().valueToTree(row);
|
||||
assertEquals(92,json.get("observedWindowOutboundQuantity").intValue());
|
||||
// Unknown evidence must stay unknown; it is never converted into a collected zero.
|
||||
doReturn(List.of(inventory(true,"300",true,null,null))).when(mapper).inventoryPage(any(),any());
|
||||
assertNull(service.inventoryCoverage(metrics(null),options()).records().get(0).observedWindowOutboundQuantity());
|
||||
}
|
||||
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);}
|
||||
private static Inventory inventory(boolean unit,String available,boolean fresh,LocalDateTime evidence,String sales){
|
||||
BigDecimal stock=fresh&&available!=null?n(available):null,out=sales==null?null:n(sales);
|
||||
|
||||
+10
@@ -23,6 +23,15 @@ class AnalyticsReadMapperTest {
|
||||
assertFalse(sql.contains("ll_selfop_outbound_order")); assertFalse(sql.contains("ll_selfop_outbound_item")); assertFalse(sql.contains("ll_selfop_sync_stage"));
|
||||
assertFalse(sql.contains("${")); assertFalse(sql.contains("Exact-SKU")); assertFalse(sql.contains("OWNER"));
|
||||
}
|
||||
@Test void quantityOrderingIsAppliedBeforePaginationAndKeepsUnknownLast(){
|
||||
for(var sort:SalesSort.values()){
|
||||
var sql=bound(config(),"salesPage",Map.of("p",request("SUMMARY"),"salesSort",sort)).getSql();
|
||||
String direction=sort==SalesSort.QUANTITY_ASC?"ASC":"DESC";
|
||||
assertTrue(sql.contains("sort_observed_quantity IS NULL,sort_observed_quantity "+direction));
|
||||
assertTrue(sql.contains("ORDER BY observed_quantity IS NULL,observed_quantity "+direction));
|
||||
assertTrue(sql.indexOf("sort_observed_quantity "+direction)<sql.indexOf("LIMIT"));
|
||||
}
|
||||
}
|
||||
@Test void allMapperStatementsAreSelectAndProvidersBindTheNamedRecord(){
|
||||
var c=config(); assertNotNull(AnalyticsReadMapper.class.getAnnotation(Mapper.class));
|
||||
for(String method:List.of("scopes","publishedRun","page","count","rankingStats","profile","daily","applyBarriers")){
|
||||
@@ -84,6 +93,7 @@ class AnalyticsReadMapperTest {
|
||||
assertTrue(sql.contains("st.snapshot_run_id=?")); assertTrue(sql.contains("st.source_account=?")); assertTrue(sql.contains("st.warehouse_code=?"));
|
||||
assertTrue(sql.contains("AND st.product_quality=0"));
|
||||
if("HOT".equals(kind))assertTrue(sql.contains("business_date>=LEAST(CAST(? AS DATE),TIMESTAMPADD(DAY,-30,CAST(? AS DATE))) AND business_date<GREATEST(CAST(? AS DATE),TIMESTAMPADD(DAY,1,CAST(? AS DATE)))"));
|
||||
else if("SLOW".equals(kind))assertTrue(sql.contains("business_date>=LEAST(CAST(? AS DATE),TIMESTAMPADD(DAY,-30,CAST(? AS DATE))) AND business_date<TIMESTAMPADD(DAY,1,CAST(? AS DATE))"));
|
||||
else assertTrue(sql.contains("business_date>=TIMESTAMPADD(DAY,-30,CAST(? AS DATE)) AND business_date<TIMESTAMPADD(DAY,1,CAST(? AS DATE))"));
|
||||
assertTrue(sql.contains("GROUP BY customer_code,sku_code"));
|
||||
assertTrue(sql.indexOf("GROUP BY customer_code,sku_code")<sql.indexOf("LEFT JOIN ll_selfop_inventory_snapshot"));
|
||||
|
||||
+38
-5
@@ -280,13 +280,14 @@ public class AnalyticsSqlSemanticTest {
|
||||
Map<String,Object> totals=totals(p,options);assertEquals(106L,((Number)totals.get("sku_count")).longValue());
|
||||
assertEquals(106L,((Number)totals.get("above_threshold_count")).longValue());assertDecimal("31905.000001",(BigDecimal)totals.get("physical_quantity_known"));
|
||||
}
|
||||
@Test void ownerSalesCompleteDoesNotRequireEachSkuObservationAndRiskUnknownIsRetained() throws Exception {
|
||||
@Test void completeWindowSalesAndCoverageDoNotRequireSkuObservationAge() throws Exception {
|
||||
profile(22,"C","NEW","INVENTORY_OBSERVATION",utc(D.minusDays(1),0),"PCS");stockExact("NEW",0,"20","20","0","0","VALID",null);
|
||||
daily(11,"W","C","NEW",D.minusDays(1),"5");
|
||||
var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.noOutbound());
|
||||
var row=inventory(null,30,null,0,20).get(0);assertDecimal("5",row.ownerOutbound());assertDecimal("5",row.outboundQuantity30d());assertEquals(0,row.noOutbound());
|
||||
assertFalse(row.observationEligible());assertDecimal("120",row.coverageDays());assertFalse(row.unassessable());
|
||||
Map<String,Object> totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,1),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null,null,null));
|
||||
assertDecimal("5",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
|
||||
assertEquals(1L,((Number)totals.get("no_outbound_unknown")).longValue());assertDecimal("20",(BigDecimal)totals.get("no_outbound_unknown_physical"));
|
||||
assertEquals(0L,((Number)totals.get("no_outbound_unknown")).longValue());
|
||||
}
|
||||
@Test void ownersAreCaseSensitiveDistinctAndPrefixWildcardsAreLiteral() throws Exception {
|
||||
for(String owner:List.of("A%","A_test","ABC","abc")){
|
||||
@@ -327,6 +328,19 @@ public class AnalyticsSqlSemanticTest {
|
||||
assertEquals(0,row.partialDayCount());assertEquals(366,row.unknownDayCount());
|
||||
}
|
||||
}
|
||||
@Test void slowCustomRangeCountsOnlySelectedDatesIncludingHistoryBeyondThirtyDays() throws Exception {
|
||||
profile(11,"C","CUSTOM","VALID_OUTBOUND",utc(D.minusDays(60),0),"PCS");
|
||||
profile(22,"C","CUSTOM","INVENTORY_OBSERVATION",utc(D.minusDays(60),0),"PCS");
|
||||
stock("C","CUSTOM",0,"100");
|
||||
daily(11,"W","C","CUSTOM",D.minusDays(40),"30");
|
||||
daily(11,"W","C","CUSTOM",D.minusDays(10),"20");
|
||||
daily(11,"W","C","CUSTOM",D.minusDays(1),"10");
|
||||
var shortRows=page(request("SLOW",D.minusDays(3),D,null,null,utc(D,0),0,20));
|
||||
var longRows=page(request("SLOW",D.minusDays(45),D,null,null,utc(D,0),0,20));
|
||||
assertEquals(1,shortRows.size());assertEquals(1,longRows.size());
|
||||
assertEquals(0,new BigDecimal("10").compareTo(shortRows.get(0).windowQuantity()));
|
||||
assertEquals(0,new BigDecimal("60").compareTo(longRows.get(0).windowQuantity()));
|
||||
}
|
||||
@Test void rankingStatisticsPreserveFullEligibilityTopCapExclusionsAndSharedSkuOwners() throws Exception {
|
||||
for(int i=0;i<110;i++){
|
||||
String sku=String.format("SKU-%03d",i);profile(11,"C",sku,"VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
|
||||
@@ -386,7 +400,7 @@ public class AnalyticsSqlSemanticTest {
|
||||
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());
|
||||
assertDecimal("0",second.get(1).outboundQuantity30d());assertFalse(second.get(1).unassessable());assertFalse(second.get(1).observationEligible());
|
||||
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());
|
||||
@@ -399,6 +413,24 @@ public class AnalyticsSqlSemanticTest {
|
||||
for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_ASC"))
|
||||
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null,null,null))).contains("stock_paged"));
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings={"AVAILABLE","PHYSICAL","FROZEN","DEFECTIVE","TRANSIT","OUTBOUND","AVERAGE_DAILY","COVERAGE_DAYS"})
|
||||
void allInventoryNumericSortsKeepUnknownLastAndSortBeforePagination(String metric) throws Exception {
|
||||
for(String sku:List.of("LOW","LOW-TIE","HIGH","UNKNOWN")) {
|
||||
String unit=sku.equals("UNKNOWN")?"BOX":"PCS";
|
||||
profile(11,"C",sku,"VALID_OUTBOUND",utc(D.minusDays(40),0),unit);
|
||||
profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays(40),0),unit);
|
||||
boolean high=sku.equals("HIGH");
|
||||
stockExact(sku,0,high?"1000":"100",high?"1000":"100",high?"9":"1",high?"90":"10","VALID",null);
|
||||
stockExact(sku,1,high?"90":"10","0","0","0","VALID",null);
|
||||
daily(11,"W","C",sku,D.minusDays(1),high?"5":"1");
|
||||
}
|
||||
for(String direction:List.of("ASC","DESC")) {
|
||||
var expected=direction.equals("ASC")?List.of("LOW","LOW-TIE","HIGH","UNKNOWN"):List.of("HIGH","LOW","LOW-TIE","UNKNOWN");
|
||||
assertEquals(expected,inventory(null,null,metric+"_"+direction,0,20).stream().map(AnalyticsReadRows.Inventory::skuCode).toList());
|
||||
assertEquals(expected.get(1),inventory(null,null,metric+"_"+direction,1,1).get(0).skuCode());
|
||||
}
|
||||
}
|
||||
@Test void stockFirstLastOutboundSkipsInvalidLatestRowsAndKeepsAllScopeBoundaries() throws Exception {
|
||||
for(String sku:List.of("HISTORY","NONE","ANCHOR")){
|
||||
profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays(90),0),"PCS");stock("C",sku,0,"10");
|
||||
@@ -469,7 +501,8 @@ public class AnalyticsSqlSemanticTest {
|
||||
assertEquals(1,second.size());assertNotEquals("TEN",second.get(0).skuCode());
|
||||
assertTrue(inventoryRows(request("INVENTORY",D.minusDays(10),D,"C",null,utc(D,8),3,1),o).isEmpty());
|
||||
var zero=rows.stream().filter(r->r.skuCode().equals("ZERO")).findFirst().orElseThrow();assertDecimal("0",zero.outboundQuantity30d());assertNull(zero.coverageDays());
|
||||
assertNull(rows.stream().filter(r->r.skuCode().equals("YOUNG")).findFirst().orElseThrow().outboundQuantity30d());
|
||||
var young=rows.stream().filter(r->r.skuCode().equals("YOUNG")).findFirst().orElseThrow();
|
||||
assertDecimal("0",young.outboundQuantity30d());assertFalse(young.observationEligible());assertEquals(1,young.noOutbound());assertNull(young.coverageDays());
|
||||
var filtered=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","COVERAGE_DAYS_DESC",30,null,null,null,null,10,D.minusDays(1));
|
||||
assertEquals(1L,((Number)literalQuery("inventoryCount",p,filtered).get(0)).longValue());
|
||||
assertEquals(List.of("TEN"),inventoryRows(p,filtered).stream().map(AnalyticsReadRows.Inventory::skuCode).toList());
|
||||
|
||||
+17
-3
@@ -50,13 +50,27 @@ class DashboardPolicyTest {
|
||||
assertEquals("STALE",c.salesStatus());assertTrue(c.reasonCodes().contains("SALES_STALE"));
|
||||
}
|
||||
}
|
||||
@Test void historicalRecencyFixDoesNotRelaxInvalidQuantitySourceOrRebuildGuards(){
|
||||
@Test void historicalCoverageStillRequiresKnownSourceAndValidQuantity(){
|
||||
LocalDate day=DAY.minusDays(3);var rows=List.of(new DashboardReadMapper.Day(day,new BigDecimal("2251"),0L));
|
||||
assertEquals("UNKNOWN",DashboardSalesPolicy.points(publishedDayContext("STALE",false),false,day,day.plusDays(1),rows,java.util.List.of()).get(0).dataStatus());
|
||||
var invalid=DashboardSalesPolicy.points(publishedDayContext("STALE",false),true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,new BigDecimal("2"),1L)),java.util.List.of()).get(0);
|
||||
assertEquals("PARTIAL",invalid.dataStatus());assertNull(invalid.outboundQuantity());assertTrue(invalid.reasonCodes().contains("SALES_QUANTITY_OR_UNIT_INVALID"));
|
||||
var rebuilding=DashboardSalesPolicy.points(publishedDayContext("REBUILD_REQUIRED",true),true,day,day.plusDays(1),rows,java.util.List.of()).get(0);
|
||||
assertEquals("PARTIAL",rebuilding.dataStatus());assertNull(rebuilding.outboundQuantity());
|
||||
}
|
||||
@Test void pendingRebuildPreservesFrozenHistoricalCoverageWithoutFillingGapsOrToday(){
|
||||
LocalDate day=DAY.minusDays(3);var c=publishedDayContext("REBUILD_REQUIRED",true);
|
||||
for(BigDecimal amount:List.of(BigDecimal.ZERO,new BigDecimal("2251"))){
|
||||
var rows=amount.signum()==0?List.<DashboardReadMapper.Day>of():List.of(new DashboardReadMapper.Day(day,amount,0L));
|
||||
var points=DashboardSalesPolicy.points(c,true,day,DAY.plusDays(1),rows,List.of());
|
||||
assertEquals("COMPLETE",points.get(0).dataStatus());assertEquals(amount,points.get(0).outboundQuantity());
|
||||
assertTrue(points.get(0).reasonCodes().contains("SALES_REBUILD_REQUIRED"));
|
||||
assertFalse(points.get(0).reasonCodes().contains("SALES_DAY_INCOMPLETE"));
|
||||
assertEquals(List.of("COMPLETE","UNKNOWN","UNKNOWN","UNKNOWN"),points.stream().map(p->p.dataStatus()).toList());
|
||||
assertTrue(points.get(3).reasonCodes().contains("TODAY_INCOMPLETE"));
|
||||
assertNull(SalesBucketPolicy.aggregate(points).outboundQuantity());
|
||||
}
|
||||
var invalid=DashboardSalesPolicy.points(c,true,day,day.plusDays(1),List.of(new DashboardReadMapper.Day(day,BigDecimal.ONE,1L)),List.of()).get(0);
|
||||
assertEquals("PARTIAL",invalid.dataStatus());assertNull(invalid.outboundQuantity());
|
||||
assertEquals("UNKNOWN",DashboardSalesPolicy.points(c,false,day,day.plusDays(1),List.of(),List.of()).get(0).dataStatus());
|
||||
}
|
||||
private static AnalyticsResponses.Context publishedDayContext(String status,boolean rebuild){
|
||||
LocalDate day=DAY.minusDays(3);var zone=ZoneId.of("Asia/Shanghai");
|
||||
|
||||
+16
@@ -184,6 +184,22 @@ class DashboardSqlSemanticTest {
|
||||
assertEquals(6,count(stats,"no_outbound_eligible"));
|
||||
assertTrue(ranks().stream().anyMatch(row->row.rankCode().equals("NO_OUTBOUND")&&row.skuCode().equals("FROZEN-0")));
|
||||
}
|
||||
@Test void newSkuWindowMetricsMatchDashboardAndDrilldownWithoutObservationAge()throws Exception{
|
||||
fixture("NEW-SOLD","30","30","15");fixture("NEW-UNSOLD","40","40",null);
|
||||
try(var statement=connection.createStatement()) {
|
||||
statement.execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=NULL,evidence_type='NONE'");
|
||||
}
|
||||
var p=windowRequest(D.minusDays(15),D,true,"DASHBOARD");
|
||||
var report=query(DashboardReadMapper.class,"report",Map.of("p",p,"o",options()));
|
||||
Map<String,Object> values=new HashMap<>();((Map<?,?>)report.get(0)).forEach((k,v)->values.put(k.toString().toLowerCase(Locale.ROOT),v));
|
||||
assertEquals(1,count(values,"long_eligible"));assertEquals(1,count(values,"no_outbound_eligible"));assertEquals(0,count(values,"bucket_unknown"));
|
||||
var rows=query(AnalyticsReadMapper.class,"inventoryPage",Map.of("p",p,"o",options())).stream().map(AnalyticsReadRows.Inventory.class::cast).toList();
|
||||
assertEquals(2,rows.size());assertTrue(rows.stream().noneMatch(AnalyticsReadRows.Inventory::observationEligible));
|
||||
var sold=rows.stream().filter(row->row.skuCode().equals("NEW-SOLD")).findFirst().orElseThrow();
|
||||
assertEquals(0,new BigDecimal("15").compareTo(sold.outboundQuantity30d()));assertEquals(0,new BigDecimal("30").compareTo(sold.coverageDays()));
|
||||
var zero=rows.stream().filter(row->row.skuCode().equals("NEW-UNSOLD")).findFirst().orElseThrow();
|
||||
assertEquals(0,zero.outboundQuantity30d().signum());assertNull(zero.coverageDays());assertEquals(1,zero.noOutbound());
|
||||
}
|
||||
@Test void exactNinetyBoundaryAndUnknownInventoryRemainDistinct()throws Exception{
|
||||
fixture("NINETY","90","90","30");fixture("NINETY04","90.04","90.04","30");fixture("INFINITE","999","999",null);
|
||||
var s=stats();assertEquals(1,count(s,"bucket_days_60_90"));assertEquals(1,count(s,"bucket_days_gt_90"));assertEquals(1,count(s,"bucket_no_outbound_in_window"));
|
||||
|
||||
+10
@@ -46,6 +46,16 @@ class InventoryTurnoverContextTest {
|
||||
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 fullYearContextRemainsSmallAndReconstructsEveryMissingDate() {
|
||||
var q=new InventoryTurnoverRequest("demo","W",END,366,"SKU",null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,null);
|
||||
var first=contexts.resolve(q).response();
|
||||
assertEquals(366,first.dailySamples().size());assertEquals(365,first.missingSampleDates().size());
|
||||
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());
|
||||
assertEquals(first,at(NOW.plusSeconds(60)).resolve(next).response());
|
||||
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of());
|
||||
assertThrows(BizException.class,()->contexts.resolve(next));
|
||||
}
|
||||
@Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception {
|
||||
String token=contexts.resolve(request(null)).response().snapshotContext();
|
||||
var evidence=json.readTree(Base64.getUrlDecoder().decode(token));
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@ class InventoryTurnoverRequestTest {
|
||||
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),
|
||||
for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",367),
|
||||
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"),
|
||||
|
||||
+24
-3
@@ -31,6 +31,27 @@ class InventoryTurnoverSqlTest {
|
||||
for(int i=0;i<30;i++){LocalDate date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),i>=30-covered?1:0,i<samples?100L+i:null));}
|
||||
return new InventoryTurnoverSql.Input(q,11,22,fresh,true,List.copyOf(days));
|
||||
}
|
||||
@Test void selectedCustomWindowsUseEveryDayIncludingBeyondThirtyAndKeepSampleGaps() throws Exception {
|
||||
seed("A","SKU",1);
|
||||
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)) {
|
||||
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
|
||||
var dates=new ArrayList<InventoryTurnoverSql.Day>();
|
||||
int horizon=Math.max(30,n);
|
||||
for(int i=0;i<horizon;i++) {
|
||||
var date=END.minusDays(horizon-1-i);
|
||||
dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,date.equals(END)?null:100L));
|
||||
}
|
||||
var input=new InventoryTurnoverSql.Input(q,11,22,true,true,dates);
|
||||
var row=query("page",input).get(0);
|
||||
decimal(Integer.toString(Math.min(30,n)*10),row.get("w"+n+"_outbound"));
|
||||
decimal(Integer.toString(n-1),row.get("sample_count"));
|
||||
assertEquals(END.toString(),row.get("missing_dates"));
|
||||
assertNull(row.get("turnover"));
|
||||
var chart=query("chartWindows",input).get(0);
|
||||
decimal(Integer.toString(Math.min(30,n)*10),chart.get("w"+n+"_outbound"));
|
||||
}
|
||||
}
|
||||
@Test void threeWindowsAndDailyQualitiesAreNotMultiplied() throws Exception {
|
||||
seed("A","SKU",30);
|
||||
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
|
||||
@@ -110,7 +131,7 @@ class InventoryTurnoverSqlTest {
|
||||
execute("INSERT INTO ll_selfop_sku_daily_sales VALUES (99,'demo','W','A','SKU',DATE '"+END+"',9999,'PCS')");
|
||||
var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20);
|
||||
var chart=query("chartWindows",input(q,18,0,true)).get(0);
|
||||
var windows=InventoryTurnoverPolicy.windows(chart,END.plusDays(1));
|
||||
var windows=InventoryTurnoverPolicy.windows(chart,END.plusDays(1),List.of(7,15,30));
|
||||
for(int i=0;i<2;i++) {
|
||||
var window=windows.get(i);
|
||||
assertEquals("COMPLETE",window.dataStatus());assertEquals(window.days(),window.completeDayCount());
|
||||
@@ -190,7 +211,7 @@ class InventoryTurnoverSqlTest {
|
||||
var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null);
|
||||
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates));
|
||||
var row=query("page",p).get(0);
|
||||
decimal(onlyLast?"536870912":"536870911",row.get("sample_mask"));
|
||||
assertNotNull(row.get("sample_mask"));
|
||||
decimal(String.valueOf(onlyLast?1:n-1),row.get("sample_count"));
|
||||
assertEquals(String.join(",",missing),row.get("missing_dates"));
|
||||
assertNull(row.get("average_physical"));assertNull(row.get("turnover"));
|
||||
@@ -514,7 +535,7 @@ class InventoryTurnoverSqlTest {
|
||||
// MySQL SIGNED is a signed 64-bit integer; H2's alias is only 32-bit.
|
||||
sql=sql.replace(" AS SIGNED)"," AS BIGINT)");
|
||||
// MySQL's unbounded CHAR cast preserves the date; H2 defaults to CHAR(1).
|
||||
sql=sql.replace("CAST(business_date AS CHAR)","CAST(business_date AS VARCHAR(10))");
|
||||
sql=sql.replace("CAST(business_date AS CHAR)","CAST(business_date AS VARCHAR(10))").replace("CAST(d.business_date AS CHAR)","CAST(d.business_date AS VARCHAR(10))");
|
||||
// H2 inlines repeatedly referenced CTEs exponentially. Materialize each unchanged
|
||||
// production relation in this local fixture; MySQL execution plans remain unverified.
|
||||
List<String> relations=new ArrayList<>();int cursor=5;
|
||||
|
||||
+10
-10
@@ -21,7 +21,7 @@ class InventoryWindowPolicyTest {
|
||||
assertEquals(DAY.minusDays(30),InventoryWindowPolicy.range(options(null,null),DAY).startDate());
|
||||
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(10,DAY),DAY));
|
||||
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(10,DAY.plusDays(1)),DAY));
|
||||
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(7,null),DAY));
|
||||
assertThrows(BizException.class,()->InventoryWindowPolicy.range(options(367,null),DAY));
|
||||
}
|
||||
@Test void selectedWindowPredicatesWorkForTenDayAndHistoricalWindows() {
|
||||
for(var o:new AnalyticsInventoryOptions[]{
|
||||
@@ -33,18 +33,18 @@ class InventoryWindowPolicyTest {
|
||||
assertThrows(BizException.class,()->options(10,null).validate("OVERVIEW",null));
|
||||
}
|
||||
@Test void tenDayFormulaKeepsMissingAndZeroSalesDistinctAndAvoidsIntermediateRounding() {
|
||||
var valid=InventoryWindowPolicy.calculate("VALID",new BigDecimal("120"),new BigDecimal("30"),true,true,true,true,10);
|
||||
var valid=InventoryWindowPolicy.calculate("VALID",new BigDecimal("120"),new BigDecimal("30"),true,true,true,10);
|
||||
assertEquals(0,new BigDecimal("3").compareTo(valid.average()));assertEquals(new BigDecimal("40.0"),valid.days());
|
||||
var partial=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ONE,true,false,true,true,10);
|
||||
var partial=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ONE,true,false,true,10);
|
||||
assertNull(partial.average());assertNull(partial.days());assertEquals("COVERAGE_INSUFFICIENT",partial.status());
|
||||
var zero=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ZERO,true,true,true,true,10);
|
||||
var zero=InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.ZERO,true,true,true,10);
|
||||
assertEquals(0,zero.average().signum());assertNull(zero.days());assertEquals("NO_OUTBOUND_IN_WINDOW",zero.status());
|
||||
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,null,true,true,true,true,10).days());
|
||||
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,true,true,false,true,10).days());
|
||||
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,true,true,true,false,10).days());
|
||||
assertNull(InventoryWindowPolicy.calculate("STALE",BigDecimal.ZERO,BigDecimal.TEN,true,true,true,true,10).days());
|
||||
assertEquals(new BigDecimal("0.0"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ZERO,null,false,false,false,false,10).days());
|
||||
assertEquals(new BigDecimal("3.3"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ONE,new BigDecimal("3"),true,true,true,true,10).days());
|
||||
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,null,true,true,true,10).days());
|
||||
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,false,true,true,10).days());
|
||||
assertNull(InventoryWindowPolicy.calculate("VALID",BigDecimal.TEN,BigDecimal.TEN,true,true,false,10).days());
|
||||
assertNull(InventoryWindowPolicy.calculate("STALE",BigDecimal.ZERO,BigDecimal.TEN,true,true,true,10).days());
|
||||
assertEquals(new BigDecimal("0.0"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ZERO,null,false,false,false,10).days());
|
||||
assertEquals(new BigDecimal("3.3"),InventoryWindowPolicy.calculate("VALID",BigDecimal.ONE,new BigDecimal("3"),true,true,true,10).days());
|
||||
}
|
||||
@Test void inventoryResponseHasOneNeutralWindowQuantityWithoutThirtyDayAliases() {
|
||||
var names=java.util.Arrays.stream(AnalyticsResponses.InventoryMetric.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList();
|
||||
|
||||
Reference in New Issue
Block a user