This commit is contained in:
2026-09-20 20:40:21 +08:00
parent 088c219c20
commit 2213647db4
4 changed files with 55 additions and 4 deletions
@@ -0,0 +1,22 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { replayCompletedCommand } 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' }
const success = { http: 200, body: { code: '200' } }
const responses = [failure, success]
const diagnostics = []
let calls = 0
assert.equal(await replayCompletedCommand(async () => { calls++; return responses.shift() }, r => diagnostics.push(r)), success)
assert.equal(calls, 2)
assert.deepEqual(diagnostics, [failure])
})
test('business failures are never retried; a second connection failure remains failure', async () => {
for (const response of [{ http: 200, body: { code: 'A0409' } }, { http: 503 }, { http: 0 }]) {
let calls = 0
const result = await replayCompletedCommand(async () => { calls++; return response }, () => {})
assert.equal(result, response)
assert.equal(calls, response.http === 0 ? 2 : 1)
}
})
@@ -3,6 +3,15 @@ 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 replayCompletedCommand(send, recordTransportFailure) {
const first = await send()
if (first.http !== 0) return first
// Used only after authoritative COMPLETED + operation history have been read.
// The closure retains the exact original body and idempotency key.
recordTransportFailure(first)
return send()
}
function evidenceSignature(data) {
return JSON.stringify((data?.evidences || []).map((item) => ({
id: id(item?.id), type: item?.evidenceType || null, fileId: id(item?.fileId),
@@ -268,8 +277,12 @@ export async function run(context) {
&& id(concurrentCompleteOperations[0]?.operatorId) && Boolean(concurrentCompleteOperations[0]?.createdAt)
&& completion.task?.taskStatus === 'COMPLETED' && completion.task?.workflowStatus === 'COMPLETED'
const completeResponse = completion.task?.taskStatus === 'COMPLETED'
? await request(context, 'POST', `${taskPath}/complete`, completeBody,
{ 'Idempotency-Key': completeKey })
? await replayCompletedCommand(() => request(context, 'POST', `${taskPath}/complete`, completeBody,
{ 'Idempotency-Key': completeKey }), failed => {
context.createdResources.loadingCompleteReplayTransportFailures ??= []
context.createdResources.loadingCompleteReplayTransportFailures.push({ taskId,
operationId: completeBody.operationId, http: failed.http, error: failed.raw || 'transport failure' })
})
: acceptedCompleteResponse
context.assert(MODULE, '两个不同幂等键并发 complete 仅一成功且只写一条完成审计',
completeResponse, concurrentCompleteVerified,
@@ -254,6 +254,20 @@ function previewGroupsSorted(groups) {
async function convertContainerPlans(context, producer, details) {
const bookingId = id(producer.containerBookingId)
const arrival = await api(context, 'POST', '按柜转换前读取预计到仓时间',
`${ORDERS}/container-arrival-times/query`, { sourceContainerBookingIds: [bookingId] })
const currentArrival = Array.isArray(arrival.data)
? arrival.data.find(row => id(row.sourceContainerBookingId) === bookingId) : null
if (!success(arrival.response) || !currentArrival || !Number.isInteger(Number(currentArrival.version))) return null
if (!nonBlank(currentArrival.estimatedWarehouseArrivalAt)) {
const expected = await api(context, 'PUT', '按柜转换前补齐预计到仓时间',
`${ORDERS}/container-summaries/${encodeURIComponent(bookingId)}/expected-arrival`, {
estimatedWarehouseArrivalAt: new Date(Date.now() + 2 * 86400000).toISOString().slice(0, 19),
expectedVersion: Number(currentArrival.version), remark: `${context.marker}-container-plan-arrival`,
}, { 'Idempotency-Key': `${context.marker}-container-plan-arrival`.slice(0, 128) })
if (!success(expected.response) || id(expected.data?.sourceContainerBookingId) !== bookingId
|| !nonBlank(expected.data?.estimatedWarehouseArrivalAt)) return null
}
const previewPath = query(`${PLANS}/container-preview`, { sourceContainerBookingId: bookingId })
const preview = await api(context, 'GET', '预览整柜待生成入仓计划清单', previewPath)
const groups = Array.isArray(preview.data?.groups) ? preview.data.groups : []
@@ -254,17 +254,19 @@ export async function run(context) {
const merchantToken = await loginMerchant(context, customerId)
if (!merchantToken) return
const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`)
if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return
const updateBody = {
vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64),
driverName: `五期验收司机-${context.marker.slice(-6)}`,
driverPhone: '19900000000', carrierName: '五期开发验收车队',
route: `${context.marker}-PHASE5-ROUTE`, estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(),
estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(),
}
const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路',
`${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody)
context.assert(MODULE, '排车编辑字段完整回显', updated.response,
updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate
&& updated.data?.driverName === updateBody.driverName && updated.data?.route === updateBody.route,
&& updated.data?.driverName === updateBody.driverName && updated.data?.route === beforeEdit.data.route,
trace(updated.response), OPTIONS)
if (!success(updated.response)) return