fix: complete single-order acceptance and add Lingxing WMS integration
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
# Copy to .env.local for local WMS connectivity verification.
|
||||
LINGXING_WMS_APP_KEY=
|
||||
LINGXING_WMS_APP_SECRET=
|
||||
+49
@@ -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() + "]";
|
||||
}
|
||||
}
|
||||
+55
@@ -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) { }
|
||||
}
|
||||
+25
@@ -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); }
|
||||
}
|
||||
}
|
||||
+263
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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; }
|
||||
}
|
||||
+74
@@ -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}
|
||||
+100
@@ -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());
|
||||
}
|
||||
}
|
||||
+261
@@ -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)));
|
||||
}
|
||||
}
|
||||
+91
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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), /审核状态/)
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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))
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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' }] }
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
Reference in New Issue
Block a user