feat(selfop): reconstruct historical inventory and publish qualified turnover

This commit is contained in:
2026-10-15 13:30:37 +08:00
parent d81d9cbda4
commit 99215bbb79
35 changed files with 1652 additions and 106 deletions
@@ -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(
@@ -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,
@@ -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
@@ -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()));