feat(selfop): support complete inventory sales windows
This commit is contained in:
+2
-2
@@ -52,8 +52,8 @@ public class PlatformAnalyticsController {
|
||||
requirePlatform(user);return ApiResponse.success(service.slowSkus(query));
|
||||
}
|
||||
@GetMapping("/inventory-coverage")
|
||||
@Operation(summary="正品库存、固定30天日均和可售天数")
|
||||
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.Metric>> inventoryCoverage(
|
||||
@Operation(summary="正品库存、10或30完整日日均和预计可售天数")
|
||||
public ApiResponse<AnalyticsResponses.Page<AnalyticsResponses.InventoryMetric>> inventoryCoverage(
|
||||
@AuthenticationPrincipal AuthenticatedUser user,@ModelAttribute AnalyticsQuery query,@ModelAttribute AnalyticsInventoryOptions options){
|
||||
requirePlatform(user);return ApiResponse.success(service.inventoryCoverage(query,options));
|
||||
}
|
||||
|
||||
+5
-1
@@ -5,12 +5,16 @@ import java.util.Set;
|
||||
|
||||
/** Endpoint-specific inventory presentation, separate from immutable version/date identity. */
|
||||
public record AnalyticsInventoryOptions(String keyword,String attention,String sortBy,Integer coverageDaysThreshold,
|
||||
String coverageBucket,String bucketRuleVersion,String coverageSelection,String drilldownFrom) {
|
||||
String coverageBucket,String bucketRuleVersion,String coverageSelection,String drilldownFrom,
|
||||
Integer salesWindowDays,@org.springframework.format.annotation.DateTimeFormat(iso=org.springframework.format.annotation.DateTimeFormat.ISO.DATE) java.time.LocalDate salesWindowEndDate) {
|
||||
public int salesDays(){return salesWindowDays==null?30:salesWindowDays;}
|
||||
public String attentionValue(){return attention==null?"ALL":attention;}
|
||||
public String sortValue(){return sortBy==null?"AVAILABLE_DESC":sortBy;}
|
||||
public String keywordPattern(){return keyword==null?null:keyword.replace("!","!!").replace("%","!%").replace("_","!_")+"%";}
|
||||
public void validate(String endpoint,AnalyticsQuery q){
|
||||
CoverageDistributionPolicy.validate(this);
|
||||
if(salesDays()!=10&&salesDays()!=30)throw new BizException("库存销量窗口仅支持10或30天");
|
||||
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_30D","COVERAGE_GT_THRESHOLD","FROZEN_STOCK","DEFECTIVE_STOCK","UNASSESSABLE").contains(attentionValue()))throw new BizException("库存关注条件无效");
|
||||
|
||||
+5
-3
@@ -20,6 +20,8 @@ public final class AnalyticsInventorySql {
|
||||
static String dashboardCte(AnalyticsSqlRequest p){return cte(p,false,true);}
|
||||
private static String cte(AnalyticsSqlRequest p,boolean stockFirst){return cte(p,stockFirst,false);}
|
||||
private static String cte(AnalyticsSqlRequest p,boolean stockFirst,boolean dashboard){
|
||||
String days="INVENTORY".equals(p.kind())?Long.toString(java.time.temporal.ChronoUnit.DAYS.between(p.from(),p.until())):"30";
|
||||
String complete="INVENTORY".equals(p.kind())?"#{p.completeWindow}":"#{p.complete30}";
|
||||
return "WITH candidates AS ("+keys(p)+"), profiles AS (SELECT c.customer_code,c.sku_code,COALESCE(i.product_name,s.product_name) product_name,"
|
||||
+"CASE WHEN i.quantity_unit='PCS' THEN 1 ELSE 0 END stock_unit_valid,"
|
||||
+"CASE WHEN s.sku_code IS NULL OR s.quantity_unit='PCS' THEN 1 ELSE 0 END sales_unit_valid,"
|
||||
@@ -38,16 +40,16 @@ public final class AnalyticsInventorySql {
|
||||
+" values_by_sku AS (SELECT c.customer_code,c.sku_code,c.product_name,c.unit_valid,"
|
||||
+"CASE WHEN c.sales_evidence IS NULL THEN c.stock_evidence WHEN c.stock_evidence IS NULL THEN c.sales_evidence ELSE LEAST(c.sales_evidence,c.stock_evidence) END evidence_at_utc,"
|
||||
+stockValue("physical_quantity")+","+stockValue("available_quantity")+","+stockValue("frozen_quantity")+","+stockValue("transit_quantity")+","+stockValue("defective_physical_quantity")+",st.observed_at_utc,"
|
||||
+"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.complete30}=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound,"
|
||||
+"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND "+complete+"=1 THEN COALESCE(s.observed_quantity,0) END owner_outbound,"
|
||||
+"CASE WHEN c.sales_unit_valid=1 AND COALESCE(s.unit_valid,1)=1 AND #{p.salesKnown}=1 THEN s.observed_quantity END observed_outbound,s.order_count "
|
||||
+"FROM profiles c "+(stockFirst?"JOIN stock_paged pk ON pk.customer_code=c.customer_code AND pk.sku_code=c.sku_code ":"")
|
||||
+"LEFT JOIN stock st ON st.customer_code=c.customer_code AND st.sku_code=c.sku_code LEFT JOIN sales s ON s.customer_code=c.customer_code AND s.sku_code=c.sku_code),"
|
||||
+" qualified AS (SELECT v.*,CASE WHEN evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN 1 ELSE 0 END observation_eligible,"
|
||||
+"CASE WHEN unit_valid=1 AND evidence_at_utc<=CONVERT_TZ(CAST(#{p.from} AS DATETIME),'+08:00','+00:00') THEN owner_outbound END outbound_quantity30d FROM values_by_sku v),"
|
||||
+" calculated AS (SELECT q.*,CASE WHEN available_quantity=0 THEN 0 WHEN available_quantity>0 AND outbound_quantity30d>0 THEN available_quantity*30/outbound_quantity30d END coverage_days,"
|
||||
+" 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 "
|
||||
+"WHEN physical_quantity>0 AND available_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d>0 AND available_quantity*30>outbound_quantity30d*#{o.coverageDaysThreshold} THEN 1 ELSE 0 END END above_threshold "
|
||||
+"WHEN physical_quantity>0 AND available_quantity>0 AND outbound_quantity30d IS NOT NULL THEN CASE WHEN outbound_quantity30d>0 AND available_quantity*"+days+">outbound_quantity30d*#{o.coverageDaysThreshold} THEN 1 ELSE 0 END END above_threshold "
|
||||
+"FROM qualified q), result AS (SELECT c.*,CASE WHEN physical_quantity IS NULL OR available_quantity IS NULL OR outbound_quantity30d IS NULL THEN 1 ELSE 0 END unassessable FROM calculated c) ";
|
||||
}
|
||||
private static String salesCte(AnalyticsSqlRequest p,boolean stockFirst,boolean unused){String d=stockFirst?"d.":"";
|
||||
|
||||
+17
-17
@@ -29,13 +29,13 @@ public class AnalyticsQueryService {
|
||||
LocalDate fullFrom,LocalDate fullUntil,SalesRangePolicy.Range range,AnalyticsResponses.Context context){}
|
||||
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.SalesSummary> salesSummary(AnalyticsQuery q){
|
||||
Snapshot s=context(q,true,false,false); AnalyticsSqlRequest p=request(s,"SUMMARY");
|
||||
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();
|
||||
return page(s,records,count,Map.of());
|
||||
}
|
||||
public AnalyticsResponses.Trend dailySales(AnalyticsQuery q){
|
||||
Snapshot s=context(q,true,true,false);
|
||||
Snapshot s=context(q,true,true,false,null);
|
||||
Profile sp=profile(s,s.sales),ip=profile(s,s.inventory);
|
||||
if(sp==null&&ip==null)throw new BizException("所选固定版本中无该货主和SKU记录");
|
||||
String unit=sp!=null?sp.quantityUnit():ip==null?null:ip.quantityUnit();
|
||||
@@ -66,18 +66,18 @@ public class AnalyticsQueryService {
|
||||
}
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.Metric> hotSkus(AnalyticsQuery q){return metrics(q,"HOT",true);}
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.Metric> slowSkus(AnalyticsQuery q){return metrics(q,"SLOW",false);}
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.Metric> inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){
|
||||
o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false);var p=request(s,"INVENTORY");
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.InventoryMetric> inventoryCoverage(AnalyticsQuery q,AnalyticsInventoryOptions o){
|
||||
o.validate("INVENTORY",q);Snapshot s=context(q,false,false,false,o);var p=request(s,"INVENTORY");
|
||||
CoverageDistributionPolicy.requireDrilldown(q,o,s.context(),inventoryFreshUntil(s),clock.instant());
|
||||
long total=mapper.inventoryCount(p,o);
|
||||
return page(s,q.offset()>=total?List.of():mapper.inventoryPage(p,o).stream().map(r->inventoryMetric(s,r)).toList(),total,Map.of());
|
||||
}
|
||||
public AnalyticsResponses.Page<AnalyticsResponses.OwnerOption> owners(AnalyticsQuery q,AnalyticsInventoryOptions o){
|
||||
o.validate("OWNERS",q);Snapshot s=context(q,false,false,false);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o);
|
||||
o.validate("OWNERS",q);Snapshot s=context(q,false,false,false,null);var p=request(s,"OWNERS");long total=mapper.ownerCount(p,o);
|
||||
return page(s,q.offset()>=total?List.of():mapper.owners(p,o).stream().map(code->new AnalyticsResponses.OwnerOption(code,null)).toList(),total,Map.of());
|
||||
}
|
||||
public AnalyticsResponses.OwnerOverview ownerOverview(AnalyticsQuery q,AnalyticsInventoryOptions o){
|
||||
o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o);
|
||||
o.validate("OVERVIEW",q);Snapshot s=context(q,false,false,false,null);var raw=mapper.ownerTotals(request(s,"OVERVIEW"),o);
|
||||
Map<String,Object> totals=new HashMap<>();if(raw!=null)raw.forEach((key,value)->totals.put(key.toLowerCase(Locale.ROOT),value));
|
||||
long count=number(totals,"sku_count");if(count==0)return null;
|
||||
var physical=quantity(totals,"physical_quantity");var outbound=quantity(totals,"owner_outbound");
|
||||
@@ -90,12 +90,12 @@ public class AnalyticsQueryService {
|
||||
quantity(totals,"frozen_quantity"),quantity(totals,"defective_physical_quantity"),quantity(totals,"transit_quantity"),outbound,average,
|
||||
risk(totals,"no_outbound",physical,false),risk(totals,"above_threshold",physical,o.coverageDaysThreshold()==null));
|
||||
}
|
||||
private AnalyticsResponses.Metric inventoryMetric(Snapshot s,Inventory r){
|
||||
private AnalyticsResponses.InventoryMetric inventoryMetric(Snapshot s,Inventory r){
|
||||
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=InventoryCoveragePolicy.calculate(quality,r.availableQuantity(),r.outboundQuantity30d(),s.trusted,
|
||||
complete(s,s.range.startDate(),s.range.endDateExclusive()),observed,Boolean.TRUE.equals(r.unitValid()));
|
||||
List<String> attention=new ArrayList<>();if(Integer.valueOf(1).equals(r.noOutbound()))attention.add("NO_OUTBOUND_30D");
|
||||
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());
|
||||
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");
|
||||
if(Boolean.TRUE.equals(r.unassessable()))attention.add("UNASSESSABLE");
|
||||
@@ -106,10 +106,10 @@ 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("outboundQuantity30d",r.outboundQuantity30d()==null?"UNKNOWN":"VALID");
|
||||
return new AnalyticsResponses.Metric(s.q.sourceAccount(),r.customerCode(),s.q.warehouseCode(),r.skuCode(),r.productName(),Boolean.TRUE.equals(r.unitValid())?"PCS":null,
|
||||
r.outboundQuantity30d(),r.orderCount(),null,r.physicalQuantity(),r.availableQuantity(),r.frozenQuantity(),r.transitQuantity(),utc(r.observedAtUtc()),
|
||||
divide30(r.outboundQuantity30d()),calculated.days(),quality,coverage,observed,utc(r.evidenceAtUtc()),calculated.status(),List.copyOf(new LinkedHashSet<>(reasons)),
|
||||
statuses.put("defectivePhysicalQuantity",r.defectivePhysicalQuantity()==null?unknown:"VALID");statuses.put("windowOutboundQuantity",r.outboundQuantity30d()==null?"UNKNOWN":"VALID");
|
||||
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));
|
||||
}
|
||||
private static boolean positive(BigDecimal n){return n!=null&&n.signum()>0;}
|
||||
@@ -132,7 +132,7 @@ public class AnalyticsQueryService {
|
||||
unknown,decimal(row,field+"_unknown_physical"),reasons);
|
||||
}
|
||||
private AnalyticsResponses.Page<AnalyticsResponses.Metric> metrics(AnalyticsQuery q,String kind,boolean hot){
|
||||
Snapshot s=context(q,false,false,hot); AnalyticsSqlRequest p=request(s,kind);
|
||||
Snapshot s=context(q,false,false,hot,null); AnalyticsSqlRequest p=request(s,kind);
|
||||
List<AnalyticsReadMapper.RankingStat> statistics=mapper.rankingStats(p);
|
||||
long total=statistics.isEmpty()?0:statistics.get(0).total();
|
||||
List<AnalyticsResponses.Metric> rows=q.offset()>=total?List.of():mapper.page(p).stream().map(r->metric(s,r,kind)).toList();
|
||||
@@ -173,7 +173,7 @@ public class AnalyticsQueryService {
|
||||
stockValid?r.frozenQuantity():null,stockValid?r.transitQuantity():null,utc(r.inventoryObservedAtUtc()),result.average(),result.days(),
|
||||
r.inventoryQuality(),coverage,observed,evidence,result.status(),List.copyOf(new LinkedHashSet<>(reasons)),null,null,r.sales30Quantity(),null,historyStart(s),List.of(),Map.of());
|
||||
}
|
||||
Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot){
|
||||
Snapshot context(AnalyticsQuery q,boolean chart,boolean exact,boolean hot,AnalyticsInventoryOptions inventoryOptions){
|
||||
q.validate(chart,exact,hot); Instant now=clock.instant();
|
||||
List<Scope> scopes=mapper.scopes(q.sourceAccount(),q.warehouseCode());
|
||||
Scope salesScope=scope(scopes,"OUTBOUND"),stockScope=scope(scopes,"INVENTORY");
|
||||
@@ -199,7 +199,7 @@ public class AnalyticsQueryService {
|
||||
LocalDate anchor=asOf.atZone(BUSINESS_ZONE).toLocalDate();
|
||||
SalesRangePolicy.Range range;
|
||||
try{
|
||||
range=chart?SalesRangePolicy.resolve(q.rangePreset()==null?"TODAY":q.rangePreset(),q.startDate(),q.endDate(),
|
||||
range=inventoryOptions!=null?InventoryWindowPolicy.range(inventoryOptions,anchor):chart?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());
|
||||
|
||||
+8
@@ -29,6 +29,14 @@ public final class AnalyticsResponses {
|
||||
String status,List<String> reasonCodes,String customerName,BigDecimal defectivePhysicalQuantity,
|
||||
BigDecimal outboundQuantity30d,LocalDate lastObservedOutboundDate,LocalDate observedHistoryStartDate,
|
||||
List<String> attentionReasons,Map<String,String> quantityStatuses){}
|
||||
public record InventoryMetric(String sourceAccount,String customerCode,String warehouseCode,String sku,String productName,
|
||||
String quantityUnit,Long skuOutboundOrderCount,
|
||||
BigDecimal physicalQuantity,BigDecimal availableQuantity,BigDecimal frozenQuantity,BigDecimal transitQuantity,
|
||||
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,
|
||||
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){}
|
||||
public record RiskShare(Long matchedSkuCount,BigDecimal matchedPhysicalQuantity,BigDecimal physicalQuantityRatio,
|
||||
|
||||
+2
-2
@@ -17,13 +17,13 @@ public class DashboardQueryService {
|
||||
public DashboardQueryService(AnalyticsQueryService contextService,DashboardReadMapper mapper){this.contextService=contextService;this.mapper=mapper;}
|
||||
public DashboardResponses.Dashboard dashboard(AnalyticsQuery input){
|
||||
AnalyticsQuery q=DashboardRequest.normalize(input);
|
||||
var snapshot=contextService.context(q,true,false,false);var c=snapshot.context();
|
||||
var snapshot=contextService.context(q,true,false,false,null);var c=snapshot.context();
|
||||
if(snapshot.sales()!=null&&!Boolean.TRUE.equals(snapshot.sales().warehouseDailyReady()))
|
||||
throw new com.baiyue.common.core.exception.BizException("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询");
|
||||
var base=contextService.request(snapshot,"DASHBOARD");LocalDate fixedFrom=c.anchorDate().minusDays(30),fixedUntil=c.anchorDate();
|
||||
var p=new AnalyticsSqlRequest(base.account(),base.warehouse(),null,null,base.salesRun(),base.inventoryRun(),fixedFrom,fixedUntil,base.anchor(),fixedFrom,
|
||||
base.completeFrom(),base.completeUntil(),base.coveredFrom(),base.coveredUntil(),base.salesKnown(),base.formalSales(),base.complete30(),base.complete30(),base.stockFresh(),0,10,"DASHBOARD");
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);
|
||||
var days=mapper.daily(p,c.rangeStartDate(),c.rangeEndDateExclusive());
|
||||
var report=mapper.report(p,o).stream().map(DashboardQueryService::normalize).toList();
|
||||
Map<String,Object> stats=report.get(0);var ranked=report.stream().filter(row->row.get("rank_code")!=null).map(DashboardQueryService::ranked).toList();
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.statistics.SalesRangePolicy;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneId;
|
||||
|
||||
/** Inventory-only complete natural-day window; dashboard and ranking policies remain fixed. */
|
||||
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天");
|
||||
LocalDate end=o.salesWindowEndDate()==null?anchor.minusDays(1):o.salesWindowEndDate();
|
||||
if(!end.isBefore(anchor))throw new BizException("库存销量截止日必须早于查询当日");
|
||||
boolean defaultThirty=days==30&&end.equals(anchor.minusDays(1));
|
||||
if(!defaultThirty&&(o.coverageBucket()!=null||o.coverageSelection()!=null||o.drilldownFrom()!=null
|
||||
||"NO_OUTBOUND_30D".equals(o.attentionValue())||"OUTBOUND_30D_ASC".equals(o.sortValue())))
|
||||
throw new BizException("30日关注、排序及总览下钻仅适用于截至昨日的30日窗口");
|
||||
LocalDate until=end.plusDays(1),from=until.minusDays(days);var zone=ZoneId.of("Asia/Shanghai");
|
||||
return new SalesRangePolicy.Range(anchor,from,until,days,from.atStartOfDay(zone).toInstant(),until.atStartOfDay(zone).toInstant());
|
||||
}
|
||||
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) {
|
||||
if(days!=10&&days!=30)throw new IllegalArgumentException("Inventory window must be 10 or 30 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
|
||||
?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");
|
||||
}
|
||||
}
|
||||
@@ -179,8 +179,8 @@ paths:
|
||||
'403': {description: 非平台身份或缺少独立只读权限}
|
||||
/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage:
|
||||
get:
|
||||
summary: 正品库存与固定30日可售天数
|
||||
description: '固定30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。'
|
||||
summary: 正品库存与固定10或30日可售天数
|
||||
description: '固定10或30天,不接受图表周期。可售仅品质0可用量,不扣冻结或加在途次品。先UNKNOWN/INVALID/STALE,再有效零库存为0,再校验正库存单位、窗口、观察期;零销量天数为空。 权限platform:self-operated-analytics:list。'
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/coverageBucket'}
|
||||
- {$ref: '#/components/parameters/bucketRuleVersion'}
|
||||
@@ -189,6 +189,8 @@ 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: salesWindowEndDate, in: query, schema: {type: string, format: date}, description: 含当日的窗口截止日,默认固定查询基准的北京时间昨日;必须早于查询当日,分页须携带原窗口}
|
||||
|
||||
- {$ref: '#/components/parameters/sourceAccount'}
|
||||
- {$ref: '#/components/parameters/warehouseCode'}
|
||||
@@ -211,7 +213,7 @@ paths:
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data: {$ref: '#/components/schemas/MetricPage'}
|
||||
data: {$ref: '#/components/schemas/InventoryMetricPage'}
|
||||
'401': {description: 缺少可信认证身份}
|
||||
'403': {description: 非平台身份或缺少独立只读权限}
|
||||
/api/platform/v1/last-leg/self-operated-analytics/sync-overview:
|
||||
@@ -652,6 +654,37 @@ components:
|
||||
earliestReliableEvidenceAt: {type: string, format: date-time, nullable: true}
|
||||
status: {type: string, nullable: true}
|
||||
reasonCodes: {type: array, items: {type: string}}
|
||||
InventoryMetric:
|
||||
description: 库存独立窗口响应。windowOutboundQuantity为所选完整10或30日销量;未知保持null,零销量状态NO_OUTBOUND_IN_WINDOW不除零。30日专属筛选及总览下钻不能用于10日或历史截止窗口。
|
||||
type: object
|
||||
properties:
|
||||
customerName: {type: string, nullable: true}
|
||||
defectivePhysicalQuantity: {type: number, nullable: true}
|
||||
windowOutboundQuantity: {type: number, nullable: true}
|
||||
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]}}
|
||||
sourceAccount: {type: string, nullable: true}
|
||||
customerCode: {type: string, nullable: true}
|
||||
warehouseCode: {type: string, nullable: true}
|
||||
sku: {type: string, nullable: true}
|
||||
productName: {type: string, nullable: true}
|
||||
quantityUnit: {type: string, nullable: true}
|
||||
skuOutboundOrderCount: {type: string, nullable: true}
|
||||
physicalQuantity: {type: number, nullable: true}
|
||||
availableQuantity: {type: number, nullable: true}
|
||||
frozenQuantity: {type: number, nullable: true}
|
||||
transitQuantity: {type: number, nullable: true}
|
||||
inventoryObservedAt: {type: string, format: date-time, nullable: true}
|
||||
averageDailyOutboundQuantity: {type: number, nullable: true}
|
||||
inventoryCoverageDays: {type: number, nullable: true}
|
||||
inventoryQuality: {type: string, nullable: true}
|
||||
salesCoverage: {type: string, nullable: true}
|
||||
observationEligible: {type: boolean, nullable: true}
|
||||
earliestReliableEvidenceAt: {type: string, format: date-time, nullable: true}
|
||||
status: {type: string, nullable: true}
|
||||
reasonCodes: {type: array, items: {type: string}}
|
||||
Protection:
|
||||
type: object
|
||||
properties:
|
||||
@@ -816,6 +849,16 @@ components:
|
||||
total: {type: string, nullable: true}
|
||||
pages: {type: string, nullable: true}
|
||||
excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}}
|
||||
InventoryMetricPage:
|
||||
type: object
|
||||
properties:
|
||||
context: {$ref: '#/components/schemas/Context'}
|
||||
records: {type: array, items: {$ref: '#/components/schemas/InventoryMetric'}}
|
||||
current: {type: integer, nullable: true}
|
||||
size: {type: integer, nullable: true}
|
||||
total: {type: string, nullable: true}
|
||||
pages: {type: string, nullable: true}
|
||||
excludedReasons: {type: object, additionalProperties: {type: string, nullable: true}}
|
||||
TaskPage:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
+3
-1
@@ -78,10 +78,12 @@ class PlatformAnalyticsControllerTest {
|
||||
var service=mock(AnalyticsQueryService.class);var collection=mock(CollectionQueryService.class);
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformAnalyticsController(service,collection,mock(DashboardQueryService.class))).setCustomArgumentResolvers(identity()).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-coverage").param("sourceAccount","demo").param("warehouseCode","W")
|
||||
.param("customerCode","C").param("attention","COVERAGE_GT_THRESHOLD").param("sortBy","COVERAGE_DAYS_DESC").param("coverageDaysThreshold","90")).andExpect(status().isOk());
|
||||
.param("customerCode","C").param("attention","COVERAGE_GT_THRESHOLD").param("sortBy","COVERAGE_DAYS_DESC").param("coverageDaysThreshold","90")
|
||||
.param("salesWindowDays","10").param("salesWindowEndDate","2026-10-12")).andExpect(status().isOk());
|
||||
var q=ArgumentCaptor.forClass(AnalyticsQuery.class);var options=ArgumentCaptor.forClass(AnalyticsInventoryOptions.class);
|
||||
verify(service).inventoryCoverage(q.capture(),options.capture());assertEquals("C",q.getValue().customerCode());
|
||||
assertEquals("COVERAGE_GT_THRESHOLD",options.getValue().attention());assertEquals("COVERAGE_DAYS_DESC",options.getValue().sortBy());assertEquals(90,options.getValue().coverageDaysThreshold());
|
||||
assertEquals(10,options.getValue().salesWindowDays());assertEquals(LocalDate.of(2026,10,12),options.getValue().salesWindowEndDate());assertNull(q.getValue().queryAsOf());
|
||||
verifyNoInteractions(collection);
|
||||
}
|
||||
@Test void dashboardUsesIndependentReadGuardAndRejectsArbitraryTopN()throws Exception{
|
||||
|
||||
+21
-9
@@ -29,7 +29,7 @@ class AnalyticsQueryServiceTest {
|
||||
when(mapper.count(any())).thenReturn(1L);
|
||||
when(mapper.page(any())).thenReturn(List.of(aggregate(true,"300","VALID",time(D.minusDays(40)))));
|
||||
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).complete30()?"150":null))).when(mapper).inventoryPage(any(),any());
|
||||
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)));
|
||||
when(mapper.profile(11,"demo","W","C","SKU")).thenReturn(profile("PCS","VALID_OUTBOUND",time(D.minusDays(40))));
|
||||
when(mapper.profile(22,"demo","W","C","SKU")).thenReturn(profile("PCS","INVENTORY_OBSERVATION",time(D.minusDays(35))));
|
||||
@@ -123,7 +123,7 @@ class AnalyticsQueryServiceTest {
|
||||
Run r=run(11,1,SH,NOW.minusSeconds(3600),null,"PUBLISHED","BUSINESS","Asia/Shanghai");
|
||||
for(LocalDateTime from:Arrays.asList(null,r.coveredUntilUtc().plusDays(1))){
|
||||
when(mapper.publishedRun(11)).thenReturn(new Run(r.id(),r.taskId(),r.scopeId(),r.sourceAccount(),r.warehouseCode(),r.runRole(),r.scopeHash(),r.scopeSnapshotJson(),r.status(),r.coverageStatus(),from,r.coveredUntilUtc(),r.completeDayFrom(),r.completeDayUntil(),r.fetchStartedAtUtc(),r.fetchFinishedAtUtc(),r.publishedAtUtc(),r.calculatedFactRevision(),r.errorCode(),r.errorMessage(),r.supersededAtUtc(),r.executionPurpose(),r.warehouseDailyReady()));
|
||||
var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).outboundQuantity30d());
|
||||
var response=service.inventoryCoverage(metrics(null),options());assertNull(response.context().completeDayFrom());assertNull(response.records().get(0).windowOutboundQuantity());
|
||||
}
|
||||
var cap=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper,times(2)).inventoryPage(cap.capture(),any());
|
||||
assertTrue(cap.getAllValues().stream().noneMatch(AnalyticsSqlRequest::complete30));
|
||||
@@ -190,6 +190,18 @@ class AnalyticsQueryServiceTest {
|
||||
assertThrows(BizException.class,()->service.slowSkus(chart("TODAY",null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.hotSkus(chart("LAST_3_DAYS",null,null,null,null)));
|
||||
}
|
||||
@Test void inventoryTenDayContextAndSqlWindowAreIndependentOfThirtyDayEligibility(){
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,10,D.minusDays(1));
|
||||
var response=service.inventoryCoverage(metrics(null),o);
|
||||
assertEquals(10,response.context().calculationWindowDays());assertEquals(D.minusDays(10),response.context().rangeStartDate());
|
||||
assertEquals(D,response.context().rangeEndDateExclusive());assertEquals(NOW,response.context().queryAsOf());
|
||||
assertEquals(n("20.0"),response.records().get(0).inventoryCoverageDays());
|
||||
assertEquals(0,n("15").compareTo(response.records().get(0).averageDailyOutboundQuantity()));
|
||||
assertEquals(n("150"),response.records().get(0).windowOutboundQuantity());
|
||||
var captor=ArgumentCaptor.forClass(AnalyticsSqlRequest.class);verify(mapper).inventoryPage(captor.capture(),eq(o));
|
||||
assertEquals(D.minusDays(10),captor.getValue().from());assertEquals(D,captor.getValue().until());assertTrue(captor.getValue().completeWindow());
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,10,D)));
|
||||
}
|
||||
@Test void unknownInventoryDoesNotReturnZeroCoverage(){
|
||||
doReturn(List.of(inventory(true,null,true,time(D.minusDays(40)),"150"))).when(mapper).inventoryPage(any(),any());
|
||||
var row=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
@@ -201,7 +213,7 @@ class AnalyticsQueryServiceTest {
|
||||
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());
|
||||
var zero=service.inventoryCoverage(metrics(null),options()).records().get(0);
|
||||
assertEquals("NO_OUTBOUND_30_DAYS",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum());
|
||||
assertEquals("NO_OUTBOUND_IN_WINDOW",zero.status());assertNull(zero.inventoryCoverageDays());assertEquals(0,zero.averageDailyOutboundQuantity().signum());
|
||||
}
|
||||
@Test void rebuildBarrierKeepsVersionButDisablesTrustedMetrics(){
|
||||
when(mapper.applyBarriers("demo","W")).thenReturn(1L); var result=service.inventoryCoverage(metrics(null),options());
|
||||
@@ -247,15 +259,15 @@ class AnalyticsQueryServiceTest {
|
||||
}
|
||||
@Test void endpointSpecificOptionsRejectNarrowingOverviewAndUnboundedAttention(){
|
||||
var q=new AnalyticsQuery("demo","W","C",null,null,null,null,null,null,null,null,null,null,null,null,null);
|
||||
assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.ownerOverview(q,new AnalyticsInventoryOptions(null,"ALL",null,null,null,null,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.ownerOverview(metrics(null),options()));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD",null,null,null,null,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,"physical;delete",1,null,null,null,null,null,null)));
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),new AnalyticsInventoryOptions(null,null,null,0,null,null,null,null,null,null)));
|
||||
verifyNoInteractions(mapper);
|
||||
}
|
||||
@Test void dashboardDrilldownRejectsStockExpiryWithinOtherwiseValidContextBeforeReadingRows(){
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN","COVERAGE_DISTRIBUTION_V1",null,"DASHBOARD");
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN","COVERAGE_DISTRIBUTION_V1",null,"DASHBOARD",null,null);
|
||||
assertThrows(BizException.class,()->service.inventoryCoverage(metrics(null),o));
|
||||
when(mapper.publishedRun(22)).thenReturn(run(22,2,IH,NOW.minusSeconds(93600),null,"PUBLISHED","BUSINESS","Asia/Shanghai"));
|
||||
var q=pinned(null,11,22,SH,IH,NOW.minusSeconds(60),1);
|
||||
@@ -264,7 +276,7 @@ class AnalyticsQueryServiceTest {
|
||||
// Normal inventory diagnostics still return the same fixed but stale version.
|
||||
assertEquals("STALE",service.inventoryCoverage(q,options()).context().inventoryStatus());
|
||||
}
|
||||
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);}
|
||||
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);
|
||||
return new Inventory("C","SKU","Product",unit,evidence,fresh?n("120"):null,stock,fresh?n("20"):null,fresh?n("30"):null,
|
||||
|
||||
+5
-5
@@ -104,7 +104,7 @@ class AnalyticsReadMapperTest {
|
||||
}
|
||||
}
|
||||
@Test void stockFirstHistoryLookupRetainsCorrelatedScopeAndReverseIndexOrder(){
|
||||
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"ALL","AVAILABLE_DESC",null,null,null,null,null);
|
||||
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"ALL","AVAILABLE_DESC",null,null,null,null,null,null,null);
|
||||
String sql=bound(c,"inventoryPage",Map.of("p",p,"o",o)).getSql();
|
||||
assertFalse(sql.contains("last_sales AS"));assertFalse(sql.contains("JOIN last_sales"));
|
||||
assertTrue(sql.contains("d.customer_code=k.customer_code AND d.sku_code=k.sku_code"));
|
||||
@@ -114,7 +114,7 @@ class AnalyticsReadMapperTest {
|
||||
immutableOnly(sql);
|
||||
}
|
||||
@Test void inventoryHistoryOnlyEnrichesQualifiedPageAndNeverCountsOrOwnerTotals(){
|
||||
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90,null,null,null,null);
|
||||
var c=config();var p=request("INVENTORY");var o=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",90,null,null,null,null,null,null);
|
||||
String page=bound(c,"inventoryPage",Map.of("p",p,"o",o)).getSql();
|
||||
assertTrue(page.contains("paged AS (SELECT * FROM result WHERE above_threshold=1 ORDER BY"));
|
||||
assertTrue(page.indexOf("LIMIT ? OFFSET ?")<page.indexOf("last_sales AS"));
|
||||
@@ -126,13 +126,13 @@ class AnalyticsReadMapperTest {
|
||||
}
|
||||
@Test void distributionCountCannotUseUnfilteredKeysAndStockOnlyCountSkipsSales(){
|
||||
var c=config();var p=request("INVENTORY");
|
||||
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"DAYS_0_30","COVERAGE_DISTRIBUTION_V1",null,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null))){
|
||||
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"DAYS_0_30","COVERAGE_DISTRIBUTION_V1",null,null,null,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null,null,null))){
|
||||
String count=bound(c,"inventoryCount",Map.of("p",p,"o",o)).getSql();
|
||||
assertTrue(count.contains("FROM result WHERE"));assertTrue(count.contains("ll_selfop_sku_daily_sales"));
|
||||
}
|
||||
for(String attention:List.of("FROZEN_STOCK","DEFECTIVE_STOCK")){
|
||||
String count=bound(c,"inventoryCount",Map.of("p",p,"o",new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null))).getSql();
|
||||
String count=bound(c,"inventoryCount",Map.of("p",p,"o",new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null,null,null))).getSql();
|
||||
assertFalse(count.contains("ll_selfop_sku_daily_sales"));assertTrue(count.contains("i.quantity_unit='PCS'"));
|
||||
assertTrue(count.contains("snapshot_run_id=?"));assertTrue(count.contains("st.amount>0"));
|
||||
}
|
||||
|
||||
+44
-16
@@ -234,7 +234,7 @@ public class AnalyticsSqlSemanticTest {
|
||||
stockExact("MISSING",1,null,"0",null,"0","UNKNOWN","FIELD_MISSING");
|
||||
var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20);
|
||||
for(String attention:List.of("FROZEN_STOCK","DEFECTIVE_STOCK")){
|
||||
var o=new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null);
|
||||
var o=new AnalyticsInventoryOptions(null,attention,null,null,null,null,null,null,null,null);
|
||||
assertEquals(1L,((Number)literalQuery("inventoryCount",p,o).get(0)).longValue());
|
||||
assertEquals(List.of("VALID"),inventory(attention,null,null,0,20).stream().map(Inventory->Inventory.skuCode()).toList());
|
||||
}
|
||||
@@ -265,7 +265,7 @@ public class AnalyticsSqlSemanticTest {
|
||||
var rows=inventory("COVERAGE_GT_THRESHOLD",300,"PHYSICAL_DESC",100,20);assertEquals(6,rows.size());
|
||||
assertEquals("BOUNDARY",rows.get(5).skuCode());assertDecimal("300.000001",rows.get(5).coverageDays());
|
||||
var p=request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),100,20);
|
||||
var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300,null,null,null,null);
|
||||
var options=new AnalyticsInventoryOptions(null,"COVERAGE_GT_THRESHOLD","PHYSICAL_DESC",300,null,null,null,null,null,null);
|
||||
assertEquals(106L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue());
|
||||
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"));
|
||||
@@ -274,7 +274,7 @@ public class AnalyticsSqlSemanticTest {
|
||||
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());
|
||||
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));
|
||||
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"));
|
||||
}
|
||||
@@ -283,17 +283,17 @@ public class AnalyticsSqlSemanticTest {
|
||||
profile(11,owner,"SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");profile(22,owner,"SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"PCS");
|
||||
}
|
||||
var p=request("OWNERS",D.minusDays(30),D,null,null,utc(D,8),0,20);
|
||||
assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null,null,null,null,null)));
|
||||
assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null,null,null,null,null)));
|
||||
assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null,null,null,null,null)));
|
||||
assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null)).get(0)).longValue());
|
||||
assertEquals(List.of("A%"),literalQuery("owners",p,new AnalyticsInventoryOptions("A%",null,null,null,null,null,null,null,null,null)));
|
||||
assertEquals(List.of("A_test"),literalQuery("owners",p,new AnalyticsInventoryOptions("A_",null,null,null,null,null,null,null,null,null)));
|
||||
assertEquals(List.of("abc"),literalQuery("owners",p,new AnalyticsInventoryOptions("a",null,null,null,null,null,null,null,null,null)));
|
||||
assertEquals(4L,((Number)literalQuery("ownerCount",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null)).get(0)).longValue());
|
||||
}
|
||||
@Test void inventoryUnitConflictCannotEraseCompleteOwnerSales() throws Exception {
|
||||
profile(11,"C","SKU","VALID_OUTBOUND",utc(D.minusDays(40),0),"PCS");
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(40),0),"BOX");
|
||||
daily(11,"W","C","SKU",D.minusDays(1),"12");stockExact("SKU",0,"30","30","0","0","VALID",null);
|
||||
var row=inventory(null,30,null,0,20).get(0);assertDecimal("12",row.ownerOutbound());assertNull(row.outboundQuantity30d());assertNull(row.physicalQuantity());
|
||||
var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null));
|
||||
var totals=totals(request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),0,20),new AnalyticsInventoryOptions(null,null,null,30,null,null,null,null,null,null));
|
||||
assertDecimal("12",(BigDecimal)totals.get("owner_outbound_known"));assertEquals(0L,((Number)totals.get("owner_outbound_unknown")).longValue());
|
||||
}
|
||||
@Test void yearlySparseSummaryPreservesOwnerIdentityAndCompletePartialUnknownSpanCounts() throws Exception {
|
||||
@@ -368,7 +368,7 @@ public class AnalyticsSqlSemanticTest {
|
||||
daily(99,"W","C","A",D.minusDays(1),"9000");daily(11,"OTHER","C","A",D.minusDays(1),"8000");
|
||||
profile(22,"C","OTHER-WAREHOUSE","INVENTORY_OBSERVATION",utc(D.minusDays(90),0),"PCS");
|
||||
try(var statement=schema.createStatement()){statement.execute("UPDATE ll_selfop_sku_profile SET warehouse_code='OTHER' WHERE sku_code='OTHER-WAREHOUSE'");}
|
||||
var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null);
|
||||
var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null,null,null);
|
||||
var p=request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),0,2);
|
||||
assertEquals(6L,((Number)literalQuery("inventoryCount",p,options).get(0)).longValue());
|
||||
var first=inventoryRows(p,options);assertEquals(List.of("B/A","C/A"),first.stream().map(r->r.customerCode()+"/"+r.skuCode()).toList());
|
||||
@@ -383,11 +383,11 @@ public class AnalyticsSqlSemanticTest {
|
||||
assertTrue(inventoryRows(request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),6,2),options).isEmpty());
|
||||
assertEquals(List.of("C"),inventoryRows(request("INVENTORY",D.minusDays(30),D,"C","A",utc(D,8),0,20),options).stream().map(AnalyticsReadRows.Inventory::customerCode).toList());
|
||||
assertEquals(2,inventoryRows(request("INVENTORY",D.minusDays(30),D,null,"A",utc(D,8),0,20),options).size());
|
||||
var bucket=new AnalyticsInventoryOptions(null,null,null,null,"DAYS_GT_90","COVERAGE_DISTRIBUTION_V1",null,null);
|
||||
var bucket=new AnalyticsInventoryOptions(null,null,null,null,"DAYS_GT_90","COVERAGE_DISTRIBUTION_V1",null,null,null,null);
|
||||
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",bucket)).contains("stock_paged"));
|
||||
assertEquals(List.of("B/A","C/A"),inventoryRows(p,bucket).stream().map(r->r.customerCode()+"/"+r.skuCode()).toList());
|
||||
for(String sort:List.of("PHYSICAL_DESC","COVERAGE_DAYS_DESC","OUTBOUND_30D_ASC"))
|
||||
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null))).contains("stock_paged"));
|
||||
assertFalse(AnalyticsInventorySql.inventoryPage(Map.of("p",p,"o",new AnalyticsInventoryOptions(null,"ALL",sort,null,null,null,null,null,null,null))).contains("stock_paged"));
|
||||
}
|
||||
@Test void stockFirstLastOutboundSkipsInvalidLatestRowsAndKeepsAllScopeBoundaries() throws Exception {
|
||||
for(String sku:List.of("HISTORY","NONE","ANCHOR")){
|
||||
@@ -409,14 +409,14 @@ public class AnalyticsSqlSemanticTest {
|
||||
historyRow(11,"demo","W","C","NONE",D,"0","PCS");
|
||||
historyRow(11,"demo","W","C","ANCHOR",D,"1","PCS");
|
||||
var p=request("INVENTORY",D.minusDays(30),D,null,null,utc(D,8),0,20);
|
||||
var options=new AnalyticsInventoryOptions(null,"ALL","AVAILABLE_DESC",null,null,null,null,null);
|
||||
var options=new AnalyticsInventoryOptions(null,"ALL","AVAILABLE_DESC",null,null,null,null,null,null,null);
|
||||
var rows=inventoryRows(p,options);assertEquals(4,rows.size());
|
||||
for(var row:rows){
|
||||
LocalDate expected=row.skuCode().equals("ANCHOR")?D:row.skuCode().equals("NONE")?null:row.customerCode().equals("B")?D.minusDays(3):D.minusDays(40);
|
||||
assertEquals(expected,row.lastOutbound(),row.customerCode()+"/"+row.skuCode());
|
||||
}
|
||||
// The pre-existing grouped path remains an independent semantic baseline.
|
||||
var legacy=inventoryRows(p,new AnalyticsInventoryOptions(null,"ALL","PHYSICAL_DESC",null,null,null,null,null));
|
||||
var legacy=inventoryRows(p,new AnalyticsInventoryOptions(null,"ALL","PHYSICAL_DESC",null,null,null,null,null,null,null));
|
||||
assertEquals(legacy,rows);
|
||||
var selected=inventoryRows(request("INVENTORY",D.minusDays(30),D,"C","HISTORY",utc(D,8),0,20),options);
|
||||
assertEquals(1,selected.size());assertEquals(D.minusDays(40),selected.get(0).lastOutbound());
|
||||
@@ -430,7 +430,7 @@ public class AnalyticsSqlSemanticTest {
|
||||
}
|
||||
@Test void stockFirstDoesNotTurnUnavailableSalesOrExpiredInventoryIntoKnownValues() throws Exception {
|
||||
profile(22,"C","SKU","INVENTORY_OBSERVATION",utc(D.minusDays(60),0),"PCS");stock("C","SKU",0,"10");daily(11,"W","C","SKU",D.minusDays(1),"3");
|
||||
var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null);
|
||||
var options=new AnalyticsInventoryOptions(null,null,null,90,null,null,null,null,null,null);
|
||||
var stale=new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,
|
||||
utc(D.minusDays(30),0),utc(D,8),true,true,true,true,false,0,20,"INVENTORY");
|
||||
var row=inventoryRows(stale,options).get(0);assertNull(row.physicalQuantity());assertNull(row.availableQuantity());assertDecimal("3",row.ownerOutbound());assertNull(row.coverageDays());
|
||||
@@ -439,16 +439,44 @@ public class AnalyticsSqlSemanticTest {
|
||||
var empty=new AnalyticsSqlRequest("demo","OTHER",null,null,0,0,D.minusDays(30),D,D,D.minusDays(30),null,null,null,null,false,false,false,false,false,0,20,"INVENTORY");
|
||||
assertTrue(inventoryRows(empty,options).isEmpty());assertEquals(0L,((Number)literalQuery("inventoryCount",empty,options).get(0)).longValue());
|
||||
}
|
||||
@Test void tenDayInventoryUsesItsOwnCompleteCoverageAndUnroundedThresholdWithoutLeakingScope()throws Exception {
|
||||
for(String sku:List.of("TEN","ZERO","YOUNG")) {
|
||||
profile(22,"C",sku,"INVENTORY_OBSERVATION",utc(D.minusDays("YOUNG".equals(sku)?5:20),0),"PCS");
|
||||
stock("C",sku,0,"TEN".equals(sku)?"6.008":"4");
|
||||
}
|
||||
daily(11,"W","C","TEN",D.minusDays(10),"1");daily(11,"W","C","TEN",D.minusDays(1),"1");
|
||||
daily(11,"W","C","TEN",D.minusDays(11),"900");daily(11,"W","C","TEN",D,"800");
|
||||
daily(99,"W","C","TEN",D.minusDays(1),"700");daily(11,"OTHER","C","TEN",D.minusDays(1),"600");
|
||||
daily(11,"W","OTHER","TEN",D.minusDays(1),"500");
|
||||
var p=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(10),D,
|
||||
utc(D.minusDays(10),0),utc(D,8),true,true,true,false,true,0,20,"INVENTORY");
|
||||
var o=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",30,null,null,null,null,10,D.minusDays(1));
|
||||
var rows=inventoryRows(p,o);var ten=rows.stream().filter(r->r.skuCode().equals("TEN")).findFirst().orElseThrow();
|
||||
assertDecimal("2",ten.outboundQuantity30d());assertDecimal("30.04",ten.coverageDays());assertEquals(1,ten.aboveThreshold());
|
||||
var stockFirst=new AnalyticsInventoryOptions(null,null,"AVAILABLE_DESC",30,null,null,null,null,10,D.minusDays(1));
|
||||
var first=inventoryRows(p,stockFirst).get(0);assertEquals("TEN",first.skuCode());assertDecimal("2",first.outboundQuantity30d());assertDecimal("30.04",first.coverageDays());
|
||||
var second=inventoryRows(request("INVENTORY",D.minusDays(10),D,"C",null,utc(D,8),1,1),o);
|
||||
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 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());
|
||||
var partial=new AnalyticsSqlRequest("demo","W","C",null,11,22,D.minusDays(10),D,D,D.minusDays(30),D.minusDays(9),D,
|
||||
utc(D.minusDays(9),0),utc(D,8),true,true,false,false,true,0,20,"INVENTORY");
|
||||
assertTrue(inventoryRows(partial,o).stream().allMatch(r->r.outboundQuantity30d()==null&&r.coverageDays()==null));
|
||||
}
|
||||
@SuppressWarnings("unchecked") private List<AnalyticsReadRows.Inventory> inventoryRows(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
|
||||
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",p,o);
|
||||
}
|
||||
@SuppressWarnings("unchecked") private List<AnalyticsReadMapper.RankingStat> rankingStats(AnalyticsSqlRequest p){
|
||||
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null));
|
||||
return (List<AnalyticsReadMapper.RankingStat>)(List<?>)literalQuery("rankingStats",p,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null));
|
||||
}
|
||||
private static void assertDecimal(String expected,BigDecimal actual){assertNotNull(actual);assertEquals(0,new BigDecimal(expected).compareTo(actual));}
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<AnalyticsReadRows.Inventory> inventory(String attention,Integer threshold,String sort,long offset,int size){
|
||||
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold,null,null,null,null));
|
||||
return (List<AnalyticsReadRows.Inventory>)(List<?>)literalQuery("inventoryPage",request("INVENTORY",D.minusDays(30),D,"C",null,utc(D,8),offset,size),new AnalyticsInventoryOptions(null,attention,sort,threshold,null,null,null,null,null,null));
|
||||
}
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String,Object> totals(AnalyticsSqlRequest p,AnalyticsInventoryOptions o){
|
||||
|
||||
+7
-7
@@ -25,20 +25,20 @@ class DashboardPolicyTest {
|
||||
var unknown=SalesBucketPolicy.aggregate(DashboardSalesPolicy.points(context(null,null,false),false,DAY.minusDays(1),DAY.plusDays(1),List.of()));assertNull(unknown.observedQuantity());
|
||||
}
|
||||
@Test void strictInventoryDrilldownExpiresBeforeContextWhileOrdinaryInventoryRemainsAllowed(){
|
||||
var c=context(NOW.minusSeconds(86400),NOW,true);var q=query(true);var strict=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,"DASHBOARD");
|
||||
var c=context(NOW.minusSeconds(86400),NOW,true);var q=query(true);var strict=new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,"DASHBOARD",null,null);
|
||||
Instant fresh=NOW.plusSeconds(60);assertEquals(fresh,CoverageDistributionPolicy.expiresAt(c,fresh));
|
||||
assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,strict,c,fresh,fresh.minusSeconds(1)));
|
||||
assertEquals("原图库存已过期,请刷新总览",assertThrows(BizException.class,()->CoverageDistributionPolicy.requireDrilldown(q,strict,c,fresh,fresh)).getMessage());
|
||||
assertThrows(BizException.class,()->CoverageDistributionPolicy.requireDrilldown(query(false),strict,c,fresh,NOW));
|
||||
assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null),c,fresh,fresh.plusSeconds(1)));
|
||||
assertDoesNotThrow(()->CoverageDistributionPolicy.requireDrilldown(q,new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null),c,fresh,fresh.plusSeconds(1)));
|
||||
assertNull(CoverageDistributionPolicy.expiresAt(context(null,null,false),fresh));
|
||||
}
|
||||
@Test void bucketSelectionAndAttentionCannotSilentlyBroadenToWarehouse(){
|
||||
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"INVALID",CoverageDistributionPolicy.RULE_VERSION,null,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,null,CoverageDistributionPolicy.RULE_VERSION,null,null),
|
||||
new AnalyticsInventoryOptions(null,"FROZEN_STOCK",null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null),
|
||||
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,"FINITE_POSITIVE",null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,"FINITE_POSITIVE",null)))assertThrows(BizException.class,()->o.validate("INVENTORY",query(true)));
|
||||
for(var o:List.of(new AnalyticsInventoryOptions(null,null,null,null,"INVALID",CoverageDistributionPolicy.RULE_VERSION,null,null,null,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,null,CoverageDistributionPolicy.RULE_VERSION,null,null,null,null),
|
||||
new AnalyticsInventoryOptions(null,"FROZEN_STOCK",null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null,null,null),
|
||||
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,"FINITE_POSITIVE",null,null,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,"FINITE_POSITIVE",null,null,null)))assertThrows(BizException.class,()->o.validate("INVENTORY",query(true)));
|
||||
assertThrows(BizException.class,()->DashboardRequest.validateParameterNames(Set.of("sourceAccount","warehouseCode","topN")));
|
||||
assertEquals("LAST_15_DAYS",DashboardRequest.normalize(query(false)).rangePreset());
|
||||
}
|
||||
|
||||
+3
-3
@@ -19,7 +19,7 @@ class DashboardQueryServiceTest {
|
||||
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
|
||||
var range=SalesRangePolicy.resolve("LAST_15_DAYS",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
|
||||
var snapshot=new AnalyticsQueryService.Snapshot(q,salesRun(true),null,true,true,day.minusDays(30),day,range,context);
|
||||
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
|
||||
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot);
|
||||
var p=new AnalyticsSqlRequest("demo","W",null,null,11,0,day.minusDays(14),day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),true,true,false,true,false,0,20,"DASHBOARD");
|
||||
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(p);
|
||||
when(mapper.daily(any(),any(),any())).thenReturn(List.of(new DashboardReadMapper.Day(day.minusDays(1),new BigDecimal("30"),0L)));
|
||||
@@ -42,7 +42,7 @@ class DashboardQueryServiceTest {
|
||||
var context=DashboardPolicyTest.context(day.minusDays(30).atStartOfDay().toInstant(ZoneOffset.ofHours(8)),now,false);
|
||||
var range=SalesRangePolicy.resolve("TODAY",null,null,"DAY",now,ZoneId.of("Asia/Shanghai"),366);
|
||||
for(Boolean ready:Arrays.asList(false,null)){
|
||||
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(new AnalyticsQueryService.Snapshot(q,salesRun(ready),null,true,true,day.minusDays(30),day,range,context));
|
||||
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(new AnalyticsQueryService.Snapshot(q,salesRun(ready),null,true,true,day.minusDays(30),day,range,context));
|
||||
var error=assertThrows(com.baiyue.common.core.exception.BizException.class,()->service.dashboard(q));
|
||||
assertEquals("该销量版本尚未生成仓库每日汇总,请重新发布统计后再查询",error.getMessage());
|
||||
}
|
||||
@@ -51,7 +51,7 @@ class DashboardQueryServiceTest {
|
||||
when(mapper.report(any(),any())).thenReturn(List.of(Map.of("total_skus",0L,"hot_eligible",0L,"hot_unknown",0L)));
|
||||
for(boolean salesPresent:List.of(true,false)){
|
||||
var snapshot=new AnalyticsQueryService.Snapshot(q,salesPresent?salesRun(true):null,null,salesPresent,salesPresent,day.minusDays(30),day,range,context);
|
||||
when(contexts.context(any(),eq(true),eq(false),eq(false))).thenReturn(snapshot);
|
||||
when(contexts.context(any(),eq(true),eq(false),eq(false),isNull())).thenReturn(snapshot);
|
||||
when(contexts.request(snapshot,"DASHBOARD")).thenReturn(new AnalyticsSqlRequest("demo","W",null,null,salesPresent?11:0,0,day,day.plusDays(1),day,day.minusDays(30),day.minusDays(30),day,
|
||||
day.minusDays(30).atStartOfDay().minusHours(8),day.atStartOfDay(),salesPresent,salesPresent,salesPresent,salesPresent,false,0,20,"DASHBOARD"));
|
||||
var result=service.dashboard(q);
|
||||
|
||||
+3
-3
@@ -115,7 +115,7 @@ class DashboardSqlSemanticTest {
|
||||
for(String bucket:CoverageDistributionPolicy.BUCKETS){
|
||||
long n=count(stats,"bucket_"+bucket.toLowerCase(Locale.ROOT));assertTrue(n>0,bucket+" must be populated");sum+=n;
|
||||
assertEquals(expected.get(bucket).size(),n,bucket);
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null);
|
||||
var o=new AnalyticsInventoryOptions(null,null,null,null,bucket,CoverageDistributionPolicy.RULE_VERSION,null,null,null,null);
|
||||
assertEquals(n,((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",o)).get(0)).longValue(),bucket);
|
||||
var all=query(AnalyticsReadMapper.class,"inventoryPage",Map.of("p",request(),"o",o)).stream().map(AnalyticsReadRows.Inventory.class::cast).map(row->row.customerCode()+"/"+row.skuCode()).toList();
|
||||
assertEquals(dashboardIdentities.get(bucket),new HashSet<>(all),bucket+" drilldown identities");assertEquals(n,all.size());
|
||||
@@ -129,7 +129,7 @@ class DashboardSqlSemanticTest {
|
||||
for(String key:seen)assertTrue(union.add(key),"identity belongs to multiple buckets: "+key);
|
||||
}
|
||||
assertEquals(count(stats,"candidate_skus"),sum);assertEquals(sum,union.size());assertFalse(union.contains("C/ZERO"));
|
||||
var finiteSelection=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",null,null,null,"FINITE_POSITIVE",null);
|
||||
var finiteSelection=new AnalyticsInventoryOptions(null,null,"COVERAGE_DAYS_DESC",null,null,null,"FINITE_POSITIVE",null,null,null);
|
||||
assertEquals(13,count(stats,"long_eligible"));
|
||||
assertEquals(count(stats,"long_eligible"),((Number)query(AnalyticsReadMapper.class,"inventoryCount",Map.of("p",request(),"o",finiteSelection)).get(0)).longValue());
|
||||
assertEquals(6,count(stats,"no_outbound_eligible"));
|
||||
@@ -226,7 +226,7 @@ class DashboardSqlSemanticTest {
|
||||
private AnalyticsSqlRequest request(){return new AnalyticsSqlRequest("demo","W",null,null,11,22,D.minusDays(30),D,D,D.minusDays(30),D.minusDays(30),D,D.minusDays(30).atStartOfDay().minusHours(8),D.atStartOfDay(),true,true,true,true,true,0,20,"DASHBOARD");}
|
||||
private AnalyticsSqlRequest pageRequest(long offset,int size){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),p.customer(),p.sku(),p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),p.completeWindow(),p.complete30(),p.stockFresh(),offset,size,"INVENTORY");}
|
||||
private AnalyticsSqlRequest request(boolean complete,boolean fresh){var p=request();return new AnalyticsSqlRequest(p.account(),p.warehouse(),null,null,p.salesRun(),p.inventoryRun(),p.from(),p.until(),p.anchor(),p.windowFrom(),p.completeFrom(),p.completeUntil(),p.coveredFrom(),p.coveredUntil(),p.salesKnown(),p.formalSales(),complete,complete,fresh,p.offset(),p.size(),p.kind());}
|
||||
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null);}
|
||||
private static AnalyticsInventoryOptions options(){return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,null,null);}
|
||||
@SuppressWarnings("unchecked") private List<Map<String,Object>> report(){
|
||||
return ((List<Map<String,Object>>)(List<?>)query(DashboardReadMapper.class,"report",Map.of("p",request(),"o",options()))).stream().map(row->{
|
||||
Map<String,Object> normalized=new HashMap<>();row.forEach((key,value)->normalized.put(key.toLowerCase(Locale.ROOT),value));return normalized;
|
||||
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class InventoryWindowPolicyTest {
|
||||
private static final LocalDate DAY=LocalDate.of(2026,10,13);
|
||||
private static AnalyticsInventoryOptions options(Integer days,LocalDate end) {
|
||||
return new AnalyticsInventoryOptions(null,null,null,null,null,null,null,null,days,end);
|
||||
}
|
||||
@Test void explicitCompleteWindowStaysFixedAcrossMidnightWithoutChangingQueryClock() {
|
||||
var o=options(10,DAY.minusDays(1));
|
||||
for(LocalDate anchor:new LocalDate[]{DAY,DAY.plusDays(1)}) {
|
||||
var range=InventoryWindowPolicy.range(o,anchor);
|
||||
assertEquals(LocalDate.of(2026,10,3),range.startDate());assertEquals(DAY,range.endDateExclusive());
|
||||
assertEquals(10,range.dayCount());assertEquals(anchor,range.anchorDate());
|
||||
}
|
||||
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));
|
||||
}
|
||||
@Test void tenDayAndHistoricalWindowsCannotMasqueradeAsThirtyDayDrilldowns() {
|
||||
for(var o:new AnalyticsInventoryOptions[]{
|
||||
new AnalyticsInventoryOptions(null,"NO_OUTBOUND_30D",null,null,null,null,null,null,10,null),
|
||||
new AnalyticsInventoryOptions(null,null,"OUTBOUND_30D_ASC",null,null,null,null,null,10,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,"UNKNOWN",CoverageDistributionPolicy.RULE_VERSION,null,null,10,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,null,null,"FINITE_POSITIVE",null,10,null),
|
||||
new AnalyticsInventoryOptions(null,null,null,null,null,null,null,"DASHBOARD",30,DAY.minusDays(2))})
|
||||
assertThrows(BizException.class,()->InventoryWindowPolicy.range(o,DAY));
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
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());
|
||||
}
|
||||
@Test void inventoryResponseHasOneNeutralWindowQuantityWithoutThirtyDayAliases() {
|
||||
var names=java.util.Arrays.stream(AnalyticsResponses.InventoryMetric.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList();
|
||||
assertTrue(names.contains("windowOutboundQuantity"));assertFalse(names.contains("outboundQuantity30d"));
|
||||
assertFalse(names.contains("outboundQuantity"));assertFalse(names.contains("todayObservedQuantity"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user