diff --git a/scripts/README.md b/scripts/README.md index 850195c07..21ba898e5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -205,6 +205,9 @@ Client 全量、Client 入库测量与计费、待审核订单和批量待分柜 ### 灰度环境 FBA 定向闭环 +同一入口可显式添加 `--prepare-domestic-pda-h5 true`,仅允许单独指定 `phase5-root-fulfillment`:创建本轮客户和两箱根订单,经测量、分柜、订舱放舱和计划确认生成一箱待拣任务及箱唛后停止,不领取/拣货/完成任务、不进入海外链。报告标记 `fullAcceptance=false`、`PREPARED_NOT_VERIFIED`,`createdResources.phase5RootFulfillment.domesticPdaH5Fixture` 保存任务、柜次、计划、仓库、入仓来源及箱信息供 H5;成功只代表准备完成。样本保留,失败不自动重试或清理,默认不传该参数时继续完整链。 + + `admin-test-env-acceptance.mjs --environment gray` 显式支持临时灰度环境,当前仅开放 `--modules phase5-root-fulfillment` 及自动展开的本轮客户资质 fixture。该链会通过正式 HTTP 新建隔离客户、两箱根订单及分柜子来源,推进国内测量、装柜、报关、人工船节点、海外收货、排车、出库和签收;不是默认全量验收。默认全量中包含 TEST 专用故障注入和未经灰度副作用核对的模块,因此 gray 拒绝省略模块、附加模块、批量订单和卸柜故障开关,也不会创建 TEST fault executor。`--f40-tax-mode TAX_REFUND` 覆盖退税文件确认链;真实邮件和供应商自动查询不在此闭环的完成声明中。 明确的仓库外环境文件须配置 `ADMIN_GRAY_GATEWAY`(统一 HTTPS 源站,不带 `/api`)、`ADMIN_GRAY_USERNAME`、`ADMIN_GRAY_PASSWORD`、`ADMIN_GRAY_PDA_USERNAME`、`ADMIN_GRAY_PDA_PASSWORD`;可选 `ADMIN_GRAY_CUSTOMER_NAME`。gray 不读取 TEST Gateway、管理员或 PDA 凭据。F40 始终创建本轮客户,不需要传已有 merchant 账号。数据库只读取证使用 `JENKINS_URL`、`JENKINS_USER`、`JENKINS_API_TOKEN` 及 Jenkins 已登记的 production SSH/host/known-hosts/env 凭据,由 `gray-regression-evidence.mjs` 实现,不调用 TEST agent 或写业务 SQL。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index ede6f17cc..b38392d65 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -313,6 +313,7 @@ function usage() { --merchant-username 复用既有已审核客户账号,不再创建客户 --merchant-password 复用客户账号密码,不写入报告 --origin-warehouse-name 固定国内起运仓名称 + --prepare-domestic-pda-h5 true 仅准备新待拣任务供H5,不执行完整履约验收 --channel-name 固定物流渠道名称 --channel-code 固定物流渠道编码 --order-profile-mode 订单档案模式:fixed 或 randomized,默认fixed @@ -408,6 +409,7 @@ export function parseArguments(argv) { const fixtureIndex = modules.indexOf('merchant-qualification-fixture') if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) } + if (values.has('prepare-domestic-pda-h5') && values.get('prepare-domestic-pda-h5') !== 'true') throw new Error('prepare-domestic-pda-h5 必须明确为 true;完整验收请省略此参数') return { environment, gateway: (values.get('gateway') || envValue('ADMIN_TEST_GATEWAY', 'ADMIN_GRAY_GATEWAY')).replace(/\/$/, ''), @@ -437,6 +439,7 @@ export function parseArguments(argv) { returnMainChainOnly: values.get('return-main-chain-only') === 'true', verifyUnloadEventRecovery: values.get('verify-unload-event-recovery') === 'true', f40TaxMode: values.get('f40-tax-mode') || '', + prepareDomesticPdaH5: values.get('prepare-domestic-pda-h5') === 'true', manualSplitFixtureReport: values.get('manual-split-fixture-report') || '', manualSplitPlanId: values.get('manual-split-plan-id') || '', manualPreloadSourceReport: values.get('manual-preload-source-report') || '', @@ -465,6 +468,7 @@ export function validateExecutionOptions(options) { } if (options.environment && !['test', 'gray'].includes(options.environment)) throw new Error('Only test or explicit gray acceptance is supported') if (options.f40TaxMode && !['BUY_DECLARATION', 'TAX_REFUND'].includes(options.f40TaxMode)) throw new Error('f40-tax-mode 必须为 BUY_DECLARATION 或 TAX_REFUND') + if (options.prepareDomesticPdaH5 && (options.requestedModules?.length !== 1 || options.requestedModules[0] !== 'phase5-root-fulfillment')) throw new Error('prepare-domestic-pda-h5 仅允许单独指定 phase5-root-fulfillment') if ('overseasGateway' in options) throw new Error('Use the single gateway option') if (options.modules.some((module) => ['phase3-loading', 'phase3-container-loading', 'phase5-root-fulfillment'].includes(module)) && (!options.pdaUsername?.trim() || !options.pdaPassword?.trim())) { @@ -624,7 +628,7 @@ export class AcceptanceRun { this.phase4ProducerAttempts = new Map() this.deploymentBlockers = [] this.coverageGaps = [] - this.context = options.f40TaxMode ? { f40TaxMode: options.f40TaxMode } : {} + this.context = { ...(options.f40TaxMode ? { f40TaxMode: options.f40TaxMode } : {}), ...(options.prepareDomesticPdaH5 ? { prepareDomesticPdaH5: true } : {}) } this.token = '' this.pdaToken = '' this.refreshToken = '' @@ -1932,10 +1936,11 @@ export class AcceptanceRun { const summary = buildAcceptanceSummary(this.steps, this.modules, this.coverageGaps) return { - reportName: this.options.environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收', marker: this.marker, + reportName: this.options.prepareDomesticPdaH5 ? '国内 PDA H5 待拣任务准备(非全链路验收)' : this.options.environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收', marker: this.marker, environment: this.options.environment || 'test', gateway: this.options.gateway, generatedAt: new Date().toISOString(), startedAt: this.startedAt, success: this.fatal === null && summary.failed === 0, fatal: this.fatal, + ...(this.options.prepareDomesticPdaH5 ? { fullAcceptance: false, purpose: 'DOMESTIC_PDA_H5_FIXTURE' } : {}), summary, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, coverageGaps: this.coverageGaps, diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 84db159ec..a4cf18f8f 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,7 @@ # Admin 测试环境 HTTP 模块 +`phase5-root-fulfillment` 可由总入口显式 `--prepare-domestic-pda-h5 true` 仅准备本轮国内H5待拣任务。调用 `phase3-order-splitting.loadOneFragment` 到正式计划确认、待拣任务及箱唛就绪即返回,不领取、不拣货、不继续海外;结果固定 `PREPARED_NOT_VERIFIED`,不算全链验收。默认完整链不变,范围及授权见根脚本README。 + `fleet-capacity-pool-schema-contract.test.mjs` 是离线数据库合同:验证发布与持久命令唯一约束、唯一商业履约来源、可空实际数量、服务与统一基线一致、非破坏性结构补齐及独立销售/调度权限;不访问环境、不证明真实履约已通过。 ## 当前新增脚本的执行边界 diff --git a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs index 6ce2059bf..f2e553f76 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting-contract.test.mjs @@ -136,3 +136,16 @@ test('booking fixture never invents supplier names without formal IDs', () => { assert.doesNotMatch(source, /SPLIT-SHIPPING|SPLIT-AGENT/) assert.doesNotMatch(source, /shippingCompanyName: `|bookingAgentName: `/) }) + + +test('H5 preparation stops after real labels but before claim and refuses already picked data', () => { + const checkpoint = source.indexOf('if (context.context?.prepareDomesticPdaH5)') + const claim = source.indexOf('const claim = await request', checkpoint) + assert.ok(checkpoint > source.indexOf('const labelResponse =')) + assert.ok(claim > checkpoint) + const boundary = source.slice(checkpoint, claim) + assert.match(boundary, /pickedPackageCount\) !== 0/) + assert.match(boundary, /throw new Error/) + assert.match(boundary, /return \{ preparedForH5: true/) + assert.doesNotMatch(boundary, /request\(/) +}) diff --git a/scripts/admin-test-env-modules/phase3-order-splitting.mjs b/scripts/admin-test-env-modules/phase3-order-splitting.mjs index efb2d1710..4ce346b5d 100644 --- a/scripts/admin-test-env-modules/phase3-order-splitting.mjs +++ b/scripts/admin-test-env-modules/phase3-order-splitting.mjs @@ -392,6 +392,14 @@ export async function loadOneFragment(context, state) { `HTTP=${labelResponse.http}, contentType=${labelResponse.contentType || 'missing'}, bytes=${labelResponse.bytes?.length ?? 0}`, OPTIONS) if (labelResponse.http !== 200 || !Number(labelResponse.bytes?.length)) return null + if (context.context?.prepareDomesticPdaH5) { + if (task?.taskStatus === 'COMPLETED' || !task.items?.length || task.items.some(row => Number(row.pickedPackageCount) !== 0)) + throw new Error('H5准备只允许尚未拣货的新任务') + return { preparedForH5: true, taskId, planId, containerId, oceanBookingId, + warehouseId, sourceInboundId: id(candidate.inboundId), sourceOrderId, + containerNo: realContainerNo, taskNo: task.taskNo, items: task.items } + } + const claim = await request(context, 'POST', `${PDA}/tasks/${taskId}/claim`, { expectedAssignmentVersion: 0, operationId: `${operationBase}-claim`, }) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 10808e6b0..7ed2caa47 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -400,6 +400,7 @@ async function fragment(context, source, rootOrderId, sequence) { warehouseId: id(summary.warehouseId), warehouseName: summary.warehouseName, sourcePackageCount: sequence === 0 ? 2 : 1, sequence, }) + if (context.context?.prepareDomesticPdaH5 && result?.preparedForH5) return result check(context, 'F40 一箱来源通过真实 PDA 完成装柜', Boolean(result?.taskId && result?.sourceInboundId), `sequence=${sequence}`) const current = await api(context, 'GET', '回查该片真实入仓来源', query(`/api/platform/inbounds/${result.sourceInboundId}`, { tenantId: id(summary.tenantId) })) @@ -618,6 +619,14 @@ async function runRootFulfillment(context) { && Number(scope.createdResources.inboundMeasurement.actualPackageCount) === 2, `root=${rootOrderId}`) proof.currentStage = 'ROOT_LOAD_AND_SHIP' const first = await fragment(scope, rootInbound, rootOrderId, 0) + if (scope.context.prepareDomesticPdaH5) { + check(scope, '国内H5待拣任务已准备且未执行拣货', first?.preparedForH5 === true && Boolean(first.taskId), 'PREPARED_NOT_VERIFIED') + proof.domesticPdaH5Fixture = first + proof.finalStatus = 'PREPARED_NOT_VERIFIED' + proof.currentStage = 'DOMESTIC_PDA_H5_READY' + proof.cleanup = 'Fresh customer/order/container/task retained for manual H5 execution; no picking, completion or overseas work executed' + return proof + } proof.fragments.push(first) proof.currentStage = 'REMAINDER_IDENTITY' const tree = await api(scope, 'GET', '首柜 PDA 完成后读取正式余量树', diff --git a/scripts/gray-acceptance-entry-contract.test.mjs b/scripts/gray-acceptance-entry-contract.test.mjs index 170230adf..f6ceb2300 100644 --- a/scripts/gray-acceptance-entry-contract.test.mjs +++ b/scripts/gray-acceptance-entry-contract.test.mjs @@ -86,3 +86,14 @@ test('direct gray evidence use cannot bypass module/fault restrictions or failed })), /gray SSH unavailable/) assert.equal(run.context.phase4ReceivingEvidenceReader, undefined) }) + + +test('domestic H5 fixture is opt-in and cannot expand the gray module scope', () => { + const defaults = validateExecutionOptions(parseArguments(args)) + assert.equal(defaults.prepareDomesticPdaH5, false) + const prepared = validateExecutionOptions(parseArguments([...args, '--prepare-domestic-pda-h5', 'true'])) + assert.equal(new AcceptanceRun(prepared).context.prepareDomesticPdaH5, true) + for (const invalid of ['yes', 'false', '1']) assert.throws(() => parseArguments([...args, '--prepare-domestic-pda-h5', invalid]), /必须明确为 true/) + assert.deepEqual(prepared.requestedModules, ['phase5-root-fulfillment']) + assert.throws(() => validateExecutionOptions({ ...prepared, environment: 'test', requestedModules: ['orders'] }), /prepare-domestic-pda-h5/) +})