优化
This commit is contained in:
@@ -0,0 +1,314 @@
|
||||
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)
|
||||
}
|
||||
Reference in New Issue
Block a user