发布:将已验 TEST 功能更新至既有生产灰度(2026-10-23) #30
@@ -10,6 +10,8 @@
|
||||
|
||||
货物废弃的库存列表按客户、仓、目的仓和 SKU 聚合,不带入仓码;脚本按该 SKU 的代表余额 ID 回查原始详情,并要求客户、仓、入仓码及全部数量桶与聚合值一致,不能给聚合行补造来源,也不能让混合来源汇总冒充单一余额。散板余位、询价和实收的 Long 版本接受服务端规范十进制编码,仍拒绝空值、负值、前导零和不安全整数。
|
||||
|
||||
已完成装柜计划的“禁止新增明细”拒绝探针保留原体、原操作键及首次连接失败诊断;只有回查原计划仍为 COMPLETED 且全部字段、明细与版本完全一致,才最多重放一次原探针,并再回查零漂移。此规则不用于普通业务写入;业务失败、计划变化或第二次连接失败仍失败,不把网络异常直接记为通过。
|
||||
|
||||
派车出库的客户归属按正式关联派送明细核对,验证客户、原出库号、原派车号、全部派送及箱数守恒,不读取 OutboundRow 未声明的 customerId。PDA 换标候选只限定原申请和真实目标托盘,不继承原接柜计划筛选;普通换标拣货按原箱号、当前位置及已核验逐 SKU 件数逐箱提交,不把箱数当件数。已发车原单的签收阶段可通过内部 continueDepartedFulfillment 续验,须保留原批次和当前发车证明;已有签收或未知结果必须核对原命令,不能重建。
|
||||
|
||||
散板来货沿当前 PDA 接口创建空托盘草稿,生成并绑定业务托盘后,读取原预报箱号与 SKU,通过逐箱清点保存显式 TEST 尺寸、重量和件数,再打托及运营实收确认。两托三箱的受控输入逐箱登记,不把托盘草稿接口当作测量接口,也不从整托总数猜测箱规;这些合成材料不代表真机测量或照片效果验收。
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user