const MODULE = 'inbound-hold' const OPTIONS = { scenario: 'inbound-hold-lifecycle', deploymentBlocker: true } const SMART = '/api/platform/v1/first-leg/smart-loading/inbound-candidates' function id(value) { return value == null ? '' : String(value) } function code(response) { return id(response?.body?.code) || 'missing' } function succeeded(response) { return response?.http === 200 && code(response) === '200' } function rows(page) { return Array.isArray(page?.records) ? page.records : [] } function historyRows(detail) { return Array.isArray(detail?.history) ? detail.history : [] } function historyFactsFrom(items) { return JSON.stringify(items.map((item) => ({ id: id(item?.id), action: item?.action || null, reason: item?.reason || null, evidenceFileId: id(item?.evidenceFileId) || null, operatorName: item?.operatorName || null, operatedAt: item?.operatedAt || null, }))) } function historyFacts(detail) { return historyFactsFrom(historyRows(detail)) } function channelGroup(channelCode) { const value = String(channelCode || '').toUpperCase() if (value.startsWith('EXX_')) return 'EXX' if (value.startsWith('MATSON_CLX_')) return 'MATSON_CLX' if (value.startsWith('MATSON_MAX_')) return 'MATSON_MAX' if (value.startsWith('HEDE_')) return 'HEDE' if (value.startsWith('OA_LA_') || value === 'OA_PARCEL') return 'OA_LA' if (value.startsWith('OA_CHICAGO_')) return 'OA_CHICAGO' if (value.startsWith('OA_HOUSTON_')) return 'OA_HOUSTON' if (value.startsWith('OA_SAVANNAH_')) return 'OA_SAVANNAH' if (value === 'OA_NEW_YORK_TRUCK') return value throw new Error(`unsupported smart-loading channel group: ${channelCode}`) } function evidence(response, extra = '') { return `HTTP=${response?.http ?? 'missing'}, code=${code(response)}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}${extra ? `, ${extra}` : ''}` } function expectCode(context, label, response, expected) { context.assert(MODULE, label, response, response?.http === 200 && code(response) === expected, evidence(response), OPTIONS) } function write(context, path, body, key) { return context.request('POST', path, body, context.token, { 'Idempotency-Key': key }) } function sameSnapshot(actual, expected) { return actual?.holdFlag === expected?.holdFlag && actual?.currentHoldReason === expected?.currentHoldReason && Number(actual?.version) === Number(expected?.version) && historyFacts(actual) === historyFacts(expected) } export async function run(context) { const fixture = context.createdResources.orders || {} const tenantId = id(fixture.tenantId) const orderId = id(fixture.orderId) const orderNo = id(fixture.orderNo) const inboundId = id(fixture.inboundId) const inboundCode = id(fixture.inboundCode) context.assert(MODULE, '复用本轮订单入库单', null, Boolean(tenantId && orderId && orderNo && inboundId && inboundCode), `tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, orderNo=${orderNo || 'missing'}, inboundId=${inboundId || 'missing'}, inboundCode=${inboundCode || 'missing'}`, OPTIONS) if (!tenantId || !orderId || !orderNo || !inboundId || !inboundCode) { throw new Error('orders fixture missing inbound hold identity') } const unifiedPath = `/api/platform/v1/orders/${encodeURIComponent(orderId)}?tenantId=${encodeURIComponent(tenantId)}` const initialUnifiedResponse = await context.request('GET', unifiedPath) const initialUnified = context.apiSuccess(MODULE, '扣货前读取统一订单', initialUnifiedResponse, evidence(initialUnifiedResponse), OPTIONS) const mainStatus = initialUnified?.summary?.mainStatus const channelCode = id(initialUnified?.summary?.channelCode) const warehouseId = id(initialUnified?.summary?.receivingWarehouseId || fixture.receivingWarehouseId) context.assert(MODULE, '统一订单提供候选筛选事实', initialUnifiedResponse, Boolean(mainStatus && channelCode && warehouseId), `mainStatus=${mainStatus || 'missing'}, channelCode=${channelCode || 'missing'}, warehouseId=${warehouseId || 'missing'}`, OPTIONS) if (!mainStatus || !channelCode || !warehouseId) throw new Error('unified order missing hold acceptance filters') const holdPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold?tenantId=${encodeURIComponent(tenantId)}` const initialHoldResponse = await context.request('GET', holdPath) const initialHold = context.apiSuccess(MODULE, '读取初始扣货快照', initialHoldResponse, evidence(initialHoldResponse), OPTIONS) context.assert(MODULE, '初始入库单允许扣货', initialHoldResponse, initialHold?.holdFlag === false && initialHold?.canHold === true && initialHold?.canRelease === false && historyRows(initialHold).length === 0, `holdFlag=${initialHold?.holdFlag}, canHold=${initialHold?.canHold}, canRelease=${initialHold?.canRelease}, history=${initialHold?.history?.length ?? 'missing'}`, OPTIONS) const initialVersion = Number(initialHold?.version) if (!Number.isInteger(initialVersion)) throw new Error('initial inbound hold version missing') const candidatePath = `${SMART}?channelGroupCodes=${encodeURIComponent(channelGroup(channelCode))}` + `&warehouseId=${encodeURIComponent(warehouseId)}&customerId=${encodeURIComponent(tenantId)}` + `&inboundCode=${encodeURIComponent(inboundCode)}&page=1&size=20` const beforeCandidatesResponse = await context.request('GET', candidatePath) const beforeCandidates = context.apiSuccess(MODULE, '扣货前查询智能装柜候选', beforeCandidatesResponse, evidence(beforeCandidatesResponse), OPTIONS) context.assert(MODULE, '扣货前入库单可进入智能装柜候选', beforeCandidatesResponse, rows(beforeCandidates).some((row) => id(row?.inboundId) === inboundId), `inboundId=${inboundId}, candidates=${rows(beforeCandidates).map((row) => id(row?.inboundId)).join(',') || 'empty'}`, OPTIONS) const foreignTenantId = (BigInt(tenantId) + 1n).toString() const foreignResponse = await context.request('GET', `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold?tenantId=${encodeURIComponent(foreignTenantId)}`) expectCode(context, '错误客户不能读取扣货快照', foreignResponse, 'A0404') const invalidCommands = [ ['扣货缺少幂等键返回A0400', context.request('POST', holdPath, { expectedVersion: initialVersion, reason: '缺少幂等键' }, context.token), 'A0400'], ['扣货空白原因返回A0422', write(context, holdPath, { expectedVersion: initialVersion, reason: ' ' }, `${context.marker}-hold-blank`), 'A0422'], ['扣货超长原因返回A0422', write(context, holdPath, { expectedVersion: initialVersion, reason: '测'.repeat(1001) }, `${context.marker}-hold-long-reason`), 'A0422'], ['扣货负版本返回A0422', write(context, holdPath, { expectedVersion: -1, reason: '负版本' }, `${context.marker}-hold-negative-version`), 'A0422'], ['扣货无效凭证ID返回A0422', write(context, holdPath, { expectedVersion: initialVersion, reason: '无效凭证', evidenceFileId: 0 }, `${context.marker}-hold-bad-evidence`), 'A0422'], ['错误客户不能执行扣货', write(context, `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold?tenantId=${encodeURIComponent(foreignTenantId)}`, { expectedVersion: initialVersion, reason: '跨客户扣货' }, `${context.marker}-hold-foreign`), 'A0404'], ] for (const [label, pending, expectedCode] of invalidCommands) { expectCode(context, label, await pending, expectedCode) } const afterInvalidResponse = await context.request('GET', holdPath) const afterInvalid = context.apiSuccess(MODULE, '回查非法命令后的初始快照', afterInvalidResponse, evidence(afterInvalidResponse), OPTIONS) context.assert(MODULE, '非法命令不改变版本、状态或历史', afterInvalidResponse, sameSnapshot(afterInvalid, initialHold), `version=${afterInvalid?.version}, history=${afterInvalid?.history?.length ?? 'missing'}`, OPTIONS) const evidenceBytes = new TextEncoder().encode(`%PDF-1.4\n${context.marker}\n%%EOF`) const evidencePath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold/evidence?tenantId=${encodeURIComponent(tenantId)}` const evidenceResponse = await context.requestMultipart('POST', evidencePath, { content: evidenceBytes, type: 'application/pdf', name: `${context.marker}-hold.pdf`, }) const evidenceFile = context.apiSuccess(MODULE, '上传本轮扣货关联凭证', evidenceResponse, evidence(evidenceResponse), OPTIONS) const evidenceFileId = id(evidenceFile?.fileId) context.assert(MODULE, '扣货凭证返回安全文件元数据和字符串长整型ID', evidenceResponse, Boolean(evidenceFileId) && typeof evidenceFile?.fileId === 'string' && evidenceFile?.fileName === `${context.marker}-hold.pdf` && evidenceFile?.contentType === 'application/pdf' && Number(evidenceFile?.sizeBytes) === evidenceBytes.length, `fileId=${evidenceFileId || 'missing'}, fileName=${evidenceFile?.fileName || 'missing'}, contentType=${evidenceFile?.contentType || 'missing'}`, OPTIONS) if (!evidenceFileId) throw new Error('hold evidence upload missing fileId') const evidenceDownloadPath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold/evidence/${encodeURIComponent(evidenceFileId)}/download?tenantId=${encodeURIComponent(tenantId)}` const unreferencedDownload = await context.requestBinary('GET', evidenceDownloadPath) expectCode(context, '尚未进入不可变历史的凭证禁止下载', unreferencedDownload, 'A0404') const reason = `${context.marker} 财务复核扣货`.slice(0, 1000) const holdKey = `${context.marker}-inbound-hold`.slice(0, 128) const holdBody = { expectedVersion: initialVersion, reason, evidenceFileId } const holdResponse = await write(context, holdPath, holdBody, holdKey) const held = context.apiSuccess(MODULE, '手工扣货入库单', holdResponse, evidence(holdResponse), OPTIONS) context.assert(MODULE, '扣货写入当前快照和不可变历史', holdResponse, held?.holdFlag === true && held?.currentHoldReason === reason && held?.canHold === false && held?.canRelease === true && Number(held?.version) === initialVersion + 1 && held?.history?.[0]?.action === 'HOLD' && held?.history?.[0]?.reason === reason && id(held?.history?.[0]?.evidenceFileId) === evidenceFileId, `holdFlag=${held?.holdFlag}, version=${held?.version}, history=${held?.history?.length ?? 'missing'}`, OPTIONS) const heldVersion = Number(held?.version) const heldHistorySize = held?.history?.length const heldHistoryFacts = historyFacts(held) const evidenceDownload = await context.requestBinary('GET', evidenceDownloadPath) context.assert(MODULE, '进入不可变历史后的扣货凭证可下载且内容一致', evidenceDownload, evidenceDownload?.http === 200 && evidenceDownload?.body == null && evidenceDownload?.contentType?.includes('application/pdf') && Buffer.from(evidenceDownload?.bytes || []).equals(Buffer.from(evidenceBytes)), `HTTP=${evidenceDownload?.http ?? 'missing'}, contentType=${evidenceDownload?.contentType || 'missing'}, bytes=${evidenceDownload?.bytes?.length ?? 'missing'}`, OPTIONS) const duplicateStateResponse = await write(context, holdPath, { expectedVersion: heldVersion, reason: `${context.marker} 重复状态` }, `${context.marker}-inbound-hold-again`.slice(0, 128)) expectCode(context, '已扣货时使用新键再次扣货返回A0409', duplicateStateResponse, 'A0409') const heldAfterDuplicateResponse = await context.request('GET', holdPath) const heldAfterDuplicate = context.apiSuccess(MODULE, '回查重复扣货后的当前快照', heldAfterDuplicateResponse, evidence(heldAfterDuplicateResponse), OPTIONS) context.assert(MODULE, '重复扣货不改变版本、原因或历史', heldAfterDuplicateResponse, sameSnapshot(heldAfterDuplicate, held), `version=${heldAfterDuplicate?.version}, history=${heldAfterDuplicate?.history?.length ?? 'missing'}`, OPTIONS) const replayResponse = await write(context, holdPath, holdBody, holdKey) const replayed = context.apiSuccess(MODULE, '扣货请求同键同载荷重放', replayResponse, evidence(replayResponse), OPTIONS) context.assert(MODULE, '扣货幂等重放不增加版本或历史', replayResponse, Number(replayed?.version) === heldVersion && replayed?.history?.length === heldHistorySize && historyFacts(replayed) === heldHistoryFacts, `version=${replayed?.version}, history=${replayed?.history?.length ?? 'missing'}`, OPTIONS) const conflictResponse = await write(context, holdPath, { ...holdBody, reason: `${reason}-冲突` }, holdKey) expectCode(context, '扣货同键异载荷返回A0409', conflictResponse, 'A0409') const heldAfterConflictResponse = await context.request('GET', holdPath) const heldAfterConflict = context.apiSuccess(MODULE, '回查扣货冲突后的当前快照', heldAfterConflictResponse, evidence(heldAfterConflictResponse), OPTIONS) context.assert(MODULE, '扣货冲突后版本、原因和历史零漂移', heldAfterConflictResponse, heldAfterConflict?.holdFlag === true && heldAfterConflict?.currentHoldReason === reason && Number(heldAfterConflict?.version) === heldVersion && historyFacts(heldAfterConflict) === heldHistoryFacts, `version=${heldAfterConflict?.version}, history=${heldAfterConflict?.history?.length ?? 'missing'}`, OPTIONS) const heldUnifiedResponse = await context.request('GET', unifiedPath) const heldUnified = context.apiSuccess(MODULE, '扣货后读取统一订单', heldUnifiedResponse, evidence(heldUnifiedResponse), OPTIONS) context.assert(MODULE, '统一订单同步展示扣货且主状态不变', heldUnifiedResponse, heldUnified?.summary?.holdFlag === true && heldUnified?.summary?.currentHoldReason === reason && heldUnified?.summary?.mainStatus === mainStatus, `holdFlag=${heldUnified?.summary?.holdFlag}, mainStatus=${heldUnified?.summary?.mainStatus}`, OPTIONS) const heldUnifiedListResponse = await context.request('GET', `/api/platform/v1/orders?tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}&page=1&size=20`) const heldUnifiedPage = context.apiSuccess(MODULE, '扣货后读取统一订单列表投影', heldUnifiedListResponse, evidence(heldUnifiedListResponse), OPTIONS) const heldUnifiedRow = rows(heldUnifiedPage).find((row) => id(row?.orderId) === orderId) context.assert(MODULE, '统一订单列表同步展示扣货字段', heldUnifiedListResponse, heldUnifiedRow?.holdFlag === true && heldUnifiedRow?.currentHoldReason === reason && heldUnifiedRow?.mainStatus === mainStatus, `orderId=${orderId}, holdFlag=${heldUnifiedRow?.holdFlag}, mainStatus=${heldUnifiedRow?.mainStatus || 'missing'}`, OPTIONS) const heldCandidatesResponse = await context.request('GET', candidatePath) const heldCandidates = context.apiSuccess(MODULE, '扣货后查询智能装柜候选', heldCandidatesResponse, evidence(heldCandidatesResponse), OPTIONS) context.assert(MODULE, '扣货入库单从智能装柜候选排除', heldCandidatesResponse, !rows(heldCandidates).some((row) => id(row?.inboundId) === inboundId), `inboundId=${inboundId}, candidates=${rows(heldCandidates).map((row) => id(row?.inboundId)).join(',') || 'empty'}`, OPTIONS) const releasePath = `/api/platform/inbounds/${encodeURIComponent(inboundId)}/hold/release?tenantId=${encodeURIComponent(tenantId)}` const releaseReason = `${context.marker} 财务复核完成`.slice(0, 1000) const releaseKey = `${context.marker}-inbound-release`.slice(0, 128) const releaseBody = { expectedVersion: heldVersion, reason: releaseReason } const releaseResponse = await write(context, releasePath, releaseBody, releaseKey) const released = context.apiSuccess(MODULE, '解除入库单扣货', releaseResponse, evidence(releaseResponse), OPTIONS) context.assert(MODULE, '解除写入当前快照和不可变历史', releaseResponse, released?.holdFlag === false && released?.currentHoldReason == null && released?.canHold === true && released?.canRelease === false && Number(released?.version) === heldVersion + 1 && released?.history?.[0]?.action === 'RELEASE' && released?.history?.[0]?.reason === releaseReason && released?.history?.some?.((item) => item.action === 'HOLD' && item.reason === reason), `holdFlag=${released?.holdFlag}, version=${released?.version}, history=${released?.history?.length ?? 'missing'}`, OPTIONS) const releasedVersion = Number(released?.version) const releasedHistorySize = released?.history?.length const releasedHistoryFacts = historyFacts(released) context.assert(MODULE, '解除后既有扣货历史身份和内容保持不可变', releaseResponse, releasedHistorySize === heldHistorySize + 1 && historyFactsFrom(historyRows(released).slice(1)) === heldHistoryFacts, `before=${heldHistorySize}, after=${releasedHistorySize}`, OPTIONS) const duplicateReleaseResponse = await write(context, releasePath, { expectedVersion: releasedVersion, reason: `${context.marker} 重复解除` }, `${context.marker}-inbound-release-again`.slice(0, 128)) expectCode(context, '未扣货时使用新键再次解除返回A0409', duplicateReleaseResponse, 'A0409') const releasedAfterDuplicateResponse = await context.request('GET', holdPath) const releasedAfterDuplicate = context.apiSuccess(MODULE, '回查重复解除后的当前快照', releasedAfterDuplicateResponse, evidence(releasedAfterDuplicateResponse), OPTIONS) context.assert(MODULE, '重复解除不改变版本、状态或历史', releasedAfterDuplicateResponse, sameSnapshot(releasedAfterDuplicate, released), `version=${releasedAfterDuplicate?.version}, history=${releasedAfterDuplicate?.history?.length ?? 'missing'}`, OPTIONS) const releaseReplayResponse = await write(context, releasePath, releaseBody, releaseKey) const releaseReplay = context.apiSuccess(MODULE, '解除请求同键同载荷重放', releaseReplayResponse, evidence(releaseReplayResponse), OPTIONS) context.assert(MODULE, '解除幂等重放不增加版本或历史', releaseReplayResponse, Number(releaseReplay?.version) === releasedVersion && releaseReplay?.history?.length === releasedHistorySize && historyFacts(releaseReplay) === releasedHistoryFacts, `version=${releaseReplay?.version}, history=${releaseReplay?.history?.length ?? 'missing'}`, OPTIONS) const staleResponse = await write(context, holdPath, { expectedVersion: heldVersion, reason: `${context.marker} 过期版本` }, `${context.marker}-inbound-hold-stale`.slice(0, 128)) expectCode(context, '过期版本再次扣货返回A0409', staleResponse, 'A0409') const finalHoldResponse = await context.request('GET', holdPath) const finalHold = context.apiSuccess(MODULE, '回查过期版本冲突后的扣货快照', finalHoldResponse, evidence(finalHoldResponse), OPTIONS) context.assert(MODULE, '过期版本冲突后解除快照和不可变历史零漂移', finalHoldResponse, finalHold?.holdFlag === false && finalHold?.currentHoldReason == null && Number(finalHold?.version) === releasedVersion && historyFacts(finalHold) === releasedHistoryFacts, `version=${finalHold?.version}, history=${finalHold?.history?.length ?? 'missing'}`, OPTIONS) const finalUnifiedResponse = await context.request('GET', unifiedPath) const finalUnified = context.apiSuccess(MODULE, '解除后读取统一订单', finalUnifiedResponse, evidence(finalUnifiedResponse), OPTIONS) context.assert(MODULE, '解除后统一订单恢复且主状态不变', finalUnifiedResponse, finalUnified?.summary?.holdFlag === false && finalUnified?.summary?.currentHoldReason == null && finalUnified?.summary?.mainStatus === mainStatus, `holdFlag=${finalUnified?.summary?.holdFlag}, mainStatus=${finalUnified?.summary?.mainStatus}`, OPTIONS) const finalUnifiedListResponse = await context.request('GET', `/api/platform/v1/orders?tenantId=${encodeURIComponent(tenantId)}&orderNo=${encodeURIComponent(orderNo)}&page=1&size=20`) const finalUnifiedPage = context.apiSuccess(MODULE, '解除后读取统一订单列表投影', finalUnifiedListResponse, evidence(finalUnifiedListResponse), OPTIONS) const finalUnifiedRow = rows(finalUnifiedPage).find((row) => id(row?.orderId) === orderId) context.assert(MODULE, '统一订单列表同步恢复扣货字段', finalUnifiedListResponse, finalUnifiedRow?.holdFlag === false && finalUnifiedRow?.currentHoldReason == null && finalUnifiedRow?.mainStatus === mainStatus, `orderId=${orderId}, holdFlag=${finalUnifiedRow?.holdFlag}, mainStatus=${finalUnifiedRow?.mainStatus || 'missing'}`, OPTIONS) const finalCandidatesResponse = await context.request('GET', candidatePath) const finalCandidates = context.apiSuccess(MODULE, '解除后查询智能装柜候选', finalCandidatesResponse, evidence(finalCandidatesResponse), OPTIONS) context.assert(MODULE, '解除后入库单恢复智能装柜候选', finalCandidatesResponse, rows(finalCandidates).some((row) => id(row?.inboundId) === inboundId), `inboundId=${inboundId}, candidates=${rows(finalCandidates).map((row) => id(row?.inboundId)).join(',') || 'empty'}`, OPTIONS) const concurrentReasons = [`${context.marker} 并发扣货A`, `${context.marker} 并发扣货B`] const concurrentResponses = await Promise.all(concurrentReasons.map((concurrentReason, index) => write(context, holdPath, { expectedVersion: releasedVersion, reason: concurrentReason }, `${context.marker}-inbound-hold-race-${index}`.slice(0, 128)))) const concurrentCodes = concurrentResponses.map(code).sort() context.assert(MODULE, '同版本并发扣货恰好一个成功一个冲突', null, JSON.stringify(concurrentCodes) === JSON.stringify(['200', 'A0409']), `codes=${concurrentCodes.join(',')}`, OPTIONS) const concurrentSnapshotResponse = await context.request('GET', holdPath) const concurrentSnapshot = context.apiSuccess(MODULE, '回查并发扣货后的唯一快照', concurrentSnapshotResponse, evidence(concurrentSnapshotResponse), OPTIONS) context.assert(MODULE, '并发扣货只递增一次版本并新增一条历史', concurrentSnapshotResponse, concurrentSnapshot?.holdFlag === true && concurrentReasons.includes(concurrentSnapshot?.currentHoldReason) && Number(concurrentSnapshot?.version) === releasedVersion + 1 && historyRows(concurrentSnapshot).length === releasedHistorySize + 1, `version=${concurrentSnapshot?.version}, history=${concurrentSnapshot?.history?.length ?? 'missing'}, reason=${concurrentSnapshot?.currentHoldReason || 'missing'}`, OPTIONS) const cleanupResponse = await write(context, releasePath, { expectedVersion: Number(concurrentSnapshot?.version), reason: `${context.marker} 并发验收恢复` }, `${context.marker}-inbound-race-cleanup`.slice(0, 128)) const cleanup = context.apiSuccess(MODULE, '并发验收后解除扣货恢复订单', cleanupResponse, evidence(cleanupResponse), OPTIONS) context.assert(MODULE, '并发验收最终恢复未扣货且历史完整', cleanupResponse, cleanup?.holdFlag === false && cleanup?.currentHoldReason == null && Number(cleanup?.version) === releasedVersion + 2 && historyRows(cleanup).length === releasedHistorySize + 2, `version=${cleanup?.version}, history=${cleanup?.history?.length ?? 'missing'}`, OPTIONS) }