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) { 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) } }