This commit is contained in:
2026-08-26 15:58:54 +08:00
parent 57a2f6ef28
commit fd1a81594a
21 changed files with 406 additions and 20 deletions
@@ -34,6 +34,11 @@ test('outbound-created query supports automated or manual carrier then reaches P
assert.match(nodes, /'DISCHARGED_FROM_VESSEL', 'PICKUP_READY'/)
assert.match(nodes, /const pickupReadyIndex = STANDARD_NODES\.indexOf\('PICKUP_READY'\)/)
assert.match(nodes, /createdResources\.phase4CarrierPickupReady/)
assert.match(nodes, /重复当前实际节点精确返回A0409/)
assert.match(nodes, /更早实际节点精确返回A0409/)
assert.match(nodes, /operation claim零残留/)
assert.match(nodes, /task、node、history零漂移/)
assert.match(nodes, /actualProgressSnapshot/)
assert.match(pda, /targetContainerNo[\s\S]*phase3Loading\?\.containerNo/)
assert.match(pda, /id\(carrierQuery\?\.trackingNo\) === targetContainerNo/)
assert.match(pda, /seen\.has\(targetContainerNo\)/)
@@ -20,8 +20,8 @@ const MARKER = 'T-PHASE4'
function ok(data, requestId = 'req-ok') {
return { http: 200, body: { code: '200', data, requestId }, requestId, traceId: 'trace-ok' }
}
function conflict(requestId) {
return { http: 200, body: { code: 'A0409', message: 'conflict', requestId }, requestId, traceId: 'trace-conflict' }
function conflict(requestId, message = 'conflict') {
return { http: 200, body: { code: 'A0409', message, requestId }, requestId, traceId: 'trace-conflict' }
}
function clone(value) { return structuredClone(value) }
@@ -121,7 +121,7 @@ test('missing phase3 outbound query is an explicit deployment blocker and perfor
function markerOwnedContext() {
let nextId = 9007199254740994000n
const calls = []; const steps = []; const idempotency = new Map()
const calls = []; const steps = []; const assertions = []; const idempotency = new Map()
const task = {
id: QUERY_ID, queryNo: `CSQ${QUERY_ID}`, customerId: CUSTOMER_ID, oceanBookingId: OCEAN_ID,
containerBookingId: CONTAINER_ID, carrierCode: 'ZIM', trackingType: 'CONTAINER_NO',
@@ -179,6 +179,11 @@ function markerOwnedContext() {
: conflict(`req-hash-${key}`)
}
if (Number(body.expectedVersion) !== Number(task.version)) return conflict(`req-version-${key}`)
if (body.actualAt != null
&& NODE_SEQUENCES[body.standardNode] <= NODE_SEQUENCES[task.latestActualNode]) {
return conflict(`req-monotonic-${key}`,
`人工实际节点必须严格晚于当前最新实际节点:${task.latestActualNode}`)
}
const providerEventId = `manual-${String(nextId)}`
nodes.push({
id: String(nextId++), providerCode: 'MANUAL', providerEventId, providerNodeCode: body.standardNode,
@@ -203,13 +208,16 @@ function markerOwnedContext() {
apiSuccess: (_module, _name, response) => {
assert.equal(response.http, 200); assert.equal(response.body.code, '200'); return response.body.data
},
assert: (_module, name, _response, condition, evidence) => assert.ok(condition, `${name}: ${evidence}`),
assert: (_module, name, response, condition, evidence, options) => {
assertions.push({ name, passed: Boolean(condition), evidence, options, requestId: response?.requestId || null })
assert.ok(condition, `${name}: ${evidence}`)
},
addStep: (_module, name, response, passed, evidence, options) => {
steps.push({ name, passed, evidence, options, requestId: response?.requestId || null })
assert.ok(passed, `${name}: ${evidence}`)
},
}
return { context, task, nodes, exceptions, histories, steps }
return { context, task, nodes, exceptions, histories, steps, assertions }
}
test('marker-owned chain covers idempotency conflicts, all actual nodes, exception DTO and empty-return stop', async () => {
@@ -218,11 +226,15 @@ test('marker-owned chain covers idempotency conflicts, all actual nodes, excepti
await run(fixture.context)
const posts = fixture.context.calls.filter((item) => item.method === 'POST')
assert.equal(posts.length, 14)
assert.equal(posts.length, 17)
assert.ok(posts.every((item) => item.path === `${BASE}/${QUERY_ID}/manual-nodes`))
assert.ok(posts.every((item) => item.headers['Idempotency-Key']))
assert.ok(posts.every((item) => Object.keys(item.body).length === 8))
assert.equal(fixture.steps.filter((item) => /A0409/.test(item.name)).length, 2)
assert.equal(fixture.steps.filter((item) => /A0409/.test(item.name)).length, 6)
assert.ok(fixture.assertions.some((item) => /重复当前实际节点A0409后task、node、history零漂移/.test(item.name)))
assert.ok(fixture.assertions.some((item) => /更早实际节点A0409后task、node、history零漂移/.test(item.name)))
assert.ok(fixture.steps.some((item) => /同级actual失败请求同键重试.*operation claim零残留/.test(item.name)))
assert.ok(fixture.steps.some((item) => /更早actual失败请求同键重试.*operation claim零残留/.test(item.name)))
assert.equal(fixture.task.status, 'STOPPED')
assert.equal(fixture.task.latestActualNode, 'EMPTY_RETURNED')
assert.equal(fixture.task.stopReason, '已还柜')
@@ -20,6 +20,11 @@ function validLongId(value) { return typeof value === 'string' && /^\d{16,}$/.te
function validNumericId(value) { return typeof value === 'string' && /^\d+$/.test(value) && value !== '0' }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function rejected(response) { return response?.http === 200 && ['A0409', '409'].includes(codeOf(response)) }
function monotonicRejected(response) {
return rejected(response)
&& String(response?.body?.message || '').includes('人工实际节点必须严格晚于当前最新实际节点')
&& Boolean(response?.requestId) && Boolean(response?.traceId)
}
function localIso(value) { return value.toISOString().slice(0, 19) }
export function markerOwnedCarrierQuery(context) {
@@ -82,6 +87,18 @@ function nodeSnapshot(detail) {
}))
}
function actualProgressSnapshot(detail) {
const task = detail?.query?.query
return {
version: Number(task?.version), latestActualNode: task?.latestActualNode,
latestActualAt: task?.latestActualAt, nodes: nodeSnapshot(detail),
histories: (Array.isArray(detail?.histories) ? detail.histories : []).map((item) => ({
id: id(item?.id), action: item?.action, fromVersion: item?.fromVersion,
toVersion: item?.toVersion, reason: item?.reason, createdAt: item?.createdAt,
})),
}
}
function assertDetail(context, response, detail, name) {
const nodes = Array.isArray(detail?.nodes) ? detail.nodes : []
const exceptions = Array.isArray(detail?.exceptions) ? detail.exceptions : []
@@ -189,7 +206,34 @@ export async function runUntilPickupReady(context) {
: Date.parse(`${frozenLatestActual}Z`)
const baseActual = (Number.isNaN(frozenLatestActualMs) ? Date.now() : frozenLatestActualMs) + 60 * 60_000
const pickupReadyIndex = STANDARD_NODES.indexOf('PICKUP_READY')
for (let index = 0; index <= pickupReadyIndex; index += 1) {
const frozenLatestNodeIndex = STANDARD_NODES.indexOf(detail?.query?.query?.latestActualNode)
context.assert(MODULE, '出仓自动实际节点作为人工补录单调起点', zeroDriftResponse,
frozenLatestNodeIndex >= 0 && frozenLatestNodeIndex < pickupReadyIndex,
`latest=${detail?.query?.query?.latestActualNode || 'missing'}`, OPTIONS)
const sameNodeSnapshot = actualProgressSnapshot(detail)
const sameNodeBody = manualNodePayload({ expectedVersion: version,
standardNode: detail.query.query.latestActualNode, observedAt: localIso(new Date()),
actualAt: localIso(new Date(baseActual)), marker: context.marker, index: frozenLatestNodeIndex + 1 })
const sameNodeResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, sameNodeBody,
context.token, { 'Idempotency-Key': `${context.marker}-carrier-same-actual`.slice(0, 128) })
context.addStep(MODULE, '重复当前实际节点精确返回A0409', sameNodeResponse, monotonicRejected(sameNodeResponse),
`HTTP=${sameNodeResponse.http}, code=${codeOf(sameNodeResponse)}, message=${sameNodeResponse.body?.message || 'missing'}`, OPTIONS)
const sameNodeReplay = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, sameNodeBody,
context.token, { 'Idempotency-Key': `${context.marker}-carrier-same-actual`.slice(0, 128) })
context.addStep(MODULE, '同级actual失败请求同键重试仍为单调A0409且operation claim零残留',
sameNodeReplay, monotonicRejected(sameNodeReplay),
`HTTP=${sameNodeReplay.http}, code=${codeOf(sameNodeReplay)}, message=${sameNodeReplay.body?.message || 'missing'}`,
OPTIONS)
const sameNodeRead = await context.request('GET', `${BASE}/${queryId}`)
detail = context.apiSuccess(MODULE, '重复当前实际节点后回查零漂移', sameNodeRead, '', OPTIONS)
context.assert(MODULE, '重复当前实际节点A0409后task、node、history零漂移', sameNodeRead,
JSON.stringify(actualProgressSnapshot(detail)) === JSON.stringify(sameNodeSnapshot),
`version=${detail?.query?.query?.version ?? 'missing'}, latest=${detail?.query?.query?.latestActualNode || 'missing'}`,
OPTIONS)
let earlierActualChecked = false
for (let index = frozenLatestNodeIndex + 1; index <= pickupReadyIndex; index += 1) {
const standardNode = STANDARD_NODES[index]
const actualAt = localIso(new Date(baseActual + index * 60_000))
const body = manualNodePayload({ expectedVersion: version, standardNode,
@@ -213,6 +257,32 @@ export async function runUntilPickupReady(context) {
`status=${updatedTask?.status || 'missing'}, latest=${updatedTask?.latestActualNode || 'none'}`, OPTIONS)
version = Number(updatedTask.version)
detail = updated
if (!earlierActualChecked) {
const progressedSnapshot = actualProgressSnapshot(detail)
const earlierNode = STANDARD_NODES[index - 1]
const earlierBody = manualNodePayload({ expectedVersion: version, standardNode: earlierNode,
observedAt: localIso(new Date()), actualAt: localIso(new Date(baseActual + (index + 1) * 60_000)),
marker: context.marker, index })
const earlierResponse = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, earlierBody,
context.token, { 'Idempotency-Key': `${context.marker}-carrier-earlier-actual`.slice(0, 128) })
context.addStep(MODULE, '补录更早实际节点精确返回A0409', earlierResponse, monotonicRejected(earlierResponse),
`HTTP=${earlierResponse.http}, code=${codeOf(earlierResponse)}, message=${earlierResponse.body?.message || 'missing'}`,
OPTIONS)
const earlierReplay = await context.request('POST', `${BASE}/${queryId}/manual-nodes`, earlierBody,
context.token, { 'Idempotency-Key': `${context.marker}-carrier-earlier-actual`.slice(0, 128) })
context.addStep(MODULE, '更早actual失败请求同键重试仍为单调A0409且operation claim零残留',
earlierReplay, monotonicRejected(earlierReplay),
`HTTP=${earlierReplay.http}, code=${codeOf(earlierReplay)}, message=${earlierReplay.body?.message || 'missing'}`,
OPTIONS)
const earlierRead = await context.request('GET', `${BASE}/${queryId}`)
detail = context.apiSuccess(MODULE, '更早实际节点冲突后回查零漂移', earlierRead, '', OPTIONS)
context.assert(MODULE, '更早实际节点A0409后task、node、history零漂移', earlierRead,
JSON.stringify(actualProgressSnapshot(detail)) === JSON.stringify(progressedSnapshot),
`version=${detail?.query?.query?.version ?? 'missing'}, latest=${detail?.query?.query?.latestActualNode || 'missing'}`,
OPTIONS)
earlierActualChecked = true
}
}
const pickupTask = detail.query.query
@@ -287,6 +357,6 @@ export async function run(context) {
const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS)
context.assert(MODULE, '历史只含业务动作且人工节点完整留痕', historyResponse,
Array.isArray(history) && history.every(validHistory)
&& history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length + 1,
&& history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length,
`history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS)
}
@@ -82,6 +82,27 @@ test('PDA module covers the container-number receiving contract and excludes out
assert.doesNotMatch(source, /awaitProducerCandidates/)
})
test('PDA receiving lifecycle is ordered from candidate claim through photos, mode-specific receipts, pallet and complete', () => {
const orderedTokens = [
"'查询当前客户待收柜号'", "'选择柜号并建立WAIT_SIGN会话'",
'uploadPhotos(context, token)', "'绑定车牌封签开柜三角色照片并签收'",
"`解析${inbound.destinationType}实物托盘`", 'receiveFba(context, token',
'receiveOverseas(context, token', "'回查柜内逐入仓码实收进度'",
"'完成当前柜并写入库存与卸柜事件'", "'完成后current返回404正常空态'",
]
let cursor = -1
for (const token of orderedTokens) {
const next = source.indexOf(token, cursor + 1)
assert.ok(next > cursor, `${orderedTokens[Math.max(0, orderedTokens.indexOf(token) - 1)]} -> ${token}`)
cursor = next
}
for (const token of [
'/current/receipts/bulk', '/current/receipts/carton',
"'FBA模式硬拒绝逐箱收货'", "'OVERSEAS模式硬拒绝批量收货'",
'PHOTO_ROLES.every', "finalStatus === 'COMPLETED'", 'completedContainers.push',
]) assert.ok(source.includes(token), token)
})
test('missing WAREHOUSE_PICKER overseas authority is a deployment blocker', async () => {
const calls = []; const gaps = []; const steps = []
const context = {