221 lines
16 KiB
JavaScript
221 lines
16 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) {
|
|
if (context.options?.fixedCustomerId) {
|
|
const tenantId = context.context.merchantQualificationFixture?.tenantId
|
|
if (!tenantId) throw new Error('固定客户前置未完成,禁止创建海外仓费用客户')
|
|
const response = await context.request('GET', `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`)
|
|
const view = context.apiSuccess(MODULE, '读取固定客户海外仓费用配置', response)
|
|
context.assert(MODULE, '费用配置属于固定客户', response, id(view?.customerId) === id(tenantId), `customerId=${id(view?.customerId)}`, OPTIONS)
|
|
context.addCoverageGap(MODULE, SCENARIO, '固定客户模式排除费用规则破坏性生命周期',
|
|
'只读现有费用配置,不覆盖共享客户费率或归档客户', 'not-implemented', {blocking:false})
|
|
return
|
|
}
|
|
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 })
|
|
}
|