Files
baiyue-firstleg/scripts/admin-test-env-modules/overseas-storage-fees.mjs
T

211 lines
15 KiB
JavaScript

const MODULE = 'overseas-storage-fees'
const SCENARIO = 'customer-overseas-storage-fee-lifecycle'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const DEFAULT_TIERS = [
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 1, endDay: 7, unitPrice: 0 },
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 8, endDay: 30, unitPrice: 0.8 },
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 31, endDay: 60, unitPrice: 1 },
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 61, endDay: 90, unitPrice: 1.5 },
{ feeType: 'STORAGE', billingUnit: 'CBM', startDay: 91, endDay: null, unitPrice: 2 },
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 1, endDay: 30, unitPrice: 1.5 },
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 31, endDay: 60, unitPrice: 2 },
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 61, endDay: 90, unitPrice: 3 },
{ feeType: 'RETURN_STORAGE', billingUnit: 'PALLET', startDay: 91, endDay: null, unitPrice: 4 },
]
function id(value) { return value == null ? '' : String(value) }
function codeOf(response) { return response?.body?.code == null ? 'missing' : String(response.body.code) }
function businessFailure(response) {
return [400, 401, 403, 404, 409].includes(response?.http)
|| (response?.http === 200 && codeOf(response) !== '200')
}
function missingRoute(response) {
return response?.http === 404 || response?.http === 405
|| /Not Found|No handler|No static resource/i.test(String(response?.raw || response?.body?.message || ''))
}
function close(a, b) { return Number(a) === Number(b) }
function tiersOf(view) { return Array.isArray(view?.tiers) ? view.tiers : [] }
function requestTiers(view) {
return tiersOf(view).map((tier) => ({
feeType: tier.feeType, billingUnit: tier.billingUnit,
startDay: Number(tier.startDay), endDay: tier.endDay == null ? null : Number(tier.endDay),
unitPrice: Number(tier.unitPrice),
}))
}
function tierSnapshot(view) {
return tiersOf(view).map((tier) => [tier.feeType, tier.billingUnit, tier.startDay, tier.endDay, String(tier.unitPrice)])
}
function defaultShape(view) {
const tiers = tiersOf(view)
const storage = tiers.filter((tier) => tier.feeType === 'STORAGE' && tier.billingUnit === 'CBM')
const returns = tiers.filter((tier) => tier.feeType === 'RETURN_STORAGE' && tier.billingUnit === 'PALLET')
const matches = (actual, expected) => actual.length === expected.length && expected.every((item) => actual.some((tier) => (
Number(tier.startDay) === item.startDay && (tier.endDay == null ? null : Number(tier.endDay)) === item.endDay
&& close(tier.unitPrice, item.unitPrice)
)))
return tiers.length === 9 && storage.length === 5 && returns.length === 4
&& matches(storage, DEFAULT_TIERS.filter((tier) => tier.feeType === 'STORAGE'))
&& matches(returns, DEFAULT_TIERS.filter((tier) => tier.feeType === 'RETURN_STORAGE'))
&& storage.some((tier) => close(tier.unitPrice, 0))
&& storage.some((tier) => tier.endDay == null && Number(tier.startDay) === 91)
&& returns.some((tier) => tier.endDay == null && Number(tier.startDay) === 91)
}
async function createFixtureCustomer(context) {
const suffix = context.marker.replace(/\D/g, '').slice(-12)
const phone = await context.findAvailablePhone(MODULE)
const payload = {
tenantName: `海外仓费用验收-${suffix}`, legalName: `海外仓费用验收有限公司-${suffix}`,
legalRepresentative: '费用验收法人', contactName: '费用验收联系人', contactPhone: phone,
contactEmail: `storage-fee-${suffix}@example.com`, servicePackage: '海外仓费用验收套餐',
contactWechat: `fee_${suffix}`, salesOwnerName: '费用验收销售', paymentTermDays: 30,
enterpriseType: 'LIMITED_COMPANY', establishedDate: '2020-01-02', registeredCapital: 1000000,
registeredCapitalCurrency: 'CNY', registeredAddress: '深圳市南山区海外仓费用验收地址', internalRemark: context.marker,
}
const response = await context.request('POST', '/api/platform/v1/iam/tenants', payload)
const data = context.apiSuccess(MODULE, '创建海外仓费用隔离客户', response, '', OPTIONS)
const customer = data?.tenant || data?.customer || data
const tenantId = id(customer?.id || data?.tenantId)
context.assert(MODULE, '海外仓费用隔离客户创建并带 marker', response,
Boolean(tenantId) && String(customer?.internalRemark || '').includes(context.marker), `tenantId=${tenantId || 'missing'}`, OPTIONS)
if (!tenantId) return null
context.createdResources.overseasStorageFeeCustomer = { tenantId, marker: context.marker, ownedByModule: true }
return tenantId
}
export async function run(context) {
const start = context.steps.length
// Always create a module-owned customer. The customers module may archive its
// own fixture, and this lifecycle must never depend on that or on history.
const tenantId = await createFixtureCustomer(context)
const ownedCustomer = Boolean(tenantId)
if (!tenantId) {
context.addStep(MODULE, '取得本轮客户 ID', null, false, '未找到可隔离客户,禁止使用历史客户', OPTIONS)
return
}
const path = `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`
const getResponse = await context.request('GET', path)
const view = context.apiSuccess(MODULE, '查询客户海外仓费用配置', getResponse, `tenantId=${tenantId}`, OPTIONS)
if (!view) {
if (ownedCustomer) {
const archiveResponse = await context.request('POST', `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/archive`)
const archived = context.apiSuccess(MODULE, '费用配置契约不可用时清理隔离客户', archiveResponse, `tenantId=${tenantId}`, OPTIONS)
context.assert(MODULE, '隔离客户清理后归档', archiveResponse, archived?.status === 'ARCHIVED', `status=${archived?.status ?? 'missing'}`, OPTIONS)
}
return
}
const activePath = `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`
const initial = {
id: id(view.id), customerId: id(view.customerId), customerName: view.customerName,
currency: view.currency, tiers: requestTiers(view), version: Number(view.version), snapshot: tierSnapshot(view),
}
context.assert(MODULE, '客户费用配置身份、默认 USD 与9档结构正确', getResponse,
initial.id && initial.customerId === tenantId && initial.currency === 'USD' && defaultShape(view),
`ruleId=${initial.id || 'missing'}, customerId=${initial.customerId}, currency=${initial.currency}, tiers=${tiersOf(view).length}`, OPTIONS)
const listPath = `/api/platform/v1/overseas-storage-fees?page=1&size=10&customerId=${encodeURIComponent(tenantId)}`
const initialListResponse = await context.request('GET', listPath)
const initialList = context.apiSuccess(MODULE, '按客户查询海外仓费用规则', initialListResponse, `tenantId=${tenantId}`, OPTIONS)
context.assert(MODULE, '每个客户恰好一条规则且规则内恰好9档', initialListResponse,
Number(initialList?.total) === 1 && initialList?.records?.length === 1
&& id(initialList.records[0]?.id) === initial.id && tiersOf(initialList.records[0]).length === 9,
`total=${initialList?.total}, records=${initialList?.records?.length}, tiers=${tiersOf(initialList?.records?.[0]).length}`, OPTIONS)
const wrongCustomerResponse = await context.request('PUT', activePath, {
customerId: `${BigInt(tenantId) + 1n}`, customerName: initial.customerName, currency: initial.currency,
tiers: initial.tiers, version: initial.version,
})
context.addStep(MODULE, '请求体客户 ID 与路径不一致时安全失败', wrongCustomerResponse,
businessFailure(wrongCustomerResponse), `HTTP=${wrongCustomerResponse.http}, code=${codeOf(wrongCustomerResponse)}`, OPTIONS)
const invalidPayloads = [
['非法费用类型', initial.tiers.map((tier, index) => index === 0 ? { ...tier, feeType: 'INVALID' } : tier)],
['费用类型与计费单位不匹配', initial.tiers.map((tier, index) => index === 0 ? { ...tier, billingUnit: 'PALLET' } : tier)],
['阶梯区间重叠', initial.tiers.map((tier) => tier.feeType === 'STORAGE' && tier.startDay === 8 ? { ...tier, startDay: 7 } : tier)],
['阶梯区间断档', initial.tiers.map((tier) => tier.feeType === 'STORAGE' && tier.startDay === 8 ? { ...tier, startDay: 9 } : tier)],
]
for (const [name, tiers] of invalidPayloads) {
const response = await context.request('PUT', activePath, {
customerId: tenantId, customerName: initial.customerName, currency: initial.currency,
tiers, version: initial.version,
})
context.addStep(MODULE, `${name}安全失败`, response, businessFailure(response),
`HTTP=${response.http}, code=${codeOf(response)}`, OPTIONS)
}
const afterInvalidResponse = await context.request('GET', activePath)
const afterInvalid = context.apiSuccess(MODULE, '负向请求后回查客户费用配置', afterInvalidResponse, '', OPTIONS)
context.assert(MODULE, '所有非法整组请求均未造成部分更新', afterInvalidResponse,
Number(afterInvalid?.version) === initial.version
&& tierSnapshot(afterInvalid).every((row, index) => row.join('|') === initial.snapshot[index]?.join('|')),
`version=${afterInvalid?.version}, tiers=${tiersOf(afterInvalid).length}`, OPTIONS)
const editedTiers = initial.tiers.map((tier, index) => {
let startDay = tier.startDay
let endDay = tier.endDay
if (tier.feeType === 'STORAGE' && tier.startDay === 1) endDay = 6
if (tier.feeType === 'STORAGE' && tier.startDay === 8) startDay = 7
if (tier.feeType === 'RETURN_STORAGE' && tier.startDay === 1) endDay = 29
if (tier.feeType === 'RETURN_STORAGE' && tier.startDay === 31) startDay = 30
return { ...tier, startDay, endDay, unitPrice: Number((tier.unitPrice + 0.125 + index / 1000).toFixed(3)) }
})
const updatePayload = {
customerId: tenantId, customerName: `${initial.customerName}-${context.marker.slice(-8)}`,
currency: 'EUR', tiers: editedTiers, version: initial.version,
}
const updateResponse = await context.request('PUT', activePath, updatePayload)
context.apiSuccess(MODULE, '整组修改客户海外仓费用', updateResponse, `tenantId=${tenantId}, tiers=9`, OPTIONS)
const afterUpdateResponse = await context.request('GET', activePath)
const afterUpdate = context.apiSuccess(MODULE, '回查整组修改后的客户费用', afterUpdateResponse, '', OPTIONS)
const editedCorrect = afterUpdate?.currency === 'EUR' && afterUpdate?.customerName === updatePayload.customerName
&& Number(afterUpdate?.version) === initial.version + 1 && tierSnapshot(afterUpdate).every((row, index) => row.join('|') === tierSnapshot({ tiers: editedTiers })[index].join('|'))
context.assert(MODULE, '修改后币种、价格、9档及版本整体回显', afterUpdateResponse, editedCorrect,
`currency=${afterUpdate?.currency}, version=${afterUpdate?.version}, tiers=${tiersOf(afterUpdate).length}`, OPTIONS)
const repeatedListResponse = await context.request('GET', listPath)
const repeatedList = context.apiSuccess(MODULE, '编辑后再次按客户查询唯一规则', repeatedListResponse, '', OPTIONS)
context.assert(MODULE, '重复初始化结果保持幂等且编辑值未被覆盖', repeatedListResponse,
Number(repeatedList?.total) === 1 && repeatedList?.records?.length === 1
&& id(repeatedList.records[0]?.id) === initial.id
&& repeatedList.records[0]?.currency === 'EUR'
&& Number(repeatedList.records[0]?.version) === initial.version + 1
&& tierSnapshot(repeatedList.records[0]).every((row, index) => row.join('|') === tierSnapshot({ tiers: editedTiers })[index].join('|')),
`total=${repeatedList?.total}, ruleId=${id(repeatedList?.records?.[0]?.id)}, version=${repeatedList?.records?.[0]?.version}`, OPTIONS)
const staleResponse = await context.request('PUT', activePath, { ...updatePayload, customerName: `${updatePayload.customerName}-STALE`, version: initial.version })
const staleRejected = staleResponse.http === 200 && ['A0409', '409'].includes(codeOf(staleResponse))
context.addStep(MODULE, '旧 version 整组保存返回并发冲突', staleResponse, staleRejected,
`HTTP=${staleResponse.http}, code=${codeOf(staleResponse)}`, OPTIONS)
const afterConflictResponse = await context.request('GET', activePath)
const afterConflict = context.apiSuccess(MODULE, '冲突后回查费用配置', afterConflictResponse, '', OPTIONS)
context.assert(MODULE, '冲突未造成部分更新', afterConflictResponse,
afterConflict?.customerName === updatePayload.customerName && afterConflict?.currency === 'EUR'
&& tierSnapshot(afterConflict).every((row, index) => row.join('|') === tierSnapshot({ tiers: editedTiers })[index].join('|')),
`version=${afterConflict?.version}, unchanged=${afterConflict?.customerName === updatePayload.customerName}`, OPTIONS)
const restorePayload = {
customerId: tenantId, customerName: initial.customerName, currency: initial.currency,
tiers: initial.tiers, version: Number(afterConflict?.version),
}
const restoreResponse = await context.request('PUT', activePath, restorePayload)
context.apiSuccess(MODULE, '恢复本轮客户费用原始配置', restoreResponse, 'restore=initial', OPTIONS)
const finalResponse = await context.request('GET', activePath)
const final = context.apiSuccess(MODULE, '回查恢复后的客户费用', finalResponse, '', OPTIONS)
context.assert(MODULE, '恢复后9档、USD、免费档和无上限档一致', finalResponse,
final?.customerName === initial.customerName && final?.currency === 'USD' && defaultShape(final)
&& Number(final?.version) === Number(afterConflict?.version) + 1,
`currency=${final?.currency}, version=${final?.version}, tiers=${tiersOf(final).length}`, OPTIONS)
const invalidCustomerResponse = await context.request('GET', '/api/platform/v1/iam/tenants/999999999999999999/overseas-storage-fees')
context.addStep(MODULE, '错误客户 ID 查询安全失败', invalidCustomerResponse, businessFailure(invalidCustomerResponse),
`HTTP=${invalidCustomerResponse.http}, code=${codeOf(invalidCustomerResponse)}`, OPTIONS)
if (ownedCustomer) {
const archiveResponse = await context.request('POST', `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/archive`)
const archived = context.apiSuccess(MODULE, '清理费用隔离客户', archiveResponse, `tenantId=${tenantId}`, OPTIONS)
context.assert(MODULE, '费用隔离客户清理后归档', archiveResponse, archived?.status === 'ARCHIVED', `status=${archived?.status ?? 'missing'}`, OPTIONS)
}
const steps = context.steps.slice(start)
context.scenarios.push({ name: SCENARIO, module: 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 })
}