import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { basename } from 'node:path' const PRICE_BOOK_PATH = '/api/platform/v1/fba-pricing/carrier-rates/price-books' const DOMESTIC_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=DOMESTIC' const OVERSEAS_WAREHOUSE_PATH = '/api/platform/v1/warehouse-admin?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS' const EXPECTED_CHANNEL_CODES = [ 'MATSON_CLX_TRUCK', 'MATSON_MAX_TRUCK', 'EXX_TRUCK', 'HEDE_TRUCK', 'EXX_RAIL_NY', 'OA_LA_TRUCK', 'OA_LA_FULL_TRUCK', 'OA_CHICAGO_RAIL', 'OA_HOUSTON_TRUCK', 'OA_NEW_YORK_TRUCK', 'OA_SAVANNAH_TRUCK', 'MATSON_CLX_PARCEL', 'MATSON_MAX_PARCEL', 'HEDE_PARCEL', 'OA_PARCEL', ] const MAPPING_ISSUE_CODES = new Set([ 'ORIGIN_MAPPING_REQUIRED', 'ORIGIN_WAREHOUSE_INVALID', 'OVERSEAS_MAPPING_REQUIRED', 'OVERSEAS_WAREHOUSE_INVALID', 'WAREHOUSE_VALIDATION_FAILED', 'DUPLICATE_ORIGIN_MAPPING', 'DUPLICATE_OVERSEAS_MAPPING', 'UNUSED_ORIGIN_MAPPING', 'UNUSED_OVERSEAS_MAPPING', 'DUPLICATE_WAREHOUSE_MAPPING', 'DUPLICATE_RATE', ]) function sha256(bytes) { return createHash('sha256').update(bytes).digest('hex') } function normalize(value) { return String(value || '').trim().toUpperCase() } function validId(value) { return typeof value === 'string' && /^[1-9][0-9]*$/.test(value) } function finishScenario(context, module, scenario, start) { const steps = context.steps.slice(start) context.scenarios.push({ name: scenario, module, success: steps.every((step) => step.passed), deploymentBlocked: steps.some((step) => step.deploymentBlocker), passed: steps.filter((step) => step.passed).length, failed: steps.filter((step) => !step.passed).length, }) } async function uploadWorkbook(context, bytes, fileName) { const form = new FormData() form.append('file', new Blob([bytes], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }), fileName) try { const response = await fetch(`${context.options.gateway}${PRICE_BOOK_PATH}`, { method: 'POST', headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` }, body: form, signal: AbortSignal.timeout(120_000), }) const content = await response.text() let payload = null try { payload = content ? JSON.parse(content) : null } catch { /* Raw response is diagnostic evidence. */ } return { method: 'POST', path: PRICE_BOOK_PATH, http: response.status, body: payload, raw: payload === null ? content.slice(0, 500) : null, requestId: payload?.requestId || response.headers.get('x-request-id'), traceId: payload?.traceId || response.headers.get('x-trace-id'), } } catch (error) { return { method: 'POST', path: PRICE_BOOK_PATH, http: 0, body: null, raw: error instanceof Error ? error.message : String(error), requestId: null, traceId: null, } } } function buildMappings(detail, warehouses) { const domestic = warehouses.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'DOMESTIC') const overseas = warehouses.filter((warehouse) => warehouse.status === 'ACTIVE' && warehouse.warehouseType === 'OVERSEAS') const originSources = new Map() const overseasSources = new Map() for (const rate of detail.rates || []) { const originCode = String(rate.originWarehouseCode || '').trim() if (originCode && !originSources.has(normalize(originCode))) originSources.set(normalize(originCode), originCode) if (rate.destinationType === 'OVERSEAS' && String(rate.destinationName || '').trim()) { const sourceName = String(rate.destinationName).trim() const key = normalize(sourceName) if (!overseasSources.has(key)) overseasSources.set(key, { sourceName, destinationCode: String(rate.destinationCode || '').trim(), }) } } const originMappings = [] const missingOrigins = [] for (const sourceCode of originSources.values()) { const matches = domestic.filter((warehouse) => normalize(warehouse.warehouseCode) === normalize(sourceCode)) if (matches.length === 1) originMappings.push({ originWarehouseCode: sourceCode, warehouseId: String(matches[0].id) }) else missingOrigins.push(`${sourceCode}:${matches.length}`) } const overseasMappings = [] const missingOverseas = [] for (const source of overseasSources.values()) { const matches = overseas.filter((warehouse) => ( source.destinationCode ? normalize(warehouse.warehouseCode) === normalize(source.destinationCode) : normalize(warehouse.warehouseName) === normalize(source.sourceName) )) if (matches.length === 1) overseasMappings.push({ sourceDestinationName: source.sourceName, warehouseId: String(matches[0].id) }) else missingOverseas.push(`${source.sourceName}/${source.destinationCode || '-'}:${matches.length}`) } return { originMappings, overseasMappings, missingOrigins, missingOverseas, originSources, overseasSources } } export async function run(context) { const module = 'pricing' const scenario = 'price-book-lifecycle' const start = context.steps.length const blocker = { scenario, deploymentBlocker: true } const listBeforeResponse = await context.request('GET', PRICE_BOOK_PATH) const versionsBefore = context.apiSuccess(module, '查询发布前船期价格版本', listBeforeResponse, '', blocker) const activeBefore = Array.isArray(versionsBefore) ? versionsBefore.filter((version) => version.status === 'ACTIVE') : [] context.assert(module, '发布前最多一份生效版本', listBeforeResponse, Array.isArray(versionsBefore) && activeBefore.length <= 1, `versions=${Array.isArray(versionsBefore) ? versionsBefore.length : 'n/a'}, active=${activeBefore.length}`, blocker) const sourcePath = process.env.ADMIN_TEST_PRICE_BOOK_FILE let sourceBytes let sourceName if (sourcePath) { try { sourceBytes = await readFile(sourcePath) sourceName = basename(sourcePath) } catch (error) { context.addStep(module, '读取显式船期价格测试文件', null, false, `path=${sourcePath}, error=${error instanceof Error ? error.message : String(error)}`, blocker) finishScenario(context, module, scenario, start) return } } else { const activeFixture = activeBefore[0] const sourceResponse = activeFixture?.id ? await context.requestBinary('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(activeFixture.id)}/source`) : null const zipMagic = sourceResponse?.bytes?.[0] === 0x50 && sourceResponse?.bytes?.[1] === 0x4b const fixtureReady = sourceResponse?.http === 200 && sourceResponse.bytes?.length > 0 && zipMagic context.addStep(module, '复用当前生效价格源作为本轮隔离上传夹具', sourceResponse, fixtureReady, `activeVersionId=${activeFixture?.id || 'missing'}, bytes=${sourceResponse?.bytes?.length || 0}, zipMagic=${zipMagic}`, blocker) if (!fixtureReady) { finishScenario(context, module, scenario, start) return } sourceBytes = sourceResponse.bytes sourceName = activeFixture.originalFileName || `price-book-${activeFixture.id}.xlsx` } const sourceHash = sha256(sourceBytes) const uploadName = `${context.marker}-${sourceName}` context.addStep(module, '船期价格测试文件已隔离', null, sourceBytes.length > 0 && uploadName.includes(context.marker) && uploadName.toLowerCase().endsWith('.xlsx'), `fileName=${uploadName}, bytes=${sourceBytes.length}, sha256=${sourceHash}`, blocker) const uploadResponse = await uploadWorkbook(context, sourceBytes, uploadName) const uploaded = context.apiSuccess(module, '上传并解析船期价格表', uploadResponse, `fileName=${uploadName}, bytes=${sourceBytes.length}, sha256=${sourceHash}`, blocker) if (!uploaded?.version) { finishScenario(context, module, scenario, start) return } const versionId = String(uploaded.version.id || '') context.createdResources.priceBookVersion = { versionId, marker: context.marker, sourceSha256: sourceHash } context.assert(module, '上传返回隔离的待确认版本', uploadResponse, validId(versionId) && uploaded.version.status === 'PENDING_CONFIRMATION' && uploaded.version.originalFileName === uploadName && uploaded.version.sourceSha256 === sourceHash, `versionId=${versionId}, status=${uploaded.version.status}, fileName=${uploaded.version.originalFileName}, shaMatched=${uploaded.version.sourceSha256 === sourceHash}`, blocker) const channels = Array.isArray(uploaded.channels) ? uploaded.channels : [] const rates = Array.isArray(uploaded.rates) ? uploaded.rates : [] const issues = Array.isArray(uploaded.issues) ? uploaded.issues : [] const channelCodes = channels.map((channel) => String(channel.channelCode)) const channelSet = new Set(channelCodes) const expectedChannels = EXPECTED_CHANNEL_CODES.every((code) => channelSet.has(code)) context.assert(module, '解析出15个固定渠道且编码唯一', uploadResponse, channels.length === EXPECTED_CHANNEL_CODES.length && channelSet.size === channels.length && expectedChannels && Number(uploaded.version.channelCount) === channels.length, `channelCount=${channels.length}, unique=${channelSet.size}, missing=${EXPECTED_CHANNEL_CODES.filter((code) => !channelSet.has(code)).join(',') || 'none'}`, blocker) const ratesValid = rates.length > 0 && rates.every((rate) => ( channelSet.has(String(rate.channelCode)) && ['FBA_WAREHOUSE_RATE', 'REGION_WEIGHT_TIER'].includes(String(rate.priceModel)) && String(rate.originWarehouseCode || '').trim() && ['KG', 'CBM'].includes(String(rate.chargeUnit)) && Number(rate.unitPrice) > 0 && /^[A-Z]{3}$/.test(String(rate.currency || '')) && String(rate.sourceSheetName || '').trim() && Number(rate.sourceRowNumber) >= 1 )) context.assert(module, '解析价格行完整且可追溯到 Excel', uploadResponse, ratesValid && Number(uploaded.version.rateCount) === rates.length, `rateCount=${rates.length}, declared=${uploaded.version.rateCount}, valid=${ratesValid}`, blocker) const blockingIssues = issues.filter((issue) => issue.severity === 'BLOCKING') const unexpectedBlocking = blockingIssues.filter((issue) => !MAPPING_ISSUE_CODES.has(issue.issueCode)) const infrastructureIssue = issues.find((issue) => issue.issueCode === 'UPLOAD_OR_PARSE_FAILED' || /SQLSyntaxError|bad SQL grammar|Service Unavailable/i.test(String(issue.message || ''))) context.assert(module, '解析阻断仅包含可确认的仓库映射问题', uploadResponse, !infrastructureIssue && unexpectedBlocking.length === 0 && Number(uploaded.version.blockingErrorCount) === blockingIssues.length, `blocking=${blockingIssues.length}, unexpected=${[...new Set(unexpectedBlocking.map((issue) => issue.issueCode))].join(',') || 'none'}, infrastructure=${infrastructureIssue?.issueCode || 'none'}`, blocker) const listPendingResponse = await context.request('GET', PRICE_BOOK_PATH) const versionsPending = context.apiSuccess(module, '上传后回查价格版本列表', listPendingResponse, '', blocker) const pendingEntry = versionsPending?.find?.((version) => String(version.id) === versionId) const activeAfterUpload = versionsPending?.filter?.((version) => version.status === 'ACTIVE') || [] context.assert(module, '上传只生成待确认版本且不替换当前生效版本', listPendingResponse, pendingEntry?.status === 'PENDING_CONFIRMATION' && activeAfterUpload.length === activeBefore.length && activeBefore.every((before) => activeAfterUpload.some((after) => String(after.id) === String(before.id))), `pending=${pendingEntry?.status || 'missing'}, activeBefore=${activeBefore.map((item) => item.id).join(',') || 'none'}, activeAfterUpload=${activeAfterUpload.map((item) => item.id).join(',') || 'none'}`, blocker) const detailResponse = await context.request('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}`) const detail = context.apiSuccess(module, '查询待确认版本渠道和价格预览', detailResponse, '', blocker) context.assert(module, '详情与上传解析结果一致', detailResponse, String(detail?.version?.id) === versionId && detail?.version?.status === 'PENDING_CONFIRMATION' && detail?.channels?.length === channels.length && detail?.rates?.length === rates.length && detail?.issues?.length === issues.length, `versionId=${detail?.version?.id || 'missing'}, channels=${detail?.channels?.length ?? 'n/a'}, rates=${detail?.rates?.length ?? 'n/a'}, issues=${detail?.issues?.length ?? 'n/a'}`, blocker) const sourceResponse = await context.requestBinary('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}/source`) const downloadedHash = sourceResponse.bytes?.length ? sha256(sourceResponse.bytes) : '' const zipMagic = sourceResponse.bytes?.[0] === 0x50 && sourceResponse.bytes?.[1] === 0x4b context.addStep(module, '下载原始 XLSX 且内容散列一致', sourceResponse, sourceResponse.http === 200 && sourceResponse.bytes?.length === sourceBytes.length && zipMagic && downloadedHash === sourceHash && !sourceResponse.contentType.toLowerCase().includes('json'), `bytes=${sourceResponse.bytes?.length ?? 0}, contentType=${sourceResponse.contentType || 'missing'}, zipMagic=${zipMagic}, sha256=${downloadedHash}`, blocker) if (!detail || unexpectedBlocking.length > 0 || infrastructureIssue) { finishScenario(context, module, scenario, start) return } const domesticResponse = await context.request('GET', DOMESTIC_WAREHOUSE_PATH) const domesticPage = context.apiSuccess(module, '查询启用的平台公共国内仓', domesticResponse, '', blocker) const overseasResponse = await context.request('GET', OVERSEAS_WAREHOUSE_PATH) const overseasPage = context.apiSuccess(module, '查询启用的平台公共海外仓', overseasResponse, '', blocker) const warehouses = [ ...(Array.isArray(domesticPage?.records) ? domesticPage.records : []), ...(Array.isArray(overseasPage?.records) ? overseasPage.records : []), ] const mappings = buildMappings(detail, warehouses) const mappingsComplete = mappings.originSources.size > 0 && mappings.originMappings.length === mappings.originSources.size && mappings.overseasMappings.length === mappings.overseasSources.size && mappings.missingOrigins.length === 0 && mappings.missingOverseas.length === 0 context.assert(module, '国内起运仓和目的海外仓均精确映射', overseasResponse, mappingsComplete, `origins=${mappings.originMappings.map((item) => `${item.originWarehouseCode}->${item.warehouseId}`).join(',') || 'none'}, overseas=${mappings.overseasMappings.map((item) => `${item.sourceDestinationName}->${item.warehouseId}`).join(',') || 'none'}, missing=${[...mappings.missingOrigins, ...mappings.missingOverseas].join(',') || 'none'}`, blocker) if (!mappingsComplete) { finishScenario(context, module, scenario, start) return } const publishResponse = await context.request('POST', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}/publish`, { originMappings: mappings.originMappings, overseasMappings: mappings.overseasMappings, }) const published = context.apiSuccess(module, '确认仓库映射并立即生效', publishResponse, `versionId=${versionId}, originMappings=${mappings.originMappings.length}, overseasMappings=${mappings.overseasMappings.length}`, blocker) context.assert(module, '发布后价格版本为 ACTIVE 且无阻断', publishResponse, published?.version?.status === 'ACTIVE' && Number(published?.version?.blockingErrorCount) === 0 && Boolean(published?.version?.publishedAt) && published?.channels?.length === channels.length && published?.rates?.length === rates.length && !(published?.issues || []).some((issue) => issue.severity === 'BLOCKING'), `status=${published?.version?.status || 'missing'}, blocking=${published?.version?.blockingErrorCount ?? 'n/a'}, publishedAt=${published?.version?.publishedAt || 'missing'}, channels=${published?.channels?.length ?? 'n/a'}, rates=${published?.rates?.length ?? 'n/a'}`, blocker) const listAfterResponse = await context.request('GET', PRICE_BOOK_PATH) const versionsAfter = context.apiSuccess(module, '发布后回查当前生效和历史版本', listAfterResponse, '', blocker) const activeAfter = versionsAfter?.filter?.((version) => version.status === 'ACTIVE') || [] const oldStatusesCorrect = activeBefore.every((before) => { if (String(before.id) === versionId) return true const after = versionsAfter?.find?.((candidate) => String(candidate.id) === String(before.id)) return after?.status === 'SUPERSEDED' && Boolean(after.supersededAt) }) context.assert(module, '始终只有一份当前生效版本', listAfterResponse, activeAfter.length === 1 && String(activeAfter[0]?.id) === versionId, `active=${activeAfter.map((item) => item.id).join(',') || 'none'}`, blocker) context.assert(module, '上一生效版本已保留为历史版本', listAfterResponse, oldStatusesCorrect, `previous=${activeBefore.map((item) => item.id).join(',') || 'none'}, superseded=${oldStatusesCorrect}`, blocker) const activeDetailResponse = await context.request('GET', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}`) const activeDetail = context.apiSuccess(module, '查询当前生效价格版本详情', activeDetailResponse, '', blocker) context.assert(module, '当前生效版本预览数据完整', activeDetailResponse, activeDetail?.version?.status === 'ACTIVE' && activeDetail?.channels?.length === channels.length && activeDetail?.rates?.length === rates.length, `status=${activeDetail?.version?.status || 'missing'}, channels=${activeDetail?.channels?.length ?? 'n/a'}, rates=${activeDetail?.rates?.length ?? 'n/a'}`, blocker) const repeatedPublishResponse = await context.request('POST', `${PRICE_BOOK_PATH}/${encodeURIComponent(versionId)}/publish`, { originMappings: mappings.originMappings, overseasMappings: mappings.overseasMappings, }) const repeatRejected = repeatedPublishResponse.http === 200 && String(repeatedPublishResponse.body?.code) !== '200' context.addStep(module, '已生效版本禁止重复发布', repeatedPublishResponse, repeatRejected, `HTTP=${repeatedPublishResponse.http}, code=${repeatedPublishResponse.body?.code ?? 'missing'}`, blocker) const finalListResponse = await context.request('GET', PRICE_BOOK_PATH) const finalVersions = context.apiSuccess(module, '重复发布被拒绝后回查版本状态', finalListResponse, '', blocker) const finalActive = finalVersions?.filter?.((version) => version.status === 'ACTIVE') || [] context.assert(module, '重复发布未改变唯一生效版本', finalListResponse, finalActive.length === 1 && String(finalActive[0]?.id) === versionId, `active=${finalActive.map((item) => item.id).join(',') || 'none'}`, blocker) finishScenario(context, module, scenario, start) }