From 58dfcc21f69bb92d16a09a8dba1bbcea7b81f584 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 2 Oct 2026 21:33:39 +0800 Subject: [PATCH] fix(order): make merchant draft edits and cancellations versioned and idempotent --- .../MerchantFirstLegOrderController.java | 18 +- .../com/baiyue/order/dto/OrderRequests.java | 12 + .../service/OrderApplicationService.java | 37 +- .../src/main/resources/openapi/order-v1.yaml | 50 ++ .../MerchantOrderCommandBindingTest.java | 80 +++ .../MerchantOrderCommandTransactionTest.java | 304 +++++++++++ .../OrderApplicationServiceStateTest.java | 14 +- scripts/README.md | 2 +- ...ient-test-env-acceptance-contract.test.mjs | 265 ++++++++++ scripts/client-test-env-acceptance.mjs | 473 +++++++++++++++++- 10 files changed, 1227 insertions(+), 28 deletions(-) create mode 100644 order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java diff --git a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java index bd8756c38..ed4fc1218 100644 --- a/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/MerchantFirstLegOrderController.java @@ -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 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 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 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); } + } diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 068440df2..85d7a66c1 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -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) diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 6c9fd5e18..f518dbd9e 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -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); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index b5a7fdad2..28ce196b1 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -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] diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java new file mode 100644 index 000000000..a215469cf --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java @@ -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"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java new file mode 100644 index 000000000..5089f6971 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java @@ -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 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 concurrent(Runnable first, Runnable second) throws Exception { + var workers = Executors.newFixedThreadPool(2); + var start = new CountDownLatch(1); + try { + List> 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 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 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) + ")"); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 087c9615d..e9bcb4573 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -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); diff --git a/scripts/README.md b/scripts/README.md index afe32d1a5..e37862961 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 全量入口 | diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index 576890d63..32662a731 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -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', ''], + ['xl/sharedStrings.xml', '运输方式 *客户备注仓库|CODE'], + ['xl/worksheets/sheet1.xml', '订单基础01old data'], + ['xl/worksheets/sheet2.xml', '2'], + ]) +} + +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('中文'), 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) => `${r.orderXmlEncode(value)}`).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', `${cells(['订单基础'], 1)}${cells(headers, 2)}`) + template.set('xl/worksheets/sheet2.xml', `${cells(['国内仓|CN', '目的仓|US', '海运渠道|SEA'], 1)}`) + bytes = r.orderZipWrite(template) + } else if (request.path.includes('/error-reports/')) { + bytes = r.orderZipWrite(new Map([['xl/worksheets/sheet1.xml', `${cells(['Excel行号', '错误代码', '错误说明'], 1)}${cells(['3', 'A0422', 'cargo required'], 2)}`]])) + } 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/) +}) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index b028d889c..a127030f5 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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(/]*>([\s\S]*?)<\/si>/g)].map(item => + [...item[1].matchAll(/]*>([\s\S]*?)<\/t>/g)].map(text => orderXmlDecode(text[1])).join('')) + const sheet = entries.get(path) + assertPresent(sheet, `template ${path}`) + return [...sheet.matchAll(/]*\br="(\d+)"[^>]*>([\s\S]*?)<\/row>/g)].map(row => ({ number: Number(row[1]), cells: + [...row[2].matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(cell => { + const ref = cell[1].match(/\br="([A-Z]+)\d+"/)?.[1] + const content = cell[2] ?? '' + const value = content.match(/]*>([\s\S]*?)<\/v>/)?.[1] + const text = /\bt="s"/.test(cell[1]) ? strings[Number(value)] : /\bt="inlineStr"/.test(cell[1]) + ? [...content.matchAll(/]*>([\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(/]*\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.map((text, col) => `${orderXmlEncode(text)}`).join('')}`).join('') + entries.set('xl/worksheets/sheet1.xml', sheet.replace(/]*>[\s\S]*?<\/sheetData>/, `${headers}${data}`)) + 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(/]*\br="2"[^>]*>[\s\S]*?<\/row>/, '')) + 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('', `${orderXmlEncode(forged[3])}`)) + 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()