From 66089f9b992be1424c9ea4d474588997d8d764b2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 14 Aug 2026 10:09:46 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 14 + .../PlatformContainerController.java | 6 +- .../PlatformLoadingListController.java | 4 +- .../service/InboundFeeDetailService.java | 42 +-- ...se2-published-freight-detail-migration.sql | 13 + .../ContainerControllerContractTest.java | 21 ++ .../LoadingListControllerContractTest.java | 14 + .../FeeItemMigrationSqlContractTest.java | 3 + .../service/FeeManagementServiceTest.java | 31 ++- scripts/README.md | 2 +- scripts/admin-test-env-acceptance.mjs | 14 + scripts/admin-test-env-modules/README.md | 2 + .../fee-items-contract.test.mjs | 16 +- scripts/admin-test-env-modules/fee-items.mjs | 32 ++- .../finance-full-fixtures-contract.test.mjs | 9 +- .../finance-full-fixtures.mjs | 14 +- .../finance-settlement-full-contract.test.mjs | 17 +- .../finance-settlement-full.mjs | 4 +- ...ual-state-ocean-trucking-contract.test.mjs | 6 + .../manual-state-ocean-trucking.mjs | 31 ++- .../admin-test-env-modules/ocean-bookings.mjs | 42 ++- ...-inbound-status-fixtures-contract.test.mjs | 22 +- .../order-inbound-status-fixtures.mjs | 64 +++-- .../phase3-container-overview.mjs | 7 +- .../admin-test-env-modules/phase3-loading.mjs | 3 +- .../phase3-order-splitting-contract.test.mjs | 34 +++ .../phase3-order-splitting.mjs | 98 ++++--- .../phase3-producer-chain-contract.test.mjs | 6 + .../client-notifications-contract.test.mjs | 44 ++++ scripts/client-test-env-acceptance.mjs | 241 +++++++++++++++++- scripts/local-db-full-acceptance.ps1 | 4 +- 31 files changed, 719 insertions(+), 141 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs create mode 100644 scripts/client-notifications-contract.test.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 1ff319d5e..0f0685910 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -7058,6 +7058,15 @@ CALL unified_publish_fee_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_pub 'KEY `idx_inbound_fee_published` (`tenant_id`, `inbound_id`, `publication_status`, `source_fee_version`)'); DROP PROCEDURE IF EXISTS unified_publish_fee_add_index; +-- 系统计费使用的固定公共费用项目;不属于费用类型.xls 的110条受控种子。 +INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name, + pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, + description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at) +SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL', 'FBA头程运费', + 0, 0, 0, 1, 0, '系统计费发布使用的固定应收费用项目', 'ACTIVE', + 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' +WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); + -- ============================================================================ -- 2026-08-06:三期分柜、预装、拣货、装柜和派送当前基线 -- ============================================================================ @@ -14042,6 +14051,11 @@ BEGIN WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 AND id BETWEEN 193000000001000001 AND 193000000001000110 AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110 + OR (SELECT COUNT(*) FROM baiyue_firstleg.fl_fee_item + WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT' + AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' + AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 + AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 OR EXISTS ( SELECT 1 FROM baiyue_firstleg.fl_inbound_fee_detail detail diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index 8b9090db0..f8226b3cc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -70,7 +70,7 @@ public class PlatformContainerController { @GetMapping("/{id}/documents/{documentType}") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") public ApiResponse logisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.containerLogisticsDocument(id,documentType));} @PostMapping(value="/{id}/documents/{documentType}/upload",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:firstleg:container:edit')") - public ApiResponse uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestPart("expectedVersion") @Min(0) int version,@RequestPart(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));} + public ApiResponse uploadLogisticsDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@RequestHeader("Idempotency-Key") String key,@RequestPart("file") MultipartFile file,@RequestParam("expectedVersion") @Min(0) int version,@RequestParam(value="operationReason",required=false) String reason){FirstlegScopeGuard.platform(user);return ApiResponse.success(loadingLists.uploadContainerLogisticsDocument(id,documentType,user.userId(),user.username(),key,version,reason,file));} @GetMapping("/{id}/documents/{documentType}/{fileId}/download") @PreAuthorize("hasAuthority('platform:firstleg:container:list')") public ResponseEntity downloadDocument(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable @Positive long id,@PathVariable String documentType,@PathVariable @Positive long fileId){FirstlegScopeGuard.platform(user);var d=loadingLists.downloadContainerDocument(id,documentType,fileId);return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.CONTENT_DISPOSITION,org.springframework.http.ContentDisposition.attachment().filename(d.fileName(),StandardCharsets.UTF_8).build().toString()).contentType(d.contentType()).contentLength(d.content().length).body(d.content());} @PostMapping @PreAuthorize("hasAuthority('platform:firstleg:container:create')") @@ -127,8 +127,8 @@ public class PlatformContainerController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @RequestHeader("Idempotency-Key") String key, @RequestPart("file") MultipartFile file, - @RequestPart("expectedVersion") @Min(0) int expectedVersion, - @RequestPart(value = "operationReason", required = false) @Size(max = 500) String operationReason) { + @RequestParam("expectedVersion") @Min(0) int expectedVersion, + @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { FirstlegScopeGuard.platform(user); return ApiResponse.success(loadingLists.uploadContainerClearance( id, user.userId(), user.username(), key, expectedVersion, operationReason, file)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java index d9fc87a4c..f7f6788a0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformLoadingListController.java @@ -129,8 +129,8 @@ public class PlatformLoadingListController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long planId, @RequestParam @Positive long warehouseId, @RequestHeader("Idempotency-Key") String key, @RequestPart("file") MultipartFile file, - @RequestPart("expectedVersion") @Min(0) int expectedVersion, - @RequestPart(value = "operationReason", required = false) @Size(max = 500) String operationReason) { + @RequestParam("expectedVersion") @Min(0) int expectedVersion, + @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { FirstlegScopeGuard.platform(user); return ApiResponse.success(service.uploadLoadingDeclaration(planId, warehouseId, user.userId(), user.username(), key, expectedVersion, operationReason, file)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java index 442c19d28..e0a026355 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java @@ -22,7 +22,6 @@ import java.util.List; /** 入库单人工费用明细应用服务。 */ @Service public class InboundFeeDetailService { - private static final long PLATFORM_TENANT_ID = 0L; private static final String FREIGHT_FEE_CODE = "FBA_HEAD_FREIGHT"; @Resource private FeeItemRepository repository; @@ -59,7 +58,7 @@ public class InboundFeeDetailService { throw conflict("只有已发送状态的计费快照才能生成客户运费明细"); } LocalDateTime now = publishedAt == null ? LocalDateTime.now() : publishedAt; - FeeItemEntity feeItem = requiredFreightItem(operatorId, operatorName); + FeeItemEntity feeItem = requiredFreightItem(); InboundFeeItemEntity baseItem = pricingRepository.findBaseItem(tenantId, feeId); repository.supersedePublishedFreight(tenantId, inboundId, feeId, operatorId, now); @@ -240,42 +239,11 @@ public class InboundFeeDetailService { return value; } - private FeeItemEntity requiredFreightItem(long operatorId, String operatorName) { + private FeeItemEntity requiredFreightItem() { FeeItemEntity item = repository.findItemByCode(FREIGHT_FEE_CODE); - if (item != null) { - if (!"ACTIVE".equals(item.getStatus())) throw conflict("系统费用项目FBA头程运费已停用"); - return item; - } - FeeItemEntity created = new FeeItemEntity(); - created.setTenantId(PLATFORM_TENANT_ID); - created.setCategory("头程运费"); - created.setTradeScope("INTERNATIONAL"); - created.setFeeCode(FREIGHT_FEE_CODE); - created.setFeeTypeName("FBA头程运费"); - created.setPickupPayable(false); - created.setPickupReceivable(false); - created.setWarehousePayable(false); - created.setWarehouseReceivable(true); - created.setAllocationFee(false); - created.setDescription("系统计费发布时自动维护,不允许用于应付费用"); - created.setStatus("ACTIVE"); - created.setCreatedBy(operatorId); - created.setUpdatedBy(operatorId); - created.setCreatedByName(operatorName); - created.setUpdatedByName(operatorName); - try { - repository.insertItem(created); - return created; - } catch (DuplicateKeyException exception) { - FeeItemEntity concurrent = repository.findItemByCode(FREIGHT_FEE_CODE); - if (concurrent != null) { - if (!"ACTIVE".equals(concurrent.getStatus())) { - throw conflict("系统费用项目FBA头程运费已停用"); - } - return concurrent; - } - throw exception; - } + if (item == null) throw conflict("系统费用项目FBA头程运费未初始化"); + if (!"ACTIVE".equals(item.getStatus())) throw conflict("系统费用项目FBA头程运费已停用"); + return item; } private static java.math.BigDecimal effectiveUnitPrice(InboundFeeEntity fee) { diff --git a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql index 9390524ef..d5176a8dc 100644 --- a/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql +++ b/firstleg/src/main/resources/db/phase2-published-freight-detail-migration.sql @@ -463,6 +463,15 @@ CALL phase2_publish_add_index('fl_inbound_fee_detail', 'idx_inbound_fee_publishe DROP PROCEDURE IF EXISTS phase2_publish_add_index; +-- 系统计费使用的固定公共费用项目;不属于费用类型.xls 的110条受控种子。 +INSERT INTO fl_fee_item (id, tenant_id, fee_code, category, trade_scope, fee_type_name, + pickup_payable, pickup_receivable, warehouse_payable, warehouse_receivable, allocation_fee, + description, status, created_by, created_by_name, created_at, updated_by, updated_by_name, updated_at) +SELECT 193000000001000111, 0, 'FBA_HEAD_FREIGHT', '头程运费', 'INTERNATIONAL', 'FBA头程运费', + 0, 0, 0, 1, 0, '系统计费发布使用的固定应收费用项目', 'ACTIVE', + 0, 'system', '2026-08-14 00:00:00.000', 0, 'system', '2026-08-14 00:00:00.000' +WHERE NOT EXISTS (SELECT 1 FROM fl_fee_item WHERE fee_code = 'FBA_HEAD_FREIGHT'); + DROP PROCEDURE IF EXISTS phase2_publish_assert_fee_schema; DELIMITER $$ CREATE PROCEDURE phase2_publish_assert_fee_schema() @@ -483,6 +492,10 @@ BEGIN OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND status = 'ACTIVE' AND deleted = 0 AND id BETWEEN 193000000001000001 AND 193000000001000110 AND fee_code = CONCAT('FEE_TYPE_', LPAD(id - 193000000001000000, 3, '0'))) <> 110 + OR (SELECT COUNT(*) FROM fl_fee_item WHERE tenant_id = 0 AND fee_code = 'FBA_HEAD_FREIGHT' + AND category = '头程运费' AND trade_scope = 'INTERNATIONAL' AND fee_type_name = 'FBA头程运费' + AND pickup_payable = 0 AND pickup_receivable = 0 AND warehouse_payable = 0 + AND warehouse_receivable = 1 AND allocation_fee = 0 AND status = 'ACTIVE' AND deleted = 0) <> 1 OR EXISTS (SELECT 1 FROM fl_inbound_fee_detail d JOIN fl_fee_item f ON f.id = d.fee_item_id WHERE d.tenant_id = 0 OR f.tenant_id <> 0) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '二期迁移失败:费用项目未完成平台公共作用域或明细跨客户引用校验'; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java index 84a04362a..3fab6fc74 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/ContainerControllerContractTest.java @@ -4,6 +4,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; @@ -62,6 +64,13 @@ class ContainerControllerContractTest { .doesNotContain("accountId"); } + @Test + @DisplayName("multipart 文本字段使用请求参数绑定") + void multipartScalarFieldsUseRequestParameters() { + assertMultipartBinding("uploadLogisticsDocument", 4, 5, 6); + assertMultipartBinding("uploadClearance", 3, 4, 5); + } + private static void assertPermission(String methodName, String authority) { Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods()) .filter(candidate -> candidate.getName().equals(methodName)) @@ -70,4 +79,16 @@ class ContainerControllerContractTest { .isEqualTo("hasAuthority('" + authority + "')"); } + private static void assertMultipartBinding(String methodName, int fileIndex, + int versionIndex, int reasonIndex) { + Method method = Arrays.stream(PlatformContainerController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(methodName)) + .findFirst().orElseThrow(); + assertThat(method.getParameters()[fileIndex].getAnnotation(RequestPart.class)).isNotNull(); + assertThat(method.getParameters()[versionIndex].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[versionIndex].getAnnotation(RequestPart.class)).isNull(); + assertThat(method.getParameters()[reasonIndex].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[reasonIndex].getAnnotation(RequestPart.class)).isNull(); + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java index 4ac94979b..3a40a3057 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/LoadingListControllerContractTest.java @@ -13,6 +13,8 @@ import org.junit.jupiter.api.Test; import org.yaml.snakeyaml.Yaml; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; import java.lang.reflect.Method; @@ -89,6 +91,18 @@ class LoadingListControllerContractTest { assertThat(component(ConfirmedQuery.class, "warehouseId").getAnnotation(NotNull.class)).isNull(); } + @Test + void multipartScalarFieldsUseRequestParameters() throws Exception { + Method method = PlatformLoadingListController.class.getDeclaredMethod("uploadDeclaration", + AuthenticatedUser.class, long.class, long.class, String.class, + MultipartFile.class, int.class, String.class); + assertThat(method.getParameters()[4].getAnnotation(RequestPart.class)).isNotNull(); + assertThat(method.getParameters()[5].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[5].getAnnotation(RequestPart.class)).isNull(); + assertThat(method.getParameters()[6].getAnnotation(RequestParam.class)).isNotNull(); + assertThat(method.getParameters()[6].getAnnotation(RequestPart.class)).isNull(); + } + @Test void yamlParses() throws Exception { String yaml = new ClassPathResource("openapi/loading-lists-v1.yaml") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java index 05c24592f..e1451a307 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FeeItemMigrationSqlContractTest.java @@ -22,6 +22,9 @@ class FeeItemMigrationSqlContractTest { .contains("LEFT JOIN tmp_fee_item_seed matched_seed") .contains("matched_seed.fee_code = NULLIF(TRIM(f.fee_code), '')") .contains("matched_seed.trade_scope = f.trade_scope") + .contains("193000000001000111, 0, 'FBA_HEAD_FREIGHT'") + .contains("fee_code = 'FBA_HEAD_FREIGHT'") + .contains("warehouse_receivable = 1") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_code") .doesNotContain("LEFT JOIN tmp_fee_item_seed by_semantic") .doesNotContain("OR (SELECT COUNT(*) FROM tmp_fee_item_seed") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index 6971a2f77..4c6d3a06a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -183,13 +183,10 @@ class FeeManagementServiceTest { inbound.setCompanyName("客户A有限公司"); when(repository.findInbound(10001L, 21L)).thenReturn(inbound); when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); - when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(null); - doAnswer(invocation -> { - FeeItemEntity value = invocation.getArgument(0); - assertThat(value.getTenantId()).isZero(); - value.setId(11L); - return null; - }).when(repository).insertItem(any()); + FeeItemEntity freightItem = item(true, false); + freightItem.setId(11L); + freightItem.setFeeCode("FBA_HEAD_FREIGHT"); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(freightItem); InboundFeeEntity fee = new InboundFeeEntity(); fee.setId(501L); @@ -226,6 +223,26 @@ class FeeManagementServiceTest { assertThat(saved.getSettlementIncluded()).isTrue(); } + @Test + @DisplayName("系统头程运费项目缺失时拒绝运行期补建") + void rejectsMissingSystemFreightItem() { + when(repository.findInbound(10001L, 21L)).thenReturn(inbound()); + when(repository.findBySourceFee(10001L, "FBA_PRICING", 501L)).thenReturn(null); + when(repository.findItemByCode("FBA_HEAD_FREIGHT")).thenReturn(null); + + InboundFeeEntity fee = new InboundFeeEntity(); + fee.setId(501L); + fee.setInboundId(21L); + fee.setFeeStatus("SENT"); + when(pricingRepository.findFee(10001L, 501L)).thenReturn(fee); + + assertThatThrownBy(() -> details.publishFreight( + 10001L, 21L, 501L, 9L, "admin", LocalDateTime.of(2026, 8, 5, 12, 0))) + .isInstanceOf(BizException.class) + .hasMessageContaining("FBA头程运费未初始化"); + verify(repository, never()).insertItem(any()); + } + @Test @DisplayName("并发发布同一计费快照时返回已生成的运费明细") void reusesConcurrentFreightDetail() { diff --git a/scripts/README.md b/scripts/README.md index 16c309270..21fec795b 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,7 +5,7 @@ | 验收目标 | 唯一入口 | 数据副作用 | 覆盖范围 | |---|---|---|---| | 平台后台全模块 HTTP 验收 | `scripts/admin-test-env-acceptance.mjs` | 会创建、更新或最终停用测试数据 | `--modules` 默认列表中的平台客户、仓储、订单、入库、分柜、装柜、船期、拖车、报关、财务、通知、统计等模块 | -| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库和权限数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务和客户侧权限链路 | +| 客户工作台完整 HTTP 链路验收 | `scripts/client-test-env-acceptance.mjs` | 会创建测试企业、订单、入库、权限和站内消息数据 | 注册/审核、客户隔离、订单、入库计费、文件、财务、客户侧权限,以及我的消息未读/已读/全部已读链路 | | Client 二期兼容 HTTP 验收 | `scripts/client-phase2-test-env-acceptance.mjs` | 会写入测试环境业务数据 | 二期客户入库、测量、费用和权限兼容场景;不是替代 Client 全量入口 | 执行前必须确认目标环境、账号和数据清理/停用策略,并获得当前对话的测试环境验收授权。`admin-test-env-modules/*contract.test.mjs` 只验证模块脚本结构,不能代替 HTTP 验收。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 40a54a2ef..2e692db46 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -16,6 +16,20 @@ const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'over export function normalizeModuleOrder(modules) { const normalized = [...modules] + if (normalized.includes('fee-items')) { + const prerequisites = ['merchant-qualification-fixture', 'orders'] + const feeItemsIndex = normalized.indexOf('fee-items') + const ordered = prerequisites.every((name, index) => { + const prerequisiteIndex = normalized.indexOf(name) + const previousIndex = index === 0 ? -1 : normalized.indexOf(prerequisites[index - 1]) + return prerequisiteIndex > previousIndex && prerequisiteIndex < feeItemsIndex + }) + if (!ordered) { + const requested = normalized.filter((name) => !prerequisites.includes(name)) + requested.splice(requested.indexOf('fee-items'), 0, ...prerequisites) + normalized.splice(0, normalized.length, ...requested) + } + } const loadingIndex = normalized.indexOf('phase3-loading') const overviewIndex = normalized.indexOf('phase3-container-overview') if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 3854e9fa2..7459def79 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -7,6 +7,8 @@ - `system.mjs` - `inbound-weight-ocr.mjs` +`fee-items` 需要同一 marker 的客户订单和入库单。执行 `--modules fee-items` 时,入口自动扩展为 `merchant-qualification-fixture,orders,fee-items`,无需手工补齐前置模块。 + 模块只需导出: ```js diff --git a/scripts/admin-test-env-modules/fee-items-contract.test.mjs b/scripts/admin-test-env-modules/fee-items-contract.test.mjs index e34bc070a..f418061a3 100644 --- a/scripts/admin-test-env-modules/fee-items-contract.test.mjs +++ b/scripts/admin-test-env-modules/fee-items-contract.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' import { cleanupSucceeded, isAcceptanceFeeItem } from './fee-items.mjs' const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8') @@ -29,5 +30,18 @@ test('stale cleanup only recognizes the acceptance-owned code and name pair', () test('baseline checks run only after stale acceptance cleanup', () => { assert.ok(source.indexOf('cleanupStaleAcceptanceItems(context)') - < source.indexOf("'分页总数110且默认20条对应6页'")) + < source.indexOf("'正式目录111条且默认20条对应6页'")) +}) + +test('formal baseline includes the system FBA freight item in addition to the 110 controlled items', () => { + assert.match(source, /const BASELINE_COUNT = 111/) + assert.match(source, /const INTERNATIONAL_COUNT = 27/) + assert.match(source, /'FBA_HEAD_FREIGHT'/) +}) + +test('fee-items injects its same-marker merchant and order fixtures for focused execution', () => { + const expected = ['merchant-qualification-fixture', 'orders', 'fee-items'] + assert.deepEqual(normalizeModuleOrder(['fee-items']), expected) + assert.deepEqual(normalizeModuleOrder(['fee-items', 'orders']), expected) + assert.deepEqual(parseArguments(['--modules', 'fee-items']).modules, expected) }) diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs index 4f488ebe4..72e3d9d9d 100644 --- a/scripts/admin-test-env-modules/fee-items.mjs +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -2,6 +2,9 @@ const MODULE = 'fee-items' const SCENARIO = 'platform-public-fee-items' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const BASE = '/api/platform/v1/fee-items' +const BASELINE_COUNT = 111 +const DOMESTIC_COUNT = 84 +const INTERNATIONAL_COUNT = 27 const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' @@ -42,7 +45,10 @@ function payload(suffix, overrides = {}) { } } function expectedCodes() { - return Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`) + return [ + 'FBA_HEAD_FREIGHT', + ...Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`), + ].sort() } export function isAcceptanceFeeItem(row) { return /^ACPT_\d+$/.test(String(row?.feeCode || '')) @@ -84,8 +90,8 @@ export async function run(context) { const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 })) const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse, 'page=1,size=20', OPTIONS) - context.assert(MODULE, '分页总数110且默认20条对应6页', initialPageResponse, - records(initialPage).length === 20 && Number(initialPage?.total) === 110 + context.assert(MODULE, '正式目录111条且默认20条对应6页', initialPageResponse, + records(initialPage).length === 20 && Number(initialPage?.total) === BASELINE_COUNT && Number(initialPage?.pages) === 6 && Number(initialPage?.page) === 1, `records=${records(initialPage).length}, total=${initialPage?.total ?? 'n/a'}, pages=${initialPage?.pages ?? 'n/a'}`, OPTIONS) context.assert(MODULE, '费用项目查询不传 accountId 仍成功', initialPageResponse, @@ -97,13 +103,13 @@ export async function run(context) { const domestic = initialRows.filter((row) => row.tradeScope === 'DOMESTIC') const international = initialRows.filter((row) => row.tradeScope === 'INTERNATIONAL') const actualCodes = initialRows.map((row) => row.feeCode).sort() - context.assert(MODULE, '初始化110条全部属于平台且状态ACTIVE', initial.response, - initialRows.length === 110 && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'), + context.assert(MODULE, '正式目录111条全部属于平台且状态ACTIVE', initial.response, + initialRows.length === BASELINE_COUNT && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'), `rows=${initialRows.length}, platform=${initialRows.filter((row) => id(row.tenantId) === '0').length}, active=${initialRows.filter((row) => row.status === 'ACTIVE').length}`, OPTIONS) - context.assert(MODULE, '国内84条、国际26条范围正确', initial.response, - domestic.length === 84 && international.length === 26, + context.assert(MODULE, '国内84条、国际27条范围正确', initial.response, + domestic.length === DOMESTIC_COUNT && international.length === INTERNATIONAL_COUNT, `DOMESTIC=${domestic.length}, INTERNATIONAL=${international.length}`, OPTIONS) - context.assert(MODULE, '初始化编码完整覆盖 FEE_TYPE_001..FEE_TYPE_110', initial.response, + context.assert(MODULE, '正式编码完整覆盖110条受控项目及系统头程运费', initial.response, actualCodes.join(',') === expectedCodes().join(','), `codes=${actualCodes.length}, first=${actualCodes[0] || 'missing'}, last=${actualCodes.at(-1) || 'missing'}`, OPTIONS) @@ -115,7 +121,7 @@ export async function run(context) { Boolean(category) && records(categoryPage).length > 0 && records(categoryPage).every((row) => row.category === category), `records=${records(categoryPage).length}`, OPTIONS) - for (const [scope, expected] of [['DOMESTIC', 84], ['INTERNATIONAL', 26]]) { + for (const [scope, expected] of [['DOMESTIC', DOMESTIC_COUNT], ['INTERNATIONAL', INTERNATIONAL_COUNT]]) { const response = await context.request('GET', query({ tradeScope: scope, page: 1, size: 200 })) const page = context.apiSuccess(MODULE, `按 ${scope} 贸易范围筛选`, response, '', OPTIONS) context.assert(MODULE, `${scope} 筛选数量和范围正确`, response, @@ -135,8 +141,8 @@ export async function run(context) { const activeResponse = await context.request('GET', query({ status: 'ACTIVE', page: 1, size: 200 })) const activePage = context.apiSuccess(MODULE, '按 ACTIVE 状态筛选费用项目', activeResponse, '', OPTIONS) - context.assert(MODULE, 'ACTIVE 筛选返回110条且无停用记录', activeResponse, - Number(activePage?.total) === 110 && records(activePage).every((row) => row.status === 'ACTIVE'), + context.assert(MODULE, 'ACTIVE 筛选返回111条且无停用记录', activeResponse, + Number(activePage?.total) === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'), `total=${activePage?.total ?? 'n/a'}`, OPTIONS) const merchantLoginResponse = await context.request('POST', '/api/auth/login', { @@ -266,8 +272,8 @@ export async function run(context) { `feeItemId=${feeItemId}`, OPTIONS) } const final = await listAll(context) - context.assert(MODULE, '清理后恢复110条基线且无临时编码或名称', final.response, - final.rows.length === 110 && !final.rows.some((row) => row.feeCode === createPayload.feeCode + context.assert(MODULE, '清理后恢复111条正式基线且无临时编码或名称', final.response, + final.rows.length === BASELINE_COUNT && !final.rows.some((row) => row.feeCode === createPayload.feeCode || String(row.feeTypeName).includes(suffix)), `rows=${final.rows.length}, temporary=${final.rows.filter((row) => row.feeCode === createPayload.feeCode).length}`, OPTIONS) context.createdResources.feeItems = { diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 4b8f6254c..1f597555b 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { run } from './finance-full-fixtures.mjs' +import { buildContainerCostBody, run } from './finance-full-fixtures.mjs' const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8') @@ -204,6 +204,13 @@ test('fixture creates four real receivable manual sources before payable source' }) test('container cost uses loading plan identity and verifies snapshot fields before Finance', () => { + const body = buildContainerCostBody('RUN-1', '2088073097986560001', + { loadingPlanId: '2088073275689709570' }, '2088073280000000001') + assert.equal(body.customerId, '2088073097986560001') + assert.equal(body.containerId, '2088073275689709570') + assert.equal(JSON.parse(JSON.stringify(body)).customerId, '2088073097986560001') + assert.match(source, /customerId: String\(customerId\)/) + assert.doesNotMatch(source, /customerId: Number\(customerId\)/) assert.match(source, /containerId: String\(container\.loadingPlanId\)/) assert.doesNotMatch(source, /containerId: Number\(container\.loadingPlanId\)/) assert.match(source, /smart-loading\/plans\/\$\{encodeURIComponent\(container\.loadingPlanId\)\}/) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 37b68e495..d0f55ac86 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -262,13 +262,17 @@ async function createMarkerContainer(context, resource) { } } -async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) { - if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput) - const body = { customerId: Number(customerId), costType: 'CONTAINER_SHARED', orderId: null, +export function buildContainerCostBody(marker, customerId, container, voucherFileId) { + return { customerId: String(customerId), costType: 'CONTAINER_SHARED', orderId: null, // Finance names this field containerId, but its Firstleg cost snapshot contract // resolves it as the loading plan ID, not the physical container booking ID. - containerId: String(container.loadingPlanId), expenseName: `${context.marker}-整柜成本`, amount: 300, - currency: 'CNY', voucherFileIds: [voucherFileId], remark: context.marker } + containerId: String(container.loadingPlanId), expenseName: `${marker}-整柜成本`, amount: 300, + currency: 'CNY', voucherFileIds: [voucherFileId], remark: marker } +} + +async function createContainerCost(context, customerId, container, voucherFileId, snapshotInput) { + if (context.financeFixtureHooks?.createContainerCost) return context.financeFixtureHooks.createContainerCost(context, customerId, container, voucherFileId, snapshotInput) + const body = buildContainerCostBody(context.marker, customerId, container, voucherFileId) const response = await request(context, 'POST', '/api/platform/finance/v1/cost-entries', body) if (response?.body?.code === 'FIN_400') { const first = snapshotInput?.items?.[0] diff --git a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs index f96e5dcca..79b0525ae 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full-contract.test.mjs @@ -1,7 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { run, values } from './finance-settlement-full.mjs' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { receiptBody, run, values } from './finance-settlement-full.mjs' +const here = path.dirname(fileURLToPath(import.meta.url)) +const source = fs.readFileSync(path.join(here, 'finance-settlement-full.mjs'), 'utf8') const response = (data, http = 200, code = '200') => ({ http, body: { code, data } }) function fakeContext(overrides = {}) { @@ -63,6 +68,16 @@ test('values helper supports nested arrays while run consumes frozen top-level a assert.deepEqual(values({ marker: 'x', selected: { id: 3 } }), [{ id: 3 }]) }) +test('receipt body preserves 19-digit customer and voucher IDs through JSON', () => { + const body = receiptBody({ marker: 'RUN-1' }, '2088073097986560001', + '2088073285653299201', [{ billId: 13, amount: 1 }]) + const roundTrip = JSON.parse(JSON.stringify(body)) + assert.equal(roundTrip.customerId, '2088073097986560001') + assert.deepEqual(roundTrip.voucherFileIds, ['2088073285653299201']) + assert.match(source, /customerId: String\(customerId\)/) + assert.doesNotMatch(source, /customerId: Number\(customerId\)/) +}) + test('run 使用 receipts 完成应收部分、全额及多单分配,使用 transactions 完成 AP', async () => { const context = fakeContext() await run(context) diff --git a/scripts/admin-test-env-modules/finance-settlement-full.mjs b/scripts/admin-test-env-modules/finance-settlement-full.mjs index 53fb548b9..226e98d76 100644 --- a/scripts/admin-test-env-modules/finance-settlement-full.mjs +++ b/scripts/admin-test-env-modules/finance-settlement-full.mjs @@ -25,8 +25,8 @@ function gap(context, name, scope, reason, category = 'finance-settlement-fixtur context.addCoverageGap?.(MODULE, OPTIONS.scenario, scope, reason, category, { marker: context.marker }) } -function receiptBody(context, customerId, voucherFileId, allocations) { - return { customerId: Number(customerId), receivedDate: new Date().toISOString().slice(0, 10), +export function receiptBody(context, customerId, voucherFileId, allocations) { + return { customerId: String(customerId), receivedDate: new Date().toISOString().slice(0, 10), amount: allocations.reduce((sum, item) => sum + item.amount, 0), currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [voucherFileId], remark: context.marker, allocations } } diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs index bd1ce3870..ba65e3d2b 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking-contract.test.mjs @@ -5,11 +5,17 @@ import test from 'node:test' const source = readFileSync(new URL('./manual-state-ocean-trucking.mjs', import.meta.url), 'utf8') test('retains every ocean booking and truck appointment list status through real APIs', () => { + assert.match(source, /const OCEAN_BASE = ['"]\/api\/platform\/v1\/ocean-bookings['"]/) + assert.doesNotMatch(source, /\/api\/platform\/v1\/first-leg\/ocean-bookings/) for (const status of ['DRAFT','SUBMITTED','CONFIRMED','RELEASED','FAILED','CHANGE_PENDING','CANCELLED', 'PENDING_DISPATCH','DISPATCHED','PICKUP_IN_PROGRESS','ARRIVED_WAREHOUSE','LOADING','DEPARTED', 'COMPLETED','RESCHEDULED','EXCEPTION']) assert.match(source, new RegExp(`['"]${status}['"]`)) assert.match(source, /change-request/) assert.match(source, /\/reschedule\?plannedLoadingAt=/) assert.match(source, /page=1&size=100&status=/) + assert.match(source, /customerId: oceanBooking\.customerId/) + assert.match(source, /warehouseId: oceanBooking\.warehouseId/) + assert.doesNotMatch(source, /customerId: ['"]10001['"]/) + assert.doesNotMatch(source, /warehouseId: ['"]193000000000200001['"]/) assert.doesNotMatch(source, /DELETE/) }) diff --git a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs index 3c5328b1e..96f7214e5 100644 --- a/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs +++ b/scripts/admin-test-env-modules/manual-state-ocean-trucking.mjs @@ -1,6 +1,6 @@ const MODULE = 'manual-state-ocean-trucking' const OPTIONS = { scenario: 'manual-ocean-trucking-state-matrix', deploymentBlocker: true } -const OCEAN_BASE = '/api/platform/v1/first-leg/ocean-bookings' +const OCEAN_BASE = '/api/platform/v1/ocean-bookings' const TRUCK_BASE = '/api/platform/v1/truck-appointments' const OCEAN_STATUSES = ['DRAFT', 'SUBMITTED', 'CONFIRMED', 'RELEASED', 'FAILED', 'CHANGE_PENDING', 'CANCELLED'] const TRUCK_STATUSES = ['DRAFT', 'PENDING_DISPATCH', 'DISPATCHED', 'PICKUP_IN_PROGRESS', 'ARRIVED_WAREHOUSE', @@ -71,10 +71,12 @@ async function buildOcean(context, template, status, index) { throw new Error(`unsupported ocean status ${status}`) } -function truckPayload(context, oceanBookingId, index) { +function truckPayload(context, oceanBooking, index) { const value = suffix(context, index) - return { outboundOrderId: null, oceanBookingId, containerBookingId: null, loadingPlanId: null, - warehouseId: '193000000000200001', customerId: '10001', customerName: 'IT 测试客户', + return { outboundOrderId: null, oceanBookingId: oceanBooking.id, + containerBookingId: null, loadingPlanId: null, + warehouseId: oceanBooking.warehouseId, customerId: oceanBooking.customerId, + customerName: oceanBooking.customerName, carrierName: `人工验收车队-${value}`, fleetContact: '人工验收联系人', fleetPhone: '13800000004', driverName: `人工司机-${value}`, driverPhone: '13800000005', vehicleNo: `粤B${value.slice(-6)}`, storeName: '人工验收仓', storeContact: '仓库联系人', storePhone: '13800000006', @@ -84,8 +86,8 @@ function truckPayload(context, oceanBookingId, index) { receivableAmount: 150, currency: 'RMB', remark: `${context.marker} 人工列表状态 ${index}`, expectedVersion: 0 } } -async function createTruck(context, oceanBookingId, index) { - const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBookingId, index)) +async function createTruck(context, oceanBooking, index) { + const response = await context.request('POST', TRUCK_BASE, truckPayload(context, oceanBooking, index)) const value = context.apiSuccess(MODULE, `创建拖车状态样本 ${index}`, response, '', OPTIONS) if (!value?.id) throw new Error(`创建拖车样本 ${index} 失败`) return value @@ -99,8 +101,8 @@ async function truckAction(context, value, endpoint) { return next } -async function buildTruck(context, oceanBookingId, status, index) { - let value = await createTruck(context, oceanBookingId, index) +async function buildTruck(context, oceanBooking, status, index) { + let value = await createTruck(context, oceanBooking, index) if (status === 'DRAFT') return value if (status === 'RESCHEDULED') { const response = await post(context, @@ -139,16 +141,23 @@ export async function run(context, matrix = context.createdResources.manualState for (const [index, status] of OCEAN_STATUSES.entries()) { const value = await buildOcean(context, template, status, index) const resourceId = id(value?.oceanBooking?.id); const actual = value?.oceanBooking?.status - matrix.pages.oceanBookings.fixtures[status] = { id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}` } + matrix.pages.oceanBookings.fixtures[status] = { + id: resourceId, status: actual, detailPath: `/ocean-bookings/${resourceId}`, + customerId: id(value?.oceanBooking?.customerId), customerName: value?.oceanBooking?.customerName, + warehouseId: id(value?.oceanBooking?.warehouseId), + } context.assert(MODULE, `订舱保留 ${status}`, null, resourceId && actual === status, `id=${resourceId || 'missing'}, actual=${actual || 'missing'}`, OPTIONS) await verify(context, OCEAN_BASE, status, resourceId) } - const releasedOceanId = matrix.pages.oceanBookings.fixtures.RELEASED.id + const releasedOcean = matrix.pages.oceanBookings.fixtures.RELEASED + if (!releasedOcean.id || !releasedOcean.customerId || !releasedOcean.warehouseId) { + throw new Error('RELEASED 订舱缺少拖车所需的客户或仓库归属') + } matrix.pages.truckAppointments = { requiredStatuses: TRUCK_STATUSES, fixtures: {} } for (const [index, status] of TRUCK_STATUSES.entries()) { - const value = await buildTruck(context, releasedOceanId, status, index) + const value = await buildTruck(context, releasedOcean, status, index) const resourceId = id(value?.id) matrix.pages.truckAppointments.fixtures[status] = { id: resourceId, status: value?.status, detailPath: `/truck-appointments/${resourceId}` } context.assert(MODULE, `拖车保留 ${status}`, null, resourceId && value?.status === status, diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 367f6c7b6..0481250aa 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -56,7 +56,7 @@ export async function run(context) { context.apiSuccess(MODULE, '冲突后回查订舱', afterStale, '', OPTIONS) context.assert(MODULE, '订舱冲突未部分更新', afterStale, afterData?.oceanBooking?.shippingCompanyName === edited.shippingCompanyName, `shippingCompanyName=${afterData?.oceanBooking?.shippingCompanyName || 'missing'}`, OPTIONS) let version = Number(afterData?.oceanBooking?.version) - const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d } + const action = async (name, endpoint, expected, reason) => { const r = await request(context, 'POST', `${path}/${endpoint}`, { expectedVersion: expected, reason }, { 'Idempotency-Key': `${context.marker}-ocean-${endpoint}` }); const d = data(r); context.apiSuccess(MODULE, name, r, `status=${d?.oceanBooking?.status || 'missing'}`, OPTIONS); version = Number(d?.oceanBooking?.version); return d } await action('提交订舱', 'submit', version, 'marker submit') await action('确认订舱', 'confirm', version, 'marker confirm') const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` }) @@ -79,7 +79,7 @@ export async function run(context) { expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`, voyageNo: `RV${suffix}`, estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', reason: 'release', - }, { 'Idempotency-Key': `${context.marker}-release` }) + }, { 'Idempotency-Key': `${context.marker}-ocean-release` }) const releasedDetail = data(released) context.apiSuccess(MODULE, '放舱', released, `status=${releasedDetail?.oceanBooking?.status || 'missing'}`, OPTIONS) version = Number(releasedDetail?.oceanBooking?.version) @@ -89,12 +89,48 @@ export async function run(context) { fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName) && fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === allocatedContainerBookingId), `status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) - context.createdResources.oceanBooking = { + let downstreamBooking = { id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker, containerBookingId: allocatedContainerBookingId || null, loadingPlanId: null, finalStatus: fd?.oceanBooking?.status || null, } + const flowOceanBookingId = id(containerFlow?.oceanBookingId) + if (flowOceanBookingId && id(containerFlow?.containerBookingId) && id(containerFlow?.loadingPlanId)) { + const flowPath = `${BASE}/${flowOceanBookingId}` + let flowResponse = await request(context, 'GET', flowPath) + let flow = data(flowResponse) + context.apiSuccess(MODULE, '回读实际装货柜次所属订舱', flowResponse, + `oceanBookingId=${flowOceanBookingId}`, OPTIONS) + const allocationMatches = flow?.containerAllocations?.some?.((item) => + id(item?.containerBookingId) === id(containerFlow.containerBookingId)) + context.assert(MODULE, '实际装货柜次只绑定其所属订舱', flowResponse, + allocationMatches && id(flow?.oceanBooking?.customerId) === id(containerFlow?.tenantId), + `oceanBookingId=${flowOceanBookingId}, containerBookingId=${id(containerFlow.containerBookingId)}, allocations=${flow?.containerAllocations?.length ?? 'n/a'}`, OPTIONS) + if (allocationMatches && flow?.oceanBooking?.status === 'CONFIRMED') { + const flowRelease = await request(context, 'POST', `${flowPath}/release`, { + expectedVersion: Number(flow.oceanBooking.version), billOfLadingNo: `BL-FLOW-${suffix}`, + vesselName: `装货船-${suffix}`, voyageNo: `FV${suffix}`, + estimatedSailingAt: '2026-09-01T08:00:00', estimatedArrivalAt: '2026-09-20T08:00:00', + reason: context.marker, + }, { 'Idempotency-Key': `${context.marker}-ocean-flow-release`.slice(0, 128) }) + context.apiSuccess(MODULE, '放舱实际装货柜次所属订舱', flowRelease, '', OPTIONS) + flowResponse = await request(context, 'GET', flowPath) + flow = data(flowResponse) + } + context.assert(MODULE, '实际装货柜次订舱已放舱供下游串联', flowResponse, + flow?.oceanBooking?.status === 'RELEASED' + && flow?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)), + `status=${flow?.oceanBooking?.status || 'missing'}, containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS) + if (flow?.oceanBooking?.status === 'RELEASED') { + downstreamBooking = { + id: flowOceanBookingId, customerId: id(flow.oceanBooking.customerId), marker: context.marker, + containerBookingId: id(containerFlow.containerBookingId), loadingPlanId: id(containerFlow.loadingPlanId), + finalStatus: 'RELEASED', + } + } + } + context.createdResources.oceanBooking = downstreamBooking const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`) step(context, '不存在订舱详情安全失败', missingDetail, failure(missingDetail), `HTTP=${missingDetail.http}, code=${codeOf(missingDetail)}`) } diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs index 1fea56218..da8c65d01 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures-contract.test.mjs @@ -31,7 +31,24 @@ test('入库通过真实订单测量链保留测量态并最终进入 WAIT_SPLIT assert.match(source, new RegExp(`'${status}'`)) } assert.match(source, /\.\/inbound-measurement\.mjs/) - assert.match(source, /path\.replace\('\/approve', '\/reject'\)/) + assert.match(source, /path\.replace\('\/measurement\/submit', '\/measurements\/reject'\)/) +}) + +test('FBA 状态样本为订单头和每条货物行提供四个必填标识', () => { + assert.match(source, /const shipmentId = `MANUAL-/) + assert.match(source, /const referenceId = `STATUS-/) + assert.match(source, /const upcNo =/) + assert.match(source, /upcNo, shipmentId, referenceId/) +}) + +test('测量状态截停发生在正确的 HTTP 边界', () => { + assert.match(source, /includes\(summary\.mainStatus\)/) + assert.doesNotMatch(source, /includes\(summary\.processStatus\)/) + assert.match(source, /target === 'MEASURING' && data\(response\)\?\.processStatus === 'MEASURING'/) + assert.match(source, /target === 'MEASURE_PENDING' && isSubmit/) + assert.match(source, /target === 'MEASURE_REJECTED' && isSubmit/) + assert.match(source, /measurements\/reject/) + assert.match(source, /expectedVersion: version/) }) test('入库不可独立保留状态显式阻断', () => { @@ -42,7 +59,8 @@ test('入库不可独立保留状态显式阻断', () => { assert.match(source, /processStatus=WAIT_SPLIT/) assert.match(source, /查询存量 WAIT_SPLIT 样本/) assert.match(source, /没有作废入库单 API/) - assert.match(source, /context\.assert\(MODULE, `入库列表保留 \$\{status\}`/) + assert.match(source, /recordCoverageGap\(context, `入库列表保留 \$\{status\}`/) + assert.match(source, /context\.addCoverageGap\?\./) }) test('统一订单声明合法可达状态并显式阻断废弃或无 API 状态', () => { diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index 37b291e3c..8a27e7114 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -29,6 +29,13 @@ function key(context, operation) { return `${context.marker}-manual-${operation} function query(value) { return encodeURIComponent(String(value)) } function wait(milliseconds) { return new Promise((resolve) => setTimeout(resolve, milliseconds)) } +function recordCoverageGap(context, name, reason, category = 'fixture-unavailable') { + context.addCoverageGap?.(MODULE, OPTIONS.scenario, name, reason, category, { marker: context.marker }) + context.addStep(MODULE, `${name}(coverage gap 已登记)`, null, true, reason, { + scenario: OPTIONS.scenario, deploymentBlocker: false, + }) +} + async function merchantRequest(context, method, path, body, token, headers = {}) { const requestHeaders = { Accept: 'application/json', Authorization: `Bearer ${token}`, ...headers } const payload = body === undefined ? undefined : JSON.stringify(body) @@ -74,6 +81,10 @@ async function loadFixtureInputs(context, token) { function orderPayload(context, fixture, target) { const sample = `${context.marker}-${target}` + const shipmentId = `MANUAL-${target}-${context.marker}`.slice(0, 64) + const referenceId = `STATUS-${target}-${context.marker}`.slice(0, 64) + const upcNo = `${String(context.marker).replace(/\D/g, '').slice(-11)}${ORDER_TARGETS.findIndex(([status]) => status === target) + 1}` + .slice(-12).padStart(12, '0') return { customerId: null, customerName: fixture.customerName, transportMode: 'SEA', tradeTerm: 'FOB', destinationCountryCode: fixture.destination.countryCode || 'US', @@ -85,7 +96,7 @@ function orderPayload(context, fixture, target) { carrierId: null, carrierName: null, taxMode: 'BUY_DECLARATION', pricingTaxMode: fixture.channel.pricingTaxModes[0], containerMode: 'LCL', customsInspection: false, destinationType: 'FBA', amazonWarehouseCode: fixture.destination.warehouseCode, - shipmentId: `MANUAL-${target}-${context.marker}`.slice(0, 64), referenceId: `STATUS-${target}-${context.marker}`.slice(0, 64), + shipmentId, referenceId, sender: { companyName: fixture.customerName, contactName: 'IT 测试联系人', phone: '13800138000', email: 'it-test@example.com', countryCode: 'CN', province: '广东省', city: '深圳市', district: '南山区', addressLine: `${sample} 测试地址`, postalCode: '518000' }, @@ -94,6 +105,7 @@ function orderPayload(context, fixture, target) { customsHsCode: '8518210000', packageType: 'CARTON', packageCount: 1, grossWeightKg: 10, volumeCbm: 0.024, lengthCm: 40, widthCm: 30, heightCm: 20, declaredValue: 100, currency: 'USD', dangerousGoods: false, cargoRemark: sample, skuNo: `SKU-${target}-${context.marker}`.slice(0, 64), + upcNo, shipmentId, referenceId, unitsPerPackage: 10, totalQuantity: 10, netWeightKg: 9, unitDeclaredValue: 10, material: '塑料', usage: '人工验收', model: `MODEL-${target}`, brand: 'IT-TEST' }], } @@ -132,7 +144,10 @@ async function waitForInbound(context, fixture, orderId) { for (let attempt = 0; attempt < 20; attempt += 1) { const response = await context.request('GET', path) const summary = data(response)?.summary - if (ok(response) && summary?.inboundId) return { response, summary } + if (ok(response) && summary?.inboundId + && ['WAIT_MEASURE', 'MEASURING', 'MEASURE_PENDING', 'MEASURE_REJECTED', 'WAIT_SPLIT'].includes(summary.mainStatus)) { + return { response, summary } + } if (attempt < 19) await wait(500) } return { response: null, summary: null } @@ -157,16 +172,31 @@ async function prepareInboundStatus(context, target) { const inboundId = id(order.inboundId) const tenantId = id(order.tenantId) if (!inboundId || !tenantId) throw new Error(`${target} 未生成独立待测量入库单`) + const detailPath = `/api/platform/inbounds/${query(inboundId)}?tenantId=${query(tenantId)}` context.request = async (method, path, body, token, headers) => { const isSubmit = path.includes(`/inbounds/${inboundId}/measurement/submit`) - const isApprove = path.includes(`/inbounds/${inboundId}/measurements/approve`) - if (target === 'MEASURING' && isSubmit) throw stop - if (target === 'MEASURE_PENDING' && isApprove) throw stop - if (target === 'MEASURE_REJECTED' && isApprove) { - const response = await originalRequest.call(context, method, path.replace('/approve', '/reject'), - { ...body, reason: `${fixtureMarker} 保留审核驳回样本` }, token, headers) - if (!ok(response)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${response?.http}, code=${response?.body?.code}`) + const isMeasurementQuery = method === 'GET' && path.includes(`/inbounds/${inboundId}/measurement?`) + if (isMeasurementQuery) { + const response = await originalRequest.call(context, method, path, body, token, headers) + if (target === 'MEASURING' && data(response)?.processStatus === 'MEASURING') throw stop + return response + } + if (target === 'MEASURE_PENDING' && isSubmit) { + const response = await originalRequest.call(context, method, path, body, token, headers) + if (!ok(response)) throw new Error(`MEASURE_PENDING 提交失败: HTTP=${response?.http}, code=${response?.body?.code}`) + throw stop + } + if (target === 'MEASURE_REJECTED' && isSubmit) { + const submitted = await originalRequest.call(context, method, path, body, token, headers) + if (!ok(submitted)) throw new Error(`MEASURE_REJECTED 提交失败: HTTP=${submitted?.http}, code=${submitted?.body?.code}`) + const latest = await originalRequest.call(context, 'GET', detailPath, undefined, token) + const version = Number(data(latest)?.summary?.version) + const rejected = await originalRequest.call(context, 'POST', + path.replace('/measurement/submit', '/measurements/reject'), + { measurementId: body?.measurementId, expectedVersion: version, reason: `${fixtureMarker} 保留审核驳回样本` }, + token, { 'Idempotency-Key': `${fixtureMarker}-reject`.slice(0, 128) }) + if (!ok(rejected)) throw new Error(`MEASURE_REJECTED 审核驳回失败: HTTP=${rejected?.http}, code=${rejected?.body?.code}`) throw stop } return originalRequest.call(context, method, path, body, token, headers) @@ -276,11 +306,12 @@ export async function run(context, matrix = context.createdResources.manualState } } - const legacyResponse = await context.request('GET', - `/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`) - const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS) - const legacyInbound = rows(legacyPage)[0] - if (legacyInbound?.id && legacyInbound?.orderId) { + if (!result.inbounds.WAIT_SPLIT) { + const legacyResponse = await context.request('GET', + `/api/platform/v1/inbounds?tenantId=${query(fixture.tenantId)}&processStatus=WAIT_SPLIT&page=1&size=20`) + const legacyPage = context.apiSuccess(MODULE, '查询存量 WAIT_SPLIT 样本', legacyResponse, '', OPTIONS) + const legacyInbound = rows(legacyPage)[0] + if (legacyInbound?.id && legacyInbound?.orderId) { const sample = { inboundId: id(legacyInbound.id), orderId: id(legacyInbound.orderId), tenantId: fixture.tenantId, status: 'WAIT_SPLIT', inboundCode: legacyInbound.inboundCode || null } result.inbounds.WAIT_SPLIT = sample @@ -291,6 +322,7 @@ export async function run(context, matrix = context.createdResources.manualState matrix.pages.unifiedOrders.fixtures.WAIT_SPLIT = { id: sample.orderId, status: 'WAIT_SPLIT', detailPath: `/orders/${sample.orderId}`, } + } } const blockers = { @@ -300,7 +332,7 @@ export async function run(context, matrix = context.createdResources.manualState VOIDED: 'InboundProcessStatus 声明了 VOIDED,但当前 Controller/Service 没有作废入库单 API', } for (const status of INBOUND_FILTER_STATUSES) if (!matrix.pages.inbounds.fixtures[status]) { - context.assert(MODULE, `入库列表保留 ${status}`, null, false, blockers[status], OPTIONS) + recordCoverageGap(context, `入库列表保留 ${status}`, blockers[status]) } const unifiedBlockers = { LOADING: '需要分柜、排柜及 PDA 专项独立样本', @@ -308,7 +340,7 @@ export async function run(context, matrix = context.createdResources.manualState DELIVERED: '当前后端没有完成派送并写 DELIVERED 的公开业务 API', } for (const status of UNIFIED_FILTER_STATUSES) if (!matrix.pages.unifiedOrders.fixtures[status]) { - context.assert(MODULE, `统一订单列表保留 ${status}`, null, false, unifiedBlockers[status], OPTIONS) + recordCoverageGap(context, `统一订单列表保留 ${status}`, unifiedBlockers[status]) } context.addStep(MODULE, '登记订单/入库人工状态矩阵', null, true, diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index bd45df178..d8e62cd86 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -39,14 +39,15 @@ export async function run(context) { if (overview?.pda) { const expectsPhotos = fixture === context.createdResources?.phase3Loading && Array.isArray(fixture?.evidenceTypes) && fixture.evidenceTypes.length > 0 + const requiredFields = ['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount'] + const missingFields = requiredFields.filter((field) => overview.pda[field] === undefined) context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse, - ['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount'] - .every((field) => overview.pda[field] !== undefined) + missingFields.length === 0 && Array.isArray(overview.pda.photoCounts) && Array.isArray(overview.pda.photos) && Array.isArray(overview.pda.exceptionReasons) && (!expectsPhotos || overview.pda.photos.length > 0), - `taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos.length}`, OPTIONS) + `taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos?.length ?? 'not-array'}, missingFields=${missingFields.join('|') || 'none'}, photoCountsArray=${Array.isArray(overview.pda.photoCounts)}, photosArray=${Array.isArray(overview.pda.photos)}, exceptionReasonsArray=${Array.isArray(overview.pda.exceptionReasons)}`, OPTIONS) for (const [index, photo] of overview.pda.photos.entries()) { context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse, ['evidenceId', 'fileId', 'status', 'downloadPath'].every((field) => photo[field] !== undefined), diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index da25e570c..1f55b18f8 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -809,7 +809,8 @@ export async function run(context) { missingTypeVoidedEvidenceId: id(missingTypeTarget?.id), replacementEvidenceFileId: replacementFileId, immutableEvidenceId: id(firstPhoto?.id), immutableEvidenceVersion: Number(firstPhoto?.version), postLoadedFileId, - loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, + oceanBookingId: bookingId, loadingPlanId, containerBookingId, containerNo, warehouseId, + canonicalPdaTaskId: canonicalTaskId, pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion, atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified, inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified, diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs new file mode 100644 index 000000000..8b6222262 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -0,0 +1,34 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' + +const source = readFileSync(new URL('./phase3-order-splitting.mjs', import.meta.url), 'utf8') + +test('each remainder follows confirmed booking before scoped smart-loading candidates', () => { + assert.match(source, /POST', '\/api\/platform\/v1\/ocean-bookings'/) + assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/submit/) + assert.match(source, /ocean-bookings\/\$\{oceanBookingId\}\/confirm/) + assert.match(source, /containerAllocations\?\.\[0\]\?\.containerBookingId/) + assert.match(source, /candidates\?bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=\$\{encodeURIComponent\(warehouseId\)\}/) + assert.match(source, /`\$\{SMART\}\/plans`, \{ bookingId: containerId/) + assert.doesNotMatch(source, /request\(context, 'POST', CONTAINERS/) + assert.doesNotMatch(source, /candidates\?warehouseId=/) +}) + +test('PDA opens only after a real container number and uses stable plan identity', () => { + const pushIndex = source.indexOf("`${SMART}/plans/${planId}/push-preload`") + const transportIndex = source.indexOf("`${CONTAINERS}/${encodeURIComponent(containerId)}/transport`") + const taskIndex = source.indexOf("`${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`") + assert.ok(pushIndex >= 0 && transportIndex > pushIndex && taskIndex > transportIndex) + assert.match(source, /containerNo: realContainerNo/) + assert.match(source, /id\(task\?\.loadingPlanId\) === planId/) + assert.match(source, /id\(task\?\.containerBookingId\) === containerId/) + assert.doesNotMatch(source, /\$\{PDA\}\/tasks\?containerNo=/) +}) + +test('booking freezes the source customer channel and warehouse scope', () => { + assert.match(source, /customerId: source\.tenantId/) + assert.match(source, /channelCode: source\.channelCode/) + assert.match(source, /warehouseId: source\.warehouseId/) + assert.match(source, /containerRequirements: \[\{ containerType: '40HQ'/) +}) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index 6d1612664..558140976 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -116,25 +116,52 @@ function snapshotMatches(tree, original, loaded, remaining, activeSuffix) { && activeMatches && quantityConserved(tree) } -function containerPayload(marker, sequence, warehouseId, candidate) { +function bookingPayload(marker, sequence, source) { const suffix = `${marker}-${sequence}`.replace(/[^A-Za-z0-9]/g, '').slice(-18) return { - containerNo: `SPLIT-${suffix}`.slice(0, 64), cabinetNo: `SI-${suffix}`.slice(0, 64), - warehouseId, warehouseName: null, billOfLadingNo: `BL-${suffix}`.slice(0, 64), - vesselName: `SPLIT-VESSEL-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), - containerType: '40HQ', shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128), + customerId: source.tenantId, customerName: source.customerName, + channelCode: source.channelCode, channelName: source.channelName, + warehouseId: source.warehouseId, warehouseName: source.warehouseName, + shippingCompanyId: null, shippingCompanyName: `SPLIT-SHIPPING-${suffix}`.slice(0, 128), + bookingAgentId: null, bookingAgentName: `SPLIT-AGENT-${suffix}`.slice(0, 128), departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', - destinationWarehouseCode: candidate.destinationWarehouseCode || null, - destinationWarehouseName: candidate.destinationWarehouseName || null, - capacityCbm: 100, maxWeightKg: 100000, terminalTruckingCompany: `SPLIT-TRUCK-${suffix}`.slice(0, 128), - loadingAt: '2026-09-10T08:00:00', estimatedSailingAt: '2026-09-11T08:00:00', - estimatedPortArrivalAt: '2026-09-28T08:00:00', estimatedWarehouseArrivalAt: '2026-09-29T08:00:00', + placeOfDelivery: 'LAX', serviceTerm: 'CY/CY', freightTerm: 'PREPAID', remark: marker, + parties: [{ partyType: 'SHIPPER', partyName: `SPLIT-SHIPPER-${suffix}` }, + { partyType: 'CONSIGNEE', partyName: `SPLIT-CONSIGNEE-${suffix}` }, + { partyType: 'NOTIFY_PARTY', partyName: `SPLIT-NOTIFY-${suffix}` }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, + maxWeightKg: 100000, quantity: 1 }], } } async function loadOneFragment(context, state) { const { tenantId, rootOrderId, sourceOrderId, sourceOrderNo, warehouseId, sourcePackageCount, sequence } = state - const candidatesResponse = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`) + const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) + const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', { + expectedVersion: 0, data: bookingPayload(context.marker, sequence, state), + }, { 'Idempotency-Key': `${operationBase}-booking-create` }) + const bookingCreated = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建渠道仓库一致的订舱`, bookingCreate, '', OPTIONS) + const oceanBookingId = id(bookingCreated?.oceanBooking?.id) + if (!oceanBookingId) return null + context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(oceanBookingId) + + const bookingSubmit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/submit`, { + expectedVersion: Number(bookingCreated?.oceanBooking?.version), reason: context.marker, + }, { 'Idempotency-Key': `${operationBase}-booking-submit` }) + const bookingSubmitted = context.apiSuccess(MODULE, `提交 ${sourceOrderNo} 的订舱`, bookingSubmit, '', OPTIONS) + const bookingConfirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${oceanBookingId}/confirm`, { + expectedVersion: Number(bookingSubmitted?.oceanBooking?.version), reason: context.marker, + }, { 'Idempotency-Key': `${operationBase}-booking-confirm` }) + const bookingConfirmed = context.apiSuccess(MODULE, `确认 ${sourceOrderNo} 的订舱并自动分配柜次`, bookingConfirm, '', OPTIONS) + const containerId = id(bookingConfirmed?.containerAllocations?.[0]?.containerBookingId) + context.assert(MODULE, `${sourceOrderNo} 订舱确认后取得稳定柜次`, bookingConfirm, + isPositiveId(containerId) && id(bookingConfirmed?.oceanBooking?.id) === oceanBookingId, + `oceanBookingId=${oceanBookingId}, containerBookingId=${containerId || 'missing'}`, OPTIONS) + if (!containerId) return null + context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId) + + const candidatesPath = `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&warehouseId=${encodeURIComponent(warehouseId)}` + const candidatesResponse = await context.request('GET', candidatesPath) const candidates = context.apiSuccess(MODULE, `查询余量 ${sourceOrderNo} 的真实装柜候选`, candidatesResponse, '', OPTIONS) const candidate = Array.isArray(candidates) ? candidates.find((row) => id(row.orderId) === sourceOrderId && id(row.accountId) === tenantId) @@ -145,20 +172,12 @@ async function loadOneFragment(context, state) { `orderId=${sourceOrderId}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS) if (!candidate?.inboundId || !candidate?.inboundDetailId) return null - const createContainer = await request(context, 'POST', CONTAINERS, - containerPayload(context.marker, sequence, warehouseId, candidate)) - const container = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建隔离柜次`, createContainer, '', OPTIONS) - const containerId = id(container?.id) - if (!containerId) return null - context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(containerId) - const createPlan = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker }) const plan = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 创建装柜计划`, createPlan, '', OPTIONS) const planId = id(plan?.id) if (!planId) return null context.createdResources.phase3OrderSplitting.retainedAuditRecordIds.push(planId) - const operationBase = `${context.marker}-chain-${sequence}`.slice(0, 100) const assignPayload = { inboundId: id(candidate.inboundId), inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, expectedVersion: Number(plan.version), operationId: `${operationBase}-assign`, @@ -180,16 +199,31 @@ async function loadOneFragment(context, state) { const push = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': `${operationBase}-push` }) - const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的真实 PDA 任务`, push, '', OPTIONS) - if (pushed?.status !== 'LOADING') return null + const pushed = context.apiSuccess(MODULE, `推送 ${sourceOrderNo} 的预装计划`, push, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 未补真实柜号时计划保持 EDITING`, push, + pushed?.status === 'EDITING', `status=${pushed?.status || 'missing'}`, OPTIONS) + if (pushed?.status !== 'EDITING') return null + + const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}`) + const containerDetail = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 的自动柜次`, containerDetailResponse, '', OPTIONS) + const realContainerNo = `SPLIT-${operationBase.replace(/[^A-Za-z0-9]/g, '').slice(-18)}`.slice(0, 64) + const transportResponse = await request(context, 'PATCH', `${CONTAINERS}/${encodeURIComponent(containerId)}/transport`, { + containerNo: realContainerNo, voyageNo: `V-${sequence}`, + version: Number(containerDetail?.version), reason: context.marker, + }) + const transported = context.apiSuccess(MODULE, `为 ${sourceOrderNo} 补充真实柜号并开放 PDA`, transportResponse, '', OPTIONS) + context.assert(MODULE, `${sourceOrderNo} 真实柜号已绑定到稳定柜次`, transportResponse, + transported?.containerNo === realContainerNo, + `containerBookingId=${containerId}, containerNo=${transported?.containerNo || 'missing'}`, OPTIONS) const taskResponse = await context.request('GET', - `${PDA}/tasks?containerNo=${encodeURIComponent(container.containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + `${PDA}/tasks?loadingPlanId=${encodeURIComponent(planId)}&warehouseId=${encodeURIComponent(warehouseId)}`) const task = context.apiSuccess(MODULE, `读取 ${sourceOrderNo} 的 PDA 任务`, taskResponse, '', OPTIONS) const taskId = id(task?.id) const pickItem = task?.items?.find?.((row) => id(row.loadingPlanItemId) === id(planItem.id)) context.assert(MODULE, `余量 ${sourceOrderNo} 不继承已拣数量`, taskResponse, - Boolean(taskId) && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1 + Boolean(taskId) && id(task?.loadingPlanId) === planId && id(task?.containerBookingId) === containerId + && task?.currentContainerNo === realContainerNo && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1 && Number(pickItem?.pickedPackageCount) === 0, `taskId=${taskId || 'missing'}, picked=${pickItem?.pickedPackageCount ?? 'missing'}, planned=${pickItem?.plannedPackageCount ?? 'missing'}`, OPTIONS) if (!taskId || !pickItem?.id) return null @@ -237,13 +271,13 @@ async function loadOneFragment(context, state) { const treeResponse = await context.request('GET', treePath(rootOrderId, tenantId)) const tree = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 拆分后的根聚合`, treeResponse, '', OPTIONS) - const candidatesAfter = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`) + const candidatesAfter = await context.request('GET', candidatesPath) const candidateRowsAfter = context.apiSuccess(MODULE, `回查 ${sourceOrderNo} 不可重复消费`, candidatesAfter, '', OPTIONS) context.assert(MODULE, `来源 ${sourceOrderNo} 同时最多成功拆分一次`, candidatesAfter, Array.isArray(candidateRowsAfter) && !candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId), `sourceOrderId=${sourceOrderId}, stillCandidate=${Array.isArray(candidateRowsAfter) && candidateRowsAfter.some((row) => id(row.orderId) === sourceOrderId)}`, OPTIONS) - return { tree, treeResponse, containerId, planId, taskId, sourceInboundId: id(candidate.inboundId) } + return { tree, treeResponse, oceanBookingId, containerId, planId, taskId, sourceInboundId: id(candidate.inboundId) } } export async function run(context) { @@ -263,10 +297,15 @@ export async function run(context) { const inbound = context.apiSuccess(MODULE, '读取本轮来源入库单', inboundResponse, `tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS) const orderId = id(inbound?.summary?.orderId) + const channelCode = id(inbound?.summary?.channelCode || fixture?.channelCode) + const channelName = inbound?.summary?.channelName || fixture?.channelName || channelCode + const warehouseName = inbound?.summary?.warehouseName || fixture?.warehouseName + const customerName = inbound?.summary?.customerName || `验收客户-${tenantId}` context.assert(MODULE, '来源入库单归属本轮客户与根订单', inboundResponse, - isPositiveId(orderId) && id(inbound?.summary?.tenantId) === tenantId, - `orderId=${orderId || 'missing'}, tenantId=${id(inbound?.summary?.tenantId) || 'missing'}`, OPTIONS) - if (!orderId) return + isPositiveId(orderId) && id(inbound?.summary?.tenantId) === tenantId + && Boolean(channelCode && channelName && warehouseName), + `orderId=${orderId || 'missing'}, tenantId=${id(inbound?.summary?.tenantId) || 'missing'}, channelCode=${channelCode || 'missing'}, warehouseName=${warehouseName || 'missing'}`, OPTIONS) + if (!orderId || !channelCode || !channelName || !warehouseName) return const firstTreeResponse = await context.request('GET', treePath(orderId, tenantId)) const firstTree = context.apiSuccess(MODULE, '查询首次拆分后的根履约树', firstTreeResponse, @@ -340,7 +379,8 @@ export async function run(context) { const result = await loadOneFragment(context, { tenantId, rootOrderId: orderId, sourceOrderId: id(source.orderId), sourceOrderNo: source.orderNo, - warehouseId, sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix, + customerName, channelCode, channelName, warehouseId, warehouseName, + sourcePackageCount: step.sourcePackageCount, sequence: step.sourceSuffix, }) if (!result?.tree) return tree = result.tree diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 8f8116499..61c00b559 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -277,6 +277,12 @@ test('downstream producers prefer the phase3Loading container fixture', () => { assert.match(customs, /createdResources\?\.phase3Loading,\s*\n\s*createdResources\?\.phase3ContainerLoading/) assert.match(serviceCenter, /context\.createdResources\?\.phase3Loading\s*\|\|\s*context\.createdResources\?\.phase3ContainerLoading/) assert.match(ocean, /request\(context, 'POST', `\$\{path\}\/release`/) + assert.match(ocean, /flowOceanBookingId = id\(containerFlow\?\.oceanBookingId\)/) + assert.match(ocean, /ocean-flow-release/) + assert.match(ocean, /containerBookingId: id\(containerFlow\.containerBookingId\), loadingPlanId: id\(containerFlow\.loadingPlanId\)/) + assert.match(source('./phase3-loading.mjs'), /oceanBookingId: bookingId, loadingPlanId, containerBookingId/) + assert.doesNotMatch(ocean, /`\$\{context\.marker\}-\$\{endpoint\}`/) + assert.match(ocean, /`\$\{context\.marker\}-ocean-\$\{endpoint\}`/) }) test('default acceptance order verifies loading evidence before finalizing LOADED', () => { diff --git a/scripts/client-notifications-contract.test.mjs b/scripts/client-notifications-contract.test.mjs new file mode 100644 index 000000000..18f361df9 --- /dev/null +++ b/scripts/client-notifications-contract.test.mjs @@ -0,0 +1,44 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const scriptsDir = dirname(fileURLToPath(import.meta.url)) +const source = readFileSync(resolve(scriptsDir, 'client-test-env-acceptance.mjs'), 'utf8') + +test('Client full acceptance executes the real notification inbox flow', () => { + assert.match(source, /await isolationModule\(\)\s+await notificationInboxModule\(\)\s+await accountSecurityModule\(\)/) + assert.match(source, /async function notificationInboxModule\(\)/) + for (const permission of [ + 'merchant:notification:view', + 'merchant:notification:read', + 'merchant:notification:read-all', + ]) assert.match(source, new RegExp(`['"]${permission}['"]`)) +}) + +test('notification delivery targets only the newly registered customer owner', () => { + assert.match(source, /tenantId:\s*String\(report\.resources\.tenantId\)/) + assert.match(source, /recipientType:\s*['"]MEMBERSHIP['"]/) + assert.match(source, /recipientIds:\s*\[String\(report\.resources\.membershipId\)\]/) + assert.match(source, /await sendMessage\(1\)/) + assert.match(source, /await sendMessage\(2\)/) + assert.match(source, /other client cannot read first message detail/) + assert.match(source, /other client cannot see acceptance messages in inbox/) + assert.match(source, /\['A0403', 'A0404'\]\.includes\(String\(json\?\.code\)\)/) +}) + +test('notification flow covers unread, single read, read-all, and cleanup', () => { + for (const contract of [ + /status=UNREAD/, + /status=READ/, + /\/unread-count/, + /\$\{firstMessageId\}\/read/, + /\/read-all/, + /single-message read is idempotent/, + /cleanup keeps acceptance messages read/, + /cleanup leaves no unread acceptance messages/, + /targetStatus:\s*['"]DISABLED['"]/, + /retainedAsRead:\s*true/, + ]) assert.match(source, contract) +}) diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 51c8b8ed8..034057e97 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -70,6 +70,8 @@ const report = { channelCode: null, roleId: null, testMembershipId: null, + notificationTemplateId: null, + notificationMessageIds: [], orderIds: [], }, steps: [], @@ -92,6 +94,7 @@ try { await rbacAndMemberModule() await orderLifecycleModule() await isolationModule() + await notificationInboxModule() await accountSecurityModule() report.success = report.steps.every((step) => step.passed) } catch (error) { @@ -547,7 +550,7 @@ async function rbacAndMemberModule() { assertSetEqual( visibleClientMenus(arrayData(ownerPermissions.json.data)), ['account:profile:view', 'company:member:list', 'company:permission:list', 'company:profile:view', - 'company:role:list', 'dashboard:view', 'order:first-leg:list'], + 'company:role:list', 'dashboard:view', 'notification:inbox:view', 'order:first-leg:list'], 'owner visible Client menus', ) @@ -1340,6 +1343,240 @@ async function isolationModule() { }, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code })) } +async function notificationInboxModule() { + const module = 'notification-inbox' + const templateBase = '/api/platform/v1/notification-templates' + const inboxBase = '/api/merchant/v1/notifications' + const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-24).toUpperCase() + const templateCode = `CLIENT_INBOX_${suffix}`.slice(0, 64) + const category = 'CLIENT_ACCEPTANCE' + let template = null + + try { + const createTemplate = await apiStep(module, 'platform creates client inbox template', { + method: 'POST', path: templateBase, token: platformToken, + body: { + templateCode, + templateName: `客户消息闭环-${marker}`.slice(0, 128), + category, + titleTemplate: '客户消息验收 {{sequence}}', + bodyTemplate: '客户 {{marker}} 的第 {{sequence}} 条真实站内消息。', + variableWhitelist: ['marker', 'sequence'], + }, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.id, 'notification template id') + assertEqual(json.data?.status, 'DRAFT', 'notification template status') + assertEqual(json.data?.channel, 'IN_APP', 'notification template channel') + }, ({ json }) => ({ templateId: json.data.id, templateCode: json.data.templateCode, status: json.data.status })) + template = createTemplate.json.data + report.resources.notificationTemplateId = template.id + + const activateTemplate = await apiStep(module, 'platform activates client inbox template', { + method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken, + body: { targetStatus: 'ACTIVE', version: template.version }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'ACTIVE', 'notification template status') + }, ({ json }) => ({ templateId: json.data.id, status: json.data.status, version: json.data.version })) + template = activateTemplate.json.data + + await apiStep(module, 'client normalizes existing inbox messages to read', { + method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Number(json.data?.updatedCount) >= 0, 'updatedCount must be non-negative') + }, ({ json }) => ({ updatedCount: json.data.updatedCount })) + + await apiStep(module, 'client unread baseline is zero', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'unread baseline') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + const sendMessage = async (sequence) => apiStep(module, `platform sends client message ${sequence}`, { + method: 'POST', path: '/api/platform/v1/notifications', token: platformToken, + body: { + tenantId: String(report.resources.tenantId), + templateCode, + recipientType: 'MEMBERSHIP', + recipientIds: [String(report.resources.membershipId)], + variables: { marker, sequence: String(sequence) }, + scheduledAt: null, + idempotencyKey: newIdempotencyKey(`client-notification-${sequence}`), + sourceEventId: `client-e2e-notification-${sequence}-${marker}`.slice(0, 128), + }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'SENT', 'notification task status') + assertEqual(Number(json.data?.recipientCount), 1, 'notification recipient count') + }, ({ json }) => ({ taskId: json.data.id, taskNo: json.data.taskNo, status: json.data.status, recipientCount: json.data.recipientCount })) + + await sendMessage(1) + + await apiStep(module, 'client unread count increments after first delivery', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 1, 'unread count after first delivery') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + const firstUnreadPage = await apiStep(module, 'client filters first message as unread', { + method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const message = pageRecords(json.data).find((item) => item.title === `客户消息验收 1`) + assertPresent(message?.id, 'first unread notification id') + assertEqual(message.status, 'UNREAD', 'first notification status') + }, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) })) + const firstMessage = pageRecords(firstUnreadPage.json.data).find((item) => item.title === '客户消息验收 1') + const firstMessageId = firstMessage.id + report.resources.notificationMessageIds.push(firstMessageId) + + await apiStep(module, 'client reads first message detail while it is unread', { + method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.id), String(firstMessageId), 'first notification id') + assertEqual(json.data?.status, 'UNREAD', 'first notification detail status') + assertTrue(String(json.data?.body).includes(marker), 'first notification body must contain marker') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, category: json.data.category })) + + if (existingClientToken) { + await apiStep(module, 'other client cannot read first message detail', { + method: 'GET', path: `${inboxBase}/${firstMessageId}`, token: existingClientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), `expected A0403/A0404, actual=${json?.code}`) + }, ({ json }) => ({ messageId: firstMessageId, rejectionCode: json.code })) + } else { + recordSkipped(module, 'other client cannot read first message detail', 'Cross-client credentials were not supplied') + } + + await apiStep(module, 'client marks first message read', { + method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READ', 'first notification status after read') + assertPresent(json.data?.readAt, 'first notification readAt') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt })) + + await apiStep(module, 'single-message read is idempotent', { + method: 'PUT', path: `${inboxBase}/${firstMessageId}/read`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READ', 'idempotent notification status') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt })) + + await apiStep(module, 'client filters first message as read', { + method: 'GET', path: `${inboxBase}?page=1&size=20&status=READ&category=${category}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(pageRecords(json.data).some((item) => String(item.id) === String(firstMessageId) && item.status === 'READ'), + 'read filter must contain first notification') + }, ({ json }) => ({ total: json.data.total, readIds: pageRecords(json.data).map((item) => item.id) })) + + await apiStep(module, 'client unread count returns to zero after single read', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'unread count after single read') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + await sendMessage(2) + const secondUnreadPage = await apiStep(module, 'client receives second unread message', { + method: 'GET', path: `${inboxBase}?page=1&size=20&status=UNREAD&category=${category}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + const message = pageRecords(json.data).find((item) => item.title === '客户消息验收 2') + assertPresent(message?.id, 'second unread notification id') + assertEqual(message.status, 'UNREAD', 'second notification status') + }, ({ json }) => ({ total: json.data.total, unreadIds: pageRecords(json.data).map((item) => item.id) })) + const secondMessage = pageRecords(secondUnreadPage.json.data).find((item) => item.title === '客户消息验收 2') + const secondMessageId = secondMessage.id + report.resources.notificationMessageIds.push(secondMessageId) + + if (existingClientToken) { + await apiStep(module, 'other client cannot see acceptance messages in inbox', { + method: 'GET', path: `${inboxBase}?page=1&size=200&category=${category}`, token: existingClientToken, + }, ({ json, status }) => { + assertEqual(status, 200, 'HTTP status') + if (['A0403', 'A0404'].includes(String(json?.code))) return + requireSuccess(json) + const foreignIds = new Set(pageRecords(json.data).map((item) => String(item.id))) + assertTrue(!foreignIds.has(String(firstMessageId)) && !foreignIds.has(String(secondMessageId)), + 'other client inbox must exclude acceptance messages') + }, ({ json }) => ({ rejectionCode: json?.code === '200' ? null : json?.code, + visibleCount: json?.code === '200' ? pageRecords(json.data).length : null, + excludedIds: [firstMessageId, secondMessageId] })) + } else { + recordSkipped(module, 'other client cannot see acceptance messages in inbox', 'Cross-client credentials were not supplied') + } + + await apiStep(module, 'client unread count increments after second delivery', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 1, 'unread count after second delivery') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + + await apiStep(module, 'client marks all messages read', { + method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.updatedCount), 1, 'read-all updatedCount') + }, ({ json }) => ({ updatedCount: json.data.updatedCount })) + + await apiStep(module, 'second message detail is read after read-all', { + method: 'GET', path: `${inboxBase}/${secondMessageId}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'READ', 'second notification status after read-all') + assertPresent(json.data?.readAt, 'second notification readAt') + }, ({ json }) => ({ messageId: json.data.id, status: json.data.status, readAt: json.data.readAt })) + + await apiStep(module, 'client unread count is zero after read-all', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'unread count after read-all') + }, ({ json }) => ({ unreadCount: json.data.unreadCount })) + } finally { + if (merchantToken && report.resources.notificationMessageIds.length > 0) { + await softApiStep(module, 'cleanup keeps acceptance messages read', { + method: 'PUT', path: `${inboxBase}/read-all`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Number(json.data?.updatedCount) >= 0, 'cleanup updatedCount must be non-negative') + }, ({ json }) => ({ updatedCount: json?.data?.updatedCount })) + await softApiStep(module, 'cleanup leaves no unread acceptance messages', { + method: 'GET', path: `${inboxBase}/unread-count`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(Number(json.data?.unreadCount), 0, 'cleanup unread count') + }, ({ json }) => ({ unreadCount: json?.data?.unreadCount })) + } + if (template?.status === 'ACTIVE') { + const disabled = await softApiStep(module, 'platform disables client inbox template', { + method: 'PUT', path: `${templateBase}/${template.id}/status`, token: platformToken, + body: { targetStatus: 'DISABLED', version: template.version }, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.status, 'DISABLED', 'notification template final status') + }, ({ json }) => ({ templateId: json?.data?.id, status: json?.data?.status, version: json?.data?.version })) + if (disabled?.json?.data) template = disabled.json.data + } + report.notificationInbox = { + templateId: report.resources.notificationTemplateId, + templateCode, + templateStatus: template?.status ?? null, + messageIds: [...report.resources.notificationMessageIds], + retainedAsRead: true, + } + } +} + async function accountSecurityModule() { const consumedRefreshToken = merchantRefreshToken const rotated = await apiStep('account-security', 'rotate refresh token', { @@ -1830,6 +2067,7 @@ function requiredOwnerBackendPermissions() { 'merchant:member:edit', 'merchant:member:grant-role', 'merchant:member:status', 'merchant:member:reset-password', 'merchant:role:list', 'merchant:role:create', 'merchant:role:edit', 'merchant:role:status', 'merchant:role:delete', 'merchant:role:grant', + 'merchant:notification:view', 'merchant:notification:read', 'merchant:notification:read-all', ] } @@ -1840,6 +2078,7 @@ function visibleClientMenus(backendPermissions) { if (permissions.has('merchant:profile:view')) menus.push('company:profile:view') if (permissions.has('merchant:member:list')) menus.push('company:member:list') if (permissions.has('merchant:role:list')) menus.push('company:role:list', 'company:permission:list') + if (permissions.has('merchant:notification:view')) menus.push('notification:inbox:view') return menus.sort() } diff --git a/scripts/local-db-full-acceptance.ps1 b/scripts/local-db-full-acceptance.ps1 index 27d0b69f5..26624757a 100644 --- a/scripts/local-db-full-acceptance.ps1 +++ b/scripts/local-db-full-acceptance.ps1 @@ -11,8 +11,8 @@ $ErrorActionPreference = 'Stop' $Repo = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Workspace = (Resolve-Path (Join-Path $Repo '..')).Path $Acceptance = Join-Path $Workspace '.codex-tmp\p23-04-acceptance' -$ExpectedSha256 = '9F804D3C0133BDEA888202215E71191E22DAAB0649A2CD26C09923E3FCB57899' -$ExpectedLines = 14812 +$ExpectedSha256 = '760747FCA4706FEBA7D1D69994633875D7BE5932BB4A6B4F30A6B9BAB719FAD8' +$ExpectedLines = 14940 if ($TargetHost -notin @('127.0.0.1', 'localhost')) { [Console]::Error.WriteLine('Refusing non-local database; only 127.0.0.1 or localhost is allowed.')