Files
baiyue-firstleg/scripts/admin-test-env-modules/finance-extended-fees.mjs
T

244 lines
16 KiB
JavaScript
Raw Normal View History

2026-08-12 17:34:03 +08:00
const MODULE = 'finance-extended-fees'
const OPTIONS = { scenario: 'finance-extended-real-fee-statuses', deploymentBlocker: true }
const FIXED_MARKER = 'FIN-EXT-FEE-20260812'
const BASE = '/api/platform/finance/v1'
function id(value) { return value == null ? '' : String(value) }
function rows(value) { return Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : [] }
function dateOffset(days) { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) }
function key(action) { return `${FIXED_MARKER}-${action}`.slice(0, 128) }
function request(context, method, path, body, action) {
return context.request(method, path, body, context.token, { 'Idempotency-Key': key(action) })
}
function blocker(context, name, evidence, response = null) {
context.addStep(MODULE, name, response, false, evidence, OPTIONS)
}
async function createSixthOrder(context) {
const originalMarker = context.marker
const original = context.createdResources.orders
context.marker = `${FIXED_MARKER}-STATUS6`
try {
const orders = await import(new URL('./orders.mjs', import.meta.url))
await orders.run(context)
const resource = context.createdResources.orders
return resource?.marker === context.marker && resource?.orderId && resource?.inboundId ? { ...resource } : null
} finally {
context.marker = originalMarker
if (original) context.createdResources.orders = original
else delete context.createdResources.orders
}
}
async function loadFeeItem(context, direction) {
const response = await context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200')
const data = context.apiSuccess(MODULE, `读取 ${direction} ACTIVE 费用项目`, response, '', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && (direction === 'RECEIVABLE'
2026-08-15 11:05:58 +08:00
? item.warehouseReceivable === true
2026-08-19 09:42:07 +08:00
: item.warehousePayable === true)) || null
2026-08-12 17:34:03 +08:00
}
2026-08-19 09:42:07 +08:00
async function loadActiveSupplier(context, category) {
const suffix = category ? `&category=${encodeURIComponent(category)}` : ''
const response = await context.request('GET', `/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE${suffix}`)
const data = context.apiSuccess(MODULE, '读取 Warehouse ACTIVE 供应商', response, category ? `category=${category}` : 'all categories', OPTIONS)
return rows(data).find((item) => item?.status === 'ACTIVE' && (!category || item?.category === category)) || null
2026-08-12 17:34:03 +08:00
}
2026-08-19 09:42:07 +08:00
async function createFee(context, resource, item, spec, supplier) {
2026-08-12 17:34:03 +08:00
const base = `/api/platform/v1/inbounds/${encodeURIComponent(resource.inboundId)}/fee-details`
const path = `${base}?accountId=${encodeURIComponent(resource.tenantId)}`
const body = {
feeItemId: Number(item.id), direction: spec.direction, businessDate: spec.businessDate,
2026-08-19 09:42:07 +08:00
unitPrice: spec.amount, currency: 'CNY', amount: spec.amount,
supplierId: spec.direction === 'PAYABLE' ? String(supplier.id) : null,
2026-08-12 17:34:03 +08:00
remark: `${FIXED_MARKER}-${spec.name}`,
}
const response = await request(context, 'POST', path, body, `create-${spec.name}`)
const detail = context.apiSuccess(MODULE, `创建${spec.label}`, response,
`orderId=${resource.orderId}, inboundId=${resource.inboundId}`, OPTIONS)
const feeId = id(detail?.id)
context.assert(MODULE, `${spec.label}初始状态和归属`, response,
Boolean(feeId) && id(detail?.inboundId) === id(resource.inboundId)
&& detail?.reviewStatus === 'PENDING' && Number(detail?.amount) === spec.amount,
`feeId=${feeId || 'missing'}, review=${detail?.reviewStatus || 'missing'}, amount=${detail?.amount ?? 'missing'}`, OPTIONS)
return feeId ? { ...spec, feeId, base, resource, createResponse: response } : null
}
async function readOrderInboundAndFee(context, fixture) {
const orderResponse = await context.request('GET',
`/api/platform/v1/orders/first-leg/${encodeURIComponent(fixture.resource.orderId)}?tenantId=${encodeURIComponent(fixture.resource.tenantId)}`)
const order = context.apiSuccess(MODULE, `${fixture.label}回读订单`, orderResponse,
`orderId=${fixture.resource.orderId}`, OPTIONS)
const inboundResponse = await context.request('GET',
`/api/platform/inbounds/${encodeURIComponent(fixture.resource.inboundId)}?tenantId=${encodeURIComponent(fixture.resource.tenantId)}`)
const inbound = context.apiSuccess(MODULE, `${fixture.label}回读入库单`, inboundResponse,
`inboundId=${fixture.resource.inboundId}`, OPTIONS)
const feeResponse = await context.request('GET',
`${fixture.base}?accountId=${encodeURIComponent(fixture.resource.tenantId)}`)
const feeRows = context.apiSuccess(MODULE, `${fixture.label}回读费用`, feeResponse, '', OPTIONS)
const fee = rows(feeRows).find((item) => id(item.id) === fixture.feeId)
context.assert(MODULE, `${fixture.label}订单、入库和费用关联一致`, feeResponse,
Boolean(order) && Boolean(inbound) && id(fee?.inboundId) === id(fixture.resource.inboundId),
`order=${Boolean(order)}, inbound=${Boolean(inbound)}, fee=${Boolean(fee)}`, OPTIONS)
return { order, inbound, fee, responses: { orderResponse, inboundResponse, feeResponse } }
}
async function financeState(context, customerId, fixture) {
const response = await context.request('GET',
`${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=${fixture.direction}`)
const data = context.apiSuccess(MODULE, `${fixture.label}回读 Finance 池`, response, '', OPTIONS)
const source = rows(data).find((item) => id(item.feeSourceId || item.id) === fixture.feeId)
const shouldExist = fixture.expectedFinance === 'AVAILABLE'
context.assert(MODULE, `${fixture.label} Finance 池状态`, response,
shouldExist ? source?.sourceStatus === 'AVAILABLE' : !source,
`expected=${fixture.expectedFinance}, actual=${source?.sourceStatus || 'ABSENT'}, feeSourceId=${fixture.feeId}`, OPTIONS)
return { response, source: source || null }
}
export async function run(context) {
const start = context.steps.length
const originalMarker = context.marker
context.marker = FIXED_MARKER
try {
const full = await import(new URL('./finance-full-fixtures.mjs', import.meta.url))
await full.run(context)
const baseFixture = context.createdResources.financeFullFixtures
if (!baseFixture || baseFixture.marker !== FIXED_MARKER) {
blocker(context, '生成基础重量/体积/人工费用 fixture',
`financeFullFixtures.marker=${baseFixture?.marker || 'missing'};禁止消费其他 marker`)
return
}
const automatic = baseFixture.feeSources.filter((item) => item.sourceType === 'FBA_PRICING')
for (const basis of ['WEIGHT_KG', 'VOLUME_CBM']) {
context.assert(MODULE, `${basis} 至少两条真实自动费用`, null,
automatic.filter((item) => item.billingBasis === basis).length >= 2,
`count=${automatic.filter((item) => item.billingBasis === basis).length}`, OPTIONS)
}
const sixth = await createSixthOrder(context)
context.assert(MODULE, '创建第六张独立订单与入库单', null, Boolean(sixth),
sixth ? `orderId=${sixth.orderId}, inboundId=${sixth.inboundId}` : 'missing', OPTIONS)
const resources = [...baseFixture.orders.map((item) => ({ ...item, tenantId: baseFixture.customerId })), sixth].filter(Boolean)
context.assert(MODULE, '本轮至少六张订单/入库单且无重复', null,
resources.length >= 6 && new Set(resources.map((item) => id(item.orderId))).size >= 6
&& new Set(resources.map((item) => id(item.inboundId))).size >= 6,
`orders=${resources.length}, uniqueOrders=${new Set(resources.map((item) => id(item.orderId))).size}, uniqueInbounds=${new Set(resources.map((item) => id(item.inboundId))).size}`, OPTIONS)
const receivableItem = await loadFeeItem(context, 'RECEIVABLE')
const payableItem = await loadFeeItem(context, 'PAYABLE')
2026-08-19 09:42:07 +08:00
const supplier = await loadActiveSupplier(context, payableItem?.supplierCategory || null)
if (!receivableItem || !payableItem || !supplier?.id) {
2026-08-12 17:34:03 +08:00
blocker(context, '费用状态矩阵前置主数据完整',
2026-08-19 09:42:07 +08:00
`receivableFeeItem=${id(receivableItem?.id) || 'missing'}, payableFeeItem=${id(payableItem?.id) || 'missing'}, activeSupplier=${id(supplier?.id) || 'missing'}`)
2026-08-12 17:34:03 +08:00
return
}
const specs = [
{ name: 'AR-PENDING', label: '手工应收待审核', direction: 'RECEIVABLE', amount: 111, businessDate: dateOffset(-5), action: null, expectedReview: 'PENDING', expectedFinance: 'ABSENT' },
{ name: 'AR-REJECTED', label: '手工应收驳回', direction: 'RECEIVABLE', amount: 112, businessDate: dateOffset(-4), action: 'reject', expectedReview: 'REJECTED', expectedFinance: 'ABSENT' },
{ name: 'AR-APPROVED', label: '手工应收已审核待出账', direction: 'RECEIVABLE', amount: 113, businessDate: dateOffset(-3), action: 'approve', expectedReview: 'APPROVED', expectedFinance: 'AVAILABLE' },
{ name: 'AP-PENDING', label: '手工应付待审核', direction: 'PAYABLE', amount: 81, businessDate: dateOffset(-2), action: null, expectedReview: 'PENDING', expectedFinance: 'ABSENT' },
{ name: 'AP-APPROVED', label: '手工应付已审核', direction: 'PAYABLE', amount: 82, businessDate: dateOffset(-1), action: 'approve', expectedReview: 'APPROVED', expectedFinance: 'AVAILABLE' },
{ name: 'AR-VOID', label: '手工应收作废', direction: 'RECEIVABLE', amount: 114, businessDate: dateOffset(-6), action: 'approve-void', expectedReview: 'APPROVED', expectedFinance: 'ABSENT' },
]
const fixtures = []
for (const [index, spec] of specs.entries()) {
const resource = resources[index % resources.length]
const fixture = await createFee(context, resource,
2026-08-19 09:42:07 +08:00
spec.direction === 'RECEIVABLE' ? receivableItem : payableItem, spec, supplier)
2026-08-12 17:34:03 +08:00
if (!fixture) continue
if (spec.action === 'reject') {
const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/reject?accountId=${encodeURIComponent(resource.tenantId)}`,
{ reason: FIXED_MARKER }, `reject-${spec.name}`)
context.apiSuccess(MODULE, `${spec.label}执行驳回`, response, '', OPTIONS)
} else if (spec.action?.startsWith('approve')) {
const response = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/approve?accountId=${encodeURIComponent(resource.tenantId)}`,
undefined, `approve-${spec.name}`)
context.apiSuccess(MODULE, `${spec.label}执行审核`, response, '', OPTIONS)
if (spec.action === 'approve-void') {
const voidResponse = await request(context, 'POST', `${fixture.base}/${fixture.feeId}/void?accountId=${encodeURIComponent(resource.tenantId)}`,
{ expectedSourceVersion: 1 }, `void-${spec.name}`)
context.apiSuccess(MODULE, `${spec.label}执行作废`, voidResponse, '', OPTIONS)
}
}
fixtures.push(fixture)
}
const conflictTarget = fixtures.find((item) => item.name === 'AR-APPROVED')
if (conflictTarget) {
const adjustBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: conflictTarget.businessDate,
2026-08-19 09:42:07 +08:00
unitPrice: 115, currency: 'CNY', amount: 115, remark: `${FIXED_MARKER}-CONFLICT` }
2026-08-12 17:34:03 +08:00
const conflict = await request(context, 'POST',
`${conflictTarget.base}/${conflictTarget.feeId}/adjust?accountId=${encodeURIComponent(conflictTarget.resource.tenantId)}&expectedSourceVersion=999`,
adjustBody, 'stale-adjust')
context.assert(MODULE, '错误 sourceVersion 调整产生并发版本冲突', conflict,
conflict?.body?.code !== '200',
`code=${conflict?.body?.code || 'missing'}, requestId=${conflict?.requestId || 'missing'}, traceId=${conflict?.traceId || 'missing'}`, OPTIONS)
}
const replayTarget = fixtures.find((item) => item.name === 'AR-PENDING')
if (replayTarget) {
const replayPath = `${replayTarget.base}?accountId=${encodeURIComponent(replayTarget.resource.tenantId)}`
const replayBody = { feeItemId: Number(receivableItem.id), direction: 'RECEIVABLE', businessDate: replayTarget.businessDate,
2026-08-19 09:42:07 +08:00
unitPrice: 116, currency: 'CNY', amount: 116, remark: `${FIXED_MARKER}-IDEMPOTENT` }
2026-08-12 17:34:03 +08:00
const first = await request(context, 'POST', replayPath, replayBody, 'idempotent-create')
const second = await request(context, 'POST', replayPath, replayBody, 'idempotent-create')
const firstData = first?.body?.data; const secondData = second?.body?.data
context.assert(MODULE, '同键同参人工费用创建幂等重放', second,
first?.body?.code === '200' && second?.body?.code === '200' && id(firstData?.id) === id(secondData?.id),
`first=${id(firstData?.id) || 'missing'}, replay=${id(secondData?.id) || 'missing'}, requestId=${second?.requestId || 'missing'}`, OPTIONS)
}
const statusMatrix = []
for (const fixture of fixtures) {
const read = await readOrderInboundAndFee(context, fixture)
context.assert(MODULE, `${fixture.label}最终审核状态`, read.responses.feeResponse,
read.fee?.reviewStatus === fixture.expectedReview,
`expected=${fixture.expectedReview}, actual=${read.fee?.reviewStatus || 'missing'}, publication=${read.fee?.publicationStatus || 'missing'}`, OPTIONS)
const finance = await financeState(context, baseFixture.customerId, fixture)
statusMatrix.push({ name: fixture.name, label: fixture.label, orderId: id(fixture.resource.orderId),
orderNo: fixture.resource.orderNo || null, inboundId: id(fixture.resource.inboundId), feeId: fixture.feeId,
direction: fixture.direction, businessDate: fixture.businessDate, amount: fixture.amount,
reviewStatus: read.fee?.reviewStatus || null, publicationStatus: read.fee?.publicationStatus || null,
financeState: finance.source?.sourceStatus || 'ABSENT', requestId: finance.response?.requestId || null,
traceId: finance.response?.traceId || null })
}
const voided = fixtures.find((item) => item.name === 'AR-VOID')
if (voided) {
const create = await request(context, 'POST', `${BASE}/statements`, {
customerId: String(baseFixture.customerId), currency: 'CNY', generationMode: 'SELECTED',
feeSourceIds: [voided.feeId], periodStart: voided.businessDate, periodEnd: voided.businessDate,
remark: `${FIXED_MARKER}-VOID-STATEMENT`,
}, 'voided-statement')
context.assert(MODULE, '作废来源不可选择出账', create, create?.body?.code !== '200',
`code=${create?.body?.code || 'missing'}, requestId=${create?.requestId || 'missing'}, traceId=${create?.traceId || 'missing'}`, OPTIONS)
}
const orderFeeCounts = new Map()
for (const item of [...baseFixture.feeSources, ...fixtures]) {
if (!item.orderId) continue
orderFeeCounts.set(id(item.orderId), (orderFeeCounts.get(id(item.orderId)) || 0) + 1)
}
context.assert(MODULE, '至少一张订单包含多个费用项目', null,
[...orderFeeCounts.values()].some((count) => count >= 2),
`maxFeeCount=${Math.max(0, ...orderFeeCounts.values())}`, OPTIONS)
context.assert(MODULE, '状态矩阵覆盖多个业务日期', null,
new Set(statusMatrix.map((item) => item.businessDate)).size >= 4,
`dates=${[...new Set(statusMatrix.map((item) => item.businessDate))].join(',')}`, OPTIONS)
context.createdResources.financeExtendedFees = {
marker: FIXED_MARKER, customerId: id(baseFixture.customerId),
orders: resources.map((item) => ({ orderId: id(item.orderId), orderNo: item.orderNo || null, inboundId: id(item.inboundId) })),
automaticFees: automatic, statusMatrix,
baseDeploymentBlockers: baseFixture.deploymentBlocker || [],
}
} finally {
context.marker = originalMarker
const steps = context.steps.slice(start)
context.scenarios.push({ name: OPTIONS.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 })
}
}