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 new file mode 100644 index 000000000..eb12fc1d1 --- /dev/null +++ b/scripts/admin-test-env-modules/completed-command-replay-contract.test.mjs @@ -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) + } +}) diff --git a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs index f00f230bd..8d4ebc037 100644 --- a/scripts/admin-test-env-modules/phase3-loading-finalize.mjs +++ b/scripts/admin-test-env-modules/phase3-loading-finalize.mjs @@ -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, 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 06b3e9e21..cb53399f0 100644 --- a/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs +++ b/scripts/admin-test-env-modules/phase4-tail-order-controls.mjs @@ -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 : [] diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 2285bebff..5245c94f2 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -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