diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java index 5832aa4de..663037d63 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java @@ -8,11 +8,11 @@ import org.springframework.format.annotation.DateTimeFormat; /** Separate from DETAIL: its dates, windows and filters must never be silently mixed. */ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCode, @DateTimeFormat(iso=DateTimeFormat.ISO.DATE) LocalDate endDate, Integer baseWindowDays, - String groupBy, String customerCode, String sku, String focus, Integer coverageDaysThreshold, + String groupBy, java.util.List customerCodes, String sku, String focus, Integer coverageDaysThreshold, String turnoverStatus, String sortBy, Integer current, Integer size, Boolean includeOverview, String snapshotContext, Boolean hideZeroInventory) { private static final Set PARAMETERS=Set.of("sourceAccount","warehouseCode","endDate","baseWindowDays", - "groupBy","customerCode","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext","hideZeroInventory"); + "groupBy","customerCodes","sku","focus","coverageDaysThreshold","turnoverStatus","sortBy","current","size","includeOverview","snapshotContext","hideZeroInventory"); public static void validateParameters(Set keys) { if(!PARAMETERS.containsAll(keys)) throw new BizException("库存周转不接受其他视图的查询参数"); } @@ -29,7 +29,10 @@ public record InventoryTurnoverRequest(String sourceAccount, String warehouseCod public LocalDate end(LocalDate today){return endDate==null?today.minusDays(1):endDate;} public void validate(LocalDate today) { AnalyticsQuery.text(sourceAccount,64,"来源账号"); AnalyticsQuery.text(warehouseCode,64,"仓库"); - if(customerCode!=null) AnalyticsQuery.text(customerCode,64,"货主"); + if(customerCodes!=null) { + if(customerCodes.size()>100) throw new BizException("最多选择100个货主"); + for(String code:customerCodes) AnalyticsQuery.text(code,64,"货主"); + } if(sku!=null) AnalyticsQuery.text(sku,128,"SKU"); if(days()<1||days()>366) throw new BizException("统计区间须为1至366个完整自然日"); if(!end(today).isBefore(today)) throw new BizException("截止日必须是北京时间已结束的日期"); 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 9ed24bed7..ab1e4ffc8 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 @@ -36,9 +36,13 @@ public final class InventoryTurnoverSql { "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) { - 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 scope(Input p,String alias) { + String base=alias+"source_account=#{p.query.sourceAccount} AND "+alias+"warehouse_code=#{p.query.warehouseCode}"; + var owners=p.query.customerCodes(); + if(owners==null||owners.isEmpty())return base; + var values=new ArrayList(); + for(int i=0;i=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";} @@ -56,27 +60,27 @@ public final class InventoryTurnoverSql { runs.add("#{p.days["+i+"].sampleRun,jdbcType=BIGINT}"); return runs.isEmpty()?"NULL":String.join(",",runs); } - private static String salesDays() { + private static String salesDays(Input p) { return "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 " - +scope("")+" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date=CAST(#{p.from} AS DATE) AND business_date0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)"); + .append(scope(p,"")).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,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"); @@ -254,7 +258,7 @@ public final class InventoryTurnoverSql { +"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 "WITH calendar AS ("+calendar(p)+"), sales_days AS ("+salesDays()+"), warehouse_days AS (SELECT d.business_date," + Input p=(Input)args.get("p");return "WITH calendar AS ("+calendar(p)+"), sales_days AS ("+salesDays(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), warehouse_window_counts AS (SELECT COUNT(*) day_count"+windowAggregates(p)+" FROM warehouse_days) SELECT w.*"+formalWindows(p)+" FROM warehouse_window_counts w"; 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 07a03d506..d2481ebf9 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 @@ -19,7 +19,7 @@ paths: operationId: inventoryTurnover summary: 单仓SKU或货主三窗口库存周转 description: >- - 精确customerCode限定列表与概览;sku、focus、turnoverStatus及hideZeroInventory只筛列表。 + customerCodes按所选货主集合限定列表、概览及图表,省略或空集合表示全部;sku、focus、turnoverStatus及hideZeroInventory只筛列表。 按完整候选计算依赖后排序分页,未知末尾,同值按SKU及货主或货主编码稳定排序。 OWNER不计算整体可售天数;销售事实完整即可展示货主实际出库,不受单SKU观察期影响。 SKU出库及无出库判断同样不受首次观察时间限制:可信完整覆盖的缺销量行可计零;实际异常单位或未覆盖日期保持未知。 @@ -32,7 +32,7 @@ paths: - {name: endDate, in: query, description: '含截止日,默认北京时间昨天,不接受今日或未来', schema: {type: string, format: date}} - {name: baseWindowDays, in: query, schema: {type: integer, minimum: 1, maximum: 366, 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: customerCodes, in: query, style: form, explode: true, description: '货主编码多选,重复键传输;同一编码不重复计数,最多100个', schema: {type: array, maxItems: 100, items: {type: string, minLength: 1, maxLength: 64}}} - {name: sku, in: query, description: SKU分组的精确查询,OWNER禁止传入, schema: {type: string, minLength: 1, maxLength: 128}} - {name: hideZeroInventory, in: query, description: 默认隐藏当前全品质实物件数明确为0的SKU或货主汇总行,保留未知;全量筛选后分页,不改变概览及图表, schema: {type: boolean, default: true}} - name: focus 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 index 50f37f8e9..5f768d94a 100644 --- 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 @@ -43,10 +43,12 @@ class PlatformInventoryTurnoverControllerTest { 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("customerCodes","A","B") .param("coverageDaysThreshold","60").param("current","2").param("size","20") .param("hideZeroInventory","false").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(List.of("A","B"),q.customerCodes()); assertEquals("OWNER",q.group());assertEquals(60,q.coverageDaysThreshold());assertEquals(20,q.offset());assertFalse(q.overview());assertFalse(q.hideZero());assertEquals("fixed-token",q.snapshotContext()); } } 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 index 5a0f364a7..1ada14f91 100644 --- 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 @@ -20,6 +20,13 @@ class InventoryTurnoverRequestTest { assertFalse(request(Map.of("hideZeroInventory",false)).hideZero()); assertTrue(request(Map.of("hideZeroInventory",true)).hideZero()); } + @Test void validatesMultipleOwnersAndRejectsTheRemovedSingleOwnerParameter() { + request(Map.of("customerCodes",List.of("A","B"))).validate(TODAY); + request(Map.of("customerCodes",List.of())).validate(TODAY); + assertThrows(BizException.class,()->request(Map.of("customerCodes",List.of(""))).validate(TODAY)); + assertThrows(BizException.class,()->request(Map.of("customerCodes",Collections.nCopies(101,"A"))).validate(TODAY)); + assertThrows(BizException.class,()->InventoryTurnoverRequest.validateParameters(Set.of("customerCode"))); + } @Test void rejectsIncompleteDatesFiltersSortsAndUnpinnedPagination() { for(var invalid:List.of(Map.of("endDate","2026-10-13"),Map.of("baseWindowDays",367), Map.of("groupBy","OWNER","sku","S"),Map.of("groupBy","OWNER","sortBy","COVERAGE_DESC"), 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 99b5e0768..60c841e25 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 @@ -56,6 +56,26 @@ class InventoryTurnoverSqlTest { var exact=new InventoryTurnoverRequest("demo","W",END,30,"SKU",null,"ZERO","ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",true); assertTrue(reportRows(input(exact,30,0,true)).isEmpty()); } + @Test void filtersMultipleOwnersBeforeAggregationAndPaginationWithoutMergingSameSku() throws Exception { + seed("A","SAME",30);seed("B","SAME",30);seed("C","SAME",30); + for(String group:List.of("SKU","OWNER")) { + var selected=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("B","A","A"),null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false); + var p=input(selected,30,30,true); + var rows=reportRows(p); + assertEquals(List.of("A","B"),rows.stream().map(r->r.get("customer_code")).toList()); + var all=input(new InventoryTurnoverRequest("demo","W",END,30,group,null,null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false),30,30,true); + assertEquals(3,reportRows(all).size()); + for(int page=1;page<=2;page++) { + var q=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("A","B"),null,"ALL",null,null,"PHYSICAL_DESC",page,1,false,"fixed",false); + assertEquals(rows.get(page-1).get("customer_code"),reportRows(input(q,30,30,true)).get(0).get("customer_code")); + } + var overview=query("overview",p); + assertFalse(overview.isEmpty()); + assertNotEquals(query("overview",all),overview); + var unknown=new InventoryTurnoverRequest("demo","W",END,30,group,List.of("' OR 1=1 --"),null,"ALL",null,null,"PHYSICAL_DESC",1,20,true,"fixed",false); + assertTrue(reportRows(input(unknown,30,30,true)).isEmpty()); + } + } @Test void ownerVolumeChartRanksFullTotalsExcludesUnknownsAndIgnoresListFilters() throws Exception { master(); for(int i=0;i<12;i++) {