Files
baiyue-firstleg/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs
T

329 lines
20 KiB
JavaScript

const MODULE = 'finance-direct-paid-isolation'
const SCENARIO = 'finance-direct-paid-idempotency-freeze-isolation'
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
const FINANCE = '/api/platform/finance/v1'
const CLIENT = '/api/client/finance/v1'
const OVERSEAS_FEES = '/api/platform/v1/last-leg/finance-fee-sources'
const id = (value) => value == null ? '' : String(value)
const data = (response) => response?.body?.data ?? response?.body
const rows = (value) => Array.isArray(value) ? value : Array.isArray(value?.records) ? value.records : []
const ok = (response) => response?.http === 200 && String(response?.body?.code ?? '200') === '200'
const rejected = (response) => !ok(response)
const statementId = (value) => id(value?.statementId ?? value?.reconciliationStatementId ?? value?.id)
const settlementId = (value) => id(value?.settlementId ?? value?.id)
const sourceId = (value) => id(value?.sourceFeeId ?? value?.feeSourceId ?? value?.id)
const costId = (value) => id(value?.costId ?? value?.id)
const version = (value) => Number(value?.operationVersion ?? value?.version ?? value?.summary?.version ?? 0)
const outstanding = (value) => Number(value?.outstandingAmount ?? value?.summary?.outstandingAmount ?? 0)
const today = () => new Date().toISOString().slice(0, 10)
function key(context, operation) {
return `${context.marker}-finance-direct-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
}
function settlementKey(context, operation) {
return `${context.marker}-finance-settlement-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
}
function write(context, method, path, body, operation, token = context.token, overseas = false) {
const headers = { 'Idempotency-Key': key(context, operation) }
return overseas
? context.requestOverseas(method, path, body, headers, token)
: context.request(method, path, body, token, headers)
}
function diagnostic(response) {
return `HTTP=${response?.http ?? 'missing'}, code=${id(response?.body?.code) || 'missing'}, requestId=${response?.requestId ?? 'missing'}, traceId=${response?.traceId ?? 'missing'}`
}
function blocker(context, name, reason, response = null, category = 'finance-direct-paid-prerequisite') {
context.addStep(MODULE, name, response, false, `${reason}; ${diagnostic(response)}`, OPTIONS)
context.addCoverageGap?.(MODULE, SCENARIO, name, reason, category, {
marker: context.marker, requestId: response?.requestId ?? null, traceId: response?.traceId ?? null,
})
}
function finish(context, start) {
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 })
}
export function buildDirectPaidBody(marker, fixture, supplier, warehouse) {
const businessNo = `WHS-${String(marker).replace(/[^A-Za-z0-9]/g, '').slice(-24)}`
return {
customerId: id(fixture.customerId), customerName: fixture.customerName || null,
sourceType: 'OVERSEAS_WAREHOUSE_FEE', businessType: 'WAREHOUSE_SERVICE', businessId: null,
businessNo, orderId: null, inboundId: null, inboundCode: null,
containerId: null, containerBookingId: null, containerNo: null,
warehouseId: id(warehouse.id ?? warehouse.warehouseId),
feeItemCode: 'WAREHOUSE_SERVICE_DIRECT_PAID', feeItemName: `${marker} 海外仓服务已付费用`,
direction: 'PAYABLE', supplierId: id(supplier.id ?? supplier.supplierId),
supplierName: supplier.name ?? supplier.supplierName, supplierCategory: null,
settlementMode: 'DIRECT_PAID', paidAt: new Date().toISOString(), paymentMethod: 'BANK_TRANSFER',
paymentReferenceNo: `${marker}-DIRECT-PAID`, paymentVoucherFileIds: [],
businessDate: today(), currency: 'CNY', amount: 17.31,
}
}
export function findDirectPaidCost(items, body) {
return rows(items).find((item) => Number(item?.amount ?? item?.totalAmount ?? item?.costAmount ?? item?.payableAmount) === Number(body.amount)
&& id(item?.customerId ?? item?.tenantId) === id(body.customerId)
&& (item?.feeItemName === body.feeItemName
|| id(item?.costNo ?? item?.settlementNo).startsWith('DIRECT-')))
}
export function findOpenSettlement(items) {
return rows(items).find((item) => settlementId(item)
&& outstanding(item) > 0
&& !['VOIDED', 'PAID'].includes(id(item?.status))
&& !['VOIDED', 'PAID'].includes(id(item?.paymentStatus))) ?? null
}
async function ensureFixture(context) {
const fixture = context.createdResources?.financeFullFixtures
if (!fixture || fixture.marker !== context.marker || !fixture.customerId || !fixture.voucherFileId) return null
return fixture
}
async function loadActiveSupplierAndWarehouse(context, fixture) {
const supplier = fixture.supplier || rows(fixture.suppliers)[0]
const warehouseResponse = await context.request('GET',
'/api/platform/v1/warehouse-admin?tenantId=0&page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS')
const warehousePage = context.apiSuccess(MODULE, '查询海外自营仓用于无入仓码已付费用', warehouseResponse,
diagnostic(warehouseResponse), OPTIONS)
const warehouse = rows(warehousePage).find((item) => id(item?.id ?? item?.warehouseId))
if (!id(supplier?.id ?? supplier?.supplierId) || !(supplier?.name ?? supplier?.supplierName) || !warehouse) {
return { supplier, warehouse, response: warehouseResponse }
}
return { supplier, warehouse, response: warehouseResponse }
}
async function publishDirectPaid(context, fixture, supplier, warehouse) {
const body = buildDirectPaidBody(context.marker, fixture, supplier, warehouse)
const createdResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
const created = context.apiSuccess(MODULE, '创建无入仓码海外仓 DIRECT_PAID 来源', createdResponse,
diagnostic(createdResponse), OPTIONS)
const targetId = sourceId(created?.sourceFeeId ? created : { feeSourceId: created?.sourceFeeId })
|| id(created?.sourceFeeId)
context.assert(MODULE, 'DIRECT_PAID 来源仅绑定客户、仓库与供应商', createdResponse,
Boolean(targetId) && created?.settlementMode === 'DIRECT_PAID' && created?.direction === 'PAYABLE'
&& !created?.orderId && !created?.inboundId && !created?.inboundCode && !created?.containerId,
`sourceFeeId=${targetId || 'missing'}, settlementMode=${created?.settlementMode || 'missing'}`, OPTIONS)
if (!targetId) return null
const replayResponse = await write(context, 'POST', OVERSEAS_FEES, body, 'overseas-create', context.overseasToken, true)
const replay = data(replayResponse)
context.assert(MODULE, '海外费用创建同键同参重放同一来源', replayResponse,
ok(replayResponse) && sourceId(replay?.sourceFeeId ? replay : { feeSourceId: replay?.sourceFeeId }) === targetId,
`first=${targetId}, replay=${id(replay?.sourceFeeId) || 'missing'}`, OPTIONS)
const conflictResponse = await write(context, 'POST', OVERSEAS_FEES,
{ ...body, amount: body.amount + 1 }, 'overseas-create', context.overseasToken, true)
context.assert(MODULE, '海外费用创建同键异参冲突', conflictResponse, rejected(conflictResponse),
diagnostic(conflictResponse), OPTIONS)
const submittedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/submit`,
{ idempotencyKey: key(context, 'overseas-submit') })
context.apiSuccess(MODULE, '提交 DIRECT_PAID 来源审核', submittedResponse, diagnostic(submittedResponse), OPTIONS)
const reviewedResponse = await context.requestOverseas('POST', `${OVERSEAS_FEES}/${targetId}/review`,
{ decision: 'APPROVE', reason: context.marker, idempotencyKey: key(context, 'overseas-review') })
context.apiSuccess(MODULE, '审核发布 DIRECT_PAID 来源', reviewedResponse, diagnostic(reviewedResponse), OPTIONS)
return { sourceFeeId: targetId, body }
}
async function verifyDirectPaidProjection(context, fixture, published) {
let source = null
let costs = []
let summary = null
let sourcesResponse = null
let costsResponse = null
let summaryResponse = null
for (let attempt = 0; attempt < 120; attempt += 1) {
;[sourcesResponse, costsResponse, summaryResponse] = await Promise.all([
context.request('GET', `${FINANCE}/fee-sources?page=1&pageSize=200&customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`),
context.request('GET', `${FINANCE}/cost-entries?customerId=${encodeURIComponent(fixture.customerId)}`),
context.request('GET', `${FINANCE}/customer-accounts/${encodeURIComponent(fixture.customerId)}/summary`),
])
source = rows(data(sourcesResponse)).find((item) => id(item?.sourceFeeId) === published.sourceFeeId
&& item?.sourceSystem !== 'FIRSTLEG')
costs = rows(data(costsResponse))
summary = data(summaryResponse)
if (source && findDirectPaidCost(costs, published.body)) break
await new Promise((resolve) => setTimeout(resolve, 500))
}
const cost = findDirectPaidCost(costs, published.body)
context.assert(MODULE, 'DIRECT_PAID 生成 BUSINESS_DIRECT_PAID 成本并进入客户总成本和已付成本', null,
Boolean(source && cost) && Number(summary?.totalCost) >= published.body.amount
&& Number(summary?.paidCost) >= published.body.amount,
`source=${sourceId(source) || 'missing'}, cost=${costId(cost) || 'missing'}, totalCost=${summary?.totalCost ?? 'missing'}, paidCost=${summary?.paidCost ?? 'missing'}; sources=${diagnostic(sourcesResponse)}; costs=${diagnostic(costsResponse)}; summary=${diagnostic(summaryResponse)}`, OPTIONS)
const apPoolResponse = await context.request('GET',
`${FINANCE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`)
const apListResponse = await context.request('GET',
`${FINANCE}/payable-settlements?customerId=${encodeURIComponent(fixture.customerId)}`)
const inApPool = rows(data(apPoolResponse)).some((item) => id(item?.sourceFeeId) === published.sourceFeeId)
const inAp = rows(data(apListResponse)).some((item) => rows(item?.lines).some((line) => id(line?.sourceFeeId) === sourceId(source)))
context.assert(MODULE, 'DIRECT_PAID 不进入 AP 费用池或应付结算单', apPoolResponse,
!inApPool && !inAp, `inApPool=${inApPool}, inAp=${inAp}`, OPTIONS)
return { source, cost, summary }
}
async function verifyPaymentIdempotency(context, fixture) {
const receivables = rows(fixture.receivableSettlements).filter((item) => settlementId(item) && statementId(item))
const ap = rows(fixture.payableSettlements).find((item) => settlementId(item))
const priorPayment = fixture.payments?.partial
const priorPaymentTransaction = rows(priorPayment?.transactions)[0]
if (receivables.length < 3 || !ap || !fixture.receipts?.multi || !priorPayment || !priorPaymentTransaction) {
blocker(context, '取得前序收付款成功结果用于幂等重放',
`AR=${receivables.length}, AP=${settlementId(ap) || 'missing'}, receipt=${id(fixture.receipts?.multi?.id) || 'missing'}, payment=${settlementId(priorPayment) || 'missing'};需在 finance-settlement-full 后执行本模块`, null,
'completed-finance-payment-fixture-unavailable')
return
}
const allocationAmount = Number(fixture.multiBillAllocationAmount || 1)
const allocations = receivables.slice(0, 3).map((item) => ({ billId: Number(statementId(item)), amount: allocationAmount }))
const receiptBody = { customerId: id(fixture.customerId), receivedDate: today(), amount: allocationAmount * allocations.length,
currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)],
remark: context.marker, allocations }
const receiptHeaders = { 'Idempotency-Key': settlementKey(context, 'multi-bill-receipt') }
const receiptReplay = await context.request('POST', `${FINANCE}/receipts`, receiptBody, context.token, receiptHeaders)
const receiptConflict = await context.request('POST', `${FINANCE}/receipts`,
{ ...receiptBody, remark: `${context.marker}-different` }, context.token, receiptHeaders)
context.assert(MODULE, '收款同键同参重放且异参冲突', receiptReplay,
ok(receiptReplay)
&& id(fixture.receipts.multi?.id) === id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id)
&& rejected(receiptConflict),
`receipt=${id(data(receiptReplay)?.receiptId ?? data(receiptReplay)?.id) || 'missing'}; conflict=${diagnostic(receiptConflict)}`, OPTIONS)
const paymentBody = { transactionDate: id(priorPaymentTransaction.transactionDate) || today(),
amount: Number(priorPaymentTransaction.amount), currency: 'CNY', method: 'BANK_TRANSFER',
referenceNo: `${context.marker}-AP`, voucherFileIds: [id(fixture.voucherFileId)],
expectedVersion: Math.max(0, version(priorPayment) - 1), remark: context.marker }
const path = `${FINANCE}/settlements/${encodeURIComponent(settlementId(ap))}/transactions`
const paymentHeaders = { 'Idempotency-Key': settlementKey(context, 'ap-partial-payment') }
const paymentReplay = await context.request('POST', path, paymentBody, context.token, paymentHeaders)
const paymentConflict = await context.request('POST', path,
{ ...paymentBody, referenceNo: `${paymentBody.referenceNo}-different` }, context.token, paymentHeaders)
context.assert(MODULE, 'AP 付款同键同参重放且异参冲突', paymentReplay,
ok(paymentReplay) && rejected(paymentConflict)
&& version(data(paymentReplay)) >= version(priorPayment),
`settlementId=${settlementId(ap)}, version=${version(data(paymentReplay))}; conflict=${diagnostic(paymentConflict)}`, OPTIONS)
}
async function verifyFrozenSource(context, fixture) {
const mapped = rows(fixture.statements).flatMap((item) => rows(item?.feeSourceIds)).map(id).filter(Boolean)
const targetId = mapped[0] || rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).filter(Boolean)[0]
if (!targetId) {
blocker(context, '取得已进入有效单据的来源', 'finance reconciliation/settlement fixture 未提供 feeSourceId', null,
'frozen-fee-source-unavailable')
return
}
const beforeResponse = await context.request('GET', `${FINANCE}/fee-sources/${encodeURIComponent(targetId)}`)
const before = data(beforeResponse)
const expectedVersion = version(before)
const edit = await write(context, 'PUT', `${FINANCE}/fee-sources/${targetId}`,
{ amount: Number(before?.amount || 1) + 1, reason: context.marker, expectedVersion }, 'frozen-edit')
const adjust = await write(context, 'POST', `${FINANCE}/fee-sources/${targetId}/adjust`,
{ amount: Number(before?.amount || 1) + 2, reason: context.marker, expectedVersion }, 'frozen-adjust')
const voided = await write(context, 'POST', `${FINANCE}/fee-sources/${targetId}/void`,
{ reason: context.marker, expectedVersion }, 'frozen-void')
const afterResponse = await context.request('GET', `${FINANCE}/fee-sources/${encodeURIComponent(targetId)}`)
const after = data(afterResponse)
context.assert(MODULE, '已进入有效账单或结算的来源拒绝编辑、调整和作废且零漂移', afterResponse,
[edit, adjust, voided].every(rejected) && version(after) === expectedVersion
&& Number(after?.amount) === Number(before?.amount) && after?.sourceStatus === before?.sourceStatus,
`source=${targetId}, beforeVersion=${expectedVersion}, afterVersion=${version(after)}; edit=${diagnostic(edit)}; adjust=${diagnostic(adjust)}; void=${diagnostic(voided)}`, OPTIONS)
}
async function createSecondCustomer(context) {
const originalMarker = context.marker
const originalContext = context.context?.merchantQualificationFixture
const originalResource = context.createdResources?.merchantQualificationFixture
const originalUsername = process.env.MERCHANT_TEST_USERNAME
const originalPassword = process.env.MERCHANT_TEST_PASSWORD
context.marker = `${originalMarker}-FIN-ISO`
try {
const module = await import(new URL('./merchant-qualification-fixture.mjs', import.meta.url))
await module.run(context)
const fixture = context.context?.merchantQualificationFixture
if (!fixture?.tenantId || !fixture?.username || !fixture?.password || fixture.marker !== context.marker) return null
const loginResponse = await context.request('POST', '/api/auth/login',
{ loginId: fixture.username, password: fixture.password }, '')
const login = context.apiSuccess(MODULE, '第二隔离客户登录', loginResponse, diagnostic(loginResponse), OPTIONS)
return { customerId: id(fixture.tenantId), token: login?.accessToken }
} finally {
context.marker = originalMarker
if (originalContext) context.context.merchantQualificationFixture = originalContext
else delete context.context.merchantQualificationFixture
if (originalResource) context.createdResources.merchantQualificationFixture = originalResource
else delete context.createdResources.merchantQualificationFixture
if (originalUsername == null) delete process.env.MERCHANT_TEST_USERNAME
else process.env.MERCHANT_TEST_USERNAME = originalUsername
if (originalPassword == null) delete process.env.MERCHANT_TEST_PASSWORD
else process.env.MERCHANT_TEST_PASSWORD = originalPassword
}
}
async function verifyCustomerIsolation(context, fixture) {
const second = await createSecondCustomer(context)
const target = rows(fixture.statements).find((item) => statementId(item))
const apSource = rows(fixture.payableSettlements).flatMap((item) => rows(item?.feeSourceIds)).map(id).find(Boolean)
if (!second?.customerId || !second?.token || !target || !apSource) {
blocker(context, '建立第二隔离客户与跨客户单据 Fixture',
`customer=${second?.customerId || 'missing'}, token=${Boolean(second?.token)}, statement=${statementId(target) || 'missing'}, payableSource=${apSource || 'missing'}`,
null, 'second-customer-fixture-unavailable')
return
}
const targetId = statementId(target)
const read = await context.request('GET', `${CLIENT}/bills/${encodeURIComponent(targetId)}`, undefined, second.token)
const confirm = await context.request('POST', `${CLIENT}/statements/${encodeURIComponent(targetId)}/confirm`,
{ expectedVersion: Number(target.version ?? 0) }, second.token,
{ 'Idempotency-Key': key(context, 'foreign-confirm') })
const receipt = await write(context, 'POST', `${FINANCE}/receipts`, {
customerId: second.customerId, receivedDate: today(), amount: 0.01, currency: 'CNY',
paymentMethod: 'BANK_TRANSFER', voucherFileIds: [id(fixture.voucherFileId)], remark: context.marker,
allocations: [{ billId: targetId, amount: 0.01 }],
}, 'foreign-receipt')
const payment = await write(context, 'POST', `${FINANCE}/payable-settlements`, {
customerId: second.customerId, supplierId: id(fixture.supplier?.id ?? fixture.supplier?.supplierId),
feeSourceIds: [apSource],
dueDate: today(), currency: 'CNY',
}, 'foreign-payable')
context.assert(MODULE, '第二客户不能读取或确认他人账单且平台不能跨客户分配收付款', confirm,
[read, confirm, receipt, payment].every(rejected),
`read=${diagnostic(read)}; confirm=${diagnostic(confirm)}; receipt=${diagnostic(receipt)}; payment=${diagnostic(payment)}`, OPTIONS)
}
export async function run(context) {
const start = context.steps.length
try {
const fixture = await ensureFixture(context)
if (!fixture) {
blocker(context, '取得本轮 Finance 全链 Fixture', '缺少同 marker financeFullFixtures,禁止借历史财务单据')
return
}
const input = await loadActiveSupplierAndWarehouse(context, fixture)
if (!id(input.supplier?.id ?? input.supplier?.supplierId)
|| !(input.supplier?.name ?? input.supplier?.supplierName) || !input.warehouse) {
blocker(context, '取得 DIRECT_PAID 供应商和海外仓',
`supplier=${id(input.supplier?.id ?? input.supplier?.supplierId) || 'missing'}, warehouse=${id(input.warehouse?.id ?? input.warehouse?.warehouseId) || 'missing'}`,
input.response, 'direct-paid-master-data-unavailable')
return
}
const published = await publishDirectPaid(context, fixture, input.supplier, input.warehouse)
if (!published) return
await verifyDirectPaidProjection(context, fixture, published)
await verifyPaymentIdempotency(context, fixture)
await verifyFrozenSource(context, fixture)
await verifyCustomerIsolation(context, fixture)
context.createdResources.financeDirectPaidIsolation = {
marker: context.marker, customerId: id(fixture.customerId), sourceFeeId: published.sourceFeeId,
businessNo: published.body.businessNo,
}
} finally {
finish(context, start)
}
}