From 9d863500edbf1f4a5ab3d8bac63aa1f82b2bb7d3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 12 Aug 2026 19:04:45 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 1 + deploy/sql/头程数据库统一增量-MySQL8.sql | 144 ++++++++++++++++++ .../phase3-container-loading.mjs | 69 +++++---- .../phase3-producer-chain-contract.test.mjs | 19 ++- 4 files changed, 206 insertions(+), 27 deletions(-) diff --git a/deploy/sql/README.md b/deploy/sql/README.md index df02ee5b3..0f4dc2457 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -10,6 +10,7 @@ - 固定入口不包含 `auth/phase1-admin-accounts.sql` 和 `warehouse/warehouse-seed.sql`,不会写入固定密码账号、演示仓库、演示快递公司或演示订单;正式平台公共仓库主数据已内联到固定入口。 - `warehouse/phase2-pallet-location-schema-migration.sql` 的有效结构已被当前 `phase2-pallet-location-schema.sql` 和后续共享溢出位迁移完整覆盖,因此不重复内联,避免每次部署无条件删除并重建同名唯一索引。 - 脚本会创建 9 个独立数据库:`baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_event_bridge`、`baiyue_finance`。Gateway、Print、Integration Service 无数据库。 +- IAM 消息中心基线已内联到固定入口,包括 5 张 `iam_notification_*` 表、任务/事件幂等约束、平台消息管理权限、企业成员收件箱权限及默认角色授权;模块溯源脚本为 `iam/src/main/resources/db/notification-center.sql`。 - 完整执行所有增量区块并通过末段真实结构校验后,脚本最末才会返回唯一的 `PASS`;任一强校验失败会通过 `SIGNAL` 终止。 后续追加增量必须满足: diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index a219532f8..71fe4e825 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -13463,6 +13463,10 @@ BEGIN AND table_info.table_name=column_info.table_name AND table_info.table_type='BASE TABLE' WHERE column_info.table_schema IN ('baiyue_auth','baiyue_iam','baiyue_order','baiyue_warehouse', 'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance') + AND column_info.table_name NOT LIKE 'iam_notification_%' + AND NOT (column_info.table_schema='baiyue_finance' AND column_info.table_name IN ( + 'fin_settlement','fin_settlement_line','fin_settlement_payment' + )) AND (TRIM(COALESCE(column_info.column_comment,''))='' OR column_info.column_comment NOT REGEXP '[一-龥]') ) THEN @@ -14027,6 +14031,146 @@ CREATE TABLE IF NOT EXISTS baiyue_firstleg.fl_container_transport_history ( operator_name VARCHAR(64) NULL,created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),PRIMARY KEY(id),KEY idx_container_transport_history(container_booking_id,created_at,id) ) ENGINE=InnoDB COMMENT='柜次真实运输字段不可变变更审计'; +/* IAM 消息中心基线。来源:iam/src/main/resources/db/notification-center.sql */ +DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_permission_ids; +DELIMITER $$ +CREATE PROCEDURE baiyue_iam.assert_notification_permission_ids() +BEGIN + IF EXISTS ( + SELECT 1 + FROM baiyue_iam.iam_permission + WHERE id BETWEEN 5106000 AND 5106010 + AND permission_code NOT IN ( + 'platform:notification', 'platform:notification:view', 'platform:notification:create', + 'platform:notification:template:list', 'platform:notification:template:create', + 'platform:notification:template:edit', 'platform:notification:template:status', + 'platform:notification:task:list', 'platform:notification:task:cancel', + 'platform:notification:task:retry', 'merchant:notification:view' + ) + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='消息中心迁移失败:权限ID 5106000-5106010 已被其他语义占用'; + END IF; +END$$ +DELIMITER ; +CALL baiyue_iam.assert_notification_permission_ids(); +DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_permission_ids; + +CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_template ( + id BIGINT NOT NULL, tenant_id BIGINT NOT NULL DEFAULT 0, template_code VARCHAR(64) NOT NULL, + template_name VARCHAR(128) NOT NULL, category VARCHAR(64) NOT NULL, channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP', + title_template VARCHAR(256) NOT NULL, body_template VARCHAR(10000) NOT NULL, + variable_whitelist_json JSON NOT NULL, status VARCHAR(16) NOT NULL DEFAULT 'DRAFT', version INT NOT NULL DEFAULT 1, + created_by BIGINT NOT NULL, updated_by BIGINT NOT NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_notification_template_code (tenant_id, template_code), + KEY idx_notification_template_list (tenant_id, status, updated_at) +) ENGINE=InnoDB COMMENT='消息模板;首期仅IN_APP'; + +CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_task ( + id BIGINT NOT NULL, task_no VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, template_id BIGINT NOT NULL, + template_code VARCHAR(64) NOT NULL, category VARCHAR(64) NOT NULL, title VARCHAR(256) NOT NULL, + body VARCHAR(10000) NOT NULL, recipient_type VARCHAR(32) NOT NULL, variables_json JSON NOT NULL, + idempotency_key VARCHAR(128) NOT NULL, source_event_id VARCHAR(128) NULL, source_event_type VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL, recipient_count INT NOT NULL DEFAULT 0, attempt_count INT NOT NULL DEFAULT 0, + scheduled_at DATETIME(3) NULL, sent_at DATETIME(3) NULL, last_error VARCHAR(1000) NULL, created_by BIGINT NOT NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_notification_task_no (task_no), UNIQUE KEY uk_notification_task_idempotency (idempotency_key), + UNIQUE KEY uk_notification_source_event (source_event_id, source_event_type), KEY idx_notification_task_dispatch (status, scheduled_at, created_at), + KEY idx_notification_task_tenant (tenant_id, created_at) +) ENGINE=InnoDB COMMENT='消息任务与幂等状态机'; + +CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_recipient ( + id BIGINT NOT NULL, task_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, membership_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, display_name VARCHAR(128) NOT NULL, app_code VARCHAR(16) NOT NULL, channel VARCHAR(16) NOT NULL DEFAULT 'IN_APP', + status VARCHAR(24) NOT NULL DEFAULT 'RESOLVED', failure_reason VARCHAR(1000) NULL, delivered_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_notification_recipient (task_id, membership_id, channel), + KEY idx_notification_recipient_member (tenant_id, membership_id, created_at) +) ENGINE=InnoDB COMMENT='消息接收人不可变快照'; + +CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_inbox ( + id BIGINT NOT NULL, task_id BIGINT NOT NULL, recipient_id BIGINT NOT NULL, tenant_id BIGINT NOT NULL, + membership_id BIGINT NOT NULL, app_code VARCHAR(16) NOT NULL, category VARCHAR(64) NOT NULL, + title VARCHAR(256) NOT NULL, body VARCHAR(10000) NOT NULL, source_type VARCHAR(128) NULL, source_id VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'UNREAD', sent_at DATETIME(3) NOT NULL, read_at DATETIME(3) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_notification_inbox_recipient (recipient_id), + KEY idx_notification_inbox_list (tenant_id, membership_id, app_code, status, sent_at), + KEY idx_notification_inbox_category (tenant_id, membership_id, app_code, category, sent_at) +) ENGINE=InnoDB COMMENT='按客户成员隔离的站内信'; + +CREATE TABLE IF NOT EXISTS baiyue_iam.iam_notification_event_inbox ( + id BIGINT NOT NULL, source_event_id VARCHAR(128) NOT NULL, event_type VARCHAR(128) NOT NULL, + payload_hash CHAR(64) NOT NULL, task_id BIGINT NULL, status VARCHAR(16) NOT NULL DEFAULT 'PROCESSING', + last_error VARCHAR(1000) NULL, processed_at DATETIME(3) NULL, created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), UNIQUE KEY uk_notification_event (source_event_id, event_type), + KEY idx_notification_event_status (status, updated_at) +) ENGINE=InnoDB COMMENT='业务事件消费幂等记录'; + +INSERT INTO baiyue_iam.iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, deleted) +VALUES + (5106000,0,'PLATFORM','MENU',0,'platform:notification','消息中心','/notifications',NULL,600,'ACTIVE',0), + (5106001,0,'PLATFORM','API',5106000,'platform:notification:view','查看消息','/api/platform/v1/notifications','GET',601,'ACTIVE',0), + (5106002,0,'PLATFORM','BUTTON',5106000,'platform:notification:create','发送消息','/api/platform/v1/notifications','POST',602,'ACTIVE',0), + (5106003,0,'PLATFORM','API',5106000,'platform:notification:template:list','查看消息模板','/api/platform/v1/notification-templates','GET',603,'ACTIVE',0), + (5106004,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:create','新增消息模板','/api/platform/v1/notification-templates','POST',604,'ACTIVE',0), + (5106005,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:edit','编辑消息模板','/api/platform/v1/notification-templates/{id}','PUT',605,'ACTIVE',0), + (5106006,0,'PLATFORM','BUTTON',5106000,'platform:notification:template:status','变更模板状态','/api/platform/v1/notification-templates/{id}/status','PUT',606,'ACTIVE',0), + (5106007,0,'PLATFORM','API',5106000,'platform:notification:task:list','查看消息任务','/api/platform/v1/notification-tasks','GET',607,'ACTIVE',0), + (5106008,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:cancel','取消消息任务','/api/platform/v1/notification-tasks/{id}/cancel','PUT',608,'ACTIVE',0), + (5106009,0,'PLATFORM','BUTTON',5106000,'platform:notification:task:retry','重试消息任务','/api/platform/v1/notification-tasks/{id}/retry','PUT',609,'ACTIVE',0), + (5106010,0,'MERCHANT','API',0,'merchant:notification:view','查看我的消息','/api/merchant/v1/notifications','GET',900,'ACTIVE',0) +ON DUPLICATE KEY UPDATE permission_name=VALUES(permission_name), route_path=VALUES(route_path), http_method=VALUES(http_method), status='ACTIVE', deleted=0; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) + FROM baiyue_iam.iam_permission p + JOIN baiyue_iam.iam_role r ON r.tenant_id=0 AND r.app_code='MERCHANT' AND r.status='ACTIVE' + WHERE p.tenant_id=0 AND p.permission_code='merchant:notification:view' AND r.role_code='MERCHANT_OWNER'; + +INSERT IGNORE INTO baiyue_iam.iam_role_permission (tenant_id, role_id, permission_id, created_at) +SELECT 0, r.id, p.id, CURRENT_TIMESTAMP(3) + FROM baiyue_iam.iam_permission p + JOIN baiyue_iam.iam_role r ON r.tenant_id=0 AND r.app_code='PLATFORM' AND r.status='ACTIVE' + WHERE p.tenant_id=0 AND p.permission_code LIKE 'platform:notification%' AND r.role_code='PLATFORM_ADMIN'; + +DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_center_schema; +DELIMITER $$ +CREATE PROCEDURE baiyue_iam.assert_notification_center_schema() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_iam' AND table_name IN ( + 'iam_notification_template','iam_notification_task','iam_notification_recipient','iam_notification_inbox','iam_notification_event_inbox' + )) <> 5 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM 消息中心表不完整'; + END IF; + IF (SELECT COUNT(*) FROM information_schema.statistics WHERE table_schema='baiyue_iam' AND index_name IN ( + 'uk_notification_template_code','uk_notification_task_idempotency','uk_notification_source_event', + 'uk_notification_recipient','uk_notification_inbox_recipient','uk_notification_event' + )) <> 6 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM 消息中心唯一索引不完整'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0 + AND (permission_code LIKE 'platform:notification%' OR permission_code='merchant:notification:view')) <> 11 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:IAM 消息中心权限不完整'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp + JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id + JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id + WHERE r.role_code='PLATFORM_ADMIN' AND p.permission_code LIKE 'platform:notification%') <> 10 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:平台管理员消息中心授权不完整'; + END IF; + IF (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission rp + JOIN baiyue_iam.iam_role r ON r.id=rp.role_id AND r.tenant_id=rp.tenant_id + JOIN baiyue_iam.iam_permission p ON p.id=rp.permission_id AND p.tenant_id=rp.tenant_id + WHERE r.role_code='MERCHANT_OWNER' AND p.permission_code='merchant:notification:view') <> 1 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='统一迁移失败:企业管理员消息查看授权不完整'; + END IF; +END$$ +DELIMITER ; +CALL baiyue_iam.assert_notification_center_schema(); +DROP PROCEDURE IF EXISTS baiyue_iam.assert_notification_center_schema; + -- 二、三期对外统一使用 INBOUNDED;历史 WAIT_SPLIT 仅由查询适配为 INBOUNDED,统一迁移不改写主表或历史状态。 SELECT 'PASS' AS migration_status, '统一增量全部执行完成;父子订单、履约分片、装柜门禁、订舱拖车、报关船期、船司查询、仓库角色、费用、Finance人工财务、公共资源及可靠审计结构校验通过' AS message; diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index d0dcb8bd5..0b5445956 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -61,7 +61,47 @@ export async function run(context) { `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 suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) + const channelCode = id(inbound?.summary?.channelCode || source?.channelCode) + const channelName = inbound?.summary?.channelName || source?.channelName || channelCode + const warehouseName = inbound?.summary?.warehouseName || source?.warehouseName + const bookingDraft = { + customerId: tenantId, customerName: inbound?.summary?.customerName || `验收客户-${tenantId}`, + channelCode: channelCode || 'FBA', channelName, warehouseId, warehouseName, + billOfLadingNo: `BL-${suffix}`, shippingCompanyId: null, shippingCompanyName: `验收船公司-${suffix}`, + bookingAgentId: null, bookingAgentName: `验收代理-${suffix}`, vesselName: `验收船-${suffix}`, + voyageNo: `V-${suffix}`, departurePortCode: 'CNSZX', departurePortName: '深圳', + destinationPortCode: 'USLAX', destinationPortName: '洛杉矶', placeOfDelivery: 'LAX', + serviceTerm: 'CY/CY', freightTerm: 'PREPAID', estimatedSailingAt: '2026-09-01T08:00:00', + estimatedArrivalAt: '2026-09-20T08:00:00', remark: context.marker, + parties: [{ partyType: 'SHIPPER', partyName: `发货方-${suffix}` }, + { partyType: 'CONSIGNEE', partyName: `收货方-${suffix}` }, + { partyType: 'NOTIFY_PARTY', partyName: `通知方-${suffix}` }], + containerRequirements: [{ containerType: '40HQ', containerKind: 'FCL', capacityCbm: 100, maxWeightKg: 100000, quantity: 1 }], + } + context.assert(MODULE, '订舱渠道与仓库快照完整', inboundResponse, Boolean(channelCode && channelName && warehouseName), + `channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId}, warehouseName=${warehouseName || 'missing'}`, OPTIONS) + if (!channelCode || !warehouseName) return + const bookingCreate = await request(context, 'POST', '/api/platform/v1/ocean-bookings', + { expectedVersion: 0, data: bookingDraft }, { 'Idempotency-Key': `${context.marker}-loading-booking-create` }) + const bookingCreated = context.apiSuccess(MODULE, '创建含渠道仓库快照的隔离订舱', bookingCreate, '', OPTIONS) + const bookingId = id(bookingCreated?.oceanBooking?.id) + if (!bookingId) return + const bookingVersion = Number(bookingCreated.oceanBooking.version) + const submit = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/submit`, + { expectedVersion: bookingVersion, reason: context.marker }, { 'Idempotency-Key': `${context.marker}-loading-booking-submit` }) + const submitted = context.apiSuccess(MODULE, '提交隔离订舱', submit, '', OPTIONS) + const confirm = await request(context, 'POST', `/api/platform/v1/ocean-bookings/${bookingId}/confirm`, + { expectedVersion: Number(submitted?.oceanBooking?.version), reason: context.marker }, { 'Idempotency-Key': `${context.marker}-loading-booking-confirm` }) + const confirmedBooking = context.apiSuccess(MODULE, '确认订舱并自动生成真实柜次', confirm, '', OPTIONS) + const allocation = confirmedBooking?.containerAllocations?.[0] + const containerId = id(allocation?.containerBookingId) + context.assert(MODULE, '确认响应包含自动生成的真实柜次', confirm, + Boolean(containerId) && id(confirmedBooking?.oceanBooking?.id) === bookingId, + `bookingId=${bookingId}, containerBookingId=${containerId || 'missing'}`, OPTIONS) + if (!containerId) return + + const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=${encodeURIComponent(containerId)}&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) @@ -72,37 +112,14 @@ export async function run(context) { `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 === 'WAIT_LOADING', + && ['EDITING', 'WAIT_LOADING'].includes(containerDetail?.planStatus), `planStatus=${containerDetail?.planStatus || 'missing'}`, OPTIONS) + const containerNo = id(containerDetail?.containerNo || containerDetail?.cabinetNo) 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) diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index d768a6d9f..519c66b3b 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -68,7 +68,7 @@ test('records LOADED and clears the reason only after a real LOADED reread', () test('container loading follows push, claim, real pick, completed sequence', () => { const value = source('./phase3-container-loading.mjs') - assert.match(value, /containerDetail\?\.planStatus === 'WAIT_LOADING'/) + assert.match(value, /\['EDITING', 'WAIT_LOADING'\]\.includes\(containerDetail\?\.planStatus\)/) assertOrder(value, [ "pushed?.status === 'EDITING'", "claimedPlan?.status === 'LOADING'", @@ -124,6 +124,23 @@ test('PDA inventory snapshot gates batch and records the only safe mutation gap' assert.match(convergence, /fixture\?\.inventoryMutationVerified !== true/) }) +test('container loading creates and confirms booking before scoped candidates', () => { + const value = source('./phase3-container-loading.mjs') + assertOrder(value, [ + "const bookingCreate = await request", + "const submit = await request", + "const confirm = await request", + "const candidateResponse = await context.request('GET', `${SMART}/candidates?bookingId=", + "const planResponse = await request(context, 'POST', `${SMART}/plans`", + "const assignResponse = await request", + ]) + assert.match(value, /channelCode: channelCode/) + assert.match(value, /warehouseId, warehouseName/) + assert.match(value, /containerAllocations\?\.\[0\]/) + assert.match(value, /bookingId=\$\{encodeURIComponent\(containerId\)\}&warehouseId=/) + assert.doesNotMatch(value, /POST', CONTAINERS, containerPayload/) +}) + test('PDA completion acceptance verifies immutable BATCH and COMPLETE_LOADING audit with operator metadata', () => { const container = source('./phase3-container-loading.mjs') const convergence = source('./phase3-pda-convergence.mjs')