Files
baiyue-firstleg/scripts/admin-test-env-modules/pricing.mjs
T

315 lines
18 KiB
JavaScript
Raw Normal View History

2026-08-08 10:11:04 +08:00
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 DEFAULT_PRICE_BOOK_FILE = 'C:\\Users\\BY-OY\\Documents\\WXWork\\1688854456873012\\Cache\\File\\2026-08\\2026.08.03柏悦价格表 船期2026.08.12.xlsx'
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 || DEFAULT_PRICE_BOOK_FILE
let sourceBytes
try {
sourceBytes = await readFile(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
}
const sourceHash = sha256(sourceBytes)
const uploadName = `${context.marker}-${basename(sourcePath)}`
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)
}