生产更新:同步已提交的海外仓分析及业务修复(2026-10-16) #27

Merged
admin merged 138 commits from test into main 2026-10-16 18:02:59 +08:00
16 changed files with 269 additions and 72 deletions
Showing only changes of commit 496fc11b0a - Show all commits
@@ -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,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("库存关注条件无效");
@@ -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.":"";
@@ -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());
@@ -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,
@@ -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();
@@ -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:
@@ -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{
@@ -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,
@@ -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"));
}
@@ -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){
@@ -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());
}
@@ -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);
@@ -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;
@@ -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"));
}
}