const MODULE = 'finance-extended-settlement' const SCENARIO = 'finance-extended-settlement-real-api' const REQUIRED_MARKER = 'FIN-EXT-SETTLE-20260812' const BASE = '/api/platform/finance/v1' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } 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 settlementId = (value) => id(value?.settlementId ?? value?.summary?.settlementId ?? value?.id) const statementId = (value) => id(value?.statementId ?? value?.reconciliationStatementId ?? value?.id) const outstanding = (value) => Number(value?.outstandingAmount ?? value?.summary?.outstandingAmount ?? 0) const version = (value) => Number(value?.version ?? value?.summary?.version ?? 0) const paymentStatus = (value) => id(value?.paymentStatus ?? value?.summary?.paymentStatus ?? value?.status) const dateOffset = (days) => { const value = new Date(); value.setUTCDate(value.getUTCDate() + days); return value.toISOString().slice(0, 10) } function key(context, operation) { return `${context.marker}-extended-${operation}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128) } function write(context, method, path, body, operation) { return context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, operation) }) } function diagnostic(response) { return { http: response?.http ?? null, code: response?.body?.code == null ? null : String(response.body.code), requestId: response?.requestId ?? null, traceId: response?.traceId ?? null } } function blocker(context, name, evidence, category, response = null) { context.addStep(MODULE, name, response, false, evidence, OPTIONS) context.addCoverageGap?.(MODULE, SCENARIO, name, evidence, category, { marker: context.marker, ...diagnostic(response) }) } 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 }) } async function runOwnedFixture(context) { const fixtureModule = await import(new URL('./finance-full-fixtures.mjs', import.meta.url)) await fixtureModule.run(context) const fixture = context.createdResources.financeFullFixtures return fixture?.marker === context.marker ? fixture : null } async function loadPayableInputs(context) { const [feeItemsResponse, suppliersResponse] = await Promise.all([ context.request('GET', '/api/platform/v1/fee-items?page=1&pageSize=200'), context.request('GET', '/api/platform/v1/suppliers?page=1&size=200&status=ACTIVE&category=BOOKING'), ]) const feeItems = rows(data(feeItemsResponse)) const suppliers = rows(data(suppliersResponse)) return { feeItem: feeItems.find((item) => item?.status === 'ACTIVE' && item?.warehousePayable === true && /BOOKING|FREIGHT|OCEAN|SHIPPING|订舱|海运|运费/i.test(`${item.feeCode || item.code || ''} ${item.feeName || item.name || ''}`)) || null, supplier: suppliers.find((item) => item?.status === 'ACTIVE' && item?.category === 'BOOKING' && item?.id) || null, response: !ok(feeItemsResponse) ? feeItemsResponse : suppliersResponse, } } async function createWaybillPayable(context, fixture, order, input, index) { const inboundId = id(order?.inboundId) if (!inboundId) return null const path = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details?accountId=${encodeURIComponent(fixture.customerId)}` const body = { feeItemId: Number(input.feeItem.id), direction: 'PAYABLE', businessDate: dateOffset(-3 - index), unitPrice: 90 + index * 10, amount: 90 + index * 10, currency: 'CNY', supplierId: id(input.supplier.id), remark: `${context.marker}-WAYBILL-PAYABLE-${index + 1}` } const response = await write(context, 'POST', path, body, `waybill-payable-${index + 1}`) const detail = context.apiSuccess(MODULE, `创建第 ${index + 1} 条独占运单 PAYABLE 来源`, response, `inboundId=${inboundId}`, OPTIONS) const sourceFeeId = id(detail?.id) if (!sourceFeeId) return null const approvePath = `/api/platform/v1/inbounds/${encodeURIComponent(inboundId)}/fee-details/${sourceFeeId}/approve?accountId=${encodeURIComponent(fixture.customerId)}` const approveResponse = await write(context, 'POST', approvePath, {}, `waybill-payable-${index + 1}-approve`) context.apiSuccess(MODULE, `审核第 ${index + 1} 条独占运单 PAYABLE 来源`, approveResponse, '', OPTIONS) if (!ok(approveResponse)) return null return { sourceFeeId, inboundId, orderId: id(order.orderId), waybillId: id(order.waybillId ?? order.shipmentId ?? order.orderNo), businessDate: body.businessDate, amount: body.amount, supplierId: id(input.supplier.id), supplierName: input.supplier.name || null } } async function resolvePoolSources(context, fixture, created) { let poolResponse = null for (let attempt = 0; attempt < 20; attempt += 1) { poolResponse = await context.request('GET', `${BASE}/fee-pool?customerId=${encodeURIComponent(fixture.customerId)}&direction=PAYABLE`) const poolData = data(poolResponse) const pool = rows(poolData).concat(rows(poolData?.items)) const bySource = new Map(pool.map((item) => [id(item?.sourceId), item])) const resolved = created.map((item) => { const row = bySource.get(item.sourceFeeId) const poolItemId = id(row?.poolItemId) return row ? { ...item, feeSourceId: poolItemId.includes(':') ? poolItemId.slice(poolItemId.indexOf(':') + 1) : id(row?.feeSourceId ?? row?.financeFeeSourceId ?? row?.id) } : null }) if (resolved.every((item) => item?.feeSourceId)) return { resolved, response: poolResponse } await new Promise((resolve) => setTimeout(resolve, 500)) } return { resolved: [], response: poolResponse } } async function runReconciliation(context, fixture, payableForLegacyModule) { const original = fixture.feeSources fixture.feeSources = [...original.filter((source) => source.direction !== 'PAYABLE'), payableForLegacyModule] try { const module = await import(new URL('./finance-reconciliation-full.mjs', import.meta.url)) await module.run(context) } finally { fixture.feeSources = original } } async function createExtraPayableSettlement(context, fixture, source, index) { const body = { customerId: id(fixture.customerId), supplierId: id(source.supplierId), feeSourceIds: [Number(source.feeSourceId)], dueDate: source.businessDate, currency: 'CNY' } const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, `ap-${index}`) const settlement = context.apiSuccess(MODULE, `创建第 ${index} 张独立 AP`, response, `feeSourceId=${source.feeSourceId}`, OPTIONS) if (!settlement) return null const replayResponse = await write(context, 'POST', `${BASE}/payable-settlements`, body, `ap-${index}`) const replay = context.apiSuccess(MODULE, `第 ${index} 张 AP 幂等重放`, replayResponse, '', OPTIONS) context.assert(MODULE, `第 ${index} 张 AP 幂等保持同一资源`, replayResponse, settlementId(replay) === settlementId(settlement), `settlementId=${settlementId(settlement)}`, OPTIONS) return settlement } async function detail(context, targetId) { const response = await context.request('GET', `${BASE}/settlements/${encodeURIComponent(targetId)}`) return { response, value: data(response) } } function receiptBody(context, fixture, allocations) { return { customerId: id(fixture.customerId), receivedDate: new Date().toISOString().slice(0, 10), amount: allocations.reduce((sum, item) => sum + item.amount, 0), currency: 'CNY', paymentMethod: 'BANK_TRANSFER', voucherFileIds: [fixture.voucherFileId], remark: `${context.marker}-MULTI-AR`, allocations } } async function settleReceivables(context, fixture, receivables) { const details = await Promise.all(receivables.map((item) => detail(context, settlementId(item)))) const partialBefore = outstanding(details[1].value) const paidBefore = outstanding(details[2].value) const partialAmount = Math.max(0.01, Math.floor(partialBefore * 50) / 100) const allocations = [ { billId: Number(statementId(receivables[1])), amount: partialAmount }, { billId: Number(statementId(receivables[2])), amount: paidBefore }, ] const response = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, fixture, allocations), 'multi-ar-receipt') const replayResponse = await write(context, 'POST', `${BASE}/receipts`, receiptBody(context, fixture, allocations), 'multi-ar-receipt') const receipt = data(response) const replay = data(replayResponse) context.assert(MODULE, '一笔真实收款分配两张 AR 且携带凭证', response, ok(response) && id(receipt?.receiptId ?? receipt?.id) && allocations.length === 2, `receiptId=${id(receipt?.receiptId ?? receipt?.id) || 'missing'}, voucherFileId=${fixture.voucherFileId}`, OPTIONS) context.assert(MODULE, '收款幂等重放不重复入账', replayResponse, ok(replayResponse) && id(replay?.receiptId ?? replay?.id) === id(receipt?.receiptId ?? receipt?.id), `first=${id(receipt?.receiptId ?? receipt?.id)}, replay=${id(replay?.receiptId ?? replay?.id)}`, OPTIONS) const after = await Promise.all(receivables.map((item) => detail(context, settlementId(item)))) context.assert(MODULE, '三张 AR 分别保留未收、部分收、已收清', response, outstanding(after[0].value) > 0 && outstanding(after[1].value) > 0 && outstanding(after[1].value) < partialBefore && outstanding(after[2].value) === 0 && paymentStatus(after[0].value) === 'UNPAID' && paymentStatus(after[1].value) === 'PARTIALLY_PAID' && paymentStatus(after[2].value) === 'PAID', `paymentStatus=${after.map((item) => paymentStatus(item.value)).join(',')}, outstanding=${after.map((item) => outstanding(item.value)).join(',')}`, OPTIONS) return { receipt, states: after.map((item) => item.value) } } function paymentBody(context, fixture, amount, expectedVersion, suffix) { return { transactionDate: new Date().toISOString().slice(0, 10), amount, currency: 'CNY', method: 'BANK_TRANSFER', referenceNo: `${context.marker}-${suffix}`, voucherFileIds: [fixture.voucherFileId], expectedVersion, remark: context.marker } } async function settlePayables(context, fixture, payables) { const initial = await Promise.all(payables.map((item) => detail(context, settlementId(item)))) const partialAmount = Math.max(0.01, Math.floor(outstanding(initial[1].value) * 50) / 100) const partialBody = paymentBody(context, fixture, partialAmount, version(initial[1].value), 'AP-PARTIAL') const partialResponse = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[1])}/transactions`, partialBody, 'ap-partial') const partial = data(partialResponse) context.assert(MODULE, '第二张 AP 部分付款', partialResponse, ok(partialResponse) && outstanding(partial) > 0 && outstanding(partial) < outstanding(initial[1].value), `outstanding=${outstanding(partial)}`, OPTIONS) const paidBody = paymentBody(context, fixture, outstanding(initial[2].value), version(initial[2].value), 'AP-PAID') const paidResponse = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[2])}/transactions`, paidBody, 'ap-paid') const paidReplay = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[2])}/transactions`, paidBody, 'ap-paid') const paid = data(paidResponse) const replay = data(paidReplay) context.assert(MODULE, '第三张 AP 全额付款且幂等重放', paidReplay, ok(paidResponse) && ok(paidReplay) && outstanding(paid) === 0 && outstanding(replay) === 0, `settlementId=${settlementId(paid)}, replaySettlementId=${settlementId(replay) || 'response-omitted'}, outstanding=${outstanding(paid)}, replayCode=${id(paidReplay.body?.code)}, requestId=${paidReplay.requestId ?? 'null'}, traceId=${paidReplay.traceId ?? 'null'}`, OPTIONS) const overBody = paymentBody(context, fixture, 0.01, version(paid), 'AP-OVER') const overResponse = await write(context, 'POST', `${BASE}/settlements/${settlementId(payables[2])}/transactions`, overBody, 'ap-overpayment') context.assert(MODULE, '已付清 AP 拒绝超额付款', overResponse, !ok(overResponse), `HTTP=${overResponse.http}, code=${id(overResponse.body?.code)}, requestId=${overResponse.requestId ?? 'null'}, traceId=${overResponse.traceId ?? 'null'}`, OPTIONS) const final = await Promise.all(payables.map((item) => detail(context, settlementId(item)))) context.assert(MODULE, '三张 AP 分别保留未付、部分付、已付', paidResponse, outstanding(final[0].value) > 0 && outstanding(final[1].value) > 0 && outstanding(final[2].value) === 0 && paymentStatus(final[0].value) === 'UNPAID' && paymentStatus(final[1].value) === 'PARTIALLY_PAID' && paymentStatus(final[2].value) === 'PAID', `paymentStatus=${final.map((item) => paymentStatus(item.value)).join(',')}, outstanding=${final.map((item) => outstanding(item.value)).join(',')}`, OPTIONS) return { states: final.map((item) => item.value), overpayment: diagnostic(overResponse) } } async function assertDuplicateOccupancy(context, fixture) { const first = fixture.statements?.find((item) => item?.feeSourceIds?.length) if (!first) return const body = { customerId: Number(fixture.customerId), currency: 'CNY', generationMode: 'SELECTED', feeSourceIds: [Number(first.feeSourceIds[0])], periodStart: first.periodStart, periodEnd: first.periodEnd, remark: `${context.marker}-DUPLICATE-OCCUPANCY` } const response = await write(context, 'POST', `${BASE}/statements`, body, 'duplicate-source') context.assert(MODULE, '已占用来源拒绝生成重复对账单', response, !ok(response), `code=${id(response.body?.code)}, requestId=${response.requestId ?? 'null'}, traceId=${response.traceId ?? 'null'}`, OPTIONS) } export async function run(context) { const start = context.steps.length const runnerMarker = context.marker context.marker = `${REQUIRED_MARKER}-${runnerMarker.slice(-5)}` context.assert(MODULE, '使用指定独立 marker 前缀和本轮幂等后缀', null, context.marker.startsWith(`${REQUIRED_MARKER}-`), `baseMarker=${REQUIRED_MARKER}, executionMarker=${context.marker}`, OPTIONS) const fixture = await runOwnedFixture(context) if (!fixture?.customerId || !fixture?.voucherFileId || !Array.isArray(fixture.orders) || fixture.orders.length < 3) { blocker(context, '建立真实独占订单、费用与凭证 Fixture', `customerId=${fixture?.customerId || 'missing'}, orders=${fixture?.orders?.length || 0}, voucherFileId=${fixture?.voucherFileId || 'missing'};禁止借历史来源`, 'marker-owned-fixture-unavailable') finish(context, start) return } const input = await loadPayableInputs(context) if (!input.feeItem?.id || !input.supplier?.id) { blocker(context, '取得真实 PAYABLE 费用项目与供应商', `feeItemId=${id(input.feeItem?.id) || 'missing'}, supplierId=${id(input.supplier?.id) || 'missing'}`, 'payable-master-data-unavailable', input.response) finish(context, start) return } const createdPayables = [] for (let index = 0; index < 3; index += 1) { const source = await createWaybillPayable(context, fixture, fixture.orders[index], input, index) if (source) createdPayables.push(source) } const pool = await resolvePoolSources(context, fixture, createdPayables) if (pool.resolved.length !== 3) { blocker(context, '三条本轮 PAYABLE 来源进入 Finance 费用池', `created=${createdPayables.length}, visible=${pool.resolved.length}`, 'payable-fee-pool-sync-failed', pool.response) finish(context, start) return } const legacyPayable = { ...pool.resolved[0], feeSourceId: pool.resolved[0].sourceFeeId, direction: 'PAYABLE', currency: 'CNY', sourceType: 'MANUAL', marker: context.marker, supplierId: pool.resolved[0].supplierId } await runReconciliation(context, fixture, legacyPayable) const receivables = fixture.receivableSettlements || [] const firstPayable = fixture.payableSettlements?.[0] if (receivables.length !== 3 || !firstPayable) { blocker(context, '生成三张 SELECTED 对账单、三张 AR 与首张 AP', `statements=${fixture.statements?.length || 0}, AR=${receivables.length}, AP=${fixture.payableSettlements?.length || 0}`, 'reconciliation-chain-incomplete') finish(context, start) return } await assertDuplicateOccupancy(context, fixture) const secondPayable = await createExtraPayableSettlement(context, fixture, pool.resolved[1], 2) const thirdPayable = await createExtraPayableSettlement(context, fixture, pool.resolved[2], 3) const payables = [firstPayable, secondPayable, thirdPayable].filter(Boolean) if (payables.length !== 3) { blocker(context, '生成三张独立 AP', `expected=3, actual=${payables.length}`, 'three-payable-settlements-failed') finish(context, start) return } const arResult = await settleReceivables(context, fixture, receivables) const apResult = await settlePayables(context, fixture, payables) const statusMatrix = { AR: arResult.states.map((item, index) => ({ settlementId: settlementId(item), settlementNo: item?.settlementNo ?? item?.summary?.settlementNo ?? null, expectedState: ['UNPAID', 'PARTIALLY_PAID', 'PAID'][index], paymentStatus: paymentStatus(item), outstandingAmount: outstanding(item) })), AP: apResult.states.map((item, index) => ({ settlementId: settlementId(item), settlementNo: item?.settlementNo ?? item?.summary?.settlementNo ?? null, expectedState: ['UNPAID', 'PARTIALLY_PAID', 'PAID'][index], paymentStatus: paymentStatus(item), outstandingAmount: outstanding(item) })), } context.createdResources.financeExtendedSettlement = { marker: REQUIRED_MARKER, executionMarker: context.marker, customerId: id(fixture.customerId), orderIds: fixture.orders.map((item) => id(item.orderId)), sourceIds: { receivable: fixture.feeSources.filter((item) => item.direction === 'RECEIVABLE').map((item) => id(item.feeSourceId)), payable: pool.resolved.map((item) => item.feeSourceId) }, statementIds: fixture.statements.filter((item) => item.kind !== 'SELECTED_DATE').map((item) => id(item.statementId)), receivableSettlementIds: receivables.map(settlementId), payableSettlementIds: payables.map(settlementId), voucherFileId: id(fixture.voucherFileId), receiptId: id(arResult.receipt?.receiptId ?? arResult.receipt?.id), statusMatrix, overpayment: apResult.overpayment, containerPayableDeploymentBlocker: fixture.deploymentBlocker || [], } finish(context, start) }