feat(selfop): calculate inventory and outbound volume by owner and SKU
This commit is contained in:
+18
-3
@@ -36,6 +36,19 @@ public final class InventoryTurnoverPolicy {
|
||||
var value=decimal(row,field);int unknown=owner?count(row,field+"_unknown"):value==null?1:0;
|
||||
return new Total(value,owner?decimal(row,field+"_known"):value,unknown,unknown==0?List.of():List.of("INVENTORY_UNKNOWN_OR_STALE"));
|
||||
}
|
||||
static Volume volume(Map<String,Object> row,String field) {
|
||||
var reasons=new ArrayList<String>();
|
||||
int missing=count(row,field+"_missing_dimension_skus"),unknown=count(row,field+"_unknown_quantity_skus");
|
||||
var declared=decimal(row,field+"_estimated_quantity");
|
||||
if(missing>0)reasons.add("DIMENSIONS_MISSING_OR_INVALID");
|
||||
if(unknown>0||decimal(row,field+"_volume")==null&&decimal(row,field+"_volume_known")==null)reasons.add("QUANTITY_UNAVAILABLE");
|
||||
if(declared!=null&&declared.signum()>0)reasons.add("DECLARED_DIMENSIONS_ESTIMATE");
|
||||
return new Volume(decimal(row,field+"_volume"),decimal(row,field+"_volume_known"),decimal(row,field+"_missing_dimension_quantity"),
|
||||
missing,unknown,declared,List.copyOf(reasons));
|
||||
}
|
||||
static List<VolumeWindow> volumeWindows(Map<String,Object> row,LocalDate until,List<Integer> sizes) {
|
||||
return sizes.stream().map(n->new VolumeWindow(n,until.minusDays(n),until,volume(row,"w"+n+"_outbound"))).toList();
|
||||
}
|
||||
private static Risk risk(Map<String,Object> row,String name,Total physical,boolean owner,boolean enabled) {
|
||||
if(!enabled)return new Risk(null,null,0,null);
|
||||
int unknown=owner?count(row,name+"_unknown"):row.get(name)==null?1:0;
|
||||
@@ -69,17 +82,19 @@ public final class InventoryTurnoverPolicy {
|
||||
total(r,"frozen",owner),total(r,"defective",owner),total(r,"transit",owner),owner?decimal(r,"physical_skus"):physical.value()==null?null:physical.value().signum()>0?BigDecimal.ONE:BigDecimal.ZERO,
|
||||
windows,base.averageDailyOutboundQuantity(),coverage,coverageStatus,average,turnover,sampleStatus,sampleCount,q.days(),List.copyOf(missing),
|
||||
"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"));
|
||||
risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable"),
|
||||
owner?null:decimal(r,"unit_volume"),owner?null:text(r,"dimension_source"),volume(r,"physical"),volumeWindows(r,context.endDate().plusDays(1),q.windows()));
|
||||
}
|
||||
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")));
|
||||
new Total(null,null,0,List.of("SALES_OR_STOCK_UNASSESSABLE")),volume(Map.of(),"physical"),volumeWindows(r,context.endDate().plusDays(1),volumeSizes(context)));
|
||||
}
|
||||
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()));
|
||||
new Total(decimal(r,"unassessable"),decimal(r,"unassessable"),0,List.of()),volume(r,"physical"),volumeWindows(r,context.endDate().plusDays(1),volumeSizes(context)));
|
||||
}
|
||||
private static List<Integer> volumeSizes(Context context){return java.util.stream.Stream.of(7,15,30,context.baseWindowDays()).distinct().sorted().toList();}
|
||||
}
|
||||
|
||||
+7
-2
@@ -11,6 +11,9 @@ public final class InventoryTurnoverResponses {
|
||||
BigDecimal observedQuantity, BigDecimal averageDailyOutboundQuantity, int completeDayCount,
|
||||
int partialDayCount, int unknownDayCount, String dataStatus, List<String> reasonCodes) {}
|
||||
public record Total(BigDecimal value, BigDecimal knownSubtotal, int unknownSkuCount, List<String> reasonCodes) {}
|
||||
public record Volume(BigDecimal value,BigDecimal knownSubtotal,BigDecimal missingDimensionQuantity,
|
||||
int missingDimensionSkuCount,int unknownQuantitySkuCount,BigDecimal declaredDimensionQuantity,List<String> reasonCodes) {}
|
||||
public record VolumeWindow(int days,LocalDate startDate,LocalDate endDateExclusive,Volume volume) {}
|
||||
public record Risk(BigDecimal matchedSkuCount, BigDecimal knownPhysicalQuantity, int unknownSkuCount, BigDecimal physicalQuantityRatio) {}
|
||||
public record Row(String customerCode,String customerName,String sku,String productName,
|
||||
Total physicalQuantity,Total availableQuantity,Total frozenQuantity,Total defectiveQuantity,Total transitQuantity,
|
||||
@@ -18,13 +21,15 @@ public final class InventoryTurnoverResponses {
|
||||
BigDecimal inventoryCoverageDays,String coverageStatus,BigDecimal averagePhysicalQuantity,
|
||||
BigDecimal quantityTurnoverCount,String turnoverStatus,int qualifiedSampleDays,int requiredSampleDays,
|
||||
List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate,
|
||||
List<String> attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount) {}
|
||||
List<String> attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount,
|
||||
BigDecimal unitVolumeM3,String dimensionSource,Volume inventoryVolumeM3,List<VolumeWindow> outboundVolumesM3) {}
|
||||
public record Sample(LocalDate date,Long runId) {}
|
||||
public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate,
|
||||
int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext,
|
||||
Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion,
|
||||
List<Sample> dailySamples,List<LocalDate> missingSampleDates,LocalDate accumulationStartedOn) {}
|
||||
public record Overview(Total physicalQuantity,Total availableQuantity,Total noOutboundSkuCount,Total unassessableSkuCount) {}
|
||||
public record Overview(Total physicalQuantity,Total availableQuantity,Total noOutboundSkuCount,Total unassessableSkuCount,
|
||||
Volume inventoryVolumeM3,List<VolumeWindow> outboundVolumesM3) {}
|
||||
public record OwnerPhysical(String customerCode,String customerName,BigDecimal physicalQuantity) {}
|
||||
public record Charts(List<OwnerPhysical> ownerPhysicalTop,int unknownOwnerCount,List<Window> averageDaily) {}
|
||||
public record Response(Context context,Overview summary,Charts charts,List<Row> rows,long total) {}
|
||||
|
||||
+15
-12
@@ -8,7 +8,7 @@ public final class InventoryTurnoverSql {
|
||||
private InventoryTurnoverSql() {}
|
||||
public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {}
|
||||
public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh,
|
||||
boolean salesKnown,List<Day> days) {
|
||||
boolean salesKnown,List<Day> days,LocalDateTime queryAsOf) {
|
||||
public LocalDate until(){return days.get(days.size()-1).date().plusDays(1);}
|
||||
public LocalDate from(){return days.get(0).date();}
|
||||
public LocalDate fromBase(){return baseFrom();}
|
||||
@@ -33,7 +33,7 @@ public final class InventoryTurnoverSql {
|
||||
var days=frozen.response().dailySamples().stream().map(s->new Day(s.date(),s.date().atStartOfDay().minusHours(8),
|
||||
!trusted?0:SalesQueryCoverage.complete(coverage,s.date(),s.date().plusDays(1))?1:SalesQueryCoverage.at(coverage,s.date())==null?0:2,s.runId())).toList();
|
||||
return new Input(q,frozen.response().analyticsContext().salesRunId(),frozen.response().analyticsContext().inventoryRunId(),
|
||||
"VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days);
|
||||
"VALID".equals(frozen.response().analyticsContext().inventoryStatus()),frozen.snapshot().sourceKnown(),days,LocalDateTime.ofInstant(frozen.response().queryAsOf(),ZoneOffset.UTC));
|
||||
}
|
||||
private static String windowStart(int n){return "#{p.from"+(java.util.Set.of(7,15,30).contains(n)?Integer.toString(n):"Base")+"}";}
|
||||
private static String scope(String alias) {
|
||||
@@ -75,19 +75,19 @@ public final class InventoryTurnoverSql {
|
||||
.append("COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.salesRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END END),MIN(CASE WHEN f.quantity_unit='PCS' THEN 1 ELSE 0 END)) unit_valid,")
|
||||
.append("MIN(CASE WHEN f.evidence_run_id IS NOT NULL AND f.evidence_type IN ('VALID_OUTBOUND','INVENTORY_OBSERVATION') THEN f.earliest_evidence_at_utc END) evidence ")
|
||||
.append("FROM candidates c LEFT JOIN fixed_profiles f ON f.customer_code=c.customer_code AND f.sku_code=c.sku_code GROUP BY c.customer_code,c.sku_code)")
|
||||
.append(skuWindows(p)).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ")
|
||||
.append(InventoryVolumeSql.products()).append(skuWindows(p)).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ")
|
||||
.append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date<CAST(#{p.until} AS DATE) AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)");
|
||||
sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid");
|
||||
sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid,pv.unit_volume,COALESCE(pv.dimension_source,'MISSING') dimension_source");
|
||||
for(int n:p.query().windows()) for(String suffix:List.of("outbound","observed","complete","partial")) sql.append(",w.w").append(n).append('_').append(suffix);
|
||||
sql.append(",w.sample_count,w.sample_total,w.average_physical,w.sample_mask,ls.last_outbound");
|
||||
for(String field:List.of("physical","available","frozen","defective","transit")) sql.append(",CASE WHEN #{p.stockFresh}=1 AND ip.quantity_unit='PCS' THEN st.").append(field).append(" END ").append(field);
|
||||
sql.append(" FROM profiles c JOIN sku_windows w ON w.customer_code=c.customer_code AND w.sku_code=c.sku_code LEFT JOIN last_sales ls ON ls.customer_code=c.customer_code AND ls.sku_code=c.sku_code LEFT JOIN stocks st ON st.snapshot_run_id=#{p.inventoryRun} AND st.customer_code=c.customer_code AND st.sku_code=c.sku_code ")
|
||||
.append("LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,")
|
||||
.append("LEFT JOIN product_volumes pv ON pv.customer_code=c.customer_code AND pv.sku_code=c.sku_code LEFT JOIN fixed_profiles ip ON ip.profile_run_id=#{p.inventoryRun} AND ip.customer_code=c.customer_code AND ip.sku_code=c.sku_code), sku_calculated AS (SELECT b.*,")
|
||||
.append("CASE WHEN available=0 THEN 0 WHEN available>0 AND w").append(p.query.days()).append("_outbound>0 THEN available*#{p.query.days}/w").append(p.query.days()).append("_outbound END coverage_days,")
|
||||
.append("CASE WHEN sample_total>0 AND evidence<=#{p.baseFromUtc} THEN w").append(p.query.days()).append("_outbound*#{p.query.days}/sample_total END turnover,")
|
||||
.append("CASE WHEN physical=0 THEN 0 WHEN physical>0 AND w"+p.query.days()+"_outbound IS NOT NULL THEN CASE WHEN w"+p.query.days()+"_outbound=0 THEN 1 ELSE 0 END END no_outbound,")
|
||||
.append("CASE WHEN physical IS NULL OR available IS NULL OR w").append(p.query.days()).append("_outbound IS NULL THEN 1 ELSE 0 END unassessable FROM sku_base b), sku_result AS (")
|
||||
.append("SELECT b.*,CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w")
|
||||
.append("SELECT b.*").append(InventoryVolumeSql.sku(p)).append(",CASE WHEN #{p.query.coverageDaysThreshold,jdbcType=INTEGER} IS NULL THEN NULL WHEN physical=0 OR available=0 THEN 0 WHEN coverage_days IS NOT NULL AND available>0 THEN CASE WHEN coverage_days>#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END WHEN available>0 AND w")
|
||||
.append(p.query.days()).append("_outbound=0 AND unassessable=0 THEN 0 END slow FROM sku_calculated b) ");
|
||||
return sql.toString();
|
||||
}
|
||||
@@ -184,17 +184,18 @@ public final class InventoryTurnoverSql {
|
||||
+"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) END sample_total,"
|
||||
+"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,"
|
||||
+sampleMask()+" sample_mask FROM owner_days GROUP BY customer_code), owner_windows AS (SELECT w.*"+formalWindows(p)+" FROM owner_window_counts w), owner_totals AS ("
|
||||
+totals("customer_code",true)+" FROM sku_result GROUP BY customer_code), owner_result AS (SELECT t.*,w.sample_count,w.average_physical,w.sample_mask,"
|
||||
+totals(p,"customer_code",true)+" FROM sku_result GROUP BY customer_code), owner_result AS (SELECT t.*,w.sample_count,w.average_physical,w.sample_mask,"
|
||||
+"CASE WHEN w.sample_total>0 THEN w.w"+p.query.days()+"_outbound*#{p.query.days}/w.sample_total END turnover,"
|
||||
+String.join(",",p.query().windows().stream().flatMap(n->List.of("outbound","observed","complete","partial").stream().map(s->"w.w"+n+"_"+s)).toList())
|
||||
+" FROM owner_totals t JOIN owner_windows w ON w.customer_code=t.customer_code) ";
|
||||
}
|
||||
private static String totals(String key,boolean withFocus) {
|
||||
private static String totals(Input p,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","transit")) sql.append(",CASE WHEN COUNT(").append(field).append(")=COUNT(*) THEN COALESCE(SUM(").append(field).append("),0) END ").append(field)
|
||||
.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(",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");
|
||||
sql.append(InventoryVolumeSql.totals(p));
|
||||
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();
|
||||
}
|
||||
@@ -241,7 +242,7 @@ public final class InventoryTurnoverSql {
|
||||
return cte(p)+(owner?ownerCte(p):"")+"SELECT COUNT(*) FROM "+(owner?"owner_result":"sku_result")+filter(p,owner);
|
||||
}
|
||||
public static String overview(Map<String,Object> args) {
|
||||
Input p=(Input)args.get("p");return cte(p)+totals(null,false)+" FROM sku_result";
|
||||
Input p=(Input)args.get("p");return cte(p)+totals(p,null,false)+" FROM sku_result";
|
||||
}
|
||||
public static String owners(Map<String,Object> args) {
|
||||
Input p=(Input)args.get("p");return cte(p)+ownerCte(p)
|
||||
@@ -263,22 +264,24 @@ public final class InventoryTurnoverSql {
|
||||
String source=owner?"owner_result":"sku_result";
|
||||
List<String> fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","transit","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable"));
|
||||
for(int n:p.query().windows())for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix);
|
||||
fields.addAll(InventoryVolumeSql.fields(p));
|
||||
if(owner) {
|
||||
fields.add("physical_skus");
|
||||
for(String field:List.of("physical","available","frozen","defective","transit"))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"));
|
||||
} else fields.addAll(List.of("sku_code","product_name","coverage_days","no_outbound","slow","unit_volume","dimension_source"));
|
||||
// The physical-stock chart needs owner totals, not owner sales/sample windows.
|
||||
String sql=cte(p)+(owner?ownerCte(p):overview?", owner_totals AS ("+totals("customer_code",true)+" FROM sku_result GROUP BY customer_code) ":"")
|
||||
String sql=cte(p)+(owner?ownerCte(p):overview?", owner_totals AS ("+totals(p,"customer_code",true)+" FROM sku_result GROUP BY customer_code) ":"")
|
||||
+", 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})";
|
||||
sql+=pageMissingDates();
|
||||
if(overview)sql+=", summary_result AS ("+totals(null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)";
|
||||
if(overview)sql+=", summary_result AS ("+totals(p,null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_totals WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)";
|
||||
// Statement-local only: share intermediate relations across report branches;
|
||||
// do not change connection/global settings or any result/filter semantics.
|
||||
sql+=" SELECT /*+ SET_VAR(group_concat_max_len=16384) SET_VAR(optimizer_switch='derived_merge=off') SET_VAR(optimizer_search_depth=4) */ 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_missing"
|
||||
+" UNION ALL SELECT 'TOTAL' section,0 ordinal,JSON_OBJECT('total',COUNT(*)) payload FROM "+source+filter(p,owner);
|
||||
if(overview) {
|
||||
List<String> summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown"));
|
||||
summary.addAll(InventoryVolumeSql.fields(p));
|
||||
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"
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.lastleg.selfoperated.analytics.query;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/** Volumes use a complete dimension triplet from the master-data version visible at queryAsOf. */
|
||||
final class InventoryVolumeSql {
|
||||
private InventoryVolumeSql() {}
|
||||
private static String cube(String prefix) {
|
||||
return "CASE WHEN " + prefix + "_length>0 AND " + prefix + "_width>0 AND " + prefix + "_height>0 THEN "
|
||||
+ prefix + "_length*" + prefix + "_width*" + prefix + "_height*CASE LOWER(TRIM(" + prefix + "_size_unit))"
|
||||
+ " WHEN 'mm' THEN 0.000000001 WHEN 'cm' THEN 0.000001 WHEN 'm' THEN 1"
|
||||
+ " WHEN 'in' THEN 0.000016387064 WHEN 'ft' THEN 0.028316846592 END END";
|
||||
}
|
||||
static String products() {
|
||||
return ", volume_master AS (SELECT r.id FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id"
|
||||
+ " WHERE r.source_account=#{p.query.sourceAccount} AND t.source_account=#{p.query.sourceAccount}"
|
||||
+ " AND t.task_type='MASTER_DATA' AND t.task_role='ACCOUNT_DISCOVERY'"
|
||||
+ " AND r.execution_purpose='BUSINESS' AND t.execution_purpose='BUSINESS'"
|
||||
+ " AND r.status='PUBLISHED' AND r.coverage_status='COMPLETE' AND r.published_at_utc<=#{p.queryAsOf}"
|
||||
+ " ORDER BY r.published_at_utc DESC,r.id DESC LIMIT 1), product_cubes AS ("
|
||||
+ "SELECT customer_code,sku_code," + cube("measured") + " measured_cube," + cube("declared") + " declared_cube"
|
||||
+ " FROM ll_selfop_product WHERE source_account=#{p.query.sourceAccount} AND run_id=(SELECT id FROM volume_master)),"
|
||||
+ " product_volumes AS (SELECT customer_code,sku_code,COALESCE(measured_cube,declared_cube) unit_volume,"
|
||||
+ "CASE WHEN measured_cube IS NOT NULL THEN 'WAREHOUSE_MEASURED' WHEN declared_cube IS NOT NULL THEN 'PRODUCT_DECLARED' ELSE 'MISSING' END dimension_source FROM product_cubes)";
|
||||
}
|
||||
static List<String> quantities(InventoryTurnoverSql.Input p) {
|
||||
var fields=new ArrayList<String>();fields.add("physical");
|
||||
p.query().windows().forEach(n->fields.add("w"+n+"_outbound"));return fields;
|
||||
}
|
||||
static final List<String> SUFFIXES=List.of("volume","volume_known","missing_dimension_quantity","missing_dimension_skus","unknown_quantity_skus","estimated_quantity");
|
||||
static List<String> fields(InventoryTurnoverSql.Input p) {
|
||||
return quantities(p).stream().flatMap(q->SUFFIXES.stream().map(s->q+"_"+s)).toList();
|
||||
}
|
||||
static String sku(InventoryTurnoverSql.Input p) {
|
||||
var sql=new StringBuilder();
|
||||
for(var q:quantities(p)) {
|
||||
String volume="CASE WHEN "+q+"=0 THEN 0 WHEN "+q+">0 THEN "+q+"*unit_volume END";
|
||||
sql.append(',').append(volume).append(' ').append(q).append("_volume")
|
||||
.append(',').append(volume).append(' ').append(q).append("_volume_known")
|
||||
.append(",CASE WHEN ").append(q).append(">0 AND unit_volume IS NULL THEN ").append(q).append(" ELSE 0 END ").append(q).append("_missing_dimension_quantity")
|
||||
.append(",CASE WHEN ").append(q).append(">0 AND unit_volume IS NULL THEN 1 ELSE 0 END ").append(q).append("_missing_dimension_skus")
|
||||
.append(",CASE WHEN ").append(q).append(" IS NULL THEN 1 ELSE 0 END ").append(q).append("_unknown_quantity_skus")
|
||||
.append(",CASE WHEN ").append(q).append(">0 AND dimension_source='PRODUCT_DECLARED' THEN ").append(q).append(" ELSE 0 END ").append(q).append("_estimated_quantity");
|
||||
}
|
||||
return sql.toString();
|
||||
}
|
||||
static String totals(InventoryTurnoverSql.Input p) {
|
||||
var sql=new StringBuilder();
|
||||
for(var q:quantities(p)) {
|
||||
String emptyKnown;
|
||||
if(q.equals("physical"))emptyKnown="#{p.stockFresh}=1";
|
||||
else {
|
||||
int n=Integer.parseInt(q.substring(1,q.indexOf('_')));
|
||||
String from="#{p.from"+(java.util.Set.of(7,15,30).contains(n)?Integer.toString(n):"Base")+"}";
|
||||
emptyKnown="#{p.salesKnown}=1 AND (SELECT COUNT(*) FROM calendar WHERE business_date>=CAST("+from+" AS DATE) AND coverage=1)="+n;
|
||||
}
|
||||
sql.append(",CASE WHEN COUNT(*)=0 THEN CASE WHEN ").append(emptyKnown).append(" THEN 0 END WHEN COUNT(").append(q).append("_volume)=COUNT(*) THEN SUM(").append(q).append("_volume) END ").append(q).append("_volume")
|
||||
.append(",CASE WHEN COUNT(*)=0 THEN CASE WHEN ").append(emptyKnown).append(" THEN 0 END ELSE SUM(").append(q).append("_volume) END ").append(q).append("_volume_known");
|
||||
for(var suffix:SUFFIXES.subList(2,SUFFIXES.size()))sql.append(",COALESCE(SUM(").append(q).append('_').append(suffix).append("),0) ").append(q).append('_').append(suffix);
|
||||
}
|
||||
return sql.toString();
|
||||
}
|
||||
}
|
||||
+32
-2
@@ -89,6 +89,30 @@ components:
|
||||
knownPhysicalQuantity: {$ref: '#/components/schemas/Quantity'}
|
||||
unknownSkuCount: {type: integer, minimum: 0}
|
||||
physicalQuantityRatio: {$ref: '#/components/schemas/Quantity'}
|
||||
Volume:
|
||||
type: object
|
||||
description: >-
|
||||
m³,按SKU先计算再汇总,不对分页行求和。优先完整有效的仓库实测尺寸,否则完整商品申报尺寸;不混搭长宽高。
|
||||
支持mm/cm/m/in/ft,未知单位及非正尺寸无效。商品资料固定为queryAsOf之前最近完整已发布批次,按账号+货主+SKU关联。
|
||||
库存按全品质实物件数,不计在途,不代表库位占用;历史出库按当前商品尺寸估算,不代表历史包裹实测体积。
|
||||
数量已知为0可得0,无需尺寸;数量未知或正数量缺有效尺寸时正式值null,已知小计单独返回。
|
||||
required: [value, knownSubtotal, missingDimensionQuantity, missingDimensionSkuCount, unknownQuantitySkuCount, declaredDimensionQuantity, reasonCodes]
|
||||
properties:
|
||||
value: {type: number, nullable: true, description: 全部数量和所需尺寸可确认时的体积,m³}
|
||||
knownSubtotal: {type: number, nullable: true, description: 可计算部分的体积小计,m³,不冒充总量}
|
||||
missingDimensionQuantity: {$ref: '#/components/schemas/Quantity'}
|
||||
missingDimensionSkuCount: {type: integer, minimum: 0}
|
||||
unknownQuantitySkuCount: {type: integer, minimum: 0}
|
||||
declaredDimensionQuantity: {allOf: [{$ref: '#/components/schemas/Quantity'}], description: 使用商品申报尺寸估算的件数}
|
||||
reasonCodes: {type: array, items: {type: string, enum: [DIMENSIONS_MISSING_OR_INVALID, QUANTITY_UNAVAILABLE, DECLARED_DIMENSIONS_ESTIMATE]}}
|
||||
VolumeWindow:
|
||||
type: object
|
||||
required: [days, startDate, endDateExclusive, volume]
|
||||
properties:
|
||||
days: {type: integer, minimum: 1, maximum: 366}
|
||||
startDate: {type: string, format: date}
|
||||
endDateExclusive: {type: string, format: date}
|
||||
volume: {$ref: '#/components/schemas/Volume'}
|
||||
Window:
|
||||
type: object
|
||||
required: [days, startDate, endDateExclusive, outboundQuantity, observedQuantity, averageDailyOutboundQuantity, completeDayCount, partialDayCount, unknownDayCount, dataStatus, reasonCodes]
|
||||
@@ -133,7 +157,7 @@ components:
|
||||
accumulationStartedOn: {type: string, format: date, nullable: true}
|
||||
Row:
|
||||
type: object
|
||||
required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, transitQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutboundInWindow, slowCoverage, unassessableSkuCount]
|
||||
required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, transitQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutboundInWindow, slowCoverage, unassessableSkuCount, unitVolumeM3, dimensionSource, inventoryVolumeM3, outboundVolumesM3]
|
||||
properties:
|
||||
customerCode: {type: string}
|
||||
customerName: {type: string, nullable: true}
|
||||
@@ -163,15 +187,21 @@ components:
|
||||
noOutboundInWindow: {$ref: '#/components/schemas/Risk'}
|
||||
slowCoverage: {$ref: '#/components/schemas/Risk'}
|
||||
unassessableSkuCount: {type: integer, minimum: 0}
|
||||
unitVolumeM3: {type: number, nullable: true, description: SKU单件体积m³,货主汇总不提供}
|
||||
dimensionSource: {type: string, nullable: true, enum: [WAREHOUSE_MEASURED, PRODUCT_DECLARED, MISSING], description: SKU尺寸来源,货主汇总不提供}
|
||||
inventoryVolumeM3: {$ref: '#/components/schemas/Volume'}
|
||||
outboundVolumesM3: {type: array, minItems: 3, maxItems: 4, items: {$ref: '#/components/schemas/VolumeWindow'}}
|
||||
Overview:
|
||||
type: object
|
||||
nullable: true
|
||||
required: [physicalQuantity, availableQuantity, noOutboundSkuCount, unassessableSkuCount]
|
||||
required: [physicalQuantity, availableQuantity, noOutboundSkuCount, unassessableSkuCount, inventoryVolumeM3, outboundVolumesM3]
|
||||
properties:
|
||||
physicalQuantity: {$ref: '#/components/schemas/Total'}
|
||||
availableQuantity: {$ref: '#/components/schemas/Total'}
|
||||
noOutboundSkuCount: {$ref: '#/components/schemas/Total'}
|
||||
unassessableSkuCount: {$ref: '#/components/schemas/Total'}
|
||||
inventoryVolumeM3: {$ref: '#/components/schemas/Volume'}
|
||||
outboundVolumesM3: {type: array, minItems: 3, maxItems: 4, items: {$ref: '#/components/schemas/VolumeWindow'}}
|
||||
Charts:
|
||||
type: object
|
||||
nullable: true
|
||||
|
||||
+69
-7
@@ -17,22 +17,83 @@ class InventoryTurnoverSqlTest {
|
||||
@BeforeEach void setup() throws Exception {
|
||||
db=DriverManager.getConnection("jdbc:h2:mem:turnover_"+UUID.randomUUID()+";MODE=MySQL","sa","");
|
||||
config.addMapper(InventoryTurnoverReadMapper.class);
|
||||
execute("CREATE TABLE ll_selfop_sync_task(id BIGINT,source_account VARCHAR,task_type VARCHAR,task_role VARCHAR,execution_purpose VARCHAR)");
|
||||
execute("CREATE TABLE ll_selfop_sync_run(id BIGINT,task_id BIGINT,source_account VARCHAR,execution_purpose VARCHAR,status VARCHAR,coverage_status VARCHAR,published_at_utc TIMESTAMP)");
|
||||
execute("CREATE TABLE ll_selfop_product(run_id BIGINT,source_account VARCHAR,customer_code VARCHAR,sku_code VARCHAR,declared_length DECIMAL(24,6),declared_width DECIMAL(24,6),declared_height DECIMAL(24,6),declared_size_unit VARCHAR,measured_length DECIMAL(24,6),measured_width DECIMAL(24,6),measured_height DECIMAL(24,6),measured_size_unit VARCHAR)");
|
||||
execute("CREATE ALIAS CONVERT_TZ FOR 'com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsSqlSemanticTest.convertZone'");
|
||||
execute("CREATE TABLE ll_selfop_sku_profile(profile_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_name VARCHAR,quantity_unit VARCHAR,evidence_type VARCHAR,evidence_run_id BIGINT,earliest_evidence_at_utc TIMESTAMP)");
|
||||
execute("CREATE TABLE ll_selfop_sku_daily_sales(sales_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,business_date DATE,outbound_quantity DECIMAL(20,6),quantity_unit VARCHAR)");
|
||||
execute("CREATE TABLE ll_selfop_inventory_snapshot(snapshot_run_id BIGINT,source_account VARCHAR,warehouse_code VARCHAR,customer_code VARCHAR,sku_code VARCHAR,product_quality INT,physical_quantity DECIMAL(20,6),available_quantity DECIMAL(20,6),frozen_quantity DECIMAL(20,6),transit_quantity DECIMAL(20,6),quality_status VARCHAR,quality_reason VARCHAR)");
|
||||
}
|
||||
@AfterEach void close() throws Exception {db.close();}
|
||||
private void master() throws Exception {
|
||||
execute("INSERT INTO ll_selfop_sync_task VALUES(900,'demo','MASTER_DATA','ACCOUNT_DISCOVERY','BUSINESS')");
|
||||
execute("INSERT INTO ll_selfop_sync_run VALUES(901,900,'demo','BUSINESS','PUBLISHED','COMPLETE',TIMESTAMP '2026-10-12 00:00:00')");
|
||||
}
|
||||
private void product(String owner,String sku,String declared,String measured) throws Exception {
|
||||
execute("INSERT INTO ll_selfop_product VALUES(901,'demo','"+owner+"','"+sku+"',"+declared+","+measured+")");
|
||||
}
|
||||
@Test void emptyUncoveredOutboundVolumeIsUnknownAndCompleteEmptyVolumesAreZero() throws Exception {
|
||||
var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20);
|
||||
var unknown=query("overview",input(q,0,0,true)).get(0);
|
||||
decimal("0",unknown.get("physical_volume"));assertNull(unknown.get("w30_outbound_volume"));
|
||||
var complete=query("overview",input(q,30,0,true)).get(0);
|
||||
decimal("0",complete.get("physical_volume"));decimal("0",complete.get("w30_outbound_volume"));
|
||||
}
|
||||
@Test void volumesUseWholeMeasuredTripletAndConvertInchesWithoutMergingOwnersOrPages() throws Exception {
|
||||
master();seed("A","SAME",0);seed("B","SAME",0);
|
||||
product("A","SAME","1,1,1,'in'","100,100,100,'cm'");
|
||||
product("B","SAME","1,1,1,'in'","100,NULL,100,'cm'");
|
||||
var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,1);
|
||||
var a=reportRows(input(q,30,0,true)).get(0);
|
||||
decimal("1",a.get("unit_volume"));decimal("610",a.get("physical_volume"));decimal("300",a.get("w30_outbound_volume"));
|
||||
assertEquals("WAREHOUSE_MEASURED",a.get("dimension_source"));
|
||||
var b=reportRows(input(request("SKU","ALL","PHYSICAL_DESC",null,null,2,1),30,0,true)).get(0);
|
||||
decimal("0.000016387064",b.get("unit_volume"));decimal("0.00999610904",b.get("physical_volume"));
|
||||
assertEquals("PRODUCT_DECLARED",b.get("dimension_source"));decimal("610",b.get("physical_estimated_quantity"));
|
||||
var summary=query("overview",input(q,30,0,true)).get(0);decimal("610.00999610904",summary.get("physical_volume"));
|
||||
var owners=reportRows(input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,0,true));
|
||||
assertEquals(2,owners.size());decimal("300",owners.get(0).get("w30_outbound_volume"));
|
||||
decimal("0.0049161192",owners.get(1).get("w30_outbound_volume"));
|
||||
}
|
||||
@Test void missingDimensionsAndUnknownQuantityRemainSeparateAndZeroNeedsNoDimensions() throws Exception {
|
||||
master();seed("A","KNOWN",0);seed("A","MISSING",0);
|
||||
product("A","KNOWN","10,10,10,'cm'","0,0,0,'cm'");
|
||||
product("A","MISSING","10,10,10,'unsupported'","10,-1,10,'cm'");
|
||||
var q=request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20);
|
||||
var row=reportRows(input(q,30,0,true)).get(0);
|
||||
assertNull(row.get("physical_volume"));decimal("0.61",row.get("physical_volume_known"));
|
||||
decimal("610",row.get("physical_missing_dimension_quantity"));decimal("1",row.get("physical_missing_dimension_skus"));
|
||||
assertNull(row.get("w30_outbound_volume"));decimal("0.3",row.get("w30_outbound_volume_known"));
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=NULL");
|
||||
row=reportRows(input(q,7,0,true)).get(0);
|
||||
assertNull(row.get("physical_volume"));decimal("2",row.get("physical_unknown_quantity_skus"));
|
||||
assertNull(row.get("w30_outbound_volume"));decimal("2",row.get("w30_outbound_unknown_quantity_skus"));
|
||||
execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=CASE WHEN sku_code='MISSING' THEN 0 WHEN product_quality=0 THEN 600 ELSE 10 END");
|
||||
execute("UPDATE ll_selfop_sku_daily_sales SET outbound_quantity=0 WHERE sku_code='MISSING'");
|
||||
row=reportRows(input(q,30,0,true)).get(0);
|
||||
decimal("0.61",row.get("physical_volume"));decimal("0.3",row.get("w30_outbound_volume"));
|
||||
decimal("0",row.get("physical_missing_dimension_skus"));
|
||||
}
|
||||
@Test void volumeMasterVersionIsFrozenAndFailedFutureOrOtherAccountRowsCannotLeak() throws Exception {
|
||||
master();seed("A","SKU",0);product("A","SKU","10,10,10,'cm'","NULL,NULL,NULL,NULL");
|
||||
execute("INSERT INTO ll_selfop_sync_run VALUES(902,900,'demo','BUSINESS','PUBLISHED','COMPLETE',TIMESTAMP '2026-10-14 00:00:00'),(903,900,'demo','BUSINESS','FAILED','COMPLETE',TIMESTAMP '2026-10-12 10:00:00')");
|
||||
execute("INSERT INTO ll_selfop_product VALUES(902,'demo','A','SKU',100,100,100,'cm',NULL,NULL,NULL,NULL),(903,'demo','A','SKU',200,200,200,'cm',NULL,NULL,NULL,NULL),(901,'other','A','SKU',300,300,300,'cm',NULL,NULL,NULL,NULL)");
|
||||
var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,0,true);
|
||||
decimal("0.001",reportRows(p).get(0).get("unit_volume"));
|
||||
var later=new InventoryTurnoverSql.Input(p.query(),11,22,true,true,p.days(),LocalDateTime.of(2026,10,15,0,0));
|
||||
decimal("1",reportRows(later).get(0).get("unit_volume"));
|
||||
}
|
||||
static InventoryTurnoverRequest request(String group,String focus,String sort,String sku,Integer threshold,int page,int size) {
|
||||
return new InventoryTurnoverRequest("demo","W",END,30,group,null,sku,focus,threshold,null,sort,page,size,true,null);
|
||||
}
|
||||
static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) {
|
||||
List<InventoryTurnoverSql.Day> days=new ArrayList<>();
|
||||
for(int i=0;i<30;i++){LocalDate date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),i>=30-covered?1:0,i<samples?100L+i:null));}
|
||||
return new InventoryTurnoverSql.Input(q,11,22,fresh,true,List.copyOf(days));
|
||||
return new InventoryTurnoverSql.Input(q,11,22,fresh,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0));
|
||||
}
|
||||
@Test void selectedCustomWindowsUseEveryDayIncludingBeyondThirtyAndKeepSampleGaps() throws Exception {
|
||||
seed("A","SKU",1);
|
||||
seed("A","SKU",1);master();product("A","SKU","10,10,10,'cm'","NULL,NULL,NULL,NULL");
|
||||
execute("UPDATE ll_selfop_sku_profile SET earliest_evidence_at_utc=TIMESTAMP '2025-01-01 00:00:00'");
|
||||
for(int n:List.of(3,45,366)) {
|
||||
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
|
||||
@@ -42,9 +103,10 @@ class InventoryTurnoverSqlTest {
|
||||
var date=END.minusDays(horizon-1-i);
|
||||
dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,date.equals(END)?null:100L));
|
||||
}
|
||||
var input=new InventoryTurnoverSql.Input(q,11,22,true,true,dates);
|
||||
var input=new InventoryTurnoverSql.Input(q,11,22,true,true,dates,LocalDateTime.of(2026,10,13,0,0));
|
||||
var row=query("page",input).get(0);
|
||||
decimal(Integer.toString(Math.min(30,n)*10),row.get("w"+n+"_outbound"));
|
||||
decimal(new BigDecimal("0.01").multiply(BigDecimal.valueOf(Math.min(30,n))).toPlainString(),row.get("w"+n+"_outbound_volume"));
|
||||
decimal(Integer.toString(n-1),row.get("sample_count"));
|
||||
assertEquals(END.toString(),row.get("missing_dates"));
|
||||
assertNull(row.get("turnover"));
|
||||
@@ -235,7 +297,7 @@ class InventoryTurnoverSqlTest {
|
||||
if(i>=30-n&&!present)missing.add(date.toString());
|
||||
}
|
||||
var q=new InventoryTurnoverRequest("demo","W",END,n,group,null,null,"ALL",null,null,null,1,20,true,null);
|
||||
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates));
|
||||
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(dates),LocalDateTime.of(2026,10,13,0,0));
|
||||
var row=query("page",p).get(0);
|
||||
assertNotNull(row.get("sample_mask"));
|
||||
decimal(String.valueOf(onlyLast?1:n-1),row.get("sample_count"));
|
||||
@@ -278,7 +340,7 @@ class InventoryTurnoverSqlTest {
|
||||
for(int i=0;i<30;i++){var date=END.minusDays(29-i);days.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,first+i));}
|
||||
for(int n:List.of(7,15,30)) {
|
||||
var q=new InventoryTurnoverRequest("demo","W",END,n,"SKU",null,null,"ALL",null,null,null,1,20,true,null);
|
||||
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(days));
|
||||
var p=new InventoryTurnoverSql.Input(q,11,22,true,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0));
|
||||
var row=reportRows(p).get(0);
|
||||
decimal(String.valueOf(n),row.get("sample_count"));
|
||||
decimal(n==7?"270":n==15?"230":"155",row.get("average_physical"));
|
||||
@@ -286,7 +348,7 @@ class InventoryTurnoverSqlTest {
|
||||
assertNotNull(row.get("turnover"));
|
||||
}
|
||||
var missing=days.get(14);days.set(14,new InventoryTurnoverSql.Day(missing.date(),missing.startUtc(),1,null));
|
||||
var row=reportRows(new InventoryTurnoverSql.Input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),11,22,true,true,List.copyOf(days))).get(0);
|
||||
var row=reportRows(new InventoryTurnoverSql.Input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),11,22,true,true,List.copyOf(days),LocalDateTime.of(2026,10,13,0,0))).get(0);
|
||||
decimal("29",row.get("sample_count"));assertNull(row.get("turnover"));
|
||||
assertEquals(missing.date().toString(),row.get("missing_dates"));
|
||||
}
|
||||
@@ -314,7 +376,7 @@ class InventoryTurnoverSqlTest {
|
||||
var p=input(request(group,"ALL","PHYSICAL_DESC",null,null,1,20),0,30,true);
|
||||
var unknown=reportRows(p).get(0);assertNull(unknown.get("w30_outbound"));assertNull(unknown.get("w30_observed"));
|
||||
var partialDays=p.days().stream().map(d->new InventoryTurnoverSql.Day(d.date(),d.startUtc(),2,d.sampleRun())).toList();
|
||||
var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays)).get(0);
|
||||
var partial=reportRows(new InventoryTurnoverSql.Input(p.query(),11,22,true,true,partialDays,LocalDateTime.of(2026,10,13,0,0))).get(0);
|
||||
assertNull(partial.get("w30_outbound"));decimal("0",partial.get("w30_observed"));decimal("30",partial.get("w30_partial"));
|
||||
var complete=reportRows(input(p.query(),30,30,true)).get(0);
|
||||
decimal("0",complete.get("w30_outbound"));decimal("30",complete.get("w30_complete"));
|
||||
|
||||
Reference in New Issue
Block a user