From da85e30f33a5155dbd7a5d4573fdfa514e2c04c3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 8 Aug 2026 21:34:39 +0800 Subject: [PATCH] test(firstleg): cover phase3 container loading flow --- deploy/sql/头程数据库统一增量-MySQL8.sql | 129 ++++++++- .../firstleg/service/SmartLoadingService.java | 11 +- .../db/phase3-smart-loading-schema.sql | 120 +++++++++ .../SmartLoadingSqlContractTest.java | 13 +- .../service/SmartLoadingServiceTest.java | 3 +- scripts/admin-test-env-acceptance.mjs | 52 +++- .../phase3-container-loading.mjs | 248 ++++++++++++++++++ .../service-center-flow.mjs | 83 +++++- 8 files changed, 644 insertions(+), 15 deletions(-) create mode 100644 scripts/admin-test-env-modules/phase3-container-loading.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index f266c61fb..ed3c3a695 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4893,6 +4893,92 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; +DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; +DELIMITER $$ +CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() +BEGIN + DECLARE item_fk_count INT DEFAULT 0; + DECLARE item_target_count INT DEFAULT 0; + DECLARE history_fk_count INT DEFAULT 0; + DECLARE history_target_count INT DEFAULT 0; + DECLARE item_drop_clauses TEXT; + DECLARE history_drop_clauses TEXT; + + IF EXISTS ( + SELECT 1 FROM fl_loading_pick_item item + LEFT JOIN fl_loading_pick_task task ON task.id = item.pick_task_id + WHERE task.id IS NULL + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '柜次PDA明细存在无法迁移到智能装柜任务的引用'; + END IF; + IF EXISTS ( + SELECT 1 FROM fl_loading_pick_history history + LEFT JOIN fl_loading_pick_task task ON task.id = history.pick_task_id + WHERE task.id IS NULL + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '柜次PDA历史存在无法迁移到智能装柜任务的引用'; + END IF; + + SELECT COUNT(*), + SUM(k.referenced_table_name = 'fl_loading_pick_task' + AND k.referenced_column_name = 'id' + AND (SELECT COUNT(*) FROM information_schema.key_column_usage peer + WHERE peer.constraint_schema = k.constraint_schema + AND peer.table_name = k.table_name + AND peer.constraint_name = k.constraint_name) = 1), + GROUP_CONCAT(CONCAT('DROP FOREIGN KEY `', REPLACE(k.constraint_name, '`', '``'), '`') + ORDER BY k.constraint_name SEPARATOR ', ') + INTO item_fk_count, item_target_count, item_drop_clauses + FROM information_schema.key_column_usage k + WHERE k.constraint_schema = 'baiyue_firstleg' + AND k.table_name = 'fl_loading_pick_item' + AND k.column_name = 'pick_task_id' + AND k.referenced_table_name IS NOT NULL; + IF item_fk_count <> 1 OR item_target_count <> 1 THEN + IF item_drop_clauses IS NOT NULL THEN + SET @phase3_fk_ddl = CONCAT('ALTER TABLE fl_loading_pick_item ', item_drop_clauses); + PREPARE phase3_fk_statement FROM @phase3_fk_ddl; + EXECUTE phase3_fk_statement; + DEALLOCATE PREPARE phase3_fk_statement; + END IF; + ALTER TABLE fl_loading_pick_item + ADD CONSTRAINT fk_loading_pick_item_task + FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id); + END IF; + + SELECT COUNT(*), + SUM(k.referenced_table_name = 'fl_loading_pick_task' + AND k.referenced_column_name = 'id' + AND (SELECT COUNT(*) FROM information_schema.key_column_usage peer + WHERE peer.constraint_schema = k.constraint_schema + AND peer.table_name = k.table_name + AND peer.constraint_name = k.constraint_name) = 1), + GROUP_CONCAT(CONCAT('DROP FOREIGN KEY `', REPLACE(k.constraint_name, '`', '``'), '`') + ORDER BY k.constraint_name SEPARATOR ', ') + INTO history_fk_count, history_target_count, history_drop_clauses + FROM information_schema.key_column_usage k + WHERE k.constraint_schema = 'baiyue_firstleg' + AND k.table_name = 'fl_loading_pick_history' + AND k.column_name = 'pick_task_id' + AND k.referenced_table_name IS NOT NULL; + IF history_fk_count <> 1 OR history_target_count <> 1 THEN + IF history_drop_clauses IS NOT NULL THEN + SET @phase3_fk_ddl = CONCAT('ALTER TABLE fl_loading_pick_history ', history_drop_clauses); + PREPARE phase3_fk_statement FROM @phase3_fk_ddl; + EXECUTE phase3_fk_statement; + DEALLOCATE PREPARE phase3_fk_statement; + END IF; + ALTER TABLE fl_loading_pick_history + ADD CONSTRAINT fk_loading_pick_history_task + FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id); + END IF; +END$$ +DELIMITER ; +CALL repair_phase3_loading_pick_task_foreign_keys(); +DROP PROCEDURE repair_phase3_loading_pick_task_foreign_keys; + DROP TRIGGER IF EXISTS trg_container_booking_before_update; DELIMITER $$ CREATE TRIGGER trg_container_booking_before_update @@ -5443,6 +5529,40 @@ BEGIN 'uk_loading_pick_history_operation')) <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失'; END IF; + IF (SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema = 'baiyue_firstleg' + AND table_name = 'fl_loading_pick_item' + AND column_name = 'pick_task_id' + AND referenced_table_name IS NOT NULL) <> 1 + OR (SELECT COUNT(*) FROM information_schema.key_column_usage outer_fk + WHERE outer_fk.constraint_schema = 'baiyue_firstleg' + AND outer_fk.table_name = 'fl_loading_pick_item' + AND outer_fk.column_name = 'pick_task_id' + AND outer_fk.referenced_table_name = 'fl_loading_pick_task' + AND outer_fk.referenced_column_name = 'id' + AND (SELECT COUNT(*) FROM information_schema.key_column_usage peer + WHERE peer.constraint_schema = outer_fk.constraint_schema + AND peer.table_name = outer_fk.table_name + AND peer.constraint_name = outer_fk.constraint_name) = 1) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:柜次PDA明细任务外键不唯一或指向错误'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema = 'baiyue_firstleg' + AND table_name = 'fl_loading_pick_history' + AND column_name = 'pick_task_id' + AND referenced_table_name IS NOT NULL) <> 1 + OR (SELECT COUNT(*) FROM information_schema.key_column_usage outer_fk + WHERE outer_fk.constraint_schema = 'baiyue_firstleg' + AND outer_fk.table_name = 'fl_loading_pick_history' + AND outer_fk.column_name = 'pick_task_id' + AND outer_fk.referenced_table_name = 'fl_loading_pick_task' + AND outer_fk.referenced_column_name = 'id' + AND (SELECT COUNT(*) FROM information_schema.key_column_usage peer + WHERE peer.constraint_schema = outer_fk.constraint_schema + AND peer.table_name = outer_fk.table_name + AND peer.constraint_name = outer_fk.constraint_name) = 1) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:柜次PDA历史任务外键不唯一或指向错误'; + END IF; IF (SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema = 'baiyue_firstleg' AND trigger_name IN ('trg_container_booking_before_update', @@ -6489,10 +6609,10 @@ ON DUPLICATE KEY UPDATE status = 'ACTIVE', built_in = 1, deleted = 0; INSERT INTO iam_role_permission (role_id, permission_id, tenant_id) -SELECT 4000001, id, 0 -FROM iam_permission -WHERE tenant_id = 0 AND app_code = 'PLATFORM' - AND permission_code IN ( +SELECT role.id, permission.id, role.tenant_id +FROM iam_role role +JOIN iam_permission permission ON permission.tenant_id = 0 AND permission.app_code = 'PLATFORM' + AND permission.permission_code IN ( 'firstleg:inbound:split', 'firstleg:inbound:preload', 'firstleg:inbound:preload-view', @@ -6503,6 +6623,7 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM' 'platform:firstleg:inbound:picking:edit', 'platform:firstleg:inbound:picking:view' ) +WHERE role.tenant_id = 0 AND role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR') ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id); INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 9a53a5342..332d0e3ab 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -314,7 +314,7 @@ public class SmartLoadingService { || !Objects.equals(history.getActionType(), action) || itemId != null && !Objects.equals(history.getLoadingPlanItemId(), itemId) || history.getAfterSnapshot() == null - || !history.getAfterSnapshot().contains("\"requestHash\":\"" + requestHash + "\"")) { + || !sameRequestHash(history.getAfterSnapshot(), requestHash)) { throw conflict("operationId 已用于不同请求"); } LoadingPlanEntity plan = repository.plan(planId); @@ -322,6 +322,15 @@ public class SmartLoadingService { return view(plan, requireBooking(plan.getContainerBookingId())); } + private boolean sameRequestHash(String snapshot, String requestHash) { + try { + var value = objectMapper.readTree(snapshot); + return value != null && requestHash.equals(value.path("requestHash").asText()); + } catch (JsonProcessingException exception) { + return false; + } + } + private void history(long planId, Long itemId, String action, String from, String to, String operation, long operatorId, String operatorName) { history(planId, itemId, action, from, to, operation, null, null, operatorId, operatorName); diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 553f42b4b..b6de06d60 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -461,6 +461,92 @@ CREATE TABLE IF NOT EXISTS fl_loading_pick_history ( CONSTRAINT fk_loading_pick_history_item FOREIGN KEY (pick_item_id) REFERENCES fl_loading_pick_item (id) ) ENGINE=InnoDB COMMENT='PDA拣货幂等和操作审计历史'; +DROP PROCEDURE IF EXISTS repair_phase3_loading_pick_task_foreign_keys; +DELIMITER $$ +CREATE PROCEDURE repair_phase3_loading_pick_task_foreign_keys() +BEGIN + DECLARE item_fk_count INT DEFAULT 0; + DECLARE item_target_count INT DEFAULT 0; + DECLARE history_fk_count INT DEFAULT 0; + DECLARE history_target_count INT DEFAULT 0; + DECLARE item_drop_clauses TEXT; + DECLARE history_drop_clauses TEXT; + + IF EXISTS ( + SELECT 1 FROM fl_loading_pick_item item + LEFT JOIN fl_loading_pick_task task ON task.id = item.pick_task_id + WHERE task.id IS NULL + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '柜次PDA明细存在无法迁移到智能装柜任务的引用'; + END IF; + IF EXISTS ( + SELECT 1 FROM fl_loading_pick_history history + LEFT JOIN fl_loading_pick_task task ON task.id = history.pick_task_id + WHERE task.id IS NULL + ) THEN + SIGNAL SQLSTATE '45000' + SET MESSAGE_TEXT = '柜次PDA历史存在无法迁移到智能装柜任务的引用'; + END IF; + + SELECT COUNT(*), + SUM(k.referenced_table_name = 'fl_loading_pick_task' + AND k.referenced_column_name = 'id' + AND (SELECT COUNT(*) FROM information_schema.key_column_usage peer + WHERE peer.constraint_schema = k.constraint_schema + AND peer.table_name = k.table_name + AND peer.constraint_name = k.constraint_name) = 1), + GROUP_CONCAT(CONCAT('DROP FOREIGN KEY `', REPLACE(k.constraint_name, '`', '``'), '`') + ORDER BY k.constraint_name SEPARATOR ', ') + INTO item_fk_count, item_target_count, item_drop_clauses + FROM information_schema.key_column_usage k + WHERE k.constraint_schema = 'baiyue_firstleg' + AND k.table_name = 'fl_loading_pick_item' + AND k.column_name = 'pick_task_id' + AND k.referenced_table_name IS NOT NULL; + IF item_fk_count <> 1 OR item_target_count <> 1 THEN + IF item_drop_clauses IS NOT NULL THEN + SET @phase3_fk_ddl = CONCAT('ALTER TABLE fl_loading_pick_item ', item_drop_clauses); + PREPARE phase3_fk_statement FROM @phase3_fk_ddl; + EXECUTE phase3_fk_statement; + DEALLOCATE PREPARE phase3_fk_statement; + END IF; + ALTER TABLE fl_loading_pick_item + ADD CONSTRAINT fk_loading_pick_item_task + FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id); + END IF; + + SELECT COUNT(*), + SUM(k.referenced_table_name = 'fl_loading_pick_task' + AND k.referenced_column_name = 'id' + AND (SELECT COUNT(*) FROM information_schema.key_column_usage peer + WHERE peer.constraint_schema = k.constraint_schema + AND peer.table_name = k.table_name + AND peer.constraint_name = k.constraint_name) = 1), + GROUP_CONCAT(CONCAT('DROP FOREIGN KEY `', REPLACE(k.constraint_name, '`', '``'), '`') + ORDER BY k.constraint_name SEPARATOR ', ') + INTO history_fk_count, history_target_count, history_drop_clauses + FROM information_schema.key_column_usage k + WHERE k.constraint_schema = 'baiyue_firstleg' + AND k.table_name = 'fl_loading_pick_history' + AND k.column_name = 'pick_task_id' + AND k.referenced_table_name IS NOT NULL; + IF history_fk_count <> 1 OR history_target_count <> 1 THEN + IF history_drop_clauses IS NOT NULL THEN + SET @phase3_fk_ddl = CONCAT('ALTER TABLE fl_loading_pick_history ', history_drop_clauses); + PREPARE phase3_fk_statement FROM @phase3_fk_ddl; + EXECUTE phase3_fk_statement; + DEALLOCATE PREPARE phase3_fk_statement; + END IF; + ALTER TABLE fl_loading_pick_history + ADD CONSTRAINT fk_loading_pick_history_task + FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id); + END IF; +END$$ +DELIMITER ; +CALL repair_phase3_loading_pick_task_foreign_keys(); +DROP PROCEDURE repair_phase3_loading_pick_task_foreign_keys; + DROP TRIGGER IF EXISTS trg_container_booking_before_update; DELIMITER $$ CREATE TRIGGER trg_container_booking_before_update @@ -1091,6 +1177,40 @@ BEGIN 'uk_loading_pick_history_operation')) <> 3 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:PDA唯一索引缺失'; END IF; + IF (SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema = 'baiyue_firstleg' + AND table_name = 'fl_loading_pick_item' + AND column_name = 'pick_task_id' + AND referenced_table_name IS NOT NULL) <> 1 + OR (SELECT COUNT(*) FROM information_schema.key_column_usage outer_fk + WHERE outer_fk.constraint_schema = 'baiyue_firstleg' + AND outer_fk.table_name = 'fl_loading_pick_item' + AND outer_fk.column_name = 'pick_task_id' + AND outer_fk.referenced_table_name = 'fl_loading_pick_task' + AND outer_fk.referenced_column_name = 'id' + AND (SELECT COUNT(*) FROM information_schema.key_column_usage peer + WHERE peer.constraint_schema = outer_fk.constraint_schema + AND peer.table_name = outer_fk.table_name + AND peer.constraint_name = outer_fk.constraint_name) = 1) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:柜次PDA明细任务外键不唯一或指向错误'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.key_column_usage + WHERE constraint_schema = 'baiyue_firstleg' + AND table_name = 'fl_loading_pick_history' + AND column_name = 'pick_task_id' + AND referenced_table_name IS NOT NULL) <> 1 + OR (SELECT COUNT(*) FROM information_schema.key_column_usage outer_fk + WHERE outer_fk.constraint_schema = 'baiyue_firstleg' + AND outer_fk.table_name = 'fl_loading_pick_history' + AND outer_fk.column_name = 'pick_task_id' + AND outer_fk.referenced_table_name = 'fl_loading_pick_task' + AND outer_fk.referenced_column_name = 'id' + AND (SELECT COUNT(*) FROM information_schema.key_column_usage peer + WHERE peer.constraint_schema = outer_fk.constraint_schema + AND peer.table_name = outer_fk.table_name + AND peer.constraint_name = outer_fk.constraint_name) = 1) <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '智能装柜迁移失败:柜次PDA历史任务外键不唯一或指向错误'; + END IF; IF (SELECT COUNT(*) FROM information_schema.triggers WHERE trigger_schema = 'baiyue_firstleg' AND trigger_name IN ('trg_container_booking_before_update', diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java index 1b8bbe08a..b73394989 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/SmartLoadingSqlContractTest.java @@ -144,6 +144,16 @@ class SmartLoadingSqlContractTest { .contains("UNIQUE KEY uk_loading_pick_history_operation (operation_id)") .contains("上层拆分按业务规则向上取整") .contains("CONSTRAINT chk_loading_pick_item_planned CHECK (planned_package_count > 0)") + .contains("CALL repair_phase3_loading_pick_task_foreign_keys()") + .contains("GROUP_CONCAT(CONCAT('DROP FOREIGN KEY `'") + .contains("item_fk_count <> 1 OR item_target_count <> 1") + .contains("history_fk_count <> 1 OR history_target_count <> 1") + .contains("FOREIGN KEY (pick_task_id) REFERENCES fl_loading_pick_task (id)") + .contains("referenced_column_name = 'id'") + .contains("柜次PDA明细任务外键不唯一或指向错误") + .contains("柜次PDA历史任务外键不唯一或指向错误") + .doesNotContain("DROP FOREIGN KEY fk_loading_pick_item_task") + .doesNotContain("DROP FOREIGN KEY fk_loading_pick_history_task") .contains("trg_loading_pick_task_before_update") .contains("trg_loading_pick_item_before_update") .contains("trg_loading_pick_item_before_delete") @@ -171,7 +181,8 @@ class SmartLoadingSqlContractTest { .contains("tmp_phase3_inbound_role_grant") .contains("DROP TEMPORARY TABLE IF EXISTS tmp_phase3_inbound_role_grant") .contains("5102121, 0, 'PLATFORM'") - .contains("5102129, 0, 'PLATFORM'"); + .contains("5102129, 0, 'PLATFORM'") + .contains("role.role_code IN ('PLATFORM_ADMIN', 'ORDER_OPERATOR')"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 27c0f7ee5..604885ee4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -311,7 +311,8 @@ class SmartLoadingServiceTest { value.setLoadingPlanId(planId); value.setLoadingPlanItemId(itemId); value.setActionType(action); - value.setAfterSnapshot("{\"requestHash\":\"" + hash + "\"}"); + // MySQL JSON columns normalize a space after the colon; replay must parse structurally. + value.setAfterSnapshot("{\"requestHash\": \"" + hash + "\"}"); return value; } diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index a48d67951..6f9df25ae 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ import process from 'node:process' const DEFAULT_GATEWAY = 'http://8.163.30.128:9000' const DEFAULT_USERNAME = 'platform_admin' const DEFAULT_PASSWORD = 'ChangeMe123!' -const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow'] +const DEFAULT_MODULES = ['customers', 'overseas-storage-fees', 'system', 'master-data', 'orders', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'phase3-container-loading', 'phase3-loading', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow'] const WEIGHT_IMAGE_PATH = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', @@ -435,6 +435,47 @@ class AcceptanceRun { } } + async preparePhase3ContainerFixture() { + const originalMarker = this.marker + const fixtureMarker = `${originalMarker}-CAB` + const createdKeys = ['orders', 'ocrInbound', 'testInbound', 'inboundMeasurement'] + const contextKeys = ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound'] + const createdSnapshot = new Map(createdKeys.map((key) => [key, { + present: Object.hasOwn(this.createdResources, key), value: this.createdResources[key], + }])) + const contextSnapshot = new Map(contextKeys.map((key) => [key, { + present: Object.hasOwn(this.context, key), value: this.context[key], + }])) + + this.marker = fixtureMarker + const orders = await import(new URL('./admin-test-env-modules/orders.mjs', import.meta.url)) + await this.executeModule('orders-phase3-container-fixture', () => orders.run(this)) + const measurement = await import(new URL('./admin-test-env-modules/inbound-measurement.mjs', import.meta.url)) + await this.executeModule('inbound-measurement-phase3-container-fixture', () => measurement.run(this)) + const fixture = this.createdResources.inboundMeasurement || this.createdResources.orders + this.createdResources.orchestrationFixtures = { + ...(this.createdResources.orchestrationFixtures || {}), + phase3Container: fixture ? { + marker: fixtureMarker, + tenantId: fixture.tenantId, + orderId: fixture.orderId || this.createdResources.orders?.orderId, + inboundId: fixture.inboundId, + } : { marker: fixtureMarker, failed: true }, + } + + return () => { + this.marker = originalMarker + for (const [key, snapshot] of createdSnapshot) { + if (snapshot.present) this.createdResources[key] = snapshot.value + else delete this.createdResources[key] + } + for (const [key, snapshot] of contextSnapshot) { + if (snapshot.present) this.context[key] = snapshot.value + else delete this.context[key] + } + } + } + async run() { const loginResponse = await this.request('POST', '/api/auth/login', { username: this.options.username, password: this.options.password }, '') const login = this.apiSuccess('authentication', '平台管理员登录', loginResponse) @@ -455,6 +496,15 @@ class AcceptanceRun { { scenario: 'inbound-resource-isolation' }) } } + if (module === 'phase3-container-loading') { + try { + restoreFixture = await this.preparePhase3ContainerFixture() + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + this.addStep('phase3-container-fixture', '创建独立柜次级入库单', null, false, reason, + { scenario: 'phase3-container-resource-isolation' }) + } + } let handler = handlers[module] if (!handler) { try { diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs new file mode 100644 index 000000000..f96601f25 --- /dev/null +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -0,0 +1,248 @@ +const MODULE = 'phase3-container-loading' +const OPTIONS = { scenario: 'phase3-container-smart-loading-pda', deploymentBlocker: true } +const CONTAINERS = '/api/platform/v1/containers' +const SMART = '/api/platform/v1/first-leg/smart-loading' +const PDA = '/api/platform/v1/first-leg/pda-loading' +const FIRST_LEG = '/api/platform/v1/first-leg' + +function id(value) { return value == null ? '' : String(value) } +function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +function failure(response) { + return [400, 401, 403, 404, 409, 503].includes(response?.http) + || (response?.http === 200 && codeOf(response) !== '200') +} +function records(page) { return Array.isArray(page?.records) ? page.records : [] } + +async function request(context, method, path, body, headers = {}) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method, + headers: { + Accept: 'application/json', Authorization: `Bearer ${context.token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method, path, http: response.status, body: payload, + raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } + } +} + +function fixture(context) { + return context.createdResources?.orchestrationFixtures?.phase3Container + || context.createdResources?.inboundMeasurement + || context.createdResources?.orders +} + +export async function run(context) { + const source = fixture(context) + const tenantId = id(source?.tenantId) + const inboundId = id(source?.inboundId) + if (!tenantId || !inboundId || source?.marker !== context.marker) { + context.addStep(MODULE, '取得独立柜次级 INBOUNDED fixture', null, false, + `fixture marker=${source?.marker || 'missing'}, current marker=${context.marker}`, OPTIONS) + return + } + + const inboundResponse = await context.request('GET', + `/api/platform/inbounds/${inboundId}?tenantId=${encodeURIComponent(tenantId)}`) + const inbound = context.apiSuccess(MODULE, '读取柜次级独立入库单', inboundResponse, + `tenantId=${tenantId}, inboundId=${inboundId}`, OPTIONS) + const warehouseId = id(inbound?.summary?.warehouseId) + context.assert(MODULE, '柜次候选入库单状态、测量和仓库门禁有效', inboundResponse, + inbound?.summary?.processStatus === 'INBOUNDED' && inbound?.summary?.measureReviewStatus === 'APPROVED' + && Boolean(warehouseId), + `status=${inbound?.summary?.processStatus || 'missing'}, review=${inbound?.summary?.measureReviewStatus || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS) + if (!warehouseId) return + + const candidateResponse = await context.request('GET', `${SMART}/candidates?warehouseId=${encodeURIComponent(warehouseId)}`) + const candidates = context.apiSuccess(MODULE, '查询智能装柜真实候选货物', candidateResponse, '', OPTIONS) + const candidate = Array.isArray(candidates) + ? candidates.find((item) => id(item.inboundId) === inboundId && Number(item.availablePackageCount) > 0) + : null + context.assert(MODULE, '候选货物归属本轮入库单且剩余箱数有效', candidateResponse, + Boolean(candidate?.inboundDetailId) && id(candidate?.warehouseId) === warehouseId + && id(candidate?.accountId) === tenantId, + `candidateFound=${Boolean(candidate)}, available=${candidate?.availablePackageCount ?? 'missing'}`, OPTIONS) + if (!candidate?.inboundDetailId) return + + const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) + const containerNo = `P3C-${suffix}`.slice(0, 64) + const cabinetNo = `P3I-${suffix}`.slice(0, 64) + const containerPayload = { + containerNo, cabinetNo, warehouseId, warehouseName: inbound?.summary?.warehouseName || null, + billOfLadingNo: `BL-${suffix}`, vesselName: `P3-VESSEL-${suffix}`.slice(0, 128), voyageNo: `V-${suffix}`.slice(0, 64), + containerType: '40HQ', shippingCompanyId: null, shippingCompanyName: `P3-SHIPPING-${suffix}`.slice(0, 128), + departurePortCode: 'CNSZX', departurePortName: '深圳', destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', + destinationWarehouseCode: candidate.destinationWarehouseCode || null, + destinationWarehouseName: candidate.destinationWarehouseName || null, + capacityCbm: 100, maxWeightKg: 100000, terminalTruckingCompany: `P3-TRUCK-${suffix}`.slice(0, 128), + loadingAt: '2026-09-02T08:00:00', estimatedSailingAt: '2026-09-03T08:00:00', + estimatedPortArrivalAt: '2026-09-20T08:00:00', estimatedWarehouseArrivalAt: '2026-09-21T08:00:00', + } + const createContainerResponse = await request(context, 'POST', CONTAINERS, containerPayload) + const container = context.apiSuccess(MODULE, '创建 marker 隔离货柜', createContainerResponse, + `containerNo=${containerNo}`, OPTIONS) + const containerId = id(container?.id) + context.assert(MODULE, '货柜归属仓库、路线、版本和编号正确', createContainerResponse, + Boolean(containerId) && container?.containerNo === containerNo && container?.cabinetNo === cabinetNo + && id(container?.warehouseId) === warehouseId && Number(container?.version) === 0, + `containerId=${containerId || 'missing'}, version=${container?.version ?? 'missing'}`, OPTIONS) + if (!containerId) return + + const containerDetailResponse = await context.request('GET', `${CONTAINERS}/${containerId}`) + const containerDetail = context.apiSuccess(MODULE, '回查货柜详情', containerDetailResponse, '', OPTIONS) + context.assert(MODULE, '货柜详情保留候选货物路线约束', containerDetailResponse, + containerDetail?.destinationWarehouseCode === (candidate.destinationWarehouseCode || null) + && containerDetail?.planStatus === 'EDITING', + `planStatus=${containerDetail?.planStatus || 'missing'}`, OPTIONS) + + const planResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: context.marker }) + const plan = context.apiSuccess(MODULE, '创建智能装柜计划', planResponse, '', OPTIONS) + const planId = id(plan?.id) + context.assert(MODULE, '装柜计划与货柜关联且处于 EDITING', planResponse, + Boolean(planId) && id(plan?.bookingId) === containerId && plan?.status === 'EDITING' + && Number(plan?.version) === 0 && Array.isArray(plan?.items) && plan.items.length === 0, + `planId=${planId || 'missing'}, status=${plan?.status || 'missing'}`, OPTIONS) + if (!planId) return + + const planReplayResponse = await request(context, 'POST', `${SMART}/plans`, { bookingId: containerId, remark: 'replay' }) + const planReplay = context.apiSuccess(MODULE, '重放货柜装柜计划创建', planReplayResponse, '', OPTIONS) + context.assert(MODULE, '同一货柜重放返回同一计划', planReplayResponse, + id(planReplay?.id) === planId, `first=${planId}, replay=${id(planReplay?.id) || 'missing'}`, OPTIONS) + + const assignPayload = { + inboundId, inboundDetailId: id(candidate.inboundDetailId), packageCount: 1, + expectedVersion: Number(plan.version), operationId: `${context.marker}-container-assign`.slice(0, 128), + } + const assignResponse = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, assignPayload) + const assigned = context.apiSuccess(MODULE, '人工分配本轮候选货物', assignResponse, '', OPTIONS) + const planItem = assigned?.items?.find?.((item) => id(item.inboundId) === inboundId) + context.assert(MODULE, '分配明细归属、箱数和计划版本正确', assignResponse, + Boolean(planItem?.id) && Number(planItem?.packageCount) === 1 && Number(assigned?.version) === 1, + `itemId=${id(planItem?.id) || 'missing'}, version=${assigned?.version ?? 'missing'}`, OPTIONS) + if (!planItem?.id) return + + const assignReplayResponse = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, assignPayload) + const assignReplay = context.apiSuccess(MODULE, '分配 operationId 幂等重放', assignReplayResponse, '', OPTIONS) + context.assert(MODULE, '重放不重复增加装柜箱数', assignReplayResponse, + assignReplay?.items?.length === 1 && Number(assignReplay.items[0]?.packageCount) === 1 + && Number(assignReplay?.version) === Number(assigned?.version), + `items=${assignReplay?.items?.length ?? 'n/a'}, version=${assignReplay?.version ?? 'missing'}`, OPTIONS) + + const staleAssignResponse = await request(context, 'POST', `${SMART}/plans/${planId}/assignments`, { + ...assignPayload, expectedVersion: 0, operationId: `${context.marker}-container-stale`.slice(0, 128), + }) + context.addStep(MODULE, '旧计划版本分配返回冲突', staleAssignResponse, + staleAssignResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleAssignResponse)), + `HTTP=${staleAssignResponse.http}, code=${codeOf(staleAssignResponse)}`, OPTIONS) + + const pushKey = `${context.marker}-container-push`.slice(0, 128) + const pushResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, + { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey }) + const pushed = context.apiSuccess(MODULE, '推送预装并创建 PDA 任务', pushResponse, '', OPTIONS) + context.assert(MODULE, '推送后计划进入 LOADING 且版本递增', pushResponse, + pushed?.status === 'LOADING' && Number(pushed?.version) === Number(assigned.version) + 1, + `status=${pushed?.status || 'missing'}, version=${pushed?.version ?? 'missing'}`, OPTIONS) + if (pushed?.status !== 'LOADING') return + + const pushReplayResponse = await request(context, 'POST', `${SMART}/plans/${planId}/push-preload`, + { expectedVersion: Number(assigned.version) }, { 'Idempotency-Key': pushKey }) + const pushReplay = context.apiSuccess(MODULE, '推送预装 Idempotency-Key 重放', pushReplayResponse, '', OPTIONS) + context.assert(MODULE, '推送重放保持同一 LOADING 计划', pushReplayResponse, + id(pushReplay?.id) === planId && pushReplay?.status === 'LOADING' + && Number(pushReplay?.version) === Number(pushed.version), + `planId=${id(pushReplay?.id) || 'missing'}, version=${pushReplay?.version ?? 'missing'}`, OPTIONS) + + const preloadPageResponse = await context.request('GET', + `${FIRST_LEG}/loading-plans?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`) + const preloadPage = context.apiSuccess(MODULE, '查询本轮预装清单', preloadPageResponse, '', OPTIONS) + context.assert(MODULE, '预装清单包含本轮 LOADING 计划', preloadPageResponse, + records(preloadPage).some((item) => id(item.planId) === planId && item.status === 'LOADING'), + `records=${records(preloadPage).length}`, OPTIONS) + + const taskResponse = await context.request('GET', + `${PDA}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=${encodeURIComponent(warehouseId)}`) + const task = context.apiSuccess(MODULE, '查询本轮柜次 PDA 拣货任务', taskResponse, '', OPTIONS) + const taskId = id(task?.id) + const pickItem = task?.items?.find?.((item) => id(item.loadingPlanItemId) === id(planItem.id)) + context.assert(MODULE, 'PDA 任务、计划明细和待拣箱数关联正确', taskResponse, + Boolean(taskId) && id(task?.loadingPlanId) === planId && task?.taskStatus === 'LOADING' + && Boolean(pickItem?.id) && Number(pickItem?.plannedPackageCount) === 1, + `taskId=${taskId || 'missing'}, itemId=${id(pickItem?.id) || 'missing'}`, OPTIONS) + if (!taskId || !pickItem?.id) return + + const wrongWarehouseResponse = await context.request('GET', + `${PDA}/tasks?containerNo=${encodeURIComponent(containerNo)}&warehouseId=1`) + context.addStep(MODULE, 'PDA 任务仓库隔离拒绝', wrongWarehouseResponse, + failure(wrongWarehouseResponse), `HTTP=${wrongWarehouseResponse.http}, code=${codeOf(wrongWarehouseResponse)}`, OPTIONS) + + const stalePickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, { + warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version) + 1, + operationId: `${context.marker}-pda-stale`.slice(0, 128), + }) + context.addStep(MODULE, 'PDA 旧明细版本拣货返回冲突', stalePickResponse, + stalePickResponse.http === 200 && ['A0409', '409'].includes(codeOf(stalePickResponse)), + `HTTP=${stalePickResponse.http}, code=${codeOf(stalePickResponse)}`, OPTIONS) + + const pickPayload = { + warehouseId, pickedPackageCount: 1, locationIds: [], version: Number(pickItem.version), + operationId: `${context.marker}-pda-pick`.slice(0, 128), + } + const pickResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) + const picked = context.apiSuccess(MODULE, '执行本轮 PDA 全量拣货', pickResponse, '', OPTIONS) + context.assert(MODULE, '最后一箱拣货后 PDA 任务进入 COMPLETED', pickResponse, + picked?.taskStatus === 'COMPLETED' + && picked?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), + `taskStatus=${picked?.taskStatus || 'missing'}`, OPTIONS) + + const pickReplayResponse = await request(context, 'POST', `${PDA}/tasks/${taskId}/items/${id(pickItem.id)}/pick`, pickPayload) + const pickReplay = context.apiSuccess(MODULE, 'PDA operationId 幂等重放', pickReplayResponse, '', OPTIONS) + context.assert(MODULE, 'PDA 重放保持已完成任务和拣货数', pickReplayResponse, + pickReplay?.taskStatus === 'COMPLETED' + && pickReplay?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)), + `taskStatus=${pickReplay?.taskStatus || 'missing'}`, OPTIONS) + + const finalPlanResponse = await context.request('GET', `${SMART}/plans/${planId}`) + const finalPlan = context.apiSuccess(MODULE, '回查柜次装柜计划终态', finalPlanResponse, '', OPTIONS) + context.assert(MODULE, '装柜计划进入 COMPLETED 且明细已冻结', finalPlanResponse, + finalPlan?.status === 'COMPLETED' && finalPlan?.items?.length === 1 && Boolean(finalPlan.items[0]?.frozenAt), + `status=${finalPlan?.status || 'missing'}, frozen=${Boolean(finalPlan?.items?.[0]?.frozenAt)}`, OPTIONS) + + const confirmedPageResponse = await context.request('GET', + `${FIRST_LEG}/loading-lists?warehouseId=${encodeURIComponent(warehouseId)}&keyword=${encodeURIComponent(containerNo)}&page=1&size=20`) + const confirmedPage = context.apiSuccess(MODULE, '查询完成装柜清单', confirmedPageResponse, '', OPTIONS) + context.assert(MODULE, '完成清单分页包含本轮计划', confirmedPageResponse, + records(confirmedPage).some((item) => id(item.planId) === planId && item.status === 'COMPLETED'), + `records=${records(confirmedPage).length}`, OPTIONS) + + const confirmedDetailResponse = await context.request('GET', + `${FIRST_LEG}/loading-lists/${planId}?warehouseId=${encodeURIComponent(warehouseId)}`) + const confirmedDetail = context.apiSuccess(MODULE, '查询完成装柜清单详情', confirmedDetailResponse, '', OPTIONS) + context.assert(MODULE, '装柜清单明细、客户归属、冻结和 PDA 历史完整', confirmedDetailResponse, + id(confirmedDetail?.summary?.planId) === planId && confirmedDetail?.summary?.status === 'COMPLETED' + && confirmedDetail?.items?.some?.((item) => id(item.inboundId) === inboundId + && id(item.accountId) === tenantId && Boolean(item.frozenAt)) + && confirmedDetail?.histories?.some?.((item) => item.actionType === 'PDA_COMPLETE_LOADING' + && Boolean(item.operatorName) && Boolean(item.occurredAt)), + `items=${confirmedDetail?.items?.length ?? 'n/a'}, histories=${confirmedDetail?.histories?.length ?? 'n/a'}`, OPTIONS) + + const immutableResponse = await request(context, 'PATCH', + `${FIRST_LEG}/loading-plans/${planId}/transport?warehouseId=${encodeURIComponent(warehouseId)}`, + { containerNo: `MUTATE-${suffix}`.slice(0, 64), voyageNo: 'MUTATE', expectedVersion: Number(finalPlan?.version), operationReason: context.marker }, + { 'Idempotency-Key': `${context.marker}-completed-mutate`.slice(0, 128) }) + context.addStep(MODULE, '完成装柜后运输信息不可变', immutableResponse, + immutableResponse.http === 200 && ['A0409', '409'].includes(codeOf(immutableResponse)), + `HTTP=${immutableResponse.http}, code=${codeOf(immutableResponse)}`, OPTIONS) + + context.createdResources.phase3ContainerLoading = { + marker: context.marker, tenantId, inboundId, warehouseId, containerBookingId: containerId, + loadingPlanId: planId, pdaTaskId: taskId, finalStatus: 'COMPLETED', + } +} diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index 6dbc877a1..c982eed6e 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -5,9 +5,31 @@ const TRUCK = '/api/platform/v1/truck-appointments' function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +async function request(context, method, path, body, headers = {}) { + try { + const response = await fetch(`${context.options.gateway}${path}`, { + method, + headers: { + Accept: 'application/json', Authorization: `Bearer ${context.token}`, + ...(body === undefined ? {} : { 'Content-Type': 'application/json; charset=utf-8' }), ...headers, + }, + body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(60_000), + }) + const raw = await response.text(); let payload = null + try { payload = raw ? JSON.parse(raw) : null } catch { /* bounded raw evidence */ } + return { method, path, http: response.status, body: payload, + raw: payload === null ? raw.slice(0, 500) : null, + requestId: payload?.requestId || response.headers.get('x-request-id'), + traceId: payload?.traceId || response.headers.get('x-trace-id') } + } catch (error) { + return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null } + } +} export async function run(context) { const ocean = context.createdResources?.oceanBooking + const containerFlow = context.createdResources?.phase3ContainerLoading + const containerFixture = context.createdResources?.orchestrationFixtures?.phase3Container if (!ocean?.id || ocean.finalStatus !== 'RELEASED') { context.addStep(MODULE, '跨服务链路前置订舱终态可用', null, false, '本轮海运订舱模块未返回 RELEASED 隔离记录,未伪造关联拖车预约', OPTIONS) @@ -20,9 +42,39 @@ export async function run(context) { const customerId = id(oceanDetail?.oceanBooking?.customerId) context.assert(MODULE, '跨服务链路订舱客户归属有效', oceanResponse, Boolean(customerId), `customerId=${customerId || 'missing'}`, OPTIONS) if (!customerId) return + const linkContainer = containerFlow?.finalStatus === 'COMPLETED' + && id(containerFlow?.tenantId) === customerId + && Boolean(containerFlow?.containerBookingId) + && Boolean(containerFlow?.loadingPlanId) + let oceanVersion = Number(oceanDetail?.oceanBooking?.version) + if (containerFlow && !linkContainer) { + context.addStep(MODULE, '本轮柜次与订舱客户归属可串联', oceanResponse, false, + `oceanCustomerId=${customerId}, containerTenantId=${id(containerFlow?.tenantId) || 'missing'}, containerStatus=${containerFlow?.finalStatus || 'missing'}`, OPTIONS) + return + } + if (linkContainer) { + const requirement = oceanDetail?.containerRequirements?.find?.((item) => item?.containerType === '40HQ') + context.assert(MODULE, '订舱存在匹配本轮柜型的箱型需求', oceanResponse, + Boolean(requirement?.id), `requirementId=${id(requirement?.id) || 'missing'}`, OPTIONS) + if (!requirement?.id || !Number.isInteger(oceanVersion)) return + const allocationResponse = await request(context, 'POST', `${OCEAN}/${ocean.id}/allocations`, { + requirementId: id(requirement.id), containerBookingId: id(containerFlow.containerBookingId), expectedVersion: oceanVersion, + }, { 'Idempotency-Key': `${context.marker}-flow-container-allocation`.slice(0, 128) }) + const allocated = context.apiSuccess(MODULE, '将本轮柜次分配至已放舱订舱', allocationResponse, '', OPTIONS) + oceanVersion = Number(allocated?.oceanBooking?.version) + context.assert(MODULE, '订舱分配保留柜次关联并递增版本', allocationResponse, + allocated?.containerAllocations?.some?.((item) => id(item?.containerBookingId) === id(containerFlow.containerBookingId)) + && Number.isInteger(oceanVersion), + `containerBookingId=${id(containerFlow.containerBookingId)}, version=${oceanVersion}`, OPTIONS) + } const payload = { - outboundOrderId: null, oceanBookingId: ocean.id, containerBookingId: null, loadingPlanId: null, - warehouseId: id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), + outboundOrderId: linkContainer ? id(containerFixture?.orderId) || null : null, + oceanBookingId: ocean.id, + containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null, + loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null, + warehouseId: linkContainer + ? id(containerFlow.warehouseId) + : id(context.createdResources?.warehouse?.id || context.context?.warehouseId || '193000000000000001'), customerId, customerName: `服务中心链路客户-${suffix}`, carrierId: null, carrierName: `链路承运商-${suffix}`, fleetContact: '链路车队联系人', fleetPhone: '13800000007', driverName: '链路司机', driverPhone: '13800000008', driverIdCard: null, vehicleNo: `粤D-${suffix.slice(-6)}`, storeName: `链路门店-${suffix}`, storeContact: '链路联系人', @@ -31,23 +83,40 @@ export async function run(context) { plannedLoadingAt: '2026-09-03T08:00:00', transitMinutes: 60, payableAmount: 1, receivableAmount: 2, currency: 'RMB', remark: context.marker, expectedVersion: 0, } + if (linkContainer) { + const missingContainerResponse = await context.request('POST', TRUCK, { + ...payload, containerBookingId: null, remark: `${context.marker}-missing-container`, + }) + context.addStep(MODULE, '拖车关联装柜计划时缺失柜次被拒绝', missingContainerResponse, + missingContainerResponse.http === 200 && codeOf(missingContainerResponse) !== '200', + `HTTP=${missingContainerResponse.http}, code=${codeOf(missingContainerResponse)}`, OPTIONS) + } const response = await context.request('POST', TRUCK, payload) const appointment = context.apiSuccess(MODULE, '创建关联海运订舱的拖车预约', response, `oceanBookingId=${ocean.id}`, OPTIONS) const appointmentId = id(appointment?.id) - context.assert(MODULE, '拖车预约关联订舱 ID、版本和审计字段完整', response, + context.assert(MODULE, '拖车预约关联订舱、柜次、装柜计划、版本和审计字段完整', response, Boolean(appointmentId) && id(appointment?.oceanBookingId) === id(ocean.id) && Number.isFinite(Number(appointment?.version)) + && (!linkContainer || id(appointment?.containerBookingId) === id(containerFlow.containerBookingId) + && id(appointment?.loadingPlanId) === id(containerFlow.loadingPlanId)) && Boolean(appointment?.createdAt || appointment?.createdBy || appointment?.operatorName), - `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) + `appointmentId=${appointmentId || 'missing'}, oceanBookingId=${appointment?.oceanBookingId || 'missing'}, containerBookingId=${appointment?.containerBookingId || 'n/a'}, loadingPlanId=${appointment?.loadingPlanId || 'n/a'}, version=${appointment?.version ?? 'missing'}`, OPTIONS) if (!appointmentId) return - context.createdResources.serviceCenterFlow = { appointmentId, oceanBookingId: ocean.id, marker: context.marker, finalStatus: 'CANCELLED' } + context.createdResources.serviceCenterFlow = { + appointmentId, oceanBookingId: ocean.id, + containerBookingId: linkContainer ? id(containerFlow.containerBookingId) : null, + loadingPlanId: linkContainer ? id(containerFlow.loadingPlanId) : null, + marker: context.marker, finalStatus: 'CANCELLED', + } const action = await context.request('POST', `${TRUCK}/${appointmentId}/cancel`, { expectedVersion: Number(appointment.version), reason: context.marker }) const cancelled = context.apiSuccess(MODULE, '取消跨服务链路拖车预约', action, '', OPTIONS) context.assert(MODULE, '跨服务链路安全终态为 CANCELLED', action, cancelled?.status === 'CANCELLED', `status=${cancelled?.status || 'missing'}`, OPTIONS) const detail = await context.request('GET', `${TRUCK}/${appointmentId}`) const final = context.apiSuccess(MODULE, '回查跨服务链路安全终态', detail, '', OPTIONS) context.assert(MODULE, '跨服务链路终态持久化且关联订舱未漂移', detail, - final?.status === 'CANCELLED' && id(final?.oceanBookingId) === id(ocean.id), - `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}`, OPTIONS) + final?.status === 'CANCELLED' && id(final?.oceanBookingId) === id(ocean.id) + && (!linkContainer || id(final?.containerBookingId) === id(containerFlow.containerBookingId) + && id(final?.loadingPlanId) === id(containerFlow.loadingPlanId)), + `status=${final?.status || 'missing'}, oceanBookingId=${final?.oceanBookingId || 'missing'}, containerBookingId=${final?.containerBookingId || 'n/a'}, loadingPlanId=${final?.loadingPlanId || 'n/a'}`, OPTIONS) const historyResponse = await context.request('GET', `${TRUCK}/${appointmentId}/history`) const history = context.apiSuccess(MODULE, '查询跨服务链路拖车预约状态历史', historyResponse, '', OPTIONS)