328 lines
20 KiB
JavaScript
328 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.request(method, path, body, token, headers)
|
|
: 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.token, 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.token, 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.token, true)
|
|
context.assert(MODULE, '海外费用创建同键异参冲突', conflictResponse, rejected(conflictResponse),
|
|
diagnostic(conflictResponse), OPTIONS)
|
|
|
|
const submittedResponse = await context.request('POST', `${OVERSEAS_FEES}/${targetId}/submit`, { idempotencyKey: key(context, 'overseas-submit') })
|
|
context.apiSuccess(MODULE, '提交 DIRECT_PAID 来源审核', submittedResponse, diagnostic(submittedResponse), OPTIONS)
|
|
const reviewedResponse = await context.request('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) {
|
|
if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户财务写入')
|
|
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)
|
|
}
|
|
}
|