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 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 }) } export async function run(context) { const fixture = context.createdResources.orders || {} const tenantId = id(fixture.tenantId) const orderId = id(fixture.orderId) const inboundId = id(fixture.inboundId) const inboundCode = id(fixture.inboundCode) context.assert(MODULE, '复用本轮订单入库单', null, Boolean(tenantId && orderId && inboundId && inboundCode), `tenantId=${tenantId || 'missing'}, orderId=${orderId || 'missing'}, inboundId=${inboundId || 'missing'}, inboundCode=${inboundCode || 'missing'}`, OPTIONS) if (!tenantId || !orderId || !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 && Array.isArray(initialHold?.history), `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 reason = `${context.marker} 财务复核扣货`.slice(0, 1000) const holdKey = `${context.marker}-inbound-hold`.slice(0, 128) const holdBody = { expectedVersion: initialVersion, reason } 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, `holdFlag=${held?.holdFlag}, version=${held?.version}, history=${held?.history?.length ?? 'missing'}`, OPTIONS) const heldVersion = Number(held?.version) const heldHistorySize = held?.history?.length 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, `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 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 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 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, `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 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 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) }