feat(selfop): sync versioned owner and product directories

This commit is contained in:
2026-10-14 16:15:39 +08:00
parent 2b3b13913b
commit d311d9dc8d
47 changed files with 803 additions and 66 deletions
@@ -88,6 +88,24 @@ public class LingxingInternalController {
return ApiResponse.success(client.queryInventory(request, context(context)));
}
@PostMapping("/customers/query")
public ApiResponse<QueryPage<Customer>> customers(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context,
@Valid @RequestBody WarehouseQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryCustomers(request,context(context)));
}
@PostMapping("/products/query")
public ApiResponse<QueryPage<Product>> products(
@RequestHeader(value="X-Internal-Service-Token",required=false) String token,
@RequestHeader(value="X-Lingxing-Egress-Context",required=false) String context,
@Valid @RequestBody ProductQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryProducts(request,context(context)));
}
private EgressContext context(String header) {
if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126))
throw invalidContext();
@@ -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 }
public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS }
public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId,
String sessionId, String attemptId, String exclusiveGrantId) {
public boolean wellFormed() {
@@ -19,6 +19,11 @@ public final class LingxingWmsDtos {
@Min(1) int current, @Min(1) @Max(100) int size) { }
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 Customer(String customerCode, String customerName, String status) { }
public record Product(String customerCode, String sku, String productName,
BigDecimal length, BigDecimal width, BigDecimal height, String sizeUnit,
BigDecimal wmsLength, BigDecimal wmsWidth, BigDecimal wmsHeight, String wmsSizeUnit) { }
public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo,
@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode,
@@ -133,6 +133,24 @@ public final class LingxingWmsClient {
optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata);
}
public QueryPage<Customer> queryCustomers(WarehouseQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
validatePagination(request.current(), request.size(), 1000);
return execute("/openapi/v2/customer/list", pagination(request.current(), request.size()), context, Operation.CUSTOMERS,
(data, metadata) -> mappedPage(data, request.current(), request.size(), "current", "size", false, metadata,
row -> new Customer(requiredText(row,"customerCode",64), optionalText(row,"customerName"), optionalText(row,"status"))));
}
public QueryPage<Product> queryProducts(ProductQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
validatePagination(request.current(), request.size(), 100);
return execute("/openapi/v2/product/page", pagination(request.current(), request.size()), context, Operation.PRODUCTS,
(data, metadata) -> mappedPage(data, request.current(), request.size(), "current", "size", false, metadata,
row -> new Product(requiredText(row,"customerCode",64), requiredText(row,"sku",128), optionalText(row,"productName"),
quantity(row.get("length")),quantity(row.get("width")),quantity(row.get("height")),optionalText(row,"sizeUnit"),
quantity(row.get("wmsLength")),quantity(row.get("wmsWidth")),quantity(row.get("wmsHeight")),optionalText(row,"wmsSizeUnit"))));
}
public QueryPage<ProductStock> queryInventory(InventoryQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.warehouseCode(), 64);
@@ -51,6 +51,40 @@ x-lastleg-egress-coordination:
security:
- InternalServiceToken: []
paths:
/internal/integrations/lingxing/wms/customers/query:
post:
summary: Query one page of customers using the existing account egress permit
description: Calls /openapi/v2/customer/list. BUSINESS MASTER_DATA tasks only; no retries or separate egress path. Dimensions and units remain separate; blanks are null.
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/WarehouseQueryRequest'}
responses:
'200':
description: Standard envelope with one page or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/CustomerResponse'}
/internal/integrations/lingxing/wms/products/query:
post:
summary: Query one page of products using the existing account egress permit
description: Calls /openapi/v2/product/page. BUSINESS MASTER_DATA tasks only; no retries or separate egress path. Dimensions and units remain separate; blanks are null.
parameters:
- {$ref: '#/components/parameters/LingxingEgressContext'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/ProductQueryRequest'}
responses:
'200':
description: Standard envelope with one page or sanitized ProviderFailure.
content:
application/json:
schema: {$ref: '#/components/schemas/ProductResponse'}
/internal/integrations/lingxing/wms/egress/runtime:
get:
operationId: lingxingEgressRuntime
@@ -175,6 +209,68 @@ components:
in: header
name: X-Internal-Service-Token
schemas:
CustomerPage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/Customer'}}
CustomerResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/CustomerPage'}
- {$ref: '#/components/schemas/ProviderFailure'}
ProductPage:
allOf:
- {$ref: '#/components/schemas/Pagination'}
- type: object
required: [records]
properties:
records: {type: array, items: {$ref: '#/components/schemas/Product'}}
ProductResponse:
allOf:
- {$ref: '#/components/schemas/Envelope'}
- type: object
properties:
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/ProductPage'}
- {$ref: '#/components/schemas/ProviderFailure'}
ProductQueryRequest:
type: object
required: [current, size]
properties:
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 100}
Customer:
type: object
required: [customerCode]
properties:
customerCode: {type: string}
customerName: {type: string, nullable: true}
status: {type: string, nullable: true}
Product:
type: object
required: [customerCode, sku]
properties:
customerCode: {type: string}
sku: {type: string}
productName: {type: string, nullable: true}
length: {type: number, nullable: true}
width: {type: number, nullable: true}
height: {type: number, nullable: true}
wmsLength: {type: number, nullable: true}
wmsWidth: {type: number, nullable: true}
wmsHeight: {type: number, nullable: true}
sizeUnit: {type: string, nullable: true}
wmsSizeUnit: {type: string, nullable: true}
EgressRuntimeSnapshot:
type: object
additionalProperties: false
@@ -219,7 +315,7 @@ components:
{"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0}
EgressOperation:
type: string
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY]
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY, CUSTOMERS, PRODUCTS]
EgressMetadata:
type: object
required: [attemptId, actualHttpAttempts, accountPaused]
@@ -22,6 +22,23 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
/** Synthetic loopback transport only; no supplier or application services. */
class LingxingWmsClientTest {
@Test void customerDirectoryKeepsNamesAndUsesAccountGate() {
response.set(page("current","size",1,1000,1,1,"{\"customerCode\":\"C\",\"customerName\":\"示例货主\",\"status\":\"0\",\"telephoneNo\":\"not-forwarded\"}"));
var result=client().queryCustomers(new WarehouseQueryRequest(1,1000),context());
assertThat(path.get()).isEqualTo("/openapi/v2/customer/list");
assertThat(result.records()).containsExactly(new Customer("C","示例货主","0"));
assertThat(completion.get().operation()).isEqualTo(Operation.CUSTOMERS);
}
@Test void productDirectoryRetainsDeclaredDimensionsAndUnknownMeasurements() {
response.set(page("current","size",1,100,1,1,"{\"customerCode\":\"C\",\"sku\":\"SKU\",\"productName\":\"商品\",\"length\":5,\"width\":11,\"height\":5,\"sizeUnit\":\"cm\",\"wmsLength\":\"\",\"wmsWidth\":\"\",\"wmsHeight\":\"\",\"wmsSizeUnit\":\"\"}"));
var result=client().queryProducts(new ProductQueryRequest(1,100),context());
assertThat(result.records().get(0).length()).isEqualByComparingTo("5");
assertThat(result.records().get(0).wmsLength()).isNull();
assertThat(result.records().get(0).sku()).isEqualTo("SKU");
assertThat(completion.get().operation()).isEqualTo(Operation.PRODUCTS);
assertThatThrownBy(()->client().queryProducts(new ProductQueryRequest(1,101),context())).isInstanceOf(LingxingWmsException.class);
assertThat(calls.get()).isEqualTo(1);
}
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
private static final String PRIVATE = "private-secret@example.test";
private static final String ORDER = """