fix(order): enforce versioned platform commands and submissions

This commit is contained in:
2026-10-02 23:14:24 +08:00
parent 25580dab86
commit c55ecabbfb
27 changed files with 540 additions and 74 deletions
@@ -196,8 +196,9 @@ public class MerchantFirstLegOrderController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id, @Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Parameter(description = "幂等键,同一操作者的同一请求重试时必须保持不变", required = true) @Parameter(description = "幂等键,同一操作者的同一请求重试时必须保持不变", required = true)
@RequestHeader("Idempotency-Key") String idempotencyKey) { @RequestHeader("Idempotency-Key") String idempotencyKey,
service.submit(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, idempotencyKey); @Valid @RequestBody OrderRequests.SubmitRequest request) {
service.submit(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, request, idempotencyKey);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -122,9 +122,10 @@ public class PlatformFirstLegOrderController {
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
@Parameter(description = "订单 ID", required = true) @PathVariable long id, @Parameter(description = "订单 ID", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.SaveRequest request) { @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody OrderRequests.PlatformOrderUpdateRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId); long scopedTenant = OrderScopeGuard.platform(user, tenantId);
service.updateSubmittedOrder(scopedTenant, user.userId(), id, request); service.updateSubmittedOrder(scopedTenant, user.userId(), id, request, idempotencyKey);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -141,9 +142,10 @@ public class PlatformFirstLegOrderController {
@Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId, @Parameter(description = "目标租户 ID", required = true) @RequestParam long tenantId,
@Parameter(description = "订单 ID", required = true) @PathVariable long id, @Parameter(description = "订单 ID", required = true) @PathVariable long id,
@Parameter(description = "提交订单幂等键,同键同请求返回首次结果", required = true) @Parameter(description = "提交订单幂等键,同键同请求返回首次结果", required = true)
@RequestHeader("Idempotency-Key") String idempotencyKey) { @RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody OrderRequests.SubmitRequest request) {
long scopedTenant = OrderScopeGuard.platform(user, tenantId); long scopedTenant = OrderScopeGuard.platform(user, tenantId);
service.submit(scopedTenant, user.userId(), user.username(), id, idempotencyKey); service.submit(scopedTenant, user.userId(), user.username(), id, request, idempotencyKey);
return ApiResponse.success(null); return ApiResponse.success(null);
} }
@@ -277,8 +279,10 @@ public class PlatformFirstLegOrderController {
public ApiResponse<Void> cancel(@AuthenticationPrincipal AuthenticatedUser user, public ApiResponse<Void> cancel(@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId, @Parameter(description = "Target tenant ID / 目标租户 ID", example = "10001", required = true) @RequestParam long tenantId,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id, @Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.ReasonRequest request) { @RequestHeader("Idempotency-Key") String idempotencyKey,
return transit(user, tenantId, id, OrderStatus.CANCELLED, request.reason()); @Valid @RequestBody OrderRequests.CancelRequest request) {
service.cancel(OrderScopeGuard.platform(user, tenantId), user.userId(), user.username(), id, request, idempotencyKey);
return ApiResponse.success(null);
} }
@BusinessAudit(event = "ORDER_START", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Start processing / 开始处理") @BusinessAudit(event = "ORDER_START", resourceType = "FIRST_LEG_ORDER", resourceId = "#id", description = "Start processing / 开始处理")
@@ -284,6 +284,15 @@ public final class OrderRequests {
@NotNull @Valid SaveRequest draft @NotNull @Valid SaveRequest draft
) { } ) { }
@Schema(name = "PlatformOrderUpdateRequest", description = "平台订单版本化幂等编辑")
public record PlatformOrderUpdateRequest(
@NotNull @Min(0) Integer expectedVersion,
@NotNull @Valid SaveRequest draft
) { }
@Schema(name = "OrderSubmitRequest", description = "订单版本化幂等提交")
public record SubmitRequest(@NotNull @Min(0) Integer expectedVersion) { }
@Schema(name = "MerchantOrderCancelRequest", description = "客户订单版本化幂等取消") @Schema(name = "MerchantOrderCancelRequest", description = "客户订单版本化幂等取消")
public record CancelRequest( public record CancelRequest(
@NotNull @Min(0) Integer expectedVersion, @NotNull @Min(0) Integer expectedVersion,
@@ -155,8 +155,15 @@ public class OrderApplicationService {
* 因此不会因为该兼容能力意外获得已提交订单的编辑权限。 * 因此不会因为该兼容能力意外获得已提交订单的编辑权限。
*/ */
@Transactional @Transactional
public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.SaveRequest request) { public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.PlatformOrderUpdateRequest request,
updateOrderData(tenantId, userId, id, request, true, null); String idempotencyKey) {
requireExpectedVersion(request.expectedVersion());
if (request.draft() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "订单内容不能为空");
IdempotencyContext command = beginIdempotency(tenantId, userId, "UPDATE_SUBMITTED_ORDER", id, idempotencyKey,
requestHash("UPDATE_SUBMITTED_ORDER", List.of(id, request)));
if (command.replayed()) return;
updateOrderData(tenantId, userId, id, request.draft(), true, request.expectedVersion());
completeIdempotency(command, id);
} }
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request, private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
@@ -234,7 +241,10 @@ public class OrderApplicationService {
requirePrintableInboundCode(order); requirePrintableInboundCode(order);
order.setSubmittedAt(LocalDateTime.now()); order.setSubmittedAt(LocalDateTime.now());
} }
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update"); if (repository.saveOrder(tenantId, order) != 1) {
if (target == OrderStatus.SUBMITTED) throw new BizException(ResultCode.CONFLICT, "Concurrent order update");
throw new BizException("Concurrent order update");
}
record(tenantId, id, from, target, target.name(), reason, userId, username); record(tenantId, id, from, target, target.name(), reason, userId, username);
if (target == OrderStatus.SUBMITTED) prepareSubmittedInbound(order); if (target == OrderStatus.SUBMITTED) prepareSubmittedInbound(order);
if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) { if (target == OrderStatus.SUBMITTED || target == OrderStatus.APPROVED || target == OrderStatus.CANCELLED) {
@@ -245,13 +255,15 @@ public class OrderApplicationService {
/** 提交订单的幂等入口。 */ /** 提交订单的幂等入口。 */
@Transactional @Transactional
public void submit(long tenantId, long userId, String username, long id, String idempotencyKey) { public void submit(long tenantId, long userId, String username, long id, OrderRequests.SubmitRequest request,
String idempotencyKey) {
requireExpectedVersion(request.expectedVersion());
IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "SUBMIT_ORDER", id, idempotencyKey, IdempotencyContext idempotency = beginIdempotency(tenantId, userId, "SUBMIT_ORDER", id, idempotencyKey,
requestHash("SUBMIT_ORDER", Map.of("orderId", id))); requestHash("SUBMIT_ORDER", List.of(id, request)));
if (idempotency.replayed()) { if (idempotency.replayed()) {
return; return;
} }
transit(tenantId, userId, username, id, OrderStatus.SUBMITTED, null); transit(tenantId, userId, username, id, OrderStatus.SUBMITTED, null, request.expectedVersion());
completeIdempotency(idempotency, id); completeIdempotency(idempotency, id);
} }
+44 -4
View File
@@ -626,10 +626,15 @@ paths:
/api/merchant/v1/orders/first-leg/{id}/submit: /api/merchant/v1/orders/first-leg/{id}/submit:
post: post:
summary: 提交订单 summary: 提交订单
description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。 description: 必须携带读取详情时的expectedVersion;原键同载荷重放先返回首次成功,不重复提交。改版本/订单的同键请求与过期首次提交为A0409。每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
parameters: parameters:
- $ref: '#/components/parameters/OrderId' - $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/OrderSubmitRequest'}
responses: responses:
'200': {description: 成功或业务失败,按响应体 code 判断} '200': {description: 成功或业务失败,按响应体 code 判断}
/api/platform/v1/orders/first-leg: /api/platform/v1/orders/first-leg:
@@ -750,28 +755,52 @@ paths:
/api/platform/v1/orders/first-leg/{id}/submit: /api/platform/v1/orders/first-leg/{id}/submit:
post: post:
summary: 平台代客提交指定订单 summary: 平台代客提交指定订单
description: 每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。 description: 必须携带读取详情时的expectedVersion;原键同载荷重放先返回首次成功,不重复提交。改版本/订单的同键请求与过期首次提交为A0409。每条货物的每箱数量、箱数和总数量均必填,且总数量必须等于每箱数量 × 箱数。
parameters: parameters:
- $ref: '#/components/parameters/OrderId' - $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/TenantId' - $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/IdempotencyKey' - $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/OrderSubmitRequest'}
responses: responses:
'200': {description: 成功或业务失败,按响应体 code 判断} '200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:submit 权限} '403': {description: 需要 platform:order:submit 权限}
/api/platform/v1/orders/first-leg/{id}: /api/platform/v1/orders/first-leg/{id}:
put: put:
summary: 平台编辑指定订单草稿 summary: 平台版本化幂等编辑订单
description: 仅DRAFT/REJECTED/SUBMITTED可编辑;SUBMITTED保持不回退。原键同载荷先返回首次成功,不重写明细历史或重复准备入仓。缺版本/旧平铺体拒绝,首次版本冲突或同键异载荷为A0409。
parameters: parameters:
- $ref: '#/components/parameters/OrderId' - $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/TenantId' - $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody: requestBody:
required: true required: true
content: content:
application/json: application/json:
schema: {$ref: '#/components/schemas/FirstLegOrderDraft'} schema: {$ref: '#/components/schemas/PlatformOrderUpdateRequest'}
responses: responses:
'200': {description: 成功或业务失败,按响应体 code 判断} '200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 platform:order:edit 权限} '403': {description: 需要 platform:order:edit 权限}
/api/platform/v1/orders/first-leg/{id}/cancel:
post:
summary: 平台版本化幂等取消订单
description: 复用客户取消命令的版本、原因和幂等规则;原键同载荷成功重放不重复根聚合/历史/事件,同键异载荷或新命令重复取消为A0409。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/TenantId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantOrderCancelRequest'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断;无业务返回数据}
'403': {description: 需要platform:order:cancel权限和平台身份}
/internal/order/v1/fulfillment-splits: /internal/order/v1/fulfillment-splits:
post: post:
summary: 创建父订单装柜拆分(Firstleg 内部) summary: 创建父订单装柜拆分(Firstleg 内部)
@@ -1279,6 +1308,17 @@ components:
type: string type: string
enum: [TAX_INCLUDED, TAX_EXCLUDED] enum: [TAX_INCLUDED, TAX_EXCLUDED]
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义 description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
PlatformOrderUpdateRequest:
type: object
required: [expectedVersion, draft]
properties:
expectedVersion: {type: integer, minimum: 0}
draft: {$ref: '#/components/schemas/FirstLegOrderDraft'}
OrderSubmitRequest:
type: object
required: [expectedVersion]
properties:
expectedVersion: {type: integer, minimum: 0}
MerchantDraftUpdateRequest: MerchantDraftUpdateRequest:
type: object type: object
required: [expectedVersion, draft] required: [expectedVersion, draft]
@@ -40,14 +40,56 @@ class MerchantOrderCommandBindingTest {
service = mock(OrderApplicationService.class); service = mock(OrderApplicationService.class);
var controller = new MerchantFirstLegOrderController(); var controller = new MerchantFirstLegOrderController();
ReflectionTestUtils.setField(controller, "service", service); ReflectionTestUtils.setField(controller, "service", service);
mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { var platform = new PlatformFirstLegOrderController();
ReflectionTestUtils.setField(platform, "service", service);
mvc = MockMvcBuilders.standaloneSetup(controller, platform).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; } public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; }
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer model, NativeWebRequest request, WebDataBinderFactory binder) { public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer model, NativeWebRequest request, WebDataBinderFactory binder) {
if (parameter.getDeclaringClass() == PlatformFirstLegOrderController.class)
return new AuthenticatedUser(7L, 0L, 0L, 1L, "operator", "PLATFORM", "WEB", Set.of());
return new AuthenticatedUser(7L, 11L, 11L, 1L, "operator", "MERCHANT", "WEB", Set.of()); return new AuthenticatedUser(7L, 11L, 11L, 1L, "operator", "MERCHANT", "WEB", Set.of());
} }
}).build(); }).build();
} }
@Test
void platformEditRequiresVersionEnvelopeAndKey() throws Exception {
String path = "/api/platform/v1/orders/first-leg/1?tenantId=11";
String body = "{\"expectedVersion\":2,\"draft\":" + DRAFT + "}";
mvc.perform(put(path).contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest());
for (String invalid : new String[] {DRAFT, "{\"draft\":" + DRAFT + "}", "{\"expectedVersion\":2}"})
mvc.perform(put(path).header("Idempotency-Key", "platform-edit").contentType(MediaType.APPLICATION_JSON).content(invalid)).andExpect(status().isBadRequest());
verifyNoInteractions(service);
mvc.perform(put(path).header("Idempotency-Key", "platform-edit").contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isOk());
verify(service).updateSubmittedOrder(eq(11L), eq(7L), eq(1L), argThat(r -> r.expectedVersion() == 2 && "Offline".equals(r.draft().customerName())), eq("platform-edit"));
}
@Test
void bothSubmitRoutesRequireVersionAndKeepAuthenticatedScope() throws Exception {
for (String path : new String[] {PATH + "/submit", "/api/platform/v1/orders/first-leg/1/submit?tenantId=11"}) {
mvc.perform(post(path).contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":2}")).andExpect(status().isBadRequest());
for (String invalid : new String[] {"{}", "{\"expectedVersion\":null}", "{\"expectedVersion\":-1}"})
mvc.perform(post(path).header("Idempotency-Key", "submit").contentType(MediaType.APPLICATION_JSON).content(invalid)).andExpect(status().isBadRequest());
mvc.perform(post(path).header("Idempotency-Key", "submit")).andExpect(status().isBadRequest());
}
verifyNoInteractions(service);
for (String path : new String[] {PATH + "/submit", "/api/platform/v1/orders/first-leg/1/submit?tenantId=11"})
mvc.perform(post(path).header("Idempotency-Key", "submit").contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":2}")).andExpect(status().isOk());
verify(service, times(2)).submit(11L, 7L, "operator", 1L, new OrderRequests.SubmitRequest(2), "submit");
}
@Test
void platformCancelUsesVersionedCommandWhileRejectKeepsReasonOnly() throws Exception {
String path = "/api/platform/v1/orders/first-leg/1/cancel?tenantId=11";
mvc.perform(post(path).header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content("{\"reason\":\"old\"}")).andExpect(status().isBadRequest());
mvc.perform(post(path).contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":0,\"reason\":\"reason\"}")).andExpect(status().isBadRequest());
verifyNoInteractions(service);
mvc.perform(post(path).header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content("{\"expectedVersion\":0,\"reason\":\"reason\"}")).andExpect(status().isOk());
verify(service).cancel(11L, 7L, "operator", 1L, new OrderRequests.CancelRequest(0, "reason"), "cancel");
mvc.perform(post("/api/platform/v1/orders/first-leg/1/reject?tenantId=11").contentType(MediaType.APPLICATION_JSON).content("{\"reason\":\"reason\"}")).andExpect(status().isOk());
verify(service).transit(11L, 7L, "operator", 1L, com.baiyue.order.entity.OrderStatus.REJECTED, "reason");
}
@Test @Test
void editRequiresHeaderAndVersionedDraftEnvelope() throws Exception { void editRequiresHeaderAndVersionedDraftEnvelope() throws Exception {
String body = "{\"expectedVersion\":0,\"draft\":" + DRAFT + "}"; String body = "{\"expectedVersion\":0,\"draft\":" + DRAFT + "}";
@@ -43,9 +43,9 @@ class OrderControllerPermissionTest {
assertAuthority(PlatformFirstLegOrderController.class, "create", "hasAuthority('platform:order:create')", assertAuthority(PlatformFirstLegOrderController.class, "create", "hasAuthority('platform:order:create')",
AuthenticatedUser.class, long.class, String.class, OrderRequests.SaveRequest.class); AuthenticatedUser.class, long.class, String.class, OrderRequests.SaveRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "updateDraft", "hasAuthority('platform:order:edit')", assertAuthority(PlatformFirstLegOrderController.class, "updateDraft", "hasAuthority('platform:order:edit')",
AuthenticatedUser.class, long.class, long.class, OrderRequests.SaveRequest.class); AuthenticatedUser.class, long.class, long.class, String.class, OrderRequests.PlatformOrderUpdateRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')", assertAuthority(PlatformFirstLegOrderController.class, "submit", "hasAuthority('platform:order:submit')",
AuthenticatedUser.class, long.class, long.class, String.class); AuthenticatedUser.class, long.class, long.class, String.class, OrderRequests.SubmitRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "batchApprove", "hasAuthority('platform:order:approve')", assertAuthority(PlatformFirstLegOrderController.class, "batchApprove", "hasAuthority('platform:order:approve')",
AuthenticatedUser.class, long.class, OrderRequests.BatchReviewRequest.class); AuthenticatedUser.class, long.class, OrderRequests.BatchReviewRequest.class);
assertAuthority(PlatformFirstLegOrderController.class, "batchReject", "hasAuthority('platform:order:reject')", assertAuthority(PlatformFirstLegOrderController.class, "batchReject", "hasAuthority('platform:order:reject')",
@@ -42,6 +42,7 @@ class MerchantOrderCommandTransactionTest {
private OrderApplicationService service; private OrderApplicationService service;
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private OrderRequests.SaveRequest draft; private OrderRequests.SaveRequest draft;
private com.baiyue.order.client.FirstlegInboundClient firstleg;
@BeforeEach @BeforeEach
void prepare() throws Exception { void prepare() throws Exception {
@@ -77,6 +78,11 @@ class MerchantOrderCommandTransactionTest {
ReflectionTestUtils.setField(target, "repository", repository); ReflectionTestUtils.setField(target, "repository", repository);
ReflectionTestUtils.setField(target, "fulfillmentCompletion", proxy(completion, manager)); ReflectionTestUtils.setField(target, "fulfillmentCompletion", proxy(completion, manager));
ReflectionTestUtils.setField(target, "json", json); ReflectionTestUtils.setField(target, "json", json);
ReflectionTestUtils.setField(target, "eligibilityGuard", mock(OrderEligibilityGuard.class));
firstleg = mock(com.baiyue.order.client.FirstlegInboundClient.class);
ReflectionTestUtils.setField(target, "firstlegProvider", new StaticListableBeanFactory(Map.of("firstleg", firstleg))
.getBeanProvider(com.baiyue.order.client.FirstlegInboundClient.class));
ReflectionTestUtils.setField(target, "internalToken", "offline-test-token");
ReflectionTestUtils.setField(target, "productImageGuard", mock(ProductImageGuard.class)); ReflectionTestUtils.setField(target, "productImageGuard", mock(ProductImageGuard.class));
ReflectionTestUtils.setField(target, "inboundCodeModeProvider", mock(InboundCodeModeProvider.class)); ReflectionTestUtils.setField(target, "inboundCodeModeProvider", mock(InboundCodeModeProvider.class));
ReflectionTestUtils.setField(target, "customerAddressServiceProvider", new StaticListableBeanFactory().getBeanProvider(CustomerAddressService.class)); ReflectionTestUtils.setField(target, "customerAddressServiceProvider", new StaticListableBeanFactory().getBeanProvider(CustomerAddressService.class));
@@ -90,7 +96,7 @@ class MerchantOrderCommandTransactionTest {
service = proxy(target, manager); service = proxy(target, manager);
draft = json.readValue(""" draft = json.readValue("""
{"customerName":"Offline","transportMode":"SEA","destinationCountryCode":"US", {"customerName":"Offline","transportMode":"SEA","destinationCountryCode":"US",
"cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1}], "cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1,"unitsPerPackage":1,"totalQuantity":1,"grossWeightKg":1,"volumeCbm":1,"woodenProduct":false}],
"pickupMethod":"SELF_EXPRESS","receivingWarehouseId":8,"receivingWarehouseName":"Offline", "pickupMethod":"SELF_EXPRESS","receivingWarehouseId":8,"receivingWarehouseName":"Offline",
"channelCode":"TEST","channelName":"Offline","taxMode":"BUY_DECLARATION", "channelCode":"TEST","channelName":"Offline","taxMode":"BUY_DECLARATION",
"pricingTaxMode":"TAX_INCLUDED","destinationType":"OVERSEAS"} "pricingTaxMode":"TAX_INCLUDED","destinationType":"OVERSEAS"}
@@ -100,6 +106,116 @@ class MerchantOrderCommandTransactionTest {
db.update("INSERT INTO ord_order_party(id,tenant_id,order_id) VALUES(102,11,1)"); db.update("INSERT INTO ord_order_party(id,tenant_id,order_id) VALUES(102,11,1)");
} }
@Test
void platformSubmittedEditKeepsStateAndOriginalReplayDoesNotPrepareInboundAgain() {
db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1");
platformEdit(11, 1, 0, "platform");
assertThat(status(1)).isEqualTo("SUBMITTED");
assertThat(version(1)).isEqualTo(1);
verify(firstleg, times(1)).prepare(anyString(), any());
var before = snapshot();
platformEdit(11, 1, 0, "platform");
assertThat(snapshot()).isEqualTo(before);
verify(firstleg, times(1)).prepare(anyString(), any());
assertThatThrownBy(() -> platformEdit(11, 1, 1, "platform")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> platformEdit(11, 2, 0, "platform")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> platformEdit(11, 1, 0, "stale")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> platformEdit(12, 1, 1, "cross")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void platformEditRollbackDoesNotPublishAfterCommitPreparation() {
db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1");
db.execute("ALTER TABLE ord_idempotency_record ADD CONSTRAINT platform_fail CHECK(status<>'COMPLETED')");
var before = snapshot();
assertThatThrownBy(() -> platformEdit(11, 1, 0, "platform")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
verifyNoInteractions(firstleg);
db.execute("ALTER TABLE ord_idempotency_record DROP CONSTRAINT platform_fail");
platformEdit(11, 1, 0, "platform");
verify(firstleg).prepare(anyString(), any());
}
@Test
void platformEditDoesNotExpandToApprovedAndHasDistinctOperationScope() {
edit(11, 1, 0, "same-key");
platformEdit(11, 1, 1, "same-key");
assertThat(count("ord_idempotency_record")).isEqualTo(2);
db.update("UPDATE ord_order SET status='APPROVED' WHERE id=1");
var before = snapshot();
assertThatThrownBy(() -> platformEdit(11, 1, 2, "not-editable")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void platformConcurrentEditsKeepOneVersionAndOnePreparation() throws Exception {
db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1");
assertThat(concurrent(() -> platformEdit(11, 1, 0, "same"), () -> platformEdit(11, 1, 0, "same"))).containsExactly(true, true);
assertThat(concurrent(() -> platformEdit(11, 1, 1, "a"), () -> platformEdit(11, 1, 1, "b"))).containsExactlyInAnyOrder(true, false);
assertThat(version(1)).isEqualTo(2);
assertThat(count("ord_order_status_history")).isEqualTo(2);
verify(firstleg, times(2)).prepare(anyString(), any());
}
@Test
void submitOriginalReplayKeepsInboundCodeHistoryOutboxAndPreparation() {
edit(11, 1, 0, "ready");
submit(11, 1, 1, "submit");
assertThat(status(1)).isEqualTo("SUBMITTED");
assertThat(version(1)).isEqualTo(2);
assertThat(count("ord_outbox_event")).isEqualTo(1);
var before = snapshot();
submit(11, 1, 1, "submit");
assertThatThrownBy(() -> submit(11, 1, 2, "submit")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> submit(11, 2, 1, "submit")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> submit(11, 1, 2, "new-command")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
verify(firstleg, times(1)).prepare(anyString(), any());
}
@Test
void submitStaleVersionWrongCustomerAndMissingVersionDoNotWrite() {
edit(11, 1, 0, "ready");
var before = snapshot();
assertThatThrownBy(() -> submit(11, 1, 0, "stale")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> submit(12, 1, 1, "cross")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.submit(11, 7, "operator", 1, new OrderRequests.SubmitRequest(null), "missing")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
verifyNoInteractions(firstleg);
}
@Test
void submitOutboxFailureRollsBackStateHistoryCommandAndPreparation() {
edit(11, 1, 0, "ready");
db.execute("ALTER TABLE ord_outbox_event ADD CONSTRAINT submit_fail CHECK(event_type<>'FIRST_LEG_ORDER_PREPARED')");
var before = snapshot();
assertThatThrownBy(() -> submit(11, 1, 1, "submit")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
verifyNoInteractions(firstleg);
db.execute("ALTER TABLE ord_outbox_event DROP CONSTRAINT submit_fail");
submit(11, 1, 1, "submit");
verify(firstleg).prepare(anyString(), any());
}
@Test
void concurrentSubmitCommandsCannotDuplicateTransition() throws Exception {
edit(11, 1, 0, "ready");
assertThat(concurrent(() -> submit(11, 1, 1, "same"), () -> submit(11, 1, 1, "same"))).containsExactly(true, true);
order(2, 11, "DRAFT", "MASTER", null);
edit(11, 2, 0, "ready-two");
assertThat(concurrent(() -> submit(11, 2, 1, "a"), () -> submit(11, 2, 1, "b"))).containsExactlyInAnyOrder(true, false);
assertThat(count("ord_outbox_event")).isEqualTo(2);
verify(firstleg, times(2)).prepare(anyString(), any());
}
private void platformEdit(long customer, long id, int version, String key) {
service.updateSubmittedOrder(customer, 7, id, new OrderRequests.PlatformOrderUpdateRequest(version, draft), key);
}
private void submit(long customer, long id, int version, String key) {
service.submit(customer, 7, "operator", id, new OrderRequests.SubmitRequest(version), key);
}
@Test @Test
void editReplaysOriginalCommandBeforeCurrentVersionAndRejectsAnyChangedIdentity() { void editReplaysOriginalCommandBeforeCurrentVersionAndRejectsAnyChangedIdentity() {
edit(11, 1, 0, "edit"); edit(11, 1, 0, "edit");
@@ -271,7 +387,10 @@ class MerchantOrderCommandTransactionTest {
List<Future<Boolean>> results = new ArrayList<>(); List<Future<Boolean>> results = new ArrayList<>();
for (Runnable work : List.of(first, second)) results.add(workers.submit(() -> { for (Runnable work : List.of(first, second)) results.add(workers.submit(() -> {
start.await(); start.await();
try { work.run(); return true; } catch (BizException conflict) { return false; } try { work.run(); return true; } catch (BizException conflict) {
assertThat(conflict.getResultCode()).isEqualTo(com.baiyue.common.core.api.ResultCode.CONFLICT);
return false;
}
})); }));
start.countDown(); start.countDown();
return List.of(results.get(0).get(15, TimeUnit.SECONDS), results.get(1).get(15, TimeUnit.SECONDS)); return List.of(results.get(0).get(15, TimeUnit.SECONDS), results.get(1).get(15, TimeUnit.SECONDS));
@@ -277,6 +277,7 @@ class OrderApplicationServiceStateTest {
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request(); OrderRequests.SaveRequest request = request();
OrderEntity order = new OrderEntity(); OrderEntity order = new OrderEntity();
order.setVersion(0);
order.setId(1L); order.setId(1L);
order.setTenantId(10001L); order.setTenantId(10001L);
order.setStatus(OrderStatus.SUBMITTED); order.setStatus(OrderStatus.SUBMITTED);
@@ -285,7 +286,7 @@ class OrderApplicationServiceStateTest {
when(repository.saveOrder(10001L, order)).thenReturn(1); when(repository.saveOrder(10001L, order)).thenReturn(1);
service(repository, mock(OrderEligibilityGuard.class), masterData) service(repository, mock(OrderEligibilityGuard.class), masterData)
.updateSubmittedOrder(10001L, 2L, 1L, request); .updateSubmittedOrder(10001L, 2L, 1L, new OrderRequests.PlatformOrderUpdateRequest(0, request), "platform-edit");
assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED); assertThat(order.getStatus()).isEqualTo(OrderStatus.SUBMITTED);
verify(repository).replaceDraftChildren(10001L, 1L); verify(repository).replaceDraftChildren(10001L, 1L);
@@ -389,7 +390,7 @@ class OrderApplicationServiceStateTest {
when(repository.findCargo(20002L, 1L)).thenReturn(List.of(valid, missing)); when(repository.findCargo(20002L, 1L)).thenReturn(List.of(valid, missing));
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.submit(20002L, 2L, "u", 1L, "missing-sku")) .submit(20002L, 2L, "u", 1L, new OrderRequests.SubmitRequest(0), "missing-sku"))
.isInstanceOf(BizException.class).hasMessage("第 2 条一件代发货物必须填写 SKU NO.") .isInstanceOf(BizException.class).hasMessage("第 2 条一件代发货物必须填写 SKU NO.")
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT); assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
@@ -434,7 +435,7 @@ class OrderApplicationServiceStateTest {
source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(), source.customsInspection(), source.destinationType(), source.amazonWarehouseCode(), source.shipmentId(),
source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP"); source.referenceId(), source.inboundCode(), "ONE_CLICK_DROPSHIP");
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.updateSubmittedOrder(10001L, 2L, 1L, missingSku)) .updateSubmittedOrder(10001L, 2L, 1L, new OrderRequests.PlatformOrderUpdateRequest(0, missingSku), "platform-edit"))
.isInstanceOf(BizException.class).hasMessage("第 1 条一件代发货物必须填写 SKU NO.") .isInstanceOf(BizException.class).hasMessage("第 1 条一件代发货物必须填写 SKU NO.")
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
verify(repository, never()).saveOrder(anyLong(), any()); verify(repository, never()).saveOrder(anyLong(), any());
@@ -463,7 +464,7 @@ class OrderApplicationServiceStateTest {
when(repository.findCargo(20002L, 1L)).thenReturn(List.of( when(repository.findCargo(20002L, 1L)).thenReturn(List.of(
mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third)); mixedCargo("SKU-1", 10), mixedCargo("SKU-2", 11), third));
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.submit(20002L, 2L, "u", 1L, "mixed-carton-conflict")) .submit(20002L, 2L, "u", 1L, new OrderRequests.SubmitRequest(0), "mixed-carton-conflict"))
.isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致") .isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致")
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT); assertThat(order.getStatus()).isEqualTo(OrderStatus.DRAFT);
@@ -501,7 +502,7 @@ class OrderApplicationServiceStateTest {
when(repository.findOwned(10001L, 1L)).thenReturn(order); when(repository.findOwned(10001L, 1L)).thenReturn(order);
var request = mixedCartonRequest(31); var request = mixedCartonRequest(31);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class)) assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.updateSubmittedOrder(10001L, 2L, 1L, request)) .updateSubmittedOrder(10001L, 2L, 1L, new OrderRequests.PlatformOrderUpdateRequest(0, request), "platform-edit"))
.isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致") .isInstanceOf(BizException.class).hasMessage("同箱 SKU 的箱数、包装类型、箱规、重量和体积必须一致")
.extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED); .extracting(exception -> ((BizException) exception).getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
verify(repository, never()).saveOrder(anyLong(), any()); verify(repository, never()).saveOrder(anyLong(), any());
@@ -717,6 +718,7 @@ class OrderApplicationServiceStateTest {
OrderRepository repository = mock(OrderRepository.class); OrderRepository repository = mock(OrderRepository.class);
OrderEligibilityGuard eligibility = mock(OrderEligibilityGuard.class); OrderEligibilityGuard eligibility = mock(OrderEligibilityGuard.class);
OrderEntity order = new OrderEntity(); OrderEntity order = new OrderEntity();
order.setVersion(0);
order.setId(1L); order.setId(1L);
order.setTenantId(10001L); order.setTenantId(10001L);
order.setStatus(OrderStatus.DRAFT); order.setStatus(OrderStatus.DRAFT);
@@ -725,7 +727,7 @@ class OrderApplicationServiceStateTest {
.when(eligibility).requireApproved(10001L); .when(eligibility).requireApproved(10001L);
assertThatThrownBy(() -> service(repository, eligibility, mock(OrderMasterDataGuard.class)) assertThatThrownBy(() -> service(repository, eligibility, mock(OrderMasterDataGuard.class))
.submit(10001L, 2L, "u", 1L, "submit-before-approval")) .submit(10001L, 2L, "u", 1L, new OrderRequests.SubmitRequest(0), "submit-before-approval"))
.isInstanceOf(BizException.class) .isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode()) .extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN); .isEqualTo(ResultCode.FORBIDDEN);
@@ -1068,6 +1070,7 @@ class OrderApplicationServiceStateTest {
private OrderEntity persistedDraft(String pickupMethod, String destinationType) { private OrderEntity persistedDraft(String pickupMethod, String destinationType) {
OrderEntity order = new OrderEntity(); OrderEntity order = new OrderEntity();
order.setVersion(0);
order.setId(1L); order.setId(1L);
order.setTenantId(20002L); order.setTenantId(20002L);
order.setStatus(OrderStatus.DRAFT); order.setStatus(OrderStatus.DRAFT);
@@ -121,7 +121,7 @@ class OrderIdempotencyServiceTest {
when(repository.findIdempotency(10001L, 20001L, "SUBMIT_ORDER", "submit-key")) when(repository.findIdempotency(10001L, 20001L, "SUBMIT_ORDER", "submit-key"))
.thenReturn(existing); .thenReturn(existing);
service(repository).submit(10001L, 20001L, "operator", 998L, "submit-key"); service(repository).submit(10001L, 20001L, "operator", 998L, new OrderRequests.SubmitRequest(0), "submit-key");
verify(repository, never()).findOwned(anyLong(), anyLong()); verify(repository, never()).findOwned(anyLong(), anyLong());
verify(repository, never()).saveOrder(anyLong(), any()); verify(repository, never()).saveOrder(anyLong(), any());
+1
View File
@@ -1,5 +1,6 @@
# 后端脚本管理与全量 HTTP 验收索引 # 后端脚本管理与全量 HTTP 验收索引
头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。生产单票的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。
`event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id` 或 `--container-booking-id`;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。 `event-delivery-fault-proxy.py` 是 TEST 环境定向事件 ACK 丢失工具,不创建或重放事件。运行者显式传入 `--upstream` 完整消费者地址、`--event-type`,并只选 `--event-id`、`--loading-plan-id` 或 `--container-booking-id`;柜次选择精确匹配 payload.sourceContainerBookingId,用于海外卸柜回写,不接受混合选择条件。`EVENT_FAULT_CONTROL_TOKEN` 从外部环境注入,保护固定本机监听的控制端口。默认 PASS;经鉴权切为 FAULT 后,仅在选定事件已真实转发且收到匹配的 APPLIED/DUPLICATE ACK 时向发送方返回 503,其他事件直通。同一选择范围内锁定首个事件后不改选其他事件 ID,原 ID 改摘要或载荷直接拒绝。日志只保留事件标识、摘要和结果,不保留正文或凭据。它会影响实际投递,仅限明确授权的测试环境;生产者配置、重试、DEAD 原事件人工恢复及最终恢复直连由运行者负责,工具退出不自动恢复服务配置。`--log` 可显式指定脱敏证据文件,失败保留原事件与日志,禁止改摘要或借其他事件重跑。`event-delivery-fault-proxy.test.mjs` 使用本机替身服务验证行为,不访问共享环境;工具离线通过不能替代真实重试验收。
`admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。 `admin-test-env-acceptance.mjs` 与 `client-test-env-acceptance.mjs` 仅接受统一 TEST Gateway 和 Auth 会话;移除环境文件中的 `ADMIN_OVERSEAS_GATEWAY`,不再传 `--overseas-gateway` 或非 test 环境。平台、客户、PDA 身份仍分别登录并验证权限及客户隔离。海外资源接口使用 `/api/platform/v1/overseas-warehouse-resources/**`,仓库主档仍归 Warehouse。跨服务模块的重复 GET 不证明事件重放;真实重试/幂等证据缺失会报告部署阻断,详见模块 README 和下方事件人工重试入口。离线合同不会执行真实 HTTP;环境执行仍需本轮写入授权。
+2 -1
View File
@@ -1,5 +1,6 @@
# Admin 测试环境 HTTP 模块 # Admin 测试环境 HTTP 模块
头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。
`phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。 `phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。
总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。
@@ -8,7 +9,7 @@
`phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。 `phase5-root-fulfillment.mjs` 是显式 TEST 的 F40 两箱根/余量专项,通过总入口 `--modules phase5-root-fulfillment` 准备本轮资质客户;模块用正式下单、测量、国内 PDA 拆分装柜、船节点、海外 PDA 收货、拣货发车及文件签收链创建两柜真实来源。默认根先签,编程调用 `AcceptanceRun.context.f40SignatureOrder='CHILD_FIRST'` 切换子先签;分别核对第一片签收不得根完成、最后一片签收才根完成、真实签收时间、状态历史和重放零新增。
`AcceptanceRun.context.f40SignatureOrder='ROOT_SIGNED_REMAINDER_CANCELLED'` 单独验证合法末票余量取消:本轮新根通过 `orderStopAtApproved=true` 停在原始 `APPROVED`,只装运签收首箱;回查真实履约树及未装子单原始 `APPROVED`、入仓 `WAIT_SPLIT` 和一箱余量后,调用公开取消接口。`PROCESSING` 子单不得作为取消样本。根完成事件必须唯一发布,取消子单完成事件显式为零;原取消请求重放须 `A0409`,且根子版本、历史、摘要、履约树及完成 Outbox 零漂移。两种双签模式仍须两片实际签收,不能用取消替代第二签。 `AcceptanceRun.context.f40SignatureOrder='ROOT_SIGNED_REMAINDER_CANCELLED'` 单独验证合法末票余量取消:本轮新根通过 `orderStopAtApproved=true` 停在原始 `APPROVED`,只装运签收首箱;回查真实履约树及未装子单原始 `APPROVED`、入仓 `WAIT_SPLIT` 和一箱余量后,调用公开取消接口。`PROCESSING` 子单不得作为取消样本。根完成事件必须唯一发布,取消子单完成事件显式为零;原键原版本原载荷取消重放成功;同键异载荷及新键重复取消返回 `A0409`,根子版本、历史、摘要、履约树及完成 Outbox 均不漂移。两种双签模式仍须两片实际签收,不能用取消替代第二签。
会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。 会写已授权测试环境,保留新客户、单据、文件及报告,临时海外 PDA 在 finally 停用;失败保留样本,不自动删除或重复执行部分已完成链。签收资料使用新 key 保存同文件时,按正式保存命令递增一次资料版本、新增一条版本历史及 `SAVE_DOCUMENTS` 审计;签收时间、状态、原完成记录、文件绑定和根子完成事件不得变化。同一新命令再用原 key、原请求重放,必须明确 `replayed=true` 且完整资料、派送事实、版本和审计历史零漂移。资料保存前后及原 key 重放响应保存在 `phase5RootFulfillment.receiptReplayProofs`,失败也保留已取得的快照。
@@ -4,6 +4,20 @@ import fs from 'node:fs'
import path from 'node:path' import path from 'node:path'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { transition } from './order-inbound-status-fixtures.mjs'
test('dynamic Platform cancellation freezes version and key while reject body stays unchanged',async()=>{
const calls=[]
const context={marker:'fixture',token:'admin',apiSuccess(_m,_n,r){assert.equal(r.body.code,'200');return r.body.data},
async request(method,path,body,token,headers){calls.push({method,path,body,token,headers});return {http:200,body:{code:'200',data:{order:{version:9}}}}}}
await transition(context,'merchant',{tenantId:'2'},'1','CANCELLED','cancel')
const command=calls.find(row=>row.method==='POST')
assert.equal(command.token,'admin');assert.equal(command.body.expectedVersion,9);assert.ok(command.headers['Idempotency-Key'])
calls.length=0
await transition(context,'merchant',{tenantId:'2'},'1','REJECTED','reject')
assert.equal(calls.length,1);assert.deepEqual(calls[0].body,{reason:'fixture 保留 REJECTED 人工样本'})
})
const here = path.dirname(fileURLToPath(import.meta.url)) const here = path.dirname(fileURLToPath(import.meta.url))
const source = fs.readFileSync(path.join(here, 'order-inbound-status-fixtures.mjs'), 'utf8') const source = fs.readFileSync(path.join(here, 'order-inbound-status-fixtures.mjs'), 'utf8')
@@ -1,4 +1,4 @@
import { findPricedOrderRouteAcrossOrigins } from './orders.mjs' import { findPricedOrderRouteAcrossOrigins, freezeOrderCommand } from './orders.mjs'
const MODULE = 'order-inbound-status-fixtures' const MODULE = 'order-inbound-status-fixtures'
const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true } const OPTIONS = { scenario: 'manual-order-inbound-status-matrix', deploymentBlocker: true }
@@ -117,17 +117,21 @@ async function createOrder(context, token, fixture, target) {
return orderId return orderId
} }
async function transition(context, token, fixture, orderId, target, action) { export async function transition(context, token, fixture, orderId, target, action) {
const merchantPath = `/api/merchant/v1/orders/first-leg/${query(orderId)}` const merchantPath = `/api/merchant/v1/orders/first-leg/${query(orderId)}`
if (action === 'submit') { if (action === 'submit') {
const response = await merchantRequest(context, 'POST', `${merchantPath}/submit`, undefined, token, const detail = context.apiSuccess(MODULE, `${target} 冻结提交版本`, await merchantRequest(context, 'GET', merchantPath, undefined, token))
{ 'Idempotency-Key': key(context, `${target}-submit`) }) const command = freezeOrderCommand(detail, key(context, `${target}-submit`))
const response = await merchantRequest(context, 'POST', `${merchantPath}/submit`, command.body, token, command.headers)
context.apiSuccess(MODULE, `${target} 样本提交订单`, response) context.apiSuccess(MODULE, `${target} 样本提交订单`, response)
return return
} }
const platformPath = `/api/platform/v1/orders/first-leg/${query(orderId)}/${action}?tenantId=${query(fixture.tenantId)}` const platformPath = `/api/platform/v1/orders/first-leg/${query(orderId)}/${action}?tenantId=${query(fixture.tenantId)}`
const body = action === 'reject' || action === 'cancel' ? { reason: `${context.marker} 保留 ${target} 人工样本` } : undefined const body = action === 'reject' || action === 'cancel' ? { reason: `${context.marker} 保留 ${target} 人工样本` } : undefined
const response = await context.request('POST', platformPath, body) const command = action === 'cancel' ? freezeOrderCommand(context.apiSuccess(MODULE, `${target} 冻结取消版本`,
await context.request('GET', `/api/platform/v1/orders/first-leg/${query(orderId)}?tenantId=${query(fixture.tenantId)}`)),
key(context, `${target}-cancel`), body) : null
const response = await context.request('POST', platformPath, command?.body ?? body, context.token, command?.headers ?? {})
context.apiSuccess(MODULE, `${target} 样本执行 ${action}`, response) context.apiSuccess(MODULE, `${target} 样本执行 ${action}`, response)
} }
@@ -2,7 +2,36 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType } from './orders.mjs' import { fixtureInboundCode, orderPersistenceFacts, requestedOrderDestinationType, freezeOrderCommand, verifyPlatformDraftEdit } from './orders.mjs'
test('order command freezes original detail version/key and never accepts an absent version',()=>{
const detail={order:{version:3}},draft={customerRemark:'original'}
const command=freezeOrderCommand(detail,'key',{draft})
detail.order.version=8;draft.customerRemark='changed'
assert.deepEqual(command,{body:{draft:{customerRemark:'original'},expectedVersion:3},headers:{'Idempotency-Key':'key'}})
assert.ok(Object.isFrozen(command.body.draft))
for(const version of [undefined,null,'3',-1])assert.throws(()=>freezeOrderCommand({order:{version}},'key'))
})
test('Platform edit uses exact customer and admin identity; replay/stale/foreign writes preserve full facts',async()=>{
let detail={order:{id:'10',tenantId:'20',status:'DRAFT',version:3,customerRemark:'marker'},cargoes:[],parties:[],internalNotes:[],statusHistory:[]}
let saved
const writes=[]
const context={marker:'marker',token:'admin',createdResources:{orders:{}},apiSuccess(_m,_n,r){assert.equal(r.body.code,'200');return r.body.data},
assert(_m,name,_r,ok){assert.ok(ok,name)},async request(method,path,body,token,headers){
assert.equal(path,'/api/platform/v1/orders/first-leg/10?tenantId=20')
if(method==='GET')return {http:200,body:{code:'200',data:structuredClone(detail)}}
writes.push({body,token,headers})
let code='200'
if(token!=='admin')code='A0403'
else if(saved)code=saved.key===headers['Idempotency-Key']&&saved.payload===JSON.stringify(body)?'200':'A0409'
else {assert.equal(body.expectedVersion,3);saved={key:headers['Idempotency-Key'],payload:JSON.stringify(body)};detail.order.version++;detail.order.customerRemark=body.draft.customerRemark;detail.statusHistory.push({action:'UPDATE'})}
return {http:200,body:{code}}
}}
const result=await verifyPlatformDraftEdit(context,'20','10','merchant',{customerRemark:'marker'})
assert.equal(result.order.version,4);assert.equal(writes.length,5)
assert.deepEqual(writes[1],writes[2]);assert.equal(writes[0].token,'merchant')
})
const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('orders.mjs', import.meta.url), 'utf8')
+58 -9
View File
@@ -34,6 +34,47 @@ function rotate(values, offset) {
return [...values.slice(shift), ...values.slice(0, shift)] return [...values.slice(shift), ...values.slice(0, shift)]
} }
function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) } function key(context, operation) { return `${context.marker}-${operation}`.slice(0, 128) }
export function freezeOrderCommand(detail, idempotencyKey, payload = {}) {
const version = detail?.order?.version
if (!Number.isSafeInteger(version) || version < 0 || !idempotencyKey) throw new Error('订单首次详情版本或幂等键缺失')
const command = structuredClone({ body: { ...payload, expectedVersion: version }, headers: { 'Idempotency-Key': idempotencyKey } })
const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) } }
freeze(command)
return command
}
export async function verifyPlatformDraftEdit(context, tenantId, orderId, merchantToken, payload) {
const path = `/api/platform/v1/orders/first-leg/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}`
const read = async label => context.apiSuccess(MODULE, label, await context.request('GET', path))
const before = await read('Admin 编辑前冻结客户订单版本')
if (id(before?.order?.tenantId) !== id(tenantId) || id(before?.order?.id) !== id(orderId) || before.order.status !== 'DRAFT') throw new Error('平台编辑客户/草稿身份不符')
const editedPayload = structuredClone(payload)
editedPayload.customerRemark = `${payload.customerRemark} 平台编辑验收`
const command = freezeOrderCommand(before, key(context, 'platform-draft-edit'), { draft: editedPayload })
context.createdResources.orders.platformEditCommand = command
const send = (body, headers, token = context.token) => context.request('PUT', path, body, token, headers)
const unauthorized = await send(command.body, command.headers, merchantToken)
context.assert(MODULE, 'Client不得调用Platform编辑接口', unauthorized,
[200,403].includes(unauthorized.http) && String(unauthorized.body?.code) === 'A0403', 'merchant-token/platform-route', CONTRACT_OPTIONS)
const afterDenied = await read('平台编辑越权拒绝后回查')
context.assert(MODULE, '越权平台编辑后完整事实不变', null, orderPersistenceFacts(before) === orderPersistenceFacts(afterDenied), 'no-drift', CONTRACT_OPTIONS)
context.apiSuccess(MODULE, 'Admin按客户范围版本化编辑草稿', await send(command.body, command.headers))
const edited = await read('平台编辑后权威详情')
context.assert(MODULE, '平台编辑保持身份且版本与UPDATE历史递增', null,
id(edited?.order?.id) === id(orderId) && id(edited.order.tenantId) === id(tenantId) && edited.order.status === 'DRAFT'
&& edited.order.version > before.order.version && edited.order.customerRemark === editedPayload.customerRemark
&& edited.statusHistory.length === before.statusHistory.length + 1, 'versioned-edit', CONTRACT_OPTIONS)
context.apiSuccess(MODULE, 'Platform编辑原键原版本重放成功', await send(command.body, command.headers))
for (const [label, body, headers] of [
['Platform编辑原键异载荷冲突', {...command.body, draft:{...editedPayload,customerRemark:`${editedPayload.customerRemark}-conflict`}},command.headers],
['Platform编辑旧页面版本冲突', command.body, {'Idempotency-Key':key(context,'platform-edit-stale')}],
]) {
const rejected = await send(body, headers)
context.assert(MODULE, label, rejected, rejected.http === 200 && String(rejected.body?.code) === 'A0409', 'exact-conflict', CONTRACT_OPTIONS)
}
const stable = await read('Platform重放及冲突后完整事实')
context.assert(MODULE, 'Platform重放与拒绝不重复写入', null, orderPersistenceFacts(stable) === orderPersistenceFacts(edited), 'order/cargo/parties/history', CONTRACT_OPTIONS)
return stable
}
export function fixtureInboundCode(policy, marker) { export function fixtureInboundCode(policy, marker) {
if (policy?.mode === 'AUTO' && policy.manualEntryRequired === false) return {} if (policy?.mode === 'AUTO' && policy.manualEntryRequired === false) return {}
if (policy?.mode !== 'LEGACY_MANUAL' || policy.manualEntryRequired !== true || !marker) { if (policy?.mode !== 'LEGACY_MANUAL' || policy.manualEntryRequired !== true || !marker) {
@@ -82,8 +123,8 @@ async function verifyCargoRequirementNegatives(context, payload, policy, merchan
const orderId = id(created.body?.data) const orderId = id(created.body?.data)
if (!orderId) throw new Error('negative draft missing order id') if (!orderId) throw new Error('negative draft missing order id')
const before = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken) const before = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken)
const submitted = await request(context, 'POST', `${base}/${orderId}/submit`, undefined, merchantToken, const submitCommand = freezeOrderCommand(context.apiSuccess(MODULE, '负向提交冻结草稿版本', before), key(context, `cargo-negative-${index}-submit`))
{ 'Idempotency-Key': key(context, `cargo-negative-${index}-submit`) }) const submitted = await request(context, 'POST', `${base}/${orderId}/submit`, submitCommand.body, merchantToken, submitCommand.headers)
const after = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken) const after = await request(context, 'GET', `${base}/${orderId}`, undefined, merchantToken)
context.assert(MODULE, `${payload.destinationType}/${payload.fulfillmentType} ${item.name}提交拒绝且草稿零漂移`, submitted, context.assert(MODULE, `${payload.destinationType}/${payload.fulfillmentType} ${item.name}提交拒绝且草稿零漂移`, submitted,
submitted.http === 200 && String(submitted.body?.code) === 'A0422' submitted.http === 200 && String(submitted.body?.code) === 'A0422'
@@ -805,7 +846,7 @@ export async function run(context) {
const clientDetailPath = `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}` const clientDetailPath = `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`
const draftResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) const draftResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
const draft = context.apiSuccess(MODULE, 'Client 查询隔离订单草稿', draftResponse) let draft = context.apiSuccess(MODULE, 'Client 查询隔离订单草稿', draftResponse)
const orderNo = id(draft?.order?.orderNo) const orderNo = id(draft?.order?.orderNo)
context.assert(MODULE, 'Client 草稿身份、marker 和状态正确', draftResponse, context.assert(MODULE, 'Client 草稿身份、marker 和状态正确', draftResponse,
id(draft?.order?.id) === orderId && draft?.order?.status === 'DRAFT' id(draft?.order?.id) === orderId && draft?.order?.status === 'DRAFT'
@@ -813,6 +854,7 @@ export async function run(context) {
`orderNo=${orderNo || 'missing'}, status=${draft?.order?.status ?? 'missing'}`) `orderNo=${orderNo || 'missing'}, status=${draft?.order?.status ?? 'missing'}`)
assertCargoContract(context, draftResponse, draft?.cargoes, payload) assertCargoContract(context, draftResponse, draft?.cargoes, payload)
context.createdResources.orders.orderNo = orderNo context.createdResources.orders.orderNo = orderNo
draft = await verifyPlatformDraftEdit(context, tenantId, orderId, merchantToken, payload)
const createConflictPayload = { const createConflictPayload = {
...payload, ...payload,
@@ -829,18 +871,25 @@ export async function run(context) {
orderPersistenceFacts(draftAfterConflict) === orderPersistenceFacts(draft), orderPersistenceFacts(draftAfterConflict) === orderPersistenceFacts(draft),
`orderId=${orderId}, status=${draftAfterConflict?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS) `orderId=${orderId}, status=${draftAfterConflict?.order?.status ?? 'missing'}`, CONTRACT_OPTIONS)
const submitResponse = await requestTransportRetry(context, 'POST', `${clientDetailPath}/submit`, undefined, const submitCommand = freezeOrderCommand(draft, key(context, 'client-submit'))
merchantToken, { 'Idempotency-Key': key(context, 'client-submit') }) context.createdResources.orders.submitCommand = submitCommand
const submitResponse = await requestTransportRetry(context, 'POST', `${clientDetailPath}/submit`, submitCommand.body,
merchantToken, submitCommand.headers)
context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`) context.apiSuccess(MODULE, 'Client 提交隔离订单', submitResponse, `orderId=${orderId}`)
const submitReplayResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, const submitReplayResponse = await request(context, 'POST', `${clientDetailPath}/submit`, submitCommand.body,
merchantToken, { 'Idempotency-Key': key(context, 'client-submit') }) merchantToken, submitCommand.headers)
context.apiSuccess(MODULE, 'Client 提交订单同键同载荷精确重放', submitReplayResponse, `orderId=${orderId}`) context.apiSuccess(MODULE, 'Client 提交订单同键同载荷精确重放', submitReplayResponse, `orderId=${orderId}`)
const submittedResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken) const submittedResponse = await request(context, 'GET', clientDetailPath, undefined, merchantToken)
const submitted = context.apiSuccess(MODULE, 'Client 回查已提交订单', submittedResponse) const submitted = context.apiSuccess(MODULE, 'Client 回查已提交订单', submittedResponse)
context.assert(MODULE, 'Client 订单已进入待审核状态', submittedResponse, context.assert(MODULE, 'Client 订单已进入待审核状态', submittedResponse,
submitted?.order?.status === 'SUBMITTED', `status=${submitted?.order?.status ?? 'missing'}`) submitted?.order?.status === 'SUBMITTED', `status=${submitted?.order?.status ?? 'missing'}`)
const duplicateSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, undefined, const conflictSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, { expectedVersion: submitted.order.version },
merchantToken, { 'Idempotency-Key': key(context, 'client-submit-new-key') }) merchantToken, submitCommand.headers)
context.assert(MODULE, '提交同键异版本返回A0409', conflictSubmitResponse,
conflictSubmitResponse.http === 200 && String(conflictSubmitResponse.body?.code) === 'A0409', 'frozen-key/different-payload', CONTRACT_OPTIONS)
const duplicateCommand = freezeOrderCommand(submitted, key(context, 'client-submit-new-key'))
const duplicateSubmitResponse = await request(context, 'POST', `${clientDetailPath}/submit`, duplicateCommand.body,
merchantToken, duplicateCommand.headers)
context.assert(MODULE, '已提交订单使用新键重复提交返回A0409', duplicateSubmitResponse, context.assert(MODULE, '已提交订单使用新键重复提交返回A0409', duplicateSubmitResponse,
duplicateSubmitResponse.http === 200 && String(duplicateSubmitResponse.body?.code) === 'A0409', duplicateSubmitResponse.http === 200 && String(duplicateSubmitResponse.body?.code) === 'A0409',
`http=${duplicateSubmitResponse.http}, code=${duplicateSubmitResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS) `http=${duplicateSubmitResponse.http}, code=${duplicateSubmitResponse.body?.code ?? 'missing'}`, CONTRACT_OPTIONS)
@@ -323,12 +323,16 @@ function cancellationFixture(change=null) {
return cancellationOutbox() return cancellationOutbox()
}} }}
if(change)change({rootDetail,childDetail,childInbound,tree}) if(change)change({rootDetail,childDetail,childInbound,tree})
context.request=async(method,path,body)=>{ let frozenCancel
calls.push({method,path,body}) context.request=async(method,path,body,token,headers)=>{
calls.push({method,path,body,headers})
if(method==='POST'){ if(method==='POST'){
assert.equal(path,'/api/platform/v1/orders/first-leg/101/cancel?tenantId=90') assert.equal(path,'/api/platform/v1/orders/first-leg/101/cancel?tenantId=90')
assert.ok(body.reason.includes('offline-only')) assert.ok(body.reason.includes('offline-only'))
if(count++)return {http:200,body:{code:'A0409'}} assert.ok(headers?.['Idempotency-Key'])
if(count++)return {http:200,body:{code:headers['Idempotency-Key']===frozenCancel.key && JSON.stringify(body)===frozenCancel.payload ? '200' : 'A0409'}}
assert.equal(body.expectedVersion,childDetail.order.version)
frozenCancel={key:headers['Idempotency-Key'],payload:JSON.stringify(body)}
childDetail.order.status='CANCELLED';childDetail.order.version++ childDetail.order.status='CANCELLED';childDetail.order.version++
childDetail.statusHistory=history('CANCELLED') childDetail.statusHistory=history('CANCELLED')
rootDetail.order.status='COMPLETED';rootDetail.order.version++ rootDetail.order.status='COMPLETED';rootDetail.order.version++
@@ -352,7 +356,13 @@ test('formal cancellation helper rereads physical eligibility, uses public cance
const {context,proof,calls}=cancellationFixture() const {context,proof,calls}=cancellationFixture()
const result=await cancelUnloadedRemainder(context,proof,'90','100','101','300','301') const result=await cancelUnloadedRemainder(context,proof,'90','100','101','300','301')
assert.equal(validRemainderCancellationGate(result),true) assert.equal(validRemainderCancellationGate(result),true)
assert.equal(calls.filter(row=>row.method==='POST').length,2) const writes=calls.filter(row=>row.method==='POST')
assert.equal(writes.length,4)
assert.deepEqual(writes[1].body,writes[0].body)
assert.deepEqual(writes[1].headers,writes[0].headers)
assert.equal(writes[2].headers['Idempotency-Key'],writes[0].headers['Idempotency-Key'])
assert.notEqual(writes[3].headers['Idempotency-Key'],writes[0].headers['Idempotency-Key'])
assert.ok(writes[3].body.expectedVersion>writes[0].body.expectedVersion)
assert.equal(proof.snapshots.length,3) assert.equal(proof.snapshots.length,3)
assert.deepEqual(proof.cancelReplayOutbox.before,proof.cancelReplayOutbox.after) assert.deepEqual(proof.cancelReplayOutbox.before,proof.cancelReplayOutbox.after)
assert.equal(context.steps.every(step=>step.passed),true) assert.equal(context.steps.every(step=>step.passed),true)
@@ -1,4 +1,5 @@
import { loadOneFragment } from './phase3-order-splitting.mjs' import { loadOneFragment } from './phase3-order-splitting.mjs'
import { freezeOrderCommand } from './orders.mjs'
import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { verifyPalletLabels } from './phase3-loading-finalize.mjs'
import { stableLoadedCartonMarks } from './phase3-loading.mjs' import { stableLoadedCartonMarks } from './phase3-loading.mjs'
import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse, import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolveSharedWarehouse,
@@ -247,9 +248,12 @@ export async function cancelUnloadedRemainder(context, proof, customerId, rootOr
'raw-approved-root/child; one-loaded-root/one-unloaded-remainder') 'raw-approved-root/child; one-loaded-root/one-unloaded-remainder')
proof.snapshots.push({phase:'BEFORE_REMAINDER_CANCELLATION',...before,tree,childInbound:childInbound.summary}) proof.snapshots.push({phase:'BEFORE_REMAINDER_CANCELLATION',...before,tree,childInbound:childInbound.summary})
const cancelPath = query(`/api/platform/v1/orders/first-leg/${childOrderId}/cancel`,{tenantId:customerId}) const cancelPath = query(`/api/platform/v1/orders/first-leg/${childOrderId}/cancel`,{tenantId:customerId})
const cancelBody = {reason:`${context.context.f40BaseMarker} 未装柜余量取消验收`} const cancelCommand = freezeOrderCommand({order:before.child.rawOrder}, `${context.context.f40BaseMarker}-cancel-remainder`.slice(0,128),
{reason:`${context.context.f40BaseMarker} 未装柜余量取消验收`})
proof.cancelCommand = cancelCommand
const cancelBody = cancelCommand.body
proof.currentStage = 'REMAINDER_CANCELLATION' proof.currentStage = 'REMAINDER_CANCELLATION'
await api(context,'POST','正式取消未装柜 APPROVED 余量订单',cancelPath,cancelBody) await api(context,'POST','正式取消未装柜 APPROVED 余量订单',cancelPath,cancelBody,context.token,cancelCommand.headers)
const after = await waitState(() => state(context,customerId,rootOrderId,childOrderId,rootInboundId), const after = await waitState(() => state(context,customerId,rootOrderId,childOrderId,rootInboundId),
validRemainderCancellationGate) validRemainderCancellationGate)
check(context,'F40 最后有效未装余量取消后根仅完成一次且子未签收',validRemainderCancellationGate(after), check(context,'F40 最后有效未装余量取消后根仅完成一次且子未签收',validRemainderCancellationGate(after),
@@ -268,9 +272,13 @@ export async function cancelUnloadedRemainder(context, proof, customerId, rootOr
value => exactCancelledRemainderOutboxProof(value,customerId,rootOrderId,childOrderId),30) : null value => exactCancelledRemainderOutboxProof(value,customerId,rootOrderId,childOrderId),30) : null
if (beforeOutbox) check(context,'F40 取消后唯一根完成Outbox且取消子完成Outbox为零', if (beforeOutbox) check(context,'F40 取消后唯一根完成Outbox且取消子完成Outbox为零',
exactCancelledRemainderOutboxProof(beforeOutbox,customerId,rootOrderId,childOrderId),'exact-root-one/cancelled-child-zero') exactCancelledRemainderOutboxProof(beforeOutbox,customerId,rootOrderId,childOrderId),'exact-root-one/cancelled-child-zero')
const replay = await context.request('POST',cancelPath,cancelBody) const replay = await context.request('POST',cancelPath,cancelBody,context.token,cancelCommand.headers)
check(context,'F40 取消重放按当前非幂等状态接口返回 A0409', check(context,'F40 原键原版本取消重放成功',success(replay),'cancel-original-key-replay',replay)
replay?.http === 200 && String(replay?.body?.code) === 'A0409','cancel-replay-conflict',replay) const conflict = await context.request('POST',cancelPath,{...cancelBody,reason:`${cancelBody.reason}-conflict`},context.token,cancelCommand.headers)
check(context,'F40 原键不同取消载荷返回 A0409',conflict?.http === 200 && String(conflict?.body?.code) === 'A0409','cancel-payload-conflict',conflict)
const duplicate = freezeOrderCommand({order:after.child.rawOrder}, `${context.context.f40BaseMarker}-cancel-new-key`.slice(0,128), {reason:cancelBody.reason})
const rejected = await context.request('POST',cancelPath,duplicate.body,context.token,duplicate.headers)
check(context,'F40 新键重复取消返回 A0409',rejected?.http === 200 && String(rejected?.body?.code) === 'A0409','cancel-new-key-conflict',rejected)
const stable = await state(context,customerId,rootOrderId,childOrderId,rootInboundId) const stable = await state(context,customerId,rootOrderId,childOrderId,rootInboundId)
const replayTree = await api(context,'GET','取消重放后回查原履约树',treePath) const replayTree = await api(context,'GET','取消重放后回查原履约树',treePath)
check(context,'F40 取消重放订单版本历史根摘要及履约台账零变化', check(context,'F40 取消重放订单版本历史根摘要及履约台账零变化',
@@ -38,6 +38,20 @@ test('frozen edit command preserves original version, key and nested payload thr
assert.throws(() => r.orderMutationCommand(snapshot, payload, ''), /key/) assert.throws(() => r.orderMutationCommand(snapshot, payload, ''), /key/)
}) })
test('submit command carries only frozen expectedVersion with the same original replay key',()=>{
const r=runtime(),detail={order:{version:4}}
const command=r.orderMutationCommand(detail,{},'submit-original')
detail.order.version=9
assert.deepEqual(plain(command),{headers:{'Idempotency-Key':'submit-original'},body:{expectedVersion:4}})
assert.match(source,/const submitCommand = orderMutationCommand\(submitBefore, \{\}, submitKey\)/)
assert.match(source,/submit replay keeps version and history unchanged/)
assert.match(source,/submit same key different version conflicts/)
assert.match(source,/submit new key cannot submit already submitted order/)
for(const name of ['pre-approval-submit','incomplete-submit','cancelled-submit','cancelled-submit-proof']) {
assert.match(source,new RegExp(`orderMutationCommand\\([^\\n]+(?:\\n[^\\n]+)?newIdempotencyKey\\('${name}'\\)`))
}
})
test('zero-drift validator detects changes in every aggregate and preserves array ordering', () => { test('zero-drift validator detects changes in every aggregate and preserves array ordering', () => {
const r = runtime(), before = { order: { version: 2 }, cargoes: [{ id: '1', amount: 2 }], parties: [], internalNotes: [], statusHistory: [{ id: 'h1' }, { id: 'h2' }] } const r = runtime(), before = { order: { version: 2 }, cargoes: [{ id: '1', amount: 2 }], parties: [], internalNotes: [], statusHistory: [{ id: 'h1' }, { id: 'h2' }] }
r.assertOrderUnchanged(before, structuredClone(before)) r.assertOrderUnchanged(before, structuredClone(before))
+21 -6
View File
@@ -221,7 +221,7 @@ async function merchantBeforeApprovalModule() {
await apiStep('order-before-approval', 'submit is blocked before approval', { await apiStep('order-before-approval', 'submit is blocked before approval', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
headers: { 'Idempotency-Key': newIdempotencyKey('pre-approval-submit') }, ...orderMutationCommand(await ownedOrderSnapshot('order-before-approval', 'freeze pre-approval submit version', orderId), {}, newIdempotencyKey('pre-approval-submit')),
}, ({ json, status }) => { }, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status') assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0403', 'business code') assertEqual(String(json?.code), 'A0403', 'business code')
@@ -1118,7 +1118,7 @@ async function orderLifecycleModule() {
report.resources.orderIds.push(draft.json.data) report.resources.orderIds.push(draft.json.data)
await softApiStep('order-validation', `${testCase.name} is rejected on submit`, { await softApiStep('order-validation', `${testCase.name} is rejected on submit`, {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${draft.json.data}/submit`, token: merchantToken, method: 'POST', path: `/api/merchant/v1/orders/first-leg/${draft.json.data}/submit`, token: merchantToken,
headers: { 'Idempotency-Key': newIdempotencyKey('incomplete-submit') }, ...orderMutationCommand(await ownedOrderSnapshot('order-validation', 'freeze incomplete submit version', draft.json.data), {}, newIdempotencyKey('incomplete-submit')),
}, ({ json, status }) => { }, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status') assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0422', 'business code') assertEqual(String(json?.code), 'A0422', 'business code')
@@ -1269,15 +1269,29 @@ async function orderLifecycleModule() {
}, ({ json }) => ({ total: json.data?.total, orderId, status: 'DRAFT' })) }, ({ json }) => ({ total: json.data?.total, orderId, status: 'DRAFT' }))
const submitKey = newIdempotencyKey('approved-submit') const submitKey = newIdempotencyKey('approved-submit')
const submitBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze submission page version', orderId)
const submitCommand = orderMutationCommand(submitBefore, {}, submitKey)
recordOrderMutationRequest('initial lifecycle submit', {method:'POST',path:`/api/merchant/v1/orders/first-leg/${orderId}/submit`,...submitCommand}, submitBefore)
await apiStep('order-lifecycle', 'submit order', { await apiStep('order-lifecycle', 'submit order', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
headers: { 'Idempotency-Key': submitKey }, ...submitCommand,
}, ({ json }) => requireSuccess(json), () => ({ orderId })) }, ({ json }) => requireSuccess(json), () => ({ orderId }))
const submittedBeforeReplay = await ownedOrderSnapshot('order-lifecycle', 'freeze successful submission facts', orderId)
await apiStep('order-lifecycle', 'submit order idempotent replay', { await apiStep('order-lifecycle', 'submit order idempotent replay', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken, method: 'POST', path: `/api/merchant/v1/orders/first-leg/${orderId}/submit`, token: merchantToken,
headers: { 'Idempotency-Key': submitKey }, ...submitCommand,
}, ({ json }) => requireSuccess(json), () => ({ orderId, replayed: true })) }, ({ json }) => requireSuccess(json), () => ({ orderId, replayed: true }))
await ownedOrderSnapshot('order-lifecycle', 'submit replay keeps version and history unchanged', orderId, submittedBeforeReplay)
const submitPath = `/api/merchant/v1/orders/first-leg/${orderId}/submit`
await rejectOrderMutation('submit same key different version conflicts', orderId, {
method: 'POST', path: submitPath, token: merchantToken, headers: submitCommand.headers,
body: { expectedVersion: submittedBeforeReplay.order.version },
}, ['A0409'])
await rejectOrderMutation('submit new key cannot submit already submitted order', orderId, {
method: 'POST', path: submitPath, token: merchantToken,
...orderMutationCommand(submittedBeforeReplay, {}, newIdempotencyKey('new-key-submit')),
}, ['A0409'])
await apiStep('order-lifecycle', 'submitted status is persisted', { await apiStep('order-lifecycle', 'submitted status is persisted', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, method: 'GET', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken,
@@ -1392,7 +1406,7 @@ async function orderLifecycleModule() {
}, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message })) }, ({ json }) => ({ orderId: cancelOrderId, rejectionCode: json.code, message: json.message }))
await softApiStep('order-boundary', 'cancelled order cannot be submitted', { await softApiStep('order-boundary', 'cancelled order cannot be submitted', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/submit`, token: merchantToken, method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/submit`, token: merchantToken,
headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit') }, ...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze cancelled submit version', cancelOrderId), {}, newIdempotencyKey('cancelled-submit')),
}, ({ json, status }) => { }, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status') assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0409', 'business code') assertEqual(String(json?.code), 'A0409', 'business code')
@@ -2290,7 +2304,8 @@ async function orderMutationEvidenceModule() {
...orderMutationCommand(cancelled, { reason }, newIdempotencyKey('new-key-repeat-cancel')), ...orderMutationCommand(cancelled, { reason }, newIdempotencyKey('new-key-repeat-cancel')),
}, ['A0409']) }, ['A0409'])
await rejectOrderMutation('cancelled draft cannot be submitted', id, { await rejectOrderMutation('cancelled draft cannot be submitted', id, {
method: 'POST', path: `${base}/${id}/submit`, token: merchantToken, headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit-proof') }, method: 'POST', path: `${base}/${id}/submit`, token: merchantToken,
...orderMutationCommand(cancelled, {}, newIdempotencyKey('cancelled-submit-proof')),
}, ['A0409']) }, ['A0409'])
report.orderMutationLimitations = [ report.orderMutationLimitations = [
'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.', 'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.',
@@ -2,7 +2,16 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, randomOrderOptions, selectSameChannelRoutes, validateCount } from './create-pending-review-orders.mjs' import { PRODUCT_CATALOG, buildOrderPayload, diversifyRoutes, parseArguments, randomOrderOptions, selectSameChannelRoutes, validateCount, pendingSubmitCommand } from './create-pending-review-orders.mjs'
test('submission binds original detail identity version and immutable key',()=>{
const detail={order:{id:'42',version:7}}, command=pendingSubmitCommand(detail,'42','original')
detail.order.version=99
assert.deepEqual(command,{body:{expectedVersion:7},headers:{'Idempotency-Key':'original'}})
assert.ok(Object.isFrozen(command.body))
for(const version of [undefined,null,'7',-1])assert.throws(()=>pendingSubmitCommand({order:{id:'42',version}},'42','key'))
assert.throws(()=>pendingSubmitCommand(detail,'other','key'))
})
const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8') const source = readFileSync(new URL('./create-pending-review-orders.mjs', import.meta.url), 'utf8')
+12 -1
View File
@@ -31,6 +31,13 @@ export const PRODUCT_CATALOG = [
const DEFAULT_COUNT = 50 const DEFAULT_COUNT = 50
export function pendingSubmitCommand(detail, orderId, idempotencyKey) {
if (String(detail?.order?.id) !== String(orderId) || !Number.isSafeInteger(detail?.order?.version)
|| detail.order.version < 0 || !idempotencyKey) throw new Error('Missing original order identity/version/key for submit')
return Object.freeze({ body: Object.freeze({ expectedVersion: detail.order.version }),
headers: Object.freeze({ 'Idempotency-Key': idempotencyKey }) })
}
export function parseArguments(values) { export function parseArguments(values) {
const options = {} const options = {}
const booleanOptions = new Set(['help', 'same-channel']) const booleanOptions = new Set(['help', 'same-channel'])
@@ -428,10 +435,13 @@ export async function main(argv = process.argv.slice(2)) {
const orderId = String(requireSuccess(createResponse, `Create order ${index}`) ?? '') const orderId = String(requireSuccess(createResponse, `Create order ${index}`) ?? '')
if (!orderId) throw new Error(`Create order ${index} did not return orderId`) if (!orderId) throw new Error(`Create order ${index} did not return orderId`)
const submitDetail = requireSuccess(await request(gateway, timeoutMs, 'GET',
`/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}`, { token }), `Freeze submit version ${index}`)
const submitCommand = pendingSubmitCommand(submitDetail, orderId, `${marker}-submit-${index}`.slice(0, 128))
const submitResponse = await request(gateway, timeoutMs, 'POST', const submitResponse = await request(gateway, timeoutMs, 'POST',
`/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}/submit`, { `/api/merchant/v1/orders/first-leg/${encodeURIComponent(orderId)}/submit`, {
token, token,
headers: { 'Idempotency-Key': `${marker}-submit-${index}`.slice(0, 128) }, ...submitCommand,
}) })
requireSuccess(submitResponse, `Submit order ${index}`) requireSuccess(submitResponse, `Submit order ${index}`)
@@ -451,6 +461,7 @@ export async function main(argv = process.argv.slice(2)) {
} }
const result = { const result = {
index, index,
submitCommand,
orderId, orderId,
orderNo, orderNo,
clientStatus: detail.order.status, clientStatus: detail.order.status,
+4 -2
View File
@@ -88,7 +88,7 @@ export function client(settings, fetcher = fetch, policy = allowedRequest) {
return { return {
get requests() { return requests }, get requests() { return requests },
async request(region, method, path, token, body, idempotencyKey) { async request(region, method, path, token, body, idempotencyKey) {
if (!policy(method, path, region)) throw new Error('请求不在生产前置检查白名单内') if (!policy(method, path, region, body, idempotencyKey)) throw new Error('请求不在生产前置检查白名单内')
if (++requests > 100) throw new Error('超过单次前置检查请求上限') if (++requests > 100) throw new Error('超过单次前置检查请求上限')
let response let response
try { try {
@@ -185,7 +185,7 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher
await preflight(settings, base, value => { context = value }) await preflight(settings, base, value => { context = value })
if (context.warehouses['CN-YIWU-01'] !== bound.origin.id) throw new Error('下单始发仓与义乌主档不符') if (context.warehouses['CN-YIWU-01'] !== bound.origin.id) throw new Error('下单始发仓与义乌主档不符')
const root = '/merchant/v1/orders/first-leg' const root = '/merchant/v1/orders/first-leg'
const policy = (method, path, region) => region === 'cn' && ( const policy = (method, path, region, body, idempotencyKey) => region === 'cn' && (
method === 'GET' && (path === `${root}/inbound-code-policy` method === 'GET' && (path === `${root}/inbound-code-policy`
|| state.orderId && path === `${root}/${state.orderId}` || state.orderId && path === `${root}/${state.orderId}`
|| path === `/merchant/v1/warehouses/${bound.destination.id}` || path === `/merchant/v1/warehouses/${bound.destination.id}`
@@ -193,6 +193,8 @@ export async function executeOrder(settings, input, journalPath, resume, fetcher
|| method === 'POST' && (path === '/merchant/v1/first-leg/pricing/estimate' || method === 'POST' && (path === '/merchant/v1/first-leg/pricing/estimate'
|| path === root && state.phase === 'CREATE_INTENT' || path === root && state.phase === 'CREATE_INTENT'
|| state.orderId && path === `${root}/${state.orderId}/submit` && state.phase === 'SUBMIT_INTENT' || state.orderId && path === `${root}/${state.orderId}/submit` && state.phase === 'SUBMIT_INTENT'
&& Number.isSafeInteger(state.submitCommand?.body?.expectedVersion)
&& JSON.stringify(body) === JSON.stringify(state.submitCommand.body) && idempotencyKey === state.submitCommand.idempotencyKey
|| state.orderId && path === `/platform/v1/orders/first-leg/${state.orderId}/approve?tenantId=${settings.customer.id}` && state.phase === 'APPROVE_INTENT')) || state.orderId && path === `/platform/v1/orders/first-leg/${state.orderId}/approve?tenantId=${settings.customer.id}` && state.phase === 'APPROVE_INTENT'))
const http = client(settings, fetcher, policy) const http = client(settings, fetcher, policy)
const merchant = context.logins.MERCHANT.accessToken const merchant = context.logins.MERCHANT.accessToken
+14 -3
View File
@@ -10,6 +10,13 @@ const canonical = value => Array.isArray(value) ? value.map(canonical)
: value && typeof value === 'object' : value && typeof value === 'object'
? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
export const fingerprint = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex') export const fingerprint = value => createHash('sha256').update(JSON.stringify(canonical(value))).digest('hex')
export function validateSubmitCheckpoint(state) {
if (['SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase)) {
assert(Number.isSafeInteger(state.submitCommand?.body?.expectedVersion) && state.submitCommand.body.expectedVersion >= 0
&& typeof state.submitCommand.idempotencyKey === 'string' && state.submitCommand.idempotencyKey.length > 0,
'提交检查点缺少首次版本或原幂等键,禁止续跑时补取新版')
}
}
export function validateOrderInput(input) { export function validateOrderInput(input) {
const { batch, customer, origin, destination, payload, operator } = input const { batch, customer, origin, destination, payload, operator } = input
@@ -66,6 +73,7 @@ export async function withOrderJournal(path, input, action) {
assert(state.version === 1 && state.binding === binding(input), '批次配置、环境、客户、账号或载荷已变化') assert(state.version === 1 && state.binding === binding(input), '批次配置、环境、客户、账号或载荷已变化')
assert(['NEW', 'CREATE_INTENT', 'CREATED', 'SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase), '检查点阶段无效') assert(['NEW', 'CREATE_INTENT', 'CREATED', 'SUBMIT_INTENT', 'SUBMITTED', 'APPROVE_INTENT', 'APPROVED'].includes(state.phase), '检查点阶段无效')
assert(['NEW', 'CREATE_INTENT'].includes(state.phase) ? state.orderId === null : longId(state.orderId), '检查点订单身份无效') assert(['NEW', 'CREATE_INTENT'].includes(state.phase) ? state.orderId === null : longId(state.orderId), '检查点订单身份无效')
validateSubmitCheckpoint(state)
const save = async patch => { const save = async patch => {
const next = { ...state, ...patch } const next = { ...state, ...patch }
const file = await open(temp, 'wx', 0o600) const file = await open(temp, 'wx', 0o600)
@@ -114,8 +122,10 @@ export function assertOrderDetail(detail, input, orderId) {
export async function runOrderStage({ input, state, save, transport, authorize, resume = false }) { export async function runOrderStage({ input, state, save, transport, authorize, resume = false }) {
validateOrderInput(input) validateOrderInput(input)
assert(state.binding === binding(input), '检查点绑定不符') assert(state.binding === binding(input), '检查点绑定不符')
validateSubmitCheckpoint(state)
await authorize(input) await authorize(input)
const key = op => `prod-release-${fingerprint({ binding: state.binding, op }).slice(0, 48)}` const key = op => `prod-release-${fingerprint({ binding: state.binding, op }).slice(0, 48)}`
if (state.submitCommand) assert(state.submitCommand.idempotencyKey === key('submit'), '提交原幂等键与批次不符')
const request = (role, method, path, body, idempotencyKey) => transport({ role, method, path, body, idempotencyKey }) const request = (role, method, path, body, idempotencyKey) => transport({ role, method, path, body, idempotencyKey })
if (state.phase === 'CREATE_INTENT') assert(resume, '创建结果待核对;需要显式续跑') if (state.phase === 'CREATE_INTENT') assert(resume, '创建结果待核对;需要显式续跑')
if (['NEW', 'CREATE_INTENT'].includes(state.phase)) { if (['NEW', 'CREATE_INTENT'].includes(state.phase)) {
@@ -132,15 +142,16 @@ export async function runOrderStage({ input, state, save, transport, authorize,
let order = await read() let order = await read()
if (state.phase === 'CREATED') { if (state.phase === 'CREATED') {
assert(order.status === 'DRAFT', '创建后状态被外部修改') assert(order.status === 'DRAFT', '创建后状态被外部修改')
await save({ phase: 'SUBMIT_INTENT' }) assert(Number.isSafeInteger(order.version) && order.version >= 0, '提交前详情缺少有效版本')
await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit')) await save({ phase: 'SUBMIT_INTENT', submitCommand: { body: { expectedVersion: order.version }, idempotencyKey: key('submit') } })
await request('MERCHANT', 'POST', `${path}/submit`, state.submitCommand.body, state.submitCommand.idempotencyKey)
order = await read() order = await read()
assert(order.status === 'SUBMITTED', '提交后未进入待审核') assert(order.status === 'SUBMITTED', '提交后未进入待审核')
await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode }) await save({ phase: 'SUBMITTED', orderNo: order.orderNo, inboundCode: order.inboundCode })
} else if (state.phase === 'SUBMIT_INTENT') { } else if (state.phase === 'SUBMIT_INTENT') {
assert(resume, '提交结果待核对;需要显式续跑') assert(resume, '提交结果待核对;需要显式续跑')
if (order.status === 'DRAFT') { if (order.status === 'DRAFT') {
await request('MERCHANT', 'POST', `${path}/submit`, undefined, key('submit')) await request('MERCHANT', 'POST', `${path}/submit`, state.submitCommand.body, state.submitCommand.idempotencyKey)
order = await read() order = await read()
} }
assert(order.status === 'SUBMITTED', '提交结果无法安全核对') assert(order.status === 'SUBMITTED', '提交结果无法安全核对')
+33 -3
View File
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage } from './prod-release-order-stage.mjs' import { fingerprint, validateOrderInput, withOrderJournal, runOrderStage, validateSubmitCheckpoint } from './prod-release-order-stage.mjs'
import { executeOrder } from './prod-release-acceptance.mjs' import { executeOrder } from './prod-release-acceptance.mjs'
function fixture() { function fixture() {
@@ -28,13 +28,13 @@ function server(input, fail) {
let status = null let status = null
let failed = false let failed = false
const calls = [] const calls = []
const state = { calls, created: 0, approved: 0, const state = { calls, created: 0, approved: 0, orderVersion: 5,
async transport(request) { async transport(request) {
calls.push(request) calls.push(request)
const { method, path, idempotencyKey } = request const { method, path, idempotencyKey } = request
if (path.endsWith('/inbound-code-policy')) return { mode: 'AUTO', manualEntryRequired: false } if (path.endsWith('/inbound-code-policy')) return { mode: 'AUTO', manualEntryRequired: false }
if (method === 'GET') return { order: { ...input.payload, id: '5', tenantId: '1', customerId: '1', if (method === 'GET') return { order: { ...input.payload, id: '5', tenantId: '1', customerId: '1',
status, orderNo: 'FIXTURE-ORDER', inboundCode: status === 'DRAFT' ? null : 'FIXTURE-INBOUND' }, cargoes: input.payload.cargoes } status, version:state.orderVersion, orderNo: 'FIXTURE-ORDER', inboundCode: status === 'DRAFT' ? null : 'FIXTURE-INBOUND' }, cargoes: input.payload.cargoes }
let op let op
if (path.endsWith('/first-leg')) { if (path.endsWith('/first-leg')) {
op = 'create' op = 'create'
@@ -43,6 +43,8 @@ function server(input, fail) {
} else if (path.endsWith('/submit')) { } else if (path.endsWith('/submit')) {
op = 'submit' op = 'submit'
assert.ok(idempotencyKey) assert.ok(idempotencyKey)
assert.deepEqual(request.body,{expectedVersion:state.orderVersion})
state.orderVersion++
status = 'SUBMITTED' status = 'SUBMITTED'
} else { } else {
assert.match(path, /^\/platform\/v1\/orders\/first-leg\/5\/approve\?tenantId=1$/) assert.match(path, /^\/platform\/v1\/orders\/first-leg\/5\/approve\?tenantId=1$/)
@@ -67,6 +69,34 @@ async function run(path, input, api, resume = false, authorize = async () => {})
test('fingerprint is stable for reordered keys', () => { test('fingerprint is stable for reordered keys', () => {
assert.equal(fingerprint({ a: 1, b: 2 }), fingerprint({ b: 2, a: 1 })) assert.equal(fingerprint({ a: 1, b: 2 }), fingerprint({ b: 2, a: 1 }))
}) })
test('submit-intent without original numeric version/key is blocked rather than repaired from current detail',()=>{
for(const submitCommand of [undefined,{body:{},idempotencyKey:'k'},{body:{expectedVersion:'5'},idempotencyKey:'k'},{body:{expectedVersion:5}}]) {
assert.throws(()=>validateSubmitCheckpoint({phase:'SUBMIT_INTENT',submitCommand}),/首次版本/)
}
validateSubmitCheckpoint({phase:'SUBMIT_INTENT',submitCommand:{body:{expectedVersion:0},idempotencyKey:'k'}})
})
test('uncertain submit persists request before transport and resume retains original version even after external edit',async t=>{
const path=await sandbox(t),input=fixture(),api=server(input)
const original=api.transport;let attempted=false
api.transport=async request=>{
if(request.path.endsWith('/submit')&&!attempted){
attempted=true
const disk=JSON.parse(await readFile(path,'utf8'))
assert.equal(disk.phase,'SUBMIT_INTENT');assert.deepEqual(disk.submitCommand.body,request.body)
assert.equal(disk.submitCommand.idempotencyKey,request.idempotencyKey)
throw new Error('uncertain-before-send')
}
return original(request)
}
await assert.rejects(run(path,input,api),/uncertain-before-send/)
const first=JSON.parse(await readFile(path,'utf8')).submitCommand
api.orderVersion=99
await assert.rejects(run(path,input,api,true)) // original version 5 must not be refreshed to 99
assert.deepEqual(api.calls.filter(call=>call.path.endsWith('/submit')).at(-1).body,first.body)
assert.deepEqual(JSON.parse(await readFile(path,'utf8')).submitCommand,first)
})
test('rejects multiple SKUs, excess cartons, incorrect company and volume', () => { test('rejects multiple SKUs, excess cartons, incorrect company and volume', () => {
for (const change of [i => i.payload.cargoes.push(i.payload.cargoes[0]), for (const change of [i => i.payload.cargoes.push(i.payload.cargoes[0]),
i => i.payload.cargoes[0].packageCount = 300, i => i.payload.cargoes[0].packageCount = 300,
+3 -1
View File
@@ -416,8 +416,10 @@ export async function executeCandidates({ candidates, limit, concurrency = 1, st
entry.orderNo = draft.order.orderNo entry.orderNo = draft.order.orderNo
entry.declarationVerified = true entry.declarationVerified = true
if (['pending-review', 'wait-split'].includes(stage)) { if (['pending-review', 'wait-split'].includes(stage)) {
if (!Number.isSafeInteger(draft.order.version) || draft.order.version < 0) throw Error('SUBMIT_VERSION_MISSING')
entry.submitCommand = { body: { expectedVersion: draft.order.version }, headers: { 'Idempotency-Key': `${marker}-submit` } }
entry.stage = 'submit'; await save() entry.stage = 'submit'; await save()
await api('POST', `${clientPath}/submit`, undefined, merchant, { 'Idempotency-Key': `${marker}-submit` }) await api('POST', `${clientPath}/submit`, entry.submitCommand.body, merchant, entry.submitCommand.headers)
} }
if (stage === 'pending-review') { if (stage === 'pending-review') {
const submitted = await api('GET', clientPath, undefined, merchant) const submitted = await api('GET', clientPath, undefined, merchant)
+28 -2
View File
@@ -6,6 +6,32 @@ import { normalizeRows, channelName, matchRoute, measurementShape, buildSourceDr
const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号'] const header = ['进仓编号', '客户', '渠道', '亚马逊仓', '实际件数', '实际毛重', '实际立方', '预报件数', '买单/退税', '品名', 'SKU', 'FBA货件编号', '跟踪编号']
const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1'] const row = ['ABCD2606010001', 'Example', '合德海卡包税', 'ABC1', 31, 100, 2, 31, '买单', 'Goods', 'SKU1', 'SHIP1', 'REF1']
const order = () => normalizeRows([header, row]).orders[0] const order = () => normalizeRows([header, row]).orders[0]
test('Excel submit saves original key/version before uncertain transport and never auto-refreezes failed entries',async t=>{
const originalExitCode=process.exitCode
t.after(()=>{process.exitCode=originalExitCode})
const value=order(),route=matchRoute(value,catalog),state={identity:{batchId:'frozen-submit'},orders:{}}
const saved=[]
const api=async(method,path,body,_token,headers)=>{
if(path.endsWith('/pricing/estimate'))return {channelCode:route.channel.channelCode,pricingTaxMode:value.pricingTaxMode}
if(method==='POST'&&path.endsWith('/first-leg'))return 'order-frozen'
if(method==='GET')return {order:{id:'order-frozen',version:8,orderNo:'FL',customerRemark:state.orders[value.key].marker},cargoes:[{packageCount:value.packageCount,grossWeightKg:value.weightKg,volumeCbm:value.volumeCbm}]}
if(path.endsWith('/submit')){
const checkpoint=saved.at(-1).orders[value.key]
assert.equal(checkpoint.stage,'submit');assert.deepEqual(checkpoint.submitCommand,{body,headers})
assert.deepEqual(body,{expectedVersion:8});throw new Error('transport lost')
}
throw new Error('unexpected call')
}
const options={candidates:[{order:value,route}],limit:1,state,save:async()=>saved.push(structuredClone(state)),api,
ctx:{},merchant:'merchant',profile:{tenantName:'Example'},sharedResources:{productImageFileId:'image'},stage:'pending-review'}
await executeCandidates(options)
assert.equal(process.exitCode,1)
assert.equal(state.orders[value.key].status,'FAILED')
assert.equal(state.orders[value.key].submitCommand.body.expectedVersion,8)
await executeCandidates({...options,api:async()=>{assert.fail('failed checkpoint must not issue another request')}})
assert.equal(state.orders[value.key].submitCommand.body.expectedVersion,8)
})
const catalog = { receiving: { id: '1', warehouseName: 'Origin' }, const catalog = { receiving: { id: '1', warehouseName: 'Origin' },
channels: [{ channelName: '合德海卡', channelCode: 'CHANNEL', pricingTaxModes: ['TAX_INCLUDED'] }], channels: [{ channelName: '合德海卡', channelCode: 'CHANNEL', pricingTaxModes: ['TAX_INCLUDED'] }],
destinations: [{ id: '2', warehouseType: 'FBA', warehouseCode: 'ABC1', warehouseName: 'ABC1', countryCode: 'US' }] } destinations: [{ id: '2', warehouseType: 'FBA', warehouseCode: 'ABC1', warehouseName: 'ABC1', countryCode: 'US' }] }
@@ -150,7 +176,7 @@ test('pending-review stage submits but never calls approval or measurement APIs'
if (path === '/api/merchant/v1/orders/first-leg/order-1' && method === 'GET') { if (path === '/api/merchant/v1/orders/first-leg/order-1' && method === 'GET') {
detailReads++ detailReads++
return { order: { id: 'order-1', orderNo: 'FL-1', customerRemark: Object.values(state.orders)[0].marker, return { order: { id: 'order-1', orderNo: 'FL-1', customerRemark: Object.values(state.orders)[0].marker,
status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT', version: 7 },
cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] }
} }
if (path === '/api/merchant/v1/orders/order-1') return { summary: { mainStatus: 'PENDING_REVIEW' } } if (path === '/api/merchant/v1/orders/order-1') return { summary: { mainStatus: 'PENDING_REVIEW' } }
@@ -175,7 +201,7 @@ test('pending-review retries an estimate-only pricing failure and records it as
if (path === '/api/merchant/v1/orders/first-leg/order-2' && method === 'GET') { if (path === '/api/merchant/v1/orders/first-leg/order-2' && method === 'GET') {
detailReads++ detailReads++
return { order: { id: 'order-2', orderNo: 'FL-2', customerRemark: Object.values(state.orders)[0].marker, return { order: { id: 'order-2', orderNo: 'FL-2', customerRemark: Object.values(state.orders)[0].marker,
status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT' }, status: detailReads > 1 ? 'SUBMITTED' : 'DRAFT', version: 7 },
cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] } cargoes: [{ packageCount: value.packageCount, grossWeightKg: value.weightKg, volumeCbm: value.volumeCbm }] }
} }
if (path === '/api/merchant/v1/orders/order-2') return { summary: { mainStatus: 'PENDING_REVIEW' } } if (path === '/api/merchant/v1/orders/order-2') return { summary: { mainStatus: 'PENDING_REVIEW' } }