feat(selfop): calculate inventory and outbound volume by owner and SKU

This commit is contained in:
2026-10-14 17:48:52 +08:00
parent 814eb11d2f
commit 8bc8518496
6 changed files with 205 additions and 26 deletions
@@ -36,6 +36,19 @@ public final class InventoryTurnoverPolicy {
var value=decimal(row,field);int unknown=owner?count(row,field+"_unknown"):value==null?1:0; 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")); 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) { 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); if(!enabled)return new Risk(null,null,0,null);
int unknown=owner?count(row,name+"_unknown"):row.get(name)==null?1:0; 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, 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), 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), "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) { 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"); 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())) { if(count(r,"sku_count")==0&&!"VALID".equals(context.analyticsContext().inventoryStatus())) {
var unknown=new Total(null,null,0,List.of("INVENTORY_UNKNOWN_OR_STALE")); 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")), 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), 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(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();}
} }
@@ -11,6 +11,9 @@ public final class InventoryTurnoverResponses {
BigDecimal observedQuantity, BigDecimal averageDailyOutboundQuantity, int completeDayCount, BigDecimal observedQuantity, BigDecimal averageDailyOutboundQuantity, int completeDayCount,
int partialDayCount, int unknownDayCount, String dataStatus, List<String> reasonCodes) {} int partialDayCount, int unknownDayCount, String dataStatus, List<String> reasonCodes) {}
public record Total(BigDecimal value, BigDecimal knownSubtotal, int unknownSkuCount, 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 Risk(BigDecimal matchedSkuCount, BigDecimal knownPhysicalQuantity, int unknownSkuCount, BigDecimal physicalQuantityRatio) {}
public record Row(String customerCode,String customerName,String sku,String productName, public record Row(String customerCode,String customerName,String sku,String productName,
Total physicalQuantity,Total availableQuantity,Total frozenQuantity,Total defectiveQuantity,Total transitQuantity, 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 inventoryCoverageDays,String coverageStatus,BigDecimal averagePhysicalQuantity,
BigDecimal quantityTurnoverCount,String turnoverStatus,int qualifiedSampleDays,int requiredSampleDays, BigDecimal quantityTurnoverCount,String turnoverStatus,int qualifiedSampleDays,int requiredSampleDays,
List<LocalDate> missingSampleDates,List<String> sampleReasonCodes,LocalDate lastObservedOutboundDate, 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 Sample(LocalDate date,Long runId) {}
public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate, public record Context(String snapshotContext,String sourceAccount,String warehouseCode,LocalDate endDate,
int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext, int baseWindowDays,Instant queryAsOf,Instant queryExpiresAt,AnalyticsResponses.Context analyticsContext,
Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion, Instant inventoryObservedFrom,Instant inventoryObservedUntil,String sampleRuleVersion,
List<Sample> dailySamples,List<LocalDate> missingSampleDates,LocalDate accumulationStartedOn) {} 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 OwnerPhysical(String customerCode,String customerName,BigDecimal physicalQuantity) {}
public record Charts(List<OwnerPhysical> ownerPhysicalTop,int unknownOwnerCount,List<Window> averageDaily) {} 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) {} public record Response(Context context,Overview summary,Charts charts,List<Row> rows,long total) {}
@@ -8,7 +8,7 @@ public final class InventoryTurnoverSql {
private InventoryTurnoverSql() {} private InventoryTurnoverSql() {}
public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {} public record Day(LocalDate date,LocalDateTime startUtc,int coverage,Long sampleRun) {}
public record Input(InventoryTurnoverRequest query,long salesRun,long inventoryRun,boolean stockFresh, 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 until(){return days.get(days.size()-1).date().plusDays(1);}
public LocalDate from(){return days.get(0).date();} public LocalDate from(){return days.get(0).date();}
public LocalDate fromBase(){return baseFrom();} 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), 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(); !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(), 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 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) { 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("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("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("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)"); .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); 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"); 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); 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 ") 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 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 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=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("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) "); .append(p.query.days()).append("_outbound=0 AND unassessable=0 THEN 0 END slow FROM sku_calculated b) ");
return sql.toString(); 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) 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," +"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 (" +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," +"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()) +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) "; +" 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); 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) 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"); .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"); 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"); 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"); 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(); 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); 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) { 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) { public static String owners(Map<String,Object> args) {
Input p=(Input)args.get("p");return cte(p)+ownerCte(p) 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"; 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")); 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); 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) { if(owner) {
fields.add("physical_skus"); 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 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); 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. // 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})"; +", 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(); 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; // Statement-local only: share intermediate relations across report branches;
// do not change connection/global settings or any result/filter semantics. // 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" 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); +" UNION ALL SELECT 'TOTAL' section,0 ordinal,JSON_OBJECT('total',COUNT(*)) payload FROM "+source+filter(p,owner);
if(overview) { if(overview) {
List<String> summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown")); 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");} 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" 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' section,ordinal,JSON_OBJECT('customer_code',customer_code,'physical',physical) payload FROM top_owners"
@@ -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();
}
}
@@ -89,6 +89,30 @@ components:
knownPhysicalQuantity: {$ref: '#/components/schemas/Quantity'} knownPhysicalQuantity: {$ref: '#/components/schemas/Quantity'}
unknownSkuCount: {type: integer, minimum: 0} unknownSkuCount: {type: integer, minimum: 0}
physicalQuantityRatio: {$ref: '#/components/schemas/Quantity'} 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: Window:
type: object type: object
required: [days, startDate, endDateExclusive, outboundQuantity, observedQuantity, averageDailyOutboundQuantity, completeDayCount, partialDayCount, unknownDayCount, dataStatus, reasonCodes] required: [days, startDate, endDateExclusive, outboundQuantity, observedQuantity, averageDailyOutboundQuantity, completeDayCount, partialDayCount, unknownDayCount, dataStatus, reasonCodes]
@@ -133,7 +157,7 @@ components:
accumulationStartedOn: {type: string, format: date, nullable: true} accumulationStartedOn: {type: string, format: date, nullable: true}
Row: Row:
type: object 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: properties:
customerCode: {type: string} customerCode: {type: string}
customerName: {type: string, nullable: true} customerName: {type: string, nullable: true}
@@ -163,15 +187,21 @@ components:
noOutboundInWindow: {$ref: '#/components/schemas/Risk'} noOutboundInWindow: {$ref: '#/components/schemas/Risk'}
slowCoverage: {$ref: '#/components/schemas/Risk'} slowCoverage: {$ref: '#/components/schemas/Risk'}
unassessableSkuCount: {type: integer, minimum: 0} 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: Overview:
type: object type: object
nullable: true nullable: true
required: [physicalQuantity, availableQuantity, noOutboundSkuCount, unassessableSkuCount] required: [physicalQuantity, availableQuantity, noOutboundSkuCount, unassessableSkuCount, inventoryVolumeM3, outboundVolumesM3]
properties: properties:
physicalQuantity: {$ref: '#/components/schemas/Total'} physicalQuantity: {$ref: '#/components/schemas/Total'}
availableQuantity: {$ref: '#/components/schemas/Total'} availableQuantity: {$ref: '#/components/schemas/Total'}
noOutboundSkuCount: {$ref: '#/components/schemas/Total'} noOutboundSkuCount: {$ref: '#/components/schemas/Total'}
unassessableSkuCount: {$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: Charts:
type: object type: object
nullable: true nullable: true
@@ -17,22 +17,83 @@ class InventoryTurnoverSqlTest {
@BeforeEach void setup() throws Exception { @BeforeEach void setup() throws Exception {
db=DriverManager.getConnection("jdbc:h2:mem:turnover_"+UUID.randomUUID()+";MODE=MySQL","sa",""); db=DriverManager.getConnection("jdbc:h2:mem:turnover_"+UUID.randomUUID()+";MODE=MySQL","sa","");
config.addMapper(InventoryTurnoverReadMapper.class); 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 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_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_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)"); 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();} @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) { 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); 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) { static InventoryTurnoverSql.Input input(InventoryTurnoverRequest q,int covered,int samples,boolean fresh) {
List<InventoryTurnoverSql.Day> days=new ArrayList<>(); 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));} 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 { @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'"); 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)) { 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); 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); var date=END.minusDays(horizon-1-i);
dates.add(new InventoryTurnoverSql.Day(date,date.atStartOfDay().minusHours(8),1,date.equals(END)?null:100L)); 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); var row=query("page",input).get(0);
decimal(Integer.toString(Math.min(30,n)*10),row.get("w"+n+"_outbound")); 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")); decimal(Integer.toString(n-1),row.get("sample_count"));
assertEquals(END.toString(),row.get("missing_dates")); assertEquals(END.toString(),row.get("missing_dates"));
assertNull(row.get("turnover")); assertNull(row.get("turnover"));
@@ -235,7 +297,7 @@ class InventoryTurnoverSqlTest {
if(i>=30-n&&!present)missing.add(date.toString()); 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 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); var row=query("page",p).get(0);
assertNotNull(row.get("sample_mask")); assertNotNull(row.get("sample_mask"));
decimal(String.valueOf(onlyLast?1:n-1),row.get("sample_count")); 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 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)) { 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 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); var row=reportRows(p).get(0);
decimal(String.valueOf(n),row.get("sample_count")); decimal(String.valueOf(n),row.get("sample_count"));
decimal(n==7?"270":n==15?"230":"155",row.get("average_physical")); decimal(n==7?"270":n==15?"230":"155",row.get("average_physical"));
@@ -286,7 +348,7 @@ class InventoryTurnoverSqlTest {
assertNotNull(row.get("turnover")); assertNotNull(row.get("turnover"));
} }
var missing=days.get(14);days.set(14,new InventoryTurnoverSql.Day(missing.date(),missing.startUtc(),1,null)); 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")); decimal("29",row.get("sample_count"));assertNull(row.get("turnover"));
assertEquals(missing.date().toString(),row.get("missing_dates")); 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 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 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 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")); 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); var complete=reportRows(input(p.query(),30,30,true)).get(0);
decimal("0",complete.get("w30_outbound"));decimal("30",complete.get("w30_complete")); decimal("0",complete.get("w30_outbound"));decimal("30",complete.get("w30_complete"));