fix: complete single-order acceptance and add Lingxing WMS integration

This commit is contained in:
2026-10-12 16:09:21 +08:00
parent 6c4ee2bfde
commit c06e788b0f
27 changed files with 2305 additions and 46 deletions
+3
View File
@@ -0,0 +1,3 @@
# Copy to .env.local for local WMS connectivity verification.
LINGXING_WMS_APP_KEY=
LINGXING_WMS_APP_SECRET=
@@ -0,0 +1,49 @@
package com.baiyue.integration.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.net.URI;
import java.time.Duration;
@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms")
public record LingxingWmsProperties(
@DefaultValue("false") boolean enabled,
@DefaultValue("https://api.xlwms.com") URI baseUri,
String appKey,
String appSecret,
@DefaultValue("5s") Duration connectTimeout,
@DefaultValue("15s") Duration requestTimeout,
@DefaultValue("1s") Duration minRequestInterval
) {
public LingxingWmsProperties {
baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri;
connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout;
requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout;
minRequestInterval = minRequestInterval == null ? Duration.ofSeconds(1) : minRequestInterval;
if (connectTimeout.isZero() || connectTimeout.isNegative()
|| requestTimeout.isZero() || requestTimeout.isNegative()
|| minRequestInterval.isNegative() || minRequestInterval.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("领星 WMS 超时必须为正数,最小请求间隔须在 0 至 1 天之间");
}
String host = baseUri.getHost();
boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host)
|| "[::1]".equals(host);
boolean secureScheme = "https".equalsIgnoreCase(baseUri.getScheme())
|| ("http".equalsIgnoreCase(baseUri.getScheme()) && loopback);
if (host == null || !secureScheme || baseUri.getUserInfo() != null
|| baseUri.getQuery() != null || baseUri.getFragment() != null
|| (!baseUri.getPath().isEmpty() && !"/".equals(baseUri.getPath()))) {
throw new IllegalArgumentException("领星 WMS base-uri 必须是 HTTPS 源地址;仅本机测试允许 HTTP");
}
}
public boolean hasCredentials() {
return appKey != null && !appKey.isBlank() && appSecret != null && !appSecret.isBlank();
}
@Override
public String toString() {
return "LingxingWmsProperties[enabled=" + enabled + ", credentialsConfigured=" + hasCredentials() + "]";
}
}
@@ -0,0 +1,55 @@
package com.baiyue.integration.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.baiyue.integration.provider.lingxing.LingxingWmsClient;
import com.baiyue.integration.provider.lingxing.LingxingWmsException;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "领星 WMS 内部查询", description = "仅内部服务令牌认证,不提供公网路由")
@RestController
@RequestMapping("/internal/integrations/lingxing/wms")
public class LingxingInternalController {
private final InternalServiceTokenGuard tokenGuard;
private final LingxingWmsClient client;
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client) {
this.tokenGuard = tokenGuard;
this.client = client;
}
@Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要")
@PostMapping("/outbound-orders/query")
public ApiResponse<PageResult> query(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@Valid @RequestBody QueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryPage(request));
}
@ExceptionHandler(LingxingWmsException.class)
public ApiResponse<ProviderFailure> handleProviderFailure(LingxingWmsException exception,
HttpServletRequest request) {
ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR;
return ApiResponse.of(code, exception.getMessage(),
new ProviderFailure(exception.code(), exception.retryable(),
exception.upstreamStatus(), exception.sourceCode()),
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
}
public record ProviderFailure(String errorCode, boolean retryable,
Integer upstreamStatus, Integer sourceCode) { }
}
@@ -0,0 +1,25 @@
package com.baiyue.integration.dto.lingxing;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
public final class LingxingWmsDtos {
private LingxingWmsDtos() { }
public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime,
@Min(1) int current, @Min(1) @Max(100) int size) { }
// Preserve source time strings until the account's source timezone has been verified.
public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode,
Integer status, String createTime, String outboundTime) { }
public record PageResult(List<OrderSummary> records, Long total, int current, int size,
int sourceCode, Instant observedAt) {
public PageResult { records = List.copyOf(records); }
}
}
@@ -0,0 +1,263 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Semaphore;
@Component
public final class LingxingWmsClient {
private static final String PAGE_PATH = "/openapi/v2/order/delivery/page";
private static final Set<Integer> AUTH_ERROR_CODES = Set.of(11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001);
private static final DateTimeFormatter SOURCE_TIME = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final LingxingWmsProperties properties;
private final ObjectMapper mapper;
private final LingxingWmsSigner signer;
private final HttpClient httpClient;
private final Clock clock;
private final Semaphore concurrency = new Semaphore(1);
private long lastRequestNanos;
private boolean hasRequested;
@Autowired
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper) {
this(properties, mapper, Clock.systemUTC());
}
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock) {
this.properties = properties;
this.mapper = mapper;
this.clock = clock;
signer = new LingxingWmsSigner(mapper);
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout())
.followRedirects(HttpClient.Redirect.NEVER).build();
}
public PageResult queryPage(QueryRequest request) {
if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false);
if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false);
validate(request);
if (!concurrency.tryAcquire()) throw failure("LOCAL_RATE_LIMITED", "领星 WMS 查询正在执行,请稍后重试", true);
try {
reserveRequest();
Map<String, Object> data = new LinkedHashMap<>();
data.put("current", request.current());
data.put("size", request.size());
data.put("timeType", "outboundTime");
data.put("startTime", SOURCE_TIME.format(request.startTime()));
data.put("endTime", SOURCE_TIME.format(request.endTime()));
data.put("status", 100);
var signed = signer.sign(properties.appKey(), properties.appSecret(), PAGE_PATH, data, clock.instant());
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(PAGE_PATH))
.timeout(properties.requestTimeout()).header("Accept", "application/json")
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build();
HttpResponse<String> response = send(httpRequest);
validateStatus(response.statusCode());
return page(response.body(), request);
} finally {
concurrency.release();
}
}
private void validate(QueryRequest request) {
if (request == null || request.startTime() == null || request.endTime() == null
|| !request.startTime().truncatedTo(ChronoUnit.SECONDS).isBefore(request.endTime().truncatedTo(ChronoUnit.SECONDS))
|| request.startTime().isBefore(request.endTime().minusMonths(1))
|| request.current() < 1 || request.size() < 1 || request.size() > 100) {
// These are local request bounds, not a claim about the supplier's pagination limits.
throw failure("INVALID_REQUEST", "领星 WMS 查询范围最多一个自然月,每页条数须为 1 至 100", false);
}
}
private void reserveRequest() {
long now = System.nanoTime();
if (hasRequested && now - lastRequestNanos < properties.minRequestInterval().toNanos()) {
throw failure("LOCAL_RATE_LIMITED", "领星 WMS 请求过于频繁,请稍后重试", true);
}
hasRequested = true;
lastRequestNanos = now;
}
private HttpResponse<String> send(HttpRequest request) {
try {
return httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
} catch (HttpTimeoutException exception) {
throw failure("TIMEOUT", "领星 WMS 请求超时", true);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw failure("INTERRUPTED", "领星 WMS 请求被中断", true);
} catch (IOException exception) {
throw failure("IO_FAILED", "领星 WMS 网络请求失败", true);
}
}
private void validateStatus(int status) {
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, status, null);
if (status == 401 || status == 403) {
throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null);
}
if (status != 200) {
throw upstreamFailure("HTTP_FAILED", "领星 WMS 返回异常 HTTP 状态", status >= 500, status, null);
}
}
private PageResult page(String body, QueryRequest request) {
JsonNode root;
try {
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body);
} catch (JsonProcessingException exception) {
throw invalidResponse("JSON", null);
}
if (root == null || !root.isObject()) throw invalidResponse("ROOT_TYPE_" + nodeType(root), null);
JsonNode sourceCode = root.get("code");
// Official OMP ErrorResponse is distinct from its integer-code business response.
if (sourceCode != null && sourceCode.isTextual()) {
Integer errorCode = diagnosticCode(sourceCode);
if (errorCode != null && AUTH_ERROR_CODES.contains(errorCode)) throw sourceRejected(errorCode);
throw invalidResponse("CODE_TYPE_STRING", errorCode);
}
if (sourceCode == null || !sourceCode.isIntegralNumber() || !sourceCode.canConvertToInt()) {
throw invalidResponse("CODE_TYPE_" + nodeType(sourceCode), diagnosticCode(sourceCode));
}
if (sourceCode.intValue() != 200) {
throw sourceRejected(sourceCode.intValue());
}
JsonNode success = root.get("success");
// OMP marks success required, but the verified WMS response omits it; validate it when present.
if (success != null) {
if (!success.isBoolean()) throw invalidResponse("SUCCESS_TYPE_" + nodeType(success), 200);
if (!success.booleanValue()) throw sourceRejected(200);
}
JsonNode data = root.get("data");
if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200);
JsonNode records = data.get("records");
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200);
if (records.size() > request.size()) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
Long total = optionalTotal(data.get("total"));
if (total != null && total < records.size()) throw invalidResponse("TOTAL_BELOW_RECORD_COUNT", 200);
verifyPage(data.get("current"), request.current(), "CURRENT");
verifyPage(data.get("size"), request.size(), "SIZE");
List<OrderSummary> summaries = new ArrayList<>();
for (JsonNode record : records) {
if (!record.isObject()) throw invalidResponse("ORDER_TYPE_" + nodeType(record), 200);
String deliveryNo = optionalText(record, "deliveryNo");
if (deliveryNo == null || deliveryNo.isBlank()) throw invalidResponse("DELIVERY_NO_EMPTY", 200);
summaries.add(new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), optionalText(record, "customerCode"),
optionalText(record, "whCode"), optionalStatus(record.get("status")),
optionalText(record, "createTime"), optionalText(record, "outboundTime")));
}
return new PageResult(summaries, total, request.current(), request.size(), 200, clock.instant());
}
private static String optionalText(JsonNode record, String field) {
JsonNode value = record.get(field);
if (value == null || value.isNull()) return null;
if (!value.isTextual()) {
String stage = switch (field) {
case "deliveryNo" -> "DELIVERY_NO";
case "sourceNo" -> "SOURCE_NO";
case "customerCode" -> "CUSTOMER_CODE";
case "whCode" -> "WAREHOUSE_CODE";
case "createTime" -> "CREATE_TIME";
case "outboundTime" -> "OUTBOUND_TIME";
default -> "TEXT_FIELD";
};
throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
}
return value.textValue();
}
private static Integer optionalStatus(JsonNode value) {
if (missingNumber(value)) return null;
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
throw invalidResponse("STATUS_TYPE_" + nodeType(value), 200);
}
return value.intValue();
}
private static Long optionalTotal(JsonNode value) {
if (missingNumber(value)) return null;
if (!value.isIntegralNumber() || !value.canConvertToLong()) {
throw invalidResponse("TOTAL_TYPE_" + nodeType(value), 200);
}
if (value.longValue() < 0) throw invalidResponse("TOTAL_NEGATIVE", 200);
return value.longValue();
}
private static void verifyPage(JsonNode value, int expected, String stage) {
if (missingNumber(value)) return;
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
}
if (value.intValue() != expected) throw invalidResponse(stage + "_MISMATCH", 200);
}
private static Integer diagnosticCode(JsonNode value) {
if (value == null) return null;
if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue();
// Only the documented authentication error envelope accepts string error codes.
if (!value.isTextual() || !value.textValue().matches("[0-9]{1,10}")) return null;
try {
return Integer.valueOf(value.textValue());
} catch (NumberFormatException exception) {
return null;
}
}
private static String nodeType(JsonNode value) {
return value == null ? "MISSING" : value.getNodeType().name();
}
private static boolean missingNumber(JsonNode value) {
// OMP explicitly serializes null numeric fields as an empty string or null.
return value == null || value.isNull() || (value.isTextual() && value.textValue().isEmpty());
}
private static LingxingWmsException sourceRejected(int code) {
if (code == 11008) {
return upstreamFailure("AUTHORIZATION_REJECTED",
"领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code);
}
if (code == 11006) {
return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code);
}
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", code == 31001, 200, code);
}
private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) {
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合出库分页接口契约(" + stage + ")",
false, 200, sourceCode);
}
private static LingxingWmsException failure(String code, String message, boolean retryable) {
return upstreamFailure(code, message, retryable, null, null);
}
private static LingxingWmsException upstreamFailure(String code, String message, boolean retryable,
Integer status, Integer sourceCode) {
return new LingxingWmsException("LINGXING_WMS_" + code, message, retryable, status, sourceCode);
}
}
@@ -0,0 +1,24 @@
package com.baiyue.integration.provider.lingxing;
/** Only fixed messages and numeric upstream metadata may leave the adapter. */
public final class LingxingWmsException extends RuntimeException {
private final String code;
private final boolean retryable;
private final Integer upstreamStatus;
private final Integer sourceCode;
public LingxingWmsException(String code, String message, boolean retryable,
Integer upstreamStatus, Integer sourceCode) {
// Upstream exceptions can contain signed URLs or response fragments; never attach them.
super(message);
this.code = code;
this.retryable = retryable;
this.upstreamStatus = upstreamStatus;
this.sourceCode = sourceCode;
}
public String code() { return code; }
public boolean retryable() { return retryable; }
public Integer upstreamStatus() { return upstreamStatus; }
public Integer sourceCode() { return sourceCode; }
}
@@ -0,0 +1,74 @@
package com.baiyue.integration.provider.lingxing;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.ObjectWriter;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.node.ObjectNode;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.Map;
import java.util.StringJoiner;
/** OMP service-provider signature; the data object keeps its transmitted insertion order. */
public final class LingxingWmsSigner {
private final ObjectMapper mapper;
private final ObjectWriter writer;
public LingxingWmsSigner(ObjectMapper mapper) {
this.mapper = mapper;
writer = mapper.writer().without(SerializationFeature.INDENT_OUTPUT, SerializationFeature.WRAP_ROOT_VALUE);
}
public SignedRequest sign(String appKey, String appSecret, String path, Map<String, ?> data, Instant requestedAt) {
if (appKey == null || appKey.isBlank() || appSecret == null || appSecret.isBlank()
|| path == null || !path.startsWith("/openapi/v2/") || path.contains("?")
|| data == null || requestedAt == null) throw failure();
try {
JsonNode dataNode = mapper.valueToTree(data);
String timestamp = Long.toString(requestedAt.getEpochSecond());
// Official OMP sample uses Map/List string form, not JSON, and includes parameter names.
String input = appSecret + path + "appKey" + appKey + "data" + parameterValue(dataNode)
+ "timestamp" + timestamp + appSecret;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
String signature = HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
ObjectNode body = mapper.createObjectNode();
body.put("appKey", appKey);
body.set("data", dataNode);
body.put("timestamp", timestamp);
body.put("sign", signature);
return new SignedRequest(writer.writeValueAsString(body));
} catch (JsonProcessingException | GeneralSecurityException | IllegalArgumentException exception) {
throw failure();
}
}
private static String parameterValue(JsonNode value) {
if (value.isObject()) {
StringJoiner rendered = new StringJoiner(", ", "{", "}");
value.fields().forEachRemaining(field -> rendered.add(field.getKey() + "=" + parameterValue(field.getValue())));
return rendered.toString();
}
if (value.isArray()) {
StringJoiner rendered = new StringJoiner(", ", "[", "]");
value.forEach(child -> rendered.add(parameterValue(child)));
return rendered.toString();
}
return value.isNull() ? "null" : value.asText();
}
private static LingxingWmsException failure() {
return new LingxingWmsException("LINGXING_WMS_SIGNATURE_ERROR", "领星 WMS 请求签名失败", false, null, null);
}
public record SignedRequest(String body) {
@Override public String toString() { return "SignedRequest[REDACTED]"; }
}
}
@@ -0,0 +1,68 @@
# Select explicitly with --spring.config.location=classpath:/application-lingxing-local.yml.
# No remote imports, database, registry or scheduled jobs are used by this local instance.
server:
address: 127.0.0.1
port: ${INTEGRATION_LOCAL_PORT:19210}
spring:
application:
name: integration-service
autoconfigure:
exclude:
- org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration
- com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration
cloud:
bootstrap:
enabled: false
discovery:
enabled: false
service-registry:
auto-registration:
enabled: false
nacos:
config:
enabled: false
import-check:
enabled: false
discovery:
enabled: false
register-enabled: false
baiyue:
runtime:
scheduling-enabled: false
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
integration:
lingxing-wms:
enabled: ${LINGXING_WMS_ENABLED:false}
base-uri: https://api.xlwms.com
app-key: ${LINGXING_WMS_APP_KEY:}
app-secret: ${LINGXING_WMS_APP_SECRET:}
connect-timeout: 5s
request-timeout: 15s
min-request-interval: 1s
baidu-ocr:
enabled: false
amazon-sp-api:
enabled: false
carrier-schedule:
matson:
enabled: false
hede:
enabled: false
sml:
enabled: false
zim:
enabled: false
evergreen:
enabled: false
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: never
@@ -35,6 +35,14 @@ baiyue:
security:
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
integration:
lingxing-wms:
enabled: ${LINGXING_WMS_ENABLED:false}
base-uri: https://api.xlwms.com
app-key: ${LINGXING_WMS_APP_KEY:}
app-secret: ${LINGXING_WMS_APP_SECRET:}
connect-timeout: 5s
request-timeout: 15s
min-request-interval: 1s
baidu-ocr:
enabled: ${BAIDU_OCR_ENABLED:false}
api-key: ${BAIDU_OCR_API_KEY:}
@@ -0,0 +1,81 @@
openapi: 3.0.3
info:
title: Lingxing WMS Internal Read API
version: 1.0.0
description: Read-only provider adapter. No business scheduling, persistence, inventory mutation or public Gateway route.
security:
- InternalServiceToken: []
paths:
/internal/integrations/lingxing/wms/outbound-orders/query:
post:
summary: Query one page of completed WMS outbound summaries by outbound time
description: Calls OMP /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. Source time strings are preserved. This is a single page, not a complete sales report. It uses the self-developed-system API authorization in the WMS management console.
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/QueryRequest'}
responses:
'200':
description: Standard envelope; code 200 for success, A0401 for rejected internal identity, A0422 for bean validation, B0001 or S0503 for provider/configuration failures. Failures never return a successful empty page.
content:
application/json:
schema: {$ref: '#/components/schemas/Response'}
components:
securitySchemes:
InternalServiceToken:
type: apiKey
in: header
name: X-Internal-Service-Token
schemas:
QueryRequest:
type: object
required: [startTime, endTime, current, size]
properties:
startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime; must precede endTime and must not be earlier than endTime minus one calendar month. This is a local guard.}
endTime: {type: string, example: '2026-10-12T08:00:00', description: Source local datetime.}
current: {type: integer, minimum: 1}
size: {type: integer, minimum: 1, maximum: 100, description: Local request cap; not a claim about the provider quota. Use 1 for connectivity verification.}
OrderSummary:
type: object
required: [deliveryNo]
properties:
deliveryNo: {type: string}
sourceNo: {type: string, nullable: true}
customerCode: {type: string, nullable: true}
whCode: {type: string, nullable: true}
status: {type: integer, nullable: true}
createTime: {type: string, nullable: true}
outboundTime: {type: string, nullable: true}
PageResult:
type: object
required: [records, current, size, sourceCode, observedAt]
properties:
records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}}
total: {type: string, pattern: '^[0-9]+$', nullable: true, description: Provider total only when supplied. Java Long follows the platform JSON string convention.}
current: {type: integer}
size: {type: integer}
sourceCode: {type: integer}
observedAt: {type: string, format: date-time}
ProviderFailure:
type: object
required: [errorCode, retryable]
properties:
errorCode: {type: string}
retryable: {type: boolean}
upstreamStatus: {type: integer, nullable: true}
sourceCode: {type: integer, nullable: true}
Response:
type: object
required: [code, message, data, timestamp]
properties:
code: {type: string}
message: {type: string, description: Sanitized local message; never the raw provider response.}
data:
nullable: true
oneOf:
- {$ref: '#/components/schemas/PageResult'}
- {$ref: '#/components/schemas/ProviderFailure'}
requestId: {type: string, nullable: true}
traceId: {type: string, nullable: true}
timestamp: {type: string, format: date-time}
@@ -0,0 +1,100 @@
package com.baiyue.integration.controller;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.common.web.LongIdJacksonConfiguration;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.provider.lingxing.LingxingWmsClient;
import com.baiyue.integration.provider.lingxing.LingxingWmsException;
import com.baiyue.integration.security.InternalServiceTokenGuard;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.http.MediaType;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class LingxingInternalControllerTest {
private static final String PATH = "/internal/integrations/lingxing/wms/outbound-orders/query";
private static final String BODY = """
{"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1}
""";
private final String token = UUID.randomUUID().toString();
private LingxingWmsClient client;
private MockMvc mvc;
@BeforeEach
void setUp() {
var guard = new InternalServiceTokenGuard();
ReflectionTestUtils.setField(guard, "token", token);
ReflectionTestUtils.invokeMethod(guard, "initializeToken");
client = mock(LingxingWmsClient.class);
var builder = Jackson2ObjectMapperBuilder.json();
try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) {
context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder);
}
mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client))
.setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build()))
.setControllerAdvice(new GlobalExceptionHandler()).build();
}
@Test
void missingOrWrongInternalTokenCannotCallProvider() throws Exception {
mvc.perform(post(PATH).contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
mvc.perform(post(PATH).header("X-Internal-Service-Token", "wrong")
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
verifyNoInteractions(client);
}
@Test
void authenticatedReadReturnsOnlyOutboundSummary() throws Exception {
when(client.queryPage(any())).thenReturn(new PageResult(
List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100,
"2026-10-01 08:00:00", "2026-10-02 08:00:00")),
1L, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z")));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"))
.andExpect(jsonPath("$.data.total").isString()).andExpect(jsonPath("$.data.total").value("1"))
.andExpect(jsonPath("$.data.records[0].deliveryNo").value("TEST-OUT-001"))
.andExpect(jsonPath("$.data.records[0].recipient").doesNotExist());
}
@Test
void invalidPageCannotReachProvider() throws Exception {
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
.contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0")))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
verifyNoInteractions(client);
}
@Test
void providerFailureRemainsAnExplicitBusinessFailure() throws Exception {
when(client.queryPage(any())).thenThrow(new LingxingWmsException(
"LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null));
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
.contentType(MediaType.APPLICATION_JSON).content(BODY))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503"))
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED"))
.andExpect(jsonPath("$.data.upstreamStatus").value(429))
.andExpect(jsonPath("$.data.records").doesNotExist());
}
}
@@ -0,0 +1,261 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.net.InetSocketAddress;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.HexFormat;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.junit.jupiter.api.Assertions.assertThrows;
class LingxingWmsClientTest {
private static final String APP_KEY = "local-test-app-key";
private static final String APP_SECRET = "local-test-app-secret";
private static final String PRIVATE_DATA = "customer-private@example.test";
private static final String PATH = "/openapi/v2/order/delivery/page";
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00.987Z");
private static final String EMPTY = "{\"code\":200,\"success\":true,\"data\":{\"total\":0,\"current\":1,\"size\":1,\"records\":[]}}";
private final ObjectMapper mapper = new ObjectMapper();
private final AtomicInteger calls = new AtomicInteger();
private final AtomicInteger responseStatus = new AtomicInteger(200);
private final AtomicReference<String> responseBody = new AtomicReference<>(EMPTY);
private final AtomicReference<String> receivedBody = new AtomicReference<>();
private final AtomicReference<URI> receivedUri = new AtomicReference<>();
private final AtomicReference<String> receivedMethod = new AtomicReference<>();
private HttpServer server;
@BeforeEach
void startServer() throws IOException {
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext(PATH, exchange -> {
calls.incrementAndGet();
receivedMethod.set(exchange.getRequestMethod());
receivedUri.set(exchange.getRequestURI());
receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
byte[] bytes = responseBody.get().getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
exchange.sendResponseHeaders(responseStatus.get(), bytes.length);
exchange.getResponseBody().write(bytes);
exchange.close();
});
server.start();
}
@AfterEach
void stopServer() { server.stop(0); }
@Test
void sendsOmpWmsSignatureAndOutboundTimeFilterAndOnlyReturnsWhitelist() throws Exception {
responseBody.set("""
{"code":200,"success":true,"data":{"total":12,"current":1,"size":1,"records":[{
"deliveryNo":"DO-LOCAL-001","sourceNo":"OB-LOCAL-001","customerCode":"C-LOCAL",
"whCode":"WH-LOCAL","status":100,"createTime":"2026-09-21 10:11:12",
"outboundTime":"2026-09-22 20:21:22","email":"customer-private@example.test",
"remark":"local-test-app-secret","receiver":"PRIVATE_RECEIVER"
}]}}
""");
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
var body = mapper.readTree(receivedBody.get());
String timestamp = Long.toString(NOW.getEpochSecond());
String dataValue = "{current=1, size=1, timeType=outboundTime, startTime=2026-09-12 08:00:00, endTime=2026-10-12 08:00:00, status=100}";
String input = APP_SECRET + PATH + "appKey" + APP_KEY + "data" + dataValue + "timestamp" + timestamp + APP_SECRET;
assertThat(body.path("sign").textValue()).isEqualTo(hmac(input));
assertThat(body.path("timestamp").textValue()).isEqualTo(timestamp).matches("\\d{10}");
assertThat(body.path("appKey").textValue()).isEqualTo(APP_KEY);
assertThat(body.path("data").toString()).isEqualTo("{\"current\":1,\"size\":1,\"timeType\":\"outboundTime\",\"startTime\":\"2026-09-12 08:00:00\",\"endTime\":\"2026-10-12 08:00:00\",\"status\":100}");
assertThat(receivedUri.get().getPath()).isEqualTo(PATH);
assertThat(receivedUri.get().getRawQuery()).isNull();
assertThat(receivedMethod.get()).isEqualTo("POST");
assertThat(receivedBody.get()).doesNotContain(APP_SECRET, "authcode", "reqTime", "access_token", "pageSize");
assertThat(result.total()).isEqualTo(12L);
assertThat(result.observedAt()).isEqualTo(NOW);
assertThat(result.sourceCode()).isEqualTo(200);
assertThat(result.records()).singleElement().satisfies(order -> {
assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001");
assertThat(order.sourceNo()).isEqualTo("OB-LOCAL-001");
assertThat(order.customerCode()).isEqualTo("C-LOCAL");
assertThat(order.whCode()).isEqualTo("WH-LOCAL");
assertThat(order.status()).isEqualTo(100);
assertThat(order.createTime()).isEqualTo("2026-09-21 10:11:12");
assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22");
});
String summary = mapper.writeValueAsString(result.records().get(0));
assertThat(mapper.readTree(summary).size()).isEqualTo(7);
assertThat(summary).doesNotContain(PRIVATE_DATA, APP_SECRET, "PRIVATE_RECEIVER");
}
@Test
void emptySuccessfulPageHasNoInventedOrder() {
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
assertThat(result.records()).isEmpty();
assertThat(result.total()).isZero();
assertThat(calls).hasValue(1);
}
@Test
void verifiedWmsEnvelopeWithoutSuccessRequiresValidPage() {
responseBody.set("""
{"code":200,"data":{"total":1,"current":1,"size":1,"records":[{
"deliveryNo":"DO-LOCAL-001","status":100,"outboundTime":"2026-09-22 20:21:22"
}]}}
""");
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
assertThat(result.sourceCode()).isEqualTo(200);
assertThat(result.total()).isEqualTo(1L);
assertThat(result.records()).singleElement().satisfies(order -> {
assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001");
assertThat(order.status()).isEqualTo(100);
assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22");
});
assertThat(calls).hasValue(1);
}
@Test
void documentedEmptyNumericValuesRemainUnknown() {
responseBody.set("{\"code\":200,\"success\":true,\"data\":{\"total\":\"\",\"records\":[{\"deliveryNo\":\"DO-LOCAL\",\"status\":\"\",\"outboundTime\":\"\"}]}}");
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
assertThat(result.total()).isNull();
assertThat(result.records().get(0).status()).isNull();
assertThat(result.records().get(0).outboundTime()).isEmpty();
}
@Test
void disabledAndMissingCredentialsNeverSendRequest() {
assertFailure(client(false, APP_KEY, APP_SECRET, Duration.ZERO), "DISABLED", false);
assertFailure(client(true, "", APP_SECRET, Duration.ZERO), "MISSING_CREDENTIALS", false);
assertFailure(client(true, APP_KEY, " ", Duration.ZERO), "MISSING_CREDENTIALS", false);
assertThat(calls).hasValue(0);
}
@ParameterizedTest
@ValueSource(ints = {11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001})
void documentedStringAuthErrorsNeedNoDataAndDoNotEchoPayloadOrRetry(int sourceCode) {
responseBody.set("{\"code\":\"" + sourceCode + "\",\"msg\":\"" + APP_KEY + APP_SECRET + PRIVATE_DATA + "\"}");
String expected = sourceCode == 11008 ? "AUTHORIZATION_REJECTED"
: sourceCode == 11006 ? "SIGNATURE_REJECTED" : "SOURCE_REJECTED";
var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), expected, sourceCode == 31001);
assertThat(error.sourceCode()).isEqualTo(sourceCode);
assertThat(error.upstreamStatus()).isEqualTo(200);
assertThat(calls).hasValue(1);
}
@Test
void falseBusinessSuccessIsNotAnEmptySuccess() {
responseBody.set("{\"code\":200,\"success\":false,\"msg\":\"" + PRIVATE_DATA + "\"}");
assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), "SOURCE_REJECTED", false);
}
@ParameterizedTest
@ValueSource(ints = {401, 403, 429, 500, 302})
void httpFailuresDoNotEchoBodyOrRetry(int status) {
responseStatus.set(status);
responseBody.set(APP_KEY + APP_SECRET + PRIVATE_DATA);
String code = status == 429 ? "HTTP_RATE_LIMITED" : status == 401 || status == 403 ? "AUTHORIZATION_REJECTED" : "HTTP_FAILED";
var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), code, status == 429 || status >= 500);
assertThat(error.upstreamStatus()).isEqualTo(status);
assertThat(calls).hasValue(1);
}
@Test
void malformedResponsesExposeFixedContractStageOnly() {
String[][] scenarios = {
{"<html>" + PRIVATE_DATA + "</html>", "JSON"},
{"[]", "ROOT_TYPE_ARRAY"},
{"{}", "CODE_TYPE_MISSING"},
{"{\"code\":\"200\"}", "CODE_TYPE_STRING"},
{"{\"code\":\"99999\"}", "CODE_TYPE_STRING"},
{"{\"code\":200}", "DATA_TYPE_MISSING"},
{"{\"code\":200,\"data\":{}}", "RECORDS_TYPE_MISSING"},
{"{\"code\":200,\"success\":null}", "SUCCESS_TYPE_NULL"},
{"{\"code\":200,\"success\":\"true\"}", "SUCCESS_TYPE_STRING"},
{"{\"code\":200,\"success\":true,\"data\":[]}", "DATA_TYPE_ARRAY"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":{}}}", "RECORDS_TYPE_OBJECT"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{},{}]}}", "RECORDS_OVER_PAGE_SIZE"},
{"{\"code\":200,\"success\":true,\"data\":{\"total\":\"0\",\"records\":[]}}", "TOTAL_TYPE_STRING"},
{"{\"code\":200,\"success\":true,\"data\":{\"current\":2,\"records\":[]}}", "CURRENT_MISMATCH"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":1}]}}", "DELIVERY_NO_TYPE_NUMBER"},
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":\"A\",\"status\":\"100\"}]}}", "STATUS_TYPE_STRING"}
};
var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO);
for (String[] scenario : scenarios) {
responseBody.set(scenario[0]);
var error = assertFailure(client, "INVALID_RESPONSE", false);
assertThat(error.getMessage()).contains("(" + scenario[1] + ")");
assertThat(error.upstreamStatus()).isEqualTo(200);
}
}
@Test
void localRateLimitStopsSecondRequest() {
var client = client(true, APP_KEY, APP_SECRET, Duration.ofMinutes(1));
client.queryPage(query());
assertFailure(client, "LOCAL_RATE_LIMITED", true);
assertThat(calls).hasValue(1);
}
@Test
void validatesLocalPaginationAndMonthBeforeSending() {
var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO);
var query = query();
for (var invalid : new QueryRequest[] {
new QueryRequest(query.startTime(), query.endTime(), 0, 1),
new QueryRequest(query.startTime(), query.endTime(), 1, 101),
new QueryRequest(query.endTime(), query.startTime(), 1, 1),
new QueryRequest(query.startTime().minusSeconds(1), query.endTime(), 1, 1),
new QueryRequest(null, query.endTime(), 1, 1)
}) assertThatThrownBy(() -> client.queryPage(invalid)).isInstanceOf(LingxingWmsException.class);
assertThat(calls).hasValue(0);
}
private LingxingWmsException assertFailure(LingxingWmsClient client, String code, boolean retryable) {
var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query()));
assertThat(error.code()).isEqualTo("LINGXING_WMS_" + code);
assertThat(error.retryable()).isEqualTo(retryable);
assertThat(error.getCause()).isNull();
StringWriter trace = new StringWriter();
error.printStackTrace(new PrintWriter(trace));
assertThat(trace.toString()).doesNotContain(APP_KEY, APP_SECRET, PRIVATE_DATA, "sign=", "http://");
return error;
}
private LingxingWmsClient client(boolean enabled, String key, String secret, Duration interval) {
var properties = new LingxingWmsProperties(enabled, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
key, secret, Duration.ofSeconds(2), Duration.ofSeconds(2), interval);
return new LingxingWmsClient(properties, mapper, Clock.fixed(NOW, ZoneOffset.UTC));
}
private static QueryRequest query() {
LocalDateTime end = LocalDateTime.of(2026, 10, 12, 8, 0);
return new QueryRequest(end.minusMonths(1), end, 1, 1);
}
private static String hmac(String input) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(APP_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
}
}
@@ -0,0 +1,91 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingWmsProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.junit.jupiter.api.Test;
import org.springframework.boot.context.properties.bind.Bindable;
import org.springframework.boot.context.properties.bind.Binder;
import org.springframework.boot.context.properties.source.MapConfigurationPropertySource;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class LingxingWmsSignerTest {
@Test
void matchesOfficialOmpParameterRenderingIncludingNamesPathAndBothSecrets() throws Exception {
var mapper = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
var signer = new LingxingWmsSigner(mapper);
var data = new LinkedHashMap<String, Object>();
data.put("status", 0);
data.put("current", 1);
data.put("size", 10);
var signed = signer.sign("YOUR_APP_KEY", "YOUR_APP_SECRET", "/openapi/v2/customer/list", data,
Instant.ofEpochSecond(1790056664));
var body = mapper.readTree(signed.body());
String officialInput = "YOUR_APP_SECRET/openapi/v2/customer/listappKeyYOUR_APP_KEYdata{status=0, current=1, size=10}timestamp1790056664YOUR_APP_SECRET";
assertThat(body.path("sign").textValue()).isEqualTo(hmac("YOUR_APP_SECRET", officialInput));
var fieldNames = new ArrayList<String>();
body.path("data").fieldNames().forEachRemaining(fieldNames::add);
assertThat(fieldNames).containsExactly("status", "current", "size");
assertThat(signed.body()).doesNotContain("\n", "YOUR_APP_SECRET", "authcode", "reqTime");
assertThat(signed.toString()).doesNotContain("YOUR_APP_KEY", "YOUR_APP_SECRET", body.path("sign").textValue());
}
@Test
void nestedDataUsesTransmittedOrderAndMapListValueSyntax() throws Exception {
var mapper = new ObjectMapper();
var signer = new LingxingWmsSigner(mapper);
var child = new LinkedHashMap<String, Object>();
child.put("z", "中文");
child.put("A", 2);
var data = new LinkedHashMap<String, Object>();
data.put("items", List.of(child));
var signed = signer.sign("key", "secret", "/openapi/v2/customer/list", data, Instant.ofEpochSecond(1770000000));
String input = "secret/openapi/v2/customer/listappKeykeydata{items=[{z=中文, A=2}]}timestamp1770000000secret";
assertThat(mapper.readTree(signed.body()).path("sign").textValue()).isEqualTo(hmac("secret", input));
}
@Test
void propertiesBindWmsOnlyAndRedactBothCredentials() {
var source = new MapConfigurationPropertySource(Map.of(
"baiyue.integration.lingxing-wms.enabled", "true",
"baiyue.integration.lingxing-wms.app-key", "example-key",
"baiyue.integration.lingxing-wms.app-secret", "example-secret"));
var properties = new Binder(source).bind("baiyue.integration.lingxing-wms", Bindable.of(LingxingWmsProperties.class)).get();
assertThat(properties.enabled()).isTrue();
assertThat(properties.hasCredentials()).isTrue();
assertThat(properties.baseUri()).isEqualTo(URI.create("https://api.xlwms.com"));
assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(5));
assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(15));
assertThat(properties.minRequestInterval()).isEqualTo(Duration.ofSeconds(1));
assertThat(properties.toString()).doesNotContain("example-key", "example-secret");
}
@Test
void disabledIsDefaultAndPlainHttpOutsideLoopbackIsRejected() {
var properties = new LingxingWmsProperties(false, null, null, null, null, null, null);
assertThat(properties.enabled()).isFalse();
assertThat(properties.hasCredentials()).isFalse();
assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null, null))
.isInstanceOf(IllegalArgumentException.class);
}
private static String hmac(String key, String input) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
}
}
+35 -24
View File
@@ -3,7 +3,7 @@ import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { normalizeOrderInput, measurementPlan } from './prod-release-measurement-plan.mjs'
// Production is deliberately not routed through the multi-customer test runner.
// Single-order acceptance never invokes the multi-customer fixture runner.
export const STAGES = Object.freeze([
'CUSTOMER_ORDER', 'ORDER_REVIEW', 'YIWU_MEASUREMENT', 'CUSTOMER_FEE_CONFIRMATION',
'CONTAINER_ALLOCATION', 'DOMESTIC_PDA_LOADING', 'CUSTOMS_AND_BOOKING',
@@ -20,9 +20,8 @@ export const DOMESTIC_WAREHOUSE_CODES = Object.freeze(['CN-YIWU-01', 'CN-DONGGUA
export function plan() {
return {
scope: { orders: 1, origins: [...DOMESTIC_WAREHOUSE_CODES], origin: 'CN-YIWU-01', receiving: 'US-LAX-01', sameOrder: true },
status: 'FULL_FLOW_NOT_IMPLEMENTED', fullFlowVerified: false,
stages: STAGES.map(name => ({ name, status: ['CUSTOMER_ORDER', 'ORDER_REVIEW'].includes(name)
? 'IMPLEMENTED_NOT_HTTP_VERIFIED' : 'NOT_IMPLEMENTED' })),
status: 'FULL_FLOW_IMPLEMENTED_NOT_HTTP_VERIFIED', fullFlowVerified: false,
stages: STAGES.map(name => ({ name, status: 'IMPLEMENTED_NOT_HTTP_VERIFIED' })),
excluded: ['其他客户', '混柜及拆单', '退货及异常分支', '真实付款', '第三方下单或发送邮件', '浏览器和设备交互'],
policy: { createMasterData: false, createAccounts: false, deleteData: false },
}
@@ -43,20 +42,22 @@ function baseUrl(value, path) {
}
export function config(env) {
if (env.CHONGCHONG_ENV !== 'prod') throw new Error('必须显式配置 CHONGCHONG_ENV=prod')
if (!['test', 'gray'].includes(env.CHONGCHONG_ENV)) throw new Error('必须显式配置 CHONGCHONG_ENV=test 或 gray')
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
if (env[key] != null) throw new Error(`移除旧海外网关配置:${key};所有业务使用统一 Gateway`)
}
const get = key => requireValue(env, `CHONGCHONG_${key}`)
const cn = baseUrl(get('ADMIN_API_BASE_URL'), '/api')
const overseas = baseUrl(get('ADMIN_OVERSEAS_API_BASE_URL'), '/overseas-api')
const apiBase = baseUrl(get('ADMIN_API_BASE_URL'), '/api')
const merchant = baseUrl(get('MERCHANT_API_BASE_URL'), '/api')
if (new URL(cn).origin !== new URL(overseas).origin || cn !== merchant) {
throw new Error('当前专属入口要求国内、海外及客户 API 使用同一 Admin 前端代理')
if (apiBase !== merchant) {
throw new Error('单票入口要求平台及客户 API 使用同一 HTTPS /api 统一入口')
}
const customer = { id: get('ACCEPTANCE_CUSTOMER_ID'), code: get('ACCEPTANCE_CUSTOMER_CODE'), name: get('ACCEPTANCE_CUSTOMER_NAME') }
if (!/^[1-9]\d*$/.test(customer.id)) throw new Error('客户 ID 必须为非零十进制字符串')
const credentials = Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role =>
[role, { loginId: get(`${role}_USERNAME`), password: get(`${role}_PASSWORD`) }]))
if (get('OVERSEAS_LAX_PDA_WAREHOUSE_CODE') !== 'US-LAX-01') throw new Error('海外 PDA 必须绑定洛杉矶仓')
return { cn, overseas, customer, credentials }
return { environment: env.CHONGCHONG_ENV, apiBase, customer, credentials }
}
function id(value) {
@@ -88,11 +89,11 @@ export function client(settings, fetcher = fetch, policy = allowedRequest) {
return {
get requests() { return requests },
async request(region, method, path, token, body, idempotencyKey) {
if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在生产前置检查白名单内')
if (++requests > 100) throw new Error('超过单次前置检查请求上限')
if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在单票请求白名单内')
if (++requests > 100) throw new Error('超过单阶段请求上限')
let response
try {
response = await fetcher((region === 'cn' ? settings.cn : settings.overseas) + path, {
response = await fetcher(settings.apiBase + path, {
method, redirect: 'error', signal: AbortSignal.timeout(20_000),
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}),
...(idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}) },
@@ -129,7 +130,7 @@ export async function preflight(settings, http, receiveContext = () => {}) {
const logins = {}
for (const [role, credentials] of Object.entries(settings.credentials)) {
logins[role] = await http.request('cn', 'POST', '/auth/login', undefined, credentials)
if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少国内 Token`)
if (!logins[role]?.accessToken) throw new Error(`${role} 登录缺少统一 accessToken`)
}
const admin = logins.ADMIN.accessToken
assertCustomer(await http.request('cn', 'GET', `/platform/v1/iam/tenants/${settings.customer.id}`, admin), settings.customer)
@@ -152,8 +153,8 @@ export async function preflight(settings, http, receiveContext = () => {}) {
!cnWarehouses.some(w => w.warehouseCode === code && id(w.id) === warehouses[code]))) {
throw new Error('国内 PDA 不可访问义乌或东莞仓')
}
const overseasToken = logins.OVERSEAS_LAX_PDA.overseasAccessToken
if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少海外 Token')
const overseasToken = logins.OVERSEAS_LAX_PDA.accessToken
if (!overseasToken) throw new Error('洛杉矶 PDA 登录缺少统一 accessToken')
const binding = await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', overseasToken)
if (binding?.warehouseCode !== 'US-LAX-01' || id(binding.warehouseId) !== warehouses['US-LAX-01']) throw new Error('洛杉矶 PDA 仓库绑定不符')
const counts = {}
@@ -169,14 +170,14 @@ export async function preflight(settings, http, receiveContext = () => {}) {
return { ...plan(), status: 'BASIC_PREFLIGHT_PASSED', requests: http.requests,
checks: ['客户身份及箱规状态', '四个账号登录', '义乌、东莞及洛杉矶仓', 'PDA 仓库范围', '国内既有托盘及可用库位'],
resourceCounts: counts,
pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '测量至签收的写入执行器和检查点', '所有业务阶段真实 HTTP 验证'],
pending: ['单票执行入口的精确线路及小票报价检查', '供应商与海外资源门禁', '全流程输入指定的资源资格', '本轮全部业务阶段真实 HTTP 验证'],
}
}
export async function executeOrder(settings, input, journalPath, resume, fetcher = fetch) {
const { validateOrderInput, withOrderJournal, runOrderStage } = await import('./prod-release-order-stage.mjs')
// These facts come from the selected external environment, not from user-editable order JSON.
const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: settings.cn,
const bound = { ...normalizeOrderInput(input), customer: settings.customer, environment: { mode: settings.environment, apiBase: settings.apiBase },
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
validateOrderInput(bound)
return withOrderJournal(journalPath, bound, async (state, save) => {
@@ -229,9 +230,19 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher
export async function main(argv = process.argv.slice(2), env = process.env) {
const [mode = 'plan', ...rest] = argv
if (mode === 'full-flow') {
if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-full-flow'
|| rest.length === 4 && rest[3] !== '--resume') throw Error('full-flow 需要输入文件、检查点、--confirm-single-full-flow,可选 --resume')
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw Error('输入和检查点不能使用同一文件')
const { executeFullFlow } = await import('./prod-release-full-flow.mjs')
const result = await executeFullFlow(config(env), JSON.parse(await readFile(inputPath, 'utf8')), journalPath, rest[3] === '--resume')
console.log(JSON.stringify(result, null, 2))
return
}
if (mode === 'delivery-stage') {
if (rest.length !== 3 || rest[2] !== '--confirm-production-delivery') {
throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-production-delivery')
if (rest.length !== 3 || rest[2] !== '--confirm-single-delivery') {
throw new Error('delivery-stage 需要输入文件、独立签收检查点、--confirm-single-delivery')
}
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件')
@@ -252,8 +263,8 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
return
}
if (mode === 'order-stage') {
if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-production-order'
|| rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-production-order,可选 --resume')
if ((rest.length !== 3 && rest.length !== 4) || rest[2] !== '--confirm-single-order'
|| rest.length === 4 && rest[3] !== '--resume') throw new Error('order-stage 需要输入文件、检查点、--confirm-single-order,可选 --resume')
const inputPath = resolve(rest[0]), journalPath = resolve(rest[1])
if (inputPath === journalPath) throw new Error('输入和检查点不能使用同一文件')
const input = JSON.parse(await readFile(inputPath, 'utf8'))
@@ -261,7 +272,7 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
console.log(JSON.stringify(result))
return
}
if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];全链路执行尚未开放')
if (!['plan', 'preflight'].includes(mode) || rest.length) throw new Error('用法:prod-release-acceptance.mjs [plan|preflight];完整链使用 full-flow <输入> <检查点> --confirm-single-full-flow')
if (mode === 'plan') { console.log(JSON.stringify(plan(), null, 2)); return }
const settings = config(env)
const reportPath = resolve('reports', `prod-release-preflight-${Date.now()}.json`)
@@ -276,5 +287,5 @@ export async function main(argv = process.argv.slice(2), env = process.env) {
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
main().catch(error => { console.error(`生产验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 })
main().catch(error => { console.error(`单票验收入口失败:${error instanceof SyntaxError ? '输入或检查点 JSON 无效' : error.message}`); process.exitCode = 1 })
}
+38 -6
View File
@@ -3,9 +3,8 @@ import assert from 'node:assert/strict'
import { plan, config, assertCustomer, allowedRequest, client, listAll, preflight, main, DOMESTIC_WAREHOUSE_CODES } from './prod-release-acceptance.mjs'
function environment() {
const env = { CHONGCHONG_ENV: 'prod',
const env = { CHONGCHONG_ENV: 'gray',
CHONGCHONG_ADMIN_API_BASE_URL: 'https://acceptance.invalid/api',
CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL: 'https://acceptance.invalid/overseas-api',
CHONGCHONG_MERCHANT_API_BASE_URL: 'https://acceptance.invalid/api',
CHONGCHONG_ACCEPTANCE_CUSTOMER_ID: '9007199254740993',
CHONGCHONG_ACCEPTANCE_CUSTOMER_CODE: 'fixture', CHONGCHONG_ACCEPTANCE_CUSTOMER_NAME: 'fixture',
@@ -25,8 +24,8 @@ test('single order scope does not claim implemented or full coverage', () => {
assert.deepEqual(result.scope.origins, [...DOMESTIC_WAREHOUSE_CODES])
assert.equal(result.scope.receiving, 'US-LAX-01')
assert.equal(result.fullFlowVerified, false)
assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 2)
assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 12)
assert.equal(result.stages.filter(s => s.status === 'IMPLEMENTED_NOT_HTTP_VERIFIED').length, 14)
assert.equal(result.stages.filter(s => s.status === 'NOT_IMPLEMENTED').length, 0)
})
test('configuration has no development/test fallback', () => {
assert.throws(() => config({ ADMIN_TEST_GATEWAY: 'https://acceptance.invalid' }))
@@ -85,7 +84,7 @@ function fakeHttp(bindingId = '3') {
return { requests: 0, async request(region, method, path) {
this.requests++
assert.equal(allowedRequest(method, path, region), true)
if (method === 'POST') return { accessToken: 'fixture-token', overseasAccessToken: 'fixture-overseas-token' }
if (method === 'POST') return { accessToken: 'fixture-token' }
if (path.includes('/iam/')) return profile
if (path.includes('/warehouse-profile')) return { warehouseId: bindingId, warehouseCode: 'US-LAX-01' }
if (path.endsWith('/pda-loading/warehouses')) return [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }]
@@ -110,6 +109,39 @@ test('PDA warehouse mismatch blocks preflight', async () => {
})
test('execute, resume and unrecognized arguments fail closed', async () => {
for (const args of [['execute'], ['resume'], ['preflight', '--execute'], ['plan', '--count', '2']]) {
await assert.rejects(main(args, {}), /尚未开放/)
await assert.rejects(main(args, {}), /完整链使用/)
}
})
test('explicit gray/test mode shares one API base and rejects retired environment keys', () => {
for (const mode of ['gray', 'test']) {
const settings = config({ ...environment(), CHONGCHONG_ENV: mode })
assert.equal(settings.environment, mode)
assert.equal(settings.apiBase, 'https://acceptance.invalid/api')
assert.equal(settings.overseas, undefined)
}
for (const mode of ['prod', 'dev', '', undefined]) {
assert.throws(() => config({ ...environment(), CHONGCHONG_ENV: mode }), /显式配置/)
}
for (const key of ['CHONGCHONG_ADMIN_OVERSEAS_API_BASE_URL', 'CHONGCHONG_MERCHANT_OVERSEAS_API_BASE_URL']) {
assert.throws(() => config({ ...environment(), [key]: 'https://acceptance.invalid/api' }), /旧海外网关/)
}
})
test('overseas PDA calls the unified gateway using its ordinary access token', async () => {
const calls = []
const http = client(config(environment()), async (url, options) => {
calls.push({ url, token: options.headers.Authorization })
return { ok: true, status: 200, json: async () => ({ code: '200', data: {} }) }
})
await http.request('overseas', 'GET', '/pda/v1/last-leg/receiving-containers/warehouse-profile', 'ordinary-access')
assert.deepEqual(calls, [{ url: 'https://acceptance.invalid/api/pda/v1/last-leg/receiving-containers/warehouse-profile', token: 'Bearer ordinary-access' }])
})
test('unapproved customer blocks preflight before any business stage', async () => {
const http = fakeHttp(), original = http.request.bind(http)
http.request = async (...args) => args[2].includes('/iam/')
? { ...profile, status: 'PROFILE_REQUIRED' } : original(...args)
await assert.rejects(preflight(config(environment()), http), /审核状态/)
})
+8 -6
View File
@@ -5,7 +5,8 @@ import { fingerprint } from './prod-release-order-stage.mjs'
const check = (ok, message) => { if (!ok) throw new Error(message) }
const longId = value => typeof value === 'string' && /^[1-9]\d*$/.test(value)
const segment = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,80}$/.test(value)
const number = value => Number.isSafeInteger(Number(value)) ? Number(value) : NaN
const number = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value))
&& Number.isSafeInteger(Number(value)) && Number(value) >= 0 ? Number(value) : NaN
export function validateDeliveryInput(input) {
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
@@ -79,6 +80,7 @@ export async function runDeliveryStage({ input, customerId, state, save, request
let delivery = await readDelivery()
if (delivery.order.status === 'WAIT_DELIVERY') {
check(!state.startIntent, '配送中操作结果不确定,禁止重发;请核对服务端状态')
check(Number.isSafeInteger(number(delivery.order.version)), '派送状态版本缺失或无效')
await save({ startIntent: true })
await request('overseas', 'PUT', scoped(`${deliveryPath}/status`), {
status: 'DELIVERING', expectedVersion: number(delivery.order.version), remark: `RELEASE-ACCEPTANCE:${input.batch}`,
@@ -142,12 +144,12 @@ export async function runDeliveryStage({ input, customerId, state, save, request
export async function executeDelivery(settings, input, journalPath, fetcher = fetch) {
validateDeliveryInput(input)
const binding = fingerprint({ input, cn: settings.cn, overseas: settings.overseas,
const binding = fingerprint({ input, environment: settings.environment, apiBase: settings.apiBase,
customer: settings.customer, admin: settings.credentials.ADMIN.loginId })
return withDeliveryJournal(journalPath, binding, async (state, save) => {
const { client } = await import('./prod-release-acceptance.mjs')
const login = await client(settings, fetcher).request('cn', 'POST', '/auth/login', null, settings.credentials.ADMIN)
check(login?.accessToken && login?.overseasAccessToken, '缺少国内或海外令牌')
check(login?.accessToken, '缺少统一 accessToken')
const root = '/platform/v1/last-leg'
const delivery = `${root}/delivery-orders/${input.deliveryNo}`
const scoped = path => `${path}?customerId=${settings.customer.id}`
@@ -161,13 +163,13 @@ export async function executeDelivery(settings, input, journalPath, fetcher = fe
: region === 'overseas' && (method === 'GET' && reads.has(path) || method === 'PUT' && writes.has(path)))
return runDeliveryStage({ input, customerId: settings.customer.id, state, save,
request: (region, method, path, body) => http.request(region, method, path,
region === 'cn' ? login.accessToken : login.overseasAccessToken, body),
login.accessToken, body),
upload: async (path, name, text) => {
check(path === scoped(`${delivery}/documents/RECEIPT/files`), '上传路径不符')
const form = new FormData(); form.append('file', new Blob([text], { type: 'text/plain' }), name)
let response
try { response = await fetcher(settings.overseas + path, { method: 'POST', redirect: 'error',
signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.overseasAccessToken}` }, body: form }) }
try { response = await fetcher(settings.apiBase + path, { method: 'POST', redirect: 'error',
signal: AbortSignal.timeout(20_000), headers: { Authorization: `Bearer ${login.accessToken}` }, body: form }) }
catch { throw new Error('签收上传连接失败或超时;未自动重试') }
const data = await response.json()
const safe = value => /^[\w-]{1,100}$/.test(String(value)) ? value : 'unavailable'
+47 -1
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs'
import { executeDelivery, runDeliveryStage, validateDeliveryInput, verifyOutbound, withDeliveryJournal } from './prod-release-delivery-stage.mjs'
const input = { batch: 'acceptance-1234', orderId: '101', orderNo: 'FL-101', tailOrderId: '201',
warehouseId: '301', inboundCode: 'IN-101', dispatchNo: 'DS-101', outboundNo: 'OB-101', deliveryNo: 'DL-101', packageCount: 1 }
@@ -112,3 +112,49 @@ test('journal rejects changed binding and concurrent processes', async () => {
await assert.rejects(withDeliveryJournal(path, 'changed', async () => {}), /不符/)
} finally { await rm(dir, { recursive: true, force: true }) }
})
test('missing or malformed delivery version blocks status write and upload', async () => {
for (const version of [undefined, null, '', ' ', false, -1, '9007199254740993']) {
const h = harness(), original = h.options.request
h.options.request = async (...args) => {
const result = await original(...args)
if (result?.order?.deliveryNo) result.order.version = version
return result
}
await assert.rejects(runDeliveryStage(h.options), /版本缺失或无效/)
assert.equal(h.writes.length, 0)
assert.equal(h.uploads(), 0)
}
})
test('delivery executor uses one gateway/accessToken for status, multipart receipt and domestic reconciliation', async t => {
const dir = await mkdtemp(join(tmpdir(), 'single-delivery-test-'))
t.after(() => rm(dir, { recursive: true, force: true }))
const h = harness(), calls = []
const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: { id: '501' },
credentials: { ADMIN: { loginId: 'admin-fixture', password: 'secret-fixture' } } }
const fetcher = async (url, options) => {
assert.ok(url.startsWith(settings.apiBase + '/'))
const path = url.slice(settings.apiBase.length)
calls.push(path)
let data
if (path === '/auth/login') data = { accessToken: 'single-access' }
else {
assert.equal(options.headers.Authorization, 'Bearer single-access')
if (options.body instanceof FormData) {
assert.equal(path, '/platform/v1/last-leg/delivery-orders/DL-101/documents/RECEIPT/files?customerId=501')
assert.match(await options.body.get('file').text(), /NOT A REAL PROOF/)
data = { fileId: '701' }
} else {
data = await h.options.request(path.startsWith('/platform/v1/last-leg/') ? 'overseas' : 'cn',
options.method, path, options.body ? JSON.parse(options.body) : undefined)
}
}
return { ok: true, status: 200, json: async () => ({ code: '200', data }) }
}
const result = await executeDelivery(settings, input, join(dir, 'journal.json'), fetcher)
assert.equal(result.status, 'DELIVERY_STAGE_PASSED')
assert.equal(result.fullFlowVerified, false)
assert.equal(calls.filter(path => path === '/auth/login').length, 1)
})
+231
View File
@@ -0,0 +1,231 @@
import { fingerprint } from './prod-release-order-stage.mjs'
import { oceanBookingDraft } from './admin-test-env-modules/ocean-booking-fixture.mjs'
import { iso6346ContainerNumber } from './admin-test-env-modules/iso6346-container-number.mjs'
import { channelGroup } from './admin-test-env-modules/smart-loading-channel-group.mjs'
const assert = (value, message) => { if (!value) throw Error(message) }
const id = value => value == null ? '' : String(value)
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFAAH/iZk9HQAAAABJRU5ErkJggg==', 'base64')
const NODES = ['DEPARTED_WAREHOUSE', 'AT_ORIGIN_PORT', 'LOADED_ON_VESSEL', 'DEPARTED_ORIGIN_PORT', 'IN_TRANSIT', 'ARRIVED_DESTINATION_PORT', 'DISCHARGED_FROM_VESSEL', 'PICKUP_READY']
const localTime = () => new Date(Date.now() + 8 * 3600000).toISOString().slice(0, 19)
function fileForm(name, content = PNG, type = 'image/png', fields = {}) {
const form = new FormData()
for (const [key, value] of Object.entries(fields)) form.append(key, String(value))
form.append('file', new Blob([content], { type }), name)
return form
}
export function domesticSuccess(response) {
if (response?.http !== 200 || String(response?.body?.code) !== '200') {
const error = Error(`国内履约请求失败 HTTP=${response?.http} code=${response?.body?.code}`)
error.evidence = { http: response?.http, code: response?.body?.code, message: response?.body?.message,
requestId: response?.requestId, traceId: response?.traceId }
throw error
}
return response.body.data
}
// Persist intent before invoking transport. Never retry a partially observed write.
export function domesticCommands(checkpoint, binding) {
const { state, save } = checkpoint
assert(!state.binding || state.binding === binding, '国内检查点绑定发生变化')
return async (name, command, execute) => {
const previous = state.commands?.[name]
if (previous?.status === 'DONE') return previous.result
assert(!previous, `国内写入 ${name} 结果未知,必须人工核对原请求,禁止自动重放`)
const intent = { status: 'INTENT', command, fingerprint: fingerprint(command) }
const persist = async value => {
const patch = { binding, commands: { ...state.commands, [name]: value } }
await save(patch); Object.assign(state, patch)
}
await persist(intent)
const result = await execute()
await persist({ ...intent, status: 'DONE', result })
return result
}
}
export async function runDomesticStage({ input, order, context, checkpoint }) {
const config = input.domestic
const cargo = input.payload.cargoes[0]
assert(order?.status === 'ORDER_APPROVED' && order.orderId && order.inboundCode, '必须使用本票已审核订单')
assert(cargo.packageCount >= 1 && cargo.packageCount <= 2 && input.payload.cargoes.length === 1, '国内阶段仅支持本票一个 SKU、1~2 箱')
assert(input.payload.taxMode === 'BUY_DECLARATION', '当前专票须为买单报关,不自动改写报关方式')
assert(config?.palletId && config.assigneeId && config.bookingAgent?.id && config.carrier?.id
&& config.carrier.carrierCode && config.estimatedSailingAt && config.estimatedArrivalAt
&& config.terminalFleet?.id && config.terminalFleet.name, '缺少已有国内资源、目的港车队或明确船期')
assert(context.token && context.pdaToken && context.merchantToken, '缺少已登录平台、国内 PDA 或客户会话')
const binding = fingerprint({ input, orderId: order.orderId })
const command = domesticCommands(checkpoint, binding)
const tenantId = input.customer.id, warehouseId = input.origin.id
const q = `tenantId=${encodeURIComponent(tenantId)}`
const key = name => `release-${fingerprint({ binding, name }).slice(0, 48)}`
const get = async (path, token = context.token) => domesticSuccess(await context.request('GET', path, undefined, token))
const write = (name, method, path, body, token = context.token) => command(name, { method, path, body, idempotencyKey: key(name) },
async () => domesticSuccess(await context.request(method, path, body, token, { 'Idempotency-Key': key(name) })))
const upload = (name, path, form, token = context.token) => command(name, { method: 'POST', path, idempotencyKey: key(name), testEvidence: name },
async () => domesticSuccess(await context.requestMultipart('POST', path, form, token, { 'Idempotency-Key': key(name) })))
const poll = async (read, condition, description) => {
for (let i = 0; i < (context.pollAttempts || 45); i++) {
const data = await read()
if (condition(data)) return data
await (context.sleep || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
throw Error(`国内异步事实未就绪:${description}`)
}
const unified = await poll(() => get(`/api/platform/v1/orders/${order.orderId}?${q}`), data => data.summary?.inboundId, '订单入库投影')
const inboundId = id(unified.summary.inboundId)
const inboundPath = `/api/platform/inbounds/${inboundId}?${q}`
const base = `/api/platform/v1/first-leg/inbounds/${inboundId}`
const measurementPath = `${base}/measurement?${q}`
const inbound = await get(inboundPath)
assert(id(inbound.summary?.tenantId) === tenantId && inbound.summary.inboundCode === order.inboundCode
&& id(inbound.summary?.orderId) === order.orderId, '入库单客户、订单或入仓码不属于本票')
await write('accept', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/accept?${q}`)
await write('start', 'POST', `/api/platform/v1/orders/first-leg/${order.orderId}/start?${q}`)
await command('carton-labels', { method: 'POST', path: `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}` }, async () => {
const result = await context.requestBinary('POST', `/api/platform/inbounds/${inboundId}/carton-label-pdf?${q}`)
assert(result.http === 200 && String(result.contentType).includes('pdf') && Buffer.from(result.bytes || []).subarray(0, 4).toString() === '%PDF', '本票箱唛未成功生成')
return { generated: true }
})
const scanForm = new FormData()
scanForm.append('data', new Blob([JSON.stringify({ boxCode: `${order.inboundCode}#${cargo.packageCount}-1`,
length: cargo.lengthCm, width: cargo.widthCm, height: cargo.heightCm,
machineNo: 'TEST-RELEASE-SIMULATED', measuredAt: localTime() })], { type: 'application/json' }))
scanForm.append('image', new Blob([PNG], { type: 'image/png' }), 'TEST-simulated-carton.png')
const scan = await upload('carton-scan', `${base}/measurement/carton-scans?${q}`, scanForm)
assert(scan?.itemId && scan.measurementId, '测量扫描未生成本票明细')
await write('carton-count', 'PUT', `${base}/measurement/items/${scan.itemId}/confirmed-package-count?${q}`,
{ measurementVersion: Number(scan.measurementVersion), confirmedPackageCount: cargo.packageCount })
const measured = await get(measurementPath)
assert(measured.items?.length === 1 && id(measured.items[0].id) === id(scan.itemId), '本票测量明细数量不符')
const item = measured.items[0]
await write('measurement-draft', 'PUT', `${base}/measurement/draft?${q}`, {
measurementId: measured.measurementId, version: Number(measured.version), actualGoodsName: cargo.goodsName || cargo.skuNo,
items: [{ inboundDetailId: item.inboundDetailId, itemNo: item.itemNo, boxSpecification: item.boxSpecification,
packageCount: cargo.packageCount, lengthCm: cargo.lengthCm, widthCm: cargo.widthCm, heightCm: cargo.heightCm,
intelligentImageFileId: item.intelligentImageFileId, overLimitWarning: item.overLimitWarning ?? null,
outerLengthCm: cargo.lengthCm, outerWidthCm: cargo.widthCm, outerHeightCm: cargo.heightCm,
coefficientMode: 'ADD', remark: `${input.batch} TEST模拟测量,非实测证据` }] })
const weightFile = await upload('weight-file', `/api/platform/v1/files/measurements/${tenantId}/${inboundId}`, fileForm('TEST-weight.png'))
const weight = await write('weight-create', 'POST', `${base}/weight-results?${q}`, { fileId: weightFile.fileId, palletId: config.palletId,
ocrProvider: 'ADMIN_ACCEPTANCE', ocrRawText: null, ocrWeightValue: null, ocrWeightUnit: null, confidence: null })
assert(weight.palletTareWeightKg != null && Number(weight.palletTareWeightKg) >= 0, '缺少已有托盘皮重快照')
await write('weight-confirm', 'POST', `${base}/weight-results/${weight.id}/confirm?${q}`, {
version: Number(weight.version), adjustedWeightValue: Number(cargo.grossWeightKg) + Number(weight.palletTareWeightKg),
adjustedWeightUnit: 'KG', adjustmentReason: `${input.batch} TEST模拟称重,申报货重加托盘皮重` })
const ready = await get(measurementPath)
await write('measurement-submit', 'POST', `${base}/measurement/submit?${q}`, { measurementId: ready.measurementId, version: Number(ready.version) })
const pending = await get(inboundPath)
await write('measurement-approve', 'POST', `/api/platform/v1/inbounds/${inboundId}/measurements/approve?${q}`,
{ measurementId: ready.measurementId, expectedVersion: Number(pending.summary.version), reason: `${input.batch} TEST专票流程` })
const approved = await poll(() => get(measurementPath), value => value.processStatus === 'WAIT_SPLIT'
|| checkpoint.state.commands?.['plan-assign']?.status === 'DONE', '测量审核待分柜')
assert(approved.measurementStatus === 'APPROVED' && approved.measureReviewStatus === 'APPROVED'
&& Number(approved.actualPackageCount) === cargo.packageCount
&& Math.abs(Number(approved.actualNetWeightKg) - cargo.grossWeightKg) <= 0.001, '测量审核、箱数或净重不符')
const latestFee = await context.request('GET', `/api/platform/v1/inbounds/${inboundId}/fees/latest?${q}`, undefined, context.token)
if (latestFee.http === 200 && String(latestFee.body?.code) === 'A0404') {
await write('fee-calculate', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/calculate?${q}`)
} else domesticSuccess(latestFee)
await write('fee-confirm', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/confirm?${q}`)
const sent = await write('fee-send', 'POST', `/api/platform/v1/inbounds/${inboundId}/fees/send?${q}`)
assert(sent.feeStatus === 'SENT', '头程费用未发送客户')
const billing = `/api/merchant/v1/first-leg/orders/${order.orderId}/inbound-billing`
const customerFee = await write('customer-fee-confirm', 'POST', `${billing}/confirm`, { expectedFeeVersion: Number(sent.feeVersion) }, context.merchantToken)
assert(customerFee.customerConfirmationStatus === 'CONFIRMED', '客户费用未确认')
const containers = '/api/platform/v1/containers', smart = '/api/platform/v1/first-leg/smart-loading', pda = '/api/platform/v1/first-leg/pda-loading'
const simulation = await write('container-create', 'POST', containers, { channelGroupCode: channelGroup(input.payload.channelCode), warehouseId,
containerType: '40HQ', capacityCbm: 68, maxWeightKg: 19500 })
const containerId = id(simulation.id)
const booking = await write('booking-create', 'POST', '/api/platform/v1/ocean-bookings', { expectedVersion: 0,
data: oceanBookingDraft({ marker: input.batch }, input.batch, { customerId: tenantId, customerName: input.customer.name,
channelCode: input.payload.channelCode, warehouseId }, { shippingCompanyId: config.carrier.id, shippingCompanyName: config.carrier.name,
carrierCode: config.carrier.carrierCode, bookingAgentId: config.bookingAgent.id, bookingAgentName: config.bookingAgent.name,
estimatedSailingAt: config.estimatedSailingAt, estimatedArrivalAt: config.estimatedArrivalAt }) })
const oceanBookingId = id(booking.oceanBooking.id)
const document = await upload('booking-document', `/api/platform/v1/ocean-bookings/${oceanBookingId}/documents/upload`,
fileForm('TEST-booking-confirmation.txt', `${input.batch} TEST模拟订舱确认文件,非真实单证`, 'text/plain',
{ expectedVersion: booking.oceanBooking.version, documentType: 'BOOKING_CONFIRMATION' }))
const released = await write('booking-release', 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/release`,
{ expectedVersion: Number(document.bookingVersion), reason: `${input.batch} TEST` })
assert(released.oceanBooking?.status === 'RELEASED', '订舱未放舱')
const requirement = released.containerRequirements?.find(value => value.containerType === '40HQ')
assert(requirement?.id, '订舱缺少40HQ需求')
const containerNo = iso6346ContainerNumber(input.batch)
await write('booking-link', 'POST', `${containers}/${containerId}/ocean-booking-link`, { oceanBookingId,
requirementId: id(requirement.id), containerNo, expectedVersion: Number(simulation.version) })
const candidates = await get(`${smart}/candidates?bookingId=${containerId}&warehouseId=${warehouseId}`)
const candidate = candidates.find(value => id(value.orderId) === order.orderId && id(value.accountId) === tenantId)
// After assignment a resumed run uses the captured candidate, never another order.
const assignedBefore = checkpoint.state.commands?.['plan-assign']
assert(candidate || assignedBefore?.status === 'DONE', '本票不在可分柜候选中')
const plan = await get(`${smart}/plans/by-booking/${containerId}`), planId = id(plan.id)
const assigned = await write('plan-assign', 'POST', `${smart}/plans/${planId}/assignments`, {
inboundId, inboundDetailId: id(candidate?.inboundDetailId), packageCount: cargo.packageCount,
expectedVersion: Number(plan.version), operationId: key('plan-assign') })
assert(assigned.items?.length === 1 && Number(assigned.items[0].packageCount) === cargo.packageCount, '装柜计划混入其他货物或数量不符')
const confirmable = await get(`/api/platform/v1/first-leg/loading-plans/${planId}?warehouseId=${warehouseId}`)
const confirmed = await write('plan-confirm', 'POST', `/api/platform/v1/first-leg/loading-plans/${planId}/confirm`,
{ expectedVersion: Number(confirmable.summary.version), assigneeId: config.assigneeId })
const taskId = id(confirmed.readiness?.pdaTaskId)
assert(taskId && confirmed.confirmationStatus === 'CONFIRMED', '国内 PDA 任务未生成')
const claimed = await write('pda-claim', 'POST', `${pda}/tasks/${taskId}/claim`,
{ expectedAssignmentVersion: 0, operationId: key('pda-claim') }, context.pdaToken)
assert(claimed.items?.length === 1, '国内 PDA 任务货物不唯一')
const snapshot = await get(`${pda}/tasks/${taskId}/inventory-snapshot?warehouseId=${warehouseId}`, context.pdaToken)
const picked = await write('pda-pick', 'POST', `${pda}/tasks/${taskId}/batch`, { warehouseId, loadingPlanId: planId,
containerBookingId: containerId, expectedVersion: Number(snapshot.taskVersion), inventorySnapshotToken: snapshot.snapshotToken,
items: [{ itemId: id(claimed.items[0].id), action: 'PICK', pickedPackageCount: cargo.packageCount, version: Number(claimed.items[0].version) }] }, context.pdaToken)
for (const evidenceType of ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']) {
const params = new URLSearchParams({ warehouseId, expectedVersion: '0', evidenceType, remark: `${input.batch} TEST模拟凭证` })
if (evidenceType === 'SEAL') params.set('sealNo', `TEST-${input.batch}`.slice(0, 64))
await upload(`loading-${evidenceType}`, `${pda}/tasks/${taskId}/evidence?${params}`, fileForm(`TEST-${evidenceType}.png`), context.pdaToken)
}
await write('pda-complete', 'POST', `${pda}/tasks/${taskId}/complete`, { expectedTaskVersion: Number(picked.version),
operationId: key('pda-complete'), remark: `${input.batch} TEST装柜` }, context.pdaToken)
await poll(() => get(`${pda}/tasks/${taskId}?warehouseId=${warehouseId}`, context.pdaToken),
value => value.taskStatus === 'COMPLETED' && value.workflowStatus === 'COMPLETED', '国内装柜完成')
const customsPath = `/api/platform/v1/customs/loading-plans/${planId}`
let customs = await get(customsPath)
assert(customs.inbounds?.length === 1 && id(customs.inbounds[0].inboundId) === inboundId
&& customs.inbounds.every(value => value.taxMode === 'BUY_DECLARATION'), '报关聚合不是本票买单货物')
if (customs.status === 'READY_TO_START') customs = await write('customs-start', 'POST', `${customsPath}/start`,
{ expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` })
if (customs.status === 'DECLARING') customs = await write('customs-complete', 'POST', `${customsPath}/complete-all-buy`,
{ expectedVersion: Number(customs.version), confirm: true, remark: `${input.batch} TEST` })
assert(customs.status === 'COMPLETED', '国内报关未完成')
const outboundPath = `/api/platform/v1/first-leg/outbound-dispatches/${containerId}`
const outbound = await poll(() => get(outboundPath), value => value.aggregate?.dispatchStatus === 'WAITING_FOR_LOADING', '报关自动国内离仓')
assert(outbound.aggregate.inbounds?.length === 1 && id(outbound.aggregate.inbounds[0].inboundId) === inboundId, '离仓货物归属不符')
const queries = await poll(() => get(`/api/platform/v1/carrier-schedule-queries?containerBookingId=${containerId}&page=1&size=20`),
value => value.records?.some(row => row.query?.triggerSource === 'OUTBOUND_CONFIRM'), '离仓船节点任务')
const owned = queries.records.filter(row => id(row.query?.containerBookingId) === containerId && row.query.triggerSource === 'OUTBOUND_CONFIRM')
assert(owned.length === 1, '本柜船节点任务不唯一')
const carrierQueryId = id(owned[0].query.id), carrierPath = `/api/platform/v1/carrier-schedule-queries/${carrierQueryId}`
let carrierDetail = await get(carrierPath)
let start = NODES.indexOf(carrierDetail.query?.query?.latestActualNode)
assert(start >= 0, '缺少自动离仓起点')
for (let index = start + 1; index < NODES.length; index++) {
const latest = carrierDetail.query.query
// Current observed wall clock; actual timestamps advance from the authoritative last node.
const actualAt = new Date(Date.parse(`${latest.latestActualAt}Z`) + 1000).toISOString().slice(0, 19)
carrierDetail = await write(`carrier-${NODES[index]}`, 'POST', `${carrierPath}/manual-nodes`, {
expectedVersion: Number(latest.version), standardNode: NODES[index], observedAt: localTime(), actualAt, estimatedAt: null,
locationCode: 'USLAX', locationName: 'Los Angeles', reason: `${input.batch} TEST模拟船节点 ${NODES[index]}` })
}
assert(carrierDetail.query?.query?.latestActualNode === 'PICKUP_READY', '船节点未到可提柜')
const terminalPath = `/api/platform/v1/terminal-drayages/${containerId}`
const initialTerminal = await get(terminalPath)
assert(id(initialTerminal.containerBookingId) === containerId && initialTerminal.containerNo === containerNo, '目的港拖车货柜不符')
assert(!initialTerminal.fleetSupplierId || checkpoint.state.commands?.['terminal-fleet']?.status === 'DONE', '禁止更换既有车队触发邮件')
const selected = await write('terminal-fleet', 'POST', `${terminalPath}/fleet`, { expectedVersion: Number(initialTerminal.version),
fleetSupplierId: config.terminalFleet.id, fleetSupplierName: config.terminalFleet.name, reason: `${input.batch} TEST` })
const appointmentAt = new Date(Date.parse(`${carrierDetail.query.query.latestActualAt}Z`) + 3600000).toISOString().slice(0, 19)
const scheduled = await write('terminal-appointment', 'POST', `${terminalPath}/pickup-appointment`, {
expectedVersion: Number(selected.version), pickupAppointmentAt: appointmentAt, reason: `${input.batch} TEST` })
assert(scheduled.pickupAppointmentAt === appointmentAt, '目的港提柜预约未持久化')
return { status: 'DOMESTIC_PICKUP_SCHEDULED', orderId: order.orderId, orderNo: order.orderNo, tenantId, warehouseId,
inboundId, inboundCode: order.inboundCode, containerId, containerBookingId: containerId, containerNo,
oceanBookingId, planId, loadingPlanId: planId, taskId, carrierQueryId, packageCount: cargo.packageCount,
terminalDrayageVersion: Number(scheduled.version), pickupAppointmentAt: appointmentAt }
}
@@ -0,0 +1,122 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { domesticCommands, domesticSuccess, runDomesticStage } from './prod-release-domestic-stage.mjs'
const journal = () => {
const state = {}, saves = []
return { state, saves, async save(patch) { saves.push(structuredClone(patch)); Object.assign(state, patch) } }
}
test('uncertain writes retain original intent and cannot replay', async () => {
const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound')
let count = 0
const execute = async () => { count++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw Error('network') }
await assert.rejects(run('create', { path: '/create' }, execute), /network/)
await assert.rejects(run('create', { path: '/create' }, execute), /结果未知/)
assert.equal(count, 1)
})
test('completed writes reuse exact saved result and reject another binding', async () => {
const checkpoint = journal(), run = domesticCommands(checkpoint, 'bound')
const first = await run('create', { path: '/create' }, async () => ({ id: '17' }))
assert.deepEqual(await run('create', { path: '/create' }, () => { throw Error('must not run') }), first)
assert.throws(() => domesticCommands(checkpoint, 'other'), /绑定/)
})
test('business rejection retains diagnostic evidence and cannot count as success', () => {
assert.throws(() => domesticSuccess({ http: 200, body: { code: 'A0409', message: 'version' }, requestId: 'r', traceId: 't' }),
error => error.evidence.code === 'A0409' && error.evidence.requestId === 'r' && error.evidence.traceId === 't')
assert.throws(() => domesticSuccess({ http: 503, body: { code: '200' } }))
})
function fixture() {
const input = { batch: 'release-test-001', customer: { id: '1', name: 'TEST' }, origin: { id: '2' },
payload: { taxMode: 'BUY_DECLARATION', channelCode: 'MATSON_CLX', cargoes: [{ skuNo: 'TEST-SKU', packageCount: 2,
lengthCm: 30, widthCm: 20, heightCm: 10, grossWeightKg: 8 }] },
domestic: { palletId: '3', assigneeId: '4', bookingAgent: { id: '5', name: 'AGENT' },
carrier: { id: '6', name: 'CARRIER', carrierCode: 'MATSON' }, terminalFleet: { id: '7', name: 'FLEET' },
estimatedSailingAt: '2026-10-12T08:00:00', estimatedArrivalAt: '2026-10-20T08:00:00' } }
const order = { status: 'ORDER_APPROVED', orderId: '10', orderNo: 'TEST-ORDER', inboundCode: 'TEST-INBOUND' }
const checkpoint = journal(), calls = []
const context = { token: 'platform-test', merchantToken: 'merchant-test', pdaToken: 'pda-test', pollAttempts: 1, sleep: async () => {} }
let containerNo, carrierNode = 'DEPARTED_WAREHOUSE', carrierVersion = 0, latestActualAt = '2026-10-12T10:00:00'
let measured = false
const carrier = () => ({ query: { query: { latestActualNode: carrierNode, version: carrierVersion, latestActualAt } } })
context.request = async (method, path, body, token, headers) => {
calls.push({ method, path, body, token, headers })
let data
if (path === '/api/platform/v1/orders/10?tenantId=1') data = { summary: { inboundId: '20' } }
else if (path === '/api/platform/inbounds/20?tenantId=1') data = { summary: { tenantId: '1', orderId: '10', inboundCode: 'TEST-INBOUND', version: 1 } }
else if (path.includes('/measurement?')) data = { measurementId: '30', version: 2, items: [{ id: '31', inboundDetailId: '32', itemNo: 1 }],
processStatus: measured ? 'WAIT_SPLIT' : 'MEASURING', measurementStatus: 'APPROVED', measureReviewStatus: 'APPROVED', actualPackageCount: 2, actualNetWeightKg: 8 }
else if (path.includes('/measurements/approve')) { measured = true; data = {} }
else if (path.includes('/weight-results?')) data = { id: '40', version: 0, palletTareWeightKg: 2 }
else if (path.includes('/fees/send')) data = { feeStatus: 'SENT', feeVersion: 1 }
else if (path.includes('/inbound-billing/confirm')) data = { customerConfirmationStatus: 'CONFIRMED' }
else if (path === '/api/platform/v1/containers') data = { id: '50', version: 0 }
else if (path === '/api/platform/v1/ocean-bookings') data = { oceanBooking: { id: '60', version: 0 } }
else if (path.includes('/ocean-bookings/60/release')) data = { oceanBooking: { status: 'RELEASED' }, containerRequirements: [{ id: '61', containerType: '40HQ' }] }
else if (path.includes('/ocean-booking-link')) { containerNo = body.containerNo; data = {} }
else if (path.includes('/smart-loading/candidates')) data = [{ orderId: '10', accountId: '1', inboundDetailId: '32' }]
else if (path.includes('/plans/by-booking/')) data = { id: '70', version: 0 }
else if (path.includes('/plans/70/assignments')) data = { items: [{ id: '71', packageCount: 2 }] }
else if (path.includes('/loading-plans/70/confirm')) data = { confirmationStatus: 'CONFIRMED', readiness: { pdaTaskId: '80' } }
else if (path.includes('/first-leg/loading-plans/70?')) data = { summary: { version: 1 } }
else if (path.includes('/tasks/80/claim')) data = { items: [{ id: '81', version: 0 }] }
else if (path.includes('/tasks/80/inventory-snapshot')) data = { taskVersion: 1, snapshotToken: 'snapshot' }
else if (path.includes('/tasks/80/batch')) data = { version: 2 }
else if (path.includes('/tasks/80?')) data = { taskStatus: 'COMPLETED', workflowStatus: 'COMPLETED' }
else if (path === '/api/platform/v1/customs/loading-plans/70') data = { inbounds: [{ inboundId: '20', taxMode: 'BUY_DECLARATION' }], status: 'READY_TO_START', version: 0 }
else if (path.endsWith('/customs/loading-plans/70/start')) data = { status: 'DECLARING', version: 1 }
else if (path.endsWith('/complete-all-buy')) data = { status: 'COMPLETED', version: 2 }
else if (path.includes('/outbound-dispatches/50')) data = { aggregate: { dispatchStatus: 'WAITING_FOR_LOADING', inbounds: [{ inboundId: '20' }] } }
else if (path.includes('/carrier-schedule-queries?')) data = { records: [{ query: { id: '90', containerBookingId: '50', triggerSource: 'OUTBOUND_CONFIRM' } }] }
else if (path.endsWith('/carrier-schedule-queries/90')) data = carrier()
else if (path.endsWith('/manual-nodes')) { carrierNode = body.standardNode; carrierVersion++; latestActualAt = body.actualAt; data = carrier() }
else if (path.endsWith('/terminal-drayages/50')) data = { containerBookingId: '50', containerNo, version: 0 }
else if (path.endsWith('/terminal-drayages/50/fleet')) data = { version: 1 }
else if (path.endsWith('/pickup-appointment')) data = { version: 2, pickupAppointmentAt: body.pickupAppointmentAt }
else if (/\/(accept|start)\?/.test(path) || /confirmed-package-count|measurement\/draft|weight-results\/40\/confirm|measurement\/submit|fees\/latest|fees\/confirm|tasks\/80\/complete/.test(path)) data = {}
else throw Error(`unhandled ${method} ${path}`)
return { http: 200, body: { code: '200', data } }
}
context.requestMultipart = async (method, path, form, token, headers) => {
assert.ok(form instanceof FormData)
calls.push({ method, path, token, headers, multipart: true })
let data
if (path.includes('/carton-scans')) data = { itemId: '31', measurementId: '30', measurementVersion: 0 }
else if (path.includes('/files/measurements/')) data = { fileId: '41' }
else if (path.includes('/documents/upload')) data = { bookingVersion: 1 }
else if (path.includes('/tasks/80/evidence')) data = { taskId: '80' }
else throw Error(`unhandled upload ${path}`)
return { http: 200, body: { code: '200', data } }
}
context.requestBinary = async () => ({ http: 200, contentType: 'application/pdf', bytes: Buffer.from('%PDF-test') })
return { input, order, context, checkpoint, calls }
}
test('single-ticket positive HTTP orchestration conserves two cartons and stops at pickup appointment', async () => {
const value = fixture(), result = await runDomesticStage(value)
assert.equal(result.status, 'DOMESTIC_PICKUP_SCHEDULED')
assert.equal(result.inboundId, '20')
assert.equal(result.packageCount, 2)
const batch = value.calls.find(call => call.path.endsWith('/tasks/80/batch'))
assert.equal(batch.body.items[0].pickedPackageCount, 2)
assert.equal(batch.token, value.context.pdaToken)
const weight = value.calls.find(call => call.path.includes('/weight-results/40/confirm'))
assert.equal(weight.body.adjustedWeightValue, 10)
assert.equal(value.calls.filter(call => call.path.endsWith('/manual-nodes')).length, 7)
assert.equal(value.calls.filter(call => call.path.includes('/evidence?')).length, 5)
assert.ok(value.calls.every(call => !/pickup-email|\/suppliers|\/users|\/customers/.test(call.path)))
assert.ok(Object.values(value.checkpoint.state.commands).every(command => command.status === 'DONE'))
})
test('tax mode and customer identity fail closed before unrelated business writes', async () => {
const value = fixture()
value.input.payload.taxMode = 'SELF_DECLARATION'
await assert.rejects(runDomesticStage(value), /买单报关/)
assert.equal(value.calls.length, 0)
})
test('a timeout at first business write preserves intent and stops all later actions', async () => {
const value = fixture(), request = value.context.request
value.context.request = async (...args) => args[0] === 'POST' ? Promise.reject(Error('timeout')) : request(...args)
await assert.rejects(runDomesticStage(value), /timeout/)
assert.equal(value.checkpoint.state.commands.accept.status, 'INTENT')
assert.equal(Object.keys(value.checkpoint.state.commands).length, 1)
})
+93
View File
@@ -0,0 +1,93 @@
import { resolve } from 'node:path'
import { fingerprint, validateOrderInput } from './prod-release-order-stage.mjs'
import { normalizeOrderInput } from './prod-release-measurement-plan.mjs'
import { executeOrder } from './prod-release-acceptance.mjs'
import { executeDelivery } from './prod-release-delivery-stage.mjs'
import { createFlowContext, withFlowJournal, requireSuccess } from './prod-release-http.mjs'
import { runDomesticStage } from './prod-release-domestic-stage.mjs'
import { runOverseasStage, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs'
export async function executeFullFlow(settings, rawInput, journalPath, resume = false, fetcher = fetch) {
const input = { ...normalizeOrderInput(rawInput), customer: settings.customer,
environment: { mode: settings.environment, apiBase: settings.apiBase },
operator: { merchant: settings.credentials.MERCHANT.loginId, admin: settings.credentials.ADMIN.loginId } }
validateOrderInput(input)
const binding = fingerprint({ input, credentials: Object.fromEntries(Object.entries(settings.credentials).map(([key,value])=>[key,value.loginId])) })
return withFlowJournal(journalPath, binding, async (state, save) => {
if (state.status !== 'NEW' && !resume) throw Error('已有全流程检查点,须保持原输入并显式 --resume')
const ctx = createFlowContext({ settings, state, save, fetcher })
ctx.marker = input.batch
const checkpoint = name => {
const local = state.stages[name] || {}
return { state: local, save: async patch => {
const next = { ...local, ...patch }
await save({ stages: { ...state.stages, [name]: next } }); Object.assign(local, next)
} }
}
const bridgeFetch = async (url, options) => {
if (!url.startsWith(settings.apiBase + '/')) throw Error('全流程请求偏离统一Gateway')
const path = '/api' + url.slice(settings.apiBase.length)
const token = options.headers?.Authorization?.replace(/^Bearer /, '')
const body = options.body instanceof FormData ? options.body : options.body ? JSON.parse(options.body) : undefined
const result = await ctx.requestWithBody(options.method, path, body, token ?? null,
options.headers?.['Idempotency-Key'] ? { 'Idempotency-Key': options.headers['Idempotency-Key'] } : {})
if (path === '/api/auth/login' && result.body?.code === '200') ctx.registerSession(result.body.data)
return { ok: result.http >= 200 && result.http < 300, status: result.http, json: async () => result.body }
}
try {
await save({ status: 'RUNNING' })
ctx.token = (await ctx.login('ADMIN')).accessToken
ctx.merchantToken = (await ctx.login('MERCHANT')).accessToken
ctx.pdaToken = (await ctx.login('CN_PDA')).accessToken
ctx.overseasPdaToken = (await ctx.login('OVERSEAS_LAX_PDA')).accessToken
const binding = requireSuccess(await ctx.request('GET', '/api/pda/v1/last-leg/receiving-containers/warehouse-profile', undefined, ctx.overseasPdaToken))
if (binding.warehouseCode !== 'US-LAX-01') throw Error('海外PDA不是洛杉矶接柜仓')
const orderCp = checkpoint('order')
let order = orderCp.state.result
if (!order) {
order = await executeOrder(settings, input, resolve(`${journalPath}.order.json`), resume, bridgeFetch)
await orderCp.save({ result: order })
}
console.log('单票阶段:ORDER_APPROVED')
const domesticCp = checkpoint('domestic')
let domestic = domesticCp.state.result
if (!domestic) {
domestic = await runDomesticStage({ input, order, context: ctx, checkpoint: domesticCp })
await domesticCp.save({ result: domestic })
}
console.log(`单票阶段:${domestic.status}`)
const overseasCp = checkpoint('overseas')
let overseas = overseasCp.state.result
if (!overseas) {
overseas = await runOverseasStage({ input: { ...input, customerId: settings.customer.id,
packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId },
domestic: { ...domestic, sourceInboundId: domestic.inboundId, orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode },
context: ctx, checkpoint: overseasCp })
await overseasCp.save({ result: overseas })
}
console.log(`单票阶段:${overseas.status}`)
const delivery = await executeDelivery(settings, { batch: input.batch, ...overseas,
orderId: order.orderId, orderNo: order.orderNo, inboundCode: order.inboundCode,
packageCount: input.payload.cargoes[0].packageCount, warehouseId: binding.warehouseId },
resolve(`${journalPath}.delivery.json`), bridgeFetch)
if (delivery.blockers.length) throw Error(`签收/回传未闭环:${delivery.blockers.join(',')}`)
const tripClosure = await finishSingleOrderTrip({ input: { ...input, customerId: settings.customer.id }, overseas, context: ctx, checkpoint: checkpoint('tripClosure') })
const result = { status: 'SINGLE_ORDER_HTTP_COMPLETED', fullFlowVerified: true,
scope: 'ONE_ORDER_NORMAL_HTTP_PATH', orderNo: order.orderNo, inboundCode: order.inboundCode,
containerNo: domestic.containerNo, dispatchNo: overseas.dispatchNo, deliveryNo: overseas.deliveryNo,
domesticOrderStatus: delivery.domesticOrderStatus, domesticInboundStatus: delivery.domesticInboundStatus,
tripClosureStatus: tripClosure.status,
syntheticEvidence: true, excluded: ['真实货物及设备', '浏览器视觉', '故障注入', '多客户/拆单/异常分支', '真实付款/第三方下单/邮件'],
completedAt: new Date().toISOString() }
await save({ status: 'COMPLETED', result })
return result
} catch (error) {
await save({ status: 'BLOCKED', failure: error.message })
throw error
} finally {
const cleanup = await ctx.close()
if (state.result) state.result.sessionCleanup = cleanup.length ? 'FAILED' : 'COMPLETED'
await save({ cleanup })
}
})
}
+123
View File
@@ -0,0 +1,123 @@
import { createHash } from 'node:crypto'
import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'
import { dirname } from 'node:path'
const hash = value => createHash('sha256').update(value).digest('hex')
const safe = value => typeof value === 'string' && /^[\w-]{1,100}$/.test(value) ? value : 'unavailable'
export const success = response => response?.http === 200 && response.body?.code === '200'
export function requireSuccess(response) {
if (!success(response)) throw new Error(`HTTP=${response?.http}; code=${safe(response?.body?.code)}; requestId=${safe(response?.requestId)}; traceId=${safe(response?.traceId)}`)
return response.body.data
}
export async function withFlowJournal(path, binding, action) {
await mkdir(dirname(path), { recursive: true })
const lock = await open(`${path}.lock`, 'wx', 0o600)
try {
let state
try { state = JSON.parse(await readFile(path, 'utf8')) }
catch (error) {
if (error.code !== 'ENOENT') throw new Error('全流程检查点损坏;禁止新建替代批次')
state = { version: 1, binding, stages: {}, requests: [], status: 'NEW' }
}
if (state.version !== 1 || state.binding !== binding) throw new Error('全流程检查点绑定变化')
const save = async patch => {
const next = { ...state, ...patch }
const file = await open(`${path}.next`, 'wx', 0o600)
try { await file.writeFile(JSON.stringify(next, null, 2)); await file.sync() } finally { await file.close() }
await rename(`${path}.next`, path)
Object.assign(state, next)
}
await save({})
return await action(state, save)
} finally { await lock.close(); await unlink(`${path}.lock`) }
}
async function bodyDigest(body) {
if (!(body instanceof FormData)) return hash(JSON.stringify(body ?? null))
const parts = []
for (const [key, value] of body) parts.push([key, typeof value === 'string' ? value
: { name: value.name, type: value.type, sha256: hash(Buffer.from(await value.arrayBuffer())) }])
return hash(JSON.stringify(parts))
}
// Only public business HTTP. No fixture runner, SQL, deployment or automatic network retry.
export function createFlowContext({ settings, state, save, fetcher = fetch }) {
const sessions = []
const context = {
marker: '', options: {}, context: {}, createdResources: {}, steps: [],
registerSession(session) { sessions.push(session) },
async request(method, path, body, token = context.token, extraHeaders = {}) {
return send(method, path, body, token, extraHeaders, false)
},
async requestWithBody(method, path, body, token = context.token, extraHeaders = {}) {
return send(method, path, body, token, extraHeaders, false)
},
async requestMultipart(method, path, file, token = context.token, extraHeaders = {}) {
const form = file instanceof FormData ? file : new FormData()
if (!(file instanceof FormData)) {
if (!file || file.content === undefined) throw new Error('缺少上传内容')
form.append('file', new Blob([file.content], { type: file.type || 'application/octet-stream' }), file.name || 'acceptance.bin')
}
return send(method, path, form, token, extraHeaders, false)
},
async requestBinary(method, path, token = context.token) { return send(method, path, undefined, token, {}, true) },
apiSuccess(module, name, response) {
const data = requireSuccess(response)
context.steps.push({ module, name, passed: true })
return data
},
assert(module, name, response, condition) {
context.steps.push({ module, name, passed: Boolean(condition) })
if (!condition) throw new Error(`${module}: ${name}`)
},
async login(role) {
const data = requireSuccess(await send('POST', '/api/auth/login', settings.credentials[role], null, {}, false))
if (!data?.accessToken || !data.refreshToken) throw new Error(`${role}: 登录令牌不完整`)
sessions.push(data)
return data
},
async close() {
const failures = []
for (const session of sessions) {
try { requireSuccess(await send('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken, {}, false)) }
catch { failures.push('SESSION_LOGOUT_FAILED') }
}
return failures
},
}
async function send(method, path, body, token, extraHeaders, binary) {
if (!['GET', 'POST', 'PUT'].includes(method) || !/^\/api\/(auth\/(login|logout)$|platform\/|merchant\/|pda\/)/.test(path)
|| path.includes('..') || path.includes('#')) throw new Error('全流程请求路径或方法不允许')
const auth = path === '/api/auth/login' || path === '/api/auth/logout'
if (path !== '/api/auth/logout' && state.requests.length >= 2500) throw new Error('全流程请求超过安全上限')
const entry = { sequence: state.requests.length + 1, method, path, startedAt: new Date().toISOString(),
status: 'INTENT', ...(auth ? {} : { bodyHash: await bodyDigest(body) }) }
await save({ requests: [...state.requests, entry] })
let result
try {
const response = await fetcher(settings.apiBase.replace(/\/api$/, '') + path, {
method, redirect: 'error', signal: AbortSignal.timeout(45000),
headers: { ...(body !== undefined && !(body instanceof FormData) ? { 'Content-Type': 'application/json' } : {}),
...extraHeaders, ...(token ? { Authorization: `Bearer ${token}` } : {}) },
...(body !== undefined ? { body: body instanceof FormData ? body : JSON.stringify(body) } : {}),
})
if (binary && response.ok && !response.headers.get('content-type')?.includes('json')) {
const bytes = new Uint8Array(await response.arrayBuffer())
result = { http: response.status, bytes, buffer: Buffer.from(bytes), contentType: response.headers.get('content-type'), headers: Object.fromEntries(response.headers) }
} else {
const envelope = await response.json()
result = { http: response.status, body: envelope, requestId: envelope?.requestId || response.headers.get('x-request-id'),
traceId: envelope?.traceId || response.headers.get('x-trace-id') }
}
} catch {
await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row, status: 'UNKNOWN' } : row) })
throw new Error(`HTTP结果不确定:${method} ${path};禁止自动重试`)
}
await save({ requests: state.requests.map(row => row.sequence === entry.sequence ? { ...row,
status: success(result) || result.bytes ? 'DONE' : 'REJECTED', http: result.http,
code: safe(result.body?.code), requestId: safe(result.requestId), traceId: safe(result.traceId) } : row) })
return result
}
return context
}
+50
View File
@@ -0,0 +1,50 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createFlowContext, requireSuccess } from './prod-release-http.mjs'
const settings = { apiBase: 'https://fixture.invalid/api', credentials: { ADMIN: { loginId: 'fixture', password: 'private' } } }
function harness(fetcher, saveError = false) {
const state = { requests: [] }, saves = []
const save = async patch => { if (saveError) throw Error('disk failed'); Object.assign(state,patch); saves.push(structuredClone(state)) }
return { state, saves, ctx: createFlowContext({ settings, state, save, fetcher }) }
}
test('persists intent before writes and never saves credentials or tokens', async () => {
let h
h = harness(async (_url, options) => {
assert.equal(h.state.requests.at(-1).status, 'INTENT')
return new Response(JSON.stringify({ code: '200', data: { accessToken: 'access-secret', refreshToken: 'refresh-secret' } }))
})
await h.ctx.login('ADMIN')
await h.ctx.close()
assert.doesNotMatch(JSON.stringify(h.state), /private|access-secret|refresh-secret/)
assert.ok(h.state.requests.every(row=>row.status==='DONE'))
})
test('disk failure prevents HTTP and unknown network result is never retried', async () => {
let calls=0
const broken=harness(async()=>{calls++;throw Error('network')},true)
await assert.rejects(broken.ctx.request('POST','/api/platform/v1/example',{}),/disk/)
assert.equal(calls,0)
const h=harness(async()=>{calls++;throw Error('network')})
await assert.rejects(h.ctx.request('POST','/api/platform/v1/example',{}),/结果不确定/)
assert.equal(calls,1)
assert.equal(h.state.requests[0].status,'UNKNOWN')
})
test('business errors retain diagnostic IDs and binary responses preserve bytes',async()=>{
const h=harness(async()=>new Response(JSON.stringify({code:'A0403',requestId:'req-1',traceId:'trace-1',message:'private'})))
const response=await h.ctx.request('GET','/api/platform/v1/example')
assert.throws(()=>requireSuccess(response),/A0403.*req-1.*trace-1/)
assert.doesNotMatch(JSON.stringify(h.state),/private/)
const binary=harness(async()=>new Response('%PDF-fixture',{headers:{'content-type':'application/pdf'}}))
const pdf=await binary.ctx.requestBinary('POST','/api/platform/inbounds/1/carton-label-pdf')
assert.equal(Buffer.from(pdf.bytes).toString(),'%PDF-fixture')
assert.equal(pdf.contentType,'application/pdf')
})
test('request limit blocks business calls but permits session logout', async () => {
const h = harness(async () => new Response(JSON.stringify({ code: '200', data: {} })))
h.ctx.registerSession({ accessToken: 'secret', refreshToken: 'refresh' })
h.state.requests = Array.from({ length: 2500 }, (_, index) => ({ sequence: index + 1, status: 'DONE' }))
await assert.rejects(h.ctx.request('GET', '/api/platform/v1/example'), /安全上限/)
assert.deepEqual(await h.ctx.close(), [])
assert.equal(h.state.requests.at(-1).path, '/api/auth/logout')
assert.equal(h.state.requests.at(-1).status, 'DONE')
})
+2 -2
View File
@@ -2,13 +2,13 @@
export const CARTON_PROFILE = Object.freeze({ lengthCm: 40, widthCm: 30, heightCm: 30, weightKg: 7.5 })
export function cartonTotals(packageCount) {
if (!Number.isInteger(packageCount) || packageCount < 1 || packageCount > 2) throw new Error('生产冒烟仅允许 1~2 箱')
if (!Number.isInteger(packageCount) || packageCount < 1 || packageCount > 2) throw new Error('单票冒烟仅允许 1~2 箱')
return { packageCount, lengthCm: 40, widthCm: 30, heightCm: 30,
grossWeightKg: packageCount * 7.5, volumeCbm: packageCount * 36 / 1000 }
}
export function normalizeOrderInput(input) {
if (!Array.isArray(input?.payload?.cargoes) || input.payload.cargoes.length !== 1) throw new Error('生产冒烟仅支持一个 SKU')
if (!Array.isArray(input?.payload?.cargoes) || input.payload.cargoes.length !== 1) throw new Error('单票冒烟仅支持一个 SKU')
const cargo = input.payload.cargoes[0]
const totals = cartonTotals(cargo.packageCount)
if (cargo.netWeightKg != null && (!Number.isFinite(Number(cargo.netWeightKg))
+4 -1
View File
@@ -36,6 +36,8 @@ export function validateOrderInput(input) {
assert(payload.inboundCode == null && payload.senderAddressId == null && payload.privateDestinationAddressId == null, '不允许手工入仓码或地址副作用')
assert(Array.isArray(payload.cargoes) && payload.cargoes.length === 1, '专属冒烟固定一个 SKU')
const cargo = payload.cargoes[0]
assert(payload.shipmentId && payload.referenceId && cargo.shipmentId && cargo.referenceId,
'FBA 订单及货物行均必须提供 Shipment ID 和 Reference ID')
assert(Number.isInteger(cargo.packageCount) && cargo.packageCount >= 1 && cargo.packageCount <= 2, '冒烟仅允许 1~2 箱')
assert(cargo.woodenProduct === false && cargo.dangerousGoods === false && !cargo.packingGroupNo, '仅支持非木制非危险单 SKU 货物')
assert(longId(cargo.productImageFileId), '需要客户已上传产品图片,不生成虚假证明文件')
@@ -58,7 +60,8 @@ function binding(input) {
// The caller must retain this file between invocations. Never generate a new batch to retry a failed one.
export async function withOrderJournal(path, input, action) {
validateOrderInput(input)
assert(typeof input.environment === 'string' && input.environment.startsWith('https://'), '缺少环境绑定')
assert(['test', 'gray'].includes(input.environment?.mode)
&& typeof input.environment.apiBase === 'string' && input.environment.apiBase.startsWith('https://'), '缺少环境绑定')
await mkdir(dirname(path), { recursive: true })
const lock = await open(`${path}.lock`, 'wx', 0o600)
const temp = `${path}.next`
+9 -6
View File
@@ -7,15 +7,17 @@ import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage, valid
import { executeOrder } from './prod-release-acceptance.mjs'
function fixture() {
return { batch: 'release-fixture-001', environment: 'https://fixture.invalid/api',
return { batch: 'release-fixture-001', environment: { mode: 'gray', apiBase: 'https://fixture.invalid/api' },
operator: { merchant: 'fixture-client', admin: 'fixture-admin' },
customer: { id: '1', name: 'fixture', code: 'fixture' }, origin: { id: '2', code: 'CN-YIWU-01' },
destination: { id: '3', code: 'FIXTURE', type: 'FBA' },
payload: { customerName: 'fixture', receivingWarehouseId: '2', destinationWarehouseId: '3',
shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture',
amazonWarehouseCode: 'FIXTURE', transportMode: 'SEA', destinationType: 'FBA', fulfillmentType: 'STANDARD',
containerMode: 'LCL', pickupMethod: 'SELF_EXPRESS', channelCode: 'FIXTURE', pricingTaxMode: 'TAX_INCLUDED',
customerRemark: 'RELEASE-ACCEPTANCE:release-fixture-001',
cargoes: [{ skuNo: 'fixture-sku', packageCount: 2, unitsPerPackage: 1, totalQuantity: 2,
shipmentId: 'SHIP-fixture', referenceId: 'REF-fixture',
lengthCm: 40, widthCm: 30, heightCm: 30, grossWeightKg: 15, volumeCbm: 0.072,
productImageFileId: '4', woodenProduct: false, dangerousGoods: false }] } }
}
@@ -143,7 +145,7 @@ test('uncertain approval that still reads submitted must not be resent', async t
test('change in environment, account, payload or batch is blocked before HTTP', async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
await run(path, input, api)
for (const change of [i => i.environment = 'https://other.invalid/api', i => i.operator.merchant = 'other',
for (const change of [i => i.environment.apiBase = 'https://other.invalid/api', i => i.environment.mode = 'test', i => i.operator.merchant = 'other',
i => i.payload.channelCode = 'OTHER', i => { i.batch = 'release-fixture-002'; i.payload.customerRemark = `RELEASE-ACCEPTANCE:${i.batch}` }]) {
const other = fixture(); change(other)
const count = api.calls.length
@@ -184,14 +186,15 @@ test('corrupt journal is preserved and fails closed', async t => {
for (const quoteMismatch of [false, true]) {
test(`entry integration: customer/profile/route checks and HTTP headers (quote mismatch=${quoteMismatch})`, async t => {
const path = await sandbox(t), input = fixture(), api = server(input)
const settings = { cn: 'https://fixture.invalid/api', overseas: 'https://fixture.invalid/overseas-api', customer: input.customer,
const settings = { environment: 'gray', apiBase: 'https://fixture.invalid/api', customer: input.customer,
credentials: Object.fromEntries(['ADMIN', 'MERCHANT', 'CN_PDA', 'OVERSEAS_LAX_PDA'].map(role => [role, { loginId: role, password: 'secret' }])) }
const fetcher = async (url, options) => {
const endpoint = new URL(url).pathname.replace(/^\/(overseas-api|api)/, '') + new URL(url).search
assert.ok(url.startsWith('https://fixture.invalid/api/'))
const endpoint = new URL(url).pathname.replace(/^\/api/, '') + new URL(url).search
let data
if (endpoint === '/auth/login') {
const role = JSON.parse(options.body).loginId
data = { accessToken: role, overseasAccessToken: `${role}-overseas` }
data = { accessToken: role }
} else if (endpoint.includes('/iam/')) {
data = { id: '1', tenantCode: 'fixture', tenantName: 'fixture', status: 'ACTIVE', dimensionInitializationStatus: 'READY' }
} else if (endpoint.includes('/warehouse-admin?')) {
@@ -203,7 +206,7 @@ for (const quoteMismatch of [false, true]) {
assert.equal(options.headers.Authorization, 'Bearer CN_PDA')
data = [{ id: '2', warehouseCode: 'CN-YIWU-01' }, { id: '4', warehouseCode: 'CN-DONGGUAN-01' }]
} else if (endpoint.endsWith('/warehouse-profile')) {
assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA-overseas')
assert.equal(options.headers.Authorization, 'Bearer OVERSEAS_LAX_PDA')
data = { warehouseId: '6', warehouseCode: 'US-LAX-01' }
} else if (/\/platform\/v1\/(pallets|locations)\?/.test(endpoint)) {
data = { total: '1', records: [{ warehouseId: '2', status: 'AVAILABLE' }] }
+283
View File
@@ -0,0 +1,283 @@
import { fingerprint } from './prod-release-order-stage.mjs'
import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs'
const BASE = '/api/platform/v1/last-leg'
const PDA = '/api/pda/v1/last-leg'
const check = (ok, message) => { if (!ok) throw new Error(message) }
const id = value => value == null ? '' : String(value)
const query = (path, values) => `${path}?${new URLSearchParams(values)}`
const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64')
/** Each successful command is persisted; an interrupted command must be investigated, never resent. */
export function overseasCommands(context, checkpoint) {
const { state, save } = checkpoint
const unwrap = response => {
check(response?.http === 200 && String(response?.body?.code) === '200',
`海外 HTTP=${response?.http}; code=${response?.body?.code}; requestId=${response?.body?.requestId}; traceId=${response?.body?.traceId}`)
return response.body.data
}
async function command(name, method, path, body, token = context.token, headers = {}, file = null) {
const binding = fingerprint({ method, path, body: body ?? null, headers, file })
const previous = state.commands?.[name]
check(!previous || previous.binding === binding, `海外命令 ${name} 与检查点不符`)
if (previous?.status === 'DONE') return previous.result
check(!previous, `海外命令 ${name} 结果未知;停止重发并保留检查点`)
const persist = async value => { const commands = { ...state.commands, [name]: value }; await save({ commands }); state.commands = commands }
await persist({ binding, status: 'INTENT' })
let response
if (file) {
const form = new FormData()
form.append('file', new Blob([PNG], { type: 'image/png' }), file)
response = await context.requestWithBody(method, path, form, token)
} else response = await context.request(method, path, body, token, headers)
const result = unwrap(response)
await persist({ binding, status: 'DONE', result })
return result
}
return { command, read: async (path, token = context.token) => unwrap(await context.request('GET', path, undefined, token)),
readCurrentReceivingSession: async token => {
const response = await context.request('GET', `${PDA}/receiving-containers/current`, undefined, token)
// Only the initial current-session probe treats the service's absent-session business code as empty.
// Subsequent mandatory session reads and every other resource remain strict.
if (response?.http === 200 && String(response?.body?.code) === 'A0404') return null
return unwrap(response)
},
lookup: async (path, body) => unwrap(await context.request('POST', path, body)) }
}
export function validateOverseasInput(input, domestic) {
check(/^[A-Za-z0-9][A-Za-z0-9_-]{7,47}$/.test(input.batch || ''), '无效验收批次')
check([1, 2].includes(input.packageCount), '海外仅支持1~2箱单票')
for (const key of ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`)
for (const key of ['vehicleId', 'driverId']) check(/^[1-9]\d*$/.test(input.resources?.[key] || ''), `缺少既有 ${key}`)
check(Boolean(input.resources.carrierName), '缺少既有车队名称')
for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`)
for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`)
if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks)
&& domestic.cartonMarks.length === input.packageCount && new Set(domestic.cartonMarks).size === input.packageCount,
'本票冻结箱码数量不符')
}
/** Normal single-ticket path only. No fixture runners, account creation, or master-data mutation. */
export async function runOverseasStage({ input, domestic, context, checkpoint }) {
validateOverseasInput(input, domestic)
check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话')
const { state, save } = checkpoint
check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点')
const { read, lookup, command, readCurrentReceivingSession } = overseasCommands(context, checkpoint)
const pdaToken = context.overseasPdaToken
const remark = `RELEASE-ACCEPTANCE:${input.batch}`
const key = name => `${input.batch}-${name}`
if (!state.schedule) {
const schedule = { plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(),
expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(),
arrival: new Date(Date.now() + 86400000).toISOString().slice(0, 19) }
await save({ schedule }); state.schedule = schedule
}
const schedule = state.schedule
const vehicle = await read(`${BASE}/master-data/vehicles/${input.resources.vehicleId}`)
const driver = await read(query(`${BASE}/fleet/drivers/${input.resources.driverId}`, { expectedEnd: schedule.expectedDriverEndAt }))
check(id(vehicle.id) === input.resources.vehicleId && vehicle.region === 'OVERSEAS' && vehicle.status === 'IN_SERVICE', '既有海外车辆不可用')
check(id(driver.id) === input.resources.driverId && driver.qualification?.canDispatch === true, '既有司机证照/审核不满足本次出车')
let sources
for (let attempt = 0; attempt < 60; attempt++) {
const page = await read(query(`${BASE}/orders`, { containerNo: domestic.containerNo, page: '1', size: '200' }))
sources = (page.records || []).filter(row => id(row.sourceContainerBookingId) === domestic.containerBookingId)
if (sources.length) break
if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
check(sources?.length === 1 && id(sources[0].customerId) === input.customerId
&& id(sources[0].sourceInboundId) === domestic.sourceInboundId && sources[0].inboundCode === domestic.inboundCode
&& id(sources[0].overseasWarehouseId) === input.warehouseId, '柜内尾程来源不属于本次唯一客户/单票/接柜仓')
const tailOrderId = id(sources[0].id)
const arrivalRows = await lookup(`${BASE}/orders/container-arrival-times/query`, { sourceContainerBookingIds: [domestic.containerBookingId] })
const arrival = arrivalRows.find(row => id(row.sourceContainerBookingId) === domestic.containerBookingId)
check(arrival && Number.isSafeInteger(Number(arrival.version)), '柜级到仓版本缺失')
if (!arrival.estimatedWarehouseArrivalAt) await command('arrival', 'PUT', `${BASE}/orders/container-summaries/${domestic.containerBookingId}/expected-arrival`,
{ estimatedWarehouseArrivalAt: schedule.arrival, expectedVersion: Number(arrival.version), remark }, undefined, { 'Idempotency-Key': key('arrival') })
let plans = state.commands?.convert?.result
if (!plans) {
const preview = await read(query(`${BASE}/inbound-plans/container-preview`, { sourceContainerBookingId: domestic.containerBookingId }))
check(preview.ready === true && Number(preview.totalOrderCount) === 1 && Number(preview.pendingOrderCount) === 1, '本票计划不可转换或存在未登记计划')
plans = await command('convert', 'POST', `${BASE}/inbound-plans/convert-container`, {
sourceContainerBookingId: domestic.containerBookingId, snapshotVersion: Number(preview.snapshotVersion), snapshotHash: preview.snapshotHash,
}, undefined, { 'Idempotency-Key': key('convert') })
}
check(plans?.length === 1 && plans[0].lines?.length > 0 && plans[0].lines.every(line => line.inboundCode === domestic.inboundCode)
&& plans[0].pallets?.length === 1, '本票入仓计划/实体托盘不唯一')
const pallet = plans[0].pallets[0]
check(pallet.palletNo && pallet.warehousePalletId, '计划未生成实体托盘')
const receiving = `${PDA}/receiving-containers`
if (!state.commands?.receiveComplete) {
const current = await readCurrentReceivingSession(pdaToken)
check(!current || current.containerNo === domestic.containerNo && current.inbounds?.length === 1
&& current.inbounds[0].inboundCode === domestic.inboundCode, '海外 PDA 正在操作其他柜')
const detailPath = query(`${receiving}/${encodeURIComponent(domestic.containerNo)}`, { sourceContainerBookingId: domestic.containerBookingId })
const detail = await read(detailPath, pdaToken)
check(id(detail.overseasWarehouseId) === input.warehouseId && detail.inbounds?.length === 1
&& detail.inbounds[0].inboundCode === domestic.inboundCode && Number(detail.totalPackages) === input.packageCount, '收货柜身份或箱数不符')
const destinationType = detail.inbounds[0].destinationType
check(['OVERSEAS', 'FBA'].includes(destinationType), '单票不支持此收货类型')
if (destinationType === 'OVERSEAS') check(domestic.cartonMarks?.length === input.packageCount,
'OVERSEAS逐箱收货必须提供本票正式冻结箱码')
await command('prepare', 'POST', query(`${receiving}/${encodeURIComponent(domestic.containerNo)}/prepare`, { sourceContainerBookingId: domestic.containerBookingId }), { idempotencyKey: key('prepare') }, pdaToken)
const photos = []
for (const role of ['LICENSE_PLATE', 'CONTAINER_SEAL', 'DOOR_OPEN']) {
const uploaded = await command(`photo-${role}`, 'POST', `${receiving}/current/unload-photos/files`, undefined, pdaToken, {}, `${input.batch}-SYNTHETIC-${role}.png`)
check(/^[1-9]\d*$/.test(id(uploaded.fileId)), '验收图片未返回文件ID')
photos.push({ role, fileId: id(uploaded.fileId) })
}
await command('sign', 'POST', `${receiving}/current/sign`, { photos, idempotencyKey: key('sign') }, pdaToken)
const resolved = await command('resolve-pallet', 'POST', `${receiving}/current/pallets/resolve`, { palletCode: pallet.palletNo, inboundCode: domestic.inboundCode }, pdaToken)
check(resolved.compatible === true, '计划托盘不兼容')
// FBA receives cumulative physical counts on its source pallet; carton resolution is not a receiving prerequisite.
for (const [index, cartonMark] of (destinationType === 'OVERSEAS' ? domestic.cartonMarks : []).entries()) {
const carton = await command(`resolve-carton-${index}`, 'POST', `${receiving}/current/cartons/resolve`, { cartonMark }, pdaToken)
check(carton.inboundCode === domestic.inboundCode, '箱码未归属本票')
await command(`receive-${index}`, 'POST', `${receiving}/current/receipts/carton`, { cartonMark, palletCode: pallet.palletNo, idempotencyKey: key(`receive-${index}`) }, pdaToken)
}
if (destinationType === 'FBA') await command('receive-bulk', 'POST', `${receiving}/current/receipts/bulk`, {
inboundCode: domestic.inboundCode, palletCode: pallet.palletNo, packageCount: input.packageCount, idempotencyKey: key('receive-bulk'),
}, pdaToken)
const received = await read(`${receiving}/current`, pdaToken)
check(Number(received.pendingPackages) === 0 && Number(received.totalPackages) === input.packageCount, '本票收货数量未核平')
await command('receiveComplete', 'POST', `${receiving}/current/complete`, { idempotencyKey: key('receive-complete') }, pdaToken)
}
if (!state.commands?.emptyReturn) {
const drayagePath = `/api/platform/v1/terminal-drayages/${domestic.containerBookingId}`
let drayage
for (let attempt = 0; attempt < 60; attempt++) {
drayage = await read(drayagePath)
if (drayage.status === 'UNLOADED' && drayage.unloadingCompletedAt) break
if (attempt < 59) await (context.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))))(1000)
}
check(id(drayage.containerBookingId) === domestic.containerBookingId && drayage.status === 'UNLOADED'
&& drayage.allowedActions?.includes('RECORD_EMPTY_RETURN'), '海外卸柜事实尚未回传国内码头拖车')
const unloaded = Date.parse(`${drayage.unloadingCompletedAt}Z`)
check(Number.isFinite(unloaded), '卸柜时间无效')
const returned = await command('emptyReturn', 'POST', `${drayagePath}/empty-return`, {
expectedVersion: Number(drayage.version), actualAt: new Date(unloaded + 3600000).toISOString().slice(0, 19), remark,
}, undefined, { 'Idempotency-Key': key('empty-return') })
check(returned.status === 'EMPTY_RETURNED', '实际还柜未完成')
}
let dispatch = state.commands?.dispatch?.result
if (!dispatch) {
const candidates = await read(`${BASE}/dispatches/candidates?sourceStage=RECEIVED`)
const matches = candidates.filter(row => row.sourceStage === 'RECEIVED' && row.inboundCode === domestic.inboundCode
&& id(row.customerId) === input.customerId && id(row.overseasWarehouseId) === input.warehouseId)
check(matches.length === 1 && Number(matches[0].packageCount) === input.packageCount, '本票排车候选不唯一或数量不符')
dispatch = await command('dispatch', 'POST', `${BASE}/dispatches/pre-arrange`, { planPalletIds: matches.map(row => id(row.planPalletId)) })
}
const dispatchNo = dispatch.dispatchNo
check(Boolean(dispatchNo), '排车单号缺失')
const transport = { vehiclePlate: vehicle.plateNumber, driverName: driver.details?.name,
driverPhone: driver.details?.phone, carrierName: input.resources.carrierName, estimatedArrivalAt: schedule.arrival }
await command('dispatch-vehicle', 'PUT', `${BASE}/dispatches/${dispatchNo}`, transport)
const transfer = await command('transfer', 'POST', query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), {
idempotencyKey: key('transfer'), plannedDeliveryAt: schedule.arrival, deliveryMethod: 'TRUCK_PALLET',
waybillNo: key('WAYBILL'), carrierName: transport.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET',
vehicleType: 'TRUCK', vehicleSize: '15托', driverName: transport.driverName, driverPhone: transport.driverPhone,
vehiclePlate: transport.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark,
fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark }],
}, context.merchantToken)
check(transfer.deliveries?.length === 1 && transfer.outboundNo, '本票未形成唯一出库/派送单')
let trip = await command('trip', 'POST', `${BASE}/fleet/trips`, { warehouseId: input.warehouseId,
plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt,
stops: [{ name: '验收起运仓' }, { name: '待匹配本票目的地' }], remark, idempotencyKey: key('trip') })
const tripNo = trip.trip.tripNo, tripPath = `${BASE}/fleet/trips/${tripNo}`
trip = await command('trip-assign', 'POST', `${tripPath}/assignment`, { recordVersion: trip.trip.recordVersion,
vehicleId: input.resources.vehicleId, driverId: input.resources.driverId, reason: remark })
trip = await command('trip-bind', 'POST', `${tripPath}/dispatches/bind`, { recordVersion: trip.trip.recordVersion, dispatchNo, reason: remark })
let stops = state.stops
if (!stops) {
const loading = await read(`${tripPath}/loading`)
check(loading.cargo?.length > 0 && loading.cargo.every(row => row.destinationKey), '本票装载缺少真实目的地')
stops = [{ name: '验收起运仓' }, ...new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])).values()]
await save({ stops }); state.stops = stops
}
trip = await command('trip-plan', 'PUT', `${tripPath}/plan`, { recordVersion: trip.trip.recordVersion,
plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, stops, reason: remark })
trip = await command('trip-review', 'POST', `${tripPath}/dispatches/${dispatchNo}/review`, { recordVersion: trip.trip.recordVersion, decision: 'APPROVED' })
const pdaDispatch = `${PDA}/dispatches/${dispatchNo}`
if (!state.commands?.pickComplete) {
const picking = await read(pdaDispatch, pdaToken)
const exactDispatch = await read(`${BASE}/dispatches/${dispatchNo}`)
check(exactDispatch.items?.length > 0 && exactDispatch.items.every(row => row.inboundCode === domestic.inboundCode)
&& picking.items?.length === exactDispatch.items.length
&& picking.items.every(row => exactDispatch.items.some(item => id(item.id) === id(row.dispatchItemId))), 'PDA拣货包含其他票')
for (const item of picking.items) {
let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages)
for (const position of item.positions || []) {
if (remaining <= 0) break
const packages = Math.min(remaining, Number(position.availablePackages))
if (!(packages > 0)) continue
await command(`pick-${item.dispatchItemId}-${position.id}`, 'POST', `${pdaDispatch}/items/${item.dispatchItemId}/pick`, {
positionId: id(position.id), packages, productQuantity: packages, idempotencyKey: key(`pick-${position.id}`), remark,
}, pdaToken)
remaining -= packages
}
check(remaining === 0, '本票库存不足以完成拣货')
}
await command('pickComplete', 'POST', `${pdaDispatch}/complete`, { idempotencyKey: key('pick-complete') }, pdaToken)
}
let loadingResult = state.commands?.loading?.result
if (!loadingResult) {
const loading = await read(`${tripPath}/loading`)
check(!loading.blockReason, '整车装载未就绪')
loadingResult = await command('loading', 'POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion,
manifestHash: loading.manifestHash, pallets: controlledPalletMeasurements(loading.cargo, stops), reason: `${remark} 合成验收测量` })
}
const reviewed = await command('loading-review', 'POST', `${tripPath}/loading-review`, { recordVersion: loadingResult.recordVersion, decision: 'APPROVED' })
if (!state.commands?.depart) {
const ready = await read(pdaDispatch, pdaToken)
check(ready.status === 'COMPLETED' && ready.canDepart === true, 'PDA发车门禁未通过')
}
await command('depart', 'POST', `${pdaDispatch}/depart`, { recordVersion: reviewed.trip.recordVersion, idempotencyKey: key('depart'), remark }, pdaToken)
const outbound = await read(`${BASE}/outbound-orders/${transfer.outboundNo}`)
check(outbound.outbound?.status === 'OUTBOUNDED' && outbound.pallets?.length > 0
&& outbound.pallets.every(row => row.inboundCode === domestic.inboundCode), '本票未真实完成出库')
const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId,
tailOrderId, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: transfer.deliveries[0].deliveryNo,
tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY' }
await save({ result }); return result
}
/** Close only this ticket's trip after delivery; release its two resource claims without creating probe trips. */
export async function finishSingleOrderTrip({ input, overseas, context, checkpoint }) {
check(overseas?.tripNo && overseas?.dispatchNo && overseas?.deliveryNo, '缺少本票车次与派送身份')
check(!Object.values(checkpoint.state.commands || {}).some(row => row.status !== 'DONE'), '车次结案命令结果未知;停止重发')
const { read, command } = overseasCommands(context, checkpoint)
const path = `${BASE}/fleet/trips/${encodeURIComponent(overseas.tripNo)}`
const delivery = await read(query(`${BASE}/delivery-orders/${encodeURIComponent(overseas.deliveryNo)}`, { customerId: input.customerId }))
check(delivery.order?.status === 'DELIVERED' && id(delivery.order.customerId) === input.customerId
&& delivery.order.dispatchNo === overseas.dispatchNo, '本票尚未完成签收,不能结案车次')
const detail = await read(path)
check(detail.trip?.tripNo === overseas.tripNo && id(detail.trip.vehicleId) === input.resources.vehicleId
&& id(detail.trip.driverId) === input.resources.driverId && detail.dispatches?.length === 1
&& detail.dispatches[0].dispatchNo === overseas.dispatchNo, '车次含其他票或资源身份不符')
let completed = checkpoint.state.commands?.tripComplete?.result
if (!completed) {
const completion = await read(`${path}/completion`)
check(completion.canComplete === true && completion.outcomes?.length > 0, '车次结案条件未通过')
completed = await command('tripComplete', 'POST', `${path}/complete`, { recordVersion: completion.recordVersion,
idempotencyKey: `${input.batch}-trip-complete`, reason: '单票验收实际签收完成' })
}
check(completed.trip?.status === 'COMPLETED' && completed.trip.completedAt, '车次货物未结案')
const endLocation = detail.trip.stops?.at(-1)?.name
check(Boolean(endLocation), '本车次缺少结束地点')
const vehicle = await command('releaseVehicle', 'POST', `${path}/resources/release`, {
recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', actualEndAt: completed.trip.completedAt,
endLocation, vehicleStatus: 'IN_SERVICE', idempotencyKey: `${input.batch}-vehicle-release`,
reason: '单票受控验收车辆在签收末站结束,无返程安排',
})
const driver = await command('releaseDriver', 'POST', `${path}/resources/release`, {
recordVersion: vehicle.trip.recordVersion, resourceKind: 'DRIVER', actualEndAt: completed.trip.completedAt,
endLocation, vehicleStatus: null, idempotencyKey: `${input.batch}-driver-release`,
reason: '单票受控验收司机在签收末站结束,无返程安排',
})
check(driver.trip?.vehicleRelease && driver.trip?.driverRelease, '本车次车辆或司机未释放')
const final = await read(path)
check(final.trip?.status === 'COMPLETED' && final.trip.vehicleRelease && final.trip.driverRelease, '车次结案回查未通过')
const result = { tripNo: overseas.tripNo, status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' }
await checkpoint.save({ result }); return result
}
@@ -0,0 +1,158 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs'
const input = { batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1,
resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } }
const domestic = { containerBookingId: '5', sourceInboundId: '6', orderId: '7', containerNo: 'TEST0000001',
inboundCode: 'IN-TEST', orderNo: 'ORDER-TEST', cartonMarks: ['CARTON-TEST'] }
const ok = data => ({ http: 200, body: { code: '200', data } })
const journal = () => { const state = {}; return { state, save: async patch => Object.assign(state, patch) } }
test('reject multi-ticket quantities and duplicate frozen cartons before HTTP', () => {
assert.throws(() => validateOverseasInput({ ...input, packageCount: 3 }, domestic), /1~2/)
assert.throws(() => validateOverseasInput({ ...input, packageCount: 2 }, { ...domestic, cartonMarks: ['a', 'a'] }), /箱码/)
})
test('command records intent before HTTP; uncertain result blocks resend', async () => {
const checkpoint = journal(); let calls = 0
const { command } = overseasCommands({ request: async () => {
calls++; assert.equal(checkpoint.state.commands.create.status, 'INTENT'); throw new Error('timeout')
} }, checkpoint)
await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /timeout/)
await assert.rejects(command('create', 'POST', '/example', { x: 1 }), /结果未知/)
assert.equal(calls, 1)
})
test('completed command restores result without HTTP and rejects changed request', async () => {
const checkpoint = journal(); let calls = 0
const { command } = overseasCommands({ request: async () => { calls++; return ok({ id: '99' }) } }, checkpoint)
await command('create', 'POST', '/example', { x: 1 })
assert.deepEqual(await command('create', 'POST', '/example', { x: 1 }), { id: '99' })
await assert.rejects(command('create', 'POST', '/example', { x: 2 }), /不符/)
assert.equal(calls, 1)
})
test('unknown prior command blocks even read/other writes on continuation', async () => {
const checkpoint = journal(); checkpoint.state.commands = { lost: { status: 'INTENT' } }
await assert.rejects(runOverseasStage({ input, domestic, checkpoint,
context: { overseasPdaToken: 'pda', merchantToken: 'merchant', request: () => assert.fail('must not call HTTP') } }), /结果未知/)
})
test('only initial current receiving probe accepts HTTP 200 A0404 as no session', async () => {
const calls = []
const helpers = overseasCommands({ request: async (method, path, body, token) => {
calls.push({ method, path, token }); return { http: 200, body: { code: 'A0404' } }
} }, journal())
assert.equal(await helpers.readCurrentReceivingSession('pda'), null)
assert.deepEqual(calls[0], { method: 'GET', path: '/api/pda/v1/last-leg/receiving-containers/current', token: 'pda' })
await assert.rejects(helpers.read('/api/pda/v1/last-leg/receiving-containers/current', 'pda'), /A0404/)
await assert.rejects(helpers.read('/api/platform/v1/last-leg/orders/1'), /A0404/)
})
test('current receiving probe preserves auth, transport and other business errors', async () => {
for (const [http, code] of [[200, 'A0403'], [200, 'A0500'], [401, 'A0404'], [404, 'A0404'], [503, 'A0404']]) {
const helpers = overseasCommands({ request: async () => ({ http, body: { code, requestId: 'request-test' } }) }, journal())
await assert.rejects(helpers.readCurrentReceivingSession('pda'), /request-test/)
}
})
function fixture({ foreignSource = false, destinationType = 'OVERSEAS' } = {}) {
const checkpoint = journal(), calls = []
const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0,
inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] }
const trip = { trip: { tripNo: 'TRIP-TEST', recordVersion: 1, stops: [] } }
const cargo = [{ warehousePalletId: '9', palletNo: 'PLT-TEST', destinationKey: 'DEST', destinationLabel: 'Destination' }]
const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {},
requestWithBody: async (method, path, form, token) => {
calls.push({ method, path, token }); assert.equal(token, 'pda'); assert.ok(form instanceof FormData); return ok({ fileId: '10' })
}, request: async (method, path, body, token) => {
calls.push({ method, path, body, token })
if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' })
if (path.includes('/fleet/drivers/')) return ok({ id: '4', qualification: { canDispatch: true }, details: { name: 'Driver', phone: '10000000000' } })
if (path.includes('/orders?containerNo=')) return ok({ records: [{ sourceContainerBookingId: '5', customerId: foreignSource ? '99' : '1', sourceInboundId: '6',
inboundCode: domestic.inboundCode, overseasWarehouseId: '2', id: '8' }] })
if (path.endsWith('/container-arrival-times/query')) return ok([{ sourceContainerBookingId: '5', version: 0 }])
if (path.includes('/container-preview?')) return ok({ ready: true, totalOrderCount: 1, pendingOrderCount: 1, snapshotVersion: 1, snapshotHash: 'hash' })
if (path.endsWith('/convert-container')) return ok([{ lines: [{ inboundCode: domestic.inboundCode }], pallets: [{ palletNo: 'PLT-TEST', warehousePalletId: '9' }] }])
if (path.endsWith('/current') && method === 'GET') return calls.filter(c => c.path.endsWith('/current')).length === 1
? { http: 200, body: { code: 'A0404' } } : ok(session)
if (path.includes('/receiving-containers/TEST0000001?')) return ok(session)
if (path.endsWith('/pallets/resolve')) return ok({ compatible: true })
if (path.endsWith('/cartons/resolve')) return ok({ inboundCode: domestic.inboundCode })
if (path.endsWith('/terminal-drayages/5')) return ok({ containerBookingId: '5', status: 'UNLOADED',
version: 1, unloadingCompletedAt: '2026-10-12T12:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] })
if (path.endsWith('/empty-return')) return ok({ status: 'EMPTY_RETURNED' })
if (path.endsWith('/candidates?sourceStage=RECEIVED')) return ok([{ sourceStage: 'RECEIVED', inboundCode: domestic.inboundCode,
customerId: '1', overseasWarehouseId: '2', packageCount: 1, planPalletId: '11' }])
if (path.endsWith('/pre-arrange')) return ok({ dispatchNo: 'DISPATCH-TEST' })
if (path.includes('/merchant/v1/last-leg/delivery-orders?')) return ok({ outboundNo: 'OUT-TEST', deliveries: [{ deliveryNo: 'DELIVERY-TEST' }] })
if (path.endsWith('/loading') && method === 'GET') return ok({ cargo, recordVersion: 1, manifestHash: 'manifest' })
if (path.endsWith('/loading') && method === 'POST') return ok({ recordVersion: 2 })
if (path.includes('/fleet/trips')) return ok(trip)
if (path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST') return ok({ status: 'COMPLETED', canDepart: true,
items: [{ dispatchItemId: '12', scheduledPackages: 1, pickedPackages: 0, positions: [{ id: '13', availablePackages: 1 }] }] })
if (path === '/api/platform/v1/last-leg/dispatches/DISPATCH-TEST' && method === 'GET') return ok({ items: [{ id: '12', inboundCode: domestic.inboundCode }] })
if (path.includes('/outbound-orders/')) return ok({ outbound: { status: 'OUTBOUNDED' }, pallets: [{ inboundCode: domestic.inboundCode }] })
if (method === 'GET') assert.fail(`unexpected GET ${path}`)
return ok({})
} }
return { input, domestic, context, checkpoint, calls }
}
test('normal single-ticket HTTP chain reaches outbound with existing master resources and PDA identity', async () => {
const f = fixture(), result = await runOverseasStage(f)
assert.equal(result.status, 'OVERSEAS_OUTBOUNDED')
assert.equal(result.fullFlowVerified, false)
assert.equal(result.deliveryNo, 'DELIVERY-TEST')
assert.ok(f.calls.some(row => row.path.endsWith('/receipts/carton') && row.token === 'pda'))
assert.ok(f.calls.some(row => row.path.endsWith('/depart') && row.token === 'pda'))
assert.equal(f.calls.filter(row => row.path.endsWith('/terminal-drayages/5')).length, 1)
assert.equal(f.calls.filter(row => row.path.endsWith('/empty-return')).length, 1)
assert.equal(f.calls.filter(row => row.method !== 'GET' && /\/master-data\/|\/fleet\/drivers|\/iam\//.test(row.path)).length, 0)
assert.ok(Object.values(f.checkpoint.state.commands).every(row => row.status === 'DONE'))
})
test('foreign customer source stops before plan conversion or receiving', async () => {
const f = fixture({ foreignSource: true })
await assert.rejects(runOverseasStage(f), /来源不属于/)
assert.equal(f.calls.filter(row => row.method !== 'GET').length, 0)
})
test('FBA receives cumulative package count without fabricated or resolved carton marks', async () => {
const f = fixture({ destinationType: 'FBA' })
f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks
const result = await runOverseasStage(f)
assert.equal(result.status, 'OVERSEAS_OUTBOUNDED')
const received = f.calls.filter(row => row.path.endsWith('/receipts/bulk'))
assert.equal(received.length, 1)
assert.equal(received[0].body.packageCount, 1)
assert.equal(f.calls.filter(row => /\/cartons\/resolve|\/receipts\/carton/.test(row.path)).length, 0)
})
test('OVERSEAS missing frozen carton marks fails before opening receiving session', async () => {
const f = fixture(); f.domestic = { ...f.domestic }; delete f.domestic.cartonMarks
await assert.rejects(runOverseasStage(f), /正式冻结箱码/)
assert.equal(f.calls.filter(row => /\/prepare\?|\/current\/sign|\/receipts\//.test(row.path)).length, 0)
})
test('signed single ticket closes its trip and releases only its existing resources', async () => {
const checkpoint = journal(), writes = []
const overseas = { tripNo: 'TRIP-TEST', dispatchNo: 'DISPATCH-TEST', deliveryNo: 'DELIVERY-TEST' }
let trip = { tripNo: overseas.tripNo, vehicleId: '3', driverId: '4', recordVersion: 1,
stops: [{ name: 'Origin' }, { name: 'Destination' }] }
const context = { request: async (method, path, body) => {
if (path.includes('/delivery-orders/')) return ok({ order: { customerId: '1', status: 'DELIVERED', dispatchNo: overseas.dispatchNo } })
if (path.endsWith('/completion')) return ok({ canComplete: true, recordVersion: 1, outcomes: [{}] })
if (method === 'POST') {
writes.push({ path, body }); trip = { ...trip, recordVersion: trip.recordVersion + 1 }
if (path.endsWith('/complete')) trip = { ...trip, status: 'COMPLETED', completedAt: '2026-10-12T12:00:00Z' }
else if (body.resourceKind === 'VEHICLE') trip.vehicleRelease = { ended: true }
else trip.driverRelease = { ended: true }
}
return ok({ trip, dispatches: [{ dispatchNo: overseas.dispatchNo }] })
} }
assert.equal((await finishSingleOrderTrip({ input, overseas, context, checkpoint })).status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED')
assert.equal(writes.length, 3)
assert.deepEqual(writes.map(row => row.body.recordVersion), [1, 2, 3])
})