const MODULE = 'fee-items' const SCENARIO = 'platform-public-fee-items' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const BASE = '/api/platform/v1/fee-items' const MERCHANT_USERNAME = process.env.MERCHANT_TEST_USERNAME || 'merchant_admin' const MERCHANT_PASSWORD = process.env.MERCHANT_TEST_PASSWORD || 'ChangeMe123!' 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 Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`) } 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) context.assert(MODULE, '分页总数110且默认20条对应6页', initialPageResponse, records(initialPage).length === 20 && Number(initialPage?.total) === 110 && Number(initialPage?.pages) === 6 && 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 initial = await listAll(context) const initialRows = 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, '初始化110条全部属于平台且状态ACTIVE', initial.response, initialRows.length === 110 && 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条、国际26条范围正确', initial.response, domestic.length === 84 && international.length === 26, `DOMESTIC=${domestic.length}, INTERNATIONAL=${international.length}`, OPTIONS) context.assert(MODULE, '初始化编码完整覆盖 FEE_TYPE_001..FEE_TYPE_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', 84], ['INTERNATIONAL', 26]]) { 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) === 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 筛选返回110条且无停用记录', activeResponse, Number(activePage?.total) === 110 && records(activePage).every((row) => row.status === 'ACTIVE'), `total=${activePage?.total ?? 'n/a'}`, OPTIONS) const merchantLoginResponse = await context.request('POST', '/api/auth/login', { username: MERCHANT_USERNAME, password: MERCHANT_PASSWORD, }, '') const merchantLogin = success(merchantLoginResponse) ? merchantLoginResponse.body?.data : null const merchantToken = merchantLogin?.accessToken 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) 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, settlementIncluded: false, 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, '清理后恢复110条基线且无临时编码或名称', final.response, final.rows.length === 110 && !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, } } }