From db07111e63dc6ef6be1c4d15ed66c768d181f4c2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 19:48:49 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ase3-container-documents-contract.test.mjs | 8 ++++++++ .../phase3-container-documents.mjs | 13 ++++++++++--- .../service-center-flow.mjs | 19 +++++++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs index 32b0234a4..5ba7f26f0 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents-contract.test.mjs @@ -16,6 +16,14 @@ import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' const source = readFileSync(new URL('./phase3-container-documents.mjs', import.meta.url), 'utf8') const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +test('document applicability resolves exact released booking rather than missing container channel', () => { + assert.match(source, /bookingId = id\(detail.container.oceanBookingId\)/) + assert.match(source, /id\(booking\?\.id\) !== bookingId/) + assert.match(source, /booking\?\.status !== 'RELEASED'/) + assert.match(source, /applicableDocumentTypes\(booking.channelCode\)/) + assert.doesNotMatch(source, /applicableDocumentTypes\(detail.container.channelCode\)/) +}) + test('destination clearance and delivery order documents are frozen while pickup uses the cabinet code', () => { assert.deepEqual(DOCUMENT_TYPES, [ 'DESTINATION_CLEARANCE', 'DELIVERY_ORDER', diff --git a/scripts/admin-test-env-modules/phase3-container-documents.mjs b/scripts/admin-test-env-modules/phase3-container-documents.mjs index 00bc03c9b..738387045 100644 --- a/scripts/admin-test-env-modules/phase3-container-documents.mjs +++ b/scripts/admin-test-env-modules/phase3-container-documents.mjs @@ -124,11 +124,18 @@ export async function run(context) { const detailResponse = await context.request('GET', `${CONTAINERS}/domestic/${containerId}`) const detail = context.apiSuccess(MODULE, '读取本轮货柜权威渠道', detailResponse, '', OPTIONS) - if (id(detail?.container?.id) !== containerId || !detail?.container?.channelCode) { - context.addStep(MODULE, '货柜渠道适用性前置', detailResponse, false, '缺少同柜权威渠道', OPTIONS) + if (id(detail?.container?.id) !== containerId || !detail?.container?.oceanBookingId) { + context.addStep(MODULE, '货柜渠道适用性前置', detailResponse, false, '缺少同柜关联订舱', OPTIONS) return } - const expectedTypes = applicableDocumentTypes(detail.container.channelCode) + const bookingId = id(detail.container.oceanBookingId) + const bookingResponse = await context.request('GET', `/api/platform/v1/ocean-bookings/${encodeURIComponent(bookingId)}`) + const booking = context.apiSuccess(MODULE, '读取本柜关联订舱权威渠道', bookingResponse, '', OPTIONS)?.oceanBooking + if (id(booking?.id) !== bookingId || booking?.status !== 'RELEASED' || !booking?.channelCode) { + context.addStep(MODULE, '货柜订舱渠道适用性前置', bookingResponse, false, '关联订舱身份、放舱状态或渠道缺失', OPTIONS) + return + } + const expectedTypes = applicableDocumentTypes(booking.channelCode) const specs = [ { type: 'DESTINATION_CLEARANCE', diff --git a/scripts/admin-test-env-modules/service-center-flow.mjs b/scripts/admin-test-env-modules/service-center-flow.mjs index c39789d35..0e55ee6bc 100644 --- a/scripts/admin-test-env-modules/service-center-flow.mjs +++ b/scripts/admin-test-env-modules/service-center-flow.mjs @@ -104,6 +104,25 @@ export async function run(context) { `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 const path = `${TRUCK}/${appointmentId}` + // This consumer can run after all booking containers have departed. Creation then + // completes the appointment transactionally; trying SUBMIT would be an illegal reversal. + if (appointment.status === 'COMPLETED') { + const currentResponse = await context.request('GET', path) + const current = context.apiSuccess(MODULE, '回查离仓后创建拖车的自动完成状态', currentResponse, '', OPTIONS) + const historyResponse = await context.request('GET', `${path}/history`) + const history = context.apiSuccess(MODULE, '回查拖车自动完成权威历史', historyResponse, '', OPTIONS) + const verified = current?.status === 'COMPLETED' && id(current.oceanBookingId) === id(ocean.id) + && Array.isArray(history) && history.some(row => row.action === 'CREATE' && id(row.appointmentId) === appointmentId) + && history.some(row => row.action === 'AUTO_COMPLETE' && row.toStatus === 'COMPLETED' + && id(row.appointmentId) === appointmentId && Boolean(row.createdAt)) + context.assert(MODULE, '订舱已全部离仓时拖车自动完成且不逆向提交', currentResponse, verified, + `appointmentId=${appointmentId}, status=${current?.status || 'missing'}`, OPTIONS) + if (verified) context.createdResources.serviceCenterFlow = { + marker: context.marker, appointmentId, oceanBookingId: ocean.id, finalStatus: 'COMPLETED', + completionMode: 'AUTO_COMPLETE_AFTER_DEPARTURE', + } + return + } const dispatchUpload = await uploadDispatchOrder(context, appointmentId) const dispatchAttachment = context.apiSuccess(MODULE, '跨服务拖车上传有效派车单附件', dispatchUpload, '', OPTIONS) if (!dispatchAttachment?.id) return -- 2.54.0 From bd03ed56712529f02f2732678233d5798793800f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 20 Sep 2026 19:57:47 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 4 - Jenkinsfile.service | 28 +++-- deploy/sql/README.md | 4 +- .../baiyue_file/V2__widen_business_type.sql | 8 ++ .../V3__relax_valid_nullable_fields.sql | 12 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 2 +- deploy/sql/海外区域统一增量-MySQL8.sql | 6 +- .../src/main/resources/db/lastleg.sql | 6 +- scripts/admin-test-env-modules/README.md | 4 + ...-overseas-pda-operations-contract.test.mjs | 11 +- .../phase4-overseas-pda-operations.mjs | 108 ++++++++++++------ ...ase4-tail-order-controls-contract.test.mjs | 11 ++ .../phase4-tail-order-controls.mjs | 87 ++++++++------ scripts/controller-pipeline-contract.test.mjs | 4 +- ...ema-alignment-migrations-contract.test.mjs | 6 + scripts/overseas-deployment-contract.test.mjs | 3 +- ...est-environment-pipeline-contract.test.mjs | 3 +- 17 files changed, 210 insertions(+), 97 deletions(-) create mode 100644 deploy/sql/migrations/cn/baiyue_file/V2__widen_business_type.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V3__relax_valid_nullable_fields.sql diff --git a/Jenkinsfile b/Jenkinsfile index e0f8a152a..861c71258 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -331,10 +331,6 @@ pipeline { if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') } - if (['BOTH', 'SELECT'].contains(bridgeRegion) && - !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) { - error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') - } if (bridgeRegion == 'SELECT') { echo '兼容旧 Jenkins 参数 SELECT:本次按 BOTH 同时部署国内和海外事件桥' } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index 1d44cdf46..2f213a75f 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -55,7 +55,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: environmentChoicesForServiceJob(env.JOB_BASE_NAME), description: '由当前单服务 Job 固定的部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 在开发/测试环境同时部署国内和海外实例;SELECT 为总控兼容值') + choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS', 'SELECT'], description: '仅 event-bridge-service 使用;BOTH 依次部署国内和海外实例;SELECT 为总控兼容值') string(name: 'IMAGE_REPOSITORY', defaultValue: 'git.buggoglobal.com/java-project/baiyue-firstleg', description: '共享镜像仓库基础路径,不包含服务名和标签') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'git.buggoglobal.com/java-project/eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') } @@ -130,10 +130,6 @@ pipeline { if (!['BOTH', 'SELECT', 'CN', 'OVERSEAS'].contains(bridgeRegion)) { error('EVENT_BRIDGE_REGION 只能为 BOTH、CN 或 OVERSEAS') } - if ((deployBothBridges || ['BOTH', 'SELECT'].contains(bridgeRegion)) && - !['development', 'test'].contains(env.RESOLVED_ENVIRONMENT)) { - error('事件桥 BOTH 仅支持开发或测试环境;预发布和生产必须明确选择 CN 或 OVERSEAS') - } } } @@ -285,6 +281,11 @@ pipeline { script { String credentialId = "firstleg-${env.RESOLVED_ENVIRONMENT}-env" String serviceDeployResource = "firstleg-production-${env.RESOLVED_SERVICE}-deploy" + List bridgeRegions = (env.RESOLVED_SERVICE == 'event-bridge-service' && + (env.DEPLOY_BOTH_EVENT_BRIDGES == 'true' || + ['BOTH', 'SELECT'].contains(params.EVENT_BRIDGE_REGION))) + ? ['CN', 'OVERSEAS'] + : [params.EVENT_BRIDGE_REGION] lock(resource: serviceDeployResource, inversePrecedence: true) { withCredentials([ file(credentialsId: credentialId, variable: 'DEPLOY_ENV_FILE'), @@ -293,12 +294,17 @@ pipeline { string(credentialsId: 'firstleg-production-host', variable: 'TARGET_SSH_HOST'), file(credentialsId: 'firstleg-production-known-hosts', variable: 'TARGET_SSH_KNOWN_HOSTS') ]) { - withEnv([ - 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/services', - 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST, - "EVENT_BRIDGE_REGION_OVERRIDE=${params.EVENT_BRIDGE_REGION}" - ]) { - sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + bridgeRegions.each { bridgeRegion -> + if (env.RESOLVED_SERVICE == 'event-bridge-service') { + echo "部署生产事件桥实例: ${bridgeRegion}" + } + withEnv([ + 'TARGET_DEPLOY_DIR=/data/firstleg/deploy/services', + 'TARGET_SSH_TARGET=' + env.TARGET_SSH_USER + '@' + env.TARGET_SSH_HOST, + "EVENT_BRIDGE_REGION_OVERRIDE=${bridgeRegion}" + ]) { + sh "sh deploy/scripts/deploy-built-service.sh '${env.RESOLVED_ENVIRONMENT}' '${env.RESOLVED_IMAGE_REPOSITORY}' '${env.RESOLVED_IMAGE_TAG}' '${env.RESOLVED_SERVICE}' \"\$DEPLOY_ENV_FILE\"" + } } } } diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 5cc03e831..aadb21f39 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -68,8 +68,8 @@ |---|---|---| | CN / `baiyue_iam` | `migrations/cn/baiyue_iam/V1__align_schema_baseline.sql` | 收敛客户编号、昵称和资质原文件名定义;收紧前检查存量数据 | | CN / `baiyue_firstleg` | `migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql` | 收敛一件代发托盘码非空定义;收紧前检查存量数据 | -| CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql` | 补齐 PDA 发布表和固定渠道,收敛文件业务类型长度 | -| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql` | 补齐尾程字段并收敛字段定义和排序规则;收紧前检查存量数据 | +| CN / `baiyue_file` | `migrations/cn/baiyue_file/V1__align_schema_baseline.sql`、`V2__widen_business_type.sql` | 补齐 PDA 发布表和固定渠道,并支持正式长业务类型 | +| OVERSEAS / `baiyue_lastleg` | `migrations/overseas/baiyue_lastleg/V1__align_lastleg_production_schema.sql`、`V2__align_schema_baseline.sql`、`V3__relax_valid_nullable_fields.sql` | 补齐尾程字段并收敛字段定义;放宽退货出库和预入仓批量 SKU 换标合法空值 | 上述新增候选当前只完成编写和离线检查,尚未在测试或生产执行。测试通过后必须复用同一份 SQL 和 manifest 晋级生产,不得重新生成或修改候选。`scripts/prod-database-schema-acceptance.mjs` 仍只负责历史尾程 V1 的专用只读核对/受控执行,不代表本批跨 schema 发布入口;本批按迁移 manifest 管理,实际执行须等待环境执行器接入或当前轮明确授权后的受控人工执行。 diff --git a/deploy/sql/migrations/cn/baiyue_file/V2__widen_business_type.sql b/deploy/sql/migrations/cn/baiyue_file/V2__widen_business_type.sql new file mode 100644 index 000000000..e931a4649 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_file/V2__widen_business_type.sql @@ -0,0 +1,8 @@ +-- Region: CN +-- Schema: baiyue_file +-- Purpose: 支持长度超过 32 的正式文件业务类型,例如海外转运测量照片和换标汇总文档。 +-- Preconditions: 连接必须指向 baiyue_file。 +-- Postconditions: file_object.business_type 为 varchar(64),与 File 模块正式 SQL 一致。 + +ALTER TABLE file_object + MODIFY COLUMN business_type varchar(64) DEFAULT NULL COMMENT '关联业务类型'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V3__relax_valid_nullable_fields.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V3__relax_valid_nullable_fields.sql new file mode 100644 index 000000000..6b5c7795d --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V3__relax_valid_nullable_fields.sql @@ -0,0 +1,12 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 放宽代码明确允许为空的退货出库仓位与批量 SKU 换标字段。 +-- Preconditions: 连接必须指向 baiyue_lastleg。 +-- Postconditions: 三个字段可空,退货箱直接出库及预入仓批量 SKU 换标可正常持久化。 + +ALTER TABLE ll_outbound_pda_pick + MODIFY COLUMN inventory_position_id bigint NULL COMMENT '库存仓位ID;退货箱直接出库时为空'; + +ALTER TABLE ll_relabel_item + MODIFY COLUMN new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空', + MODIFY COLUMN source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空'; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d4f4fcce5..53475ef2e 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4427,7 +4427,7 @@ CREATE TABLE file_export_task_guard ( CREATE TABLE file_object ( id bigint unsigned NOT NULL COMMENT '文件ID(雪花ID)', tenant_id bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', - business_type varchar(32) DEFAULT NULL COMMENT '关联业务类型', + business_type varchar(64) DEFAULT NULL COMMENT '关联业务类型', business_id varchar(64) DEFAULT NULL COMMENT '关联业务逻辑ID', original_name varchar(255) NOT NULL COMMENT '原始文件名', object_key varchar(512) NOT NULL COMMENT '对象存储键', diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 82bf8d971..4249d4ce3 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1724,7 +1724,7 @@ CREATE TABLE ll_outbound_pda_pick ( customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', task_id bigint NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id bigint NOT NULL COMMENT '出库托盘明细ID', - inventory_position_id bigint NOT NULL COMMENT '库存仓位ID', + inventory_position_id bigint DEFAULT NULL COMMENT '库存仓位ID;退货箱直接出库时为空', packages bigint NOT NULL COMMENT '拣货箱数', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '拣货幂等键', picked_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间', @@ -2211,7 +2211,7 @@ CREATE TABLE ll_relabel_item ( notified_package_count int NOT NULL COMMENT '已通知箱数', shipment_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号(无FBA去向时为空)', tracking_code varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '物流跟踪编码', - new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU编码', + new_sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空', label_requirement varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标要求', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货物明细备注', relabel_file_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标文件ID', @@ -2228,7 +2228,7 @@ CREATE TABLE ll_relabel_item ( source_inbound_order_id bigint DEFAULT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)', source_inbound_order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原入仓单号快照', source_inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原进仓编码快照', - source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原海外中转仓ID', + source_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空', source_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原海外中转仓编码快照', source_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原海外中转仓名称快照', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index fd8bfe12e..f6f240715 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1561,7 +1561,7 @@ PREPARE outbound_pda_completion_index_stmt FROM @ddl; EXECUTE outbound_pda_compl CREATE TABLE IF NOT EXISTS ll_outbound_pda_pick ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', task_id BIGINT NOT NULL COMMENT 'PDA任务ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '出库托盘明细ID', - inventory_position_id BIGINT NOT NULL COMMENT '库存仓位ID', packages BIGINT NOT NULL COMMENT '拣货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键', + inventory_position_id BIGINT NULL COMMENT '库存仓位ID;退货箱直接出库时为空', packages BIGINT NOT NULL COMMENT '拣货箱数', idempotency_key VARCHAR(255) NOT NULL COMMENT '拣货幂等键', picked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拣货时间', PRIMARY KEY(id), UNIQUE KEY uk_ll_outbound_pda_pick_key(customer_id,idempotency_key) ) ENGINE=InnoDB COMMENT='一件代发出库PDA托盘拣货记录'; @@ -1861,7 +1861,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item ( notified_package_count INT NOT NULL COMMENT '已通知箱数', shipment_no VARCHAR(128) NULL COMMENT '货件编号(无FBA去向时为空)', tracking_code VARCHAR(255) NULL COMMENT '物流跟踪编码', - new_sku_code VARCHAR(128) NOT NULL COMMENT '换标后SKU编码', + new_sku_code VARCHAR(128) NULL COMMENT '换标后SKU编码;批量SKU换标使用子明细JSON时为空', label_requirement VARCHAR(1000) NULL COMMENT '换标要求', remark VARCHAR(1000) NULL COMMENT '货物明细备注', relabel_file_id VARCHAR(128) NULL COMMENT '换标文件ID', @@ -1878,7 +1878,7 @@ CREATE TABLE IF NOT EXISTS ll_relabel_item ( source_inbound_order_id BIGINT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)', source_inbound_order_no VARCHAR(128) NULL COMMENT '原入仓单号快照', source_inbound_code VARCHAR(128) NULL COMMENT '原进仓编码快照', - source_warehouse_id VARCHAR(128) NOT NULL COMMENT '原海外中转仓ID', + source_warehouse_id VARCHAR(128) NULL COMMENT '原海外中转仓ID;预入仓批量SKU换标时为空', source_warehouse_code VARCHAR(128) NULL COMMENT '原海外中转仓编码快照', source_warehouse_name VARCHAR(255) NULL COMMENT '原海外中转仓名称快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 1a281b68f..113741e2d 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,6 +4,10 @@ 总入口是 `../admin-test-env-acceptance.mjs`。每个业务模块使用独立的同名 `.mjs` 文件,避免多个对话同时修改总入口。 +普通库存换标回归由 `phase4-overseas-pda-operations` 验证:正常箱先完成混合换标的箱标与 SKU 阶段,另两箱保留跳过/异常;首次显式异常完工后经平台 `/rework` 退回并验证幂等,返工重新完成全部箱标/SKU 事实,再生成本轮目标托盘、绑定及核对库存流水,最后正常完工和客户确认。PDA 不自行批准复核,不在客户确认后才首次组托;异常首次完工会创建关联异常记录,属于测试数据副作用,保留为验收证据。 + +尾程暂扣按真实入库阶段验收:`phase4-tail-order-controls` 在 PDA 入库前验证暂扣返回冲突并回查订单零漂移,继续人工节点及整柜计划转换;`phase4-overseas-pda-operations` 接收本轮完成库存后调用同模块的 `verifyReceivedOrderHold`,重新核对本轮客户、订单和 `INBOUND_COMPLETED` 状态,再执行暂扣、同键重放/异参冲突、解除。正向暂扣未执行或前置不足仍是失败,不以入库前拒绝代替正向通过。 + `pda-loading-link-regression.mjs` 通过总入口 `--modules pda-loading-link-regression` 运行,复用 `dropship-pallet-matrix.mjs` 的国内正式订单、测量、封托、订舱、证据和完成生产链,仅执行国内关联回归。覆盖单票加入后全移除换柜完成、多票共托反复换三个旧柜后完成、逐托即时提交及同键重放,以及一单多柜中仍有实际分配的未开始柜导致 `OWNER_READY/A0409`。每次重新创建带 marker 的业务数据,通过公开 HTTP 回查旧柜有效分配数、PDA 终态和入仓状态;不写业务数据库伪造前置。零分配残留由真实整托移除链观察,报告保存 `createdResources.pdaLoadingLinkRegression`。负向柜保留为 `COMPLETION_FAILED`,成功柜保留为 `COMPLETED`,不自动清理;失败报告保留且不得算通过。使用显式测试环境文件及 `--output reports/<本轮名称>.json`,须有真实 HTTP 写入授权;不启动海外收货或出库。 退货专项同时核验新建退货单、退货主单、签收单、批次和自动箱唛的完整业务编号:`RT/RR/RB/RC/RETU-` 加 25 位大写 Base36 UUID 编码;不再截取内部 ID 六位尾号。幂等重放仍核对同一已保存编号,手工指定箱唛不受系统生成格式约束。编号生成器另有完整 128 位编码往返、原截断碰撞和本地并行生成单测;本地生成压测不增加 HTTP 并发。 diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index 6ddf32790..c7f97676e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -64,10 +64,10 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', '/swap', '/disassemble', '/assemble', '/unbind', '/relabel-pallet-label', '/relabel-disassemble', '/api/pda/v1/last-leg/relabel-tasks', - '/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/review', + '/candidates', '/claim', '/units', '/skip', '/exceptions', '/photos/files', '/complete', '/rework', ]) assert.ok(source.includes(endpoint), endpoint) for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409', - 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'APPROVE', 'COMPLETED', + 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'COMPLETED', 'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:', 'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', 'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map', @@ -101,6 +101,13 @@ test('PDA operations reuse received plan pallets and the public location without }) test('P5 follows warehouse review, customer confirmation and fulfillment completion in order', () => { + assert.match(source, /request\(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK'/) + assert.doesNotMatch(source, /decision: 'APPROVE'/) + assert.match(source, /manualCompletion: true/) + assert.match(source, /P5返工重新确认箱标阶段/) + assert.match(source, /P5返工重新完成SKU阶段/) + assert.ok(source.indexOf('P5换标箱${index + 1}拆托') < source.indexOf('P5返工组托后提交完工等待客户确认')) + assert.ok(source.indexOf('P5返工组托后提交完工等待客户确认') < source.indexOf('const confirmBody')) assert.match(source, /applicationStatus === 'CUSTOMER_CONFIRM_PENDING'/) assert.match(source, /const merchantRelabelBase = '\/api\/merchant\/v1\/last-leg\/relabel-applications'/) assert.match(source, /`\$\{merchantRelabelBase\}\/\$\{encodeURIComponent\(relabelNo\)\}\/confirm-completion`/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 4f43ab24f..a086c9511 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -1,3 +1,5 @@ +import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs' + const MODULE = 'phase4-overseas-pda-operations' const SCENARIO = 'phase4-overseas-pda-p2-p5' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -460,8 +462,20 @@ async function verifyRelabel(context, token, merchantToken, fixture) { return false } const unitPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/units` + if (item.operationType === 'CARTON_AND_SKU') { + const carton = await request(context, token, 'POST', 'P5正常换标先确认箱标阶段', + `${pdaBase}/${encodeURIComponent(relabelNo)}/cartons/scan`, { + cartonMark: item.cartonMark, expectedVersion: Number(detail.version), + idempotencyKey: key(context, 'RELABEL-NORMAL-CARTON'), + }) + if (!success(carton.response)) return false + detail = carton.data + } const scanBody = { itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: item.fbaCartonMark || item.newSkuCode, + skuVerifications: (item.skuItems || []).map(sku => ({ + skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), + })), expectedVersion: Number(detail.version), idempotencyKey: key(context, 'RELABEL-NORMAL') } const scanned = await request(context, token, 'POST', 'P5正常换标一箱', unitPath, scanBody) const scanReplay = await request(context, token, 'POST', 'P5正常换标同键重放', unitPath, scanBody) @@ -516,43 +530,45 @@ async function verifyRelabel(context, token, merchantToken, fixture) { if (!photoId) return false const completePath = `${pdaBase}/${encodeURIComponent(relabelNo)}/complete` const completeBody = { photoFileIds: [photoId], expectedVersion: Number(detail.version), - idempotencyKey: key(context, 'RELABEL-COMPLETE-1'), remark: `${context.marker}-complete-1` } - const completed = await request(context, token, 'POST', 'P5首次完成换标进入WAIT_REVIEW', completePath, completeBody) + manualCompletion: true, idempotencyKey: key(context, 'RELABEL-COMPLETE-1'), remark: `${context.marker}-complete-1` } + const completed = await request(context, token, 'POST', 'P5异常手动完成换标进入WAIT_REVIEW', completePath, completeBody) + if (!success(completed.response)) return false const completeReplay = await request(context, token, 'POST', 'P5首次完成同键重放', completePath, completeBody) context.assert(MODULE, 'P5首次完成重放保持WAIT_REVIEW', completeReplay.response, completeReplay.data?.taskStatus === 'WAIT_REVIEW', trace(completeReplay.response), OPTIONS) - const reviewPath = `${pdaBase}/${encodeURIComponent(relabelNo)}/review` - const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data?.version), + const reviewPath = `${relabelBase}/${encodeURIComponent(relabelNo)}/rework` + const reworkBody = { decision: 'REWORK', expectedVersion: Number(completed.data.version), idempotencyKey: key(context, 'RELABEL-REWORK'), remark: `${context.marker}-rework` } - const reworked = await request(context, token, 'POST', 'P5复核退回REWORK', reviewPath, reworkBody) - const reworkReplay = await request(context, token, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody) + const reworked = await request(context, context.overseasToken, 'POST', 'P5平台复核退回REWORK', reviewPath, reworkBody) + if (!success(reworked.response)) return false + const reworkReplay = await request(context, context.overseasToken, 'POST', 'P5 REWORK同键重放', reviewPath, reworkBody) context.assert(MODULE, 'P5 REWORK重放保持PROCESSING', reworkReplay.response, - reworkReplay.data?.taskStatus === 'PROCESSING', trace(reworkReplay.response), OPTIONS) - const secondCompleteBody = { photoFileIds: [photoId], expectedVersion: Number(reworked.data?.version), - idempotencyKey: key(context, 'RELABEL-COMPLETE-2'), remark: `${context.marker}-complete-2` } - const secondCompleted = await request(context, token, 'POST', 'P5返工后再次完成WAIT_REVIEW', - completePath, secondCompleteBody) - const secondReplay = await request(context, token, 'POST', 'P5再次完成同键重放', completePath, secondCompleteBody) - context.assert(MODULE, 'P5再次完成重放保持WAIT_REVIEW', secondReplay.response, - secondReplay.data?.taskStatus === 'WAIT_REVIEW', trace(secondReplay.response), OPTIONS) - const approveBody = { decision: 'APPROVE', expectedVersion: Number(secondCompleted.data?.version), - idempotencyKey: key(context, 'RELABEL-APPROVE'), remark: `${context.marker}-approved` } - const approved = await request(context, token, 'POST', 'P5复核批准换标完成', reviewPath, approveBody) - const approveReplay = await request(context, token, 'POST', 'P5批准同键重放', reviewPath, approveBody) - context.assert(MODULE, 'P5 PDA批准后等待客户确认', approveReplay.response, - approved.data?.taskStatus === 'COMPLETED' && approved.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING' - && approveReplay.data?.taskStatus === 'COMPLETED', trace(approveReplay.response), OPTIONS) - if (approved.data?.applicationStatus !== 'CUSTOMER_CONFIRM_PENDING') return false - - const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'), - remark: `${context.marker}-customer-confirmed` } - const confirmedResponse = await context.requestOverseas('POST', - `${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken) - const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null - context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse, - confirmed?.application?.status === 'READY_FOR_OUTBOUND' - && confirmed?.application?.fulfillmentStatus === 'WAIT_PRE_DISPATCH', trace(confirmedResponse), OPTIONS) - if (confirmed?.application?.status !== 'READY_FOR_OUTBOUND') return false + reworkReplay.data?.taskStatus === 'PROCESSING' && Number(reworkReplay.data.relabelledPackages) === 0, + trace(reworkReplay.response), OPTIONS) + detail = reworked.data + for (const redoItem of detail.items || []) { + if (redoItem.operationType === 'CARTON_AND_SKU') { + const carton = await request(context, token, 'POST', 'P5返工重新确认箱标阶段', + `${pdaBase}/${encodeURIComponent(relabelNo)}/cartons/scan`, { + cartonMark: redoItem.cartonMark, expectedVersion: Number(detail.version), + idempotencyKey: key(context, `RELABEL-REDO-CARTON-${redoItem.itemId}`), + }) + if (!success(carton.response)) return false + detail = carton.data + } + const redo = await request(context, token, 'POST', 'P5返工重新完成SKU阶段', unitPath, { + itemId: id(redoItem.itemId), oldLabel: redoItem.cartonMark, newLabel: redoItem.newSkuCode, + skuVerifications: (redoItem.skuItems || []).map(sku => ({ + skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), + })), + expectedVersion: Number(detail.version), idempotencyKey: key(context, `RELABEL-REDO-SKU-${redoItem.itemId}`), + }) + if (!success(redo.response)) return false + detail = redo.data + } + const bindingResponse = await context.requestOverseas('GET', `${relabelBase}/${encodeURIComponent(relabelNo)}`) + const bindingDetail = success(bindingResponse) ? bindingResponse.body.data : null + if (!Array.isArray(bindingDetail?.items) || bindingDetail.items.length !== 3) return false const operationBase = '/api/pda/v1/last-leg/pallet-operations' const printKeyA = key(context, 'RELABEL-PRINT-A') @@ -574,7 +590,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { const targets = [printedA.data, printedB.data] const beforeBySource = new Map() const movedFacts = [] - const sourceCodes = confirmed.items.map((itemDetail) => { + const sourceCodes = bindingDetail.items.map((itemDetail) => { const sourcePositionId = id(itemDetail?.sourceInventoryPositionId) return sourceLines.find((line) => id(line.position.positionId) === sourcePositionId)?.position?.palletNo || '' }) @@ -584,12 +600,12 @@ async function verifyRelabel(context, token, merchantToken, fixture) { }, new Map()) const splitSource = [...sourceFrequency.entries()].find(([, count]) => count >= 2)?.[0] || '' let splitSourceMoves = 0 - for (const [index, itemDetail] of confirmed.items.entries()) { + for (const [index, itemDetail] of bindingDetail.items.entries()) { const sourcePositionId = id(itemDetail?.sourceInventoryPositionId) const sourceLine = sourceLines.find((line) => id(line.position.positionId) === sourcePositionId) const sourcePalletCode = sourceLine?.position?.palletNo if (!sourcePalletCode || !itemDetail?.cartonMark) { - blocker(context, 'P5换标拆托来源快照', `item=${index + 1}缺少来源托盘或箱唛`, confirmedResponse) + blocker(context, 'P5换标拆托来源快照', `item=${index + 1}缺少来源托盘或箱唛`, bindingResponse) return false } const sourceBefore = (await resolvePallet(context, token, sourcePalletCode, @@ -670,6 +686,27 @@ async function verifyRelabel(context, token, merchantToken, fixture) { && relabelLedgers.every((row) => String(row?.remark || '').startsWith('换标拆托')), `rows=${relabelLedgers.length}, deltas=${relabelLedgers.map((row) => row.deltaPackages).join(',')}; ${trace(ledgerResponse)}`, OPTIONS) + const afterBinding = await request(context, token, 'GET', 'P5组托后刷新任务版本', + `${pdaBase}/${encodeURIComponent(relabelNo)}`) + if (!success(afterBinding.response)) return false + const finalBody = { photoFileIds: [photoId], expectedVersion: Number(afterBinding.data.version), + idempotencyKey: key(context, 'RELABEL-COMPLETE-2'), remark: `${context.marker}-complete-2` } + const approved = await request(context, token, 'POST', 'P5返工组托后提交完工等待客户确认', completePath, finalBody) + if (!success(approved.response) || approved.data?.applicationStatus !== 'CUSTOMER_CONFIRM_PENDING') return false + const finalReplay = await request(context, token, 'POST', 'P5返工完工同键重放', completePath, finalBody) + context.assert(MODULE, 'P5返工完工重放保持待客户确认', finalReplay.response, + finalReplay.data?.taskStatus === 'WAIT_REVIEW' && finalReplay.data?.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', + trace(finalReplay.response), OPTIONS) + const confirmBody = { idempotencyKey: key(context, 'RELABEL-CUSTOMER-CONFIRM'), + remark: `${context.marker}-customer-confirmed` } + const confirmedResponse = await context.requestOverseas('POST', + `${merchantRelabelBase}/${encodeURIComponent(relabelNo)}/confirm-completion`, confirmBody, {}, merchantToken) + const confirmed = success(confirmedResponse) ? confirmedResponse.body?.data : null + context.addStep(MODULE, 'P5客户确认换标完成进入待排车', confirmedResponse, + confirmed?.application?.status === 'READY_FOR_OUTBOUND' + && confirmed?.application?.fulfillmentStatus === 'WAIT_PRE_DISPATCH', trace(confirmedResponse), OPTIONS) + if (confirmed?.application?.status !== 'READY_FOR_OUTBOUND') return false + context.createdResources.phase4OverseasRelabel = { marker: context.marker, relabelNo, customerId, overseasWarehouseId: id(fixture.overseasWarehouseId), targetPalletIds: [...new Set(movedFacts.map((fact) => fact.targetPalletId))], @@ -719,6 +756,7 @@ export async function run(context) { return } fixture.overseasWarehouseId = id(handoff.overseasWarehouseId) + await verifyReceivedOrderHold(context) await verifyStocktake(context, token, fixture) await verifyInventoryMove(context, token, fixture) await verifyPalletOperations(context, token, fixture) diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs index 99d667038..6225b8682 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls-contract.test.mjs @@ -3,9 +3,20 @@ import { readFile } from 'node:fs/promises' import test from 'node:test' import { fileURLToPath } from 'node:url' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' +import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs' const source = await readFile(fileURLToPath(new URL('./phase4-tail-order-controls.mjs', import.meta.url)), 'utf8') +test('hold positive lifecycle stays after receiving; pre-receipt verifies refusal without mutation', async () => { + assert.match(source, /入库前暂扣必须拒绝/) + assert.match(source, /拒绝暂扣后订单事实零漂移/) + assert.match(source, /representative\?\.currentStatus !== 'INBOUND_COMPLETED'/) + const operations = await readFile(new URL('./phase4-overseas-pda-operations.mjs', import.meta.url), 'utf8') + assert.ok(operations.indexOf('await verifyReceivedOrderHold(context)') > operations.indexOf('handoff.completedFixtures')) + assert.ok(operations.indexOf('await verifyReceivedOrderHold(context)') < operations.indexOf('await verifyStocktake(context')) + await assert.rejects(verifyReceivedOrderHold({ marker: 'run', createdResources: {} }), /缺少本轮/) +}) + test('tail order controls execute after current producer and before overseas PDA', () => { const modules = normalizeModuleOrder(['phase4-overseas-pda']) const controls = modules.indexOf('phase4-tail-order-controls') diff --git a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs index e19e10159..06b3e9e21 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -320,6 +320,49 @@ async function convertContainerPlans(context, producer, details) { return plans } +export async function verifyReceivedOrderHold(context) { + const owned = context.createdResources.phase4TailOrderControls + if (owned?.marker !== context.marker || !owned.representativeOrderId) throw new Error('缺少本轮暂扣目标订单') + const producer = targetProducer(context) + const loaded = await detail(context, { id: owned.representativeOrderId, customerId: producer?.tenantId }, '入库后读取本轮暂扣目标') + const representative = loaded.data?.order + if (!success(loaded.response) || id(representative?.id) !== id(owned.representativeOrderId) + || id(representative?.customerId) !== id(producer?.tenantId) + || representative?.currentStatus !== 'INBOUND_COMPLETED') throw new Error('暂扣正向验收必须使用本轮已完成入库订单') + const orderPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { + customerId: id(representative.customerId), + }) + const holdKey = `${context.marker}-tail-hold`.slice(0, 128) + const holdBody = { held: true, chargeable: true, reason: `${context.marker} 暂扣验收`, expectedVersion: Number(representative.version) } + const held = await api(context, 'PUT', '暂扣本轮单票尾程订单', orderPath, holdBody, { 'Idempotency-Key': holdKey }) + context.assert(MODULE, '暂扣只推进单票版本并保留计费原因', held.response, + held.data?.held === true && held.data?.chargeable === true + && id(held.data?.orderId) === id(representative.id) && Number(held.data?.version) === Number(representative.version) + 1, + trace(held.response), OPTIONS) + if (!success(held.response)) return + const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey }) + context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response, + JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS) + const heldConflict = await context.requestOverseas('PUT', orderPath, + { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken) + context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS) + + const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker) + const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody, + { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken) + context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS) + + const releaseKey = `${context.marker}-tail-release`.slice(0, 128) + const releaseBody = { held: false, chargeable: false, reason: `${context.marker} 解除暂扣`, expectedVersion: Number(held.data.version) } + const released = await api(context, 'PUT', '解除本轮单票尾程暂扣', orderPath, releaseBody, { 'Idempotency-Key': releaseKey }) + context.assert(MODULE, '解除暂扣清空计费与原因并推进版本', released.response, + released.data?.held === false && released.data?.chargeable === false && released.data?.reason == null + && Number(released.data?.version) === Number(held.data.version) + 1, trace(released.response), OPTIONS) + if (!success(released.response)) return + + owned.holdVerifiedAfterReceipt = true +} + export async function run(context) { context.createdResources.phase4TailOrderControls = { marker: context.marker, containerNo: null, orderIds: [], planNos: [], representativeOrderId: null, @@ -355,48 +398,28 @@ export async function run(context) { refreshedDetails.push(refreshed.data.order) } const representative = refreshedDetails[0] - const orderPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { - customerId: id(representative.customerId), - }) - const holdKey = `${context.marker}-tail-hold`.slice(0, 128) - const holdBody = { held: true, chargeable: true, reason: `${context.marker} 暂扣验收`, expectedVersion: Number(representative.version) } - const held = await api(context, 'PUT', '暂扣本轮单票尾程订单', orderPath, holdBody, { 'Idempotency-Key': holdKey }) - context.assert(MODULE, '暂扣只推进单票版本并保留计费原因', held.response, - held.data?.held === true && held.data?.chargeable === true - && id(held.data?.orderId) === id(representative.id) && Number(held.data?.version) === Number(representative.version) + 1, - trace(held.response), OPTIONS) - if (!success(held.response)) return - const heldReplay = await api(context, 'PUT', '暂扣命令同键同载荷重放', orderPath, holdBody, { 'Idempotency-Key': holdKey }) - context.assert(MODULE, '暂扣重放版本零漂移', heldReplay.response, - JSON.stringify(heldReplay.data) === JSON.stringify(held.data), trace(heldReplay.response), OPTIONS) - const heldConflict = await context.requestOverseas('PUT', orderPath, - { ...holdBody, reason: `${holdBody.reason}-DIFFERENT` }, { 'Idempotency-Key': holdKey }, context.overseasToken) - context.addStep(MODULE, '暂扣同键异载荷返回A0409', heldConflict, conflict(heldConflict), trace(heldConflict), OPTIONS) - - const blockedPlanBody = manualPlanBody(representative, held.data.version, context.marker) - const blockedPlan = await context.requestOverseas('POST', `${PLANS}/manual`, blockedPlanBody, - { 'Idempotency-Key': `${context.marker}-held-plan`.slice(0, 128) }, context.overseasToken) - context.addStep(MODULE, '暂扣尾程订单不能生成人工计划', blockedPlan, conflict(blockedPlan), trace(blockedPlan), OPTIONS) - - const releaseKey = `${context.marker}-tail-release`.slice(0, 128) - const releaseBody = { held: false, chargeable: false, reason: `${context.marker} 解除暂扣`, expectedVersion: Number(held.data.version) } - const released = await api(context, 'PUT', '解除本轮单票尾程暂扣', orderPath, releaseBody, { 'Idempotency-Key': releaseKey }) - context.assert(MODULE, '解除暂扣清空计费与原因并推进版本', released.response, - released.data?.held === false && released.data?.chargeable === false && released.data?.reason == null - && Number(released.data?.version) === Number(held.data.version) + 1, trace(released.response), OPTIONS) - if (!success(released.response)) return + const rejectedHold = await context.requestOverseas('PUT', query( + `${ORDERS}/${encodeURIComponent(id(representative.id))}/hold`, { customerId: id(representative.customerId) }), + { held: true, chargeable: true, reason: `${context.marker} 入库前禁止暂扣`, expectedVersion: Number(representative.version) }, + { 'Idempotency-Key': `${context.marker}-pre-receipt-hold` }) + context.addStep(MODULE, '入库前暂扣必须拒绝', rejectedHold, conflict(rejectedHold), trace(rejectedHold), OPTIONS) + const afterHold = await detail(context, representative, '入库前暂扣拒绝后回查订单') + const unchanged = success(afterHold.response) && JSON.stringify(afterHold.data?.order) === JSON.stringify(representative) + context.assert(MODULE, '拒绝暂扣后订单事实零漂移', afterHold.response, unchanged, trace(afterHold.response), OPTIONS) + if (!conflict(rejectedHold) || !unchanged) return + // The positive hold/release lifecycle runs only after PDA receiving. const nodesPath = query(`${ORDERS}/${encodeURIComponent(id(representative.id))}/manual-nodes`, { customerId: id(representative.customerId), }) const nodesKey = `${context.marker}-tail-manual-nodes`.slice(0, 128) - const nodesBody = { expectedVersion: Number(released.data.version), nodes: completeManualNodes(context.marker) } + const nodesBody = { expectedVersion: Number(representative.version), nodes: completeManualNodes(context.marker) } const nodes = await api(context, 'PUT', '保存尾程八个人工节点快照', nodesPath, nodesBody, { 'Idempotency-Key': nodesKey }) context.assert(MODULE, '人工节点永久切换MANUAL且完整返回八行', nodes.response, nodes.data?.manualOverrideEnabled === true && nodes.data?.nodes?.length === 8 && nodes.data.nodes.every((node) => node?.source === 'MANUAL') - && Number(nodes.data?.version) === Number(released.data.version) + 1, + && Number(nodes.data?.version) === Number(representative.version) + 1, `nodes=${nodes.data?.nodes?.length ?? 'missing'}; ${trace(nodes.response)}`, OPTIONS) if (!success(nodes.response)) return const nodesReplay = await api(context, 'PUT', '人工节点同键同载荷重放', nodesPath, nodesBody, diff --git a/scripts/controller-pipeline-contract.test.mjs b/scripts/controller-pipeline-contract.test.mjs index 80909a4e0..7680e9048 100644 --- a/scripts/controller-pipeline-contract.test.mjs +++ b/scripts/controller-pipeline-contract.test.mjs @@ -52,11 +52,11 @@ test('coordinator cannot initialize test databases', () => { assert.doesNotMatch(pipeline, /测试数据库初始化/) }) -test('coordinator defaults event bridge deployments to both non-production regions', () => { +test('coordinator defaults event bridge deployments to both regions', () => { assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS'\]/) assert.match(pipeline, /\['BOTH', 'SELECT', 'CN', 'OVERSEAS'\]\.contains\(bridgeRegion\)/) assert.match(pipeline, /兼容旧 Jenkins 参数 SELECT:本次按 BOTH/) - assert.match(pipeline, /事件桥 BOTH 仅支持开发或测试环境/) + assert.doesNotMatch(pipeline, /事件桥 BOTH 仅支持开发或测试环境/) assert.match(pipeline, /String downstreamImageTag = deployBothBridges[\s\S]*?\? "\$\{env\.IMAGE_TAG\}-both"/) assert.match(pipeline, /String downstreamBridgeRegion = deployBothBridges[\s\S]*?\? 'CN'/) assert.match(pipeline, /service == 'event-bridge-service' \? params\.EVENT_BRIDGE_REGION : 'CN'/) diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index 6dd584fa6..6822655b5 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -9,7 +9,9 @@ const migrations = { iam: 'deploy/sql/migrations/cn/baiyue_iam/V1__align_schema_baseline.sql', firstleg: 'deploy/sql/migrations/cn/baiyue_firstleg/V1__align_schema_baseline.sql', file: 'deploy/sql/migrations/cn/baiyue_file/V1__align_schema_baseline.sql', + fileForwardFix: 'deploy/sql/migrations/cn/baiyue_file/V2__widen_business_type.sql', lastleg: 'deploy/sql/migrations/overseas/baiyue_lastleg/V2__align_schema_baseline.sql', + lastlegForwardFix: 'deploy/sql/migrations/overseas/baiyue_lastleg/V3__relax_valid_nullable_fields.sql', } async function sources() { @@ -30,10 +32,14 @@ test('schema alignment migrations cover the verified drift', async () => { for (const field of ['tenant_code', 'customer_nickname', 'original_file_name']) assert.match(source.iam, new RegExp(`\\b${field}\\b`)) assert.match(source.firstleg, /\bpallet_no\b/) for (const table of ['file_pda_release_channel', 'file_pda_release']) assert.match(source.file, new RegExp(`\\b${table}\\b`)) + assert.match(source.fileForwardFix, /business_type\s+varchar\(64\)/i) for (const field of [ 'actual_discard_packages', 'pda_status', 'pda_operator_id', 'pda_completed_at', 'quantity', 'pallet_code', 'discrepancy_reason', 'manually_completed_at', ]) assert.match(source.lastleg, new RegExp(`\\b${field}\\b`)) + for (const field of ['inventory_position_id', 'new_sku_code', 'source_warehouse_id']) { + assert.match(source.lastlegForwardFix, new RegExp(`\\b${field}\\b`)) + } }) test('constraint tightening is guarded before any alter table', async () => { diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 8b8bbb62d..aa85ff3e8 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -47,7 +47,8 @@ test('Jenkins pipelines recognize all overseas deployable modules', async () => assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) assert.match(service, /choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) - assert.match(controller, /事件桥 BOTH 仅支持开发或测试环境/) + assert.doesNotMatch(controller, /事件桥 BOTH 仅支持开发或测试环境/) + assert.match(service, /部署生产事件桥实例:/) }) test('service map freezes unique internal ports and moves event bridge away from 9300', async () => { diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index 229e936e9..e51a32b24 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -238,7 +238,7 @@ test('direct deployments wait for health and automatically restore the previous assert.match(directDeployScript, /return 1/) }) -test('one event bridge build deploys both regional instances in development and test', () => { +test('one event bridge build deploys both regional instances in every environment', () => { assert.match(pipeline, /choice\(name: 'EVENT_BRIDGE_REGION', choices: \['BOTH', 'CN', 'OVERSEAS', 'SELECT'\]/) assert.match(pipeline, /\['BOTH', 'SELECT'\]\.contains\(params\.EVENT_BRIDGE_REGION\)/) assert.match(pipeline, /\? \['CN', 'OVERSEAS'\]/) @@ -248,4 +248,5 @@ test('one event bridge build deploys both regional instances in development and assert.match(pipeline, /imageTag\?\.endsWith\('-both'\)/) assert.match(pipeline, /imageTag\.substring\(0, imageTag\.length\(\) - '-both'\.length\(\)\)/) assert.match(pipeline, /env\.DEPLOY_BOTH_EVENT_BRIDGES = deployBothBridges \? 'true' : 'false'/) + assert.match(pipeline, /部署生产事件桥实例:/) }) -- 2.54.0