feat(selfop): reconstruct historical inventory and publish qualified turnover
This commit is contained in:
+9
@@ -87,6 +87,15 @@ public class LingxingInternalController {
|
||||
tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlow(request,context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "读取固定单仓最多一天的全部货主品质库存流水单页")
|
||||
@PostMapping("/inventory-flow-page/query")
|
||||
public ApiResponse<QueryPage<InventoryFlow>> inventoryFlowPage(
|
||||
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
|
||||
@RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context,
|
||||
@Valid @RequestBody InventoryFlowPageRequest request) {
|
||||
tokenGuard.verify(token);return ApiResponse.success(client.queryInventoryFlowPage(request,context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页")
|
||||
@PostMapping("/product-inventory/query")
|
||||
public ApiResponse<QueryPage<ProductStock>> inventory(
|
||||
|
||||
+4
-1
@@ -23,9 +23,12 @@ public final class LingxingWmsDtos {
|
||||
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 InventoryFlowPageRequest(@NotBlank @Size(max=64) String warehouseCode,
|
||||
@NotNull LocalDateTime startTime,@NotNull LocalDateTime endTime,
|
||||
@Min(1) int current,@Min(1) @Max(1000) int size) { }
|
||||
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) { }
|
||||
BigDecimal bookQty, String bookChangQty, BigDecimal closeQty, String createTime, String sourceNo) { }
|
||||
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,
|
||||
|
||||
+28
-8
@@ -173,16 +173,35 @@ public final class LingxingWmsClient {
|
||||
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->{
|
||||
(data,metadata)->mappedPage(data,1,1000,"current","size",true,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(),
|
||||
String id=requiredText(row,"id",64);
|
||||
return new InventoryFlow(id,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"));
|
||||
optionalText(row,"bookChangQty"),quantity(row.get("closeQty")),optionalText(row,"createTime"),optionalText(row,"sourceNo"));
|
||||
}));
|
||||
}
|
||||
|
||||
/** One fixed warehouse interval page. Scheduling and coverage belong to Lastleg. */
|
||||
public QueryPage<InventoryFlow> queryInventoryFlowPage(InventoryFlowPageRequest request,EgressContext context) {
|
||||
if(request==null) throw badRequest();
|
||||
requireInput(request.warehouseCode(),64);validatePagination(request.current(),request.size(),1000);
|
||||
if(request.startTime()==null || request.endTime()==null || !request.startTime().isBefore(request.endTime())
|
||||
|| request.endTime().isAfter(request.startTime().plusDays(1))) throw badRequest();
|
||||
Map<String,Object> body=pagination(request.current(),request.size());
|
||||
body.put("whCode",request.warehouseCode());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,request.current(),request.size(),"current","size",true,metadata,row->{
|
||||
String warehouse=requiredText(row,"whCode",64);
|
||||
if(!request.warehouseCode().equals(warehouse)) throw invalidResponse("FLOW_IDENTITY_MISMATCH",200);
|
||||
return new InventoryFlow(requiredText(row,"id",64),requiredText(row,"customerCode",64),warehouse,
|
||||
requiredText(row,"productSku",128),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"),optionalText(row,"sourceNo"));
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -343,7 +362,7 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
|
||||
private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) {
|
||||
QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", true, metadata, record -> {
|
||||
QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", size == 100, metadata, record -> {
|
||||
String deliveryNo = requiredText(record, "deliveryNo", 128);
|
||||
if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200);
|
||||
int status = requiredInt(record.get("status"), "STATUS");
|
||||
@@ -367,7 +386,7 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
|
||||
private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField,
|
||||
String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) {
|
||||
String sizeField, boolean allowEmptyDefaultSize, AttemptMetadata metadata, Function<JsonNode, T> projection) {
|
||||
JsonNode records = data.get("records");
|
||||
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200);
|
||||
if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
|
||||
@@ -377,7 +396,8 @@ public final class LingxingWmsClient {
|
||||
int sourceSize = requiredInt(data.get(sizeField), "SIZE");
|
||||
int pages = requiredInt(data.get("pages"), "PAGES");
|
||||
if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200);
|
||||
boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1
|
||||
// Verified for outbound size 100 and the bounded inventory-flow query only.
|
||||
boolean emptyException = allowEmptyDefaultSize && current == 1 && sourceCurrent == 1
|
||||
&& sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty();
|
||||
if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200);
|
||||
long expectedPages = total / size + (total % size == 0 ? 0 : 1);
|
||||
|
||||
@@ -51,6 +51,34 @@ x-lastleg-egress-coordination:
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
paths:
|
||||
/internal/integrations/lingxing/wms/inventory-flow-page/query:
|
||||
post:
|
||||
summary: One frozen warehouse inventory-flow page
|
||||
description: >-
|
||||
Shared INVENTORY_FLOW permit, one upstream request, no automatic retry. Fixed warehouse and
|
||||
inclusive Beijing start/end bounds at most one day, current>=1, size 1..1000, sorted by createTime asc.
|
||||
Customer/SKU filters are absent. Lastleg validates persisted task/page binding, page counts,
|
||||
duplicate IDs and ordering before admitting coverage. Only quantity/identity whitelist fields return.
|
||||
Empty first-page current=1,size=10,total=0,pages=0,records=[] is normalized to the requested size.
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [warehouseCode, startTime, endTime, current, size]
|
||||
properties:
|
||||
warehouseCode: {type: string, minLength: 1, maxLength: 64}
|
||||
startTime: {type: string, format: date-time, description: Beijing local datetime without offset}
|
||||
endTime: {type: string, format: date-time, description: Inclusive bound, at most one day after start}
|
||||
current: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1, maximum: 1000}
|
||||
responses:
|
||||
'200':
|
||||
description: Envelope of QueryPage with current/size/total/pages/records and source pagination evidence, or sanitized ProviderFailure. Rows retain string id, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, billType, productQuality, numeric bookQty/closeQty and string bookChangQty/createTime.
|
||||
/internal/integrations/lingxing/wms/inventory-flow/query:
|
||||
post:
|
||||
summary: One SKU inventory-flow diagnostic page
|
||||
@@ -59,6 +87,9 @@ paths:
|
||||
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.
|
||||
Source movement IDs must be strings. An empty first response with current=1, size=10, total=0,
|
||||
pages=0 and records=[] is normalized to requested size=1000 while retaining sourceSize=10.
|
||||
Other pagination mismatches remain errors.
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
requestBody:
|
||||
@@ -76,7 +107,7 @@ paths:
|
||||
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.
|
||||
description: Standard envelope with QueryPage or sanitized ProviderFailure. Flow rows retain id as string, customerCode, whCode, productSku, cellNo, innerBatchNo, billNo, sourceNo, 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
|
||||
|
||||
+50
-2
@@ -22,6 +22,19 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** Synthetic loopback transport only; no supplier or application services. */
|
||||
class LingxingWmsClientTest {
|
||||
@Test void warehouseFlowPageKeepsScopeAndUsesSharedPermit() throws Exception {
|
||||
response.set(page("current","size",2,1,2,2,"{\"id\":\"F2\",\"productQuality\":0,\"customerCode\":\"C\",\"whCode\":\"W\",\"productSku\":\"SKU\",\"sourceNo\":\"SOURCE\",\"bookChangQty\":\"-1\"}"));
|
||||
var request=new InventoryFlowPageRequest("W",LocalDateTime.parse("2026-10-01T00:00:00"),LocalDateTime.parse("2026-10-02T00:00:00"),2,1);
|
||||
var result=client().queryInventoryFlowPage(request,context());
|
||||
assertThat(result.records().get(0).sourceNo()).isEqualTo("SOURCE");
|
||||
var business=mapper.readTree(body.get()).path("data");
|
||||
assertThat(business.path("whCode").asText()).isEqualTo("W");
|
||||
assertThat(business.has("customerCode")).isFalse();assertThat(business.has("productSku")).isFalse();
|
||||
assertThat(business.path("current").asInt()).isEqualTo(2);
|
||||
assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW);
|
||||
assertThrows(LingxingWmsException.class,()->client().queryInventoryFlowPage(new InventoryFlowPageRequest("W",request.startTime(),request.endTime().plusSeconds(1),1,1000),context()));
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@Test void customerDirectoryKeepsNamesAndUsesAccountGate() {
|
||||
response.set(page("page","pageSize",1,1000,1,1,"{\"customerCode\":\"C\",\"customerName\":\"示例货主\",\"status\":\"0\",\"telephoneNo\":\"not-forwarded\"}"));
|
||||
var result=client().queryCustomers(new WarehouseQueryRequest(1,1000),context());
|
||||
@@ -315,8 +328,8 @@ class LingxingWmsClientTest {
|
||||
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"}
|
||||
{"id":"9223372036854775806","customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO",
|
||||
"productQuality":0,"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");
|
||||
@@ -324,10 +337,45 @@ class LingxingWmsClientTest {
|
||||
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).productQuality()).isZero();
|
||||
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 flowRejectsNumericIdInsteadOfCoercingUnverifiedSourceTypes() {
|
||||
response.set(page("current","size",1,1000,1,1,"""
|
||||
{"id":123,"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU-DEMO",
|
||||
"productQuality":0,"bookQty":-1,"bookChangQty":"-1","closeQty":9}
|
||||
"""));
|
||||
contract(()->client().queryInventoryFlow(flow(),context()),"TEXT_FIELD_TYPE_NUMBER");
|
||||
}
|
||||
@Test void flowAcceptsVerifiedEmptyFirstPageAndPreservesSourcePagination() {
|
||||
response.set(page("current","size",1,10,0,0,""));
|
||||
var result=client().queryInventoryFlow(flow(),context());
|
||||
assertThat(result.records()).isEmpty();
|
||||
assertThat(result.total()).isZero();
|
||||
assertThat(result.current()).isEqualTo(1);
|
||||
assertThat(result.size()).isEqualTo(1000);
|
||||
assertThat(result.sourceSize()).isEqualTo(10);
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
assertThat(completion.get().operation()).isEqualTo(Operation.INVENTORY_FLOW);
|
||||
}
|
||||
@Test void flowEmptyExceptionDoesNotAcceptNonemptyOrMalformedPagination() {
|
||||
var c=client();
|
||||
response.set(page("current","size",1,10,1,1,"{}"));
|
||||
contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH");
|
||||
response.set(page("current","size",2,10,0,0,""));
|
||||
contract(()->c.queryInventoryFlow(flow(),context()),"CURRENT_MISMATCH");
|
||||
response.set(page("current","size",1,10,0,1,""));
|
||||
contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH");
|
||||
response.set(page("current","size",1,20,0,0,""));
|
||||
contract(()->c.queryInventoryFlow(flow(),context()),"SIZE_MISMATCH");
|
||||
}
|
||||
@Test void flowEmptyExceptionDoesNotRelaxInventoryOrOtherOutboundSizes() {
|
||||
var c=client();response.set(page("current","size",1,10,0,0,""));
|
||||
contract(()->c.queryInventory(new InventoryQueryRequest("W-DEMO",1,1000),context()),"SIZE_MISMATCH");
|
||||
contract(()->c.queryPage(query(1,20),context()),"SIZE_MISMATCH");
|
||||
}
|
||||
@Test void flowPermissionDenialIsScopedAndDoesNotStopOrdinaryQueries() {
|
||||
var c=client();response.set("{\"code\":11008,\"msg\":\"private\"}");
|
||||
var error=assertThrows(LingxingWmsException.class,()->c.queryInventoryFlow(flow(),context()));
|
||||
|
||||
Reference in New Issue
Block a user