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)], ['非9档整组提交', initial.tiers.slice(0, 8)], ] 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 }) }