This commit is contained in:
2026-08-13 20:00:30 +08:00
parent 50251240f4
commit 5bc62a109e
28 changed files with 602 additions and 176 deletions
Vendored
+17 -3
View File
@@ -24,6 +24,8 @@ def commonPaths = [
'Jenkinsfile.service', 'deploy/' 'Jenkinsfile.service', 'deploy/'
] ]
def databaseMigrationPaths = ['deploy/sql/']
def downstreamResults = [:] def downstreamResults = [:]
pipeline { pipeline {
@@ -83,6 +85,7 @@ pipeline {
error('DOWNSTREAM_JOB_PREFIX 不能为空') error('DOWNSTREAM_JOB_PREFIX 不能为空')
} }
env.RESOLVED_JOB_PREFIX = jobPrefix env.RESOLVED_JOB_PREFIX = jobPrefix
env.DATABASE_CHANGE_DETECTED = 'false'
List selected = [] List selected = []
if (params.BUILD_MODE == 'ALL') { if (params.BUILD_MODE == 'ALL') {
@@ -101,8 +104,13 @@ pipeline {
} else { } else {
List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true) List changed = sh(script: "git diff --name-only ${base} HEAD", returnStdout: true)
.trim().split('\\n').collect { it.trim() }.findAll { it } .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 -> 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 -> selected = commonChanged ? serviceModules : serviceModules.findAll { module ->
changed.any { it == module || it.startsWith("${module}/") } changed.any { it == module || it.startsWith("${module}/") }
@@ -119,13 +127,19 @@ pipeline {
env.SELECTED_SERVICES = selected.join(',') env.SELECTED_SERVICES = selected.join(',')
env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' env.NO_SERVICE_CHANGE = selected ? 'false' : 'true'
if (!selected) { if (!selected) {
currentBuild.description = '没有需要构建的业务服务' currentBuild.description = env.DATABASE_CHANGE_DETECTED == 'true'
? '仅数据库迁移变更 | 需单独审核执行'
: '没有需要构建的业务服务'
} else { } else {
env.SOURCE_COMMIT = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() 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() 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}" echo "本次独立调度服务: ${env.SELECTED_SERVICES}"
} }
if (env.DATABASE_CHANGE_DETECTED == 'true') {
echo '检测到 deploy/sql/ 变更:AUTO 不据此扩大服务范围;统一 SQL 须独立审核并执行。'
}
} }
} }
} }
+3 -2
View File
@@ -12,7 +12,8 @@
|---|---| |---|---|
| 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` | | 登录/刷新/退出/改密 | `src/main/java/com/baiyue/auth/controller/AuthController.java` |
| 内部账号管理与 scope | `controller/InternalAccountController.java`、`service/AccountManagementService.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` | | 注册协作 | `service/MerchantRegistrationService.java` |
| IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` | | IAM 授权客户端 | `client/IamAuthorizationClient.java`、`client/IamOnboardingClient.java` |
| DTO/持久化 | `dto/AccountManagementDtos.java`、`entity/`、`mapper/`、`repository/` | | 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 必须保持幂等和失败补偿;内部接口仅接受服务令牌。 - 注册跨 Auth/IAM 必须保持幂等和失败补偿;内部接口仅接受服务令牌。
- 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。 - 业务错误保持 HTTP 200 + body code;Security 认证失败保留真实 401/403。
@@ -10262,6 +10262,13 @@ CREATE TABLE IF NOT EXISTS fin_subject (
UNIQUE KEY uk_fin_subject_code (subject_code) UNIQUE KEY uk_fin_subject_code (subject_code)
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体'; ) 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 ( CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -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") @FeignClient(name="firstleg-service", contextId="financeFirstlegCostSnapshotClient", url="${baiyue.clients.firstleg.url:}", path="/internal/firstleg/v1/finance-cost-snapshots")
public interface FirstlegCostSnapshotClient { public interface FirstlegCostSnapshotClient {
@GetMapping("/customers/{customerId}/containers/{containerId}") @GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}")
ApiResponse<ContainerSnapshot> container(@RequestHeader("X-Internal-Service-Token") String token, ApiResponse<ContainerSnapshot> loadingPlan(@RequestHeader("X-Internal-Service-Token") String token,
@PathVariable long customerId, @PathVariable long containerId); @PathVariable long customerId,
record ContainerSnapshot(long containerId, long customerId, List<OrderVolume> orders) {} @PathVariable long loadingPlanId);
record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId,
List<OrderVolume> orders) {}
record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {} record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
} }
@@ -175,12 +175,20 @@ public class PlatformFinanceController {
java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of(); java.util.List<FinanceService.ContainerOrderVolume> volumes = java.util.List.of();
if ("CONTAINER_SHARED".equals(request.costType())) { if ("CONTAINER_SHARED".equals(request.costType())) {
if (request.containerId() == null) throw new IllegalArgumentException("整柜成本必须指定柜次"); 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(); 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未返回有效柜内订单体积快照"); 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))); return FinanceResponses.success(resourceView(service.createCost(request.customerId(), user.userId(), internal, volumes, key)));
} }
@@ -65,6 +65,13 @@ CREATE TABLE IF NOT EXISTS fin_subject (
UNIQUE KEY uk_fin_subject_code (subject_code) UNIQUE KEY uk_fin_subject_code (subject_code)
) ENGINE=InnoDB COMMENT='平台财务主体;首期仅一个有效主体'; ) 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 ( CREATE TABLE IF NOT EXISTS fin_customer_settlement_profile (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键', tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和数据隔离键',
@@ -26,7 +26,7 @@ class FinanceContractTest {
assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')")); assertTrue(service.contains("status IN ('ISSUED','PARTIALLY_PAID','PAID')"));
String controller = Files.readString(Path.of("src/main/java/com/baiyue/finance/controller/PlatformFinanceController.java")); 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("/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())")); assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())"));
String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java")); String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java"));
assertTrue(operations.contains("bl.source_id=f.id")); assertTrue(operations.contains("bl.source_id=f.id"));
@@ -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'");
}
}
}
@@ -14,6 +14,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.util.ReflectionTestUtils;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -45,15 +46,15 @@ class PlatformFinanceControllerBehaviorTest {
var request = new PlatformFinanceController.CostRequest( var request = new PlatformFinanceController.CostRequest(
10L, "CONTAINER_SHARED", null, 88L, "清关费", new BigDecimal("300.00"), 10L, "CONTAINER_SHARED", null, 88L, "清关费", new BigDecimal("300.00"),
"CNY", List.of("91"), "整柜成本"); "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(101L, 1001L, new BigDecimal("1.25")),
new FirstlegCostSnapshotClient.OrderVolume(102L, 1002L, new BigDecimal("2.75")))); 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)); when(service.createCost(eq(10L), eq(7L), any(), any(), eq("cost-key"))).thenReturn(Map.of("id", 1L));
controller.cost(user, "cost-key", request); 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( 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(101L, 1001L, 88L, 10L, new BigDecimal("1.25")),
new FinanceService.ContainerOrderVolume(102L, 1002L, 88L, 10L, new BigDecimal("2.75")))), eq("cost-key")); 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( var request = new PlatformFinanceController.CostRequest(
10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN, 10L, "CONTAINER_SHARED", null, 88L, "拖车费", BigDecimal.TEN,
"CNY", List.of("91"), null); "CNY", List.of("91"), null);
when(snapshotClient.container("internal-token", 10L, 88L)).thenReturn(ApiResponse.success( when(snapshotClient.loadingPlan("internal-token", 10L, 88L)).thenReturn(ApiResponse.success(
new FirstlegCostSnapshotClient.ContainerSnapshot(88L, 10L, List.of()))); 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)) assertThatThrownBy(() -> controller.cost(user, "cost-key", request))
.hasMessageContaining("未返回有效柜内订单体积快照"); .hasMessageContaining("未返回有效柜内订单体积快照");
+3 -2
View File
@@ -13,6 +13,7 @@
| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | | 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` |
| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | | 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` |
| 平台测量/OCR | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.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/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` | | 审核/自动完成 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`InboundCompletionOrchestrator.java` |
| FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` | | FBA 主数据/计费 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java` |
@@ -31,7 +32,7 @@
| 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` | | 报关协同 | `customs/controller/PlatformCustomsController.java`、`MerchantCustomsController.java` |
| 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | | 跨模型兼容桥 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` |
| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.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` | | 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` |
## 修改约束 ## 修改约束
@@ -44,7 +45,7 @@
## 三期装柜与 PDA 资源结算 ## 三期装柜与 PDA 资源结算
- 智能装柜候选只能按同一国内中转仓合柜;推荐顺序按入库时间先进先出。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,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 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 - PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。
- `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。 - `releaseMode=AUTO` 时,释放资源数为 `ceil(实际拣货箱数 × 原资源数 / 原可用箱数)`;`MANUAL` 按 `locationIds` 选择,空数组表示明确释放 0 个。未传模式时,`locationIds=null` 兼容为 AUTO。
- 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。 - 头程快照必须保存仓库 `reservation_id`。A0 是共享临时库位,多个托盘可具有相同 `location_id`,跨服务结算、转移、释放必须使用 `reservationId`,不得把 `locationId` 当作唯一资源标识。
@@ -30,6 +30,9 @@ public final class InboundMeasurementResponses {
Long measurementId, Long measurementId,
String sourceType, String sourceType,
String measurementStatus, String measurementStatus,
@Schema(description = "入库主单乐观锁版本,用于测量审核、驳回和异常放行", example = "3")
Integer inboundVersion,
@Schema(description = "测量批次乐观锁版本,用于测量草稿和明细维护", example = "4")
Integer version, Integer version,
String originalDocumentNo, String originalDocumentNo,
BigDecimal originalGrossWeightKg, BigDecimal originalGrossWeightKg,
@@ -17,13 +17,13 @@ public class InternalFinanceCostSnapshotController {
@Value("${baiyue.security.internal-service-token}") private String expectedToken; @Value("${baiyue.security.internal-service-token}") private String expectedToken;
public InternalFinanceCostSnapshotController(FinanceCostSnapshotService service) { this.service = service; } public InternalFinanceCostSnapshotController(FinanceCostSnapshotService service) { this.service = service; }
@GetMapping("/customers/{customerId}/containers/{containerId}") @GetMapping("/customers/{customerId}/loading-plans/{loadingPlanId}")
public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> container( public ApiResponse<FinanceCostSnapshotService.ContainerSnapshot> loadingPlan(
@RequestHeader(value="X-Internal-Service-Token", required=false) String token, @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))) { if (token == null || !MessageDigest.isEqual(token.getBytes(StandardCharsets.UTF_8), expectedToken.getBytes(StandardCharsets.UTF_8))) {
throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效");
} }
return ApiResponse.success(service.container(customerId, containerId)); return ApiResponse.success(service.loadingPlan(customerId, loadingPlanId));
} }
} }
@@ -13,27 +13,26 @@ public class FinanceCostSnapshotService {
private final SmartLoadingRepository repository; private final SmartLoadingRepository repository;
public FinanceCostSnapshotService(SmartLoadingRepository repository) { this.repository = 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) { if (customerId <= 0 || loadingPlanId <= 0) {
throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); 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); var plan = repository.plan(loadingPlanId);
if (plan == null) plan = repository.planByBooking(loadingPlanId);
if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在"); if (plan == null) throw new BizException(ResultCode.NOT_FOUND, "柜次不存在");
long effectivePlanId = plan.getId() == null || plan.getId() <= 0 ? loadingPlanId : plan.getId(); if (plan.getContainerBookingId() == null || plan.getContainerBookingId() <= 0) {
List<OrderVolume> orders = repository.items(effectivePlanId).stream() throw new BizException(ResultCode.BAD_REQUEST, "装柜计划未关联柜次");
}
List<OrderVolume> orders = repository.items(loadingPlanId).stream()
.filter(item -> item.getAccountId() != null && item.getAccountId() == customerId) .filter(item -> item.getAccountId() != null && item.getAccountId() == customerId)
.filter(item -> item.getOrderId() != null && item.getOrderId() > 0) .filter(item -> item.getOrderId() != null && item.getOrderId() > 0)
.filter(item -> item.getVolumeCbm() != null && item.getVolumeCbm().signum() > 0) .filter(item -> item.getVolumeCbm() != null && item.getVolumeCbm().signum() > 0)
.map(item -> new OrderVolume(item.getOrderId(), item.getInboundId(), item.getVolumeCbm())) .map(item -> new OrderVolume(item.getOrderId(), item.getInboundId(), item.getVolumeCbm()))
.toList(); .toList();
if (orders.isEmpty()) throw new BizException(ResultCode.BAD_REQUEST, "柜次没有该客户的有效订单体积"); 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<OrderVolume> orders) {} public record ContainerSnapshot(long loadingPlanId, long containerBookingId, long customerId,
List<OrderVolume> orders) {}
public record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {} public record OrderVolume(long orderId, Long inboundId, BigDecimal volumeCbm) {}
} }
@@ -347,6 +347,7 @@ public class InboundMeasurementService {
review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(), review == null ? null : review.getReviewerName(), measurement == null ? null : measurement.getId(),
measurement == null ? null : measurement.getSourceType(), measurement == null ? null : measurement.getSourceType(),
measurement == null ? null : measurement.getStatus(), measurement == null ? null : measurement.getStatus(),
inbound.getVersion(),
measurement == null ? null : measurement.getVersion(), measurement == null ? null : measurement.getVersion(),
measurement == null ? null : measurement.getOriginalDocumentNo(), measurement == null ? null : measurement.getOriginalDocumentNo(),
measurement == null ? null : measurement.getOriginalGrossWeightKg(), measurement == null ? null : measurement.getOriginalGrossWeightKg(),
@@ -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;
}
}
@@ -24,28 +24,34 @@ class FinanceCostSnapshotServiceTest {
@Test @Test
void returnsOnlyValidVolumesOwnedByCustomer() { 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( when(repository.items(88L)).thenReturn(List.of(
item(10L, 101L, 1001L, "1.25"), item(10L, 101L, 1001L, "1.25"),
item(11L, 102L, 1002L, "2.00"), item(11L, 102L, 1002L, "2.00"),
item(10L, 103L, 1003L, "0"))); item(10L, 103L, 1003L, "0")));
var snapshot = service.container(10L, 88L); var snapshot = service.loadingPlan(10L, 88L);
assertThat(snapshot.customerId()).isEqualTo(10L); assertThat(snapshot.customerId()).isEqualTo(10L);
assertThat(snapshot.containerId()).isEqualTo(88L); assertThat(snapshot.loadingPlanId()).isEqualTo(88L);
assertThat(snapshot.containerBookingId()).isEqualTo(99L);
assertThat(snapshot.orders()).containsExactly( assertThat(snapshot.orders()).containsExactly(
new FinanceCostSnapshotService.OrderVolume(101L, 1001L, new BigDecimal("1.25"))); new FinanceCostSnapshotService.OrderVolume(101L, 1001L, new BigDecimal("1.25")));
} }
@Test @Test
void rejectsContainerWithoutValidCustomerVolume() { 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( when(repository.items(88L)).thenReturn(List.of(
item(11L, 102L, 1002L, "2.00"), item(11L, 102L, 1002L, "2.00"),
item(10L, 103L, 1003L, "0"))); item(10L, 103L, 1003L, "0")));
assertThatThrownBy(() -> service.container(10L, 88L)) assertThatThrownBy(() -> service.loadingPlan(10L, 88L))
.hasMessageContaining("没有该客户的有效订单体积"); .hasMessageContaining("没有该客户的有效订单体积");
} }
@@ -53,23 +59,18 @@ class FinanceCostSnapshotServiceTest {
void rejectsUnknownContainer() { void rejectsUnknownContainer() {
when(repository.plan(88L)).thenReturn(null); when(repository.plan(88L)).thenReturn(null);
assertThatThrownBy(() -> service.container(10L, 88L)) assertThatThrownBy(() -> service.loadingPlan(10L, 88L))
.hasMessageContaining("柜次不存在"); .hasMessageContaining("柜次不存在");
} }
@Test @Test
void resolvesLegacyContainerBookingIdToLoadingPlan() { void rejectsPlanWithoutContainerBooking() {
var plan = new LoadingPlanEntity(); var plan = new LoadingPlanEntity();
plan.setId(88L); plan.setId(88L);
plan.setContainerBookingId(99L); when(repository.plan(88L)).thenReturn(plan);
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")));
var snapshot = service.container(10L, 99L); assertThatThrownBy(() -> service.loadingPlan(10L, 88L))
.hasMessageContaining("未关联柜次");
assertThat(snapshot.containerId()).isEqualTo(99L);
assertThat(snapshot.orders()).hasSize(1);
} }
private LoadingPlanItemEntity item(long customerId, long orderId, long inboundId, String volume) { private LoadingPlanItemEntity item(long customerId, long orderId, long inboundId, String volume) {
@@ -87,12 +87,20 @@ class InboundMeasurementServiceTest {
@Test @Test
void detailReturnsStandardPalletRecommendationWithoutAllocatingPallet() { 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()); when(repository.listWeights(1L, 10L)).thenReturn(List.of());
InboundMeasurementResponses.Detail result = service.detail(1L, 10L); InboundMeasurementResponses.Detail result = service.detail(1L, 10L);
assertThat(result.recommendedPalletType()).isEqualTo("STANDARD"); assertThat(result.recommendedPalletType()).isEqualTo("STANDARD");
assertThat(result.inboundVersion()).isEqualTo(7);
assertThat(result.version()).isEqualTo(4);
verify(repository, never()).updateInbound(any()); verify(repository, never()).updateInbound(any());
} }
+24 -7
View File
@@ -12,7 +12,19 @@ import process from 'node:process'
const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000'
const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_USERNAME = 'platform_admin'
const DEFAULT_PASSWORD = 'ChangeMe123!' 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 WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG'
const CUSTOMER_FIELDS = [ const CUSTOMER_FIELDS = [
'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative',
@@ -47,7 +59,7 @@ function usage() {
环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`) 环境变量覆盖: ADMIN_TEST_GATEWAY, ADMIN_TEST_USERNAME, ADMIN_TEST_PASSWORD`)
} }
function parseArguments(argv) { export function parseArguments(argv) {
if (argv.includes('--help')) { usage(); process.exit(0) } if (argv.includes('--help')) { usage(); process.exit(0) }
const values = new Map() const values = new Map()
for (let index = 0; index < argv.length; index += 1) { 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(/\/$/, ''), gateway: (values.get('gateway') || process.env.ADMIN_TEST_GATEWAY || DEFAULT_GATEWAY).replace(/\/$/, ''),
username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME, username: values.get('username') || process.env.ADMIN_TEST_USERNAME || DEFAULT_USERNAME,
password: values.get('password') || process.env.ADMIN_TEST_PASSWORD || DEFAULT_PASSWORD, 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') || '', output: values.get('output') || '',
} }
} }
@@ -638,21 +650,26 @@ export class AcceptanceRun {
Number(page?.total) === records.length && records.length < 200, Number(page?.total) === records.length && records.length < 200,
`total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options) `total=${page?.total ?? 'missing'}, records=${records.length}, normal=${locations.length}`, options)
const disabled = [] const disabled = []
const skippedOccupied = []
for (const location of locations) { for (const location of locations) {
const response = await this.request('PATCH', const response = await this.request('PATCH',
`/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`, `/api/platform/v1/locations/${encodeURIComponent(location.id)}/status?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`,
{ status: 'DISABLED', version: Number(location.version) }) { status: 'DISABLED', version: Number(location.version) })
const success = response?.http === 200 && String(response?.body?.code) === '200' const success = response?.http === 200 && String(response?.body?.code) === '200'
this.assert(module, `临时停用 A0 隔离库位 ${location.locationCode}`, response, success, const occupiedConflict = response?.http === 200
`locationId=${location.id}, beforeVersion=${location.version}`, options) && 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 (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', const availableResponse = await this.request('GET',
`/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`) `/api/platform/v1/locations/available?tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&limit=200`)
const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options) const available = this.apiSuccess(module, '停用后回查收货仓无可用普通库位', availableResponse, '', options)
this.assert(module, '测量前 A0 溢出条件成立', availableResponse, this.assert(module, '测量前 A0 溢出条件成立', availableResponse,
Array.isArray(available) && available.length === 0 && disabled.length === locations.length, Array.isArray(available) && available.length === 0,
`disabled=${disabled.length}, available=${Array.isArray(available) ? available.length : 'missing'}`, options) `disabled=${disabled.length}, skippedOccupied=${skippedOccupied.map((item) => `${item.code}:${item.id}`).join('|') || 'none'}, available=${Array.isArray(available) ? available.length : 'missing'}`, options)
return disabled return disabled
} }
+13
View File
@@ -36,6 +36,19 @@ export async function run(context) {
- 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。 - 缺少已确认契约时记录失败或 `deploymentBlocker`,不得模拟成功。
- 一个模块只修改自己的 `.mjs`,不要修改其他模块文件。 - 一个模块只修改自己的 `.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` 覆盖平台公共供应商的列表、统计、新增、详情、编辑、启停及主要错误边界。执行: `suppliers.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, /const basePath = `\/api\/platform\/v1\/loading-plans\//)
assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/) assert.match(source, /sourceType: detail\?\.sourceType \|\| 'MANUAL'/)
assert.match(source, /created\?\.marker === context\.marker/) 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 === 'KG'/)
assert.match(source, /chargeUnit === 'CBM'/) assert.match(source, /chargeUnit === 'CBM'/)
}) })
@@ -65,7 +65,7 @@ function hookSet({ missing = false } = {}) {
loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }), loadSupplier: async () => missing ? null : ({ id: 'PARTNER-1', partnerName: '往来单位一', status: 'ACTIVE' }),
uploadVoucher: async () => missing ? null : 'VOUCHER-1', uploadVoucher: async () => missing ? null : 'VOUCHER-1',
verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }), verifyContainerCostSnapshotInput: async () => ({ valid: true, items: [{ accountId: '701', orderId: 'O-AUTO1', volumeCbm: 0.072 }] }),
calculateAutomaticFee: async (context, resource) => { loadPublishedAutomaticFee: async (context, resource) => {
automaticIndex += 1 automaticIndex += 1
const weight = automaticIndex % 2 === 1 const weight = automaticIndex % 2 === 1
return { feeSourceId: `F-${automaticIndex}`, orderId: resource.orderId, inboundId: resource.inboundId, 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\)\}`\)/) 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', () => { test('fixture creates four real receivable manual sources before payable source', () => {
assert.match(source, /const receivableResources = measured\.slice\(0, 4\)/) assert.match(source, /const receivableResources = measured\.slice\(0, 4\)/)
assert.match(source, /feeItems\.RECEIVABLE, 'RECEIVABLE'/) assert.match(source, /feeItems\.RECEIVABLE, 'RECEIVABLE'/)
@@ -130,11 +130,11 @@ async function createManualFee(context, resource, feeItem, direction, amount, ba
chargeableWeightKg: detail?.chargeableWeightKg ?? null, chargeableVolumeCbm: detail?.chargeableVolumeCbm ?? null } chargeableWeightKg: detail?.chargeableWeightKg ?? null, chargeableVolumeCbm: detail?.chargeableVolumeCbm ?? null }
} }
async function calculateAutomaticFee(context, resource) { async function loadPublishedAutomaticFee(context, resource) {
if (context.financeFixtureHooks?.calculateAutomaticFee) return context.financeFixtureHooks.calculateAutomaticFee(context, resource) if (context.financeFixtureHooks?.loadPublishedAutomaticFee) return context.financeFixtureHooks.loadPublishedAutomaticFee(context, resource)
const pricingInboundResponse = await context.request('GET', const pricingInboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(resource.inboundId)}?tenantId=${encodeURIComponent(resource.tenantId)}`) `/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) `inboundId=${resource.inboundId}`, OPTIONS)
const pricingSummary = pricingInbound?.summary || {} const pricingSummary = pricingInbound?.summary || {}
const actualWeightKg = Number(pricingSummary.actualGrossWeightKg ?? pricingSummary.actualWeightKg) 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'};测量表有值但主表未同步`) `服务端计费实际读取 fl_inbound_order.actual_gross_weight_kg/actual_volume_cbm;inboundId=${resource.inboundId}, actualWeightKg=${actualWeightKg || 'missing'}, actualVolumeCbm=${actualVolumeCbm || 'missing'};测量表有值但主表未同步`)
return null return null
} }
const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fees` const listPath = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details?accountId=${encodeURIComponent(resource.tenantId)}`
const query = `tenantId=${encodeURIComponent(resource.tenantId)}` const listResponse = await context.request('GET', listPath)
const calculateResponse = await request(context, 'POST', `${base}/calculate?${query}`, {}) const details = context.apiSuccess(MODULE, '回读审核链已发布自动费用来源', listResponse,
const calculated = context.apiSuccess(MODULE, '按审核测量快照执行真实自动计费', calculateResponse, `inboundId=${resource.inboundId}`, OPTIONS)
`orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS) const published = rows(details).find((item) => item?.sourceType === 'FBA_PRICING'
const feeSourceId = id(calculated?.feeId) && item?.reviewStatus === 'APPROVED' && item?.publicationStatus === 'PUBLISHED'
const billingBasis = calculated?.chargeUnit === 'KG' ? 'WEIGHT_KG' && item?.settlementIncluded === true)
: calculated?.chargeUnit === 'CBM' ? 'VOLUME_CBM' : null if (!published) {
const responseWeightKg = Number(calculated?.actualWeightKg) blocker(context, '审核链已生成可结算自动费用来源',
const responseVolumeCbm = Number(calculated?.actualVolumeCbm) `inboundId=${resource.inboundId}, processStatus=${pricingSummary.processStatus || pricingInbound?.processStatus || 'missing'}, feeDetails=${rows(details).length};测量审核完成链应在进入 WAIT_SPLIT 前生成 APPROVED + PUBLISHED 的 FBA_PRICING 来源,fixture 不重复试算/确认/发布`)
context.assert(MODULE, '自动费用返回来源、金额和计费依据', calculateResponse, return null
Boolean(feeSourceId) && Boolean(billingBasis) && Number(calculated?.totalAmount) >= 0 }
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, && 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 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', return { feeSourceId, orderId: id(resource.orderId), inboundId: id(resource.inboundId), sourceType: 'FBA_PRICING',
direction: 'RECEIVABLE', currency: sent?.currency || confirmed?.currency || calculated.currency, direction: 'RECEIVABLE', currency: published.currency,
amount: Number(sent?.totalAmount ?? confirmed?.totalAmount ?? calculated.totalAmount), amount: Number(published.amount), businessDate: String(published.businessDate || published.publishedAt || '').slice(0, 10),
businessDate: String(sent?.sentAt || confirmed?.confirmedAt || calculated.calculatedAt || '').slice(0, 10), billingBasis, chargeableQuantity: published.chargeableQuantity,
billingBasis, chargeableQuantity: sent?.chargeableQuantity ?? calculated.chargeableQuantity, chargeUnit: published.chargeUnit,
chargeUnit: sent?.chargeUnit || calculated.chargeUnit,
actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm } actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm }
} }
@@ -328,7 +330,7 @@ export async function run(context) {
} }
const fixtures = [] const fixtures = []
for (const resource of measured.slice(0, 4)) { 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) if (fee) fixtures.push(fee)
} }
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) { for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) {
@@ -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/)
})
@@ -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,
}
}
}
@@ -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` }) 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) context.apiSuccess(MODULE, '申请改船', changed, '', OPTIONS); version = Number(data(changed)?.oceanBooking?.version)
const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change') const changeConfirmed = await action('确认改船', 'change-confirm', version, 'confirm change')
let allocatedContainerBookingId = id(changeConfirmed?.containerAllocations?.[0]?.containerBookingId)
if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') { if (containerFlow?.finalStatus === 'COMPLETED' || containerFlow?.finalStatus === 'LOADED') {
context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed, context.assert(MODULE, '订舱与本轮柜次客户归属一致', changed,
id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId), id(containerFlow?.tenantId) === id(changeConfirmed?.oceanBooking?.customerId),
@@ -70,22 +71,9 @@ export async function run(context) {
context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed, context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', changed,
Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS) Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS)
if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return if (!requirement?.id || id(containerFlow?.tenantId) !== id(changeConfirmed?.oceanBooking?.customerId)) return
const existingAllocation = changeConfirmed?.containerAllocations?.find?.( context.assert(MODULE, '确认订舱自动生成并关联柜次', changed,
(item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)) Boolean(allocatedContainerBookingId) && Number.isInteger(version),
if (existingAllocation) { `containerBookingId=${allocatedContainerBookingId || 'missing'}, version=${version}`, OPTIONS)
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)
}
} }
const released = await request(context, 'POST', `${path}/release`, { const released = await request(context, 'POST', `${path}/release`, {
expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`, expectedVersion: version, billOfLadingNo: `BL-${suffix}`, vesselName: `放舱船-${suffix}`,
@@ -99,12 +87,12 @@ export async function run(context) {
context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS) context.apiSuccess(MODULE, '回查 RELEASED 订舱详情', final, '', OPTIONS)
context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final, context.assert(MODULE, '订舱状态历史、审计操作者、柜次关联和终态完整', final,
fd?.oceanBooking?.status === 'RELEASED' && fd.histories?.length >= 5 && fd.histories.every((h) => h.operatorName) 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) `status=${fd?.oceanBooking?.status || 'missing'}, histories=${fd?.histories?.length ?? 'n/a'}, allocations=${fd?.containerAllocations?.length ?? 'n/a'}`, OPTIONS)
context.createdResources.oceanBooking = { context.createdResources.oceanBooking = {
id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker, id: bookingId, customerId: id(fd?.oceanBooking?.customerId), marker: context.marker,
containerBookingId: containerFlow ? id(containerFlow.containerBookingId) : null, containerBookingId: allocatedContainerBookingId || null,
loadingPlanId: containerFlow ? id(containerFlow.loadingPlanId) : null, loadingPlanId: null,
finalStatus: fd?.oceanBooking?.status || null, finalStatus: fd?.oceanBooking?.status || null,
} }
const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`) const missingDetail = await request(context, 'GET', `${BASE}/999999999999999999`)
@@ -5,13 +5,19 @@ const CONTAINERS = '/api/platform/v1/containers'
function id(value) { return value == null ? '' : String(value) } function id(value) { return value == null ? '' : String(value) }
export async function run(context) { 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 containerId = id(fixture?.containerBookingId)
const planId = id(fixture?.loadingPlanId) const planId = id(fixture?.loadingPlanId)
const warehouseId = id(fixture?.warehouseId) 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, 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 return
} }
@@ -31,12 +37,15 @@ export async function run(context) {
`events=${timeline.length}`, OPTIONS) `events=${timeline.length}`, OPTIONS)
if (overview?.pda) { if (overview?.pda) {
const expectsPhotos = fixture === context.createdResources?.phase3Loading
&& Array.isArray(fixture?.evidenceTypes) && fixture.evidenceTypes.length > 0
context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse, context.assert(MODULE, 'overview PDA 摘要字段完整', overviewResponse,
['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount'] ['taskId', 'taskStatus', 'taskVersion', 'itemCount', 'completedItemCount', 'unresolvedItemCount']
.every((field) => overview.pda[field] !== undefined) .every((field) => overview.pda[field] !== undefined)
&& Array.isArray(overview.pda.photoCounts) && Array.isArray(overview.pda.photoCounts)
&& Array.isArray(overview.pda.photos) && 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) `taskId=${id(overview.pda.taskId)}, photos=${overview.pda.photos.length}`, OPTIONS)
for (const [index, photo] of overview.pda.photos.entries()) { for (const [index, photo] of overview.pda.photos.entries()) {
context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse, context.assert(MODULE, `overview PDA 照片摘要 ${index + 1} 字段完整`, overviewResponse,
@@ -271,8 +271,11 @@ export async function run(context) {
const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS) const inventory = context.apiSuccess(MODULE, '读取 PDA 提交前实时库存快照', inventoryResponse, '', OPTIONS)
const inventoryInbound = inventory?.inbounds?.find?.((item) => id(item.inboundId) === inboundId) const inventoryInbound = inventory?.inbounds?.find?.((item) => id(item.inboundId) === inboundId)
const inventoryIdentityVerified = id(inventory?.taskId) === canonicalTaskId const inventoryIdentityVerified = id(inventory?.taskId) === canonicalTaskId
&& id(inventory?.loadingPlanId) === loadingPlanId
&& id(inventory?.containerBookingId) === containerBookingId
&& Number(inventory?.taskVersion) === Number(claimed?.version) && 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' && Boolean(inventory?.capturedAt) && typeof inventory?.snapshotToken === 'string'
&& inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1 && inventory.snapshotToken.length === 64 && Number(inventoryInbound?.availablePackageCount) >= 1
&& (inventoryInbound?.resources || []).every((resource) => id(resource?.reservationId) && (inventoryInbound?.resources || []).every((resource) => id(resource?.reservationId)
@@ -299,7 +302,7 @@ export async function run(context) {
const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch` const batchPath = `${pdaBase}/tasks/${canonicalTaskId}/batch`
const batchKey = `${context.marker}-bridge-batch`.slice(0, 128) const batchKey = `${context.marker}-bridge-batch`.slice(0, 128)
const batchPayload = { const batchPayload = {
warehouseId, containerNo, warehouseId, loadingPlanId, containerBookingId,
expectedVersion: Number(claimed?.version), expectedVersion: Number(claimed?.version),
inventorySnapshotToken: inventory.snapshotToken, inventorySnapshotToken: inventory.snapshotToken,
items: [{ items: [{
@@ -477,36 +480,50 @@ export async function run(context) {
const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload, const canonicalPick = await request(context, 'POST', batchPath, effectiveBatchPayload,
{ 'Idempotency-Key': batchKey }) { 'Idempotency-Key': batchKey })
const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS) const pickedCanonical = context.apiSuccess(MODULE, '原子提交柜次 PDA 整批拣货', canonicalPick, '', OPTIONS)
const directPdaCompletion = pickedCanonical?.taskStatus === 'COMPLETED' const awaitingReview = pickedCanonical?.taskStatus === 'LOADING'
&& pickedCanonical?.workflowStatus === 'COMPLETED' && pickedCanonical?.workflowStatus === 'AWAITING_REVIEW'
context.assert(MODULE, 'PDA 原子批次成功并推进任务终态', canonicalPick, context.assert(MODULE, 'PDA 原子批次完成拣货并等待人工确认', canonicalPick,
directPdaCompletion && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'), awaitingReview && pickedCanonical?.items?.every?.((item) => item.itemStatus !== 'PENDING'),
`taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS) `taskStatus=${pickedCanonical?.taskStatus || 'missing'}, workflow=${pickedCanonical?.workflowStatus || 'missing'}`, OPTIONS)
const canonicalBatchReplay = await request(context, 'POST', batchPath, effectiveBatchPayload, const canonicalBatchReplay = await request(context, 'POST', batchPath, effectiveBatchPayload,
{ 'Idempotency-Key': batchKey }) { 'Idempotency-Key': batchKey })
const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200' const atomicBatchReplayVerified = canonicalBatchReplay.http === 200 && codeOf(canonicalBatchReplay) === '200'
&& id(canonicalBatchReplay.body?.data?.id) === canonicalTaskId && 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) && Number(canonicalBatchReplay.body?.data?.version) === Number(pickedCanonical?.version)
context.assert(MODULE, 'PDA 原子批次同参同键重放返回同一终态', canonicalBatchReplay, context.assert(MODULE, 'PDA 原子批次同参同键重放保持待人工确认', canonicalBatchReplay,
atomicBatchReplayVerified, atomicBatchReplayVerified,
`HTTP=${canonicalBatchReplay.http}, code=${codeOf(canonicalBatchReplay)}, taskVersion=${canonicalBatchReplay.body?.data?.version ?? 'missing'}`, OPTIONS) `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 completionOperationsResponse = await context.request('GET', `${pdaBase}/tasks/${canonicalTaskId}/operations`)
const completionOperations = context.apiSuccess(MODULE, '回查原子 Batch 完成审计', completionOperationsResponse, '', OPTIONS) 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) Array.isArray(completionOperations)
&& completionOperations.some((item) => item.actionType === 'CLAIM' && completionOperations.some((item) => item.actionType === 'CLAIM'
&& id(item.operatorId) && Boolean(item.createdAt)) && id(item.operatorId) && Boolean(item.createdAt))
&& completionOperations.some((item) => item.actionType === 'BATCH' && completionOperations.some((item) => item.actionType === 'BATCH'
&& id(item.operatorId) && Boolean(item.createdAt)) && 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'), && !completionOperations.some((item) => item.actionType === 'REVIEW'),
`actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS) `actions=${Array.isArray(completionOperations) ? completionOperations.map((item) => item.actionType).join(',') : 'missing'}`, OPTIONS)
const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`) const pickedPlanResponse = await context.request('GET', `${smartLoadingBase}/plans/${loadingPlanId}`)
const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS) const pickedPlan = context.apiSuccess(MODULE, '首笔真实拣货后回查柜次计划', pickedPlanResponse, '', OPTIONS)
context.assert(MODULE, '首笔真实拣货触发装柜状态并由单明细批次直接完成', pickedPlanResponse, context.assert(MODULE, '人工确认后柜次装柜计划完成', pickedPlanResponse,
directPdaCompletion && pickedPlan?.status === 'COMPLETED', directPdaCompletion && pickedPlan?.status === 'COMPLETED',
`status=${pickedPlan?.status || 'missing'}, version=${pickedPlan?.version ?? 'missing'}`, OPTIONS) `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) `status=${loading?.summary?.processStatus || 'missing'}, version=${loading?.summary?.version ?? 'missing'}`, OPTIONS)
if (!Number.isInteger(loadingVersion)) return if (!Number.isInteger(loadingVersion)) return
// The deployed settlement contract completes a one-item task during pick. A // COMPLETE_LOADING is the only positive completion action; REVIEW is a
// review call is therefore a negative compatibility assertion, never a second // negative compatibility assertion after the task is already complete.
// positive action that can poison the marker fixture with A0409.
const canonicalReview = await request(context, 'POST', `${pdaBase}/tasks/${canonicalTaskId}/review`, { 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, operationId: `${context.marker}-bridge-review`.slice(0, 128), remark: context.marker,
}) })
context.addStep(MODULE, '柜次 PDA 已完成后拒绝重复复核', canonicalReview, context.addStep(MODULE, '柜次 PDA 已完成后拒绝重复复核', canonicalReview,
@@ -560,55 +576,8 @@ export async function run(context) {
: canonicalReview.http === 200 && codeOf(canonicalReview) === '200', : canonicalReview.http === 200 && codeOf(canonicalReview) === '200',
`HTTP=${canonicalReview.http}, code=${codeOf(canonicalReview)}`, OPTIONS) `HTTP=${canonicalReview.http}, code=${codeOf(canonicalReview)}`, OPTIONS)
const forgedPickingStart = await request(context, 'POST', `${base}/loading/picking/start?tenantId=${encodeURIComponent(tenantId)}`, context.addStep(MODULE, '柜次 PDA 作为现场作业唯一事实源', null, true,
{ expectedVersion: 0, expectedCount: 1, pickedCount: 0, remark: 'forged-client-count' }, '本场景不调用旧入库级 picking/start 写接口,避免客户端汇总数量覆盖柜次任务事实', OPTIONS)
{ '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)
}
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL'] const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const evidenceItems = [] const evidenceItems = []
@@ -814,8 +783,15 @@ export async function run(context) {
&& evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`), && evidenceListData.evidences.some((item) => item.evidenceType === 'SEAL' && item.sealNo === `SEAL-${suffix}`),
`evidences=${evidenceListData?.evidences?.length ?? 'n/a'}`, OPTIONS) `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)}`, 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) }) { 'Idempotency-Key': `${context.marker}-p3-complete-before-downstream`.slice(0, 128) })
context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream, context.addStep(MODULE, '订舱拖车报关未齐备时 LOADED 硬门禁拒绝', blockedBeforeDownstream,
failure(blockedBeforeDownstream), failure(blockedBeforeDownstream),
@@ -824,7 +800,7 @@ export async function run(context) {
const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS) const blockedDetail = context.apiSuccess(MODULE, '回查 LOADED 硬门禁拒绝后的入库状态', blockedDetailResponse, '', OPTIONS)
context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse, context.assert(MODULE, 'LOADED 硬门禁拒绝后状态和版本零写入', blockedDetailResponse,
blockedDetail?.summary?.processStatus === 'LOADING' 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) `status=${blockedDetail?.summary?.processStatus || 'missing'}, version=${blockedDetail?.summary?.version ?? 'missing'}`, OPTIONS)
context.createdResources.phase3Loading = { context.createdResources.phase3Loading = {
@@ -835,7 +811,7 @@ export async function run(context) {
postLoadedFileId, postLoadedFileId,
loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId, loadingPlanId, containerBookingId, containerNo, warehouseId, canonicalPdaTaskId: canonicalTaskId,
pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion, pickingTaskId: canonicalTaskId, inboundVersion: loadingVersion,
atomicBatchVerified: directPdaCompletion && atomicBatchReplayVerified, atomicBatchVerified: awaitingReview && directPdaCompletion && atomicBatchReplayVerified,
inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified, inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified,
inventoryMutationVerified, inventoryMutationCleanup, inventoryMutationVerified, inventoryMutationCleanup,
marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED', marker: context.marker, inboundStatus: 'LOADING', loadingPlanStatus: 'COMPLETED', finalStatus: 'COMPLETED',
@@ -5,7 +5,7 @@ import test from 'node:test'
import { selectCustomsFlow } from './phase3-customs.mjs' import { selectCustomsFlow } from './phase3-customs.mjs'
import { buildFinalizeResource } from './phase3-loading-finalize.mjs' import { buildFinalizeResource } from './phase3-loading-finalize.mjs'
import { selectContainerLoadingFixture } from './phase3-container-loading.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) { function source(name) {
return readFileSync(new URL(name, import.meta.url), 'utf8') 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', 'const canonicalBatchReplay = await request',
]) ])
assert.match(loading, /inventorySnapshotToken: refreshedInventory\.snapshotToken/) 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, /inventorySnapshotVerified: inventorySnapshotVerified && directPdaCompletion && atomicBatchReplayVerified/)
assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/) assert.match(loading, /inventoryMutationVerified, inventoryMutationCleanup/)
assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/) assert.match(loading, /当前唯一平台代理库存变更入口只允许 A0 临时占用换绑/)
@@ -229,9 +234,10 @@ test('phase3 loading proves stale inventory zero-write for all items and complet
'旧 token 冲突对任务、明细、已拣数量和操作历史零写入', '旧 token 冲突对任务、明细、已拣数量和操作历史零写入',
'库存变化后刷新 PDA 实时库存快照', '库存变化后刷新 PDA 实时库存快照',
'原子提交柜次 PDA 整批拣货', '原子提交柜次 PDA 整批拣货',
'PDA 原子批次同参同键重放返回同一终态', 'PDA 原子批次同参同键重放保持待人工确认',
'人工确认柜次 PDA 装柜完成',
'回查原子 Batch 完成审计', '回查原子 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 () => { 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 run = new AcceptanceRun({ gateway: 'http://127.0.0.1', modules: [], username: '', password: '', output: '' })
const entry = source('../admin-test-env-acceptance.mjs') const entry = source('../admin-test-env-acceptance.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 须独立审核并执行/)
})