Files

286 lines
17 KiB
JavaScript

const MODULE = 'fee-items'
const SCENARIO = 'platform-public-fee-items'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const BASE = '/api/platform/v1/fee-items'
const BASELINE_COUNT = 112
const DOMESTIC_COUNT = 84
const INTERNATIONAL_COUNT = 28
const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || ''
const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || ''
function id(value) { return value == null ? '' : String(value) }
function records(value) { return Array.isArray(value?.records) ? value.records : [] }
function success(response) { return response.http === 200 && String(response.body?.code) === '200' }
export function cleanupSucceeded(response) {
return success(response) || response.http === 404
|| (response.http === 200 && ['A0404', '404'].includes(String(response.body?.code)))
}
function conflict(response) {
return response.http === 200 && ['A0409', '409'].includes(String(response.body?.code))
}
function forbidden(response) {
return response.http === 403 || (response.http === 200 && ['A0403', '403'].includes(String(response.body?.code)))
}
function query(parameters = {}) {
const values = new URLSearchParams()
for (const [key, value] of Object.entries(parameters)) {
if (value !== undefined && value !== null && value !== '') values.set(key, String(value))
}
const suffix = values.toString()
return suffix ? `${BASE}?${suffix}` : BASE
}
function payload(suffix, overrides = {}) {
return {
category: '验收临时费用',
tradeScope: 'DOMESTIC',
feeCode: `ACPT_${suffix}`.slice(0, 50),
feeTypeName: `全量验收费用-${suffix}`,
pickupPayable: false,
pickupReceivable: false,
warehousePayable: true,
warehouseReceivable: true,
allocationFee: false,
description: `仅用于 ${suffix} 自动化验收,完成后删除`,
...overrides,
}
}
function expectedCodes() {
return [
'FBA_HEAD_FREIGHT',
'PRIVATE_LAST_MILE_DELIVERY',
...Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`),
].sort()
}
/** 正式基线按受控编码识别;其他业务合法扩展不计入112条基线。 */
export function formalFeeItems(rows) {
const codes = new Set(expectedCodes())
return rows.filter(row => codes.has(row.feeCode))
}
function fixture(context) {
const candidates = [context.createdResources?.orders, context.createdResources?.testInbound,
context.createdResources?.inboundMeasurement, context.context?.testInbound]
return candidates.find((item) => item?.tenantId && item?.inboundId && item?.marker === context.marker) || null
}
async function listAll(context) {
const response = await context.request('GET', query({ page: 1, size: 200 }))
const page = context.apiSuccess(MODULE, '查询全部平台公共费用项目', response,
'未传 accountId', OPTIONS)
return { response, page, rows: records(page) }
}
export async function run(context) {
const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 }))
const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse,
'page=1,size=20', OPTIONS)
const initial = await listAll(context)
context.assert(MODULE, '全部目录默认20条分页与完整列表一致', initialPageResponse,
records(initialPage).length === Math.min(20, initial.rows.length) && Number(initialPage?.total) === initial.rows.length
&& Number(initialPage?.pages) === Math.ceil(initial.rows.length / 20) && Number(initialPage?.page) === 1,
`records=${records(initialPage).length}, total=${initialPage?.total ?? 'n/a'}, pages=${initialPage?.pages ?? 'n/a'}`, OPTIONS)
context.assert(MODULE, '费用项目查询不传 accountId 仍成功', initialPageResponse,
success(initialPageResponse) && !String(initialPageResponse.path || initialPageResponse.request?.path || '').includes('accountId'),
`path=${initialPageResponse.path || 'missing'}`, OPTIONS)
const initialRows = formalFeeItems(initial.rows)
const domestic = initialRows.filter((row) => row.tradeScope === 'DOMESTIC')
const international = initialRows.filter((row) => row.tradeScope === 'INTERNATIONAL')
const actualCodes = initialRows.map((row) => row.feeCode).sort()
context.assert(MODULE, '正式目录112条全部属于平台且状态ACTIVE', initial.response,
initialRows.length === BASELINE_COUNT && initialRows.every((row) => id(row.tenantId) === '0' && row.status === 'ACTIVE'),
`rows=${initialRows.length}, platform=${initialRows.filter((row) => id(row.tenantId) === '0').length}, active=${initialRows.filter((row) => row.status === 'ACTIVE').length}`, OPTIONS)
context.assert(MODULE, '国内84条、国际28条范围正确', initial.response,
domestic.length === DOMESTIC_COUNT && international.length === INTERNATIONAL_COUNT,
`DOMESTIC=${domestic.length}, INTERNATIONAL=${international.length}`, OPTIONS)
context.assert(MODULE, '正式编码完整覆盖110条受控项目及两条系统费用', initial.response,
actualCodes.join(',') === expectedCodes().join(','),
`codes=${actualCodes.length}, first=${actualCodes[0] || 'missing'}, last=${actualCodes.at(-1) || 'missing'}`, OPTIONS)
const category = initialRows[0]?.category
const categoryResponse = await context.request('GET', query({ category, page: 1, size: 200 }))
const categoryPage = context.apiSuccess(MODULE, '按费用分类筛选', categoryResponse,
`category=${category || 'missing'}`, OPTIONS)
context.assert(MODULE, '分类筛选结果不混入其他分类', categoryResponse,
Boolean(category) && records(categoryPage).length > 0 && records(categoryPage).every((row) => row.category === category),
`records=${records(categoryPage).length}`, OPTIONS)
for (const [scope, expected] of [['DOMESTIC', DOMESTIC_COUNT], ['INTERNATIONAL', INTERNATIONAL_COUNT]]) {
const response = await context.request('GET', query({ tradeScope: scope, page: 1, size: 200 }))
const page = context.apiSuccess(MODULE, `按 ${scope} 贸易范围筛选`, response, '', OPTIONS)
context.assert(MODULE, `${scope} 筛选数量和范围正确`, response,
Number(page?.total) === initial.rows.filter(row => row.tradeScope === scope).length
&& formalFeeItems(records(page)).length === expected && records(page).every((row) => row.tradeScope === scope),
`total=${page?.total ?? 'n/a'}, records=${records(page).length}`, OPTIONS)
}
const keywordSource = initialRows[0]?.feeTypeName
const keyword = String(keywordSource || '').slice(0, Math.max(1, Math.min(6, String(keywordSource || '').length)))
const keywordResponse = await context.request('GET', query({ keyword, page: 1, size: 200 }))
const keywordPage = context.apiSuccess(MODULE, '按名称关键词筛选费用项目', keywordResponse,
`keyword=${keyword || 'missing'}`, OPTIONS)
context.assert(MODULE, '关键词筛选结果名称全部命中', keywordResponse,
Boolean(keyword) && records(keywordPage).length > 0
&& records(keywordPage).every((row) => String(row.feeTypeName).includes(keyword)),
`records=${records(keywordPage).length}`, OPTIONS)
const activeResponse = await context.request('GET', query({ status: 'ACTIVE', page: 1, size: 200 }))
const activePage = context.apiSuccess(MODULE, '按 ACTIVE 状态筛选费用项目', activeResponse, '', OPTIONS)
context.assert(MODULE, 'ACTIVE 筛选覆盖112条正式项目且无停用记录', activeResponse,
Number(activePage?.total) === initial.rows.filter(row => row.status === 'ACTIVE').length
&& formalFeeItems(records(activePage)).length === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'),
`total=${activePage?.total ?? 'n/a'}`, OPTIONS)
const merchantLoginResponse = await context.request('POST', '/api/auth/login', {
loginId: MERCHANT_USERNAME, password: MERCHANT_PASSWORD,
}, '')
const merchantLogin = success(merchantLoginResponse) ? merchantLoginResponse.body?.data : null
const merchantToken = merchantLogin?.accessToken
try {
const forbiddenResponse = merchantToken
? await context.request('GET', query({ page: 1, size: 20 }), undefined, merchantToken)
: merchantLoginResponse
context.assert(MODULE, '无费用项目平台权限的客户账号被拒绝', forbiddenResponse,
Boolean(merchantToken) && forbidden(forbiddenResponse),
`HTTP=${forbiddenResponse.http}, code=${forbiddenResponse.body?.code ?? 'missing'}`, OPTIONS)
} finally {
if (merchantToken && merchantLogin.refreshToken) {
const logout = await context.request('POST', '/api/auth/logout', { refreshToken: merchantLogin.refreshToken }, merchantToken)
context.assert(MODULE, '退出费用目录权限核验会话', logout, success(logout), '', OPTIONS)
}
}
if (context.options?.fixedCustomerId) {
context.createdResources.feeItems = { marker: context.marker, mode: 'READ_ONLY', baselineCount: initialRows.length }
context.addCoverageGap(MODULE, 'fixed-fee-item-lifecycle', '费用项目增删改生命周期',
'固定客户模式只读核对现有费用目录,不创建、修改或删除公共主档及历史测试项目', 'not-implemented', { blocking: false })
return
}
const suffix = context.marker.replace(/\D/g, '').slice(-12)
const createPayload = payload(suffix)
const createResponse = await context.request('POST', BASE, createPayload)
const created = context.apiSuccess(MODULE, '创建隔离平台公共费用项目', createResponse,
`feeCode=${createPayload.feeCode}`, OPTIONS)
const feeItemId = id(created?.id)
context.assert(MODULE, '新费用项目固定 tenantId=0、ACTIVE 且字段完整', createResponse,
Boolean(feeItemId) && id(created?.tenantId) === '0' && created?.status === 'ACTIVE'
&& created?.feeCode === createPayload.feeCode && created?.feeTypeName === createPayload.feeTypeName,
`feeItemId=${feeItemId || 'missing'}, tenantId=${id(created?.tenantId) || 'missing'}, status=${created?.status || 'missing'}`, OPTIONS)
if (!feeItemId) return
let detailId = ''
let detailDeleted = false
let itemDeleted = false
try {
const duplicateNameResponse = await context.request('POST', BASE,
payload(`${suffix}N`, { feeTypeName: createPayload.feeTypeName }))
context.assert(MODULE, '同贸易范围费用名称重复返回冲突', duplicateNameResponse,
conflict(duplicateNameResponse),
`HTTP=${duplicateNameResponse.http}, code=${duplicateNameResponse.body?.code ?? 'missing'}`, OPTIONS)
const duplicateCodeResponse = await context.request('POST', BASE,
payload(`${suffix}C`, { tradeScope: 'INTERNATIONAL', feeCode: createPayload.feeCode,
feeTypeName: `${createPayload.feeTypeName}-编码冲突` }))
context.assert(MODULE, '平台公共 feeCode 重复返回冲突', duplicateCodeResponse,
conflict(duplicateCodeResponse),
`HTTP=${duplicateCodeResponse.http}, code=${duplicateCodeResponse.body?.code ?? 'missing'}`, OPTIONS)
const updatePayload = payload(suffix, {
category: '验收临时费用-已编辑', feeTypeName: `${createPayload.feeTypeName}-已编辑`,
allocationFee: true, description: `${context.marker}-updated`,
})
const updateResponse = await context.request('PUT', `${BASE}/${encodeURIComponent(feeItemId)}`, updatePayload)
const updated = context.apiSuccess(MODULE, '编辑隔离费用项目', updateResponse, '', OPTIONS)
context.assert(MODULE, '编辑字段持久化且平台归属不变', updateResponse,
id(updated?.id) === feeItemId && id(updated?.tenantId) === '0'
&& updated?.category === updatePayload.category && updated?.feeTypeName === updatePayload.feeTypeName
&& updated?.allocationFee === true && updated?.description === updatePayload.description,
`feeItemId=${feeItemId}, category=${updated?.category || 'missing'}`, OPTIONS)
const disableResponse = await context.request('PATCH', `${BASE}/${encodeURIComponent(feeItemId)}/status`,
{ status: 'DISABLED' })
context.apiSuccess(MODULE, '停用隔离费用项目', disableResponse, '', OPTIONS)
const disabledDetailResponse = await context.request('GET', `${BASE}/${encodeURIComponent(feeItemId)}`)
const disabled = context.apiSuccess(MODULE, '回查停用费用项目', disabledDetailResponse, '', OPTIONS)
context.assert(MODULE, '费用项目停用状态已持久化', disabledDetailResponse,
disabled?.status === 'DISABLED', `status=${disabled?.status || 'missing'}`, OPTIONS)
const inbound = fixture(context)
context.assert(MODULE, '取得本轮客户隔离入库单用于绑定保护', null,
Boolean(inbound), inbound
? `tenantId=${inbound.tenantId}, inboundId=${inbound.inboundId}`
: 'orders 模块未提供同 marker 入库单,未使用历史资源', OPTIONS)
if (inbound) {
const feeDetailPath = `/api/platform/v1/inbounds/${encodeURIComponent(inbound.inboundId)}/fee-details?accountId=${encodeURIComponent(inbound.tenantId)}`
const detailPayload = {
feeItemId, direction: 'RECEIVABLE', businessDate: new Date().toISOString().slice(0, 10),
unitPrice: 1.25, currency: 'CNY', amount: 1.25,
remark: context.marker,
}
const disabledBindingResponse = await context.request('POST', feeDetailPath, detailPayload)
context.assert(MODULE, '停用费用项目不可绑定新的入库费用', disabledBindingResponse,
conflict(disabledBindingResponse),
`HTTP=${disabledBindingResponse.http}, code=${disabledBindingResponse.body?.code ?? 'missing'}`, OPTIONS)
const enableResponse = await context.request('PATCH', `${BASE}/${encodeURIComponent(feeItemId)}/status`,
{ status: 'ACTIVE' })
context.apiSuccess(MODULE, '重新启用隔离费用项目用于引用保护', enableResponse, '', OPTIONS)
const bindingResponse = await context.request('POST', feeDetailPath, detailPayload)
const binding = context.apiSuccess(MODULE, '客户入库单绑定平台公共费用项目', bindingResponse,
`accountId=${inbound.tenantId}`, OPTIONS)
detailId = id(binding?.id)
context.assert(MODULE, '费用明细保留客户归属和公共项目快照', bindingResponse,
Boolean(detailId) && id(binding?.tenantId) === id(inbound.tenantId)
&& id(binding?.feeItemId) === feeItemId && binding?.feeTypeNameSnapshot === updatePayload.feeTypeName,
`detailId=${detailId || 'missing'}, detailTenantId=${id(binding?.tenantId) || 'missing'}, feeItemId=${id(binding?.feeItemId) || 'missing'}`, OPTIONS)
const protectedDeleteResponse = await context.request('DELETE', `${BASE}/${encodeURIComponent(feeItemId)}`)
context.assert(MODULE, '任意客户费用明细引用后公共项目不可删除', protectedDeleteResponse,
conflict(protectedDeleteResponse),
`HTTP=${protectedDeleteResponse.http}, code=${protectedDeleteResponse.body?.code ?? 'missing'}, customer=${inbound.tenantId}`, OPTIONS)
itemDeleted = success(protectedDeleteResponse)
if (detailId) {
const detailDeleteResponse = await context.request('DELETE',
`${feeDetailPath.replace('?', `/${encodeURIComponent(detailId)}?`)}`)
detailDeleted = success(detailDeleteResponse)
context.assert(MODULE, '清理本轮客户费用明细引用', detailDeleteResponse, detailDeleted,
`detailId=${detailId}, code=${detailDeleteResponse.body?.code ?? 'missing'}`, OPTIONS)
}
}
if (!detailId || detailDeleted) {
const deleteResponse = await context.request('DELETE', `${BASE}/${encodeURIComponent(feeItemId)}`)
itemDeleted = success(deleteResponse)
context.assert(MODULE, '删除无引用的隔离费用项目', deleteResponse, itemDeleted,
`feeItemId=${feeItemId}, code=${deleteResponse.body?.code ?? 'missing'}`, OPTIONS)
}
} finally {
const inbound = fixture(context)
if (detailId && !detailDeleted && inbound) {
const cleanupDetailResponse = await context.request('DELETE',
`/api/platform/v1/inbounds/${encodeURIComponent(inbound.inboundId)}/fee-details/${encodeURIComponent(detailId)}?accountId=${encodeURIComponent(inbound.tenantId)}`)
detailDeleted = cleanupSucceeded(cleanupDetailResponse)
context.assert(MODULE, '异常路径清理本轮费用明细', cleanupDetailResponse, detailDeleted,
`detailId=${detailId}`, OPTIONS)
}
if (!itemDeleted && (!detailId || detailDeleted)) {
const cleanupItemResponse = await context.request('DELETE', `${BASE}/${encodeURIComponent(feeItemId)}`)
itemDeleted = cleanupSucceeded(cleanupItemResponse)
context.assert(MODULE, '异常路径清理本轮费用项目', cleanupItemResponse, itemDeleted,
`feeItemId=${feeItemId}`, OPTIONS)
}
const final = await listAll(context)
context.assert(MODULE, '清理后保留原目录与112条正式基线且无本轮临时项目', final.response,
formalFeeItems(final.rows).length === BASELINE_COUNT
&& final.rows.map(row => row.feeCode).sort().join(',') === initial.rows.map(row => row.feeCode).sort().join(',')
&& !final.rows.some((row) => row.feeCode === createPayload.feeCode
|| String(row.feeTypeName).includes(suffix)),
`rows=${final.rows.length}, temporary=${final.rows.filter((row) => row.feeCode === createPayload.feeCode).length}`, OPTIONS)
context.createdResources.feeItems = {
marker: context.marker, feeItemId, detailId: detailId || null,
cleaned: itemDeleted && (!detailId || detailDeleted), baselineCount: final.rows.length,
}
}
}