fix: finalize inventory turnover queries and reuse snapshot aggregation

This commit is contained in:
2026-10-14 01:51:51 +08:00
parent d029f9a19e
commit 16d0b9c80f
10 changed files with 527 additions and 28 deletions
@@ -42,13 +42,15 @@ public class InventoryTurnoverContext {
sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf);
// Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway.
var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC)).context(aq,true,false,false,null);
String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null);
var mapping=new TreeMap<LocalDate,Long>();
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),utc(asOf),end.minusDays(29),end.plusDays(1))) {
if(sample.sampleDate()==null||sample.runId()==null||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh();
for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(29),end.plusDays(1))) {
if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(29))||sample.sampleDate().isAfter(end)
||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh();
}
List<Sample> samples=new ArrayList<>();
for(LocalDate date=end.minusDays(29);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date)));
var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),utc(asOf));
var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf));
var c=snapshot.context();
var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(),
c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),List.copyOf(samples),began);
@@ -69,8 +69,13 @@ public final class InventoryTurnoverPolicy {
"VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention),
risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable"));
}
static Overview overview(Map<String,Object> raw) {
static Overview overview(Map<String,Object> raw,Context context) {
var r=normalized(raw);int noUnknown=count(r,"no_outbound_unknown");var noKnown=decimal(r,"no_outbound_count");
if(count(r,"sku_count")==0&&!"VALID".equals(context.analyticsContext().inventoryStatus())) {
var unknown=new Total(null,null,0,List.of("INVENTORY_UNKNOWN_OR_STALE"));
return new Overview(unknown,unknown,new Total(null,null,0,List.of("SALES_OR_STOCK_UNASSESSABLE")),
new Total(null,null,0,List.of("SALES_OR_STOCK_UNASSESSABLE")));
}
return new Overview(total(r,"physical",true),total(r,"available",true),
new Total(noUnknown==0?noKnown:null,noKnown,noUnknown,noUnknown==0?List.of():List.of("SALES_OR_STOCK_UNASSESSABLE")),
new Total(decimal(r,"unassessable"),decimal(r,"unassessable"),0,List.of()));
@@ -1,6 +1,9 @@
package com.baiyue.lastleg.selfoperated.analytics.query;
import java.util.*;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
@@ -12,22 +15,31 @@ import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverP
public class InventoryTurnoverQueryService {
private final InventoryTurnoverContext contexts;
private final InventoryTurnoverReadMapper mapper;
public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper){this.contexts=contexts;this.mapper=mapper;}
private final ObjectMapper json;
public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper,ObjectMapper json){this.contexts=contexts;this.mapper=mapper;this.json=json;}
public Response query(InventoryTurnoverRequest request) {
var frozen=contexts.resolve(request);var input=InventoryTurnoverSql.input(request,frozen);var context=frozen.response();
long total=mapper.count(input);
var rows=request.offset()>=total?List.<Row>of():mapper.page(input).stream().map(r->row(r,request,context)).toList();
Overview summary=null;Charts charts=null;
if(request.overview()) {
summary=overview(mapper.overview(input));
List<OwnerPhysical> owners=new ArrayList<>();int unknown=0;
for(var raw:mapper.owners(input)) {
var r=normalized(raw);
if("META".equals(text(r,"kind")))unknown=count(r,"unknown_count");
else owners.add(new OwnerPhysical(text(r,"customer_code"),null,decimal(r,"physical")));
long total=0;boolean hasTotal=false;Overview summary=null;Charts charts=null;
List<Row> rows=new ArrayList<>();List<OwnerPhysical> owners=new ArrayList<>();int unknown=0;
for(var part:mapper.report(input)) {
var envelope=normalized(part);Map<String,Object> value;
try {
value=json.readerFor(new TypeReference<Map<String,Object>>(){}).with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS)
.readValue(text(envelope,"payload"));
} catch(Exception e){throw new IllegalStateException("Invalid turnover query projection",e);}
switch(text(envelope,"section")) {
case "ROW" -> rows.add(row(value,request,context));
case "TOTAL" -> {total=decimal(value,"total").longValueExact();hasTotal=true;}
case "OVERVIEW" -> summary=overview(value,context);
case "OWNER" -> owners.add(new OwnerPhysical(text(value,"customer_code"),null,decimal(value,"physical")));
case "OWNER_META" -> unknown=count(value,"unknown_count");
default -> throw new IllegalStateException("Unknown turnover query section");
}
}
if(!hasTotal||request.overview()&&summary==null)throw new IllegalStateException("Missing turnover query section");
if(request.overview()) {
charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(input)),context.endDate().plusDays(1)));
}
return new Response(context,summary,charts,rows,total);
return new Response(context,summary,charts,List.copyOf(rows),total);
}
}
@@ -15,11 +15,12 @@ public interface InventoryTurnoverReadMapper {
AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf);
// The immutable first-daily marker is set only by publication. Recheck its original schedule evidence.
String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} "
String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} AND r.scope_hash=#{scopeHash,jdbcType=VARCHAR} "
+"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' "
+"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' "
+"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' "
+"AND t.task_role='WAREHOUSE_SYNC' AND r.page_count>=1 AND r.source_total_count>=0 "
+"AND t.task_role='WAREHOUSE_SYNC' AND t.scope_hash=r.scope_hash "
+"AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code AND r.page_count>=1 AND r.source_total_count>=0 "
+"AND r.source_total_count=r.unique_object_count AND r.fetch_started_at_utc<=r.fetch_finished_at_utc "
+"AND DATE(DATE_ADD(t.planned_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
+"AND DATE(DATE_ADD(r.fetch_started_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date "
@@ -27,10 +28,10 @@ public interface InventoryTurnoverReadMapper {
@Select("SELECT r.inventory_daily_sample_date sample_date,r.id run_id FROM ll_selfop_sync_run r "
+"JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE
+"AND r.inventory_daily_sample_date>=#{from} AND r.inventory_daily_sample_date<#{until} ORDER BY r.inventory_daily_sample_date")
List<SampleRun> samples(@Param("account") String account,@Param("warehouse") String warehouse,
List<SampleRun> samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,
@Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until);
@Select("SELECT MIN(r.inventory_daily_sample_date) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE)
LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("asOf") LocalDateTime asOf);
LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf);
@SelectProvider(type=InventoryTurnoverSql.class,method="page")
List<Map<String,Object>> page(@Param("p") InventoryTurnoverSql.Input input);
@@ -42,4 +43,7 @@ public interface InventoryTurnoverReadMapper {
List<Map<String,Object>> owners(@Param("p") InventoryTurnoverSql.Input input);
@SelectProvider(type=InventoryTurnoverSql.class,method="chartWindows")
Map<String,Object> chartWindows(@Param("p") InventoryTurnoverSql.Input input);
@SelectProvider(type=InventoryTurnoverSql.class,method="report")
List<Map<String,Object>> report(@Param("p") InventoryTurnoverSql.Input input);
}
@@ -30,7 +30,7 @@ public final class InventoryTurnoverSql {
return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}"
+" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})";
}
private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";}
private static String valid(String field){return "product_quality IN (0,1) AND "+field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";}
private static String amount(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";}
private static String calendar(Input p) {
var parts=new ArrayList<String>();
@@ -50,7 +50,8 @@ public final class InventoryTurnoverSql {
.append("), stocks AS (").append(stocks).append("), sales_days AS (SELECT customer_code,sku_code,business_date,SUM(outbound_quantity) quantity,MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE ")
.append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date<CAST(#{p.until} AS DATE) GROUP BY customer_code,sku_code,business_date), candidates AS (")
.append("SELECT customer_code,sku_code FROM fixed_profiles UNION SELECT customer_code,sku_code FROM stocks UNION SELECT customer_code,sku_code FROM sales_days), profiles AS (")
.append("SELECT c.customer_code,c.sku_code,MAX(f.product_name) product_name,MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END) unit_valid,")
.append("SELECT c.customer_code,c.sku_code,COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN f.product_name END),MAX(f.product_name)) product_name,")
.append("COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.salesRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END)) unit_valid,")
.append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ")
.append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code), daily_values AS (")
.append("SELECT c.customer_code,c.sku_code,d.business_date,d.coverage,c.unit_valid,c.evidence,sd.quantity,sd.unit_valid sales_unit,")
@@ -60,7 +61,8 @@ public final class InventoryTurnoverSql {
.append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=d.sample_run AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), day_result AS (")
.append("SELECT v.*,CASE WHEN #{p.salesKnown}=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 THEN CASE WHEN quantity IS NOT NULL THEN quantity WHEN coverage>0 AND evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00') THEN 0 END END observed,")
.append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 AND (quantity IS NOT NULL OR evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00')) THEN 1 ELSE 0 END complete,")
.append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete FROM daily_values v), sku_windows AS (")
.append("CASE WHEN #{p.salesKnown}=1 AND coverage=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete,")
.append("CASE WHEN #{p.salesKnown}=1 AND COALESCE(sales_unit,1)=1 THEN CASE WHEN quantity IS NOT NULL THEN quantity WHEN coverage>0 THEN 0 END END owner_observed FROM daily_values v), sku_windows AS (")
.append("SELECT customer_code,sku_code").append(windowAggregates(false)).append(",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,")
.append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,")
.append("GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates ")
@@ -94,7 +96,7 @@ public final class InventoryTurnoverSql {
}
private static String ownerCte(Input p) {
return ", owner_days AS (SELECT customer_code,business_date,MIN(owner_complete) complete,"
+"CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed,"
+"SUM(owner_observed) observed,"
+"CASE WHEN COUNT(sample_physical)=COUNT(*) THEN SUM(sample_physical) END sample_physical FROM day_result GROUP BY customer_code,business_date), owner_windows AS (SELECT customer_code"
+windowAggregates(true)+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,"
+"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,"
@@ -107,9 +109,9 @@ public final class InventoryTurnoverSql {
private static String totals(String key,boolean withFocus) {
var sql=new StringBuilder("SELECT ").append(key==null?"COUNT(*) sku_count":key);
for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN COUNT(").append(field).append(")=COUNT(*) THEN COALESCE(SUM(").append(field).append("),0) END ").append(field)
.append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
.append(",CASE WHEN COUNT(*)=0 THEN 0 ELSE SUM(").append(field).append(") END ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown");
sql.append(",MAX(last_outbound) last_outbound,CASE WHEN COUNT(physical)=COUNT(*) THEN SUM(CASE WHEN physical>0 THEN 1 ELSE 0 END) END physical_skus,COALESCE(SUM(unassessable),0) unassessable");
for(String risk:List.of("no_outbound","slow")) sql.append(",SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END) ").append(risk).append("_count,SUM(CASE WHEN ").append(risk).append("=1 THEN physical ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown");
for(String risk:List.of("no_outbound","slow")) sql.append(",COALESCE(SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END),0) ").append(risk).append("_count,COALESCE(SUM(CASE WHEN ").append(risk).append("=1 THEN physical ELSE 0 END),0) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown");
if(withFocus)sql.append(",MAX(CASE WHEN available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END) fast_count,MAX(CASE WHEN frozen>0 THEN 1 ELSE 0 END) frozen_count,MAX(CASE WHEN defective>0 THEN 1 ELSE 0 END) defective_count");
return sql.toString();
}
@@ -150,9 +152,40 @@ public final class InventoryTurnoverSql {
public static String owners(Map<String,Object> args) {
Input p=(Input)args.get("p");return cte(p)+ownerCte(p)
+", ranked_owners AS (SELECT customer_code,physical FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10) "
+"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL";
+"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL ORDER BY kind DESC,physical DESC,customer_code";
}
public static String chartWindows(Map<String,Object> args) {
Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT business_date,MIN(owner_complete) complete,CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed FROM day_result GROUP BY business_date) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days";
Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT d.business_date,"
+"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete,"
+"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed "
+"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days";
}
private static String jsonFields(List<String> fields) {
return "JSON_OBJECT("+String.join(",",fields.stream().map(f->"'"+f+"',"+f).toList())+")";
}
/** One statement shares the materialized stock/day/window relations across all first-page sections. */
public static String report(Map<String,Object> args) {
Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview();
String source=owner?"owner_result":"sku_result";
List<String> fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable"));
for(int n:List.of(7,15,30))for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix);
if(owner) {
fields.add("physical_skus");
for(String field:List.of("physical","available","frozen","defective"))for(String suffix:List.of("known","unknown"))fields.add(field+"_"+suffix);
for(String risk:List.of("no_outbound","slow"))for(String suffix:List.of("count","physical","unknown"))fields.add(risk+"_"+suffix);
} else fields.addAll(List.of("sku_code","product_name","coverage_days","no_outbound","slow"));
String sql=cte(p)+(owner||overview?ownerCte(p):"")
+", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})";
if(overview)sql+=", summary_result AS ("+totals(null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)";
sql+=" SELECT 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_result"
+" UNION ALL SELECT 'TOTAL' section,0 ordinal,JSON_OBJECT('total',COUNT(*)) payload FROM "+source+filter(p,owner);
if(overview) {
List<String> summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown"));
for(String field:List.of("physical","available")){summary.add(field);summary.add(field+"_known");summary.add(field+"_unknown");}
sql+=" UNION ALL SELECT 'OVERVIEW' section,0 ordinal,"+jsonFields(summary)+" payload FROM summary_result"
+" UNION ALL SELECT 'OWNER' section,ordinal,JSON_OBJECT('customer_code',customer_code,'physical',physical) payload FROM top_owners"
+" UNION ALL SELECT 'OWNER_META' section,0 ordinal,JSON_OBJECT('unknown_count',COUNT(*)) payload FROM owner_result WHERE physical IS NULL";
}
return sql+" ORDER BY section,ordinal";
}
}
@@ -0,0 +1,195 @@
openapi: 3.0.3
info:
title: 平台自营库存周转只读查询
version: 1.0.0
description: >-
仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。
仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。
三窗为截至endDate的7/15/30个完整北京时间自然日;当前库存独立固定版本。
每次请求使用REPEATABLE_READ。snapshotContext为服务端重建验证的版本与日代表证据,
不作为授权凭据;账号、仓库、截止日、基准、queryAsOf、版本对、范围摘要、
重建状态及全部30日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。
历史代表须同范围、正式计划采集、已发布且DAILY_SCHEDULED_FIRST_V1合格标记。
上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。
历史销量完整性不依赖当前库存新鲜度;缺样本不补零、不缩短周转分母。
security: [{bearerAuth: []}]
paths:
/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover:
get:
operationId: inventoryTurnover
summary: 单仓SKU或货主三窗口库存周转
description: >-
精确customerCode限定列表与概览;sku、focus及turnoverStatus只筛列表。
按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。
OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。
首屏必须current=1且返回概览;后续携带snapshotContext,includeOverview=false省略概览。
不接受attention、salesWindowDays、版本ID或其他视图参数。
parameters:
- {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
- {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}}
- {name: endDate, in: query, description: '含截止日,默认北京时间昨天,不接受今日或未来', schema: {type: string, format: date}}
- {name: baseWindowDays, in: query, schema: {type: integer, enum: [7, 15, 30], default: 30}}
- {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}}
- {name: customerCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}}
- {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}}
- name: focus
in: query
schema: {type: string, enum: [ALL, NO_OUTBOUND_30D, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL}
- {name: coverageDaysThreshold, in: query, description: 'GT/LE必填,其余可选;无默认业务阈值', schema: {type: integer, minimum: 1}}
- {name: turnoverStatus, in: query, schema: {type: string, enum: [INSUFFICIENT]}}
- name: sortBy
in: query
description: 'OWNER禁止COVERAGE_ASC/DESC,SKU禁止NO_OUTBOUND_PHYSICAL_DESC。使用正式值及未舍入值排序。'
schema: {type: string, default: PHYSICAL_DESC, enum: [PHYSICAL_DESC, AVAILABLE_DESC, OUTBOUND_7_DESC, OUTBOUND_15_DESC, OUTBOUND_30_DESC, COVERAGE_ASC, COVERAGE_DESC, TURNOVER_DESC, NO_OUTBOUND_PHYSICAL_DESC]}
- {name: current, in: query, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}}
- {name: includeOverview, in: query, schema: {type: boolean, default: true}}
- {name: snapshotContext, in: query, description: 首屏返回的完整不可拆分上下文;翻页必填, schema: {type: string, minLength: 1, maxLength: 16384}}
responses:
'200':
description: 统一ApiResponse;参数或上下文业务错误沿现有异常响应,不伪装成空结果
content:
application/json:
schema:
type: object
properties:
code: {type: string}
message: {type: string}
data: {$ref: '#/components/schemas/Response'}
'400': {description: HTTP参数类型或日期格式不合法}
'401': {description: 无可信登录身份}
'403': {description: 非平台、代登录或缺少独立只读权限}
components:
securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
schemas:
Quantity:
type: number
nullable: true
description: PCS件数或对应指标值;未知为null,派生值六位小数,不把未知当0
Reasons:
type: array
items: {type: string}
Total:
type: object
required: [value, knownSubtotal, unknownSkuCount, reasonCodes]
properties:
value: {$ref: '#/components/schemas/Quantity'}
knownSubtotal: {$ref: '#/components/schemas/Quantity'}
unknownSkuCount: {type: integer, minimum: 0}
reasonCodes: {$ref: '#/components/schemas/Reasons'}
Risk:
type: object
description: 命中为已确认小计;只有整个实物及风险判断完整、分母大于0时才提供件数比例。未设慢消化阈值则数量与比例均null。
required: [matchedSkuCount, knownPhysicalQuantity, unknownSkuCount, physicalQuantityRatio]
properties:
matchedSkuCount: {$ref: '#/components/schemas/Quantity'}
knownPhysicalQuantity: {$ref: '#/components/schemas/Quantity'}
unknownSkuCount: {type: integer, minimum: 0}
physicalQuantityRatio: {$ref: '#/components/schemas/Quantity'}
Window:
type: object
required: [days, startDate, endDateExclusive, outboundQuantity, observedQuantity, averageDailyOutboundQuantity, completeDayCount, partialDayCount, unknownDayCount, dataStatus, reasonCodes]
properties:
days: {type: integer, enum: [7, 15, 30]}
startDate: {type: string, format: date}
endDateExclusive: {type: string, format: date}
outboundQuantity: {$ref: '#/components/schemas/Quantity'}
observedQuantity: {$ref: '#/components/schemas/Quantity'}
averageDailyOutboundQuantity: {$ref: '#/components/schemas/Quantity'}
completeDayCount: {type: integer, minimum: 0}
partialDayCount: {type: integer, minimum: 0}
unknownDayCount: {type: integer, minimum: 0}
dataStatus: {type: string, enum: [COMPLETE, PARTIAL, UNKNOWN]}
reasonCodes: {$ref: '#/components/schemas/Reasons'}
Context:
type: object
required: [snapshotContext, sourceAccount, warehouseCode, endDate, baseWindowDays, queryAsOf, queryExpiresAt, analyticsContext, inventoryObservedFrom, inventoryObservedUntil, sampleRuleVersion, dailySamples, missingSampleDates, accumulationStartedOn]
properties:
snapshotContext: {type: string}
sourceAccount: {type: string}
warehouseCode: {type: string}
endDate: {type: string, format: date}
baseWindowDays: {type: integer, enum: [7, 15, 30]}
queryAsOf: {type: string, format: date-time}
queryExpiresAt: {type: string, format: date-time}
analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'}
inventoryObservedFrom: {type: string, format: date-time, nullable: true}
inventoryObservedUntil: {type: string, format: date-time, nullable: true}
sampleRuleVersion: {type: string, enum: [DAILY_SCHEDULED_FIRST_V1]}
dailySamples:
type: array
minItems: 30
maxItems: 30
items:
type: object
required: [date, runId]
properties:
date: {type: string, format: date}
runId: {type: string, nullable: true, description: 内部版本证据,不作为用户可见编号}
missingSampleDates: {type: array, items: {type: string, format: date}}
accumulationStartedOn: {type: string, format: date, nullable: true}
Row:
type: object
required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutbound30d, slowCoverage, unassessableSkuCount]
properties:
customerCode: {type: string}
customerName: {type: string, nullable: true}
sku: {type: string, nullable: true}
productName: {type: string, nullable: true}
physicalQuantity: {$ref: '#/components/schemas/Total'}
availableQuantity: {$ref: '#/components/schemas/Total'}
frozenQuantity: {$ref: '#/components/schemas/Total'}
defectiveQuantity: {$ref: '#/components/schemas/Total'}
physicalSkuCount: {$ref: '#/components/schemas/Quantity'}
windows: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}}
averageDailyOutboundQuantity: {$ref: '#/components/schemas/Quantity'}
inventoryCoverageDays: {$ref: '#/components/schemas/Quantity'}
coverageStatus: {type: string, enum: [VALID, NOT_APPLICABLE, INVENTORY_UNKNOWN_OR_STALE, NO_AVAILABLE_STOCK, UNASSESSABLE, NO_OUTBOUND_IN_WINDOW]}
averagePhysicalQuantity: {$ref: '#/components/schemas/Quantity'}
quantityTurnoverCount: {$ref: '#/components/schemas/Quantity'}
turnoverStatus: {type: string, enum: [VALID, INSUFFICIENT, ZERO_AVERAGE_STOCK, SALES_WINDOW_UNAVAILABLE]}
qualifiedSampleDays: {type: integer, minimum: 0, maximum: 30}
requiredSampleDays: {type: integer, enum: [7, 15, 30]}
missingSampleDates: {type: array, items: {type: string, format: date}}
sampleReasonCodes: {$ref: '#/components/schemas/Reasons'}
lastObservedOutboundDate: {type: string, format: date, nullable: true}
attentionReasons: {$ref: '#/components/schemas/Reasons'}
noOutbound30d: {$ref: '#/components/schemas/Risk'}
slowCoverage: {$ref: '#/components/schemas/Risk'}
unassessableSkuCount: {type: integer, minimum: 0}
Overview:
type: object
nullable: true
required: [physicalQuantity, availableQuantity, noOutboundSkuCount, unassessableSkuCount]
properties:
physicalQuantity: {$ref: '#/components/schemas/Total'}
availableQuantity: {$ref: '#/components/schemas/Total'}
noOutboundSkuCount: {$ref: '#/components/schemas/Total'}
unassessableSkuCount: {$ref: '#/components/schemas/Total'}
Charts:
type: object
nullable: true
required: [ownerPhysicalTop, unknownOwnerCount, averageDaily]
properties:
ownerPhysicalTop:
type: array
maxItems: 10
items:
type: object
required: [customerCode, customerName, physicalQuantity]
properties:
customerCode: {type: string}
customerName: {type: string, nullable: true}
physicalQuantity: {$ref: '#/components/schemas/Quantity'}
unknownOwnerCount: {type: integer, minimum: 0}
averageDaily: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}}
Response:
type: object
required: [context, summary, charts, rows, total]
properties:
context: {$ref: '#/components/schemas/Context'}
summary: {$ref: '#/components/schemas/Overview'}
charts: {$ref: '#/components/schemas/Charts'}
rows: {type: array, maxItems: 100, items: {$ref: '#/components/schemas/Row'}}
total: {type: string, pattern: '^[0-9]+$', description: 服务统一将Long序列化为字符串}