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 8466b59ed..6277fbe8c 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 @@ -22,7 +22,7 @@ public class InventoryTurnoverQueryService { var frozen=contexts.resolve(request);var input=InventoryTurnoverSql.input(request,frozen);var context=frozen.response(); var names=ownerDirectory.names(request.sourceAccount(),context.queryAsOf()); long total=0;boolean hasTotal=false;Overview summary=null;Charts charts=null; - List rows=new ArrayList<>();List owners=new ArrayList<>();int unknown=0; + List rows=new ArrayList<>();List owners=new ArrayList<>();List volumeOwners=new ArrayList<>();int unknown=0,unknownVolume=0; for(var part:mapper.report(input)) { var envelope=normalized(part);Map value; try { @@ -37,12 +37,14 @@ public class InventoryTurnoverQueryService { case "OVERVIEW" -> summary=overview(value,context); case "OWNER" -> owners.add(new OwnerPhysical(text(value,"customer_code"),text(value,"customer_name"),decimal(value,"physical"))); case "OWNER_META" -> unknown=count(value,"unknown_count"); + case "OWNER_VOLUME" -> volumeOwners.add(new OwnerVolume(text(value,"customer_code"),text(value,"customer_name"),volume(value,"physical"))); + case "OWNER_VOLUME_META" -> unknownVolume=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(InventoryTurnoverSql.chartInput(request,frozen))),context.endDate().plusDays(1),request.windows())); + charts=new Charts(List.copyOf(owners),unknown,List.copyOf(volumeOwners),unknownVolume,windows(normalized(mapper.chartWindows(InventoryTurnoverSql.chartInput(request,frozen))),context.endDate().plusDays(1),request.windows())); } return new Response(context,summary,charts,List.copyOf(rows),total); } 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 a61df32f9..46e091962 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 @@ -31,6 +31,8 @@ public final class InventoryTurnoverResponses { 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 OwnerVolume(String customerCode,String customerName,Volume inventoryVolumeM3) {} + public record Charts(List ownerPhysicalTop,int unknownOwnerCount,List ownerVolumeTop, + int unknownVolumeOwnerCount,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 7b127f5f5..8510a8244 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 @@ -273,22 +273,27 @@ public final class InventoryTurnoverSql { 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","unit_volume","dimension_source")); - // The physical-stock chart needs owner totals, not owner sales/sample windows. + // Stock charts share owner totals, independently of list filters and pagination. 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(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)"; + 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)" + +", top_volume_owners AS (SELECT t.*,ROW_NUMBER() OVER (ORDER BY physical_volume DESC,customer_code) ordinal FROM owner_totals t WHERE physical_volume IS NOT NULL ORDER BY physical_volume 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 ownerVolumeFields=new ArrayList<>(List.of("customer_code")); + InventoryVolumeSql.SUFFIXES.forEach(suffix->ownerVolumeFields.add("physical_"+suffix)); 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" - +" UNION ALL SELECT 'OWNER_META' section,0 ordinal,JSON_OBJECT('unknown_count',COUNT(*)) payload FROM owner_totals WHERE physical IS NULL"; + +" UNION ALL SELECT 'OWNER_META' section,0 ordinal,JSON_OBJECT('unknown_count',COUNT(*)) payload FROM owner_totals WHERE physical IS NULL" + +" UNION ALL SELECT 'OWNER_VOLUME' section,ordinal,"+jsonFields(ownerVolumeFields)+" payload FROM top_volume_owners" + +" UNION ALL SELECT 'OWNER_VOLUME_META' section,0 ordinal,JSON_OBJECT('unknown_count',COUNT(*)) payload FROM owner_totals WHERE physical_volume 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 index 4c64264db..f6eff8b98 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 @@ -205,7 +205,7 @@ components: Charts: type: object nullable: true - required: [ownerPhysicalTop, unknownOwnerCount, averageDaily] + required: [ownerPhysicalTop, unknownOwnerCount, ownerVolumeTop, unknownVolumeOwnerCount, averageDaily] properties: ownerPhysicalTop: type: array @@ -218,6 +218,18 @@ components: customerName: {type: string, nullable: true} physicalQuantity: {$ref: '#/components/schemas/Quantity'} unknownOwnerCount: {type: integer, minimum: 0} + ownerVolumeTop: + type: array + maxItems: 10 + description: 当前库存正式体积降序,同体积按货主编码稳定排序;排除体积未知货主,不受列表筛选及分页影响。 + items: + type: object + required: [customerCode, customerName, inventoryVolumeM3] + properties: + customerCode: {type: string} + customerName: {type: string, nullable: true} + inventoryVolumeM3: {$ref: '#/components/schemas/Volume'} + unknownVolumeOwnerCount: {type: integer, minimum: 0, description: 因缺尺寸或库存数量未知未参与体积排名的货主数} averageDaily: {type: array, minItems: 3, maxItems: 4, items: {$ref: '#/components/schemas/Window'}} Response: type: object 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 index be4349b77..3e7847240 100644 --- 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 @@ -115,6 +115,20 @@ class InventoryTurnoverContextTest { &&p.days().subList(0,12).stream().allMatch(d->d.coverage()==0) &&p.days().subList(15,30).stream().allMatch(d->d.coverage()==1))); } + @Test void volumeChartUsesDirectoryNamesAndKeepsEstimateEvidence() { + var directory=mock(SelfopOwnerDirectory.class); + when(directory.names(any(),any())).thenReturn(Map.of("C","货主甲")); + when(mapper.report(any())).thenReturn(List.of( + Map.of("section","TOTAL","payload","{\"total\":0}"), + Map.of("section","OVERVIEW","payload","{\"sku_count\":0}"), + Map.of("section","OWNER_VOLUME","payload","{\"customer_code\":\"C\",\"physical_volume\":12.345678,\"physical_estimated_quantity\":9}"), + Map.of("section","OWNER_VOLUME_META","payload","{\"unknown_count\":2}"))); + var charts=new InventoryTurnoverQueryService(contexts,mapper,json,directory).query(request(null)).charts(); + assertEquals(2,charts.unknownVolumeOwnerCount());assertEquals(1,charts.ownerVolumeTop().size()); + var owner=charts.ownerVolumeTop().get(0);assertEquals("货主甲",owner.customerName()); + assertEquals(new java.math.BigDecimal("12.345678"),owner.inventoryVolumeM3().value()); + assertTrue(owner.inventoryVolumeM3().reasonCodes().contains("DECLARED_DIMENSIONS_ESTIMATE")); + } @Test void firstPageSharesAggregationAndPaginationDoesNotRepeatOverviewQueries() { var service=new InventoryTurnoverQueryService(contexts,mapper,json,org.mockito.Mockito.mock(SelfopOwnerDirectory.class)); when(mapper.report(any())).thenReturn(List.of(Map.of("section","TOTAL","payload","{\"total\":0}"), 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 fd02d7d8c..eb2654a15 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 @@ -33,6 +33,38 @@ class InventoryTurnoverSqlTest { 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 ownerVolumeChartRanksFullTotalsExcludesUnknownsAndIgnoresListFilters() throws Exception { + master(); + for(int i=0;i<12;i++) { + String owner=String.format("C%02d",i);seed(owner,"SAME",0); + product(owner,"SAME",(i<2?"1":Integer.toString(i))+",1,1,'m'","NULL,NULL,NULL,NULL"); + } + seed("MISSING","SAME",0);seed("UNKNOWN","SAME",0); + product("UNKNOWN","SAME","100,1,1,'m'","NULL,NULL,NULL,NULL"); + execute("UPDATE ll_selfop_inventory_snapshot SET quality_status='INVALID' WHERE customer_code='UNKNOWN'"); + for(int days:List.of(7,30)) { + var q=new InventoryTurnoverRequest("demo","W",END,days,"SKU",null,"not-present","FROZEN",null,null,"PHYSICAL_DESC",2,1,true,"fixed"); + var report=query("report",input(q,30,0,true)); + var ranking=new ArrayList(); + for(var part:report)if("OWNER_VOLUME".equals(part.get("section")))ranking.add(payload(part)); + assertEquals(10,ranking.size()); + assertEquals("C11",ranking.get(0).path("customer_code").asText()); + assertEquals("C02",ranking.get(9).path("customer_code").asText()); + decimal("6710",ranking.get(0).path("physical_volume").decimalValue()); + decimal("610",ranking.get(0).path("physical_estimated_quantity").decimalValue()); + assertEquals(2,payload(report.stream().filter(r->"OWNER_VOLUME_META".equals(r.get("section"))).findFirst().orElseThrow()).path("unknown_count").asInt()); + assertTrue(report.stream().noneMatch(r->"ROW".equals(r.get("section")))); + } + execute("UPDATE ll_selfop_product SET declared_length=1"); + var tied=query("report",input(request("OWNER","ALL","PHYSICAL_DESC",null,null,1,1),30,0,true)); + var ranking=new ArrayList(); + for(var part:tied)if("OWNER_VOLUME".equals(part.get("section")))ranking.add(payload(part)); + assertEquals("C00",ranking.get(0).path("customer_code").asText()); + assertEquals("C09",ranking.get(9).path("customer_code").asText()); + execute("UPDATE ll_selfop_inventory_snapshot SET quality_status='INVALID'"); + var unknown=query("report",input(request("SKU","ALL","PHYSICAL_DESC",null,null,1,1),30,0,true)); + assertTrue(unknown.stream().noneMatch(r->"OWNER_VOLUME".equals(r.get("section")))); + } @Test void volumeSortsUseSelectedWindowFormalValuesBeforePagingWithStableTiesAndUnknownLast() throws Exception { master(); for(String owner:List.of("A","B","C","D"))seed(owner,"SAME",0);