fix(order): make merchant draft edits and cancellations versioned and idempotent

This commit is contained in:
2026-10-02 21:33:39 +08:00
parent 4b531c7e22
commit 58dfcc21f6
10 changed files with 1227 additions and 28 deletions
@@ -12,7 +12,6 @@ import com.baiyue.order.dto.MerchantInboundOrderQuery;
import com.baiyue.order.dto.MerchantInboundOrderRow;
import com.baiyue.order.dto.InboundCodePolicyResponse;
import com.baiyue.order.dto.InboundCodeAssociationSummary;
import com.baiyue.order.entity.OrderStatus;
import com.baiyue.order.service.OrderApplicationService;
import com.baiyue.order.service.OrderScopeGuard;
import com.baiyue.order.service.MerchantInboundOrderListService;
@@ -154,8 +153,9 @@ public class MerchantFirstLegOrderController {
public ApiResponse<Void> update(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.SaveRequest request) {
service.updateDraft(OrderScopeGuard.merchant(user), user.userId(), id, request);
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody OrderRequests.DraftUpdateRequest request) {
service.updateDraft(OrderScopeGuard.merchant(user), user.userId(), id, request, idempotencyKey);
return ApiResponse.success(null);
}
@@ -211,14 +211,10 @@ public class MerchantFirstLegOrderController {
public ApiResponse<Void> cancel(
@AuthenticationPrincipal AuthenticatedUser user,
@Parameter(description = "Order ID / 订单 ID", example = "190000000000000001", required = true) @PathVariable long id,
@Valid @RequestBody OrderRequests.ReasonRequest request) {
return transit(user, id, OrderStatus.CANCELLED, request.reason());
}
private ApiResponse<Void> transit(AuthenticatedUser user, long id, OrderStatus target, String reason) {
service.transit(
OrderScopeGuard.merchant(user), user.userId(), user.username(), id, target, reason
);
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody OrderRequests.CancelRequest request) {
service.cancel(OrderScopeGuard.merchant(user), user.userId(), user.username(), id, request, idempotencyKey);
return ApiResponse.success(null);
}
}
@@ -278,6 +278,18 @@ public final class OrderRequests {
) {
}
@Schema(name = "MerchantDraftUpdateRequest", description = "客户草稿版本化幂等编辑")
public record DraftUpdateRequest(
@NotNull @Min(0) Integer expectedVersion,
@NotNull @Valid SaveRequest draft
) { }
@Schema(name = "MerchantOrderCancelRequest", description = "客户订单版本化幂等取消")
public record CancelRequest(
@NotNull @Min(0) Integer expectedVersion,
@NotBlank @Size(max = 500) String reason
) { }
@Schema(name = "OrderReasonRequest", description = "Order lifecycle reason / 订单生命周期操作原因")
public record ReasonRequest(
@Schema(description = "Reason / 原因", example = "Commercial terms need revision / 商务条款需要调整", maxLength = 500, requiredMode = Schema.RequiredMode.REQUIRED)
@@ -122,25 +122,50 @@ public class OrderApplicationService {
}
@Transactional
public void updateDraft(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
updateOrderData(tenantId, userId, id, request, false);
public void updateDraft(long tenantId, long userId, long id, OrderRequests.DraftUpdateRequest request,
String idempotencyKey) {
requireExpectedVersion(request.expectedVersion());
if (request.draft() == null) throw new BizException(ResultCode.VALIDATION_FAILED, "草稿内容不能为空");
IdempotencyContext command = beginIdempotency(tenantId, userId, "UPDATE_DRAFT", id, idempotencyKey,
requestHash("UPDATE_DRAFT", List.of(id, request)));
if (command.replayed()) return;
updateOrderData(tenantId, userId, id, request.draft(), false, request.expectedVersion());
completeIdempotency(command, id);
}
@Transactional
public void cancel(long tenantId, long userId, String username, long id, OrderRequests.CancelRequest request,
String idempotencyKey) {
requireExpectedVersion(request.expectedVersion());
IdempotencyContext command = beginIdempotency(tenantId, userId, "CANCEL_ORDER", id, idempotencyKey,
requestHash("CANCEL_ORDER", List.of(id, request)));
if (command.replayed()) return;
transit(tenantId, userId, username, id, OrderStatus.CANCELLED, request.reason(), request.expectedVersion());
completeIdempotency(command, id);
}
private void requireExpectedVersion(Integer version) {
if (version == null || version < 0)
throw new BizException(ResultCode.VALIDATION_FAILED, "必须提供有效的订单版本");
}
/**
* 平台审核队列中的订单仍允许编辑。编辑只更新订单资料,不回退已提交订单的主状态。
* 商户端继续使用 {@link #updateDraft(long, long, long, OrderRequests.SaveRequest)},
* 商户端继续使用独立的版本化草稿编辑命令,
* 因此不会因为该兼容能力意外获得已提交订单的编辑权限。
*/
@Transactional
public void updateSubmittedOrder(long tenantId, long userId, long id, OrderRequests.SaveRequest request) {
updateOrderData(tenantId, userId, id, request, true);
updateOrderData(tenantId, userId, id, request, true, null);
}
private void updateOrderData(long tenantId, long userId, long id, OrderRequests.SaveRequest request,
boolean allowSubmitted) {
boolean allowSubmitted, Integer expectedVersion) {
AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request);
validateWoodInspection(request);
OrderEntity order = require(tenantId, id);
if (expectedVersion != null && !expectedVersion.equals(order.getVersion()))
throw new BizException(ResultCode.CONFLICT, "订单版本已变化,请刷新后重试");
if (allowSubmitted) validateCompleteRequest(request);
else validateRequestCargoQuantities(request.cargoes(), false);
productImageGuard.requireOwned(tenantId, request.cargoes());
@@ -157,7 +182,7 @@ public class OrderApplicationService {
order.setStatus(OrderStatus.SUBMITTED);
}
try {
if (repository.saveOrder(tenantId, order) != 1) throw new BizException("Concurrent order update");
if (repository.saveOrder(tenantId, order) != 1) throw new BizException(ResultCode.CONFLICT, "Concurrent order update");
} catch (DuplicateKeyException duplicate) {
throw duplicateInboundCode(order, duplicate);
}
@@ -585,6 +585,44 @@ paths:
code: {type: string, example: '200'}
data: {$ref: '#/components/schemas/InboundCodePolicy'}
'403': {description: 需要 merchant:order:list、merchant:order:create 或 merchant:order:edit 权限之一}
/api/merchant/v1/orders/first-leg/{id}:
put:
summary: 客户版本化编辑草稿
description: >-
仅允许本客户 DRAFT/REJECTED 订单。必须携带读取详情时的 expectedVersion 和 Idempotency-Key;
不接受旧平铺保存请求。原键同载荷重放先返回首次成功,不再检查已递增版本或修改明细与历史;
同键不同订单/版本/内容、首次版本过期或状态不允许返回业务冲突 A0409。
版本或 draft 缺失为参数错误。主表、子明细、历史和幂等结果同事务提交或回滚。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantDraftUpdateRequest'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 merchant:order:edit 权限和本客户订单归属}
/api/merchant/v1/orders/first-leg/{id}/cancel:
post:
summary: 客户版本化幂等取消订单
description: >-
必须携带当前 expectedVersion、原因和 Idempotency-Key。原键同载荷返回首次成功,
不重复取消、聚合根订单、写历史或 Outbox;同键不同订单/版本/原因拒绝。
新命令仅在当前版本及取消状态门禁通过后执行,重复取消新命令返回 A0409。
取消、根履约聚合、历史、事件与幂等结果同事务完成;不接受缺版本的旧原因请求。
parameters:
- $ref: '#/components/parameters/OrderId'
- $ref: '#/components/parameters/IdempotencyKey'
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/MerchantOrderCancelRequest'}
responses:
'200': {description: 成功或业务失败,按响应体 code 判断}
'403': {description: 需要 merchant:order:cancel 权限和本客户订单归属}
/api/merchant/v1/orders/first-leg/{id}/submit:
post:
summary: 提交订单
@@ -1241,6 +1279,18 @@ components:
type: string
enum: [TAX_INCLUDED, TAX_EXCLUDED]
description: 报价包税维度;独立于订单 taxMode 的买单/退税语义
MerchantDraftUpdateRequest:
type: object
required: [expectedVersion, draft]
properties:
expectedVersion: {type: integer, minimum: 0}
draft: {$ref: '#/components/schemas/FirstLegOrderDraft'}
MerchantOrderCancelRequest:
type: object
required: [expectedVersion, reason]
properties:
expectedVersion: {type: integer, minimum: 0}
reason: {type: string, minLength: 1, maxLength: 500}
FirstLegOrderDraft:
type: object
required: [customerName, transportMode, cargoes, pickupMethod, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, taxMode, pricingTaxMode, destinationType]
@@ -0,0 +1,80 @@
package com.baiyue.order.controller;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.service.OrderApplicationService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.MethodParameter;
import org.springframework.http.MediaType;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.support.WebDataBinderFactory;
import org.springframework.web.context.request.NativeWebRequest;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.method.support.ModelAndViewContainer;
import java.util.Set;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
/** Local MVC binding only: no server/socket or TEST requests. */
class MerchantOrderCommandBindingTest {
private MockMvc mvc;
private OrderApplicationService service;
private static final String PATH = "/api/merchant/v1/orders/first-leg/1";
private static final String DRAFT = """
{"customerName":"Offline","transportMode":"SEA","destinationCountryCode":"US",
"cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1,"woodenProduct":false}],
"pickupMethod":"SELF_EXPRESS","receivingWarehouseId":8,"receivingWarehouseName":"Offline",
"channelCode":"TEST","channelName":"Offline","taxMode":"BUY_DECLARATION",
"pricingTaxMode":"TAX_INCLUDED","destinationType":"OVERSEAS"}
""";
@BeforeEach
void prepare() {
service = mock(OrderApplicationService.class);
var controller = new MerchantFirstLegOrderController();
ReflectionTestUtils.setField(controller, "service", service);
mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() {
public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; }
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer model, NativeWebRequest request, WebDataBinderFactory binder) {
return new AuthenticatedUser(7L, 11L, 11L, 1L, "operator", "MERCHANT", "WEB", Set.of());
}
}).build();
}
@Test
void editRequiresHeaderAndVersionedDraftEnvelope() throws Exception {
String body = "{\"expectedVersion\":0,\"draft\":" + DRAFT + "}";
mvc.perform(put(PATH).contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest());
for (String invalid : new String[] {DRAFT, "{\"draft\":" + DRAFT + "}", "{\"expectedVersion\":0}",
"{\"expectedVersion\":-1,\"draft\":" + DRAFT + "}", "{\"expectedVersion\":0,\"draft\":{}}"}) {
mvc.perform(put(PATH).header("Idempotency-Key", "edit").contentType(MediaType.APPLICATION_JSON).content(invalid))
.andExpect(status().isBadRequest());
}
verifyNoInteractions(service);
mvc.perform(put(PATH).header("Idempotency-Key", "edit").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"));
verify(service).updateDraft(eq(11L), eq(7L), eq(1L), argThat(request -> request.expectedVersion() == 0 && "Offline".equals(request.draft().customerName())), eq("edit"));
}
@Test
void cancelRejectsOldReasonOnlyBodyAndForwardsAuthenticatedIdentity() throws Exception {
String body = "{\"expectedVersion\":3,\"reason\":\"changed plan\"}";
mvc.perform(post(PATH + "/cancel").contentType(MediaType.APPLICATION_JSON).content(body)).andExpect(status().isBadRequest());
for (String invalid : new String[] {"{\"reason\":\"old\"}", "{\"expectedVersion\":null,\"reason\":\"old\"}",
"{\"expectedVersion\":-1,\"reason\":\"old\"}", "{\"expectedVersion\":0,\"reason\":\" \"}"}) {
mvc.perform(post(PATH + "/cancel").header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content(invalid))
.andExpect(status().isBadRequest());
}
verifyNoInteractions(service);
mvc.perform(post(PATH + "/cancel").header("Idempotency-Key", "cancel").contentType(MediaType.APPLICATION_JSON).content(body))
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"));
verify(service).cancel(11L, 7L, "operator", 1L, new OrderRequests.CancelRequest(3, "changed plan"), "cancel");
}
}
@@ -0,0 +1,304 @@
package com.baiyue.order.service;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.order.client.WarehouseClient;
import com.baiyue.order.config.InboundCodeModeProvider;
import com.baiyue.order.dto.OrderRequests;
import com.baiyue.order.entity.*;
import com.baiyue.order.mapper.*;
import com.baiyue.order.repository.OrderRepository;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.h2.jdbcx.JdbcDataSource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mybatis.spring.SqlSessionTemplate;
import org.springframework.aop.framework.ProxyFactory;
import org.springframework.beans.factory.support.StaticListableBeanFactory;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.test.util.ReflectionTestUtils;
import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource;
import org.springframework.transaction.interceptor.TransactionInterceptor;
import java.lang.reflect.Modifier;
import java.math.BigDecimal;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
/** Real production repository/Mapper writes and transaction proxies; only remote guards are mocked. */
class MerchantOrderCommandTransactionTest {
private JdbcTemplate db;
private OrderApplicationService service;
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private OrderRequests.SaveRequest draft;
@BeforeEach
void prepare() throws Exception {
JdbcDataSource source = new JdbcDataSource();
source.setURL("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000");
db = new JdbcTemplate(source);
// Entity-shaped test tables keep all production Mapper columns. Unique/rollback constraints
// below are real database constraints, not mocked repository outcomes.
for (Class<?> type : List.of(OrderEntity.class, OrderCargoEntity.class, OrderPartyEntity.class,
OrderStatusHistoryEntity.class, OutboxEventEntity.class, OrderIdempotencyRecordEntity.class)) table(type);
db.execute("ALTER TABLE ord_idempotency_record ADD CONSTRAINT command_scope UNIQUE(tenant_id,operator_id,operation,idempotency_key)");
MybatisConfiguration config = new MybatisConfiguration();
config.setMapUnderscoreToCamelCase(true);
for (Class<?> type : List.of(OrderMapper.class, OrderCargoMapper.class, OrderPartyMapper.class,
OrderStatusHistoryMapper.class, OutboxEventMapper.class, OrderIdempotencyRecordMapper.class,
OrderChildPurgeMapper.class)) config.addMapper(type);
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
MybatisSqlSessionFactoryBean factory = new MybatisSqlSessionFactoryBean();
factory.setConfiguration(config); factory.setDataSource(source); factory.setPlugins(interceptor);
SqlSessionTemplate session = new SqlSessionTemplate(factory.getObject());
OrderRepository repository = new OrderRepository();
for (var field : OrderRepository.class.getDeclaredFields()) {
if (config.hasMapper(field.getType())) ReflectionTestUtils.setField(repository, field.getName(), session.getMapper(field.getType()));
}
DataSourceTransactionManager manager = new DataSourceTransactionManager(source);
OrderFulfillmentCompletionService completion = new OrderFulfillmentCompletionService();
ReflectionTestUtils.setField(completion, "orders", session.getMapper(OrderMapper.class));
ReflectionTestUtils.setField(completion, "histories", session.getMapper(OrderStatusHistoryMapper.class));
ReflectionTestUtils.setField(completion, "outbox", session.getMapper(OutboxEventMapper.class));
ReflectionTestUtils.setField(completion, "json", json);
OrderApplicationService target = new OrderApplicationService();
ReflectionTestUtils.setField(target, "repository", repository);
ReflectionTestUtils.setField(target, "fulfillmentCompletion", proxy(completion, manager));
ReflectionTestUtils.setField(target, "json", json);
ReflectionTestUtils.setField(target, "productImageGuard", mock(ProductImageGuard.class));
ReflectionTestUtils.setField(target, "inboundCodeModeProvider", mock(InboundCodeModeProvider.class));
ReflectionTestUtils.setField(target, "customerAddressServiceProvider", new StaticListableBeanFactory().getBeanProvider(CustomerAddressService.class));
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
when(pricing.requireAvailable(anyLong(), anyLong(), anyString(), anyString())).thenReturn("TEST");
ReflectionTestUtils.setField(target, "pricingChannelGuard", pricing);
OrderMasterDataGuard master = mock(OrderMasterDataGuard.class);
var receiving = json.readValue("{\"warehouse\":{\"id\":8,\"warehouseName\":\"Offline\"},\"addresses\":[],\"capabilities\":[]}", WarehouseClient.Detail.class);
when(master.validate(anyLong(), any())).thenReturn(new OrderMasterDataGuard.Snapshots(receiving, null));
ReflectionTestUtils.setField(target, "masterDataGuard", master);
service = proxy(target, manager);
draft = json.readValue("""
{"customerName":"Offline","transportMode":"SEA","destinationCountryCode":"US",
"cargoes":[{"cargoName":"Offline cargo","packageType":"CARTON","packageCount":1}],
"pickupMethod":"SELF_EXPRESS","receivingWarehouseId":8,"receivingWarehouseName":"Offline",
"channelCode":"TEST","channelName":"Offline","taxMode":"BUY_DECLARATION",
"pricingTaxMode":"TAX_INCLUDED","destinationType":"OVERSEAS"}
""", OrderRequests.SaveRequest.class);
order(1, 11, "DRAFT", "MASTER", null);
db.update("INSERT INTO ord_order_cargo(id,tenant_id,order_id,item_no,cargo_name,package_count) VALUES(101,11,1,1,'original cargo',1)");
db.update("INSERT INTO ord_order_party(id,tenant_id,order_id) VALUES(102,11,1)");
}
@Test
void editReplaysOriginalCommandBeforeCurrentVersionAndRejectsAnyChangedIdentity() {
edit(11, 1, 0, "edit");
var before = snapshot();
edit(11, 1, 0, "edit");
assertThat(snapshot()).isEqualTo(before);
assertThat(version(1)).isEqualTo(1);
assertThat(count("ord_order_status_history")).isEqualTo(1);
assertThat(count("ord_idempotency_record")).isEqualTo(1);
assertThat(db.queryForObject("SELECT cargo_name FROM ord_order_cargo WHERE order_id=1", String.class)).isEqualTo("Offline cargo");
assertThatThrownBy(() -> edit(11, 1, 1, "edit")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> edit(11, 2, 0, "edit")).isInstanceOf(BizException.class);
var changed = json.valueToTree(draft); ((com.fasterxml.jackson.databind.node.ObjectNode) changed).put("customerRemark", "changed");
((com.fasterxml.jackson.databind.node.ObjectNode) changed).retain(Arrays.stream(OrderRequests.SaveRequest.class.getRecordComponents()).map(java.lang.reflect.RecordComponent::getName).toList());
var changedDraft = json.convertValue(changed, OrderRequests.SaveRequest.class);
assertThatThrownBy(() -> service.updateDraft(11, 7, 1, new OrderRequests.DraftUpdateRequest(0, changedDraft), "edit")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void staleEditWrongCustomerAndSubmittedDraftCannotChangeAnyTable() {
edit(11, 1, 0, "first");
var before = snapshot();
assertThatThrownBy(() -> edit(11, 1, 0, "stale")).isInstanceOf(BizException.class).hasMessageContaining("版本");
assertThatThrownBy(() -> edit(12, 1, 1, "first")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
db.update("UPDATE ord_order SET status='SUBMITTED' WHERE id=1");
before = snapshot();
assertThatThrownBy(() -> edit(11, 1, 1, "state")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void failureAfterReplacingChildrenRollsBackOrderChildrenHistoryAndCommand() {
db.execute("ALTER TABLE ord_order_status_history ADD CONSTRAINT fail_edit CHECK(action<>'UPDATE')");
var before = snapshot();
assertThatThrownBy(() -> edit(11, 1, 0, "retry-after-rollback")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
db.execute("ALTER TABLE ord_order_status_history DROP CONSTRAINT fail_edit");
edit(11, 1, 0, "retry-after-rollback");
assertThat(version(1)).isEqualTo(1);
}
@Test
void concurrentSameEditKeyBothSucceedWithOneHistoryAndOneVersion() throws Exception {
assertThat(concurrent(() -> edit(11, 1, 0, "same"), () -> edit(11, 1, 0, "same"))).containsExactly(true, true);
assertThat(version(1)).isEqualTo(1);
assertThat(count("ord_order_status_history")).isEqualTo(1);
assertThat(count("ord_idempotency_record")).isEqualTo(1);
}
@Test
void failedCommandCompletionRollsBackAllSuccessfulBusinessWrites() {
db.execute("ALTER TABLE ord_idempotency_record ADD CONSTRAINT fail_completion CHECK(status<>'COMPLETED')");
var before = snapshot();
assertThatThrownBy(() -> edit(11, 1, 0, "edit-rollback")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
assertThatThrownBy(() -> cancel(11, 1, 0, "cancel-rollback", "reason")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void concurrentDifferentEditKeysCannotOverwriteSameOriginalVersion() throws Exception {
assertThat(concurrent(() -> edit(11, 1, 0, "a"), () -> edit(11, 1, 0, "b"))).containsExactlyInAnyOrder(true, false);
assertThat(version(1)).isEqualTo(1);
assertThat(count("ord_order_status_history")).isEqualTo(1);
assertThat(count("ord_idempotency_record")).isEqualTo(1);
}
@Test
void cancelReplaysWithoutRepeatingHistoryOutboxOrVersionAndRejectsDifferentPayload() {
cancel(11, 1, 0, "cancel", "reason");
var before = snapshot();
cancel(11, 1, 0, "cancel", "reason");
assertThat(snapshot()).isEqualTo(before);
assertThat(status(1)).isEqualTo("CANCELLED");
assertThat(count("ord_outbox_event")).isEqualTo(1);
assertThat(db.queryForObject("SELECT operator_name FROM ord_order_status_history WHERE order_id=1", String.class)).isEqualTo("operator");
assertThat(db.queryForObject("SELECT reason FROM ord_order_status_history WHERE order_id=1", String.class)).isEqualTo("reason");
assertThat(db.queryForObject("SELECT occurred_at FROM ord_order_status_history WHERE order_id=1", java.sql.Timestamp.class)).isNotNull();
assertThatThrownBy(() -> cancel(11, 1, 0, "cancel", "other")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(11, 1, 1, "cancel", "reason")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(11, 2, 0, "cancel", "reason")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(11, 1, 1, "new-command", "reason")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void staleCancelAndCrossCustomerRejectWithoutLeavingCommandRecords() {
var before = snapshot();
assertThatThrownBy(() -> cancel(11, 1, 9, "stale", "reason")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(12, 1, 0, "other", "reason")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void sameKeyIsIsolatedByCustomerAndOperator() {
cancel(11, 1, 0, "shared", "reason");
order(2, 12, "DRAFT", "MASTER", null);
cancel(12, 2, 0, "shared", "reason");
order(3, 11, "DRAFT", "MASTER", null);
service.cancel(11, 8, "another", 3, new OrderRequests.CancelRequest(0, "reason"), "shared");
assertThat(count("ord_idempotency_record")).isEqualTo(3);
var before = snapshot();
assertThatThrownBy(() -> cancel(12, 1, 0, "cross-customer", "reason")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
@Test
void concurrentCancellationKeysSerializeAndOriginalKeyReplays() throws Exception {
assertThat(concurrent(() -> cancel(11, 1, 0, "same", "reason"), () -> cancel(11, 1, 0, "same", "reason"))).containsExactly(true, true);
assertThat(count("ord_outbox_event")).isEqualTo(1);
order(2, 11, "DRAFT", "MASTER", null);
assertThat(concurrent(() -> cancel(11, 2, 0, "a", "reason"), () -> cancel(11, 2, 0, "b", "reason"))).containsExactlyInAnyOrder(true, false);
assertThat(count("ord_outbox_event")).isEqualTo(2);
}
@Test
void cancellationAndFinalRootAggregationRollbackTogetherAndRetryOnce() {
db.update("UPDATE ord_order SET status='PROCESSING' WHERE id=1");
order(2, 11, "APPROVED", "CHILD", 1L);
db.update("INSERT INTO ord_order_status_history(id,tenant_id,order_id,action,occurred_at) VALUES(301,11,1,'OVERSEAS_DELIVERED',TIMESTAMP '2026-10-02 01:00:00')");
db.execute("ALTER TABLE ord_outbox_event ADD CONSTRAINT fail_root CHECK(event_type<>'FIRST_LEG_ORDER_FULFILLMENT_COMPLETED')");
var before = snapshot();
assertThatThrownBy(() -> cancel(11, 2, 0, "last-child", "reason")).isInstanceOf(RuntimeException.class);
assertThat(snapshot()).isEqualTo(before);
db.execute("ALTER TABLE ord_outbox_event DROP CONSTRAINT fail_root");
cancel(11, 2, 0, "last-child", "reason");
assertThat(status(1)).isEqualTo("COMPLETED");
assertThat(status(2)).isEqualTo("CANCELLED");
assertThat(count("ord_outbox_event")).isEqualTo(2);
before = snapshot();
cancel(11, 2, 0, "last-child", "reason");
assertThat(snapshot()).isEqualTo(before);
}
@Test
void missingVersionKeyOrDraftCannotReachPersistence() {
var before = snapshot();
assertThatThrownBy(() -> service.updateDraft(11, 7, 1, new OrderRequests.DraftUpdateRequest(null, draft), "x")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.updateDraft(11, 7, 1, new OrderRequests.DraftUpdateRequest(0, null), "x")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> edit(11, 1, 0, " ")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> service.cancel(11, 7, "operator", 1, new OrderRequests.CancelRequest(-1, "reason"), "x")).isInstanceOf(BizException.class);
assertThatThrownBy(() -> cancel(11, 1, 0, null, "reason")).isInstanceOf(BizException.class);
assertThat(snapshot()).isEqualTo(before);
}
private void edit(long customer, long id, int version, String key) {
service.updateDraft(customer, 7, id, new OrderRequests.DraftUpdateRequest(version, draft), key);
}
private void cancel(long customer, long id, int version, String key, String reason) {
service.cancel(customer, 7, "operator", id, new OrderRequests.CancelRequest(version, reason), key);
}
private int version(long id) { return db.queryForObject("SELECT version FROM ord_order WHERE id=?", Integer.class, id); }
private String status(long id) { return db.queryForObject("SELECT status FROM ord_order WHERE id=?", String.class, id); }
private int count(String table) { return db.queryForObject("SELECT COUNT(*) FROM " + table, Integer.class); }
private List<Object> snapshot() {
return List.of("ord_order", "ord_order_cargo", "ord_order_party", "ord_order_status_history", "ord_outbox_event", "ord_idempotency_record")
.stream().map(table -> (Object) db.queryForList("SELECT * FROM " + table + " ORDER BY id")).toList();
}
private void order(long id, long customer, String status, String role, Long parent) {
db.update("INSERT INTO ord_order(id,tenant_id,customer_id,order_no,inbound_code,status,order_role,parent_order_id,root_order_id,version,deleted) VALUES(?,?,?,?,?,?,?,?,?,0,FALSE)",
id, customer, customer, "OFFLINE-" + id, "OFFLINE-IN-" + id, status, role, parent, parent == null ? id : parent);
}
private List<Boolean> concurrent(Runnable first, Runnable second) throws Exception {
var workers = Executors.newFixedThreadPool(2);
var start = new CountDownLatch(1);
try {
List<Future<Boolean>> results = new ArrayList<>();
for (Runnable work : List.of(first, second)) results.add(workers.submit(() -> {
start.await();
try { work.run(); return true; } catch (BizException conflict) { return false; }
}));
start.countDown();
return List.of(results.get(0).get(15, TimeUnit.SECONDS), results.get(1).get(15, TimeUnit.SECONDS));
} finally { workers.shutdownNow(); }
}
@SuppressWarnings("unchecked")
private <T> T proxy(T target, DataSourceTransactionManager manager) {
ProxyFactory factory = new ProxyFactory(target); factory.setProxyTargetClass(true);
factory.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource()));
return (T) factory.getProxy();
}
private void table(Class<?> type) {
List<String> columns = new ArrayList<>();
for (Class<?> current = type; current != Object.class; current = current.getSuperclass()) {
for (var field : current.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue;
TableField mapping = field.getAnnotation(TableField.class);
if (mapping != null && !mapping.exist()) continue;
String name = mapping != null && !mapping.value().isEmpty() ? mapping.value()
: field.getName().replaceAll("([a-z0-9])([A-Z])", "$1_$2").toLowerCase(Locale.ROOT);
Class<?> value = field.getType();
String sql = value == Long.class ? "BIGINT" : value == Integer.class ? "INT" : value == Boolean.class ? "BOOLEAN"
: value == BigDecimal.class ? "DECIMAL(24,6)" : value == LocalDateTime.class ? "TIMESTAMP"
: value == LocalDate.class ? "DATE" : "VARCHAR(16000)";
columns.add(name + " " + sql + (name.equals("id") ? " PRIMARY KEY" : name.equals("version") ? " DEFAULT 0" : name.equals("deleted") ? " DEFAULT FALSE" : ""));
}
}
db.execute("CREATE TABLE " + type.getAnnotation(TableName.class).value() + " (" + String.join(",", columns) + ")");
}
}
@@ -220,6 +220,7 @@ class OrderApplicationServiceStateTest {
OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class);
OrderRequests.SaveRequest request = request();
OrderEntity order = new OrderEntity();
order.setVersion(0);
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(OrderStatus.DRAFT);
@@ -227,7 +228,7 @@ class OrderApplicationServiceStateTest {
when(masterData.validate(10001L, request)).thenReturn(snapshots());
when(repository.saveOrder(10001L, order)).thenReturn(1);
service(repository, eligibility, masterData).updateDraft(10001L, 2L, 1L, request);
service(repository, eligibility, masterData).updateDraft(10001L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, request), "edit-key");
verify(eligibility, never()).requireApproved(anyLong());
verify(repository).replaceDraftChildren(10001L, 1L);
@@ -247,7 +248,7 @@ class OrderApplicationServiceStateTest {
OrderApplicationService service = service(repository, mock(OrderEligibilityGuard.class), masterData);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
service.updateDraft(10001L, 2L, 1L, request);
service.updateDraft(10001L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, request), "edit-key");
var ordered = inOrder(pricing, repository);
ordered.verify(pricing).requireAvailable(10001L, 8L, "CH", "OVERSEAS");
@@ -261,7 +262,7 @@ class OrderApplicationServiceStateTest {
when(repository.findOwned(10001L, 1L)).thenReturn(order);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), mock(OrderMasterDataGuard.class))
.updateDraft(10001L, 2L, 1L, requestWithCargoQuantities(5, 24, 119)))
.updateDraft(10001L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, requestWithCargoQuantities(5, 24, 119)), "edit-key"))
.isInstanceOf(BizException.class)
.hasMessage("第 1 条货物总数量应等于每箱数量 × 箱数(120)")
.extracting(exception -> ((BizException) exception).getResultCode())
@@ -693,6 +694,7 @@ class OrderApplicationServiceStateTest {
void merchantDraftEditorStillCannotEditSubmittedOrder() {
OrderRepository repository = mock(OrderRepository.class);
OrderEntity order = new OrderEntity();
order.setVersion(0);
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(OrderStatus.SUBMITTED);
@@ -701,7 +703,7 @@ class OrderApplicationServiceStateTest {
when(masterData.validate(eq(10001L), any(OrderRequests.SaveRequest.class))).thenReturn(snapshots());
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class), masterData)
.updateDraft(10001L, 2L, 1L, request()))
.updateDraft(10001L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, request()), "edit-key"))
.isInstanceOf(BizException.class)
.hasMessage("当前订单状态不允许编辑订单资料")
.extracting(exception -> ((BizException) exception).getResultCode())
@@ -890,7 +892,7 @@ class OrderApplicationServiceStateTest {
OrderRepository repository = mock(OrderRepository.class);
assertThatThrownBy(() -> service(repository, mock(OrderEligibilityGuard.class),
mock(OrderMasterDataGuard.class)).updateDraft(20002L, 2L, 1L, request()))
mock(OrderMasterDataGuard.class)).updateDraft(20002L, 2L, 1L, new OrderRequests.DraftUpdateRequest(0, request()), "edit-key"))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.NOT_FOUND);
@@ -962,6 +964,7 @@ class OrderApplicationServiceStateTest {
OrderPricingChannelGuard pricing = mock(OrderPricingChannelGuard.class);
when(pricing.requireAvailable(anyLong(), anyLong(), anyString(), anyString())).thenReturn("PCHWK");
when(repository.nextInboundCodeSequence(any(LocalDate.class))).thenReturn(1L);
when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1);
ReflectionTestUtils.setField(service, "pricingChannelGuard", pricing);
ReflectionTestUtils.setField(service, "productImageGuard", mock(ProductImageGuard.class));
useInboundCodeMode(service, InboundCodeModeProvider.Mode.AUTO);
@@ -1127,6 +1130,7 @@ class OrderApplicationServiceStateTest {
private OrderEntity persistedOrder(OrderStatus status) {
OrderEntity order = new OrderEntity();
order.setVersion(0);
order.setId(1L);
order.setTenantId(10001L);
order.setStatus(status);
+1 -1
View File
@@ -170,7 +170,7 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜
| 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 |
|---|---|---|---|
| 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、海外排车/出库/派送/异常/回访、财务、通知、统计等模块 |
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、权限和站内消息数据 | 注册/审核、企业与个人资料、客户隔离、订单、文件、客户侧权限、消息已读与账号安全;未完成注册的同凭据重放须复用账号/客户/成员身份,不同凭据重复用户名须冲突;测量/计费继续使用下列专项入口 |
| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、权限和站内消息数据,并保留 Excel 源文件及错误报告 | 注册/审核、企业与个人资料、客户隔离、订单、文件、客户侧权限、消息已读与账号安全;Merchant 草稿编辑/取消使用冻结版本与幂等命令,拒绝及重放核对完整订单快照;实际中文 XLSX 验证 PARTIAL、ATOMIC 中途冲突回滚及原键恢复重试,JSON 批量草稿不能替代此项。未完成注册的同凭据重放须复用账号/客户/成员身份,不同凭据重复用户名须冲突;测量/计费继续使用下列专项入口 |
| Client 快速下单定向 HTTP 验收 | `scripts/client-quick-order-test-env-acceptance.mjs` | 使用 `MERCHANT_TEST_USERNAME/PASSWORD` 对应客户创建快速下单草稿 | 真实调用 `/api/merchant/v1/orders/first-leg/batch`,覆盖 FBA、多产品、海外仓、多个私人地址、地址归属失败、渠道失败、重复行、逐行隔离、失败重试、详情归属、幂等重放与冲突;只创建草稿,不提交、不审核 |
| 固定客户批量创建待审核订单 | `scripts/create-pending-review-orders.mjs` | 只使用 `MERCHANT_TEST_USERNAME/PASSWORD` 配置的客户账号创建指定数量订单 | 使用 `ADMIN_TEST_GATEWAY`,选择真实可计价仓库/渠道,创建并提交订单,停在待审核,不创建客户、不审核订单 |
| Client 入库测量与计费专项 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 入库测量与计费客户入库、测量、费用和权限专项场景;不是替代 Client 全量入口 |
@@ -1,6 +1,9 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import vm from 'node:vm'
import { createHash } from 'node:crypto'
import { inflateRawSync, deflateRawSync } from 'node:zlib'
const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8')
@@ -11,3 +14,265 @@ test('client registration distinguishes incomplete-registration replay from cred
assert.match(source, /duplicate username with different credentials is rejected/)
assert.match(source, /body: \{ \.\.\.registrationBody, password: `Different/)
})
const helperSource = source.slice(source.indexOf('// BEGIN ORDER ACCEPTANCE HELPERS'), source.indexOf('// END ORDER ACCEPTANCE HELPERS'))
const workflowSource = source.slice(source.indexOf('async function ownedOrderSnapshot('), source.indexOf('async function apiStep('))
function runtime(extra = {}) {
const context = vm.createContext({ Buffer, createHash, inflateRawSync, structuredClone, Blob, FormData,
assertEqual: (actual, expected, label) => assert.equal(actual, expected, label),
assertTrue: (value, label) => assert.ok(value, label),
assertPresent: (value, label) => assert.ok(value !== null && value !== undefined && value !== '', label),
requireSuccess: json => assert.equal(String(json?.code), '200'), ...extra })
vm.runInContext(`${helperSource}\n${workflowSource}`, context)
return context
}
const plain = value => JSON.parse(JSON.stringify(value))
test('frozen edit command preserves original version, key and nested payload through retries', () => {
const r = runtime(), snapshot = { order: { version: 7 } }, payload = { draft: { cargoes: [{ skuNo: 'ORIGINAL' }] } }
const command = r.orderMutationCommand(snapshot, payload, 'original-key')
snapshot.order.version = 99; payload.draft.cargoes[0].skuNo = 'CHANGED'
assert.deepEqual(plain(command), { headers: { 'Idempotency-Key': 'original-key' }, body: { expectedVersion: 7, draft: { cargoes: [{ skuNo: 'ORIGINAL' }] } } })
assert.ok(Object.isFrozen(command.body.draft.cargoes[0]))
assert.throws(() => r.orderMutationCommand({ order: {} }, payload, 'key'), /version/)
assert.throws(() => r.orderMutationCommand(snapshot, payload, ''), /key/)
})
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' }] }
r.assertOrderUnchanged(before, structuredClone(before))
for (const field of Object.keys(before)) {
const changed = structuredClone(before)
if (field === 'order') changed.order.version++
else changed[field].push({ id: 'new' })
assert.throws(() => r.assertOrderUnchanged(before, changed), /unchanged/)
}
assert.throws(() => r.assertOrderUnchanged(before, { ...before, statusHistory: [...before.statusHistory].reverse() }), /unchanged/)
r.assertOrderUnchanged({ b: 2, a: 1 }, { a: 1, b: 2 })
})
test('partial import validates physical row, exact counts, failure details and identities', () => {
const r = runtime(), data = { mode: 'PARTIAL', batchNo: 'batch', sourceFileId: 'f', errorReportFileId: 'e', result: {
total: 2, succeeded: 1, failed: 1, items: [
{ index: 0, clientRowId: 'excel-row-3', success: false, orderId: null, errorCode: 'A0422', errorMessage: 'cargo required' },
{ index: 1, clientRowId: 'excel-row-4', success: true, orderId: '99', orderNo: 'FL99' },
],
} }
r.assertImportResult(data, 'PARTIAL', [false, true])
for (const mutate of [
value => value.result.succeeded++, value => value.result.items[0].clientRowId = 'excel-row-2',
value => value.result.items[0].orderId = 'unexpected', value => value.result.items[0].errorCode = 'S0500',
value => value.result.items[1].orderNo = '', value => value.errorReportFileId = null,
]) {
const changed = structuredClone(data); mutate(changed)
assert.throws(() => r.assertImportResult(changed, 'PARTIAL', [false, true]))
}
})
function templateFixture(r) {
return new Map([
['xl/workbook.xml', '<workbook><sheets><sheet name="订单导入"/><sheet name="选项数据"/></sheets></workbook>'],
['xl/sharedStrings.xml', '<sst><si><t>运输方式 *</t></si><si><t>客户备注</t></si><si><t>仓库|CODE</t></si></sst>'],
['xl/worksheets/sheet1.xml', '<worksheet><sheetData><row r="1"><c r="A1" t="inlineStr"><is><t>订单基础</t></is></c></row><row r="2"><c r="A2" t="s"><v>0</v></c><c r="B2"/><c r="AE2" t="s"><v>1</v></c></row><row r="3"><c r="A3" t="inlineStr"><is><t>old data</t></is></c></row></sheetData><dataValidations count="1"/></worksheet>'],
['xl/worksheets/sheet2.xml', '<worksheet><sheetData><row r="1"><c r="A1" t="s"><v>2</v></c></row></sheetData></worksheet>'],
])
}
test('XLSX mutation preserves downloaded headers/options and writes 31 columns past Z', () => {
const r = runtime(), template = templateFixture(r), row = Array.from({ length: 31 }, (_, i) => `column-${i}`)
row[30] = '备注<&"\'中文'
const bytes = r.orderWorkbookRows(template, [row]), parsed = r.orderZipRead(bytes), rows = r.orderSheetRows(parsed)
assert.equal(parsed.get('xl/worksheets/sheet2.xml'), template.get('xl/worksheets/sheet2.xml'))
assert.match(parsed.get('xl/worksheets/sheet1.xml'), /dataValidations/)
assert.deepEqual(plain(rows.find(item => item.number === 2).cells.map(cell => cell.text)), ['运输方式 *', '', '客户备注'])
assert.equal(rows[2].cells[30].ref, 'AE'); assert.equal(rows[2].cells[30].text, row[30])
assert.doesNotMatch(parsed.get('xl/worksheets/sheet1.xml'), /old data/)
assert.deepEqual(plain(r.orderSheetRows(parsed, 'xl/worksheets/sheet2.xml')[0].cells), [{ ref: 'A', text: '仓库|CODE' }])
const corrupted = Buffer.from(bytes); corrupted[80] ^= 1
assert.throws(() => r.orderZipRead(corrupted))
assert.throws(() => r.orderZipRead(Buffer.from('not zip')))
})
test('ZIP reader accepts independently compressed entries and rejects damaged checksums', () => {
const r = runtime(), data = Buffer.from('<worksheet>中文</worksheet>'), name = Buffer.from('xl/worksheets/sheet1.xml')
const compressed = deflateRawSync(data)
let checksum = 0xffffffff
for (const byte of data) { checksum ^= byte; for (let i = 0; i < 8; i++) checksum = checksum & 1 ? 0xedb88320 ^ (checksum >>> 1) : checksum >>> 1 }
checksum = (checksum ^ 0xffffffff) >>> 0
const local = Buffer.alloc(30); local.writeUInt32LE(0x04034b50); local.writeUInt16LE(8, 8); local.writeUInt16LE(name.length, 26)
const central = Buffer.alloc(46); central.writeUInt32LE(0x02014b50); central.writeUInt16LE(8, 10)
central.writeUInt32LE(checksum, 16); central.writeUInt32LE(compressed.length, 20); central.writeUInt32LE(data.length, 24); central.writeUInt16LE(name.length, 28)
const end = Buffer.alloc(22); end.writeUInt32LE(0x06054b50); end.writeUInt16LE(1, 10); end.writeUInt32LE(local.length + name.length + compressed.length, 16)
const archive = Buffer.concat([local, name, compressed, central, name, end])
assert.equal(r.orderZipRead(archive).get(name.toString()), data.toString())
central.writeUInt32LE((checksum ^ 1) >>> 0, 16)
assert.throws(() => r.orderZipRead(Buffer.concat([local, name, compressed, central, name, end])), /checksum/)
})
test('rollback plan changes only row 3 validity and row 4 hash without moving physical rows', () => {
const r = runtime(), first = Array.from({ length: 31 }, (_, index) => `first-${index}`), second = Array.from({ length: 31 }, (_, index) => `second-${index}`)
const plan = r.importRollbackRows(first, second)
assert.equal(plan.seed[0][10], ''); assert.deepEqual(plain(plan.seed[1]), second)
assert.deepEqual(plain(plan.conflict[0]), first)
assert.deepEqual(plain(plan.conflict[1].slice(0, 30)), second.slice(0, 30))
assert.notEqual(plan.conflict[1][30], second[30]); assert.deepEqual(plain(plan.retry), [first, second])
const rows = r.orderSheetRows(r.orderZipRead(r.orderWorkbookRows(templateFixture(r), plan.conflict)))
assert.deepEqual(plain(rows.slice(2).map(row => row.number)), [3, 4])
})
test('actual Java path derives row keys without mode and joins both writes to the atomic transaction', async () => {
const service = await readFile(new URL('../order/src/main/java/com/baiyue/order/service/MerchantBatchDraftService.java', import.meta.url), 'utf8')
const item = await readFile(new URL('../order/src/main/java/com/baiyue/order/service/MerchantBatchDraftItemService.java', import.meta.url), 'utf8')
const orders = await readFile(new URL('../order/src/main/java/com/baiyue/order/service/OrderApplicationService.java', import.meta.url), 'utf8')
assert.match(service, /batchKey\.trim\(\) \+ "\\n" \+ rowId/)
assert.match(service, /@Transactional\s+public MerchantBatchDraftDtos.Response createAtomic/)
assert.match(service, /itemService.createJoined\(tenantId, userId, username, item.draft\(\),\s*derivedKey\(batchKey, item.clientRowId\(\)\)\)/)
assert.match(item, /@Transactional\(propagation = Propagation.MANDATORY\)/)
assert.match(orders, /requestHash\("CREATE_DRAFT", request\)/)
})
function mutationHarness(fault = '') {
let nextId = 100, nextKey = 0, nextHistory = 0
const draft = { customerRemark: 'original', cargoes: [{ skuNo: 'SKU', packageCount: 2, model: 'M', productImageFileId: 'img' }] }
const make = (id, status, body) => ({ order: { id, orderNo: `FL${id}`, inboundCode: null, tenantId: 'tenant', customerId: 'customer', version: 0, status, customerRemark: body.customerRemark },
cargoes: structuredClone(body.cargoes), parties: [], internalNotes: [], statusHistory: [] })
const orders = new Map([['submitted', make('submitted', 'SUBMITTED', draft)]]), commands = new Map(), requests = [], report = { resources: { orderIds: [] } }
const r = runtime({ merchantToken: 'owner', existingClientToken: 'other', marker: 'local-contract', report,
orderAcceptanceFixture: { payload: draft, orderId: 'submitted' }, newIdempotencyKey: () => `key-${++nextKey}`,
apiStep: async (module, name, request, validate) => {
requests.push({ name, request: structuredClone(request) })
const id = request.path.split('/')[6], current = orders.get(id)
let code = '200', data = null
if (!request.method || request.method === 'GET') data = structuredClone(current)
else if (request.path.endsWith('/first-leg')) {
data = String(nextId++); orders.set(data, make(data, 'DRAFT', request.body))
} else if (request.token === 'other') code = 'A0404'
else if (request.path.endsWith('/submit')) code = 'A0409'
else {
const key = request.headers?.['Idempotency-Key'], payload = request.body
const signature = JSON.stringify(payload), stored = commands.get(key)
if (!key) code = 'A0400'
else if (payload.expectedVersion == null || (request.method === 'POST' && !payload.reason?.trim()) || payload.draft?.cargoes[0].productImageFileId === -1) code = 'A0422'
else if (stored) {
code = stored === signature ? '200' : 'A0409'
if (fault === 'replay-write' && code === '200') current.order.version++
} else if (payload.expectedVersion !== current.order.version || current.order.status !== 'DRAFT') code = 'A0409'
else {
commands.set(key, signature); current.order.version++
const cancelled = request.method === 'POST'
if (cancelled) current.order.status = 'CANCELLED'
else { current.order.customerRemark = payload.draft.customerRemark; current.cargoes = structuredClone(payload.draft.cargoes) }
current.statusHistory.push({ id: `h${++nextHistory}`, action: cancelled ? 'CANCELLED' : 'UPDATE', fromStatus: 'DRAFT', toStatus: current.order.status,
reason: payload.reason ?? null, operatorId: 'owner', occurredAt: '2026-10-02T12:00:00' })
}
}
if (fault === 'rejection-drift' && code !== '200' && current) current.parties.push({ id: 'unexpected' })
const response = { status: 200, json: { code, data } }; validate(response); return response
},
})
return { r, requests, report, orders }
}
test('F06 actual orchestration covers frozen success replay, stale versions, missing protocol and isolation', async () => {
const { r, requests, report } = mutationHarness()
await r.orderMutationEvidenceModule()
const firstEdit = requests.find(item => item.name === 'versioned draft edit succeeds').request
const editReplay = requests.find(item => item.name === 'original edit key and frozen payload replay successfully').request
assert.deepEqual(editReplay, firstEdit)
const firstCancel = requests.find(item => item.name === 'cancel owned draft with reason').request
const cancelReplay = requests.find(item => item.name === 'original cancellation key payload and version replay successfully').request
assert.deepEqual(cancelReplay, firstCancel)
const newCancel = requests.find(item => item.name === 'repeat cancellation cannot add history').request
assert.notEqual(newCancel.headers['Idempotency-Key'], firstCancel.headers['Idempotency-Key'])
assert.ok(newCancel.body.expectedVersion > firstCancel.body.expectedVersion)
assert.ok(report.orderMutationRequests.every(item => !('token' in item)))
assert.ok(requests.some(item => item.name === 'edit missing version is rejected'))
assert.ok(requests.some(item => item.name === 'cancel missing key is rejected'))
})
for (const fault of ['replay-write', 'rejection-drift']) test(`F06 refuses false success when ${fault}`, async () => {
const { r } = mutationHarness(fault)
await assert.rejects(() => r.orderMutationEvidenceModule(), /unchanged/)
})
test('full order collection detects leaked rows, duplicated pages and truncated results', async () => {
for (const records of [[{ orderId: 'expected' }, { orderId: 'leaked' }], [{ orderId: 'expected' }, { orderId: 'expected' }]]) {
const r = runtime({ merchantToken: 'owner', apiStep: async (_module, _name, _request, validate) => {
validate({ json: { code: '200', data: { page: 1, total: 2, records } } })
} })
await assert.rejects(() => r.importOrderSet('rollback evidence', ['expected']))
}
const r = runtime({ merchantToken: 'owner', apiStep: async (_module, _name, _request, validate) => {
validate({ json: { code: '200', data: { page: 1, total: 201, records: [{ orderId: 'only-one' }] } } })
} })
await assert.rejects(() => r.importOrderSet('complete pagination'), /intermediate/)
})
function importHarness(fault = '') {
const report = { resources: { orderIds: [], tenantId: 'tenant' } }, orders = new Map(), requests = []
const masterData = { receiving: { id: 'domestic', warehouseName: '国内仓', warehouseCode: 'CN' },
destination: { id: 'fba', warehouseName: '目的仓', warehouseCode: 'US' }, channel: { channelName: '海运渠道', channelCode: 'SEA' } }
let nextKey = 0, r
const resultItem = (index, success, orderId) => ({ index, clientRowId: `excel-row-${index + 3}`, success,
orderId: success ? orderId : null, orderNo: success ? `FL${orderId}` : null,
errorCode: success ? null : 'A0422', errorMessage: success ? null : 'cargo required' })
const responseData = (mode, items) => ({ mode, batchNo: 'batch', sourceFileId: 'source-file', errorReportFileId: 'error-file',
result: { total: items.length, succeeded: items.filter(item => item.success).length, failed: items.filter(item => !item.success).length, items } })
const addDraft = (id, cells) => orders.set(id, { order: { id, orderNo: `FL${id}`, version: 0, status: 'DRAFT', tenantId: 'tenant',
customerRemark: cells[30], referenceId: cells[27], receivingWarehouseId: 'domestic', destinationWarehouseId: 'fba', channelCode: 'SEA' },
cargoes: [{ skuNo: cells[18], packageCount: 2 }], parties: [], internalNotes: [], statusHistory: [] })
const cells = (values, row) => values.map((value, index) => `<c r="${r.orderColumn(index)}${row}" t="inlineStr"><is><t>${r.orderXmlEncode(value)}</t></is></c>`).join('')
r = runtime({ report, marker: 'contract', merchantToken: 'owner', orderAcceptanceFixture: { masterData, payload: { pricingTaxMode: 'TAX_INCLUDED' } },
newIdempotencyKey: () => `key-${++nextKey}`,
apiStep: async (_module, name, request, validate) => {
requests.push({ name, path: request.path, key: request.headers?.['Idempotency-Key'] })
let code = '200', data = null, bytes = null
if (request.path.endsWith('/template')) {
const template = templateFixture(r)
const headers = Array.from({ length: 31 }, (_, index) => index === 0 ? '运输方式 *' : index === 30 ? '客户备注' : `字段${index}`)
template.set('xl/worksheets/sheet1.xml', `<worksheet><sheetData><row r="1">${cells(['订单基础'], 1)}</row><row r="2">${cells(headers, 2)}</row></sheetData></worksheet>`)
template.set('xl/worksheets/sheet2.xml', `<worksheet><sheetData><row r="1">${cells(['国内仓|CN', '目的仓|US', '海运渠道|SEA'], 1)}</row></sheetData></worksheet>`)
bytes = r.orderZipWrite(template)
} else if (request.path.includes('/error-reports/')) {
bytes = r.orderZipWrite(new Map([['xl/worksheets/sheet1.xml', `<worksheet><sheetData><row r="1">${cells(['Excel行号', '错误代码', '错误说明'], 1)}</row><row r="2">${cells(['3', 'A0422', 'cargo required'], 2)}</row></sheetData></worksheet>`]]))
} else if (request.method === 'POST') {
const file = request.body.get('file'), body = Buffer.from(await file.arrayBuffer())
if (name.startsWith('PARTIAL')) {
const parsed = r.orderSheetRows(r.orderZipRead(body)).slice(2).map(row => row.cells.map(cell => cell.text))
if (!orders.has('second')) addDraft('second', parsed[1])
data = responseData('PARTIAL', [resultItem(0, false), resultItem(1, true, 'second')])
} else if (name.startsWith('ATOMIC retry')) {
const parsed = r.orderSheetRows(r.orderZipRead(body)).slice(2).map(row => row.cells.map(cell => cell.text))
addDraft('first', parsed[0]); data = responseData('ATOMIC', [resultItem(0, true, 'first'), resultItem(1, true, 'second')])
} else if (name.startsWith('ATOMIC later')) {
code = 'A0409'
if (fault === 'leaked-first-row') {
const parsed = r.orderSheetRows(r.orderZipRead(body)).slice(2).map(row => row.cells.map(cell => cell.text))
addDraft('leaked-first', parsed[0])
}
if (fault === 'changed-existing-row') orders.get('second').order.customerRemark = 'incorrectly-overwritten'
} else code = 'A0422'
} else if (request.path.includes('?page=')) {
data = { page: 1, total: orders.size, records: [...orders.keys()].map(orderId => ({ orderId })) }
} else data = structuredClone(orders.get(request.path.split('/').at(-1)))
const response = { status: 200, headers: new Map([['content-type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']]), bytes, json: { code, data } }
validate(response); return response
},
})
return { r, report, requests }
}
test('F04 runs actual import orchestration with the same seed/conflict/retry key and records limitations', async () => {
const { r, report, requests } = importHarness()
await r.orderExcelImportModule()
const keys = requests.filter(item => item.key && /PARTIAL preserves|PARTIAL same key|ATOMIC later|ATOMIC retry/.test(item.name)).map(item => item.key)
assert.equal(keys.length, 4); assert.equal(new Set(keys).size, 1)
assert.deepEqual(report.resources.orderIds, ['second', 'first'])
assert.ok(report.orderImportLimitations.some(item => item.includes('remote source uploads')))
assert.ok(requests.some(item => item.name === 'forged option adds no orders: complete order page 1'))
})
for (const fault of ['leaked-first-row', 'changed-existing-row']) test(`F04 rejects apparent ATOMIC failure with ${fault}`, async () => {
const { r } = importHarness(fault)
await assert.rejects(() => r.orderExcelImportModule(), /unchanged/)
})
+468 -5
View File
@@ -13,9 +13,10 @@
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { randomUUID } from 'node:crypto'
import { randomUUID, createHash } from 'node:crypto'
import { resolve } from 'node:path'
import { createInterface } from 'node:readline/promises'
import { inflateRawSync } from 'node:zlib'
const args = parseArgs(process.argv.slice(2))
if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options')
@@ -84,6 +85,7 @@ let merchantToken
let merchantRefreshToken
let platformToken
let existingClientToken
let orderAcceptanceFixture
let ownerPasswordNeedsRestore = false
let temporaryOwnerPassword
@@ -97,6 +99,8 @@ try {
await rbacAndMemberModule()
await orderLifecycleModule()
await isolationModule()
await orderMutationEvidenceModule()
await orderExcelImportModule()
await notificationInboxModule()
await accountSecurityModule()
report.success = report.steps.every((step) => step.passed)
@@ -1211,8 +1215,11 @@ async function orderLifecycleModule() {
editedPayload.customerRemark = `edited-${marker}`
editedPayload.cargoes[0].cargoRemark = `edited-cargo-${marker}`
editedPayload.cargoes[0].model = `MODEL-EDIT-${marker}`.slice(0, 64)
const editBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze original draft edit version', orderId)
const editCommand = orderMutationCommand(editBefore, { draft: editedPayload }, newIdempotencyKey('edit-draft'))
recordOrderMutationRequest('initial lifecycle edit', { method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, ...editCommand }, editBefore)
await apiStep('order-lifecycle', 'edit draft', {
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload,
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, ...editCommand,
}, ({ json }) => requireSuccess(json), () => ({ orderId, customerRemark: editedPayload.customerRemark }))
const detail = await apiStep('order-lifecycle', 'order detail preserves complete product fields', {
@@ -1231,6 +1238,7 @@ async function orderLifecycleModule() {
cargo: cargoEvidence(json.data.cargoes[0]),
}))
const orderNo = detail.json.data.order.orderNo
orderAcceptanceFixture = { masterData, payload: editedPayload, orderId }
await apiStep('order-lifecycle', 'order list contains edited draft', {
method: 'GET', path: `/api/merchant/v1/orders/first-leg?page=1&size=50&orderNo=${encodeURIComponent(orderNo)}`, token: merchantToken,
@@ -1321,7 +1329,9 @@ async function orderLifecycleModule() {
}, ({ json }) => ({ orderId: 1, rejectionCode: json?.code }))
await softApiStep('order-boundary', 'submitted order cannot be edited by client', {
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken, body: editedPayload,
method: 'PUT', path: `/api/merchant/v1/orders/first-leg/${orderId}`, token: merchantToken,
...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze submitted edit attempt', orderId),
{ draft: editedPayload }, newIdempotencyKey('submitted-edit')),
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0409', 'business code')
@@ -1356,10 +1366,13 @@ async function orderLifecycleModule() {
}, ({ json }) => ({ orderId: json.data }))
const cancelOrderId = cancelDraft.json.data
report.resources.orderIds.push(cancelOrderId)
const cancelBefore = await ownedOrderSnapshot('order-lifecycle', 'freeze cancellation version', cancelOrderId)
const cancelCommand = orderMutationCommand(cancelBefore, { reason: `客户端自动化取消 ${marker}` }, newIdempotencyKey('cancel-draft'))
recordOrderMutationRequest('initial lifecycle cancellation', { method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, ...cancelCommand }, cancelBefore)
await apiStep('order-lifecycle', 'cancel second draft', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken,
body: { reason: `客户端自动化取消 ${marker}` },
...cancelCommand,
}, ({ json }) => requireSuccess(json), () => ({ orderId: cancelOrderId }))
await apiStep('order-lifecycle', 'cancelled status is persisted', {
@@ -1371,7 +1384,8 @@ async function orderLifecycleModule() {
await softApiStep('order-boundary', 'cancelled order cannot be cancelled again', {
method: 'POST', path: `/api/merchant/v1/orders/first-leg/${cancelOrderId}/cancel`, token: merchantToken,
body: { reason: `重复取消 ${marker}` },
...orderMutationCommand(await ownedOrderSnapshot('order-boundary', 'freeze repeat cancellation attempt', cancelOrderId),
{ reason: `重复取消 ${marker}` }, newIdempotencyKey('repeat-cancel')),
}, ({ json, status }) => {
assertEqual(status, 200, 'HTTP status')
assertEqual(String(json?.code), 'A0409', 'business code')
@@ -1992,6 +2006,455 @@ function buildOrderPayload(masterData, productImageFileId, suffix, remark) {
}
}
// BEGIN ORDER ACCEPTANCE HELPERS — loaded without CLI side effects by the direct contract.
function orderCanonical(value) {
if (Array.isArray(value)) return value.map(orderCanonical)
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, orderCanonical(value[key])]))
return value
}
function orderDigest(value) { return createHash('sha256').update(JSON.stringify(orderCanonical(value))).digest('hex') }
function assertOrderUnchanged(before, after) {
assertEqual(orderDigest(after), orderDigest(before), 'order, cargo, parties, notes and history must remain unchanged')
}
function assertOrderDetail(data, id) {
assertEqual(String(data?.order?.id), String(id), 'owned order identity')
for (const field of ['cargoes', 'parties', 'internalNotes', 'statusHistory']) assertTrue(Array.isArray(data?.[field]), `complete ${field} snapshot required`)
assertPresent(data.order.version, 'order version')
}
function orderMutationCommand(snapshot, payload, key) {
assertTrue(Number.isInteger(snapshot?.order?.version) && snapshot.order.version >= 0, 'frozen order version required')
assertTrue(typeof key === 'string' && key.length > 0, 'frozen idempotency key required')
const command = structuredClone({ headers: { 'Idempotency-Key': key }, body: { ...payload, expectedVersion: snapshot.order.version } })
const freeze = value => { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value) } }
freeze(command)
return command
}
function assertImportResult(data, mode, expected) {
assertEqual(data?.mode, mode, 'import mode')
assertPresent(data?.batchNo, 'batch number')
assertPresent(data?.sourceFileId, 'source file')
const result = data?.result
assertEqual(result?.total, expected.length, 'import total')
assertEqual(result?.succeeded, expected.filter(Boolean).length, 'import succeeded')
assertEqual(result?.failed, expected.filter(value => !value).length, 'import failed')
assertEqual(result?.items?.length, expected.length, 'import item count')
result.items.forEach((item, index) => {
assertEqual(item.index, index, 'result index')
assertEqual(item.clientRowId, `excel-row-${index + 3}`, 'physical Excel row')
assertEqual(item.success, expected[index], 'row success')
if (item.success) { assertPresent(item.orderId, 'created order ID'); assertPresent(item.orderNo, 'created order number') }
else {
assertEqual(item.orderId, null, 'failed row has no order')
assertEqual(item.errorCode, 'A0422', 'invalid row code')
assertPresent(item.errorMessage, 'row error reason')
}
})
if (result.failed) assertPresent(data.errorReportFileId, 'downloadable error report')
}
// Read the downloaded POI template, preserving its headers, options and validations.
// ZIP64/encrypted archives are deliberately rejected; template fixtures are small XLSX files.
function orderZipRead(bytes) {
const input = Buffer.from(bytes)
let end = input.length - 22
while (end >= Math.max(0, input.length - 65557) && input.readUInt32LE(end) !== 0x06054b50) end--
assertTrue(end >= 0 && input.readUInt32LE(end) === 0x06054b50, 'valid XLSX ZIP directory')
const entries = new Map()
let offset = input.readUInt32LE(end + 16)
for (let index = 0; index < input.readUInt16LE(end + 10); index++) {
assertEqual(input.readUInt32LE(offset), 0x02014b50, 'ZIP entry signature')
const method = input.readUInt16LE(offset + 10), size = input.readUInt32LE(offset + 20)
const length = input.readUInt32LE(offset + 24), nameLength = input.readUInt16LE(offset + 28)
const name = input.subarray(offset + 46, offset + 46 + nameLength).toString('utf8')
const local = input.readUInt32LE(offset + 42)
assertTrue(!(input.readUInt16LE(offset + 8) & 1) && [0, 8].includes(method) && length <= 16 * 1024 * 1024, 'supported bounded ZIP entry')
assertEqual(input.readUInt32LE(local), 0x04034b50, 'ZIP local signature')
const start = local + 30 + input.readUInt16LE(local + 26) + input.readUInt16LE(local + 28)
const packed = input.subarray(start, start + size)
const content = method === 8 ? inflateRawSync(packed, { maxOutputLength: 16 * 1024 * 1024 }) : packed
assertEqual(content.length, length, 'ZIP uncompressed size')
assertEqual(orderCrc(content), input.readUInt32LE(offset + 16), 'ZIP checksum')
assertTrue(!entries.has(name), 'unique ZIP entry')
entries.set(name, content.toString('utf8'))
offset += 46 + nameLength + input.readUInt16LE(offset + 30) + input.readUInt16LE(offset + 32)
}
return entries
}
function orderCrc(bytes) {
let crc = 0xffffffff
for (const byte of bytes) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0) }
return (crc ^ 0xffffffff) >>> 0
}
function orderZipWrite(entries) {
const local = [], directory = []
let offset = 0
for (const [name, text] of entries) {
const filename = Buffer.from(name), data = Buffer.from(text), crc = orderCrc(data)
const header = Buffer.alloc(30)
header.writeUInt32LE(0x04034b50); header.writeUInt16LE(20, 4); header.writeUInt16LE(0x800, 6)
header.writeUInt32LE(crc, 14); header.writeUInt32LE(data.length, 18); header.writeUInt32LE(data.length, 22); header.writeUInt16LE(filename.length, 26)
const central = Buffer.alloc(46)
central.writeUInt32LE(0x02014b50); central.writeUInt16LE(20, 4); central.writeUInt16LE(20, 6); central.writeUInt16LE(0x800, 8)
central.writeUInt32LE(crc, 16); central.writeUInt32LE(data.length, 20); central.writeUInt32LE(data.length, 24)
central.writeUInt16LE(filename.length, 28); central.writeUInt32LE(offset, 42)
local.push(header, filename, data); directory.push(central, filename)
offset += header.length + filename.length + data.length
}
const central = Buffer.concat(directory), end = Buffer.alloc(22)
end.writeUInt32LE(0x06054b50); end.writeUInt16LE(entries.size, 8); end.writeUInt16LE(entries.size, 10)
end.writeUInt32LE(central.length, 12); end.writeUInt32LE(offset, 16)
return Buffer.concat([...local, central, end])
}
function orderXmlDecode(text) {
return text.replace(/&#(x[0-9a-f]+|[0-9]+);|&(lt|gt|quot|apos|amp);/gi, (match, code, named) => code
? String.fromCodePoint(code[0].toLowerCase() === 'x' ? parseInt(code.slice(1), 16) : Number(code))
: ({ lt: '<', gt: '>', quot: '"', apos: "'", amp: '&' })[named.toLowerCase()])
}
function orderXmlEncode(text) { return String(text).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[char]) }
function orderColumn(index) {
let result = ''
for (let value = index + 1; value; value = Math.floor((value - 1) / 26)) result = String.fromCharCode(65 + (value - 1) % 26) + result
return result
}
function orderSheetRows(entries, path = 'xl/worksheets/sheet1.xml') {
const strings = [...(entries.get('xl/sharedStrings.xml') ?? '').matchAll(/<si\b[^>]*>([\s\S]*?)<\/si>/g)].map(item =>
[...item[1].matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(text => orderXmlDecode(text[1])).join(''))
const sheet = entries.get(path)
assertPresent(sheet, `template ${path}`)
return [...sheet.matchAll(/<row\b[^>]*\br="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)].map(row => ({ number: Number(row[1]), cells:
[...row[2].matchAll(/<c\b([^>]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(cell => {
const ref = cell[1].match(/\br="([A-Z]+)\d+"/)?.[1]
const content = cell[2] ?? ''
const value = content.match(/<v\b[^>]*>([\s\S]*?)<\/v>/)?.[1]
const text = /\bt="s"/.test(cell[1]) ? strings[Number(value)] : /\bt="inlineStr"/.test(cell[1])
? [...content.matchAll(/<t\b[^>]*>([\s\S]*?)<\/t>/g)].map(item => orderXmlDecode(item[1])).join('') : orderXmlDecode(value ?? '')
return { ref, text }
}) }))
}
function orderWorkbookRows(template, rows) {
const entries = new Map(template), sheet = entries.get('xl/worksheets/sheet1.xml')
const headers = [...sheet.matchAll(/<row\b[^>]*\br="([12])"[^>]*>[\s\S]*?<\/row>/g)].map(row => row[0]).join('')
assertTrue(headers.includes('r="1"') && headers.includes('r="2"'), 'both original header rows preserved')
const data = rows.map((row, index) => `<row r="${index + 3}">${row.map((text, col) => `<c r="${orderColumn(col)}${index + 3}" t="inlineStr"><is><t xml:space="preserve">${orderXmlEncode(text)}</t></is></c>`).join('')}</row>`).join('')
entries.set('xl/worksheets/sheet1.xml', sheet.replace(/<sheetData\b[^>]*>[\s\S]*?<\/sheetData>/, `<sheetData>${headers}${data}</sheetData>`))
return orderZipWrite(entries)
}
function importRollbackRows(validFirst, validSecond) {
const invalid = [...validFirst]; invalid[10] = '' // mandatory cargo name: seed only the second row
const changed = [...validSecond]; changed[30] += '-changed' // SaveRequest.customerRemark participates in CREATE_DRAFT hash
return { seed: [invalid, validSecond], conflict: [validFirst, changed], retry: [validFirst, validSecond] }
}
// END ORDER ACCEPTANCE HELPERS
async function ownedOrderSnapshot(module, name, id, expected, validate = () => {}) {
const response = await apiStep(module, name, {
path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json); assertOrderDetail(json.data, id)
if (expected) assertOrderUnchanged(expected, json.data)
validate(json.data)
}, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, version: json.data?.order?.version, snapshotSha256: orderDigest(json.data) }))
return response.json.data
}
function recordOrderMutationRequest(name, request, before) {
report.orderMutationRequests ??= []
report.orderMutationRequests.push({ name, method: request.method, path: request.path,
idempotencyKey: request.headers?.['Idempotency-Key'] ?? null, body: structuredClone(request.body),
beforeVersion: before.order.version, beforeSnapshotSha256: orderDigest(before) })
}
async function rejectOrderMutation(name, id, request, codes, statuses = [200]) {
const before = await ownedOrderSnapshot('order-mutation-evidence', `${name}: before`, id)
recordOrderMutationRequest(name, request, before)
await apiStep('order-mutation-evidence', name, request, ({ json, status }) => {
assertTrue(statuses.includes(status), `expected HTTP ${statuses}, actual=${status}`)
assertTrue(codes.includes(String(json?.code)), `expected ${codes}, actual=${json?.code}`)
}, ({ json }) => ({ orderId: String(id), rejectionCode: json?.code }))
await ownedOrderSnapshot('order-mutation-evidence', `${name}: all facts unchanged`, id, before)
}
async function orderMutationEvidenceModule() {
const module = 'order-mutation-evidence', base = '/api/merchant/v1/orders/first-leg'
assertPresent(orderAcceptanceFixture, 'completed order lifecycle fixture')
assertPresent(existingClientToken, 'independent client session')
const payload = structuredClone(orderAcceptanceFixture.payload)
payload.customerRemark = `mutation-${marker}`
const created = await apiStep(module, 'create owned mutable draft for isolation and cancellation', {
method: 'POST', path: base, token: merchantToken, body: payload, headers: { 'Idempotency-Key': newIdempotencyKey('mutation-draft') },
}, ({ json }) => { requireSuccess(json); assertPresent(json.data, 'draft ID') }, ({ json }) => ({ orderId: json.data }))
const id = created.json.data
report.resources.orderIds.push(id)
const original = await ownedOrderSnapshot(module, 'freeze draft page version and request', id)
const editPayload = structuredClone(payload); editPayload.customerRemark += '-edited'
const editCommand = orderMutationCommand(original, { draft: editPayload }, newIdempotencyKey('versioned-edit'))
const editRequest = { method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...editCommand }
recordOrderMutationRequest('versioned draft edit', editRequest, original)
await apiStep(module, 'versioned draft edit succeeds', editRequest, ({ json }) => requireSuccess(json),
() => ({ orderId: id, expectedVersion: original.order.version }))
const edited = await ownedOrderSnapshot(module, 'edited draft full snapshot', id, null, data => {
assertEqual(data.order.status, 'DRAFT', 'edit stays draft')
assertTrue(data.order.version > original.order.version, 'edit increments version')
assertEqual(data.order.customerRemark, editPayload.customerRemark, 'edited source remark')
for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(data.order[field], original.order[field], `stable edit ${field}`)
assertEqual(data.cargoes.length, editPayload.cargoes.length, 'complete edited cargo set')
data.cargoes.forEach((cargo, index) => {
for (const field of ['skuNo', 'packageCount', 'model', 'productImageFileId']) assertEqual(cargo[field], editPayload.cargoes[index][field], `edited cargo ${field}`)
})
const oldIds = new Set(original.statusHistory.map(item => String(item.id)))
const added = data.statusHistory.filter(item => !oldIds.has(String(item.id)))
assertOrderUnchanged(original.statusHistory, data.statusHistory.filter(item => oldIds.has(String(item.id))))
assertEqual(added.length, 1, 'single edit audit'); assertEqual(added[0].action, 'UPDATE', 'edit audit action')
})
recordOrderMutationRequest('original edit replay', editRequest, original)
await apiStep(module, 'original edit key and frozen payload replay successfully', editRequest, ({ json }) => requireSuccess(json),
() => ({ orderId: id, expectedVersion: original.order.version, replayed: true }))
await ownedOrderSnapshot(module, 'edit replay adds no version cargo or history', id, edited)
const changedEdit = structuredClone(editCommand); changedEdit.body.draft.customerRemark += '-conflict'
await rejectOrderMutation('edit original key different payload conflicts', id, {
method: 'PUT', path: `${base}/${id}`, token: merchantToken, ...changedEdit,
}, ['A0409'])
await rejectOrderMutation('old page version cannot overwrite newer draft', id, {
method: 'PUT', path: `${base}/${id}`, token: merchantToken,
...orderMutationCommand(original, { draft: payload }, newIdempotencyKey('stale-edit')),
}, ['A0409'])
for (const operation of ['edit', 'cancel']) {
const method = operation === 'edit' ? 'PUT' : 'POST', path = `${base}/${id}${operation === 'edit' ? '' : '/cancel'}`
const command = orderMutationCommand(edited, operation === 'edit' ? { draft: editPayload } : { reason: 'missing protocol guard' }, newIdempotencyKey(`missing-${operation}`))
const withoutVersion = structuredClone(command); delete withoutVersion.body.expectedVersion
await rejectOrderMutation(`${operation} missing version is rejected`, id, { method, path, token: merchantToken, ...withoutVersion }, ['A0422'])
await rejectOrderMutation(`${operation} missing key is rejected`, id, { method, path, token: merchantToken, body: command.body }, ['A0400'], [200, 400])
}
await rejectOrderMutation('other customer cannot edit owned draft', id, {
method: 'PUT', path: `${base}/${id}`, token: existingClientToken,
...orderMutationCommand(edited, { draft: payload }, newIdempotencyKey('foreign-edit')),
}, ['A0403', 'A0404'])
await rejectOrderMutation('other customer cannot cancel owned draft', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: existingClientToken,
...orderMutationCommand(edited, { reason: `foreign-${marker}` }, newIdempotencyKey('foreign-cancel')),
}, ['A0403', 'A0404'])
const invalid = structuredClone(payload); invalid.cargoes[0].productImageFileId = -1
await rejectOrderMutation('invalid product image edit rolls back all facts', id, {
method: 'PUT', path: `${base}/${id}`, token: merchantToken,
...orderMutationCommand(edited, { draft: invalid }, newIdempotencyKey('invalid-edit')),
}, ['A0422'])
await rejectOrderMutation('submitted order cannot be overwritten as draft', orderAcceptanceFixture.orderId, {
method: 'PUT', path: `${base}/${orderAcceptanceFixture.orderId}`, token: merchantToken,
...orderMutationCommand(await ownedOrderSnapshot(module, 'freeze submitted order edit request', orderAcceptanceFixture.orderId),
{ draft: payload }, newIdempotencyKey('submitted-edit-evidence')),
}, ['A0409'])
await rejectOrderMutation('blank cancellation reason preserves all facts', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
...orderMutationCommand(edited, { reason: ' ' }, newIdempotencyKey('empty-reason')),
}, ['A0422'])
await rejectOrderMutation('old page version cannot cancel updated draft', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
...orderMutationCommand(original, { reason: 'stale page' }, newIdempotencyKey('stale-cancel')),
}, ['A0409'])
const before = await ownedOrderSnapshot(module, 'before valid cancellation', id)
const reason = `F06-cancel-${marker}`
const cancelCommand = orderMutationCommand(before, { reason }, newIdempotencyKey('versioned-cancel'))
const cancelRequest = { method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...cancelCommand }
recordOrderMutationRequest('versioned cancellation', cancelRequest, before)
await apiStep(module, 'cancel owned draft with reason', cancelRequest, ({ json }) => requireSuccess(json), () => ({ orderId: id, reason }))
await apiStep(module, 'cancellation persists one attributed history and preserves cargo', {
path: `${base}/${id}`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json); assertOrderDetail(json.data, id)
assertEqual(json.data.order.status, 'CANCELLED', 'cancelled status')
assertTrue(json.data.order.version > before.order.version, 'version increases')
for (const field of ['cargoes', 'parties', 'internalNotes']) assertOrderUnchanged(before[field], json.data[field])
for (const field of ['id', 'orderNo', 'inboundCode', 'tenantId', 'customerId']) assertEqual(json.data.order[field], before.order[field], `stable ${field}`)
const oldIds = new Set(before.statusHistory.map(item => String(item.id)))
const added = json.data.statusHistory.filter(item => !oldIds.has(String(item.id)))
assertEqual(added.length, 1, 'one cancellation history')
assertOrderUnchanged(before.statusHistory, json.data.statusHistory.filter(item => oldIds.has(String(item.id))))
assertEqual(added[0].action, 'CANCELLED', 'cancellation action'); assertEqual(added[0].reason, reason, 'cancellation reason')
assertEqual(added[0].fromStatus, 'DRAFT', 'from draft'); assertEqual(added[0].toStatus, 'CANCELLED', 'to cancelled')
assertPresent(added[0].operatorId, 'operator'); assertPresent(added[0].occurredAt, 'server operation time')
}, ({ json }) => ({ orderId: id, status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) }))
const cancelled = await ownedOrderSnapshot(module, 'freeze cancelled facts before replay', id)
recordOrderMutationRequest('original cancellation replay', cancelRequest, before)
await apiStep(module, 'original cancellation key payload and version replay successfully', cancelRequest,
({ json }) => requireSuccess(json), () => ({ orderId: id, expectedVersion: before.order.version, replayed: true }))
await ownedOrderSnapshot(module, 'cancel replay creates no second history or version', id, cancelled)
const changedCancel = structuredClone(cancelCommand); changedCancel.body.reason += '-conflict'
await rejectOrderMutation('cancellation original key different reason conflicts', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken, ...changedCancel,
}, ['A0409'])
await rejectOrderMutation('repeat cancellation cannot add history', id, {
method: 'POST', path: `${base}/${id}/cancel`, token: merchantToken,
...orderMutationCommand(cancelled, { reason }, newIdempotencyKey('new-key-repeat-cancel')),
}, ['A0409'])
await rejectOrderMutation('cancelled draft cannot be submitted', id, {
method: 'POST', path: `${base}/${id}/submit`, token: merchantToken, headers: { 'Idempotency-Key': newIdempotencyKey('cancelled-submit-proof') },
}, ['A0409'])
report.orderMutationLimitations = [
'Requires deployed Merchant edit/cancel versioned idempotent protocol; local contracts do not prove TEST deployment.',
'Irreversible-fulfillment cancellation is not exercised by these draft/submitted fixtures.',
]
}
async function importOrderSet(name, expectedIds) {
const ids = [], seen = new Set()
let total = null
for (let page = 1; ; page++) {
await apiStep('order-excel-import', `${name}: complete order page ${page}`, {
path: `/api/merchant/v1/orders/first-leg?page=${page}&size=200`, token: merchantToken,
}, ({ json }) => {
requireSuccess(json)
assertTrue(Array.isArray(json.data?.records), 'order page records required')
assertEqual(Number(json.data.page), page, 'order page identity')
const count = Number(json.data.total)
assertTrue(Number.isSafeInteger(count) && count >= 0, 'valid order total')
if (total === null) total = count
assertEqual(count, total, 'stable order page total')
for (const row of json.data.records) {
assertPresent(row.orderId, 'listed order ID')
const id = String(row.orderId)
assertTrue(!seen.has(id), 'no duplicate order across pages'); seen.add(id); ids.push(id)
}
assertTrue(ids.length <= total, 'order pages do not exceed total')
if (page * 200 >= total) {
assertEqual(ids.length, total, 'complete order set')
if (expectedIds) assertOrderUnchanged([...expectedIds].sort(), [...ids].sort())
} else assertEqual(json.data.records.length, 200, 'full intermediate order page')
}, () => ({ page, total, collected: ids.length, orderIdsSha256: orderDigest([...ids].sort()) }))
if (page * 200 >= total) return ids
}
}
async function orderExcelImportModule() {
const module = 'order-excel-import', base = '/api/merchant/v1/orders/first-leg/batch-imports'
const { masterData, payload } = orderAcceptanceFixture
let template
await apiStep(module, 'download and inspect actual Chinese customer template', {
path: `${base}/template`, token: merchantToken, responseType: 'bytes',
}, ({ status, bytes, headers }) => {
assertEqual(status, 200, 'template HTTP status')
assertTrue((headers.get('content-type') ?? '').includes('spreadsheetml'), 'XLSX content type')
template = orderZipRead(bytes)
const header = orderSheetRows(template).find(row => row.number === 2)
assertEqual(header?.cells?.length, 31, 'current 31-column template')
assertEqual(header.cells[0].text, '运输方式 *', 'Chinese first header')
assertEqual(header.cells[30].text, '客户备注', 'Chinese final header')
assertTrue(template.get('xl/workbook.xml').includes('选项数据'), 'server-generated option sheet')
}, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }))
const options = new Set(orderSheetRows(template, 'xl/worksheets/sheet2.xml').flatMap(row => row.cells.map(cell => cell.text)))
const businessLabel = (name, code) => !name || name.toLowerCase() === code.toLowerCase() ? code : `${name}|${code}`
const receiving = businessLabel(masterData.receiving.warehouseName, masterData.receiving.warehouseCode)
const destination = businessLabel(masterData.destination.warehouseName, masterData.destination.warehouseCode)
const channel = businessLabel(masterData.channel.channelName, masterData.channel.channelCode)
for (const value of [receiving, destination, channel]) assertTrue(options.has(value), `authoritative template option ${value}`)
const makeRow = suffix => [
'海运', '', '自行快递', receiving, channel, '亚马逊FBA', destination, '', '', '',
`Excel-${suffix}`, '纸箱', '2', '20.5', '0.024', '塑料', '否', '否', `SKU-${suffix}`, '123456789012',
`SHIP-${suffix}`, `REF-${suffix}`, '买单报关', payload.pricingTaxMode === 'TAX_INCLUDED' ? '包税' : '不包税',
'散货拼箱', '否', `SHIP-${suffix}`, `REF-${suffix}`, '普通头程', '', `${marker}-${suffix}`,
]
const upload = async (name, mode, key, bytes, validate, filename = 'orders.xlsx') => {
const form = new FormData()
form.append('file', new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }), filename)
return apiStep(module, name, {
method: 'POST', path: `${base}?mode=${mode}`, token: merchantToken, headers: { 'Idempotency-Key': key }, body: form,
}, validate, ({ json }) => ({ batchNo: json?.data?.batchNo, mode, result: json?.data?.result, sourceFileId: json?.data?.sourceFileId,
errorReportFileId: json?.data?.errorReportFileId, requestBytesSha256: createHash('sha256').update(bytes).digest('hex') }))
}
const rejection = code => ({ json, status }) => {
assertEqual(status, 200, 'HTTP status'); assertEqual(String(json?.code), code, 'rejection business code')
}
const success = (mode, rows) => ({ json }) => { requireSuccess(json); assertImportResult(json.data, mode, rows) }
const verifyDraft = async (id, row, name) => {
await apiStep(module, name, { path: `/api/merchant/v1/orders/first-leg/${id}`, token: merchantToken }, ({ json }) => {
requireSuccess(json); assertOrderDetail(json.data, id)
assertEqual(json.data.order.status, 'DRAFT', 'import creates draft only')
assertEqual(String(json.data.order.tenantId), String(report.resources.tenantId), 'authenticated customer scope')
assertEqual(json.data.order.customerRemark, row[30], 'exact source remark')
assertEqual(json.data.order.referenceId, row[27], 'exact source reference')
assertEqual(String(json.data.order.receivingWarehouseId), String(masterData.receiving.id), 'authoritative receiving warehouse')
assertEqual(String(json.data.order.destinationWarehouseId), String(masterData.destination.id), 'authoritative destination')
assertEqual(json.data.order.channelCode, masterData.channel.channelCode, 'authoritative channel')
assertEqual(json.data.cargoes.length, 1, 'one Excel row creates one order cargo')
assertEqual(json.data.cargoes[0].skuNo, row[18], 'source SKU')
assertEqual(Number(json.data.cargoes[0].packageCount), 2, 'source package count')
assertTrue(!json.data.statusHistory.some(item => item.toStatus === 'SUBMITTED'), 'import never submits')
}, ({ json }) => ({ orderId: String(id), status: json.data?.order?.status, snapshotSha256: orderDigest(json.data) }))
}
const initial = await importOrderSet('before import matrix')
const rows = importRollbackRows(makeRow('rollback-first'), makeRow('rollback-second'))
const key = newIdempotencyKey('excel-transaction')
const seedBytes = orderWorkbookRows(template, rows.seed)
const seed = await upload('PARTIAL preserves only valid second row', 'PARTIAL', key, seedBytes, success('PARTIAL', [false, true]))
const retainedId = String(seed.json.data.result.items[1].orderId)
report.resources.orderIds.push(retainedId)
const seeded = [...initial, retainedId]
await importOrderSet('PARTIAL exactly one new order', seeded)
await verifyDraft(retainedId, rows.seed[1], 'PARTIAL source fields and customer match')
const retained = await ownedOrderSnapshot(module, 'seed second row full snapshot', retainedId)
const replay = await upload('PARTIAL same key bytes returns same order', 'PARTIAL', key, seedBytes, response => {
success('PARTIAL', [false, true])(response)
assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'replayed order identity')
})
await importOrderSet('PARTIAL replay adds no orders', seeded)
await ownedOrderSnapshot(module, 'PARTIAL replay preserves full existing order', retainedId, retained)
const errorData = replay.json.data
await apiStep(module, 'download actual row error workbook', {
path: `/api/merchant/v1/files/order-batch-imports/${encodeURIComponent(errorData.batchNo)}/error-reports/${errorData.errorReportFileId}`,
token: merchantToken, responseType: 'bytes',
}, ({ status, bytes }) => {
assertEqual(status, 200, 'error workbook download')
const rows = orderSheetRows(orderZipRead(bytes))
assertEqual(rows.length, 2, 'header and exactly one failed row')
assertEqual(rows[1].cells[0].text, '3', 'actual Excel error row')
assertEqual(rows[1].cells[1].text, 'A0422', 'error report code')
assertEqual(rows[1].cells[2].text, errorData.result.items[0].errorMessage, 'error report exact reason')
}, ({ bytes }) => ({ sizeBytes: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }))
await upload('ATOMIC parse rejection creates no orders', 'ATOMIC', newIdempotencyKey('excel-parse'), seedBytes, rejection('A0422'))
await importOrderSet('ATOMIC parse failure has zero additions', seeded)
// Same tenant/operator/key and physical row IDs; only row 4 customerRemark changes.
// Row 3 passes validation and writes before row 4 CREATE_DRAFT fingerprint conflicts.
await upload('ATOMIC later row idempotency conflict rolls back earlier row', 'ATOMIC', key,
orderWorkbookRows(template, rows.conflict), rejection('A0409'))
await importOrderSet('ATOMIC mid-write conflict leaves no first-row order', seeded)
await ownedOrderSnapshot(module, 'ATOMIC conflict preserves seeded order and history', retainedId, retained)
await upload('ATOMIC retry with original second-row hash succeeds', 'ATOMIC', key,
orderWorkbookRows(template, rows.retry), response => {
success('ATOMIC', [true, true])(response)
assertEqual(String(response.json.data.result.items[1].orderId), retainedId, 'second row reuses original ID')
assertTrue(String(response.json.data.result.items[0].orderId) !== retainedId, 'new first row is distinct')
}).then(async response => {
const firstId = String(response.json.data.result.items[0].orderId)
report.resources.orderIds.push(firstId); seeded.push(firstId)
await importOrderSet('ATOMIC retry adds exactly first row', seeded)
await verifyDraft(firstId, rows.retry[0], 'ATOMIC first row source facts match')
})
await ownedOrderSnapshot(module, 'successful ATOMIC replay preserves second row', retainedId, retained)
const invalidCases = [
['wrong extension', seedBytes, 'orders.csv'], ['corrupt xlsx', Buffer.from('not-an-xlsx'), 'orders.xlsx'],
['over five MiB', Buffer.alloc(5 * 1024 * 1024 + 1), 'orders.xlsx'],
['51 effective rows', orderWorkbookRows(template, Array.from({ length: 51 }, (_, index) => makeRow(`limit-${index}`))), 'orders.xlsx'],
]
const missingHeader = orderZipRead(orderWorkbookRows(template, [makeRow('header')]))
missingHeader.set('xl/worksheets/sheet1.xml', missingHeader.get('xl/worksheets/sheet1.xml').replace(/<row\b[^>]*\br="2"[^>]*>[\s\S]*?<\/row>/, '<row r="2"/>'))
invalidCases.push(['missing Chinese headers', orderZipWrite(missingHeader), 'orders.xlsx'])
for (const [name, bytes, filename] of invalidCases) {
await upload(`reject ${name}`, 'ATOMIC', newIdempotencyKey('excel-invalid'), bytes, rejection('A0422'), filename)
await importOrderSet(`${name}: order set unchanged`, seeded)
}
const forged = makeRow('forged-catalog'); forged[3] = `FORGED-${marker}`
const tampered = new Map(template)
tampered.set('xl/worksheets/sheet2.xml', tampered.get('xl/worksheets/sheet2.xml').replace('</sheetData>', `<row r="9999"><c r="A9999" t="inlineStr"><is><t>${orderXmlEncode(forged[3])}</t></is></c></row></sheetData>`))
await upload('forged hidden option is rejected against authoritative catalog', 'ATOMIC', newIdempotencyKey('excel-forged'),
orderWorkbookRows(tampered, [forged]), rejection('A0422'))
await importOrderSet('forged option adds no orders', seeded)
report.orderImportLimitations = [
'ATOMIC assertions concern Order rows and their public facts; remote source uploads are not rolled back.',
'Template selected options are checked; exhaustive warehouse pagination and cross-customer private-address template comparison remain open.',
'This HTTP matrix does not prove browser import interaction or irreversible order fulfillment cancellation.',
]
}
async function apiStep(module, name, request, validate, evidenceBuilder = defaultEvidence) {
const startedAt = new Date().toISOString()
const started = Date.now()