245 lines
16 KiB
JavaScript
245 lines
16 KiB
JavaScript
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'
|
|
? item.warehouseReceivable === true : item.warehousePayable === true)) || null
|
|
}
|
|
|
|
async function loadActivePartner(context) {
|
|
const response = await context.request('GET', '/api/platform/v1/partners?page=1&size=200&status=ACTIVE')
|
|
const data = context.apiSuccess(MODULE, '读取现有 ACTIVE 往来单位', response, '', OPTIONS)
|
|
return rows(data).find((item) => item?.status === 'ACTIVE') || null
|
|
}
|
|
|
|
async function createFee(context, resource, item, spec, partner) {
|
|
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,
|
|
unitPrice: spec.amount, currency: 'CNY', amount: spec.amount, settlementIncluded: true,
|
|
partnerId: spec.direction === 'PAYABLE' ? String(partner.id) : null,
|
|
partnerName: spec.direction === 'PAYABLE' ? (partner.partnerName || partner.name || null) : null,
|
|
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')
|
|
const partner = await loadActivePartner(context)
|
|
if (!receivableItem || !payableItem || !partner?.id) {
|
|
blocker(context, '费用状态矩阵前置主数据完整',
|
|
`receivableFeeItem=${id(receivableItem?.id) || 'missing'}, payableFeeItem=${id(payableItem?.id) || 'missing'}, activePartner=${id(partner?.id) || 'missing'}`)
|
|
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,
|
|
spec.direction === 'RECEIVABLE' ? receivableItem : payableItem, spec, partner)
|
|
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,
|
|
unitPrice: 115, currency: 'CNY', amount: 115, settlementIncluded: true,
|
|
partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-CONFLICT` }
|
|
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,
|
|
unitPrice: 116, currency: 'CNY', amount: 116, settlementIncluded: true,
|
|
partnerId: null, partnerName: null, remark: `${FIXED_MARKER}-IDEMPOTENT` }
|
|
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 })
|
|
}
|
|
}
|