From 5bc62a109e9645c4cfddd6f5d3aba51257189f17 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 13 Aug 2026 20:00:30 +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 --- Jenkinsfile | 20 ++- auth/AGENTS.md | 5 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 7 + .../client/FirstlegCostSnapshotClient.java | 10 +- .../controller/PlatformFinanceController.java | 14 +- finance/src/main/resources/db/finance.sql | 7 + .../baiyue/finance/FinanceContractTest.java | 2 +- .../FinanceSubjectSeedContractTest.java | 25 +++ ...PlatformFinanceControllerBehaviorTest.java | 38 ++++- firstleg/AGENTS.md | 5 +- .../dto/InboundMeasurementResponses.java | 3 + ...InternalFinanceCostSnapshotController.java | 8 +- .../service/FinanceCostSnapshotService.java | 17 +- .../service/InboundMeasurementService.java | 1 + ...rnalFinanceCostSnapshotControllerTest.java | 44 +++++ .../FinanceCostSnapshotServiceTest.java | 31 ++-- .../InboundMeasurementServiceTest.java | 10 +- scripts/admin-test-env-acceptance.mjs | 31 +++- scripts/admin-test-env-modules/README.md | 13 ++ .../finance-full-fixtures-contract.test.mjs | 38 ++++- .../finance-full-fixtures.mjs | 54 +++--- .../notifications-contract.test.mjs | 38 +++++ .../admin-test-env-modules/notifications.mjs | 160 ++++++++++++++++++ .../admin-test-env-modules/ocean-bookings.mjs | 26 +-- .../phase3-container-overview.mjs | 17 +- .../admin-test-env-modules/phase3-loading.mjs | 108 +++++------- .../phase3-producer-chain-contract.test.mjs | 29 +++- scripts/controller-pipeline-contract.test.mjs | 17 ++ 28 files changed, 602 insertions(+), 176 deletions(-) create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotControllerTest.java create mode 100644 scripts/admin-test-env-modules/notifications-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/notifications.mjs create mode 100644 scripts/controller-pipeline-contract.test.mjs diff --git a/Jenkinsfile b/Jenkinsfile index 478054b9e..c9a2d0c64 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -24,6 +24,8 @@ def commonPaths = [ 'Jenkinsfile.service', 'deploy/' ] +def databaseMigrationPaths = ['deploy/sql/'] + def downstreamResults = [:] pipeline { @@ -83,6 +85,7 @@ pipeline { error('DOWNSTREAM_JOB_PREFIX 不能为空') } env.RESOLVED_JOB_PREFIX = jobPrefix + env.DATABASE_CHANGE_DETECTED = 'false' List selected = [] if (params.BUILD_MODE == 'ALL') { @@ -101,8 +104,13 @@ pipeline { } else { List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true) .trim().split('\\n').collect { it.trim() }.findAll { it } + boolean databaseChanged = changed.any { path -> + databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } + } + env.DATABASE_CHANGE_DETECTED = databaseChanged ? 'true' : 'false' boolean commonChanged = changed.any { path -> - commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) } + boolean databaseMigration = databaseMigrationPaths.any { prefix -> path.startsWith(prefix) } + !databaseMigration && commonPaths.any { prefix -> path == prefix || path.startsWith(prefix) } } selected = commonChanged ? serviceModules : serviceModules.findAll { module -> changed.any { it == module || it.startsWith("${module}/") } @@ -119,13 +127,19 @@ pipeline { env.SELECTED_SERVICES = selected.join(',') env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { - currentBuild.description = '没有需要构建的业务服务' + currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true' + ? '仅数据库迁移变更 | 需单独审核执行' + : '没有需要构建的业务服务' } else { env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() env.IMAGE_TAG = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() - currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}" + String databaseSuffix = env.DATABASE_CHANGE_DETECTED == 'true' ? ' | DB migration pending' : '' + currentBuild.description = "${env.SELECTED_SERVICES} | ${env.IMAGE_TAG}${databaseSuffix}" echo "本次独立调度服务: ${env.SELECTED_SERVICES}" } + if (env.DATABASE_CHANGE_DETECTED == 'true') { + echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。' + } } } } diff --git a/auth/AGENTS.md b/auth/AGENTS.md index 6da6f0c76..2b49bd724 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -12,7 +12,8 @@ |---|---| | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | | 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.java` | -| Token 与会话 | `service/AuthService.java`、`service/TokenSessionStore.java` | +| 小 JWT 签发 | `service/AuthService.java`;JWT 只承载稳定身份、应用域和版本声明,角色/权限随 Redis 会话加载 | +| Token 会话与权限快照 | `service/TokenSessionStore.java`、`client/AuthorizationLoader.java`、`client/IamAuthorizationClient.java`;消费端映射见 `../common-security/src/main/java/com/baiyue/common/security/RedisAccessTokenSessionValidator.java`、`JwtClaimsMapper.java` | | 注册协作 | `service/MerchantRegistrationService.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | | DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` | @@ -21,7 +22,7 @@ ## 修改约束 -- 密码、Token、敏感 Claim 不得写日志;修改 Claim、Redis Key 或过期策略时联查 `common-security/`、`gateway/`、`iam/`。 +- 密码、Token、敏感 Claim 不得写日志;小 JWT 不嵌入角色和权限全集,实时授权来自 Redis 会话中的 IAM 授权快照。修改 Claim、Redis Key、权限快照或过期策略时联查 `common-security/`、`gateway/`、`iam/`。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 5d6fbc010..140df9482 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -10262,6 +10262,13 @@ CREATE TABLE IF NOT EXISTS fin_subject ( UNIQUE KEY uk_fin_subject_code (subject_code) ) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体'; +INSERT INTO fin_subject + (subject_code, subject_name, status, version, created_by, updated_by) +VALUES + ('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0) +ON DUPLICATE KEY UPDATE + subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0; + CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java b/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java index 3d2b6c1ac..2b5ce3606 100644 --- a/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java +++ b/finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java @@ -9,9 +9,11 @@ import java.util.List; @FeignClient(name="firstleg-service", contextId="financeFirstlegCostSnapshotClient", url="${baiyue.clients.firstleg.url:}", path="/internal/firstleg/v1/finance-cost-snapshots") public interface FirstlegCostSnapshotClient { - @GetMapping("/customers/{customerId}/containers/{containerId}") - ApiResponse container(@RequestHeader("X-Internal-Service-Token") String token, - @PathVariable long customerId, @PathVariable long containerId); - record ContainerSnapshot(long containerId, long customerId, List orders) {} + @GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}") + ApiResponse loadingPlan(@RequestHeader("X-Internal-Service-Token") String token, + @PathVariable long customerId, + @PathVariable long loadingPlanId); + record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId, + List orders) {} record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {} } diff --git a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java index 9c9580fea..6ee28c796 100644 --- a/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java +++ b/finance/src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java @@ -175,12 +175,20 @@ public class PlatformFinanceController { java.util.List volumes = java.util.List.of(); if ("CONTAINER_SHARED".equals(request.costType())) { if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次"); - var response = costSnapshotClient.container(fileToken, request.customerId(), request.containerId()); + var response = costSnapshotClient.loadingPlan(fileToken, request.customerId(), request.containerId()); var snapshot = response == null ? null : response.data(); - if (snapshot == null || snapshot.customerId() != request.customerId() || snapshot.containerId() != request.containerId() || snapshot.orders() == null || snapshot.orders().isEmpty()) { + if (response == null || !response.successful()) { + throw new IllegalArgumentException(response == null || response.message() == null + ? "Firstleg柜内订单体积快照不可用" : response.message()); + } + if (snapshot == null || snapshot.customerId() != request.customerId() + || snapshot.loadingPlanId() != request.containerId() + || snapshot.containerBookingId() <= 0 + || snapshot.orders() == null || snapshot.orders().isEmpty()) { throw new IllegalArgumentException("Firstleg未返回有效柜内订单体积快照"); } - volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), v.inboundId(), snapshot.containerId(), snapshot.customerId(), v.volumeCbm())).toList(); + volumes = snapshot.orders().stream().map(v -> new FinanceService.ContainerOrderVolume(v.orderId(), + v.inboundId(), snapshot.loadingPlanId(), snapshot.customerId(), v.volumeCbm())).toList(); } return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key))); } diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 35096e21d..22a0ab780 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -65,6 +65,13 @@ CREATE TABLE IF NOT EXISTS fin_subject ( UNIQUE KEY uk_fin_subject_code (subject_code) ) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体'; +INSERT INTO fin_subject + (subject_code, subject_name, status, version, created_by, updated_by) +VALUES + ('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0) +ON DUPLICATE KEY UPDATE + subject_name = VALUES(subject_name), status = 'ACTIVE', updated_by = 0; + CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index 54e847e91..7ad137d03 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -26,7 +26,7 @@ class FinanceContractTest { assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')")); String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java")); assertTrue(controller.contains("/vouchers") && controller.contains("MultipartFile")); - assertTrue(controller.contains("costSnapshotClient.container") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)")); + assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)")); assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())")); String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java")); assertTrue(operations.contains("bl.source_id=f.id")); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java new file mode 100644 index 000000000..a20c7772e --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSubjectSeedContractTest.java @@ -0,0 +1,25 @@ +package com.baiyue.finance; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceSubjectSeedContractTest { + @Test + void moduleAndUnifiedSqlSeedOneActivePlatformSubjectIdempotently() throws Exception { + String module = Files.readString(Path.of("src/main/resources/db/finance.sql"), StandardCharsets.UTF_8); + String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql"), StandardCharsets.UTF_8); + + for (String sql : new String[] {module, unified}) { + assertThat(sql) + .contains("INSERT INTO fin_subject") + .contains("('PLATFORM_DEFAULT', '平台', 'ACTIVE', 0, 0, 0)") + .contains("ON DUPLICATE KEY UPDATE") + .contains("status = 'ACTIVE'"); + } + } +} diff --git a/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java index d1725df6e..bd04c047b 100644 --- a/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/PlatformFinanceControllerBehaviorTest.java @@ -14,6 +14,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; @@ -45,15 +46,15 @@ class PlatformFinanceControllerBehaviorTest { var request = new PlatformFinanceController.CostRequest( 10L, "CONTAINER_SHARED", null, 88L, "清关费", new BigDecimal("300.00"), "CNY", List.of("91"), "整柜成本"); - var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of( + var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of( new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, new BigDecimal("1.25")), new FirstlegCostSnapshotClient.OrderVolume(102L, 1002L, new BigDecimal("2.75")))); - when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot)); + when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot)); when(service.createCost(eq(10L), eq(7L), any(), any(), eq("cost-key"))).thenReturn(Map.of("id", 1L)); controller.cost(user, "cost-key", request); - verify(snapshotClient).container("internal-token", 10L, 88L); + verify(snapshotClient).loadingPlan("internal-token", 10L, 88L); verify(service).createCost(eq(10L), eq(7L), any(), eq(List.of( new FinanceService.ContainerOrderVolume(101L, 1001L, 88L, 10L, new BigDecimal("1.25")), new FinanceService.ContainerOrderVolume(102L, 1002L, 88L, 10L, new BigDecimal("2.75")))), eq("cost-key")); @@ -64,8 +65,35 @@ class PlatformFinanceControllerBehaviorTest { var request = new PlatformFinanceController.CostRequest( 10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN, "CNY", List.of("91"), null); - when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success( - new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of()))); + when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success( + new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 99L, 10L, List.of()))); + + assertThatThrownBy(() -> controller.cost(user, "cost-key", request)) + .hasMessageContaining("未返回有效柜内订单体积快照"); + verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any()); + } + + @Test + void containerCostPreservesFirstlegBusinessFailure() { + var request = new PlatformFinanceController.CostRequest( + 10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN, + "CNY", List.of("91"), null); + when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn( + new ApiResponse<>("A0404", "柜次不存在", null, "request", "trace", Instant.now())); + + assertThatThrownBy(() -> controller.cost(user, "cost-key", request)) + .hasMessage("柜次不存在"); + verify(service, never()).createCost(any(Long.class), any(Long.class), any(), any(), any()); + } + + @Test + void containerCostRejectsMismatchedLoadingPlanIdentity() { + var request = new PlatformFinanceController.CostRequest( + 10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN, + "CNY", List.of("91"), null); + var snapshot = new FirstlegCostSnapshotClient.ContainerSnapshot(89L, 99L, 10L, List.of( + new FirstlegCostSnapshotClient.OrderVolume(101L, 1001L, BigDecimal.ONE))); + when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(snapshot)); assertThatThrownBy(() -> controller.cost(user, "cost-key", request)) .hasMessageContaining("未返回有效柜内订单体积快照"); diff --git a/firstleg/AGENTS.md b/firstleg/AGENTS.md index 709e558b6..47b35feb5 100644 --- a/firstleg/AGENTS.md +++ b/firstleg/AGENTS.md @@ -13,6 +13,7 @@ | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java` | +| 箱唛箱码生成与版本化 | `repository/InboundCartonLabelRepository.java`、`service/InboundCartonLabelService.java`;基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql` | | 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | | 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` | | FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` | @@ -31,7 +32,7 @@ | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | | 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java` | -| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | | 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` | ## 修改约束 @@ -44,7 +45,7 @@ ## 三期装柜与 PDA 资源结算 - 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 -- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 +- PDA 拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;首次打印冻结箱码映射,重复打印和墨家测量复用同一箱码,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数或页面展示字段替代箱码。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。 - 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java index 7ea2f6080..67744b939 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundMeasurementResponses.java @@ -30,6 +30,9 @@ public final class InboundMeasurementResponses { Long measurementId, String sourceType, String measurementStatus, + @Schema(description = "入库主单乐观锁版本,用于测量审核、驳回和异常放行", example = "3") + Integer inboundVersion, + @Schema(description = "测量批次乐观锁版本,用于测量草稿和明细维护", example = "4") Integer version, String originalDocumentNo, BigDecimal originalGrossWeightKg, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotController.java index de9d8ab37..71677abe3 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotController.java @@ -17,13 +17,13 @@ public class InternalFinanceCostSnapshotController { @Value("${baiyue.security.internal-service-token}") private String expectedToken; public InternalFinanceCostSnapshotController(FinanceCostSnapshotService service) { this.service = service; } - @GetMapping("/customers/{customerId}/containers/{containerId}") - public ApiResponse container( + @GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}") + public ApiResponse loadingPlan( @RequestHeader(value="X-Internal-Service-Token", required=false) String token, - @PathVariable long customerId, @PathVariable long containerId) { + @PathVariable long customerId, @PathVariable long loadingPlanId) { if (token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) { throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); } - return ApiResponse.success(service.container(customerId, containerId)); + return ApiResponse.success(service.loadingPlan(customerId, loadingPlanId)); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java index 060fefeeb..3fd196d7a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotService.java @@ -13,27 +13,26 @@ public class FinanceCostSnapshotService { private final SmartLoadingRepository repository; public FinanceCostSnapshotService(SmartLoadingRepository repository) { this.repository = repository; } - public ContainerSnapshot container(long customerId, long loadingPlanId) { + public ContainerSnapshot loadingPlan(long customerId, long loadingPlanId) { if (customerId <= 0 || loadingPlanId <= 0) { throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); } - // Older Finance deployments called this endpoint with the physical - // container booking ID. Resolve both identities during rollout while - // preserving the caller's ID in the snapshot contract. var plan = repository.plan(loadingPlanId); - if (plan == null) plan = repository.planByBooking(loadingPlanId); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); - long effectivePlanId = plan.getId() == null || plan.getId() <= 0 ? loadingPlanId : plan.getId(); - List orders = repository.items(effectivePlanId).stream() + if (plan.getContainerBookingId() == null || plan.getContainerBookingId() <= 0) { + throw new BizException(ResultCode.BAD_REQUEST, "装柜计划未关联柜次"); + } + List orders = repository.items(loadingPlanId).stream() .filter(item -> item.getAccountId() != null && item.getAccountId() == customerId) .filter(item -> item.getOrderId() != null && item.getOrderId() > 0) .filter(item -> item.getVolumeCbm() != null && item.getVolumeCbm().signum() > 0) .map(item -> new OrderVolume(item.getOrderId(), item.getInboundId(), item.getVolumeCbm())) .toList(); if (orders.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "柜次没有该客户的有效订单体积"); - return new ContainerSnapshot(loadingPlanId, customerId, orders); + return new ContainerSnapshot(loadingPlanId, plan.getContainerBookingId(), customerId, orders); } - public record ContainerSnapshot(long containerId, long customerId, List orders) {} + public record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId, + List orders) {} public record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java index defc22b2c..ed3aa7aca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundMeasurementService.java @@ -347,6 +347,7 @@ public class InboundMeasurementService { review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(), measurement == null ? null : measurement.getSourceType(), measurement == null ? null : measurement.getStatus(), + inbound.getVersion(), measurement == null ? null : measurement.getVersion(), measurement == null ? null : measurement.getOriginalDocumentNo(), measurement == null ? null : measurement.getOriginalGrossWeightKg(), diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotControllerTest.java new file mode 100644 index 000000000..4536808d9 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/controller/InternalFinanceCostSnapshotControllerTest.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.loading.controller; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.firstleg.loading.service.FinanceCostSnapshotService; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.bind.annotation.GetMapping; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class InternalFinanceCostSnapshotControllerTest { + private final FinanceCostSnapshotService service = mock(FinanceCostSnapshotService.class); + private final InternalFinanceCostSnapshotController controller = controller(); + + @Test + void requiresInternalTokenAndUsesStableLoadingPlanIdentity() { + assertThatThrownBy(() -> controller.loadingPlan("wrong", 10L, 88L)) + .isInstanceOf(BizException.class).hasMessageContaining("令牌无效"); + + controller.loadingPlan("finance-token", 10L, 88L); + + verify(service).loadingPlan(10L, 88L); + } + + @Test + void exposesLoadingPlanSnapshotPath() throws Exception { + var mapping = InternalFinanceCostSnapshotController.class + .getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class); + assertThat(mapping.value()).containsExactly("/internal/firstleg/v1/finance-cost-snapshots"); + var method = InternalFinanceCostSnapshotController.class.getDeclaredMethod( + "loadingPlan", String.class, long.class, long.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/customers/{customerId}/loading-plans/{loadingPlanId}"); + } + + private InternalFinanceCostSnapshotController controller() { + var value = new InternalFinanceCostSnapshotController(service); + ReflectionTestUtils.setField(value, "expectedToken", "finance-token"); + return value; + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java index 78a589326..4fd6b6542 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/service/FinanceCostSnapshotServiceTest.java @@ -24,28 +24,34 @@ class FinanceCostSnapshotServiceTest { @Test void returnsOnlyValidVolumesOwnedByCustomer() { - when(repository.plan(88L)).thenReturn(mock(LoadingPlanEntity.class)); + var plan = new LoadingPlanEntity(); + plan.setId(88L); + plan.setContainerBookingId(99L); + when(repository.plan(88L)).thenReturn(plan); when(repository.items(88L)).thenReturn(List.of( item(10L, 101L, 1001L, "1.25"), item(11L, 102L, 1002L, "2.00"), item(10L, 103L, 1003L, "0"))); - var snapshot = service.container(10L, 88L); + var snapshot = service.loadingPlan(10L, 88L); assertThat(snapshot.customerId()).isEqualTo(10L); - assertThat(snapshot.containerId()).isEqualTo(88L); + assertThat(snapshot.loadingPlanId()).isEqualTo(88L); + assertThat(snapshot.containerBookingId()).isEqualTo(99L); assertThat(snapshot.orders()).containsExactly( new FinanceCostSnapshotService.OrderVolume(101L, 1001L, new BigDecimal("1.25"))); } @Test void rejectsContainerWithoutValidCustomerVolume() { - when(repository.plan(88L)).thenReturn(mock(LoadingPlanEntity.class)); + var plan = new LoadingPlanEntity(); + plan.setContainerBookingId(99L); + when(repository.plan(88L)).thenReturn(plan); when(repository.items(88L)).thenReturn(List.of( item(11L, 102L, 1002L, "2.00"), item(10L, 103L, 1003L, "0"))); - assertThatThrownBy(() -> service.container(10L, 88L)) + assertThatThrownBy(() -> service.loadingPlan(10L, 88L)) .hasMessageContaining("没有该客户的有效订单体积"); } @@ -53,23 +59,18 @@ class FinanceCostSnapshotServiceTest { void rejectsUnknownContainer() { when(repository.plan(88L)).thenReturn(null); - assertThatThrownBy(() -> service.container(10L, 88L)) + assertThatThrownBy(() -> service.loadingPlan(10L, 88L)) .hasMessageContaining("柜次不存在"); } @Test - void resolvesLegacyContainerBookingIdToLoadingPlan() { + void rejectsPlanWithoutContainerBooking() { var plan = new LoadingPlanEntity(); plan.setId(88L); - plan.setContainerBookingId(99L); - when(repository.plan(99L)).thenReturn(null); - when(repository.planByBooking(99L)).thenReturn(plan); - when(repository.items(88L)).thenReturn(List.of(item(10L, 101L, 1001L, "1.25"))); + when(repository.plan(88L)).thenReturn(plan); - var snapshot = service.container(10L, 99L); - - assertThat(snapshot.containerId()).isEqualTo(99L); - assertThat(snapshot.orders()).hasSize(1); + assertThatThrownBy(() -> service.loadingPlan(10L, 88L)) + .hasMessageContaining("未关联柜次"); } private LoadingPlanItemEntity item(long customerId, long orderId, long inboundId, String volume) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java index dee4e165a..52634b9df 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundMeasurementServiceTest.java @@ -87,12 +87,20 @@ class InboundMeasurementServiceTest { @Test void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() { - when(repository.findInbound(1L, 10L)).thenReturn(inbound("MEASURE_PENDING")); + InboundOrderEntity inbound = inbound("MEASURE_PENDING"); + inbound.setVersion(7); + InboundMeasurementEntity measurement = new InboundMeasurementEntity(); + measurement.setId(30L); + measurement.setVersion(4); + when(repository.findInbound(1L, 10L)).thenReturn(inbound); + when(repository.findLatestMeasurement(1L, 10L)).thenReturn(measurement); when(repository.listWeights(1L, 10L)).thenReturn(List.of()); InboundMeasurementResponses.Detail result = service.detail(1L, 10L); assertThat(result.recommendedPalletType()).isEqualTo("STANDARD"); + assertThat(result.inboundVersion()).isEqualTo(7); + assertThat(result.version()).isEqualTo(4); verify(repository, never()).updateInbound(any()); } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index a338f7e97..b369e6cc6 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -12,7 +12,19 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const DEFAULT_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'master-data', 'suppliers', 'domestic-container', 'orders', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-loading-finalize', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'finance', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'customer-operating-statistics'] + +export function normalizeModuleOrder(modules) { + const normalized = [...modules] + const loadingIndex = normalized.indexOf('phase3-loading') + const overviewIndex = normalized.indexOf('phase3-container-overview') + if (loadingIndex < 0 || overviewIndex < 0 || overviewIndex > loadingIndex) return normalized + normalized.splice(overviewIndex, 1) + normalized.splice(normalized.indexOf('phase3-loading') + 1, 0, 'phase3-container-overview') + return normalized +} + +const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES) const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', @@ -47,7 +59,7 @@ function usage() { 环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`) } -function parseArguments(argv) { +export function parseArguments(argv) { if (argv.includes('--help')) { usage(); process.exit(0) } const values = new Map() for (let index = 0; index < argv.length; index += 1) { @@ -67,7 +79,7 @@ function parseArguments(argv) { gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''), username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME, password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD, - modules: (values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean), + modules: normalizeModuleOrder((values.get('modules') || DEFAULT_MODULES.join(',')).split(',').map((v) => v.trim()).filter(Boolean)), output: values.get('output') || '', } } @@ -638,21 +650,26 @@ export class AcceptanceRun { Number(page?.total) === records.length && records.length < 200, `total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options) const disabled = [] + const skippedOccupied = [] for (const location of locations) { const response = await this.request('PATCH', `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, { status: 'DISABLED', version: Number(location.version) }) const success = response?.http === 200 && String(response?.body?.code) === '200' - this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success, - `locationId=${location.id}, beforeVersion=${location.version}`, options) + const occupiedConflict = response?.http === 200 + && String(response?.body?.code) === 'A0409' + && String(response?.body?.message) === '预占或占用中的库位不能停用' + this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success || occupiedConflict, + `locationId=${location.id}, beforeVersion=${location.version}${occupiedConflict ? ', skipped=active-resource' : ''}`, options) if (success) disabled.push({ id: String(location.id), code: location.locationCode, version: Number(location.version) + 1 }) + if (occupiedConflict) skippedOccupied.push({ id: String(location.id), code: location.locationCode }) } const availableResponse = await this.request('GET', `/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`) const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options) this.assert(module, '测量前 A0 溢出条件成立', availableResponse, - Array.isArray(available) && available.length === 0 && disabled.length === locations.length, - `disabled=${disabled.length}, available=${Array.isArray(available) ? available.length : 'missing'}`, options) + Array.isArray(available) && available.length === 0, + `disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options) return disabled } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index edff2e81c..e09d81684 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -36,6 +36,19 @@ export async function run(context) { - 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。 - 一个模块只修改自己的 `.mjs`,不要修改其他模块文件。 +## 近期业务模块与静态合同快速索引 + +| 业务链路 | HTTP 模块/共享前置 | 离线静态合同 | +|---|---|---| +| 商户资质与下单身份 | `merchant-qualification-fixture.mjs` | `merchant-qualification-fixture-contract.test.mjs` | +| 订单创建、可计价主数据与审核异步收敛 | `orders.mjs` | `orders-pricing-fixture-contract.test.mjs`、`orders-approval-convergence-contract.test.mjs` | +| 入库测量、首次打印冻结箱码与墨家扫码 | `inbound-measurement.mjs` | `inbound-measurement-box-code-contract.test.mjs` | +| 三期柜次、装柜、PDA 与证据/出仓门禁 | `phase3-container-loading.mjs`、`phase3-loading.mjs`、`phase3-pda-convergence.mjs`、`phase3-evidence-gate.mjs`、`phase3-loading-finalize.mjs`、`phase3-outbound-dispatch.mjs` | `phase3-producer-chain-contract.test.mjs`、`manual-state-loading-pda-contract.test.mjs` | +| 订舱共享主数据前置与订舱验收 | `ocean-booking-fixture.mjs`、`ocean-bookings.mjs`、`phase3-port-schedules.mjs`、`phase3-carrier-schedule-queries.mjs` | `ocean-booking-fixture-contract.test.mjs`、`phase3-carrier-schedule-queries.test.mjs` | +| 拖车预约 | `truck-appointments.mjs` | `truck-appointments-contract.test.mjs` | + +表中的 `*-fixture.mjs` 是被业务模块复用的测试数据前置,不是新增的独立全量入口。执行 HTTP 时仍从 `../admin-test-env-acceptance.mjs` 进入;`*contract.test.mjs` 只做离线脚本结构和数据归属回归。 + ## 供应商模块 `suppliers.mjs` 覆盖平台公共供应商的列表、统计、新增、详情、编辑、启停及主要错误边界。执行: 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 f2618e810..4b8f6254c 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 @@ -25,7 +25,7 @@ test('fee writes use verified Firstleg paths and do not fabricate historical IDs assert.match(source, /const basePath = `\/api\/platform\/v1\/loading-plans\//) assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/) assert.match(source, /created\?\.marker === context\.marker/) - assert.match(source, /\/fees`/) + assert.match(source, /fee-details\?accountId=/) assert.match(source, /chargeUnit === 'KG'/) assert.match(source, /chargeUnit === 'CBM'/) }) @@ -65,7 +65,7 @@ function hookSet({ missing = false } = {}) { loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }), uploadVoucher: async () => missing ? null : 'VOUCHER-1', verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }), - calculateAutomaticFee: async (context, resource) => { + loadPublishedAutomaticFee: async (context, resource) => { automaticIndex += 1 const weight = automaticIndex % 2 === 1 return { feeSourceId: `F-${automaticIndex}`, orderId: resource.orderId, inboundId: resource.inboundId, @@ -162,6 +162,40 @@ test('manual fee approval path keeps accountId as a query parameter', () => { assert.doesNotMatch(source, /fee-details\?accountId=\$\{encodeURIComponent\(resource\.tenantId\)\}`\)/) }) +test('automatic fixture reuses the published review-chain source without recalculating at WAIT_SPLIT', () => { + assert.match(source, /loadPublishedAutomaticFee/) + assert.match(source, /sourceType === 'FBA_PRICING'/) + assert.match(source, /reviewStatus === 'APPROVED'/) + assert.match(source, /publicationStatus === 'PUBLISHED'/) + assert.match(source, /settlementIncluded === true/) + assert.match(source, /const feeSourceId = id\(published\.sourceFeeId\)/) + assert.doesNotMatch(source, /\/fees\/calculate/) + assert.doesNotMatch(source, /\/fees\/confirm/) + assert.doesNotMatch(source, /\/fees\/send/) +}) + +test('missing published automatic source is a prerequisite blocker and never emits a fixture source', async () => { + const context = fakeContext() + let automaticReads = 0 + context.financeFixtureHooks = { + ...hookSet(), + loadPublishedAutomaticFee: async (ctx, resource) => { + automaticReads += 1 + ctx.addStep('finance-full-fixtures', '审核链已生成可结算自动费用来源', null, false, + `inboundId=${resource.inboundId}; fixture 不重复试算/确认/发布`, { deploymentBlocker: true }) + return null + }, + } + + await run(context) + + assert.equal(automaticReads, 4) + assert.equal(context.createdResources.financeFullFixtures.feeSources + .filter((item) => item.sourceType === 'FBA_PRICING').length, 0) + assert.ok(context.steps.some((step) => step.deploymentBlocker + && step.name.includes('可结算自动费用来源'))) +}) + test('fixture creates four real receivable manual sources before payable source', () => { assert.match(source, /const receivableResources = measured\.slice\(0, 4\)/) assert.match(source, /feeItems\.RECEIVABLE, 'RECEIVABLE'/) diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 5f42e2494..37b68e495 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -130,11 +130,11 @@ async function createManualFee(context, resource, feeItem, direction, amount, ba chargeableWeightKg: detail?.chargeableWeightKg ?? null, chargeableVolumeCbm: detail?.chargeableVolumeCbm ?? null } } -async function calculateAutomaticFee(context, resource) { - if (context.financeFixtureHooks?.calculateAutomaticFee) return context.financeFixtureHooks.calculateAutomaticFee(context, resource) +async function loadPublishedAutomaticFee(context, resource) { + if (context.financeFixtureHooks?.loadPublishedAutomaticFee) return context.financeFixtureHooks.loadPublishedAutomaticFee(context, resource) const pricingInboundResponse = await context.request('GET', `/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(resource.tenantId)}`) - const pricingInbound = context.apiSuccess(MODULE, '计费前回读入库主表计费快照', pricingInboundResponse, + const pricingInbound = context.apiSuccess(MODULE, '回读审核完成后的入库主表状态', pricingInboundResponse, `inboundId=${resource.inboundId}`, OPTIONS) const pricingSummary = pricingInbound?.summary || {} const actualWeightKg = Number(pricingSummary.actualGrossWeightKg ?? pricingSummary.actualWeightKg) @@ -144,31 +144,33 @@ async function calculateAutomaticFee(context, resource) { `服务端计费实际读取 fl_inbound_order.actual_gross_weight_kg/actual_volume_cbm;inboundId=${resource.inboundId}, actualWeightKg=${actualWeightKg || 'missing'}, actualVolumeCbm=${actualVolumeCbm || 'missing'};测量表有值但主表未同步`) return null } - const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fees` - const query = `tenantId=${encodeURIComponent(resource.tenantId)}` - const calculateResponse = await request(context, 'POST', `${base}/calculate?${query}`, {}) - const calculated = context.apiSuccess(MODULE, '按审核测量快照执行真实自动计费', calculateResponse, - `orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS) - const feeSourceId = id(calculated?.feeId) - const billingBasis = calculated?.chargeUnit === 'KG' ? 'WEIGHT_KG' - : calculated?.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null - const responseWeightKg = Number(calculated?.actualWeightKg) - const responseVolumeCbm = Number(calculated?.actualVolumeCbm) - context.assert(MODULE, '自动费用返回来源、金额和计费依据', calculateResponse, - Boolean(feeSourceId) && Boolean(billingBasis) && Number(calculated?.totalAmount) >= 0 + const listPath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details?accountId=${encodeURIComponent(resource.tenantId)}` + const listResponse = await context.request('GET', listPath) + const details = context.apiSuccess(MODULE, '回读审核链已发布自动费用来源', listResponse, + `inboundId=${resource.inboundId}`, OPTIONS) + const published = rows(details).find((item) => item?.sourceType === 'FBA_PRICING' + && item?.reviewStatus === 'APPROVED' && item?.publicationStatus === 'PUBLISHED' + && item?.settlementIncluded === true) + if (!published) { + blocker(context, '审核链已生成可结算自动费用来源', + `inboundId=${resource.inboundId}, processStatus=${pricingSummary.processStatus || pricingInbound?.processStatus || 'missing'}, feeDetails=${rows(details).length};测量审核完成链应在进入 WAIT_SPLIT 前生成 APPROVED + PUBLISHED 的 FBA_PRICING 来源,fixture 不重复试算/确认/发布`) + return null + } + const feeSourceId = id(published.sourceFeeId) + const billingBasis = published.chargeUnit === 'KG' ? 'WEIGHT_KG' + : published.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null + const responseWeightKg = Number(published.actualGrossWeightKg ?? actualWeightKg) + const responseVolumeCbm = Number(published.actualVolumeCbm ?? actualVolumeCbm) + context.assert(MODULE, '已发布自动费用来源、金额和计费依据完整', listResponse, + Boolean(feeSourceId) && Boolean(billingBasis) && Number(published.amount) >= 0 && responseWeightKg > 0 && responseVolumeCbm > 0, - `feeSourceId=${feeSourceId || 'missing'}, chargeUnit=${calculated?.chargeUnit || 'missing'}, amount=${calculated?.totalAmount ?? 'missing'}, actualWeightKg=${responseWeightKg || 'missing'}, actualVolumeCbm=${responseVolumeCbm || 'missing'}`, OPTIONS) + `feeSourceId=${feeSourceId || 'missing'}, detailId=${id(published.id) || 'missing'}, chargeUnit=${published.chargeUnit || 'missing'}, amount=${published.amount ?? 'missing'}, actualWeightKg=${responseWeightKg || 'missing'}, actualVolumeCbm=${responseVolumeCbm || 'missing'}`, OPTIONS) if (!feeSourceId || !billingBasis) return null - const confirmResponse = await request(context, 'POST', `${base}/confirm?${query}`) - const confirmed = context.apiSuccess(MODULE, '确认真实自动费用快照', confirmResponse, '', OPTIONS) - const sendResponse = await request(context, 'POST', `${base}/send?${query}`) - const sent = context.apiSuccess(MODULE, '发布真实自动费用来源', sendResponse, '', OPTIONS) return { feeSourceId, orderId: id(resource.orderId), inboundId: id(resource.inboundId), sourceType: 'FBA_PRICING', - direction: 'RECEIVABLE', currency: sent?.currency || confirmed?.currency || calculated.currency, - amount: Number(sent?.totalAmount ?? confirmed?.totalAmount ?? calculated.totalAmount), - businessDate: String(sent?.sentAt || confirmed?.confirmedAt || calculated.calculatedAt || '').slice(0, 10), - billingBasis, chargeableQuantity: sent?.chargeableQuantity ?? calculated.chargeableQuantity, - chargeUnit: sent?.chargeUnit || calculated.chargeUnit, + direction: 'RECEIVABLE', currency: published.currency, + amount: Number(published.amount), businessDate: String(published.businessDate || published.publishedAt || '').slice(0, 10), + billingBasis, chargeableQuantity: published.chargeableQuantity, + chargeUnit: published.chargeUnit, actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm } } @@ -328,7 +330,7 @@ export async function run(context) { } const fixtures = [] for (const resource of measured.slice(0, 4)) { - const fee = await calculateAutomaticFee(context, resource) + const fee = await loadPublishedAutomaticFee(context, resource) if (fee) fixtures.push(fee) } for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) { diff --git a/scripts/admin-test-env-modules/notifications-contract.test.mjs b/scripts/admin-test-env-modules/notifications-contract.test.mjs new file mode 100644 index 000000000..37b878a2d --- /dev/null +++ b/scripts/admin-test-env-modules/notifications-contract.test.mjs @@ -0,0 +1,38 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = path.dirname(fileURLToPath(import.meta.url)) +const moduleSource = fs.readFileSync(path.join(here, 'notifications.mjs'), 'utf8') +const acceptance = fs.readFileSync(path.join(here, '..', 'admin-test-env-acceptance.mjs'), 'utf8') + +test('消息中心模块由全量编排注入 context 且不直连环境', () => { + assert.match(moduleSource, /export async function run\(context\)/) + assert.doesNotMatch(moduleSource, /https?:\/\//) + assert.doesNotMatch(moduleSource, /ADMIN_TEST_GATEWAY|process\.env|fetch\(/) + assert.match(acceptance, /import\(new URL\(`\.\/admin-test-env-modules\/\$\{module\}\.mjs`/) +}) + +test('正式 Admin 全量验收在 system 后注册消息中心', () => { + assert.match(acceptance, /CONFIGURED_MODULES\s*=\s*\[[^\]]*'system',\s*'notifications'/s) +}) + +test('覆盖模板生命周期、版本冲突、定时任务、幂等、查询和取消', () => { + for (const token of [ + '创建消息模板草稿', '编辑 DRAFT 消息模板', '旧模板版本编辑被拒绝', '激活消息模板', + '创建未来定时消息任务', '同幂等键重放定时消息任务', '按任务号和状态查询消息任务', + '取消未来定时消息任务', '已取消任务禁止重试', '停用本轮消息模板', + ]) assert.match(moduleSource, new RegExp(token)) + assert.match(moduleSource, /recipientType:\s*'PLATFORM_ROLE'/) + assert.match(moduleSource, /roleCode:\s*'PLATFORM_ADMIN'/) + assert.match(moduleSource, /Date\.now\(\) \+ 24 \* 60 \* 60 \* 1000/) +}) + +test('异常路径会取消定时任务并停用模板,不实际投递验收消息', () => { + assert.match(moduleSource, /taskStatus === 'SCHEDULED'/) + assert.match(moduleSource, /targetStatus:\s*'DISABLED'/) + assert.match(moduleSource, /finalTemplateStatus/) + assert.doesNotMatch(moduleSource, /scheduledAt:\s*null/) +}) diff --git a/scripts/admin-test-env-modules/notifications.mjs b/scripts/admin-test-env-modules/notifications.mjs new file mode 100644 index 000000000..633d0b752 --- /dev/null +++ b/scripts/admin-test-env-modules/notifications.mjs @@ -0,0 +1,160 @@ +const MODULE = 'notifications' +const OPTIONS = { scenario: 'platform-notification-administration', deploymentBlocker: true } +const TEMPLATE_BASE = '/api/platform/v1/notification-templates' +const TASK_BASE = '/api/platform/v1/notification-tasks' +const CREATE_PATH = '/api/platform/v1/notifications' + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function records(page) { return Array.isArray(page?.records) ? page.records : [] } +function hasTrace(response) { return Boolean(response?.requestId) && Boolean(response?.traceId) } +function isConflict(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) } +function query(base, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values)) if (value !== '' && value != null) params.set(key, String(value)) + return `${base}?${params}` +} + +export async function run(context) { + const suffix = context.marker.replace(/[^0-9A-Za-z]/g, '').slice(-20).toUpperCase() + const templateCode = `ACPT_NOTIFICATION_${suffix}`.slice(0, 64) + const category = 'ACCEPTANCE' + const templateCreate = { + templateCode, + templateName: `全量验收消息模板-${suffix}`, + category, + titleTemplate: '全量验收通知 {{marker}}', + bodyTemplate: '这是 {{marker}} 的消息中心全量验收定时任务。', + variableWhitelist: ['marker'], + } + + const baselineResponse = await context.request('GET', query(TEMPLATE_BASE, { page: 1, size: 20, templateCode })) + const baseline = context.apiSuccess(MODULE, '按 CODE 查询消息模板基线', baselineResponse, `templateCode=${templateCode}`, OPTIONS) + context.assert(MODULE, '消息模板分页结构完整', baselineResponse, + Array.isArray(baseline?.records) && Number(baseline?.page) === 1 && Number(baseline?.size) === 20, + `records=${records(baseline).length}, total=${baseline?.total ?? 'missing'}`, OPTIONS) + + const invalidResponse = await context.request('POST', TEMPLATE_BASE, { + ...templateCreate, + templateCode: `ACPT_INVALID_${suffix}`.slice(0, 64), + variableWhitelist: [], + }) + context.assert(MODULE, '模板占位变量未列入白名单时被拒绝且返回追踪信息', invalidResponse, + invalidResponse.http === 200 && ['A0422', '422'].includes(codeOf(invalidResponse)) && hasTrace(invalidResponse), + `HTTP=${invalidResponse.http}, code=${codeOf(invalidResponse)}, requestId=${invalidResponse.requestId || 'missing'}, traceId=${invalidResponse.traceId || 'missing'}`, OPTIONS) + + const createResponse = await context.request('POST', TEMPLATE_BASE, templateCreate) + let template = context.apiSuccess(MODULE, '创建消息模板草稿', createResponse, `templateCode=${templateCode}`, OPTIONS) + const templateId = id(template?.id) + context.assert(MODULE, '新建模板为 DRAFT 且仅支持站内信', createResponse, + Boolean(templateId) && template?.status === 'DRAFT' && template?.channel === 'IN_APP' && Number(template?.version) === 1, + `templateId=${templateId || 'missing'}, status=${template?.status || 'missing'}, channel=${template?.channel || 'missing'}`, OPTIONS) + if (!templateId) return + + let taskId = '' + let taskStatus = '' + let finalTemplateStatus = template?.status || 'UNKNOWN' + try { + const duplicateResponse = await context.request('POST', TEMPLATE_BASE, templateCreate) + context.assert(MODULE, '重复模板 CODE 返回冲突并保留追踪信息', duplicateResponse, + isConflict(duplicateResponse) && hasTrace(duplicateResponse), + `code=${codeOf(duplicateResponse)}, requestId=${duplicateResponse.requestId || 'missing'}, traceId=${duplicateResponse.traceId || 'missing'}`, OPTIONS) + + const updateResponse = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}`, { + templateName: `全量验收消息模板已编辑-${suffix}`, + category, + titleTemplate: '全量验收通知已编辑 {{marker}}', + bodyTemplate: '这是 {{marker}} 的消息中心全量验收定时任务,模板已经编辑。', + variableWhitelist: ['marker'], + version: template.version, + }) + template = context.apiSuccess(MODULE, '编辑 DRAFT 消息模板', updateResponse, '', OPTIONS) + context.assert(MODULE, '模板编辑结果及版本已持久化', updateResponse, + template?.templateName === `全量验收消息模板已编辑-${suffix}` && Number(template?.version) === 2, + `name=${template?.templateName || 'missing'}, version=${template?.version ?? 'missing'}`, OPTIONS) + + const staleUpdate = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}`, { + templateName: templateCreate.templateName, + category, + titleTemplate: templateCreate.titleTemplate, + bodyTemplate: templateCreate.bodyTemplate, + variableWhitelist: ['marker'], + version: 1, + }) + context.assert(MODULE, '旧模板版本编辑被拒绝且返回追踪信息', staleUpdate, + isConflict(staleUpdate) && hasTrace(staleUpdate), + `code=${codeOf(staleUpdate)}, requestId=${staleUpdate.requestId || 'missing'}, traceId=${staleUpdate.traceId || 'missing'}`, OPTIONS) + + const activateResponse = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}/status`, { + targetStatus: 'ACTIVE', version: template?.version, + }) + template = context.apiSuccess(MODULE, '激活消息模板', activateResponse, '', OPTIONS) + finalTemplateStatus = template?.status || finalTemplateStatus + context.assert(MODULE, '消息模板激活且版本递增', activateResponse, + template?.status === 'ACTIVE' && Number(template?.version) === 3, + `status=${template?.status || 'missing'}, version=${template?.version ?? 'missing'}`, OPTIONS) + + const scheduledAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString().slice(0, 19) + const idempotencyKey = `${context.marker}-notification-scheduled`.slice(0, 128) + const taskCreate = { + tenantId: 0, + templateCode, + recipientType: 'PLATFORM_ROLE', + roleCode: 'PLATFORM_ADMIN', + variables: { marker: context.marker }, + scheduledAt, + idempotencyKey, + sourceEventId: `${context.marker}-notification-acceptance`.slice(0, 128), + } + const taskResponse = await context.request('POST', CREATE_PATH, taskCreate) + let task = context.apiSuccess(MODULE, '创建未来定时消息任务', taskResponse, `scheduledAt=${scheduledAt}`, OPTIONS) + taskId = id(task?.id); taskStatus = task?.status || '' + context.assert(MODULE, '定时任务创建为 SCHEDULED 且解析平台角色接收人', taskResponse, + Boolean(taskId) && task?.status === 'SCHEDULED' && Number(task?.recipientCount) > 0 && task?.duplicated === false, + `taskId=${taskId || 'missing'}, status=${task?.status || 'missing'}, recipientCount=${task?.recipientCount ?? 'missing'}`, OPTIONS) + + const replayResponse = await context.request('POST', CREATE_PATH, taskCreate) + const replay = context.apiSuccess(MODULE, '同幂等键重放定时消息任务', replayResponse, '', OPTIONS) + context.assert(MODULE, '幂等重放返回同一任务且标记 duplicated', replayResponse, + id(replay?.id) === taskId && replay?.duplicated === true, + `original=${taskId || 'missing'}, replay=${id(replay?.id) || 'missing'}, duplicated=${replay?.duplicated ?? 'missing'}`, OPTIONS) + + const taskListResponse = await context.request('GET', query(TASK_BASE, { page: 1, size: 20, taskNo: task?.taskNo, category, status: 'SCHEDULED' })) + const taskPage = context.apiSuccess(MODULE, '按任务号和状态查询消息任务', taskListResponse, `taskNo=${task?.taskNo || 'missing'}`, OPTIONS) + context.assert(MODULE, '任务列表命中本轮定时任务', taskListResponse, + records(taskPage).some((row) => id(row?.id) === taskId && row?.status === 'SCHEDULED'), + `taskId=${taskId || 'missing'}, records=${records(taskPage).length}`, OPTIONS) + + if (taskId) { + const cancelResponse = await context.request('PUT', `${TASK_BASE}/${encodeURIComponent(taskId)}/cancel`) + task = context.apiSuccess(MODULE, '取消未来定时消息任务', cancelResponse, '', OPTIONS) + taskStatus = task?.status || taskStatus + context.assert(MODULE, '定时消息任务最终为 CANCELLED', cancelResponse, + task?.status === 'CANCELLED', `status=${task?.status || 'missing'}`, OPTIONS) + + const retryResponse = await context.request('PUT', `${TASK_BASE}/${encodeURIComponent(taskId)}/retry`) + context.assert(MODULE, '已取消任务禁止重试且返回追踪信息', retryResponse, + isConflict(retryResponse) && hasTrace(retryResponse), + `code=${codeOf(retryResponse)}, requestId=${retryResponse.requestId || 'missing'}, traceId=${retryResponse.traceId || 'missing'}`, OPTIONS) + } + } finally { + if (taskId && taskStatus === 'SCHEDULED') { + const cleanupTask = await context.request('PUT', `${TASK_BASE}/${encodeURIComponent(taskId)}/cancel`) + const cleanedTask = context.apiSuccess(MODULE, '异常路径取消未来定时消息任务', cleanupTask, `taskId=${taskId}`, OPTIONS) + taskStatus = cleanedTask?.status || taskStatus + } + if (template?.status === 'ACTIVE') { + const disableResponse = await context.request('PUT', `${TEMPLATE_BASE}/${encodeURIComponent(templateId)}/status`, { + targetStatus: 'DISABLED', version: template.version, + }) + template = context.apiSuccess(MODULE, '停用本轮消息模板', disableResponse, `templateId=${templateId}`, OPTIONS) + finalTemplateStatus = template?.status || finalTemplateStatus + context.assert(MODULE, '消息模板最终为 DISABLED', disableResponse, + template?.status === 'DISABLED', `status=${template?.status || 'missing'}`, OPTIONS) + } + context.createdResources.notifications = { + marker: context.marker, templateId, templateCode, finalTemplateStatus, + taskId: taskId || null, taskStatus: taskStatus || null, retained: true, + } + } +} diff --git a/scripts/admin-test-env-modules/ocean-bookings.mjs b/scripts/admin-test-env-modules/ocean-bookings.mjs index 5165568c0..367f6c7b6 100644 --- a/scripts/admin-test-env-modules/ocean-bookings.mjs +++ b/scripts/admin-test-env-modules/ocean-bookings.mjs @@ -62,6 +62,7 @@ export async function run(context) { const changed = await request(context, 'POST', `${path}/change-request`, { expectedVersion: version, vesselName: `改船-${suffix}`, voyageNo: `CV${suffix}`, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-change` }) context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version) const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change') + let allocatedContainerBookingId = id(changeConfirmed?.containerAllocations?.[0]?.containerBookingId) if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') { context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed, id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId), @@ -70,22 +71,9 @@ export async function run(context) { context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed, Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS) if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return - const existingAllocation = changeConfirmed?.containerAllocations?.find?.( - (item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)) - if (existingAllocation) { - context.assert(MODULE, '确认订舱已自动关联本轮柜次', changed, true, - `containerBookingId=${id(containerFlow.containerBookingId)}`, OPTIONS) - } else { - const allocation = await request(context, 'POST', `${path}/allocations`, { - requirementId: id(requirement.id), containerBookingId: id(containerFlow.containerBookingId), expectedVersion: version, - }, { 'Idempotency-Key': `${context.marker}-ocean-container-allocation`.slice(0, 128) }) - const allocated = context.apiSuccess(MODULE, '放舱前分配本轮柜次', allocation, '', OPTIONS) - version = Number(allocated?.oceanBooking?.version) - context.assert(MODULE, '订舱柜次分配持久化且版本递增', allocation, - allocated?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)) - && Number.isInteger(version), - `containerBookingId=${id(containerFlow.containerBookingId)}, version=${version}`, OPTIONS) - } + context.assert(MODULE, '确认订舱自动生成并关联柜次', changed, + Boolean(allocatedContainerBookingId) && Number.isInteger(version), + `containerBookingId=${allocatedContainerBookingId || 'missing'}, version=${version}`, OPTIONS) } const released = await request(context, 'POST', `${path}/release`, { expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`, @@ -99,12 +87,12 @@ export async function run(context) { context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS) context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final, fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName) - && (!containerFlow || fd?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId))), + && 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 = { id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker, - containerBookingId: containerFlow ? id(containerFlow.containerBookingId) : null, - loadingPlanId: containerFlow ? id(containerFlow.loadingPlanId) : null, + containerBookingId: allocatedContainerBookingId || null, + loadingPlanId: null, finalStatus: fd?.oceanBooking?.status || null, } const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`) diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index 2105b8c87..bd45df178 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -5,13 +5,19 @@ const CONTAINERS = '/api/platform/v1/containers' function id(value) { return value == null ? '' : String(value) } export async function run(context) { - const fixture = context.createdResources?.phase3ContainerLoading + const loadingRequested = context.options?.modules?.includes('phase3-loading') === true + const fixture = loadingRequested + ? context.createdResources?.phase3Loading + : context.createdResources?.phase3Loading || context.createdResources?.phase3ContainerLoading const containerId = id(fixture?.containerBookingId) const planId = id(fixture?.loadingPlanId) const warehouseId = id(fixture?.warehouseId) - if (!containerId || !planId || !warehouseId || fixture?.marker !== context.marker) { + const markerMatches = fixture?.marker === context.marker + || fixture?.marker === `${context.marker}-CAB` + || fixture?.sourceMarker === `${context.marker}-CAB` + if (!containerId || !planId || !warehouseId || !markerMatches) { context.addStep(MODULE, '复用智能装柜产生的货柜 overview fixture', null, false, - `containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS) + `source=${loadingRequested ? 'phase3Loading-required' : 'available-container-fixture'}, containerBookingId=${containerId || 'missing'}, loadingPlanId=${planId || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS) return } @@ -31,12 +37,15 @@ export async function run(context) { `events=${timeline.length}`, OPTIONS) if (overview?.pda) { + const expectsPhotos = fixture === context.createdResources?.phase3Loading + && Array.isArray(fixture?.evidenceTypes) && fixture.evidenceTypes.length > 0 context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse, ['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount'] .every((field) => overview.pda[field] !== undefined) && Array.isArray(overview.pda.photoCounts) && Array.isArray(overview.pda.photos) - && Array.isArray(overview.pda.exceptionReasons), + && Array.isArray(overview.pda.exceptionReasons) + && (!expectsPhotos || overview.pda.photos.length > 0), `taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos.length}`, OPTIONS) for (const [index, photo] of overview.pda.photos.entries()) { context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse, diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index bc13d56bc..da25e570c 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -271,8 +271,11 @@ export async function run(context) { const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS) const inventoryInbound = inventory?.inbounds?.find?.((item) => id(item.inboundId) === inboundId) const inventoryIdentityVerified = id(inventory?.taskId) === canonicalTaskId + && id(inventory?.loadingPlanId) === loadingPlanId + && id(inventory?.containerBookingId) === containerBookingId && Number(inventory?.taskVersion) === Number(claimed?.version) - && inventory?.containerNo === containerNo && id(inventory?.warehouseId) === warehouseId + && id(inventory?.cabinetNo) && inventory?.currentContainerNo === containerNo + && id(inventory?.warehouseId) === warehouseId && Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string' && inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1 && (inventoryInbound?.resources || []).every((resource) => id(resource?.reservationId) @@ -299,7 +302,7 @@ export async function run(context) { const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch` const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) const batchPayload = { - warehouseId, containerNo, + warehouseId, loadingPlanId, containerBookingId, expectedVersion: Number(claimed?.version), inventorySnapshotToken: inventory.snapshotToken, items: [{ @@ -477,36 +480,50 @@ export async function run(context) { const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload, { 'Idempotency-Key': batchKey }) const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS) - const directPdaCompletion = pickedCanonical?.taskStatus === 'COMPLETED' - && pickedCanonical?.workflowStatus === 'COMPLETED' - context.assert(MODULE, 'PDA 原子批次成功并推进任务终态', canonicalPick, - directPdaCompletion && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'), + const awaitingReview = pickedCanonical?.taskStatus === 'LOADING' + && pickedCanonical?.workflowStatus === 'AWAITING_REVIEW' + context.assert(MODULE, 'PDA 原子批次完成拣货并等待人工确认', canonicalPick, + awaitingReview && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'), `taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS) const canonicalBatchReplay = await request(context, 'POST', batchPath, effectiveBatchPayload, { 'Idempotency-Key': batchKey }) const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200' && id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId - && canonicalBatchReplay.body?.data?.taskStatus === 'COMPLETED' + && canonicalBatchReplay.body?.data?.taskStatus === 'LOADING' + && canonicalBatchReplay.body?.data?.workflowStatus === 'AWAITING_REVIEW' && Number(canonicalBatchReplay.body?.data?.version) === Number(pickedCanonical?.version) - context.assert(MODULE, 'PDA 原子批次同参同键重放返回同一终态', canonicalBatchReplay, + context.assert(MODULE, 'PDA 原子批次同参同键重放保持待人工确认', canonicalBatchReplay, atomicBatchReplayVerified, `HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS) + const completeResponse = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/complete`, { + expectedTaskVersion: Number(pickedCanonical?.version), + operationId: `${context.marker}-bridge-complete`.slice(0, 128), remark: context.marker, + }) + const completedCanonical = context.apiSuccess(MODULE, '人工确认柜次 PDA 装柜完成', completeResponse, '', OPTIONS) + const directPdaCompletion = completedCanonical?.taskStatus === 'COMPLETED' + && completedCanonical?.workflowStatus === 'COMPLETED' + context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse, + directPdaCompletion && completedCanonical?.items?.every?.((item) => item.itemStatus === 'PICKED'), + `taskStatus=${completedCanonical?.taskStatus || 'missing'}, workflow=${completedCanonical?.workflowStatus || 'missing'}`, OPTIONS) + const completionOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`) const completionOperations = context.apiSuccess(MODULE, '回查原子 Batch 完成审计', completionOperationsResponse, '', OPTIONS) - context.assert(MODULE, '合法 Batch 完成后保留 CLAIM/BATCH 操作者审计且无伪造 REVIEW', completionOperationsResponse, + context.assert(MODULE, '人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计', completionOperationsResponse, Array.isArray(completionOperations) && completionOperations.some((item) => item.actionType === 'CLAIM' && id(item.operatorId) && Boolean(item.createdAt)) && completionOperations.some((item) => item.actionType === 'BATCH' && id(item.operatorId) && Boolean(item.createdAt)) + && completionOperations.some((item) => item.actionType === 'COMPLETE_LOADING' + && id(item.operatorId) && Boolean(item.createdAt)) && !completionOperations.some((item) => item.actionType === 'REVIEW'), `actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS) - context.assert(MODULE, '首笔真实拣货触发装柜状态并由单明细批次直接完成', pickedPlanResponse, + context.assert(MODULE, '人工确认后柜次装柜计划完成', pickedPlanResponse, directPdaCompletion && pickedPlan?.status === 'COMPLETED', `status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS) @@ -547,11 +564,10 @@ export async function run(context) { `status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS) if (!Number.isInteger(loadingVersion)) return - // The deployed settlement contract completes a one-item task during pick. A - // review call is therefore a negative compatibility assertion, never a second - // positive action that can poison the marker fixture with A0409. + // COMPLETE_LOADING is the only positive completion action; REVIEW is a + // negative compatibility assertion after the task is already complete. const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, { - expectedTaskVersion: Number(pickedCanonical?.version), + expectedTaskVersion: Number(completedCanonical?.version), operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker, }) context.addStep(MODULE, '柜次 PDA 已完成后拒绝重复复核', canonicalReview, @@ -560,55 +576,8 @@ export async function run(context) { : canonicalReview.http === 200 && codeOf(canonicalReview) === '200', `HTTP=${canonicalReview.http}, code=${codeOf(canonicalReview)}`, OPTIONS) - const forgedPickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: 'forged-client-count' }, - { 'Idempotency-Key': `${context.marker}-p3-picking-forged-start`.slice(0, 128) }) - context.addStep(MODULE, '入库级 PDA 汇总拒绝客户端伪造数量', forgedPickingStart, failure(forgedPickingStart), - `HTTP=${forgedPickingStart.http}, code=${codeOf(forgedPickingStart)}`, OPTIONS) - - const pickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker }, - { 'Idempotency-Key': `${context.marker}-p3-picking-start`.slice(0, 128) }) - const pickingStarted = directPdaCompletion - ? null - : context.apiSuccess(MODULE, '开始本轮 PDA 拣货任务', pickingStart, '', OPTIONS) - context.addStep(MODULE, directPdaCompletion - ? '柜次 PDA 已完成后拒绝旧入库级启动入口' : '开始本轮 PDA 拣货任务', pickingStart, - directPdaCompletion - ? pickingStart.http === 200 && codeOf(pickingStart) === 'A0409' - : Boolean(pickingStarted), - `HTTP=${pickingStart.http}, code=${codeOf(pickingStart)}, status=${pickingStarted?.status || 'n/a'}`, OPTIONS) - if (!directPdaCompletion) { - context.assert(MODULE, 'PDA 拣货任务处于 IN_PROGRESS 且计划数量正确', pickingStart, - pickingStarted?.status === 'IN_PROGRESS' && Number(pickingStarted?.version) === 0 - && Number(pickingStarted?.expectedCount) === 1 && Number(pickingStarted?.pickedCount) === 1, - `status=${pickingStarted?.status || 'missing'}, version=${pickingStarted?.version ?? 'missing'}`, OPTIONS) - } - - const pickingMismatch = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: context.marker }, - { 'Idempotency-Key': `${context.marker}-p3-picking-mismatch`.slice(0, 128) }) - context.addStep(MODULE, 'PDA 拣货数量不一致时拒绝完成', pickingMismatch, failure(pickingMismatch), - `HTTP=${pickingMismatch.http}, code=${codeOf(pickingMismatch)}`, OPTIONS) - - const pickingComplete = await request(context, 'POST', `${base}/loading/picking/complete?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: 0, expectedCount: 1, pickedCount: 1, remark: context.marker }, - { 'Idempotency-Key': `${context.marker}-p3-picking-complete`.slice(0, 128) }) - const pickingCompleted = directPdaCompletion - ? null - : context.apiSuccess(MODULE, '完成本轮 PDA 拣货复核', pickingComplete, '', OPTIONS) - context.addStep(MODULE, directPdaCompletion - ? '柜次 PDA 已完成后拒绝旧入库级完成入口' : '完成本轮 PDA 拣货复核', pickingComplete, - directPdaCompletion - ? pickingComplete.http === 200 && codeOf(pickingComplete) === 'A0409' - : Boolean(pickingCompleted), - `HTTP=${pickingComplete.http}, code=${codeOf(pickingComplete)}, status=${pickingCompleted?.status || 'n/a'}`, OPTIONS) - if (!directPdaCompletion) { - context.assert(MODULE, 'PDA 拣货任务 COMPLETED 且数量一致', pickingComplete, - pickingCompleted?.status === 'COMPLETED' && Number(pickingCompleted?.version) === 1 - && Number(pickingCompleted?.expectedCount) === 1 && Number(pickingCompleted?.pickedCount) === 1, - `status=${pickingCompleted?.status || 'missing'}, version=${pickingCompleted?.version ?? 'missing'}`, OPTIONS) - } + context.addStep(MODULE, '柜次 PDA 作为现场作业唯一事实源', null, true, + '本场景不调用旧入库级 picking/start 写接口,避免客户端汇总数量覆盖柜次任务事实', OPTIONS) const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const evidenceItems = [] @@ -814,8 +783,15 @@ export async function run(context) { && evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`), `evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS) + const beforeHardGateResponse = await context.request('GET', detailPath) + const beforeHardGate = context.apiSuccess(MODULE, '读取 LOADED 硬门禁调用前即时版本', beforeHardGateResponse, '', OPTIONS) + const beforeHardGateVersion = Number(beforeHardGate?.summary?.version) + context.assert(MODULE, 'LOADED 硬门禁即时基线状态和版本有效', beforeHardGateResponse, + beforeHardGate?.summary?.processStatus === 'LOADING' && Number.isInteger(beforeHardGateVersion), + `status=${beforeHardGate?.summary?.processStatus || 'missing'}, version=${beforeHardGate?.summary?.version ?? 'missing'}`, OPTIONS) + if (!Number.isInteger(beforeHardGateVersion)) return const blockedBeforeDownstream = await request(context, 'POST', `${base}/loading/complete?tenantId=${encodeURIComponent(tenantId)}`, - { expectedVersion: loadingVersion, remark: `${context.marker}-before-downstream` }, + { expectedVersion: beforeHardGateVersion, remark: `${context.marker}-before-downstream` }, { 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) }) context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream, failure(blockedBeforeDownstream), @@ -824,7 +800,7 @@ export async function run(context) { const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS) context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse, blockedDetail?.summary?.processStatus === 'LOADING' - && Number(blockedDetail?.summary?.version) === loadingVersion, + && Number(blockedDetail?.summary?.version) === beforeHardGateVersion, `status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS) context.createdResources.phase3Loading = { @@ -835,7 +811,7 @@ export async function run(context) { postLoadedFileId, loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion, - atomicBatchVerified: directPdaCompletion && atomicBatchReplayVerified, + atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified, inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified, inventoryMutationVerified, inventoryMutationCleanup, marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED', 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 2dec25d6e..8f8116499 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 @@ -5,7 +5,7 @@ import test from 'node:test' import { selectCustomsFlow } from './phase3-customs.mjs' import { buildFinalizeResource } from './phase3-loading-finalize.mjs' import { selectContainerLoadingFixture } from './phase3-container-loading.mjs' -import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' +import { AcceptanceRun, normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' function source(name) { return readFileSync(new URL(name, import.meta.url), 'utf8') @@ -115,6 +115,11 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap' 'const canonicalBatchReplay = await request', ]) assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/) + assert.match(loading, /warehouseId, loadingPlanId, containerBookingId,/) + assert.match(loading, /inventory\?\.currentContainerNo === containerNo/) + const batchPayload = loading.slice(loading.indexOf('const batchPayload ='), loading.indexOf('const staleBatch =')) + assert.doesNotMatch(batchPayload, /containerNo[,\s]/) + assert.doesNotMatch(loading, /\/loading\/picking\/(?:start|complete)/) assert.match(loading, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/) assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/) assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/) @@ -229,9 +234,10 @@ test('phase3 loading proves stale inventory zero-write for all items and complet '旧 token 冲突对任务、明细、已拣数量和操作历史零写入', '库存变化后刷新 PDA 实时库存快照', '原子提交柜次 PDA 整批拣货', - 'PDA 原子批次同参同键重放返回同一终态', + 'PDA 原子批次同参同键重放保持待人工确认', + '人工确认柜次 PDA 装柜完成', '回查原子 Batch 完成审计', - '合法 Batch 完成后保留 CLAIM/BATCH 操作者审计且无伪造 REVIEW', + '人工完成后保留 CLAIM/BATCH/COMPLETE_LOADING 审计', ]) }) @@ -285,6 +291,23 @@ test('default acceptance order verifies loading evidence before finalizing LOADE ]) }) +test('container overview always follows phase3 loading in default and custom module selections', () => { + const reordered = normalizeModuleOrder(['phase3-container-overview', 'orders', 'phase3-loading', 'finance']) + assert.deepEqual(reordered, ['orders', 'phase3-loading', 'phase3-container-overview', 'finance']) + assert.deepEqual(parseArguments(['--modules', 'phase3-container-overview,phase3-loading']).modules, + ['phase3-loading', 'phase3-container-overview']) + + const entry = source('../admin-test-env-acceptance.mjs') + assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/) + assert.match(entry, /modules: normalizeModuleOrder\(/) +}) + +test('overview does not fall back to a container fixture when loading evidence was requested', () => { + const overview = source('./phase3-container-overview.mjs') + assert.match(overview, /loadingRequested[\s\S]*\? context\.createdResources\?\.phase3Loading/) + assert.match(overview, /source=\$\{loadingRequested \? 'phase3Loading-required'/) +}) + test('phase3 loading provisions its own marker-isolated WAIT_SPLIT fixture', async () => { const run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' }) const entry = source('../admin-test-env-acceptance.mjs') diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs new file mode 100644 index 000000000..bcaee0d4f --- /dev/null +++ b/scripts/controller-pipeline-contract.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import test from 'node:test' + +const pipeline = (await readFile(new URL('../Jenkinsfile', import.meta.url), 'utf8')) + .replaceAll('\r\n', '\n') + +test('AUTO detects database migrations without expanding them to all services', () => { + assert.match(pipeline, /def databaseMigrationPaths = \['deploy\/sql\/'\]/) + assert.match(pipeline, /!databaseMigration && commonPaths\.any/) + assert.match(pipeline, /env\.DATABASE_CHANGE_DETECTED = databaseChanged \? 'true' : 'false'/) +}) + +test('database-only changes are reported as a separate migration action', () => { + assert.match(pipeline, /仅数据库迁移变更 \| 需单独审核执行/) + assert.match(pipeline, /统一 SQL 须独立审核并执行/) +})