From 8bc8518496595bf04fa131c9c6a8df64708d7af2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 17:48:52 +0800 Subject: [PATCH] feat(selfop): calculate inventory and outbound volume by owner and SKU --- .../query/InventoryTurnoverPolicy.java | 21 ++++- .../query/InventoryTurnoverResponses.java | 9 ++- .../analytics/query/InventoryTurnoverSql.java | 27 ++++--- .../analytics/query/InventoryVolumeSql.java | 64 ++++++++++++++++ .../self-operated-inventory-turnover-v1.yaml | 34 ++++++++- .../query/InventoryTurnoverSqlTest.java | 76 +++++++++++++++++-- 6 files changed, 205 insertions(+), 26 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryVolumeSql.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java index 07565b737..cfa530f32 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java @@ -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 row,String field) { + var reasons=new ArrayList(); + 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 volumeWindows(Map row,LocalDate until,List sizes) { + return sizes.stream().map(n->new VolumeWindow(n,until.minusDays(n),until,volume(row,"w"+n+"_outbound"))).toList(); + } private static Risk risk(Map 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 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 volumeSizes(Context context){return java.util.stream.Stream.of(7,15,30,context.baseWindowDays()).distinct().sorted().toList();} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java index 6e2b1d324..a61df32f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java @@ -11,6 +11,9 @@ public final class InventoryTurnoverResponses { BigDecimal observedQuantity, BigDecimal averageDailyOutboundQuantity, int completeDayCount, int partialDayCount, int unknownDayCount, String dataStatus, List reasonCodes) {} public record Total(BigDecimal value, BigDecimal knownSubtotal, int unknownSkuCount, List reasonCodes) {} + public record Volume(BigDecimal value,BigDecimal knownSubtotal,BigDecimal missingDimensionQuantity, + int missingDimensionSkuCount,int unknownQuantitySkuCount,BigDecimal declaredDimensionQuantity,List 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 missingSampleDates,List sampleReasonCodes,LocalDate lastObservedOutboundDate, - List attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount) {} + List attentionReasons,Risk noOutboundInWindow,Risk slowCoverage,int unassessableSkuCount, + BigDecimal unitVolumeM3,String dimensionSource,Volume inventoryVolumeM3,List 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 dailySamples,List 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 outboundVolumesM3) {} public record OwnerPhysical(String customerCode,String customerName,BigDecimal physicalQuantity) {} public record Charts(List ownerPhysicalTop,int unknownOwnerCount,List averageDaily) {} public record Response(Context context,Overview summary,Charts charts,List rows,long total) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java index 18e530d73..1340d3767 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java @@ -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 days) { + boolean salesKnown,List 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_date0 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 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 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 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 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" diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryVolumeSql.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryVolumeSql.java new file mode 100644 index 000000000..83d105483 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryVolumeSql.java @@ -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 quantities(InventoryTurnoverSql.Input p) { + var fields=new ArrayList();fields.add("physical"); + p.query().windows().forEach(n->fields.add("w"+n+"_outbound"));return fields; + } + static final List SUFFIXES=List.of("volume","volume_known","missing_dimension_quantity","missing_dimension_skus","unknown_quantity_skus","estimated_quantity"); + static List 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(); + } +} diff --git a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml index d071633da..ed5465c5b 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml @@ -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 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java index 2c33f87be..f95cf7fed 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java @@ -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 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=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"));