From d81d9cbda4c80dcc65001c45e83d8234495c92a5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 15 Oct 2026 11:37:49 +0800 Subject: [PATCH] feat(selfop): add bounded inventory flow diagnostic through account egress --- .../LingxingInternalController.java | 9 ++ .../dto/lingxing/LingxingEgressDtos.java | 2 +- .../dto/lingxing/LingxingWmsDtos.java | 6 + .../provider/lingxing/LingxingWmsClient.java | 27 +++++ .../resources/openapi/lingxing-wms-v1.yaml | 28 ++++- .../lingxing/LingxingWmsClientTest.java | 32 ++++++ .../client/RestLingxingAnalyticsClient.java | 11 +- .../PlatformInventoryFlowProbeController.java | 23 ++++ .../mapper/SelfopCoordinationMapper.java | 2 +- .../analytics/request/SelfopEgressDtos.java | 2 +- .../request/SelfopEgressService.java | 5 + .../sync/InventoryFlowProbeService.java | 103 ++++++++++++++++++ .../openapi/self-operated-analytics-v1.yaml | 28 +++++ ...tformInventoryFlowProbeControllerTest.java | 22 ++++ .../request/SelfopEgressServiceTest.java | 15 +++ .../sync/InventoryFlowProbeServiceTest.java | 55 ++++++++++ 16 files changed, 365 insertions(+), 5 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeControllerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeServiceTest.java diff --git a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java index ec969e957..166a1c26b 100644 --- a/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java +++ b/integration-service/src/main/java/com/baiyue/integration/controller/LingxingInternalController.java @@ -78,6 +78,15 @@ public class LingxingInternalController { return ApiResponse.success(client.queryDetail(request, context(context))); } + @Operation(summary = "单SKU最多31日库存流水诊断,仅第一页最多1000条") + @PostMapping("/inventory-flow/query") + public ApiResponse> inventoryFlow( + @RequestHeader(value="X-Internal-Service-Token",required=false) String token, + @RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context, + @Valid @RequestBody InventoryFlowRequest request) { + tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlow(request,context(context))); + } + @Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页") @PostMapping("/product-inventory/query") public ApiResponse> inventory( diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java index e9749ff69..70b95d999 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingEgressDtos.java @@ -6,7 +6,7 @@ import java.time.Instant; public final class LingxingEgressDtos { private LingxingEgressDtos() { } public enum CollectionMode { OFF, VALIDATION, BUSINESS } - public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS } + public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS, INVENTORY_FLOW } public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId, String sessionId, String attemptId, String exclusiveGrantId) { public boolean wellFormed() { diff --git a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java index 2a0eefeae..379c617eb 100644 --- a/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java +++ b/integration-service/src/main/java/com/baiyue/integration/dto/lingxing/LingxingWmsDtos.java @@ -20,6 +20,12 @@ public final class LingxingWmsDtos { public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { } public record ProductQueryRequest(@Min(1) int current, @Min(1) @Max(100) int size) { } + public record InventoryFlowRequest(@NotBlank @Size(max=64) String warehouseCode, + @NotBlank @Size(max=64) String customerCode, @NotBlank @Size(max=128) String sku, + @NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime) { } + public record InventoryFlow(String id, String customerCode, String whCode, String productSku, + String cellNo, String innerBatchNo, String billNo, String billType, Integer productQuality, + BigDecimal bookQty, String bookChangQty, BigDecimal closeQty, String createTime) { } public record Customer(String customerCode, String customerName, String status) { } public record Product(String customerCode, String sku, String productName, BigDecimal length, BigDecimal width, BigDecimal height, String sizeUnit, diff --git a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java index 401ddb3cf..b74d26299 100644 --- a/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java +++ b/integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingWmsClient.java @@ -162,6 +162,30 @@ public final class LingxingWmsClient { (data, metadata) -> inventory(data, request, metadata)); } + /** Single bounded diagnostic page; never changes the current-inventory collection protocol. */ + public QueryPage queryInventoryFlow(InventoryFlowRequest request, EgressContext context) { + if(request==null)throw badRequest(); + requireInput(request.warehouseCode(),64);requireInput(request.customerCode(),64);requireInput(request.sku(),128); + if(request.startTime()==null||request.endTime()==null||!request.startTime().isBefore(request.endTime()) + ||request.endTime().isAfter(request.startTime().plusDays(31)))throw badRequest(); + Map body=pagination(1,1000); + body.put("whCode",request.warehouseCode());body.put("customerCode",request.customerCode()); + body.put("barcodeAccurate",request.sku());body.put("startTime",SOURCE_TIME.format(request.startTime())); + body.put("endTime",SOURCE_TIME.format(request.endTime()));body.put("sortName","createTime");body.put("sortOrder","asc"); + return execute("/openapi/v2/stock/stockCellBook/page",body,context,Operation.INVENTORY_FLOW, + (data,metadata)->mappedPage(data,1,1000,"current","size",false,metadata,row->{ + if(!request.warehouseCode().equals(requiredText(row,"whCode",64)) + ||!request.customerCode().equals(requiredText(row,"customerCode",64)) + ||!request.sku().equals(requiredText(row,"productSku",128)))throw invalidResponse("FLOW_IDENTITY_MISMATCH",200); + JsonNode id=row.get("id"); + if(id==null||!id.isIntegralNumber())throw invalidResponse("FLOW_ID_MISSING",200); + return new InventoryFlow(id.asText(),request.customerCode(),request.warehouseCode(),request.sku(), + optionalText(row,"cellNo"),optionalText(row,"innerBatchNo"),optionalText(row,"billNo"),optionalText(row,"billType"), + requiredInt(row.get("productQuality"),"PRODUCT_QUALITY"),quantity(row.get("bookQty")), + optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime")); + })); + } + private QueryPage inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) { Set> keys = new HashSet<>(); return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> { @@ -203,6 +227,9 @@ public final class LingxingWmsClient { JsonNode json; try { json = envelope(response.body()); } catch (LingxingWmsException error) { + // This optional endpoint may lack permission while ordinary collection remains authorized. + if(operation==Operation.INVENTORY_FLOW&&Integer.valueOf(11008).equals(error.sourceCode())) + throw upstreamFailure("FLOW_PERMISSION_DENIED","当前账号没有库存流水接口权限",false,200,11008); if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) { handle.rateLimited(retryAfter(response)); } diff --git a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml index 7c327a5a1..99281d72c 100644 --- a/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml +++ b/integration-service/src/main/resources/openapi/lingxing-wms-v1.yaml @@ -51,6 +51,32 @@ x-lastleg-egress-coordination: security: - InternalServiceToken: [] paths: + /internal/integrations/lingxing/wms/inventory-flow/query: + post: + summary: One SKU inventory-flow diagnostic page + description: >- + Calls stock/stockCellBook/page through the existing INVENTORY_FLOW permit. Exact warehouse/customer/SKU, + at most 31 days, current=1 and size=1000 fixed. No automatic next page or retry. Source quality and signed + quantities are preserved, not translated to inventory-snapshot quality. Source 11008 only rejects this optional + operation; authentication, rate-limit and uncertain-result account protection remain effective. + parameters: + - {$ref: '#/components/parameters/LingxingEgressContext'} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [warehouseCode, customerCode, sku, startTime, endTime] + properties: + warehouseCode: {type: string, maxLength: 64} + customerCode: {type: string, maxLength: 64} + sku: {type: string, maxLength: 128} + startTime: {type: string, format: date-time, description: Beijing local datetime without offset} + endTime: {type: string, format: date-time, description: Beijing local datetime without offset} + responses: + '200': + description: Standard envelope with QueryPage or sanitized ProviderFailure. Flow rows retain id as string, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, billType, productQuality, numeric bookQty/closeQty, string bookChangQty/createTime. Unknown source fields remain null; no calculated inventory is published. /internal/integrations/lingxing/wms/customers/query: post: summary: Query one page of customers using the existing account egress permit @@ -315,7 +341,7 @@ components: {"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0} EgressOperation: type: string - enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS] + enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS, INVENTORY_FLOW] EgressMetadata: type: object required: [attemptId, actualHttpAttempts, accountPaused] diff --git a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java index 5b1c917f7..e27c0a72c 100644 --- a/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java +++ b/integration-service/src/test/java/com/baiyue/integration/provider/lingxing/LingxingWmsClientTest.java @@ -311,6 +311,38 @@ class LingxingWmsClientTest { response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"code\":200", "\"code\":200,\"success\":" + success)); assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context())); } + private InventoryFlowRequest flow() {return new InventoryFlowRequest("W-DEMO","C-DEMO","SKU-DEMO", + LocalDateTime.of(2026,9,14,8,0),LocalDateTime.of(2026,10,14,8,0));} + @Test void flowUsesExactScopeSinglePageAndPreservesSignedChangeAndLargeId() throws Exception { + response.set(page("current","size",1,1000,1,1,""" + {"id":9223372036854775806,"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO", + "productQuality":1,"bookQty":-3,"bookChangQty":"-3","closeQty":17,"createTime":"2026-10-13 12:00:00"} + """)); + var result=client().queryInventoryFlow(flow(),context()); + assertThat(path.get()).isEqualTo("/openapi/v2/stock/stockCellBook/page"); + var sent=mapper.readTree(body.get()).path("data"); + assertThat(sent.path("barcodeAccurate").asText()).isEqualTo("SKU-DEMO"); + assertThat(sent.path("size").asInt()).isEqualTo(1000);assertThat(sent.has("productQuality")).isFalse(); + assertThat(result.records().get(0).id()).isEqualTo("9223372036854775806"); + assertThat(result.records().get(0).bookQty()).isEqualByComparingTo("-3"); + assertThat(result.records().get(0).bookChangQty()).isEqualTo("-3");assertThat(calls.get()).isEqualTo(1); + assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW); + } + @Test void flowPermissionDenialIsScopedAndDoesNotStopOrdinaryQueries() { + var c=client();response.set("{\"code\":11008,\"msg\":\"private\"}"); + var error=assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(flow(),context())); + assertThat(error.code()).isEqualTo("LINGXING_WMS_FLOW_PERMISSION_DENIED"); + assertThat(completion.get().accountPaused()).isFalse(); + response.set(page("current","size",1,1,1,1,ORDER)); + assertThat(c.queryPage(query(1,1),context()).records()).hasSize(1);assertThat(calls.get()).isEqualTo(2); + } + @Test void flowRejectsUnboundedWindowBeforeSendingAndWrongSkuAfterResponse() { + var q=flow();var c=client(); + assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(new InventoryFlowRequest(q.warehouseCode(),q.customerCode(),q.sku(),q.startTime(),q.startTime().plusDays(32)),context())); + assertThat(calls.get()).isZero(); + response.set(page("current","size",1,1000,1,1,"{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"productSku\":\"OTHER\"}")); + contract(()->c.queryInventoryFlow(q,context()),"FLOW_IDENTITY_MISMATCH"); + } private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); } private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) { var clock = new LingxingEgressGateTest.MutableClock(NOW); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java index 5c343f238..45f780ca7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java @@ -33,6 +33,7 @@ import java.util.function.Function; public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { private static final String PREFIX = "/internal/integrations/lingxing/wms"; private static final Set PROVIDER_ERROR_CODES = Set.of( + "LINGXING_WMS_FLOW_PERMISSION_DENIED", "LINGXING_WMS_AUTHORIZATION_REJECTED", "LINGXING_WMS_SIGNATURE_REJECTED", "LINGXING_WMS_SOURCE_REJECTED", "LINGXING_WMS_INVALID_RESPONSE", "LINGXING_WMS_RATE_LIMITED", "LINGXING_WMS_HTTP_RATE_LIMITED", "LINGXING_WMS_SOURCE_RATE_LIMITED", @@ -122,6 +123,13 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { quantity(n,"wmsLength"),quantity(n,"wmsWidth"),quantity(n,"wmsHeight"),optional(n,"wmsSizeUnit"))); } + public JsonNode inventoryFlow(SelfopRequestContext context,String warehouse,String customer,String sku, + LocalDateTime start,LocalDateTime end) { + gate(context);argument(warehouse,64);argument(customer,64);argument(sku,128); + return post(context,"/inventory-flow/query",Map.of("warehouseCode",warehouse,"customerCode",customer,"sku",sku, + "startTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(start),"endTime",DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(end))); + } + private Summary summary(String account, JsonNode n) { List items = null; JsonNode products = n.get("productList"); @@ -160,9 +168,10 @@ public class RestLingxingAnalyticsClient implements LingxingAnalyticsClient { case "/product-inventory/query" -> Operation.PRODUCT_INVENTORY; case "/customers/query" -> Operation.CUSTOMERS; case "/products/query" -> Operation.PRODUCTS; + case "/inventory-flow/query" -> Operation.INVENTORY_FLOW; default -> throw failure("EGRESS_OPERATION_INVALID"); }; - int size = fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0; + int size = op==Operation.INVENTORY_FLOW?1000:fields.containsKey("size") ? ((Number)fields.get("size")).intValue() : 0; String warehouse=(String)fields.get("warehouseCode"); String key=op==Operation.OUTBOUND_DETAIL ? SelfopSupport.json(List.of(fields.get("customerCode"),fields.get("deliveryNo"))) : null; var permission = egress.reserve(context,op,size,warehouse,key,SelfopSupport.hash(fields)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeController.java new file mode 100644 index 000000000..8e061e694 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeController.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.sync.InventoryFlowProbeService; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +@RestController +@RequestMapping("/api/platform/v1/last-leg/self-operated-analytics/inventory-flow-probe") +public class PlatformInventoryFlowProbeController { + private final InventoryFlowProbeService service; + public PlatformInventoryFlowProbeController(InventoryFlowProbeService service){this.service=service;} + @PostMapping + @PreAuthorize("hasAuthority('platform:self-operated-analytics:sync')") + public ApiResponse probe(@AuthenticationPrincipal AuthenticatedUser user, + @RequestBody InventoryFlowProbeService.Request request) { + if(user==null||!Long.valueOf(0).equals(user.tenantId())||!"PLATFORM".equals(user.appCode())||user.impersonated() + ||!user.permissions().contains("platform:self-operated-analytics:sync"))throw new ResponseStatusException(HttpStatus.FORBIDDEN); + return ApiResponse.success(service.probe(request,user.username())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java index b324ee8e8..2c3928d20 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java @@ -15,7 +15,7 @@ public interface SelfopCoordinationMapper { SelfopSyncTask lockTask(long id); @Select("SELECT * FROM ll_selfop_sync_run WHERE id=#{id} FOR UPDATE") SelfopSyncRun lockRun(long id); - @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") + @Select("SELECT * FROM ll_selfop_sync_task WHERE execution_purpose=#{purpose} AND task_type<>'INVENTORY_FLOW_PROBE' AND status IN ('READY','RETRY_WAIT','RUNNING') AND (execution_purpose='BUSINESS' OR (status='READY' AND validation_root_task_id=id)) AND (next_retry_at_utc IS NULL OR next_retry_at_utc<=#{now}) ORDER BY CASE WHEN task_type IN ('RECHECK','REBUILD') THEN 1 WHEN task_role='WAREHOUSE_SYNC' THEN 0 WHEN task_type='BACKFILL' THEN 3 ELSE 2 END,id LIMIT #{limit}") List due(@Param("now") LocalDateTime now, @Param("limit") int limit, @Param("purpose") String purpose); // Started inventory work may resume across midnight; unstarted historical slots remain excluded. // Publication independently requires the planned day and observation day to match for daily samples. diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java index 34f6b706e..b932dc712 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java @@ -7,7 +7,7 @@ public final class SelfopEgressDtos { boolean continuousEnabled,String sourceAccountFingerprint,boolean grantPresent,String grantFingerprint, long intervalMs,int permitMaxSeconds,boolean executionEnabled,int pendingMaxSeconds, boolean exclusiveHandoffVerified,boolean priorEgressClosed,boolean inflightDrained,boolean transferredBudgetVerified){} - public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY,CUSTOMERS,PRODUCTS} + public enum Operation {WAREHOUSES,OUTBOUND_QUERY,OUTBOUND_LOCATE,OUTBOUND_DETAIL,PRODUCT_INVENTORY,CUSTOMERS,PRODUCTS,INVENTORY_FLOW} public record EgressContext(String sourceAccount,String executionPurpose,String rootTaskId,String sessionId,String attemptId,String exclusiveGrantId){} @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.ALWAYS) public record AuthorizationDenial(String errorCode,boolean sampleDone,Instant waitUntil){} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java index 144d519fc..e8af72117 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java @@ -39,6 +39,11 @@ public class SelfopEgressService { SelfopBusinessBudget business=hasBusinessSession(root)?business(root):null; if(business!=null){verifyBusinessTask(task,root);String limit=business.limit(now);if(limit!=null){stopBusiness(root,business,limit,now);throw reject(limit);}} verify(task,run,root,account,now); + boolean flowOperation=operation==Operation.INVENTORY_FLOW; + if(flowOperation!="INVENTORY_FLOW_PROBE".equals(task.getTaskType()) + ||flowOperation&&(!"test".equals(config.getEnvironment())||!"BUSINESS".equals(task.getExecutionPurpose()) + ||pageSize!=1000||!config.getWarehouses().containsKey(warehouseCode) + ||business==null||business.http!=0||!Objects.equals(task.getRequestHash(),requestHash)))throw reject("EGRESS_FLOW_PROBE_INVALID"); boolean masterOperation=operation==Operation.CUSTOMERS||operation==Operation.PRODUCTS; if(masterOperation!=("MASTER_DATA".equals(task.getTaskType())) ||masterOperation&&(!"BUSINESS".equals(task.getExecutionPurpose())||pageSize<1||pageSize>(operation==Operation.CUSTOMERS?1000:100)))throw reject("EGRESS_OPERATION_TASK_MISMATCH"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeService.java new file mode 100644 index 000000000..4da795765 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeService.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.request.SelfopBusinessBudget; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.support.TransactionTemplate; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.selfoperated.analytics.model.SelfopSupport.*; + +/** TEST-only, one audited request. No schedule, retry, publication or historical inventory mutation. */ +@Service +public class InventoryFlowProbeService { + private final SelfopAnalyticsProperties config; + private final SelfopSyncTaskMapper tasks; + private final SelfopSyncRunMapper runs; + private final SelfopOutboundDiscoveryScopeMapper accounts; + private final SelfopLeaseService leases; + private final RestLingxingAnalyticsClient client; + private final TransactionTemplate tx; + public InventoryFlowProbeService(SelfopAnalyticsProperties config,SelfopSyncTaskMapper tasks,SelfopSyncRunMapper runs, + SelfopOutboundDiscoveryScopeMapper accounts,SelfopLeaseService leases,RestLingxingAnalyticsClient client,TransactionTemplate tx) { + this.config=config;this.tasks=tasks;this.runs=runs;this.accounts=accounts;this.leases=leases;this.client=client;this.tx=tx; + } + public record Request(String requestKey,String warehouseCode,String customerCode,String sku,LocalDateTime startTime,LocalDateTime endTime) { + public Map providerRequest(){return Map.of("warehouseCode",warehouseCode,"customerCode",customerCode,"sku",sku, + "startTime",java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(startTime), + "endTime",java.time.format.DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(endTime));} + } + public record Result(String taskNo,boolean complete,JsonNode page) {} + static void validate(Request q,LocalDateTime now) { + require(q!=null,"SELFOP_FLOW_REQUEST_REQUIRED");identity(q.requestKey(),80);identity(q.warehouseCode(),64); + identity(q.customerCode(),64);identity(q.sku(),128); + require(q.startTime()!=null&&q.endTime()!=null&&q.startTime().isBefore(q.endTime()) + &&!q.endTime().isAfter(q.startTime().plusDays(31))&&!q.endTime().isAfter(now),"SELFOP_FLOW_WINDOW_INVALID"); + } + public Result probe(Request q,String actor) { + validate(q,utcNow().plusHours(8)); + require("test".equals(config.getEnvironment())&&"BUSINESS".equals(config.getCollectionMode()) + &&config.isExecutionEnabled()&&config.getWarehouses().containsKey(q.warehouseCode()),"SELFOP_FLOW_PROBE_DISABLED"); + String key="flow-probe:"+q.requestKey(),digest=hash(q.providerRequest()); + var account=accounts.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount())); + require(account!=null,"SELFOP_SCOPE_MISSING"); + var lease=leases.acquireDiscovery(account.getId(),"flow-probe-"+UUID.randomUUID()); + try { + var existing=tasks.selectOne(new QueryWrapper().eq("source_account",config.getSourceAccount()).eq("request_key",key)); + if(existing!=null) { + require(digest.equals(existing.getRequestHash()),"SELFOP_IDEMPOTENCY_CONFLICT"); + require("SAMPLE_DONE".equals(existing.getStatus())&&existing.getRoutingManifestJson()!=null,"SELFOP_FLOW_PREVIOUS_ATTEMPT_REQUIRES_REVIEW"); + return read(existing.getRoutingManifestJson(),Result.class); + } + var now=utcNow();var task=new SelfopSyncTask();var run=new SelfopSyncRun(); + task.setId(IdWorker.getId());task.setTaskNo("LXFLOW-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT)); + task.setSourceAccount(config.getSourceAccount());task.setExecutionPurpose("BUSINESS");task.setTaskRole("ACCOUNT_DISCOVERY"); + task.setDiscoveryScopeId(account.getId());task.setTaskType("INVENTORY_FLOW_PROBE");task.setStatus("RUNNING"); + task.setApplyState("NOT_APPLICABLE");task.setAttemptCount(1);task.setRequestKey(key);task.setRequestHash(digest); + task.setScopeHash(digest);task.setQueryScopeJson(json(q.providerRequest()));task.setBusinessRootTaskId(task.getId()); + task.setBusinessBudgetJson(SelfopBusinessBudget.freeze(now));task.setRequestedBy(actor);task.setCreatedAt(now);task.setUpdatedAt(now); + run.setId(IdWorker.getId());run.setTaskId(task.getId());run.setSourceAccount(config.getSourceAccount());run.setExecutionPurpose("BUSINESS"); + run.setRunRole("ACCOUNT_DISCOVERY");run.setAttemptNo(1);run.setScopeHash(digest);run.setScopeSnapshotJson(task.getQueryScopeJson()); + run.setFenceToken(lease.fence());run.setStatus("FETCHING");run.setLastPhase("FETCHING");run.setFetchStartedAtUtc(now); + run.setCoverageStatus("UNKNOWN");run.setPageCount(0);run.setUniqueObjectCount(0L);run.setCreatedAt(now);run.setUpdatedAt(now); + tx.executeWithoutResult(s->{leases.verify(lease);tasks.insert(task);runs.insert(run);}); + try { + JsonNode page=client.inventoryFlow(SelfopRequestContext.forTask(task,run,config),q.warehouseCode(),q.customerCode(),q.sku(),q.startTime(),q.endTime()); + boolean complete=validatePage(page); + var result=new Result(task.getTaskNo(),complete,page); + tx.executeWithoutResult(s->{ + leases.verify(lease);var done=utcNow(); + task.setStatus("SAMPLE_DONE");task.setRoutingManifestJson(json(result));task.setUpdatedAt(done); + run.setStatus("SAMPLE_DONE");run.setLastPhase("INVENTORY_FLOW_PROBE");run.setPageCount(1); + run.setSourceTotalCount(page.path("total").asLong());run.setUniqueObjectCount((long)page.path("records").size()); + run.setCoverageStatus(complete?"COMPLETE":"PARTIAL");run.setFetchFinishedAtUtc(done);run.setFinishedAtUtc(done);run.setUpdatedAt(done); + tasks.updateById(task);runs.updateById(run); + }); + return result; + } catch(RuntimeException failure) { + String error=failure instanceof com.baiyue.lastleg.selfoperated.analytics.client.AnalyticsClientException c?c.code(): + failure instanceof com.baiyue.lastleg.selfoperated.analytics.request.EgressRejectedException e?e.code():"FLOW_PROBE_FAILED"; + tx.executeWithoutResult(s->{leases.verify(lease);task.setStatus("DEAD");task.setUpdatedAt(utcNow()); + run.setStatus("FAILED");run.setErrorCode(error);run.setErrorMessage("库存流水诊断失败;不自动重试");run.setFinishedAtUtc(utcNow());run.setUpdatedAt(utcNow()); + tasks.updateById(task);runs.updateById(run);}); + throw failure; + } + } finally {leases.release(lease);} + } + static boolean validatePage(JsonNode page) { + require(page!=null&&page.path("records").isArray()&&page.path("current").asInt()==1&&page.path("size").asInt()==1000 + &&page.path("total").asLong(-1)>=0,"SELFOP_FLOW_PAGE_INVALID"); + long total=page.path("total").asLong(); + require(page.path("pages").asLong(-1)==(total==0?0:(total-1)/1000+1) + &&page.path("records").size()==Math.min(1000,total),"SELFOP_FLOW_PAGE_INVALID"); + var ids=new HashSet(); + for(var row:page.path("records"))require(row.path("id").isTextual()&&!row.path("id").asText().isBlank() + &&ids.add(row.path("id").asText()),"SELFOP_FLOW_DUPLICATE_ID"); + return total<=1000; + } +} diff --git a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml index 740ce2f1a..46a2987fb 100644 --- a/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/self-operated-analytics-v1.yaml @@ -21,6 +21,34 @@ info: 重试沿既有POST sync-tasks,action=RETRY/requestKey/warehouseCode/taskNo/reason,使用独立sync权限并最终复核资格。 security: [{bearerAuth: []}] paths: + /api/platform/v1/last-leg/self-operated-analytics/inventory-flow-probe: + post: + summary: TEST单SKU库存流水有界诊断 + description: >- + 仅TEST、BUSINESS、执行开启及配置仓库;PLATFORM、tenantId=0、非代登录、独立platform:self-operated-analytics:sync权限。 + 每个requestKey最多一次供应商请求、第一页最多1000条、时间范围最多31天且不得未来。 + 复用现有账号lease/fence、1200ms间隔、permit和消费记录。占用时拒绝,由操作者另行发起,不自动重试。 + 同键成功重放读取保存结果;同键异参拒绝;失败或结果未知不得重发。任务类型INVENTORY_FLOW_PROBE,成功SAMPLE_DONE,失败DEAD。 + complete仅证明本次分页记录已全部返回,不证明历史流水无缺失或库存可还原。只保存诊断结果,不发布销量、库存或日样本。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [requestKey, warehouseCode, customerCode, sku, startTime, endTime] + properties: + requestKey: {type: string, minLength: 1, maxLength: 80} + warehouseCode: {type: string, minLength: 1, maxLength: 64} + customerCode: {type: string, minLength: 1, maxLength: 64} + sku: {type: string, minLength: 1, maxLength: 128} + startTime: {type: string, description: 北京时间本地日期时间} + endTime: {type: string, description: 北京时间本地日期时间} + responses: + '200': + description: ApiResponse;成功data包含taskNo、complete与page(records、total、current、size、pages、observedAt、egress)。流水字段定义见Integration库存流水合同。业务失败保留错误码,不返回伪成功。 + '403': + description: 非平台或缺少采集权限 /api/platform/v1/last-leg/self-operated-analytics/owner-directory: get: summary: 完整资料批次中的客户名称与编码 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeControllerTest.java new file mode 100644 index 000000000..0aab0ce34 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeControllerTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.selfoperated.analytics.controller; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.selfoperated.analytics.sync.InventoryFlowProbeService; +import org.junit.jupiter.api.Test; +import java.util.Set; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.security.access.prepost.PreAuthorize; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +class PlatformInventoryFlowProbeControllerTest { + @Test void probeRequiresRealPlatformSyncPermission() throws Exception { + var service=mock(InventoryFlowProbeService.class);var c=new PlatformInventoryFlowProbeController(service); + var reader=new AuthenticatedUser(1L,0L,0L,1L,"reader","PLATFORM","WEB",Set.of("platform:self-operated-analytics:list")); + var customer=new AuthenticatedUser(1L,1L,1L,1L,"customer","CLIENT","WEB",Set.of("platform:self-operated-analytics:sync")); + assertThrows(ResponseStatusException.class,()->c.probe(null,null));assertThrows(ResponseStatusException.class,()->c.probe(reader,null)); + assertThrows(ResponseStatusException.class,()->c.probe(customer,null));verifyNoInteractions(service); + var writer=new AuthenticatedUser(1L,0L,0L,1L,"operator","PLATFORM","WEB",Set.of("platform:self-operated-analytics:sync")); + c.probe(writer,null);verify(service).probe(null,"operator"); + assertEquals("hasAuthority('platform:self-operated-analytics:sync')",PlatformInventoryFlowProbeController.class + .getMethod("probe",AuthenticatedUser.class,InventoryFlowProbeService.Request.class).getAnnotation(PreAuthorize.class).value()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java index 1468103ec..50856827c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java @@ -44,6 +44,21 @@ class SelfopEgressServiceTest { success(c,Operation.CUSTOMERS);success(reserve(Operation.PRODUCTS,100,null,null),Operation.PRODUCTS); assertThat(budget().confirmedHttp).isEqualTo(2); } + @Test void flowProbeIsTestOnlyBoundToFrozenHashAndExactlyOneAttempt(){ + testBusiness(5,300);task.setTaskType("INVENTORY_FLOW_PROBE");task.setRequestHash(INTERNAL); + assertThatThrownBy(()->reserve(Operation.OUTBOUND_QUERY,100,null,null)).hasMessage("EGRESS_FLOW_PROBE_INVALID"); + assertThatThrownBy(()->reserve(Operation.INVENTORY_FLOW,1000,"OTHER",null)).hasMessage("EGRESS_FLOW_PROBE_INVALID"); + assertThatThrownBy(()->service.reserve(context(),Operation.INVENTORY_FLOW,1000,"A",null,PROVIDER)).hasMessage("EGRESS_FLOW_PROBE_INVALID"); + var c=reserve(Operation.INVENTORY_FLOW,1000,"A",null);success(c,Operation.INVENTORY_FLOW); + assertThatThrownBy(()->reserve(Operation.INVENTORY_FLOW,1000,"A",null)).hasMessage("EGRESS_FLOW_PROBE_INVALID"); + assertThat(businessBudget().http).isEqualTo(1);assertThat(budget().confirmedHttp).isEqualTo(1); + } + @Test void flowPermissionFailureDoesNotPauseExistingSalesAccount(){ + testBusiness(5,300);task.setTaskType("INVENTORY_FLOW_PROBE");task.setRequestHash(INTERNAL); + var c=reserve(Operation.INVENTORY_FLOW,1000,"A",null);service.authorize(new AuthorizationRequest(c,Operation.INVENTORY_FLOW,PROVIDER)); + service.complete(new CompletionRequest(c,Operation.INVENTORY_FLOW,PROVIDER,1,"LINGXING_WMS_FLOW_PERMISSION_DENIED",200,11008,false,null)); + assertThat(account.getApiPauseReason()).isNull();assertThat(budget().confirmedHttp).isEqualTo(1); + } @Test void ordinaryTasksCannotUseMasterDataOperations(){ business();assertThatThrownBy(()->reserve(Operation.CUSTOMERS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH"); assertThatThrownBy(()->reserve(Operation.PRODUCTS,10,null,null)).hasMessage("EGRESS_OPERATION_TASK_MISMATCH"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeServiceTest.java new file mode 100644 index 000000000..8b79f2b90 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeServiceTest.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.selfoperated.analytics.sync; +import org.junit.jupiter.api.Test; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import com.baiyue.lastleg.selfoperated.analytics.model.*; +import com.baiyue.lastleg.selfoperated.analytics.mapper.*; +import com.baiyue.lastleg.selfoperated.analytics.client.RestLingxingAnalyticsClient; +import org.springframework.transaction.support.TransactionTemplate; +class InventoryFlowProbeServiceTest { + @Test void successfulProbeIsAuditedReplayedAndNeverPublished() throws Exception { + var cfg=new SelfopAnalyticsProperties();cfg.setEnvironment("test");cfg.setCollectionMode("BUSINESS");cfg.setSourceAccount("demo"); + cfg.getWarehouses().put("W",new SelfopAnalyticsProperties.Warehouse()); + var tasks=mock(SelfopSyncTaskMapper.class);var runs=mock(SelfopSyncRunMapper.class);var accounts=mock(SelfopOutboundDiscoveryScopeMapper.class); + var leases=mock(SelfopLeaseService.class);var client=mock(RestLingxingAnalyticsClient.class);var tx=mock(TransactionTemplate.class); + doAnswer(i->{((java.util.function.Consumer)i.getArgument(0)).accept(null);return null;}).when(tx).executeWithoutResult(any()); + var a=new SelfopOutboundDiscoveryScope();a.setId(1L);when(accounts.selectOne(any())).thenReturn(a); + var lease=new SelfopLeaseService.Lease(1L,"probe",2L,true);when(leases.acquireDiscovery(eq(1L),anyString())).thenReturn(lease); + var saved=new java.util.concurrent.atomic.AtomicReference(); + when(tasks.selectOne(any())).thenAnswer(i->saved.get());when(tasks.insert(any(SelfopSyncTask.class))).thenAnswer(i->{saved.set(i.getArgument(0));return 1;}); + var page=new ObjectMapper().readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":1,\"records\":[{\"id\":\"1\"}]}"); + when(client.inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any())).thenReturn(page); + var s=new InventoryFlowProbeService(cfg,tasks,runs,accounts,leases,client,tx); + var end=LocalDateTime.of(2026,1,1,0,0);var q=new InventoryFlowProbeService.Request("one","W","C","S",end.minusDays(30),end); + var first=s.probe(q,"operator");assertTrue(first.complete());assertEquals(first,s.probe(q,"operator")); + assertEquals("SAMPLE_DONE",saved.get().getStatus());assertNull(saved.get().getResultPublishedRunId()); + var capture=org.mockito.ArgumentCaptor.forClass(SelfopSyncRun.class);verify(runs).insert(capture.capture()); + assertEquals("SAMPLE_DONE",capture.getValue().getStatus());assertNull(capture.getValue().getPublishedAtUtc());assertNull(capture.getValue().getInventoryDailySampleDate()); + verify(client,times(1)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any());verify(leases,times(2)).release(lease); + assertThrows(IllegalStateException.class,()->s.probe(new InventoryFlowProbeService.Request("one","W","C","OTHER",q.startTime(),q.endTime()),"operator")); + verify(client,times(1)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any()); + saved.set(null);when(client.inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any())).thenThrow(new IllegalStateException("PROVIDER_FAILURE")); + assertThrows(IllegalStateException.class,()->s.probe(q,"operator"));assertEquals("DEAD",saved.get().getStatus()); + assertThrows(IllegalStateException.class,()->s.probe(q,"operator")); + verify(client,times(2)).inventoryFlow(any(),anyString(),anyString(),anyString(),any(),any()); + } + @Test void refusesFutureAndOversizedWindow() { + var now=LocalDateTime.of(2026,10,15,12,0); + InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now.minusDays(30),now),now); + assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now.minusDays(32),now),now)); + assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validate(new InventoryFlowProbeService.Request("one","W","C","S",now,now.plusSeconds(1)),now)); + } + @Test void refusesPartialPageAndDuplicateEvidence() throws Exception { + var mapper=new ObjectMapper(); + assertTrue(InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":1,\"records\":[{\"id\":\"1\"}]}"))); + assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":2,\"records\":[{\"id\":\"1\"}]}"))); + assertThrows(IllegalStateException.class,()->InventoryFlowProbeService.validatePage(mapper.readTree("{\"current\":1,\"size\":1000,\"pages\":1,\"total\":2,\"records\":[{\"id\":\"1\"},{\"id\":\"1\"}]}"))); + var root=mapper.createObjectNode().put("current",1).put("size",1000).put("pages",2).put("total",1001); + var rows=root.putArray("records");for(int i=0;i<1000;i++)rows.addObject().put("id",String.valueOf(i)); + assertFalse(InventoryFlowProbeService.validatePage(root)); + } +} +