From 3616e9db04bf85e91bb1f0fc6f87663c5e493d3e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 23 Oct 2026 05:36:21 +0800 Subject: [PATCH] test(acceptance): recover completed-plan rejection probe after verified connection loss --- scripts/admin-test-env-modules/README.md | 2 + ...completed-command-replay-contract.test.mjs | 46 ++++++++++++++++++- .../phase3-loading-finalize.mjs | 43 +++++++++++++++-- 3 files changed, 86 insertions(+), 5 deletions(-) diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index f9e97a856..a9ccf9d50 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -10,6 +10,8 @@ 货物废弃的库存列表按客户、仓、目的仓和 SKU 聚合,不带入仓码;脚本按该 SKU 的代表余额 ID 回查原始详情,并要求客户、仓、入仓码及全部数量桶与聚合值一致,不能给聚合行补造来源,也不能让混合来源汇总冒充单一余额。散板余位、询价和实收的 Long 版本接受服务端规范十进制编码,仍拒绝空值、负值、前导零和不安全整数。 +已完成装柜计划的“禁止新增明细”拒绝探针保留原体、原操作键及首次连接失败诊断;只有回查原计划仍为 COMPLETED 且全部字段、明细与版本完全一致,才最多重放一次原探针,并再回查零漂移。此规则不用于普通业务写入;业务失败、计划变化或第二次连接失败仍失败,不把网络异常直接记为通过。 + 派车出库的客户归属按正式关联派送明细核对,验证客户、原出库号、原派车号、全部派送及箱数守恒,不读取 OutboundRow 未声明的 customerId。PDA 换标候选只限定原申请和真实目标托盘,不继承原接柜计划筛选;普通换标拣货按原箱号、当前位置及已核验逐 SKU 件数逐箱提交,不把箱数当件数。已发车原单的签收阶段可通过内部 continueDepartedFulfillment 续验,须保留原批次和当前发车证明;已有签收或未知结果必须核对原命令,不能重建。 散板来货沿当前 PDA 接口创建空托盘草稿,生成并绑定业务托盘后,读取原预报箱号与 SKU,通过逐箱清点保存显式 TEST 尺寸、重量和件数,再打托及运营实收确认。两托三箱的受控输入逐箱登记,不把托盘草稿接口当作测量接口,也不从整托总数猜测箱规;这些合成材料不代表真机测量或照片效果验收。 diff --git a/scripts/admin-test-env-modules/completed-command-replay-contract.test.mjs b/scripts/admin-test-env-modules/completed-command-replay-contract.test.mjs index eb12fc1d1..5b72bf7db 100644 --- a/scripts/admin-test-env-modules/completed-command-replay-contract.test.mjs +++ b/scripts/admin-test-env-modules/completed-command-replay-contract.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { replayCompletedCommand } from './phase3-loading-finalize.mjs' +import { replayCompletedCommand, completedPlanAssignmentProbe } from './phase3-loading-finalize.mjs' test('completed-command replay retries one connection failure and keeps its diagnostic', async () => { const failure = { http: 0, raw: 'fetch failed' } @@ -20,3 +20,47 @@ test('business failures are never retried; a second connection failure remains f assert.equal(calls, response.http === 0 ? 2 : 1) } }) + +function assignmentFixture(change = false) { + const baseline = { status: 'COMPLETED', version: 7, items: [{ id: '11', packageCount: 2 }] } + const reads = [], writes = [] + const context = { marker: 'batch', createdResources: {}, async request(method, path) { + reads.push({ method, path }); return { http: 200, body: { code: '200', data: change ? { ...baseline, version: 8 } : structuredClone(baseline) } } + } } + const input = { planPath: '/plans/1', baseline, + body: { inboundId: '10', inboundDetailId: '11', packageCount: 1, expectedVersion: 7, operationId: 'batch-negative' } } + const send = async (_context, method, path, body) => { + writes.push({ method, path, body: structuredClone(body) }) + return writes.length === 1 ? { http: 0, raw: 'fetch failed', transportError: { code: 'ECONNRESET' } } + : { http: 200, body: { code: 'A0409' } } + } + return { baseline, context, input, reads, writes, send } +} + +test('completed plan rejection probe checks unchanged authoritative state before exact original replay and retains first transport evidence', async () => { + const f = assignmentFixture() + const result = await completedPlanAssignmentProbe(f.context, f.input, f.send) + assert.equal(result.body.code, 'A0409'); assert.equal(f.writes.length, 2) + assert.deepEqual(f.writes[0], f.writes[1]); assert.equal(f.reads.length, 2) + const proof = f.context.createdResources.completedPlanAssignmentProbes[0] + assert.equal(proof.attempts[0].transportError.code, 'ECONNRESET') + assert.equal(proof.status, 'VERIFIED_REJECTION_AFTER_TRANSPORT_RECOVERY') +}) + +test('changed completed plan after lost response stops without any second write', async () => { + const f = assignmentFixture(true) + await assert.rejects(completedPlanAssignmentProbe(f.context, f.input, f.send), /禁止重放/) + assert.equal(f.writes.length, 1) + assert.equal(f.context.createdResources.completedPlanAssignmentProbes[0].status, 'UNKNOWN_RESULT_STATE_CHANGED') +}) + +test('completed plan rejection recovery never retries business rejection, server error or permits an ordinary active plan', async () => { + for (const response of [{ http: 200, body: { code: 'A0409' } }, { http: 503 }, { http: 0 }]) { + const f = assignmentFixture(); let count = 0 + await completedPlanAssignmentProbe(f.context, f.input, async () => { count++; return response }) + assert.equal(count, response.http === 0 ? 2 : 1) + } + const f = assignmentFixture(); f.input.baseline.status = 'ACTIVE' + await assert.rejects(completedPlanAssignmentProbe(f.context, f.input, f.send), /不可变命令/) + assert.equal(f.writes.length, 0) +}) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index 5e46e93a4..031e48c24 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -4,6 +4,41 @@ const OPTIONS = { scenario: 'phase3-loading-hard-gate-finalize', deploymentBlock function id(value) { return value == null ? '' : String(value) } function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) } +/** 只对已完成计划的拒绝探针核实未知结果;原体原键最多重放一次,不用于正常业务写入。 */ +export async function completedPlanAssignmentProbe(context, { planPath, body, baseline }, send = request) { + if (baseline?.status !== 'COMPLETED' || String(baseline.version) !== String(body.expectedVersion) + || !body.operationId?.startsWith(context.marker) || !body.inboundId || !body.inboundDetailId || body.packageCount !== 1) + throw Error('已完成计划拒绝探针缺少原状态、版本及不可变命令') + const signature = value => JSON.stringify(value, (_, row) => row && typeof row === 'object' && !Array.isArray(row) + ? Object.fromEntries(Object.keys(row).sort().map(key => [key, row[key]])) : row) + const original = structuredClone(body) + const evidence = { path: `${planPath}/assignments`, body: original, status: 'PREPARED', attempts: [] } + ;(context.createdResources.completedPlanAssignmentProbes ??= []).push(evidence) + await context.checkpoint?.() + const attempt = async () => { + evidence.status = 'SENDING'; await context.checkpoint?.() + const response = await send(context, 'POST', evidence.path, original) + evidence.attempts.push(response); evidence.status = response.http === 0 ? 'UNKNOWN_RESULT' : 'RESPONDED' + await context.checkpoint?.(); return response + } + const first = await attempt() + if (first.http !== 0) return first + const current = await context.request('GET', planPath) + evidence.readback = current + if (current.http !== 200 || codeOf(current) !== '200' || current.body?.data?.status !== 'COMPLETED' + || signature(current.body.data) !== signature(baseline)) { + evidence.status = 'UNKNOWN_RESULT_STATE_CHANGED'; await context.checkpoint?.() + throw Error('拒绝探针连接中断后原计划状态或明细变化,禁止重放及后续修改') + } + const replay = await attempt() + const after = await context.request('GET', planPath); evidence.after = after + const unchanged = after.http === 200 && codeOf(after) === '200' && signature(after.body.data) === signature(baseline) + evidence.status = replay.http === 200 && codeOf(replay) === 'A0409' && unchanged ? 'VERIFIED_REJECTION_AFTER_TRANSPORT_RECOVERY' : 'UNVERIFIED' + await context.checkpoint?.() + if (!unchanged) throw Error('原键拒绝探针重放后计划不一致,停止后续修改') + return replay +} + export async function replayCompletedCommand(send, recordTransportFailure) { const first = await send() if (first.http !== 0) return first @@ -84,7 +119,8 @@ async function request(context, method, path, body, headers = {}) { 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 } + return { method, path, http: 0, body: null, raw: String(error), requestId: null, traceId: null, + transportError: { name: error?.name, code: error?.cause?.code || error?.code, cause: error?.cause?.message } } } } @@ -520,13 +556,12 @@ export async function run(context) { `HTTP=${immutableTransport.http}, code=${codeOf(immutableTransport)}, requestId=${immutableTransport.requestId || 'missing'}, traceId=${immutableTransport.traceId || 'missing'}`, OPTIONS) const completedPlanItem = immutablePlan?.items?.[0] - const assignmentAfterCompleted = await request(context, 'POST', - `/api/platform/v1/first-leg/smart-loading/plans/${encodeURIComponent(loadingPlanId)}/assignments`, { + const assignmentAfterCompleted = await completedPlanAssignmentProbe(context, { planPath, baseline: immutablePlan, body: { inboundId: fixture.incrementalInboundId || inboundId, inboundDetailId: id(completedPlanItem?.inboundDetailId), packageCount: 1, expectedVersion: Number(immutablePlan?.version), operationId: `${context.marker}-assign-after-completed`.slice(0, 128), - }) + } }) context.addStep(MODULE, 'COMPLETED 后新增计划明细被拒绝', assignmentAfterCompleted, assignmentAfterCompleted.http === 200 && codeOf(assignmentAfterCompleted) === 'A0409', `HTTP=${assignmentAfterCompleted.http}, code=${codeOf(assignmentAfterCompleted)}`, OPTIONS)