553 lines
34 KiB
JavaScript
553 lines
34 KiB
JavaScript
const MODULE = 'finance-reconciliation-full'
|
||
const SCENARIO = 'finance-full-reconciliation'
|
||
const BASE = '/api/platform/finance/v1'
|
||
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
||
const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'
|
||
const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
|
||
const REQUIRED_PERMISSIONS = [
|
||
'platform:finance:customer-account:list',
|
||
STATEMENT_LIST_PERMISSION,
|
||
'platform:finance:reconciliation:create',
|
||
'platform:finance:reconciliation:review',
|
||
'platform:finance:reconciliation:issue',
|
||
'platform:finance:reconciliation:adjust',
|
||
'platform:finance:reconciliation:void',
|
||
'platform:finance:settlement-ar:create',
|
||
'platform:finance:settlement-ar:list',
|
||
'platform:finance:settlement-ap:create',
|
||
'platform:finance:settlement-ap:list',
|
||
]
|
||
|
||
function id(value) { return value == null ? '' : String(value) }
|
||
function list(value) { return Array.isArray(value) ? value : [] }
|
||
function rows(value) { return Array.isArray(value) ? value : list(value?.records) }
|
||
function statementId(value) { return id(value?.statementId ?? value?.id ?? value?.billId) }
|
||
function settlementId(value) { return id(value?.settlementId ?? value?.id) }
|
||
function sourceId(value) { return id(value?.feeSourceId ?? value?.financeFeeSourceId ?? value?.id) }
|
||
function sourceDate(value) { return value?.businessDate ?? value?.billingDate ?? value?.feeDate ?? '' }
|
||
function sourceWaybill(value) { return id(value?.waybillId ?? value?.shipmentId ?? value?.orderNo ?? value?.sourceNo) }
|
||
function financePoolId(value) {
|
||
const poolItemId = id(value?.poolItemId)
|
||
if (poolItemId) return poolItemId.includes(':') ? poolItemId.slice(poolItemId.indexOf(':') + 1) : poolItemId
|
||
return id(value?.feeSourceId ?? value?.financeFeeSourceId ?? value?.id)
|
||
}
|
||
function isUnboundContainerCost(raw) {
|
||
return raw?.sourceType === 'MANUAL' && raw?.billingBasis === 'CONTAINER_SHARED'
|
||
&& !sourceWaybill(raw)
|
||
}
|
||
function lineSourceIds(value) { return list(value?.lines).map(sourceId).filter(Boolean) }
|
||
function rejectedWith(response, code) {
|
||
return response?.http === 200 && String(response?.body?.code) === code
|
||
}
|
||
function accessDenied(response) { return rejectedWith(response, 'A0403') }
|
||
function financeSubjectMissing(response) {
|
||
return rejectedWith(response, 'FIN_409')
|
||
&& String(response?.body?.message ?? '').includes('平台财务主体未配置')
|
||
}
|
||
function sameIds(actual, expected) {
|
||
const left = [...new Set(actual)].sort()
|
||
const right = [...new Set(expected)].sort()
|
||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||
}
|
||
|
||
function present(value) { return value !== undefined && value !== null && String(value).trim() !== '' }
|
||
|
||
async function verifyIssuedStatementReadback(context, fixture, statement, label) {
|
||
const customerId = id(fixture.customerId)
|
||
const targetId = statementId(statement)
|
||
if (!customerId || !targetId) {
|
||
context.addCoverageGap(MODULE, SCENARIO, `${label} 对账单列表/详情查回`,
|
||
`必须使用本轮 fixture.customerId 和刚创建且已出账的 statementId;customerId=${customerId || 'missing'}, statementId=${targetId || 'missing'}`,
|
||
'missing-issued-statement-readback-fixture', { marker: context.marker, customerId, statementId: targetId })
|
||
return
|
||
}
|
||
const listResponse = await context.request('GET',
|
||
`${BASE}/statements?customerId=${encodeURIComponent(customerId)}`)
|
||
const summaries = context.apiSuccess(MODULE, `${label} 从正式对账单列表查回`, listResponse,
|
||
`customerId=${customerId}, statementId=${targetId}, permission=${STATEMENT_LIST_PERMISSION}`, OPTIONS)
|
||
const statementRows = rows(summaries)
|
||
const found = statementRows.find((item) => statementId(item) === targetId)
|
||
context.assert(MODULE, `${label} 正式对账单列表仅返回本轮客户且包含同一 statementId`, listResponse,
|
||
statementRows.length > 0 && statementRows.every((item) => id(item?.customerId) === customerId) && Boolean(found),
|
||
`customerId=${customerId}, statementId=${targetId}, rows=${statementRows.length}, found=${statementId(found) || 'missing'}`, OPTIONS)
|
||
|
||
const detailResponse = await context.request('GET',
|
||
`${BASE}/reconciliation-statements/${encodeURIComponent(targetId)}`)
|
||
const detail = context.apiSuccess(MODULE, `${label} 从正式对账单详情查回`, detailResponse,
|
||
`customerId=${customerId}, statementId=${targetId}, permission=${STATEMENT_LIST_PERMISSION}`, OPTIONS)
|
||
const auditShape = ['createdBy', 'createdAt', 'updatedBy', 'updatedAt']
|
||
.every((field) => Object.hasOwn(detail ?? {}, field) && present(detail?.[field]))
|
||
context.assert(MODULE, `${label} 对账单详情归属、明细、revision 与审计结构完整`, detailResponse,
|
||
id(detail?.customerId) === customerId && statementId(detail) === targetId
|
||
&& Array.isArray(detail?.lines) && detail.lines.length > 0
|
||
&& Array.isArray(detail?.revisions) && detail.revisions.length > 0
|
||
&& auditShape,
|
||
`customerId=${id(detail?.customerId) || 'missing'}, statementId=${statementId(detail) || 'missing'}, lines=${list(detail?.lines).length}, revisions=${list(detail?.revisions).length}, auditShape=${auditShape}`, OPTIONS)
|
||
}
|
||
|
||
async function findSettlementInOfficialList(context, direction, customerId, targetId, label) {
|
||
const segment = direction === 'RECEIVABLE' ? 'receivable-settlements' : 'payable-settlements'
|
||
const response = await context.request('GET',
|
||
`${BASE}/${segment}?customerId=${encodeURIComponent(customerId)}`)
|
||
const summaries = context.apiSuccess(MODULE, `从正式${label}列表查回结算单`, response,
|
||
`settlementId=${targetId}, customerId=${customerId}`, OPTIONS)
|
||
const found = rows(summaries).find((item) => settlementId(item) === targetId)
|
||
context.assert(MODULE, `正式${label}列表包含同一 settlementId`, response,
|
||
found?.direction === direction && id(found?.customerId) === id(customerId),
|
||
`expected=${targetId}, actual=${settlementId(found) || 'missing'}, direction=${found?.direction ?? 'missing'}`, OPTIONS)
|
||
return found ?? null
|
||
}
|
||
function dates(sources) {
|
||
const values = sources.map((source) => source.businessDate).sort()
|
||
return { start: values[0], end: values.at(-1) }
|
||
}
|
||
function key(context, action) {
|
||
return `${context.marker}-finance-reconciliation-${action}`.replace(/[^A-Za-z0-9_.:-]/g, '-').slice(0, 128)
|
||
}
|
||
function write(context, method, path, body, action) {
|
||
return context.request(method, path, body, context.token, { 'Idempotency-Key': key(context, action) })
|
||
}
|
||
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,
|
||
})
|
||
}
|
||
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 accountRows(value) {
|
||
const data = value?.body?.data
|
||
return rows(data).concat(rows(data?.items))
|
||
}
|
||
function accountCustomerId(value) { return id(value?.customerId ?? value?.tenantId ?? value?.accountId ?? value?.id) }
|
||
function hasWalletField(value) {
|
||
return value && typeof value === 'object' && ['balance', 'recharge', 'prepayment'].some((field) => Object.hasOwn(value, field))
|
||
}
|
||
async function verifyCustomerAccountAndLedger(context, fixture) {
|
||
const customerId = id(fixture.customerId)
|
||
const accountResponse = await context.request('GET', `${BASE}/accounts?page=1&pageSize=20`)
|
||
const accounts = accountRows(accountResponse)
|
||
const account = accounts.find((item) => accountCustomerId(item) === customerId)
|
||
context.assert(MODULE, '客户账户列表包含本轮 marker 客户且不暴露钱包字段', accountResponse,
|
||
Boolean(account) && !accounts.some(hasWalletField),
|
||
`customerId=${customerId}, accountCount=${accounts.length}, requestId=${accountResponse.requestId ?? 'null'}, traceId=${accountResponse.traceId ?? 'null'}`, OPTIONS)
|
||
const ledgerResponse = await context.request('GET', `${BASE}/accounts/${encodeURIComponent(customerId)}/ledger?page=1&pageSize=200`)
|
||
const ledger = accountRows(ledgerResponse)
|
||
const rowsForCustomer = ledger.filter((item) => {
|
||
const itemCustomer = accountCustomerId(item)
|
||
return !itemCustomer || itemCustomer === customerId
|
||
})
|
||
const expectedIds = new Set([
|
||
...(fixture.statements || []).map((item) => statementId(item)),
|
||
...(fixture.receivableSettlements || []).map((item) => settlementId(item)),
|
||
...(fixture.payableSettlements || []).map((item) => settlementId(item)),
|
||
].filter(Boolean))
|
||
const containsRoundTrip = rowsForCustomer.some((item) => expectedIds.has(statementId(item) || settlementId(item)
|
||
|| id(item?.documentId ?? item?.businessId ?? item?.sourceId ?? item?.referenceId))
|
||
|| String(item?.remark ?? '').includes(context.marker))
|
||
context.assert(MODULE, '客户账户流水仅属于本轮客户并包含账单/收款或结算流水', ledgerResponse,
|
||
rowsForCustomer.length === ledger.length && containsRoundTrip,
|
||
`customerId=${customerId}, ledgerCount=${ledger.length}, requestId=${ledgerResponse.requestId ?? 'null'}, traceId=${ledgerResponse.traceId ?? 'null'}`, OPTIONS)
|
||
fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse),
|
||
ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) }
|
||
}
|
||
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) })
|
||
}
|
||
|
||
async function normalizeFixture(context) {
|
||
const root = context.createdResources?.financeFullFixtures
|
||
if (!root || root.marker !== context.marker || !root.customerId
|
||
|| !Array.isArray(root.orders) || !Array.isArray(root.feeSources)) return null
|
||
const customerId = id(root.customerId)
|
||
const orders = new Map(root.orders.map((order) => [id(order.orderId ?? order.id), order]))
|
||
const expectedSourceIds = new Set(root.feeSources.map(sourceId).filter(Boolean))
|
||
let pool = []
|
||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||
const poolResponse = await context.request('GET',
|
||
`${BASE}/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`)
|
||
const poolData = poolResponse?.body?.data
|
||
pool = rows(poolData).concat(rows(poolData?.items))
|
||
.filter((item) => String(item?.sourceType ?? '') !== 'MANUAL_COST')
|
||
const visible = new Set(pool.map((item) => id(item?.sourceId)))
|
||
if ([...expectedSourceIds].every((source) => visible.has(source))) break
|
||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||
}
|
||
const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item]))
|
||
const attemptedSources = root.feeSources.map((raw) => {
|
||
const order = orders.get(id(raw.orderId))
|
||
const financeSource = poolBySource.get(sourceId(raw))
|
||
return {
|
||
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId ?? customerId),
|
||
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
|
||
businessDate: sourceDate(raw), waybillId: sourceWaybill(raw) || sourceWaybill(order),
|
||
marker: raw.marker ?? root.marker,
|
||
supplierId: id(raw.supplierId ?? raw.partnerId ?? root.supplierId ?? root.partnerId),
|
||
supplierName: raw.supplierName ?? raw.partnerName ?? root.supplierName ?? root.partnerName ?? null,
|
||
}
|
||
})
|
||
const hasContainerAllocations = list(root.allocations).length > 0
|
||
const uncreatedSources = attemptedSources.filter((source) => !source.feeSourceId
|
||
|| (isUnboundContainerCost(source.raw) && !hasContainerAllocations))
|
||
return {
|
||
root, customerId,
|
||
sources: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw)),
|
||
uncreatedSources,
|
||
sourceMappings: attemptedSources.filter((source) => source.feeSourceId && !isUnboundContainerCost(source.raw))
|
||
.map((source) => ({ sourceFeeId: source.sourceFeeId, feeSourceId: source.feeSourceId,
|
||
sourceType: source.raw?.sourceType ?? null, direction: source.direction })),
|
||
}
|
||
}
|
||
|
||
async function createStatement(context, sources, action, name, generationMode = 'SELECTED') {
|
||
const period = dates(sources)
|
||
const body = {
|
||
customerId: sources[0].customerId, currency: 'CNY', generationMode,
|
||
feeSourceIds: generationMode === 'SELECTED' ? sources.map((source) => source.feeSourceId) : [],
|
||
periodStart: period.start, periodEnd: period.end, remark: `${context.marker}-${action}`,
|
||
}
|
||
const response = await write(context, 'POST', `${BASE}/statements`, body, action)
|
||
const statement = context.apiSuccess(MODULE, name, response,
|
||
`mode=${generationMode}, sources=${sources.length}`, OPTIONS)
|
||
if (!statement) return { body, response, statement: null }
|
||
const replayResponse = await write(context, 'POST', `${BASE}/statements`, body, action)
|
||
const replay = context.apiSuccess(MODULE, `${name}幂等重放`, replayResponse, '', OPTIONS)
|
||
context.assert(MODULE, `${name}幂等保持同一资源和版本`, replayResponse,
|
||
statementId(replay) === statementId(statement) && Number(replay?.version) === Number(statement.version),
|
||
`first=${statementId(statement)}, replay=${statementId(replay)}, version=${replay?.version ?? 'missing'}`, OPTIONS)
|
||
return { body, response, statement }
|
||
}
|
||
|
||
async function act(context, statement, action, suffix, reason, adjustmentLines) {
|
||
if (!statement) return null
|
||
const body = { action, expectedVersion: Number(statement.version) }
|
||
if (reason) body.reason = reason
|
||
if (adjustmentLines) body.adjustmentLines = adjustmentLines
|
||
const response = await write(context, 'POST',
|
||
`${BASE}/reconciliation-statements/${statementId(statement)}/actions`, body, suffix)
|
||
const next = context.apiSuccess(MODULE, `对账单执行 ${action}`, response,
|
||
`statementId=${statementId(statement)}, version=${statement.version}`, OPTIONS)
|
||
const expected = { SUBMIT: 'PENDING_REVIEW', APPROVE: 'APPROVED', REJECT: 'REJECTED',
|
||
ISSUE: 'ISSUED', ADJUST: 'ISSUED', VOID: 'VOIDED', REOPEN: 'DRAFT' }[action]
|
||
context.assert(MODULE, `${action} 状态与版本符合服务端状态机`, response,
|
||
next?.status === expected && Number(next?.version) === Number(statement.version) + 1,
|
||
`expected=${expected}, actual=${next?.status ?? 'missing'}, version=${next?.version ?? 'missing'}`, OPTIONS)
|
||
return next
|
||
}
|
||
|
||
async function issueSimple(context, statement, suffix) {
|
||
statement = await act(context, statement, 'SUBMIT', `${suffix}-submit`)
|
||
statement = await act(context, statement, 'APPROVE', `${suffix}-approve`)
|
||
return act(context, statement, 'ISSUE', `${suffix}-issue`)
|
||
}
|
||
|
||
async function issueCompleteChain(context, statement) {
|
||
statement = await act(context, statement, 'SUBMIT', 'chain-submit-1')
|
||
if (!statement) return null
|
||
const replayBody = { action: 'SUBMIT', expectedVersion: Number(statement.version) - 1 }
|
||
const replayResponse = await write(context, 'POST',
|
||
`${BASE}/reconciliation-statements/${statementId(statement)}/actions`, replayBody, 'chain-submit-1')
|
||
const replay = context.apiSuccess(MODULE, '状态动作幂等重放原结果', replayResponse, '', OPTIONS)
|
||
context.assert(MODULE, '动作幂等重放不重复递增版本', replayResponse,
|
||
statementId(replay) === statementId(statement) && Number(replay?.version) === Number(statement.version),
|
||
`version=${replay?.version ?? 'missing'}`, OPTIONS)
|
||
statement = await act(context, statement, 'REJECT', 'chain-reject', `${context.marker}-reject`)
|
||
statement = await act(context, statement, 'REOPEN', 'chain-reopen-rejected', `${context.marker}-reopen-rejected`)
|
||
statement = await act(context, statement, 'SUBMIT', 'chain-submit-2')
|
||
statement = await act(context, statement, 'APPROVE', 'chain-approve-1')
|
||
statement = await act(context, statement, 'ISSUE', 'chain-issue-1')
|
||
if (!statement) return null
|
||
const adjustable = list(statement.lines).slice(0, 2)
|
||
if (adjustable.length < 2) return null
|
||
const beforeRevisions = list(statement.revisions).length
|
||
const adjustments = adjustable.map((line, index) => ({
|
||
statementLineId: id(line.statementLineId ?? line.id),
|
||
adjustedAmount: Number(line.statementAmount ?? line.amount) + (index + 1) / 100,
|
||
reason: `${context.marker}-adjust-${index + 1}`,
|
||
}))
|
||
statement = await act(context, statement, 'ADJUST', 'chain-adjust', `${context.marker}-multi-line`, adjustments)
|
||
context.assert(MODULE, '多行调整写入行值、原因并新增 revision', null,
|
||
statement && adjustments.every((expected) => list(statement.lines).some((line) =>
|
||
id(line.statementLineId) === expected.statementLineId
|
||
&& Number(line.statementAmount) === expected.adjustedAmount
|
||
&& line.adjustmentReason === expected.reason))
|
||
&& list(statement?.revisions).length > beforeRevisions,
|
||
`lines=${adjustments.length}, revisions=${list(statement?.revisions).length}`, OPTIONS)
|
||
statement = await act(context, statement, 'VOID', 'chain-void', `${context.marker}-void`)
|
||
statement = await act(context, statement, 'REOPEN', 'chain-reopen-voided', `${context.marker}-reopen-voided`)
|
||
statement = await act(context, statement, 'SUBMIT', 'chain-submit-final')
|
||
statement = await act(context, statement, 'APPROVE', 'chain-approve-final')
|
||
statement = await act(context, statement, 'ISSUE', 'chain-issue-final')
|
||
const actions = new Set(list(statement?.revisions).map((revision) => revision.action))
|
||
context.assert(MODULE, '完整链保留驳回、重开、调整、作废和再次出账 revision', null,
|
||
['RECONCILIATION_REJECT', 'RECONCILIATION_REOPEN', 'RECONCILIATION_ADJUST',
|
||
'RECONCILIATION_VOID', 'RECONCILIATION_ISSUE'].every((action) => actions.has(action)),
|
||
`actions=${[...actions].join(',')}`, OPTIONS)
|
||
return statement
|
||
}
|
||
|
||
async function createReceivable(context, statement, index) {
|
||
const body = { reconciliationStatementId: statementId(statement), currency: 'CNY' }
|
||
const response = await write(context, 'POST', `${BASE}/receivable-settlements`, body, `ar-${index}`)
|
||
const settlement = context.apiSuccess(MODULE, `生成第 ${index} 张应收结算单`, response,
|
||
`statementId=${statementId(statement)}`, OPTIONS)
|
||
if (!settlement) return null
|
||
const replayResponse = await write(context, 'POST', `${BASE}/receivable-settlements`, body, `ar-${index}`)
|
||
const replay = context.apiSuccess(MODULE, `第 ${index} 张应收结算单幂等重放`, replayResponse, '', OPTIONS)
|
||
context.assert(MODULE, `第 ${index} 张 AR 幂等保持同一资源`, replayResponse,
|
||
settlementId(replay) === settlementId(settlement), `settlementId=${settlementId(settlement)}`, OPTIONS)
|
||
const listed = await findSettlementInOfficialList(context, 'RECEIVABLE', settlement.customerId,
|
||
settlementId(settlement), `应收第 ${index} 张`)
|
||
context.assert(MODULE, `第 ${index} 张 AR 正式列表金额与创建结果一致`, null,
|
||
listed && Number(listed.totalAmount) === Number(settlement.totalAmount)
|
||
&& Number(listed.outstandingAmount) === Number(settlement.outstandingAmount),
|
||
`createdTotal=${settlement.totalAmount ?? 'missing'}, listedTotal=${listed?.totalAmount ?? 'missing'}`, OPTIONS)
|
||
return settlement
|
||
}
|
||
|
||
export async function run(context) {
|
||
const start = context.steps.length
|
||
const fixture = await normalizeFixture(context)
|
||
if (!fixture) {
|
||
blocker(context, '取得本轮财务全量 Fixture',
|
||
'financeFullFixtures 必须提供匹配 marker 的 customerId/orders/feeSources;禁止猜测旧字段或历史来源',
|
||
'missing-marker-owned-finance-fixture')
|
||
finish(context, start)
|
||
return
|
||
}
|
||
fixture.root.reconciliationPrerequisite = null
|
||
const permissionResponse = await context.request('GET', '/api/platform/v1/iam/authorization/permissions')
|
||
const permissions = context.apiSuccess(MODULE, '查询当前账号 Finance 有效权限', permissionResponse, '', OPTIONS)
|
||
const granted = new Set(list(permissions).map(String))
|
||
if (granted.has(LEGACY_STATEMENT_VIEW_PERMISSION)) {
|
||
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
|
||
required: [STATEMENT_LIST_PERMISSION], legacy: [LEGACY_STATEMENT_VIEW_PERMISSION] }
|
||
blocker(context, '客户对账单旧 view 权限兼容阻断',
|
||
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据;需由 IAM 角色初始化为验收账号授予 ${STATEMENT_LIST_PERMISSION} 并移除旧资源,Finance 脚本不兼容旧权限`,
|
||
'legacy-customer-statement-view-permission', permissionResponse)
|
||
finish(context, start)
|
||
return
|
||
}
|
||
const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission))
|
||
if (missingPermissions.length > 0) {
|
||
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
|
||
required: missingPermissions, legacy: [] }
|
||
blocker(context, 'Finance 写权限门禁',
|
||
`缺少正式权限资源=${missingPermissions.join(',')}; 需由 IAM 角色初始化授予验收账号;对账接口将返回 A0403,禁止继续写入`,
|
||
'missing-finance-write-permissions', permissionResponse)
|
||
finish(context, start)
|
||
return
|
||
}
|
||
if (fixture.uncreatedSources.length > 0) {
|
||
const evidence = fixture.uncreatedSources.map((source) => {
|
||
const raw = source.raw
|
||
return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, feeSourceId=${source.feeSourceId || 'missing'}, containerBookingId=${id(raw.containerBookingId) || 'missing'}`
|
||
}).join('; ')
|
||
context.addCoverageGap(MODULE, SCENARIO, '忽略不完整的柜次成本尝试',
|
||
`Finance 未返回 feeSourceId 或逐订单/运单 allocation 关联,不得作为费用来源消费:${evidence}`,
|
||
'incomplete-container-cost-attempt', { marker: context.marker, count: fixture.uncreatedSources.length })
|
||
}
|
||
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|
||
|| !source.businessDate || !source.waybillId || source.marker !== context.marker)
|
||
if (invalid.length > 0) {
|
||
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
|
||
invalidSourceCount: invalid.length }
|
||
blocker(context, 'Fixture 来源契约完整',
|
||
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker`,
|
||
'invalid-finance-fixture-contract')
|
||
finish(context, start)
|
||
return
|
||
}
|
||
|
||
const receivables = fixture.sources.filter((source) => source.customerId === fixture.customerId
|
||
&& source.direction === 'RECEIVABLE' && source.currency === 'CNY')
|
||
const businessDates = new Set(receivables.map((source) => source.businessDate))
|
||
if (receivables.length < 4 || businessDates.size < 2) {
|
||
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
|
||
receivableCount: receivables.length, businessDateCount: businessDates.size }
|
||
blocker(context, '三张 AR 与日期范围 Fixture 门禁',
|
||
`至少需要同客户 4 条 CNY 应收来源且跨 2 个真实 businessDate;actual sources=${receivables.length}, dates=${businessDates.size}`,
|
||
'insufficient-receivable-date-fixture')
|
||
finish(context, start)
|
||
return
|
||
}
|
||
|
||
const allPeriod = dates(receivables)
|
||
const currencyResponse = await write(context, 'POST', `${BASE}/statements`, {
|
||
customerId: fixture.customerId, currency: 'USD', generationMode: 'SELECTED',
|
||
feeSourceIds: receivables.map((source) => source.feeSourceId),
|
||
periodStart: allPeriod.start, periodEnd: allPeriod.end, remark: `${context.marker}-usd`,
|
||
}, 'currency-rejected')
|
||
if (accessDenied(currencyResponse)) {
|
||
blocker(context, 'Finance 对账写权限与当前令牌不一致',
|
||
`POST ${BASE}/statements 返回 A0403 Access denied;customerId=${fixture.customerId}, currency=USD, requestId=${currencyResponse.requestId ?? 'null'}, traceId=${currencyResponse.traceId ?? 'null'};有效权限查询与 Finance JWT authority 不一致,禁止继续写入`,
|
||
'stale-finance-token-authorities', currencyResponse)
|
||
finish(context, start)
|
||
return
|
||
}
|
||
context.assert(MODULE, '对账单拒绝非 CNY 币种', currencyResponse, rejectedWith(currencyResponse, 'FIN_400'),
|
||
`code=${currencyResponse.body?.code ?? 'missing'}`, OPTIONS)
|
||
|
||
const cutoffDate = [...businessDates].sort()[0]
|
||
const cutoffSources = receivables.filter((source) => source.businessDate === cutoffDate)
|
||
if (cutoffSources.length < 2) {
|
||
blocker(context, '日期筛选多行来源门禁',
|
||
`最早真实 businessDate=${cutoffDate} 仅有 ${cutoffSources.length} 条来源,无法同时验证日期排除和多行调整`,
|
||
'insufficient-same-date-receivable-sources')
|
||
finish(context, start)
|
||
return
|
||
}
|
||
// Date coverage intentionally uses SELECTED. BY_CUTOFF queries all eligible customer history.
|
||
const remainingSources = receivables.filter((source) => source.businessDate !== cutoffDate)
|
||
if (remainingSources.length < 2) {
|
||
blocker(context, '跨日期三组来源门禁',
|
||
`最早 businessDate=${cutoffDate} 之外仅有 ${remainingSources.length} 条本轮来源,无法生成三张非空独立对账单`,
|
||
'insufficient-cross-date-receivable-sources')
|
||
finish(context, start)
|
||
return
|
||
}
|
||
const groups = [cutoffSources, ...(() => {
|
||
return [remainingSources.slice(0, 1), remainingSources.slice(1)]
|
||
})()]
|
||
const issuedStatements = []
|
||
for (let index = 0; index < groups.length; index += 1) {
|
||
const created = await createStatement(context, groups[index], `selected-${index + 1}`,
|
||
`创建第 ${index + 1} 张独立 SELECTED 对账单`)
|
||
if (accessDenied(created.response)) {
|
||
blocker(context, `第 ${index + 1} 张 SELECTED 写权限`,
|
||
`POST ${BASE}/statements 返回 A0403 Access denied;customerId=${created.body.customerId}, currency=${created.body.currency}, sourceIds=${created.body.feeSourceIds.join(',')}, requestId=${created.response.requestId ?? 'null'}, traceId=${created.response.traceId ?? 'null'};请求符合 DTO,但当前令牌缺少 platform:finance:reconciliation:create authority`,
|
||
'finance-reconciliation-create-forbidden', created.response)
|
||
finish(context, start)
|
||
return
|
||
}
|
||
if (financeSubjectMissing(created.response)) {
|
||
blocker(context, '平台财务主体 Fixture 门禁',
|
||
`POST ${BASE}/statements 返回 FIN_409 平台财务主体未配置;sourceIds=${created.body.feeSourceIds.join(',')}, requestId=${created.response.requestId ?? 'null'}, traceId=${created.response.traceId ?? 'null'};需在测试库 fin_subject 中提供 ACTIVE 主体`,
|
||
'missing-active-finance-subject', created.response)
|
||
finish(context, start)
|
||
return
|
||
}
|
||
if (index === 0) {
|
||
const customerResponse = await context.request('GET', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE')
|
||
const otherCustomer = rows(customerResponse?.body?.data)
|
||
.find((item) => id(item?.id ?? item?.tenantId) !== fixture.customerId)
|
||
if (otherCustomer) {
|
||
const otherCustomerId = id(otherCustomer.id ?? otherCustomer.tenantId)
|
||
const isolationResponse = await write(context, 'POST', `${BASE}/statements`, {
|
||
customerId: otherCustomerId, currency: 'CNY', generationMode: 'SELECTED',
|
||
feeSourceIds: [groups[index][0].feeSourceId], periodStart: groups[index][0].businessDate,
|
||
periodEnd: groups[index][0].businessDate, remark: `${context.marker}-foreign`,
|
||
}, 'cross-customer-rejected')
|
||
context.assert(MODULE, 'SELECTED 拒绝归入其他客户的本轮来源', isolationResponse,
|
||
rejectedWith(isolationResponse, 'FIN_404'),
|
||
`sourceCustomerId=${fixture.customerId}, requestedCustomerId=${otherCustomerId}, code=${isolationResponse.body?.code ?? 'missing'}`, OPTIONS)
|
||
} else {
|
||
context.addCoverageGap(MODULE, SCENARIO, 'cross-customer-isolation',
|
||
'未找到另一个 ACTIVE 客户,无法用本轮来源执行跨客户归属负向请求', 'missing-cross-customer-fixture')
|
||
}
|
||
}
|
||
context.assert(MODULE, `第 ${index + 1} 张仅使用分组来源`, created.response,
|
||
created.statement && sameIds(lineSourceIds(created.statement), groups[index].map((source) => source.feeSourceId)),
|
||
`expected=${groups[index].length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS)
|
||
const issued = index === 0
|
||
? await issueCompleteChain(context, created.statement)
|
||
: await issueSimple(context, created.statement, `selected-${index + 1}`)
|
||
if (!issued) {
|
||
blocker(context, `第 ${index + 1} 张对账单出账`, '状态流转或多行调整未返回有效结果', 'statement-issue-failed')
|
||
finish(context, start)
|
||
return
|
||
}
|
||
await verifyIssuedStatementReadback(context, fixture, issued, `第 ${index + 1} 张已出账对账单`)
|
||
issuedStatements.push(issued)
|
||
if (index === 0) {
|
||
context.assert(MODULE, 'SELECTED 日期范围只包含指定真实日期来源', created.response,
|
||
created.body.periodStart === cutoffDate && created.body.periodEnd === cutoffDate
|
||
&& sameIds(lineSourceIds(created.statement), cutoffSources.map((source) => source.feeSourceId))
|
||
&& lineSourceIds(created.statement).every((source) => !receivables
|
||
.filter((item) => item.businessDate !== cutoffDate)
|
||
.some((item) => item.feeSourceId === source)),
|
||
`date=${cutoffDate}, expected=${cutoffSources.length}, actual=${lineSourceIds(created.statement).length}`, OPTIONS)
|
||
}
|
||
}
|
||
|
||
const receivableSettlements = []
|
||
for (let index = 0; index < issuedStatements.length; index += 1) {
|
||
const settlement = await createReceivable(context, issuedStatements[index], index + 1)
|
||
if (settlement) receivableSettlements.push(settlement)
|
||
}
|
||
if (receivableSettlements.length !== 3) {
|
||
blocker(context, '生成三张独立 AR', `expected=3, actual=${receivableSettlements.length}`, 'three-ar-creation-failed')
|
||
}
|
||
|
||
const payables = fixture.sources.filter((source) => source.customerId === fixture.customerId
|
||
&& source.direction === 'PAYABLE' && source.currency === 'CNY')
|
||
const payableSupplierIds = new Set(payables.map((source) => source.supplierId).filter(Boolean))
|
||
let payableSettlement = null
|
||
if (payables.length > 0 && payableSupplierIds.size === 1 && payables.every((source) => source.supplierId)) {
|
||
const dueDate = [...new Set(payables.map((source) => source.businessDate))].sort().at(-1)
|
||
const supplierId = [...payableSupplierIds][0]
|
||
const supplierName = payables.find((source) => source.supplierId === supplierId)?.supplierName ?? null
|
||
const body = { customerId: fixture.customerId, supplierId,
|
||
feeSourceIds: payables.map((source) => source.feeSourceId), dueDate, currency: 'CNY' }
|
||
const response = await write(context, 'POST', `${BASE}/payable-settlements`, body, 'payable-settlement')
|
||
payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 partnerId/supplierId 生成应付结算单',
|
||
response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS)
|
||
if (payableSettlement) {
|
||
const listed = await findSettlementInOfficialList(context, 'PAYABLE', fixture.customerId,
|
||
settlementId(payableSettlement), '应付')
|
||
context.assert(MODULE, 'AP 正式列表供应商与金额和创建结果一致', null,
|
||
listed && id(listed.supplierId) === id(payableSettlement.supplierId)
|
||
&& Number(listed.totalAmount) === Number(payableSettlement.totalAmount),
|
||
`createdSupplier=${id(payableSettlement.supplierId)}, listedSupplier=${id(listed?.supplierId) || 'missing'}`, OPTIONS)
|
||
}
|
||
if (!payableSettlement) {
|
||
blocker(context, 'PAYABLE 真实接口创建失败',
|
||
`HTTP=${response.http}, code=${response.body?.code ?? 'missing'}, requestId=${response.requestId ?? 'null'}, traceId=${response.traceId ?? 'null'}`,
|
||
'payable-settlement-api-failure', response)
|
||
}
|
||
} else {
|
||
blocker(context, 'PAYABLE 结算 Fixture 门禁',
|
||
`需要至少一条同客户 CNY PAYABLE,且全部提供相同 partnerId/supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`,
|
||
'insufficient-payable-supplier-fixture')
|
||
}
|
||
|
||
const statementRows = [
|
||
{ kind: 'SELECTED_DATE', statementId: statementId(issuedStatements[0]), status: issuedStatements[0].status,
|
||
version: issuedStatements[0].version, feeSourceIds: cutoffSources.map((source) => source.feeSourceId),
|
||
periodStart: cutoffDate, periodEnd: cutoffDate },
|
||
...issuedStatements.map((statement, index) => ({ kind: index === 0 ? 'SELECTED_FULL_CHAIN' : 'SELECTED',
|
||
statementId: statementId(statement), status: statement.status, version: statement.version,
|
||
feeSourceIds: groups[index].map((source) => source.feeSourceId),
|
||
periodStart: dates(groups[index]).start, periodEnd: dates(groups[index]).end })),
|
||
]
|
||
const arRows = receivableSettlements.map((settlement, index) => ({
|
||
settlementId: settlementId(settlement), statementId: statementId(issuedStatements[index]),
|
||
direction: settlement.direction, status: settlement.status, paymentStatus: settlement.paymentStatus,
|
||
version: settlement.version,
|
||
}))
|
||
fixture.root.statements = statementRows
|
||
fixture.root.statementMap = { byCutoff: statementRows[0], fullChain: statementRows[1],
|
||
selected: statementRows.slice(1) }
|
||
fixture.root.receivableSettlements = arRows
|
||
fixture.root.receivableSettlementMap = { byStatementId: Object.fromEntries(arRows.map((row) => [row.statementId, row])) }
|
||
fixture.root.payableSettlements = payableSettlement ? [{
|
||
settlementId: settlementId(payableSettlement), direction: payableSettlement.direction,
|
||
supplierId: id(payableSettlement.supplierId), partnerId: id(payableSettlement.partnerId ?? payableSettlement.supplierId),
|
||
supplierName: payableSettlement.supplierName ?? payables[0]?.supplierName ?? null,
|
||
status: payableSettlement.status,
|
||
paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version,
|
||
feeSourceIds: payables.map((source) => source.feeSourceId),
|
||
}] : []
|
||
fixture.root.sourceMappings = fixture.sourceMappings
|
||
await verifyCustomerAccountAndLedger(context, fixture)
|
||
finish(context, start)
|
||
}
|