feat(selfop): filter turnover by multiple owners across all aggregates

This commit is contained in:
2026-10-15 11:12:44 +08:00
parent 72d7ed6962
commit e19d303a76
6 changed files with 51 additions and 15 deletions
@@ -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<String> 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<String> 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<String> 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("截止日必须是北京时间已结束的日期");
@@ -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<String>();
for(int i=0;i<owners.size();i++)values.add("#{p.query.customerCodes["+i+"],jdbcType=VARCHAR}");
return base+" AND "+alias+"customer_code IN ("+String.join(",",values)+")";
}
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";}
@@ -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.until} AS DATE) GROUP BY customer_code,sku_code,business_date";
+scope(p,"")+" AND sales_run_id=#{p.salesRun} AND business_date>=CAST(#{p.from} AS DATE) AND business_date<CAST(#{p.until} AS DATE) GROUP BY customer_code,sku_code,business_date";
}
private static String cte(Input p) {
String stocks="SELECT snapshot_run_id,customer_code,sku_code,"+amount("physical_quantity")+" physical,"
+amount("transit_quantity")+" transit,"+amount("frozen_quantity")+" frozen,"+AnalyticsInventorySql.availableQuantity(valid("available_quantity"))+" available,"
+"CASE WHEN MIN(CASE WHEN product_quality NOT IN (0,1) OR product_quality IS NULL THEN 0 WHEN product_quality=1 AND NOT ("+valid("physical_quantity")+") THEN 0 ELSE 1 END)=1 "
+"THEN SUM(CASE WHEN product_quality=1 THEN physical_quantity ELSE 0 END) END defective "
+"FROM ll_selfop_inventory_snapshot WHERE "+scope("")+" AND (snapshot_run_id=#{p.inventoryRun} OR snapshot_run_id IN ("+sampleRuns(p)+")) "
+"FROM ll_selfop_inventory_snapshot WHERE "+scope(p,"")+" AND (snapshot_run_id=#{p.inventoryRun} OR snapshot_run_id IN ("+sampleRuns(p)+")) "
+"GROUP BY snapshot_run_id,customer_code,sku_code";
String profiles="SELECT * FROM ll_selfop_sku_profile WHERE "+scope("")+" AND (profile_run_id IN (#{p.salesRun},#{p.inventoryRun}) OR profile_run_id IN ("+sampleRuns(p)+"))";
String profiles="SELECT * FROM ll_selfop_sku_profile WHERE "+scope(p,"")+" AND (profile_run_id IN (#{p.salesRun},#{p.inventoryRun}) OR profile_run_id IN ("+sampleRuns(p)+"))";
StringBuilder sql=new StringBuilder("WITH calendar AS (").append(calendar(p)).append("), fixed_profiles AS (").append(profiles)
.append("), stocks AS (").append(stocks).append("), sales_days AS (").append(salesDays()).append("), candidates AS (")
.append("), stocks AS (").append(stocks).append("), sales_days AS (").append(salesDays(p)).append("), candidates AS (")
.append("SELECT customer_code,sku_code FROM fixed_profiles UNION SELECT customer_code,sku_code FROM stocks UNION SELECT customer_code,sku_code FROM sales_days), profiles AS (")
.append("SELECT c.customer_code,c.sku_code,COALESCE(MAX(CASE WHEN f.profile_run_id=#{p.inventoryRun} THEN f.product_name END),MAX(f.product_name)) product_name,")
.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(InventoryVolumeSql.products()).append(skuWindows(p)).append(", last_sales AS (SELECT customer_code,sku_code,MAX(business_date) last_outbound FROM ll_selfop_sku_daily_sales WHERE ")
.append(scope("")).append(" AND sales_run_id=#{p.salesRun} AND business_date<CAST(#{p.until} AS DATE) AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)");
.append(scope(p,"")).append(" AND sales_run_id=#{p.salesRun} AND business_date<CAST(#{p.until} AS DATE) AND outbound_quantity>0 AND quantity_unit='PCS' GROUP BY customer_code,sku_code)");
sql.append(", sku_base AS (SELECT c.customer_code,c.sku_code,c.product_name,c.evidence,c.unit_valid,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<String,Object> 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";
@@ -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
@@ -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());
}
}
@@ -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.<String,Object>of("endDate","2026-10-13"),Map.<String,Object>of("baseWindowDays",367),
Map.<String,Object>of("groupBy","OWNER","sku","S"),Map.<String,Object>of("groupBy","OWNER","sortBy","COVERAGE_DESC"),
@@ -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++) {