fix: finalize inventory turnover queries and reuse snapshot aggregation
This commit is contained in:
+5
-3
@@ -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);
|
||||
|
||||
+6
-1
@@ -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()));
|
||||
|
||||
+24
-12
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-4
@@ -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);
|
||||
}
|
||||
|
||||
+41
-8
@@ -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序列化为字符串}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.controller;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.security.AuthenticatedUser;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.query.*;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.core.MethodParameter;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.support.WebDataBinderFactory;
|
||||
import org.springframework.web.context.request.NativeWebRequest;
|
||||
import org.springframework.web.method.support.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class PlatformInventoryTurnoverControllerTest {
|
||||
private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));}
|
||||
@Test void enforcesIndependentPlatformReadPermissionAndStrictParameterNames() {
|
||||
var service=mock(InventoryTurnoverQueryService.class);var controller=new PlatformInventoryTurnoverController(service);
|
||||
var customer=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));
|
||||
var writer=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync"));
|
||||
assertThrows(ResponseStatusException.class,()->controller.query(null,null,Map.of()));
|
||||
assertThrows(ResponseStatusException.class,()->controller.query(customer,null,Map.of()));
|
||||
assertThrows(ResponseStatusException.class,()->controller.query(writer,null,Map.of()));
|
||||
assertThrows(BizException.class,()->controller.query(platform(),null,Map.of("attention","ALL")));
|
||||
assertThrows(BizException.class,()->controller.query(platform(),null,Map.of("salesRunId","11")));
|
||||
verifyNoInteractions(service);
|
||||
assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformInventoryTurnoverController.class.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
@Test void httpBindsFrozenTurnoverContractWithoutTouchingCollection() throws Exception {
|
||||
var service=mock(InventoryTurnoverQueryService.class);
|
||||
var identity=new HandlerMethodArgumentResolver(){
|
||||
public boolean supportsParameter(MethodParameter p){return p.getParameterType()==AuthenticatedUser.class;}
|
||||
public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory b){return platform();}
|
||||
};
|
||||
var mvc=MockMvcBuilders.standaloneSetup(new PlatformInventoryTurnoverController(service)).setCustomArgumentResolvers(identity).build();
|
||||
mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover")
|
||||
.param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12")
|
||||
.param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT")
|
||||
.param("coverageDaysThreshold","60").param("current","2").param("size","20")
|
||||
.param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk());
|
||||
var capture=ArgumentCaptor.forClass(InventoryTurnoverRequest.class);verify(service).query(capture.capture());
|
||||
var q=capture.getValue();assertEquals(LocalDate.of(2026,10,12),q.endDate());assertEquals(15,q.days());
|
||||
assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertEquals("fixed-token",q.snapshotContext());
|
||||
}
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
class InventoryTurnoverContextTest {
|
||||
private static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z");
|
||||
private static final LocalDate END=LocalDate.of(2026,10,12);
|
||||
private static final String SH="a".repeat(64),IH="b".repeat(64);
|
||||
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
|
||||
private AnalyticsReadMapper analytics;
|
||||
private InventoryTurnoverReadMapper mapper;
|
||||
private InventoryTurnoverContext contexts;
|
||||
@BeforeEach void setup() {
|
||||
analytics=mock(AnalyticsReadMapper.class);mapper=mock(InventoryTurnoverReadMapper.class);
|
||||
contexts=at(NOW);
|
||||
when(analytics.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L)));
|
||||
for(var run:List.of(run(11,1,SH),run(22,2,IH))) {
|
||||
when(analytics.publishedRun(run.id())).thenReturn(run);
|
||||
when(mapper.versionAt(eq(run.scopeId()),eq(run.scopeHash()),any())).thenReturn(run);
|
||||
}
|
||||
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.SampleRun(END,100L)));
|
||||
when(mapper.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END);
|
||||
}
|
||||
@Test void sameContextFreezesAllThirtyDatesAndMissingSamples() {
|
||||
var first=contexts.resolve(request(null)).response();
|
||||
assertEquals(30,first.dailySamples().size());assertEquals(29,first.missingSampleDates().size());
|
||||
assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate());
|
||||
assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive());
|
||||
assertEquals(first,at(NOW.plusSeconds(60)).resolve(request(first.snapshotContext())).response());
|
||||
verify(mapper,times(2)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1));
|
||||
// A subsequently published sample cannot appear in the old asOf query. If reconstruction
|
||||
// ever differs, reject the context instead of mixing old charts and new rows.
|
||||
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of());
|
||||
assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext())));
|
||||
}
|
||||
@Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception {
|
||||
String token=contexts.resolve(request(null)).response().snapshotContext();
|
||||
var evidence=json.readTree(Base64.getUrlDecoder().decode(token));
|
||||
((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L);
|
||||
String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence));
|
||||
assertThrows(BizException.class,()->contexts.resolve(request(tampered)));
|
||||
var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token);
|
||||
assertThrows(BizException.class,()->contexts.resolve(another));
|
||||
assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).resolve(request(token)));
|
||||
assertThrows(BizException.class,()->at(NOW.minusSeconds(1)).resolve(request(token)));
|
||||
assertThrows(BizException.class,()->contexts.resolve(request("not-a-token")));
|
||||
}
|
||||
@Test void absentVersionsStayAbsentAndUnconfiguredWarehouseIsRejected() {
|
||||
when(mapper.versionAt(anyLong(),anyString(),any())).thenReturn(null);
|
||||
var c=contexts.resolve(request(null)).response();
|
||||
assertEquals(0L,c.analyticsContext().salesRunId());assertEquals(0L,c.analyticsContext().inventoryRunId());
|
||||
assertEquals("UNKNOWN",c.analyticsContext().inventoryStatus());
|
||||
assertEquals(c,contexts.resolve(request(c.snapshotContext())).response());
|
||||
when(analytics.scopes("demo","W")).thenReturn(List.of());
|
||||
assertThrows(BizException.class,()->contexts.resolve(request(null)));
|
||||
}
|
||||
@Test void rejectsDuplicateRepresentativesAndScopeDrift() {
|
||||
when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(
|
||||
new InventoryTurnoverReadMapper.SampleRun(END,100L),new InventoryTurnoverReadMapper.SampleRun(END,101L)));
|
||||
assertThrows(BizException.class,()->contexts.resolve(request(null)));
|
||||
when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH));
|
||||
assertThrows(BizException.class,()->contexts.resolve(request(null)));
|
||||
}
|
||||
@Test void localHistoryCoverageSurvivesAgeButRebuildDoesNotAuthorizeFormalSales() {
|
||||
var old=at(NOW.plusSeconds(200000)).resolve(request(null));
|
||||
assertEquals("STALE",old.response().analyticsContext().inventoryStatus());
|
||||
assertTrue(InventoryTurnoverSql.input(request(null),old).days().stream().allMatch(d->d.coverage()==1));
|
||||
when(analytics.applyBarriers("demo","W")).thenReturn(1L);
|
||||
var rebuilding=contexts.resolve(request(null));
|
||||
assertTrue(InventoryTurnoverSql.input(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==0));
|
||||
}
|
||||
@Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() {
|
||||
var service=new InventoryTurnoverQueryService(contexts,mapper,json);
|
||||
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"),
|
||||
Map.of("section","OVERVIEW","payload","{\"sku_count\":0,\"physical\":0,\"available\":0,\"no_outbound_count\":0,\"unassessable\":0}")));
|
||||
var first=service.query(request(null));assertEquals(0,first.total());assertNotNull(first.summary());assertNotNull(first.charts());
|
||||
verify(mapper).report(any());verify(mapper).chartWindows(any());
|
||||
verify(mapper,never()).page(any());verify(mapper,never()).count(any());verify(mapper,never()).overview(any());verify(mapper,never()).owners(any());
|
||||
clearInvocations(mapper);
|
||||
when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}")));
|
||||
var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext());
|
||||
var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any());
|
||||
}
|
||||
private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC));}
|
||||
static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token);}
|
||||
private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);}
|
||||
private static Scope scope(long id,String family,String hash,long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,null,null,7);}
|
||||
private static Run run(long id,long scope,String hash){
|
||||
return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash,
|
||||
"{\"businessTimezone\":\"Asia/Shanghai\",\"sourceTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600}","PUBLISHED","COMPLETE",
|
||||
END.minusDays(29).atStartOfDay().minusHours(8),END.plusDays(1).atStartOfDay().minusHours(8),END.minusDays(29),END.plusDays(1),
|
||||
utc(NOW.minusSeconds(3660)),utc(NOW.minusSeconds(3600)),utc(NOW.minusSeconds(3500)),1L,null,null,null,"BUSINESS",true);
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.time.LocalDate;
|
||||
import java.util.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class InventoryTurnoverRequestTest {
|
||||
private final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
|
||||
private static final LocalDate TODAY=LocalDate.of(2026,10,13);
|
||||
private InventoryTurnoverRequest request(Map<String,Object> override) {
|
||||
Map<String,Object> values=new HashMap<>();values.put("sourceAccount","demo");values.put("warehouseCode","W");values.putAll(override);
|
||||
return json.convertValue(values,InventoryTurnoverRequest.class);
|
||||
}
|
||||
@Test void defaultsToYesterdayThirtyDaysAndFirstPageOverview() {
|
||||
var q=request(Map.of("includeOverview",false));q.validate(TODAY);
|
||||
assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview());
|
||||
}
|
||||
@Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() {
|
||||
for(var invalid:List.of(Map.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",10),
|
||||
Map.<String,Object>of("groupBy","OWNER","sku","S"),Map.<String,Object>of("groupBy","OWNER","sortBy","COVERAGE_DESC"),
|
||||
Map.<String,Object>of("sortBy","NO_OUTBOUND_PHYSICAL_DESC"),Map.<String,Object>of("current",2),
|
||||
Map.<String,Object>of("size",101),Map.<String,Object>of("coverageDaysThreshold",0),Map.<String,Object>of("focus","COVERAGE_GT"),
|
||||
Map.<String,Object>of("snapshotContext"," "),Map.<String,Object>of("turnoverStatus","VALID"))) {
|
||||
assertThrows(BizException.class,()->request(invalid).validate(TODAY),invalid.toString());
|
||||
}
|
||||
request(Map.of("groupBy","OWNER","sortBy","NO_OUTBOUND_PHYSICAL_DESC","current",2,"snapshotContext","evidence")).validate(TODAY);
|
||||
}
|
||||
}
|
||||
+63
@@ -74,6 +74,69 @@ class InventoryTurnoverSqlTest {
|
||||
var row=query("page",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),7,0,true)).get(0);
|
||||
decimal("0",row.get("coverage_days"));decimal("1",row.get("unassessable"));
|
||||
}
|
||||
@Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception {
|
||||
seed("A","SKU",30);
|
||||
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100");
|
||||
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
|
||||
var row=query("page",p).get(0);
|
||||
decimal("300",row.get("w30_outbound"));decimal("60",row.get("coverage_days"));
|
||||
decimal("29",row.get("sample_count"));assertNull(row.get("turnover"));
|
||||
execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=11");
|
||||
assertNull(query("page",p).get(0).get("w30_outbound"));
|
||||
var owner=query("page",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0);
|
||||
decimal("300",owner.get("w30_outbound"));
|
||||
}
|
||||
@Test void zeroSalesAndZeroAverageHaveDifferentTurnoverResults() throws Exception {
|
||||
seed("A","SKU",30);
|
||||
execute("DELETE FROM ll_selfop_sku_daily_sales WHERE warehouse_code='W'");
|
||||
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true);
|
||||
var row=query("page",p).get(0);
|
||||
decimal("0",row.get("turnover"));decimal("1",row.get("no_outbound"));assertNull(row.get("coverage_days"));
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100");
|
||||
row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover"));
|
||||
}
|
||||
@Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception {
|
||||
var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20);
|
||||
var covered=query("chartWindows",input(q,30,0,true)).get(0);
|
||||
decimal("0",covered.get("w30_outbound"));decimal("30",covered.get("w30_complete"));
|
||||
var absent=query("chartWindows",input(q,0,0,false)).get(0);
|
||||
assertNull(absent.get("w30_outbound"));assertNull(absent.get("w30_observed"));
|
||||
decimal("0",query("overview",input(q,30,0,true)).get(0).get("no_outbound_count"));
|
||||
}
|
||||
@Test void fullScopeFilterPrecedesPaginationAndKeepsSoldOutHistoricalCandidates() throws Exception {
|
||||
seed("A","LOW",30);seed("A","HIGH",30);seed("B","SOLD",30);
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=3000,physical_quantity=3000 WHERE sku_code='HIGH' AND snapshot_run_id=22 AND product_quality=0");
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=0,physical_quantity=0 WHERE sku_code='SOLD' AND snapshot_run_id=22");
|
||||
var p=input(request("SKU","COVERAGE_GT","COVERAGE_DESC",null,100,1,1),30,30,true);
|
||||
var selected=query("page",p);assertEquals(1,selected.size());assertEquals("HIGH",selected.get(0).get("sku_code"));
|
||||
decimal("1",query("count",p).get(0).values().iterator().next());
|
||||
decimal("3",query("overview",p).get(0).get("sku_count"));
|
||||
var owners=query("page",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true));
|
||||
var sold=owners.stream().filter(r->"B".equals(r.get("customer_code"))).findFirst().orElseThrow();
|
||||
decimal("0",sold.get("physical"));decimal("300",sold.get("w30_outbound"));decimal("0.6",sold.get("turnover"));
|
||||
}
|
||||
@Test void independentBaseWindowsUseMatchingHistoricalAverages() throws Exception {
|
||||
seed("A","SKU",30);
|
||||
for(int days:List.of(7,15)) {
|
||||
var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
|
||||
var row=query("page",input(q,30,30,true)).get(0);
|
||||
decimal("70",row.get("w7_outbound"));decimal("150",row.get("w15_outbound"));decimal("300",row.get("w30_outbound"));
|
||||
decimal("60",row.get("coverage_days"));decimal(days==7?"0.14":"0.3",row.get("turnover"));decimal(String.valueOf(days),row.get("sample_count"));
|
||||
}
|
||||
}
|
||||
@Test void sharedReportMatchesStandaloneValuesAndKeepsPageOrder() throws Exception {
|
||||
seed("A","SKU",30);seed("B","SKU",30);
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=900 WHERE customer_code='B' AND snapshot_run_id=22 AND product_quality=0");
|
||||
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,30,true);
|
||||
var report=query("report",p);var json=new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
var page=report.stream().filter(r->"ROW".equals(r.get("section"))).toList();assertEquals(1,page.size());
|
||||
Object raw=page.get(0).get("payload");var row=raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString());
|
||||
assertEquals("B",row.path("customer_code").asText());decimal("300",row.path("w30_outbound").decimalValue());decimal("0.6",row.path("turnover").decimalValue());
|
||||
var total=report.stream().filter(r->"TOTAL".equals(r.get("section"))).findFirst().orElseThrow().get("payload");
|
||||
var count=total instanceof byte[] bytes?json.readTree(bytes):json.readTree(total.toString());assertEquals(2,count.path("total").asInt());
|
||||
assertEquals(2,report.stream().filter(r->"OWNER".equals(r.get("section"))).count());
|
||||
assertEquals(1,report.stream().filter(r->"OVERVIEW".equals(r.get("section"))).count());
|
||||
}
|
||||
private void seed(String owner,String sku,int samples) throws Exception {
|
||||
profile(11,owner,sku);profile(22,owner,sku);stock(22,owner,sku,0,600,600);stock(22,owner,sku,1,10,0);
|
||||
for(int i=0;i<30;i++) {
|
||||
|
||||
Reference in New Issue
Block a user