From 16d0b9c80fd8ee627407e306418b4e42d2320f3d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 14 Oct 2026 01:51:51 +0800 Subject: [PATCH] fix: finalize inventory turnover queries and reuse snapshot aggregation --- .../query/InventoryTurnoverContext.java | 8 +- .../query/InventoryTurnoverPolicy.java | 7 +- .../query/InventoryTurnoverQueryService.java | 36 ++-- .../query/InventoryTurnoverReadMapper.java | 12 +- .../analytics/query/InventoryTurnoverSql.java | 49 ++++- .../self-operated-inventory-turnover-v1.yaml | 195 ++++++++++++++++++ ...atformInventoryTurnoverControllerTest.java | 52 +++++ .../query/InventoryTurnoverContextTest.java | 102 +++++++++ .../query/InventoryTurnoverRequestTest.java | 31 +++ .../query/InventoryTurnoverSqlTest.java | 63 ++++++ 10 files changed, 527 insertions(+), 28 deletions(-) create mode 100644 lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java index a8a0dfe92..a82f60708 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java @@ -42,13 +42,15 @@ public class InventoryTurnoverContext { sales==null?0L:sales.id(),stock==null?0L:stock.id(),sales==null?null:sales.scopeHash(),stock==null?null:stock.scopeHash(),asOf); // Staleness is evaluated at queryAsOf, so pagination does not age a fixed snapshot midway. var snapshot=new AnalyticsQueryService(analytics,json,Clock.fixed(asOf,ZoneOffset.UTC)).context(aq,true,false,false,null); + String inventoryHash=scopes.stream().filter(s->"INVENTORY".equals(s.dataFamily())).map(AnalyticsReadRows.Scope::scopeHash).findFirst().orElse(null); var mapping=new TreeMap(); - for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),utc(asOf),end.minusDays(29),end.plusDays(1))) { - if(sample.sampleDate()==null||sample.runId()==null||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh(); + for(var sample:mapper.samples(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf),end.minusDays(29),end.plusDays(1))) { + if(sample.sampleDate()==null||sample.runId()==null||sample.runId()<=0||sample.sampleDate().isBefore(end.minusDays(29))||sample.sampleDate().isAfter(end) + ||mapping.put(sample.sampleDate(),sample.runId())!=null) throw refresh(); } List samples=new ArrayList<>(); for(LocalDate date=end.minusDays(29);!date.isAfter(end);date=date.plusDays(1)) samples.add(new Sample(date,mapping.get(date))); - var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),utc(asOf)); + var began=mapper.accumulationStartedOn(q.sourceAccount(),q.warehouseCode(),inventoryHash,utc(asOf)); var c=snapshot.context(); var evidence=new Evidence(q.sourceAccount(),q.warehouseCode(),end,q.days(),asOf,c.salesRunId(),c.inventoryRunId(), c.salesScopeHash(),c.inventoryScopeHash(),c.rebuildRequired(),List.copyOf(samples),began); 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 9f377c52e..505b6906d 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 @@ -69,8 +69,13 @@ public final class InventoryTurnoverPolicy { "VALID".equals(sampleStatus)?List.of():List.of(sampleStatus),last==null?null:LocalDate.parse(last.substring(0,10)),List.copyOf(attention), risk(r,"no_outbound",physical,owner,true),risk(r,"slow",physical,owner,q.coverageDaysThreshold()!=null),count(r,"unassessable")); } - static Overview overview(Map raw) { + 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"))); + } 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())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java index 6a3e68eda..5cbfe4ca8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java @@ -1,6 +1,9 @@ package com.baiyue.lastleg.selfoperated.analytics.query; import java.util.*; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Isolation; import org.springframework.transaction.annotation.Transactional; @@ -12,22 +15,31 @@ import static com.baiyue.lastleg.selfoperated.analytics.query.InventoryTurnoverP public class InventoryTurnoverQueryService { private final InventoryTurnoverContext contexts; private final InventoryTurnoverReadMapper mapper; - public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper){this.contexts=contexts;this.mapper=mapper;} + private final ObjectMapper json; + public InventoryTurnoverQueryService(InventoryTurnoverContext contexts,InventoryTurnoverReadMapper mapper,ObjectMapper json){this.contexts=contexts;this.mapper=mapper;this.json=json;} public Response query(InventoryTurnoverRequest request) { var frozen=contexts.resolve(request);var input=InventoryTurnoverSql.input(request,frozen);var context=frozen.response(); - long total=mapper.count(input); - var rows=request.offset()>=total?List.of():mapper.page(input).stream().map(r->row(r,request,context)).toList(); - Overview summary=null;Charts charts=null; - if(request.overview()) { - summary=overview(mapper.overview(input)); - List owners=new ArrayList<>();int unknown=0; - for(var raw:mapper.owners(input)) { - var r=normalized(raw); - if("META".equals(text(r,"kind")))unknown=count(r,"unknown_count"); - else owners.add(new OwnerPhysical(text(r,"customer_code"),null,decimal(r,"physical"))); + long total=0;boolean hasTotal=false;Overview summary=null;Charts charts=null; + List rows=new ArrayList<>();List owners=new ArrayList<>();int unknown=0; + for(var part:mapper.report(input)) { + var envelope=normalized(part);Map value; + try { + value=json.readerFor(new TypeReference>(){}).with(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .readValue(text(envelope,"payload")); + } catch(Exception e){throw new IllegalStateException("Invalid turnover query projection",e);} + switch(text(envelope,"section")) { + case "ROW" -> rows.add(row(value,request,context)); + case "TOTAL" -> {total=decimal(value,"total").longValueExact();hasTotal=true;} + case "OVERVIEW" -> summary=overview(value,context); + case "OWNER" -> owners.add(new OwnerPhysical(text(value,"customer_code"),null,decimal(value,"physical"))); + case "OWNER_META" -> unknown=count(value,"unknown_count"); + default -> throw new IllegalStateException("Unknown turnover query section"); } + } + if(!hasTotal||request.overview()&&summary==null)throw new IllegalStateException("Missing turnover query section"); + if(request.overview()) { charts=new Charts(List.copyOf(owners),unknown,windows(normalized(mapper.chartWindows(input)),context.endDate().plusDays(1))); } - return new Response(context,summary,charts,rows,total); + return new Response(context,summary,charts,List.copyOf(rows),total); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java index c0dac1b28..cf4fff1b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java @@ -15,11 +15,12 @@ public interface InventoryTurnoverReadMapper { AnalyticsReadRows.Run versionAt(@Param("scopeId") Long scopeId,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); // The immutable first-daily marker is set only by publication. Recheck its original schedule evidence. - String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} " + String SAMPLE_WHERE=" r.source_account=#{account} AND r.warehouse_code=#{warehouse} AND r.scope_hash=#{scopeHash,jdbcType=VARCHAR} " +"AND r.inventory_sample_rule_version='DAILY_SCHEDULED_FIRST_V1' AND r.status='PUBLISHED' " +"AND r.execution_purpose='BUSINESS' AND r.run_role='WAREHOUSE_SYNC' AND r.coverage_status='COMPLETE' " +"AND r.published_at_utc<=#{asOf} AND t.execution_purpose='BUSINESS' AND t.task_type='SCHEDULED' " - +"AND t.task_role='WAREHOUSE_SYNC' AND r.page_count>=1 AND r.source_total_count>=0 " + +"AND t.task_role='WAREHOUSE_SYNC' AND t.scope_hash=r.scope_hash " + +"AND t.source_account=r.source_account AND t.warehouse_code=r.warehouse_code AND r.page_count>=1 AND r.source_total_count>=0 " +"AND r.source_total_count=r.unique_object_count AND r.fetch_started_at_utc<=r.fetch_finished_at_utc " +"AND DATE(DATE_ADD(t.planned_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date " +"AND DATE(DATE_ADD(r.fetch_started_at_utc,INTERVAL 8 HOUR))=r.inventory_daily_sample_date " @@ -27,10 +28,10 @@ public interface InventoryTurnoverReadMapper { @Select("SELECT r.inventory_daily_sample_date sample_date,r.id run_id FROM ll_selfop_sync_run r " +"JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE +"AND r.inventory_daily_sample_date>=#{from} AND r.inventory_daily_sample_date<#{until} ORDER BY r.inventory_daily_sample_date") - List samples(@Param("account") String account,@Param("warehouse") String warehouse, + List samples(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash, @Param("asOf") LocalDateTime asOf,@Param("from") LocalDate from,@Param("until") LocalDate until); @Select("SELECT MIN(r.inventory_daily_sample_date) FROM ll_selfop_sync_run r JOIN ll_selfop_sync_task t ON t.id=r.task_id WHERE "+SAMPLE_WHERE) - LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("asOf") LocalDateTime asOf); + LocalDate accumulationStartedOn(@Param("account") String account,@Param("warehouse") String warehouse,@Param("scopeHash") String scopeHash,@Param("asOf") LocalDateTime asOf); @SelectProvider(type=InventoryTurnoverSql.class,method="page") List> page(@Param("p") InventoryTurnoverSql.Input input); @@ -42,4 +43,7 @@ public interface InventoryTurnoverReadMapper { List> owners(@Param("p") InventoryTurnoverSql.Input input); @SelectProvider(type=InventoryTurnoverSql.class,method="chartWindows") Map chartWindows(@Param("p") InventoryTurnoverSql.Input input); + + @SelectProvider(type=InventoryTurnoverSql.class,method="report") + List> report(@Param("p") InventoryTurnoverSql.Input input); } 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 e5feb9340..665388449 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 @@ -30,7 +30,7 @@ public final class InventoryTurnoverSql { return alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}" +" AND (#{p.query.customerCode,jdbcType=VARCHAR} IS NULL OR "+alias+"customer_code=#{p.query.customerCode,jdbcType=VARCHAR})"; } - private static String valid(String field){return field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} + private static String valid(String field){return "product_quality IN (0,1) AND "+field+" IS NOT NULL AND "+field+">=0 AND (quality_status='VALID' OR quality_reason IN ('FIELD_MISSING','NEGATIVE_QUANTITY'))";} private static String amount(String field){return "CASE WHEN MIN(CASE WHEN "+valid(field)+" THEN 1 ELSE 0 END)=1 THEN SUM("+field+") END";} private static String calendar(Input p) { var parts=new ArrayList(); @@ -50,7 +50,8 @@ public final class InventoryTurnoverSql { .append("), stocks AS (").append(stocks).append("), sales_days AS (SELECT customer_code,sku_code,business_date,SUM(outbound_quantity) quantity,MIN(CASE WHEN quantity_unit='PCS' AND outbound_quantity>=0 THEN 1 ELSE 0 END) unit_valid FROM ll_selfop_sku_daily_sales WHERE ") .append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date0 AND evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00') THEN 0 END END observed,") .append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 AND (quantity IS NOT NULL OR evidence<=CONVERT_TZ(CAST(business_date AS DATETIME),'+08:00','+00:00')) THEN 1 ELSE 0 END complete,") - .append("CASE WHEN coverage=1 AND unit_valid=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete FROM daily_values v), sku_windows AS (") + .append("CASE WHEN #{p.salesKnown}=1 AND coverage=1 AND COALESCE(sales_unit,1)=1 THEN 1 ELSE 0 END owner_complete,") + .append("CASE WHEN #{p.salesKnown}=1 AND COALESCE(sales_unit,1)=1 THEN CASE WHEN quantity IS NOT NULL THEN quantity WHEN coverage>0 THEN 0 END END owner_observed FROM daily_values v), sku_windows AS (") .append("SELECT customer_code,sku_code").append(windowAggregates(false)).append(",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count,") .append("CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical,") .append("GROUP_CONCAT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) AND sample_physical IS NULL THEN CAST(business_date AS CHAR) END ORDER BY business_date SEPARATOR ',') missing_dates ") @@ -94,7 +96,7 @@ public final class InventoryTurnoverSql { } private static String ownerCte(Input p) { return ", owner_days AS (SELECT customer_code,business_date,MIN(owner_complete) complete," - +"CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed," + +"SUM(owner_observed) observed," +"CASE WHEN COUNT(sample_physical)=COUNT(*) THEN SUM(sample_physical) END sample_physical FROM day_result GROUP BY customer_code,business_date), owner_windows AS (SELECT customer_code" +windowAggregates(true)+",COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END) sample_count," +"CASE WHEN COUNT(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)=#{p.query.days} THEN SUM(CASE WHEN business_date>=CAST(#{p.baseFrom} AS DATE) THEN sample_physical END)/#{p.query.days} END average_physical," @@ -107,9 +109,9 @@ public final class InventoryTurnoverSql { private static String totals(String key,boolean withFocus) { var sql=new StringBuilder("SELECT ").append(key==null?"COUNT(*) sku_count":key); for(String field:List.of("physical","available","frozen","defective")) sql.append(",CASE WHEN COUNT(").append(field).append(")=COUNT(*) THEN COALESCE(SUM(").append(field).append("),0) END ").append(field) - .append(",SUM(").append(field).append(") ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); + .append(",CASE WHEN COUNT(*)=0 THEN 0 ELSE SUM(").append(field).append(") END ").append(field).append("_known,COUNT(*)-COUNT(").append(field).append(") ").append(field).append("_unknown"); sql.append(",MAX(last_outbound) last_outbound,CASE WHEN COUNT(physical)=COUNT(*) THEN SUM(CASE WHEN physical>0 THEN 1 ELSE 0 END) END physical_skus,COALESCE(SUM(unassessable),0) unassessable"); - for(String risk:List.of("no_outbound","slow")) sql.append(",SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END) ").append(risk).append("_count,SUM(CASE WHEN ").append(risk).append("=1 THEN physical ELSE 0 END) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown"); + for(String risk:List.of("no_outbound","slow")) sql.append(",COALESCE(SUM(CASE WHEN ").append(risk).append("=1 THEN 1 ELSE 0 END),0) ").append(risk).append("_count,COALESCE(SUM(CASE WHEN ").append(risk).append("=1 THEN physical ELSE 0 END),0) ").append(risk).append("_physical,COUNT(*)-COUNT(").append(risk).append(") ").append(risk).append("_unknown"); if(withFocus)sql.append(",MAX(CASE WHEN available>0 AND coverage_days IS NOT NULL AND coverage_days<=#{p.query.coverageDaysThreshold,jdbcType=INTEGER} THEN 1 ELSE 0 END) fast_count,MAX(CASE WHEN frozen>0 THEN 1 ELSE 0 END) frozen_count,MAX(CASE WHEN defective>0 THEN 1 ELSE 0 END) defective_count"); return sql.toString(); } @@ -150,9 +152,40 @@ public final class InventoryTurnoverSql { public static String owners(Map args) { Input p=(Input)args.get("p");return cte(p)+ownerCte(p) +", ranked_owners AS (SELECT customer_code,physical FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10) " - +"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL"; + +"SELECT 'OWNER' kind,customer_code,physical,0 unknown_count FROM ranked_owners UNION ALL SELECT 'META' kind,NULL customer_code,NULL physical,COUNT(*) unknown_count FROM owner_result WHERE physical IS NULL ORDER BY kind DESC,physical DESC,customer_code"; } public static String chartWindows(Map args) { - Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT business_date,MIN(owner_complete) complete,CASE WHEN MIN(owner_complete)=1 THEN SUM(COALESCE(quantity,0)) ELSE SUM(observed) END observed FROM day_result GROUP BY business_date) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days"; + Input p=(Input)args.get("p");return cte(p)+", warehouse_days AS (SELECT d.business_date," + +"CASE WHEN #{p.salesKnown}=1 AND d.coverage=1 AND MIN(COALESCE(s.unit_valid,1))=1 THEN 1 ELSE 0 END complete," + +"CASE WHEN #{p.salesKnown}=1 THEN CASE WHEN COUNT(s.business_date)=0 AND d.coverage>0 THEN 0 ELSE SUM(CASE WHEN s.unit_valid=1 THEN s.quantity END) END END observed " + +"FROM calendar d LEFT JOIN sales_days s ON s.business_date=d.business_date GROUP BY d.business_date,d.coverage) SELECT COUNT(*) day_count"+windowAggregates(true)+" FROM warehouse_days"; + } + private static String jsonFields(List fields) { + return "JSON_OBJECT("+String.join(",",fields.stream().map(f->"'"+f+"',"+f).toList())+")"; + } + /** One statement shares the materialized stock/day/window relations across all first-page sections. */ + public static String report(Map args) { + Input p=(Input)args.get("p");boolean owner="OWNER".equals(p.query.group()),overview=p.query.overview(); + String source=owner?"owner_result":"sku_result"; + List fields=new ArrayList<>(List.of("customer_code","physical","available","frozen","defective","sample_count","average_physical","missing_dates","last_outbound","turnover","unassessable")); + for(int n:List.of(7,15,30))for(String suffix:List.of("outbound","observed","complete","partial"))fields.add("w"+n+"_"+suffix); + if(owner) { + fields.add("physical_skus"); + for(String field:List.of("physical","available","frozen","defective"))for(String suffix:List.of("known","unknown"))fields.add(field+"_"+suffix); + for(String risk:List.of("no_outbound","slow"))for(String suffix:List.of("count","physical","unknown"))fields.add(risk+"_"+suffix); + } else fields.addAll(List.of("sku_code","product_name","coverage_days","no_outbound","slow")); + String sql=cte(p)+(owner||overview?ownerCte(p):"") + +", page_result AS (SELECT r.*,ROW_NUMBER() OVER ("+order(p,owner)+") ordinal FROM "+source+" r"+filter(p,owner)+order(p,owner)+" LIMIT #{p.query.pageSize} OFFSET #{p.query.offset})"; + if(overview)sql+=", summary_result AS ("+totals(null,false)+" FROM sku_result), top_owners AS (SELECT customer_code,physical,ROW_NUMBER() OVER (ORDER BY physical DESC,customer_code) ordinal FROM owner_result WHERE physical IS NOT NULL ORDER BY physical DESC,customer_code LIMIT 10)"; + sql+=" SELECT 'ROW' section,ordinal,"+jsonFields(fields)+" payload FROM page_result" + +" UNION ALL SELECT 'TOTAL' section,0 ordinal,JSON_OBJECT('total',COUNT(*)) payload FROM "+source+filter(p,owner); + if(overview) { + List summary=new ArrayList<>(List.of("sku_count","unassessable","no_outbound_count","no_outbound_unknown")); + for(String field:List.of("physical","available")){summary.add(field);summary.add(field+"_known");summary.add(field+"_unknown");} + sql+=" UNION ALL SELECT 'OVERVIEW' section,0 ordinal,"+jsonFields(summary)+" payload FROM summary_result" + +" UNION ALL SELECT 'OWNER' section,ordinal,JSON_OBJECT('customer_code',customer_code,'physical',physical) payload FROM top_owners" + +" UNION ALL SELECT 'OWNER_META' section,0 ordinal,JSON_OBJECT('unknown_count',COUNT(*)) payload FROM owner_result WHERE physical IS NULL"; + } + return sql+" ORDER BY section,ordinal"; } } 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 new file mode 100644 index 000000000..386d0c90c --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/self-operated-inventory-turnover-v1.yaml @@ -0,0 +1,195 @@ +openapi: 3.0.3 +info: + title: 平台自营库存周转只读查询 + version: 1.0.0 + description: >- + 仅PLATFORM、tenantId=0、非代登录及platform:self-operated-analytics:list权限。 + 仓库由服务端受控配置校验;不触发采集,不读取来源订单,不调用领星。 + 三窗为截至endDate的7/15/30个完整北京时间自然日;当前库存独立固定版本。 + 每次请求使用REPEATABLE_READ。snapshotContext为服务端重建验证的版本与日代表证据, + 不作为授权凭据;账号、仓库、截止日、基准、queryAsOf、版本对、范围摘要、 + 重建状态及全部30日映射(含缺失日期)逐项重建比较;无效或过期要求整体刷新。 + 历史代表须同范围、正式计划采集、已发布且DAILY_SCHEDULED_FIRST_V1合格标记。 + 上下文有效期最多30分钟;客户端不能直接指定runId。SKU/关注分页不改变概览范围。 + 历史销量完整性不依赖当前库存新鲜度;缺样本不补零、不缩短周转分母。 +security: [{bearerAuth: []}] +paths: + /api/platform/v1/last-leg/self-operated-analytics/inventory-turnover: + get: + operationId: inventoryTurnover + summary: 单仓SKU或货主三窗口库存周转 + description: >- + 精确customerCode限定列表与概览;sku、focus及turnoverStatus只筛列表。 + 按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。 + OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。 + 首屏必须current=1且返回概览;后续携带snapshotContext,includeOverview=false省略概览。 + 不接受attention、salesWindowDays、版本ID或其他视图参数。 + parameters: + - {name: sourceAccount, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + - {name: warehouseCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 64}} + - {name: endDate, in: query, description: '含截止日,默认北京时间昨天,不接受今日或未来', schema: {type: string, format: date}} + - {name: baseWindowDays, in: query, schema: {type: integer, enum: [7, 15, 30], default: 30}} + - {name: groupBy, in: query, schema: {type: string, enum: [SKU, OWNER], default: SKU}} + - {name: customerCode, in: query, schema: {type: string, minLength: 1, maxLength: 64}} + - {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}} + - name: focus + in: query + schema: {type: string, enum: [ALL, NO_OUTBOUND_30D, COVERAGE_GT, COVERAGE_LE, FROZEN, DEFECTIVE, UNASSESSABLE], default: ALL} + - {name: coverageDaysThreshold, in: query, description: 'GT/LE必填,其余可选;无默认业务阈值', schema: {type: integer, minimum: 1}} + - {name: turnoverStatus, in: query, schema: {type: string, enum: [INSUFFICIENT]}} + - name: sortBy + in: query + description: 'OWNER禁止COVERAGE_ASC/DESC,SKU禁止NO_OUTBOUND_PHYSICAL_DESC。使用正式值及未舍入值排序。' + schema: {type: string, default: PHYSICAL_DESC, enum: [PHYSICAL_DESC, AVAILABLE_DESC, OUTBOUND_7_DESC, OUTBOUND_15_DESC, OUTBOUND_30_DESC, COVERAGE_ASC, COVERAGE_DESC, TURNOVER_DESC, NO_OUTBOUND_PHYSICAL_DESC]} + - {name: current, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + - {name: includeOverview, in: query, schema: {type: boolean, default: true}} + - {name: snapshotContext, in: query, description: 首屏返回的完整不可拆分上下文;翻页必填, schema: {type: string, minLength: 1, maxLength: 16384}} + responses: + '200': + description: 统一ApiResponse;参数或上下文业务错误沿现有异常响应,不伪装成空结果 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: {$ref: '#/components/schemas/Response'} + '400': {description: HTTP参数类型或日期格式不合法} + '401': {description: 无可信登录身份} + '403': {description: 非平台、代登录或缺少独立只读权限} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + Quantity: + type: number + nullable: true + description: PCS件数或对应指标值;未知为null,派生值六位小数,不把未知当0 + Reasons: + type: array + items: {type: string} + Total: + type: object + required: [value, knownSubtotal, unknownSkuCount, reasonCodes] + properties: + value: {$ref: '#/components/schemas/Quantity'} + knownSubtotal: {$ref: '#/components/schemas/Quantity'} + unknownSkuCount: {type: integer, minimum: 0} + reasonCodes: {$ref: '#/components/schemas/Reasons'} + Risk: + type: object + description: 命中为已确认小计;只有整个实物及风险判断完整、分母大于0时才提供件数比例。未设慢消化阈值则数量与比例均null。 + required: [matchedSkuCount, knownPhysicalQuantity, unknownSkuCount, physicalQuantityRatio] + properties: + matchedSkuCount: {$ref: '#/components/schemas/Quantity'} + knownPhysicalQuantity: {$ref: '#/components/schemas/Quantity'} + unknownSkuCount: {type: integer, minimum: 0} + physicalQuantityRatio: {$ref: '#/components/schemas/Quantity'} + Window: + type: object + required: [days, startDate, endDateExclusive, outboundQuantity, observedQuantity, averageDailyOutboundQuantity, completeDayCount, partialDayCount, unknownDayCount, dataStatus, reasonCodes] + properties: + days: {type: integer, enum: [7, 15, 30]} + startDate: {type: string, format: date} + endDateExclusive: {type: string, format: date} + outboundQuantity: {$ref: '#/components/schemas/Quantity'} + observedQuantity: {$ref: '#/components/schemas/Quantity'} + averageDailyOutboundQuantity: {$ref: '#/components/schemas/Quantity'} + completeDayCount: {type: integer, minimum: 0} + partialDayCount: {type: integer, minimum: 0} + unknownDayCount: {type: integer, minimum: 0} + dataStatus: {type: string, enum: [COMPLETE, PARTIAL, UNKNOWN]} + reasonCodes: {$ref: '#/components/schemas/Reasons'} + Context: + type: object + required: [snapshotContext, sourceAccount, warehouseCode, endDate, baseWindowDays, queryAsOf, queryExpiresAt, analyticsContext, inventoryObservedFrom, inventoryObservedUntil, sampleRuleVersion, dailySamples, missingSampleDates, accumulationStartedOn] + properties: + snapshotContext: {type: string} + sourceAccount: {type: string} + warehouseCode: {type: string} + endDate: {type: string, format: date} + baseWindowDays: {type: integer, enum: [7, 15, 30]} + queryAsOf: {type: string, format: date-time} + queryExpiresAt: {type: string, format: date-time} + analyticsContext: {$ref: './self-operated-analytics-v1.yaml#/components/schemas/Context'} + inventoryObservedFrom: {type: string, format: date-time, nullable: true} + inventoryObservedUntil: {type: string, format: date-time, nullable: true} + sampleRuleVersion: {type: string, enum: [DAILY_SCHEDULED_FIRST_V1]} + dailySamples: + type: array + minItems: 30 + maxItems: 30 + items: + type: object + required: [date, runId] + properties: + date: {type: string, format: date} + runId: {type: string, nullable: true, description: 内部版本证据,不作为用户可见编号} + missingSampleDates: {type: array, items: {type: string, format: date}} + accumulationStartedOn: {type: string, format: date, nullable: true} + Row: + type: object + required: [customerCode, customerName, sku, productName, physicalQuantity, availableQuantity, frozenQuantity, defectiveQuantity, physicalSkuCount, windows, averageDailyOutboundQuantity, inventoryCoverageDays, coverageStatus, averagePhysicalQuantity, quantityTurnoverCount, turnoverStatus, qualifiedSampleDays, requiredSampleDays, missingSampleDates, sampleReasonCodes, lastObservedOutboundDate, attentionReasons, noOutbound30d, slowCoverage, unassessableSkuCount] + properties: + customerCode: {type: string} + customerName: {type: string, nullable: true} + sku: {type: string, nullable: true} + productName: {type: string, nullable: true} + physicalQuantity: {$ref: '#/components/schemas/Total'} + availableQuantity: {$ref: '#/components/schemas/Total'} + frozenQuantity: {$ref: '#/components/schemas/Total'} + defectiveQuantity: {$ref: '#/components/schemas/Total'} + physicalSkuCount: {$ref: '#/components/schemas/Quantity'} + windows: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}} + averageDailyOutboundQuantity: {$ref: '#/components/schemas/Quantity'} + inventoryCoverageDays: {$ref: '#/components/schemas/Quantity'} + coverageStatus: {type: string, enum: [VALID, NOT_APPLICABLE, INVENTORY_UNKNOWN_OR_STALE, NO_AVAILABLE_STOCK, UNASSESSABLE, NO_OUTBOUND_IN_WINDOW]} + averagePhysicalQuantity: {$ref: '#/components/schemas/Quantity'} + quantityTurnoverCount: {$ref: '#/components/schemas/Quantity'} + turnoverStatus: {type: string, enum: [VALID, INSUFFICIENT, ZERO_AVERAGE_STOCK, SALES_WINDOW_UNAVAILABLE]} + qualifiedSampleDays: {type: integer, minimum: 0, maximum: 30} + requiredSampleDays: {type: integer, enum: [7, 15, 30]} + missingSampleDates: {type: array, items: {type: string, format: date}} + sampleReasonCodes: {$ref: '#/components/schemas/Reasons'} + lastObservedOutboundDate: {type: string, format: date, nullable: true} + attentionReasons: {$ref: '#/components/schemas/Reasons'} + noOutbound30d: {$ref: '#/components/schemas/Risk'} + slowCoverage: {$ref: '#/components/schemas/Risk'} + unassessableSkuCount: {type: integer, minimum: 0} + Overview: + type: object + nullable: true + required: [physicalQuantity, availableQuantity, noOutboundSkuCount, unassessableSkuCount] + properties: + physicalQuantity: {$ref: '#/components/schemas/Total'} + availableQuantity: {$ref: '#/components/schemas/Total'} + noOutboundSkuCount: {$ref: '#/components/schemas/Total'} + unassessableSkuCount: {$ref: '#/components/schemas/Total'} + Charts: + type: object + nullable: true + required: [ownerPhysicalTop, unknownOwnerCount, averageDaily] + properties: + ownerPhysicalTop: + type: array + maxItems: 10 + items: + type: object + required: [customerCode, customerName, physicalQuantity] + properties: + customerCode: {type: string} + customerName: {type: string, nullable: true} + physicalQuantity: {$ref: '#/components/schemas/Quantity'} + unknownOwnerCount: {type: integer, minimum: 0} + averageDaily: {type: array, minItems: 3, maxItems: 3, items: {$ref: '#/components/schemas/Window'}} + Response: + type: object + required: [context, summary, charts, rows, total] + properties: + context: {$ref: '#/components/schemas/Context'} + summary: {$ref: '#/components/schemas/Overview'} + charts: {$ref: '#/components/schemas/Charts'} + rows: {type: array, maxItems: 100, items: {$ref: '#/components/schemas/Row'}} + total: {type: string, pattern: '^[0-9]+$', description: 服务统一将Long序列化为字符串} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java new file mode 100644 index 000000000..bf9bc058a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.query.*; +import java.time.LocalDate; +import java.util.*; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.core.MethodParameter; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.*; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +class PlatformInventoryTurnoverControllerTest { + private static AuthenticatedUser platform(){return new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION));} + @Test void enforcesIndependentPlatformReadPermissionAndStrictParameterNames() { + var service=mock(InventoryTurnoverQueryService.class);var controller=new PlatformInventoryTurnoverController(service); + var customer=new AuthenticatedUser(1L,2L,2L,1L,"demo","CLIENT","WEB",Set.of(PlatformAnalyticsController.READ_PERMISSION)); + var writer=new AuthenticatedUser(1L,0L,0L,1L,"demo","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync")); + assertThrows(ResponseStatusException.class,()->controller.query(null,null,Map.of())); + assertThrows(ResponseStatusException.class,()->controller.query(customer,null,Map.of())); + assertThrows(ResponseStatusException.class,()->controller.query(writer,null,Map.of())); + assertThrows(BizException.class,()->controller.query(platform(),null,Map.of("attention","ALL"))); + assertThrows(BizException.class,()->controller.query(platform(),null,Map.of("salesRunId","11"))); + verifyNoInteractions(service); + assertEquals("hasAuthority('platform:self-operated-analytics:list')",PlatformInventoryTurnoverController.class.getAnnotation(PreAuthorize.class).value()); + } + @Test void httpBindsFrozenTurnoverContractWithoutTouchingCollection() throws Exception { + var service=mock(InventoryTurnoverQueryService.class); + var identity=new HandlerMethodArgumentResolver(){ + public boolean supportsParameter(MethodParameter p){return p.getParameterType()==AuthenticatedUser.class;} + public Object resolveArgument(MethodParameter p,ModelAndViewContainer c,NativeWebRequest r,WebDataBinderFactory b){return platform();} + }; + var mvc=MockMvcBuilders.standaloneSetup(new PlatformInventoryTurnoverController(service)).setCustomArgumentResolvers(identity).build(); + mvc.perform(get("/api/platform/v1/last-leg/self-operated-analytics/inventory-turnover") + .param("sourceAccount","demo").param("warehouseCode","W").param("endDate","2026-10-12") + .param("baseWindowDays","15").param("groupBy","OWNER").param("focus","COVERAGE_GT") + .param("coverageDaysThreshold","60").param("current","2").param("size","20") + .param("snapshotContext","fixed-token").param("includeOverview","false")).andExpect(status().isOk()); + var capture=ArgumentCaptor.forClass(InventoryTurnoverRequest.class);verify(service).query(capture.capture()); + var q=capture.getValue();assertEquals(LocalDate.of(2026,10,12),q.endDate());assertEquals(15,q.days()); + assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertEquals("fixed-token",q.snapshotContext()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java new file mode 100644 index 000000000..594492337 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.util.*; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import static com.baiyue.lastleg.selfoperated.analytics.query.AnalyticsReadRows.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class InventoryTurnoverContextTest { + private static final Instant NOW=Instant.parse("2026-10-13T04:00:00Z"); + private static final LocalDate END=LocalDate.of(2026,10,12); + private static final String SH="a".repeat(64),IH="b".repeat(64); + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private AnalyticsReadMapper analytics; + private InventoryTurnoverReadMapper mapper; + private InventoryTurnoverContext contexts; + @BeforeEach void setup() { + analytics=mock(AnalyticsReadMapper.class);mapper=mock(InventoryTurnoverReadMapper.class); + contexts=at(NOW); + when(analytics.scopes("demo","W")).thenReturn(List.of(scope(1,"OUTBOUND",SH,11L),scope(2,"INVENTORY",IH,22L))); + for(var run:List.of(run(11,1,SH),run(22,2,IH))) { + when(analytics.publishedRun(run.id())).thenReturn(run); + when(mapper.versionAt(eq(run.scopeId()),eq(run.scopeHash()),any())).thenReturn(run); + } + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of(new InventoryTurnoverReadMapper.SampleRun(END,100L))); + when(mapper.accumulationStartedOn(eq("demo"),eq("W"),eq(IH),any())).thenReturn(END); + } + @Test void sameContextFreezesAllThirtyDatesAndMissingSamples() { + var first=contexts.resolve(request(null)).response(); + assertEquals(30,first.dailySamples().size());assertEquals(29,first.missingSampleDates().size()); + assertEquals(END.minusDays(29),first.analyticsContext().rangeStartDate()); + assertEquals(END.plusDays(1),first.analyticsContext().rangeEndDateExclusive()); + assertEquals(first,at(NOW.plusSeconds(60)).resolve(request(first.snapshotContext())).response()); + verify(mapper,times(2)).samples("demo","W",IH,utc(NOW),END.minusDays(29),END.plusDays(1)); + // A subsequently published sample cannot appear in the old asOf query. If reconstruction + // ever differs, reject the context instead of mixing old charts and new rows. + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of()); + assertThrows(BizException.class,()->contexts.resolve(request(first.snapshotContext()))); + } + @Test void rejectsTamperedVersionsMappingWarehouseAndExpiry() throws Exception { + String token=contexts.resolve(request(null)).response().snapshotContext(); + var evidence=json.readTree(Base64.getUrlDecoder().decode(token)); + ((com.fasterxml.jackson.databind.node.ObjectNode)evidence).put("salesRun",999L); + String tampered=Base64.getUrlEncoder().encodeToString(json.writeValueAsBytes(evidence)); + assertThrows(BizException.class,()->contexts.resolve(request(tampered))); + var another=new InventoryTurnoverRequest("demo","OTHER",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token); + assertThrows(BizException.class,()->contexts.resolve(another)); + assertThrows(BizException.class,()->at(NOW.plusSeconds(1800)).resolve(request(token))); + assertThrows(BizException.class,()->at(NOW.minusSeconds(1)).resolve(request(token))); + assertThrows(BizException.class,()->contexts.resolve(request("not-a-token"))); + } + @Test void absentVersionsStayAbsentAndUnconfiguredWarehouseIsRejected() { + when(mapper.versionAt(anyLong(),anyString(),any())).thenReturn(null); + var c=contexts.resolve(request(null)).response(); + assertEquals(0L,c.analyticsContext().salesRunId());assertEquals(0L,c.analyticsContext().inventoryRunId()); + assertEquals("UNKNOWN",c.analyticsContext().inventoryStatus()); + assertEquals(c,contexts.resolve(request(c.snapshotContext())).response()); + when(analytics.scopes("demo","W")).thenReturn(List.of()); + assertThrows(BizException.class,()->contexts.resolve(request(null))); + } + @Test void rejectsDuplicateRepresentativesAndScopeDrift() { + when(mapper.samples(eq("demo"),eq("W"),eq(IH),any(),any(),any())).thenReturn(List.of( + new InventoryTurnoverReadMapper.SampleRun(END,100L),new InventoryTurnoverReadMapper.SampleRun(END,101L))); + assertThrows(BizException.class,()->contexts.resolve(request(null))); + when(analytics.publishedRun(11L)).thenReturn(run(11,2,IH)); + assertThrows(BizException.class,()->contexts.resolve(request(null))); + } + @Test void localHistoryCoverageSurvivesAgeButRebuildDoesNotAuthorizeFormalSales() { + var old=at(NOW.plusSeconds(200000)).resolve(request(null)); + assertEquals("STALE",old.response().analyticsContext().inventoryStatus()); + assertTrue(InventoryTurnoverSql.input(request(null),old).days().stream().allMatch(d->d.coverage()==1)); + when(analytics.applyBarriers("demo","W")).thenReturn(1L); + var rebuilding=contexts.resolve(request(null)); + assertTrue(InventoryTurnoverSql.input(request(null),rebuilding).days().stream().allMatch(d->d.coverage()==0)); + } + @Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() { + var service=new InventoryTurnoverQueryService(contexts,mapper,json); + when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"), + Map.of("section","OVERVIEW","payload","{\"sku_count\":0,\"physical\":0,\"available\":0,\"no_outbound_count\":0,\"unassessable\":0}"))); + var first=service.query(request(null));assertEquals(0,first.total());assertNotNull(first.summary());assertNotNull(first.charts()); + verify(mapper).report(any());verify(mapper).chartWindows(any()); + verify(mapper,never()).page(any());verify(mapper,never()).count(any());verify(mapper,never()).overview(any());verify(mapper,never()).owners(any()); + clearInvocations(mapper); + when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"))); + var q=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,2,20,false,first.context().snapshotContext()); + var next=service.query(q);assertNull(next.summary());assertNull(next.charts());verify(mapper).report(any());verify(mapper,never()).chartWindows(any()); + } + private InventoryTurnoverContext at(Instant instant){return new InventoryTurnoverContext(analytics,mapper,json,Clock.fixed(instant,ZoneOffset.UTC));} + static InventoryTurnoverRequest request(String token){return new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,null,"ALL",null,null,null,1,20,true,token);} + private static LocalDateTime utc(Instant instant){return LocalDateTime.ofInstant(instant,ZoneOffset.UTC);} + private static Scope scope(long id,String family,String hash,long run){return new Scope(id,"demo","W",family,hash,"Asia/Shanghai","Asia/Shanghai",93600,run,1L,1L,false,null,null,7);} + private static Run run(long id,long scope,String hash){ + return new Run(id,id+100,scope,"demo","W","WAREHOUSE_SYNC",hash, + "{\"businessTimezone\":\"Asia/Shanghai\",\"sourceTimezone\":\"Asia/Shanghai\",\"staleAfterSeconds\":93600}","PUBLISHED","COMPLETE", + END.minusDays(29).atStartOfDay().minusHours(8),END.plusDays(1).atStartOfDay().minusHours(8),END.minusDays(29),END.plusDays(1), + utc(NOW.minusSeconds(3660)),utc(NOW.minusSeconds(3600)),utc(NOW.minusSeconds(3500)),1L,null,null,null,"BUSINESS",true); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java new file mode 100644 index 000000000..477c10cf1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.selfoperated.analytics.query; + +import com.baiyue.common.core.exception.BizException; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; +import java.util.*; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class InventoryTurnoverRequestTest { + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private static final LocalDate TODAY=LocalDate.of(2026,10,13); + private InventoryTurnoverRequest request(Map override) { + Map values=new HashMap<>();values.put("sourceAccount","demo");values.put("warehouseCode","W");values.putAll(override); + return json.convertValue(values,InventoryTurnoverRequest.class); + } + @Test void defaultsToYesterdayThirtyDaysAndFirstPageOverview() { + var q=request(Map.of("includeOverview",false));q.validate(TODAY); + assertEquals(TODAY.minusDays(1),q.end(TODAY));assertEquals(30,q.days());assertEquals("SKU",q.group());assertEquals(20,q.pageSize());assertTrue(q.overview()); + } + @Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() { + for(var invalid:List.of(Map.of("endDate","2026-10-13"),Map.of("baseWindowDays",10), + Map.of("groupBy","OWNER","sku","S"),Map.of("groupBy","OWNER","sortBy","COVERAGE_DESC"), + Map.of("sortBy","NO_OUTBOUND_PHYSICAL_DESC"),Map.of("current",2), + Map.of("size",101),Map.of("coverageDaysThreshold",0),Map.of("focus","COVERAGE_GT"), + Map.of("snapshotContext"," "),Map.of("turnoverStatus","VALID"))) { + assertThrows(BizException.class,()->request(invalid).validate(TODAY),invalid.toString()); + } + request(Map.of("groupBy","OWNER","sortBy","NO_OUTBOUND_PHYSICAL_DESC","current",2,"snapshotContext","evidence")).validate(TODAY); + } +} 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 1748f175c..13018c274 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 @@ -74,6 +74,69 @@ class InventoryTurnoverSqlTest { var row=query("page",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),7,0,true)).get(0); decimal("0",row.get("coverage_days"));decimal("1",row.get("unassessable")); } + @Test void invalidHistoricalUnitOnlyInvalidatesItsSampleAndNeverActualOwnerSales() throws Exception { + seed("A","SKU",30); + execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=100"); + var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); + var row=query("page",p).get(0); + decimal("300",row.get("w30_outbound"));decimal("60",row.get("coverage_days")); + decimal("29",row.get("sample_count"));assertNull(row.get("turnover")); + execute("UPDATE ll_selfop_sku_profile SET quantity_unit='UNKNOWN' WHERE profile_run_id=11"); + assertNull(query("page",p).get(0).get("w30_outbound")); + var owner=query("page",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)).get(0); + decimal("300",owner.get("w30_outbound")); + } + @Test void zeroSalesAndZeroAverageHaveDifferentTurnoverResults() throws Exception { + seed("A","SKU",30); + execute("DELETE FROM ll_selfop_sku_daily_sales WHERE warehouse_code='W'"); + var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true); + var row=query("page",p).get(0); + decimal("0",row.get("turnover"));decimal("1",row.get("no_outbound"));assertNull(row.get("coverage_days")); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=0 WHERE snapshot_run_id>=100"); + row=query("page",p).get(0);decimal("0",row.get("average_physical"));assertNull(row.get("turnover")); + } + @Test void emptyWarehouseChartDistinguishesCoveredZeroFromMissingEvidence() throws Exception { + var q=request("SKU","ALL","PHYSICAL_DESC",null,null,1,20); + var covered=query("chartWindows",input(q,30,0,true)).get(0); + decimal("0",covered.get("w30_outbound"));decimal("30",covered.get("w30_complete")); + var absent=query("chartWindows",input(q,0,0,false)).get(0); + assertNull(absent.get("w30_outbound"));assertNull(absent.get("w30_observed")); + decimal("0",query("overview",input(q,30,0,true)).get(0).get("no_outbound_count")); + } + @Test void fullScopeFilterPrecedesPaginationAndKeepsSoldOutHistoricalCandidates() throws Exception { + seed("A","LOW",30);seed("A","HIGH",30);seed("B","SOLD",30); + execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=3000,physical_quantity=3000 WHERE sku_code='HIGH' AND snapshot_run_id=22 AND product_quality=0"); + execute("UPDATE ll_selfop_inventory_snapshot SET available_quantity=0,physical_quantity=0 WHERE sku_code='SOLD' AND snapshot_run_id=22"); + var p=input(request("SKU","COVERAGE_GT","COVERAGE_DESC",null,100,1,1),30,30,true); + var selected=query("page",p);assertEquals(1,selected.size());assertEquals("HIGH",selected.get(0).get("sku_code")); + decimal("1",query("count",p).get(0).values().iterator().next()); + decimal("3",query("overview",p).get(0).get("sku_count")); + var owners=query("page",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,20),30,30,true)); + var sold=owners.stream().filter(r->"B".equals(r.get("customer_code"))).findFirst().orElseThrow(); + decimal("0",sold.get("physical"));decimal("300",sold.get("w30_outbound"));decimal("0.6",sold.get("turnover")); + } + @Test void independentBaseWindowsUseMatchingHistoricalAverages() throws Exception { + seed("A","SKU",30); + for(int days:List.of(7,15)) { + var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,null,"ALL",null,null,null,1,20,true,null); + var row=query("page",input(q,30,30,true)).get(0); + decimal("70",row.get("w7_outbound"));decimal("150",row.get("w15_outbound"));decimal("300",row.get("w30_outbound")); + decimal("60",row.get("coverage_days"));decimal(days==7?"0.14":"0.3",row.get("turnover"));decimal(String.valueOf(days),row.get("sample_count")); + } + } + @Test void sharedReportMatchesStandaloneValuesAndKeepsPageOrder() throws Exception { + seed("A","SKU",30);seed("B","SKU",30); + execute("UPDATE ll_selfop_inventory_snapshot SET physical_quantity=900 WHERE customer_code='B' AND snapshot_run_id=22 AND product_quality=0"); + var p=input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,30,true); + var report=query("report",p);var json=new com.fasterxml.jackson.databind.ObjectMapper(); + var page=report.stream().filter(r->"ROW".equals(r.get("section"))).toList();assertEquals(1,page.size()); + Object raw=page.get(0).get("payload");var row=raw instanceof byte[] bytes?json.readTree(bytes):json.readTree(raw.toString()); + assertEquals("B",row.path("customer_code").asText());decimal("300",row.path("w30_outbound").decimalValue());decimal("0.6",row.path("turnover").decimalValue()); + var total=report.stream().filter(r->"TOTAL".equals(r.get("section"))).findFirst().orElseThrow().get("payload"); + var count=total instanceof byte[] bytes?json.readTree(bytes):json.readTree(total.toString());assertEquals(2,count.path("total").asInt()); + assertEquals(2,report.stream().filter(r->"OWNER".equals(r.get("section"))).count()); + assertEquals(1,report.stream().filter(r->"OVERVIEW".equals(r.get("section"))).count()); + } private void seed(String owner,String sku,int samples) throws Exception { profile(11,owner,sku);profile(22,owner,sku);stock(22,owner,sku,0,600,600);stock(22,owner,sku,1,10,0); for(int i=0;i<30;i++) {