2026-08-12 10:59:39 +08:00
|
|
|
|
const MODULE = 'finance-reconciliation-full'
|
|
|
|
|
|
const SCENARIO = 'finance-full-reconciliation'
|
|
|
|
|
|
const BASE = '/api/platform/finance/v1'
|
|
|
|
|
|
const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true }
|
2026-08-13 14:03:54 +08:00
|
|
|
|
const STATEMENT_LIST_PERMISSION = 'platform:finance:customer-statement:list'
|
|
|
|
|
|
const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
|
2026-08-12 13:24:17 +08:00
|
|
|
|
const REQUIRED_PERMISSIONS = [
|
2026-08-13 14:03:54 +08:00
|
|
|
|
'platform:finance:customer-account:list',
|
|
|
|
|
|
STATEMENT_LIST_PERMISSION,
|
2026-08-12 13:24:17 +08:00
|
|
|
|
'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',
|
2026-08-13 14:03:54 +08:00
|
|
|
|
'platform:finance:settlement-ar:list',
|
2026-08-12 13:24:17 +08:00
|
|
|
|
'platform:finance:settlement-ap:create',
|
2026-08-13 14:03:54 +08:00
|
|
|
|
'platform:finance:settlement-ap:list',
|
2026-08-12 13:24:17 +08:00
|
|
|
|
]
|
2026-08-12 10:59:39 +08:00
|
|
|
|
|
|
|
|
|
|
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 ?? '' }
|
2026-08-12 14:46:52 +08:00
|
|
|
|
function sourceWaybill(value) { return id(value?.waybillId ?? value?.shipmentId ?? value?.orderNo ?? value?.sourceNo) }
|
2026-08-12 14:21:25 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-12 13:24:17 +08:00
|
|
|
|
function isUnboundContainerCost(raw) {
|
|
|
|
|
|
return raw?.sourceType === 'MANUAL' && raw?.billingBasis === 'CONTAINER_SHARED'
|
|
|
|
|
|
&& !sourceWaybill(raw)
|
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
|
function lineSourceIds(value) { return list(value?.lines).map(sourceId).filter(Boolean) }
|
2026-08-12 13:24:17 +08:00
|
|
|
|
function rejectedWith(response, code) {
|
|
|
|
|
|
return response?.http === 200 && String(response?.body?.code) === code
|
|
|
|
|
|
}
|
|
|
|
|
|
function accessDenied(response) { return rejectedWith(response, 'A0403') }
|
2026-08-12 13:55:16 +08:00
|
|
|
|
function financeSubjectMissing(response) {
|
|
|
|
|
|
return rejectedWith(response, 'FIN_409')
|
|
|
|
|
|
&& String(response?.body?.message ?? '').includes('平台财务主体未配置')
|
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
|
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])
|
|
|
|
|
|
}
|
2026-08-13 09:52:59 +08:00
|
|
|
|
|
2026-08-13 14:03:54 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-13 09:52:59 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
|
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 }
|
|
|
|
|
|
}
|
2026-08-13 14:03:54 +08:00
|
|
|
|
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))
|
|
|
|
|
|
}
|
2026-08-17 15:41:42 +08:00
|
|
|
|
function accountInformationComplete(value) {
|
|
|
|
|
|
return ['customerCode', 'customerName', 'contactName', 'contactPhone', 'status', 'lastChangedAt']
|
|
|
|
|
|
.every((field) => present(value?.[field]))
|
|
|
|
|
|
&& ['outstandingReceivable', 'pendingBilling', 'receivedInPeriod', 'outstandingPayable']
|
|
|
|
|
|
.every((field) => Number.isFinite(Number(value?.[field])))
|
|
|
|
|
|
}
|
|
|
|
|
|
function ledgerInformationComplete(value) {
|
|
|
|
|
|
return ['ledgerId', 'occurredAt', 'businessType', 'documentType', 'documentId', 'documentNo', 'operatorName']
|
|
|
|
|
|
.every((field) => present(value?.[field]))
|
|
|
|
|
|
&& ['receivableChange', 'payableChange', 'outstandingReceivableAfter']
|
|
|
|
|
|
.every((field) => Number.isFinite(Number(value?.[field])))
|
|
|
|
|
|
}
|
2026-08-13 14:03:54 +08:00
|
|
|
|
async function verifyCustomerAccountAndLedger(context, fixture) {
|
|
|
|
|
|
const customerId = id(fixture.customerId)
|
2026-08-15 17:52:46 +08:00
|
|
|
|
const accountResponse = await context.request('GET',
|
|
|
|
|
|
`${BASE}/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`)
|
2026-08-13 14:03:54 +08:00
|
|
|
|
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)
|
2026-08-17 15:41:42 +08:00
|
|
|
|
context.assert(MODULE, '客户账户基本信息、四项金额和最近变动时间完整', accountResponse,
|
|
|
|
|
|
accountInformationComplete(account),
|
|
|
|
|
|
`customerId=${customerId}, customerCode=${account?.customerCode ?? 'missing'}, contact=${account?.contactName ?? 'missing'}/${account?.contactPhone ?? 'missing'}, lastChangedAt=${account?.lastChangedAt ?? 'missing'}`, OPTIONS)
|
2026-08-13 14:03:54 +08:00
|
|
|
|
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)
|
2026-08-17 15:41:42 +08:00
|
|
|
|
const incompleteLedger = rowsForCustomer.filter((item) => !ledgerInformationComplete(item))
|
|
|
|
|
|
context.assert(MODULE, '客户账户流水时间、业务类型、关联单据、金额和操作人完整', ledgerResponse,
|
|
|
|
|
|
rowsForCustomer.length > 0 && incompleteLedger.length === 0,
|
|
|
|
|
|
`customerId=${customerId}, ledgerCount=${rowsForCustomer.length}, incomplete=${incompleteLedger.length}`, OPTIONS)
|
2026-08-13 14:03:54 +08:00
|
|
|
|
fixture.root.accountReadback = { account: account || null, accountDiagnostic: diagnostic(accountResponse),
|
|
|
|
|
|
ledgerCount: ledger.length, ledgerDiagnostic: diagnostic(ledgerResponse) }
|
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
|
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) })
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-12 14:21:25 +08:00
|
|
|
|
async function normalizeFixture(context) {
|
2026-08-12 10:59:39 +08:00
|
|
|
|
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)
|
2026-08-12 14:56:50 +08:00
|
|
|
|
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))
|
|
|
|
|
|
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))
|
|
|
|
|
|
}
|
2026-08-12 14:21:25 +08:00
|
|
|
|
const poolBySource = new Map(pool.map((item) => [id(item?.sourceId), item]))
|
2026-08-12 12:47:06 +08:00
|
|
|
|
const attemptedSources = root.feeSources.map((raw) => {
|
2026-08-12 14:21:25 +08:00
|
|
|
|
const financeSource = poolBySource.get(sourceId(raw))
|
2026-08-12 10:59:39 +08:00
|
|
|
|
return {
|
2026-08-15 17:45:51 +08:00
|
|
|
|
raw, feeSourceId: financePoolId(financeSource), sourceFeeId: sourceId(raw), customerId: id(raw.customerId),
|
2026-08-12 10:59:39 +08:00
|
|
|
|
direction: String(raw.direction ?? ''), currency: String(raw.currency ?? raw.currencyCode ?? '').toUpperCase(),
|
2026-08-15 17:45:51 +08:00
|
|
|
|
businessDate: sourceDate(raw), waybillId: sourceWaybill(raw), marker: raw.marker,
|
2026-08-19 09:42:07 +08:00
|
|
|
|
supplierId: id(raw.supplierId ?? root.supplierId),
|
|
|
|
|
|
supplierName: raw.supplierName ?? root.supplierName ?? null,
|
2026-08-12 10:59:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
})
|
2026-08-12 15:53:47 +08:00
|
|
|
|
const hasContainerAllocations = list(root.allocations).length > 0
|
|
|
|
|
|
const uncreatedSources = attemptedSources.filter((source) => !source.feeSourceId
|
|
|
|
|
|
|| (isUnboundContainerCost(source.raw) && !hasContainerAllocations))
|
2026-08-12 12:47:06 +08:00
|
|
|
|
return {
|
|
|
|
|
|
root, customerId,
|
2026-08-15 12:12:34 +08:00
|
|
|
|
sources: attemptedSources.filter((source) => source.feeSourceId
|
|
|
|
|
|
&& (!isUnboundContainerCost(source.raw) || hasContainerAllocations)),
|
2026-08-12 13:24:17 +08:00
|
|
|
|
uncreatedSources,
|
2026-08-15 12:12:34 +08:00
|
|
|
|
sourceMappings: attemptedSources.filter((source) => source.feeSourceId
|
|
|
|
|
|
&& (!isUnboundContainerCost(source.raw) || hasContainerAllocations))
|
2026-08-12 15:53:47 +08:00
|
|
|
|
.map((source) => ({ sourceFeeId: source.sourceFeeId, feeSourceId: source.feeSourceId,
|
|
|
|
|
|
sourceType: source.raw?.sourceType ?? null, direction: source.direction })),
|
2026-08-12 12:47:06 +08:00
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-08-13 09:52:59 +08:00
|
|
|
|
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)
|
2026-08-12 10:59:39 +08:00
|
|
|
|
return settlement
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function run(context) {
|
|
|
|
|
|
const start = context.steps.length
|
2026-08-12 14:21:25 +08:00
|
|
|
|
const fixture = await normalizeFixture(context)
|
2026-08-12 10:59:39 +08:00
|
|
|
|
if (!fixture) {
|
|
|
|
|
|
blocker(context, '取得本轮财务全量 Fixture',
|
|
|
|
|
|
'financeFullFixtures 必须提供匹配 marker 的 customerId/orders/feeSources;禁止猜测旧字段或历史来源',
|
|
|
|
|
|
'missing-marker-owned-finance-fixture')
|
|
|
|
|
|
finish(context, start)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-13 14:37:51 +08:00
|
|
|
|
fixture.root.reconciliationPrerequisite = null
|
2026-08-12 13:24:17 +08:00
|
|
|
|
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))
|
2026-08-13 14:03:54 +08:00
|
|
|
|
if (granted.has(LEGACY_STATEMENT_VIEW_PERMISSION)) {
|
2026-08-13 14:37:51 +08:00
|
|
|
|
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
|
|
|
|
|
|
required: [STATEMENT_LIST_PERMISSION], legacy: [LEGACY_STATEMENT_VIEW_PERMISSION] }
|
2026-08-13 14:03:54 +08:00
|
|
|
|
blocker(context, '客户对账单旧 view 权限兼容阻断',
|
2026-08-13 14:37:51 +08:00
|
|
|
|
`检测到旧权限=${LEGACY_STATEMENT_VIEW_PERMISSION};列表和详情只能以正式权限=${STATEMENT_LIST_PERMISSION} 验收,旧 view 不得作为通过依据;需由 IAM 角色初始化为验收账号授予 ${STATEMENT_LIST_PERMISSION} 并移除旧资源,Finance 脚本不兼容旧权限`,
|
2026-08-13 14:03:54 +08:00
|
|
|
|
'legacy-customer-statement-view-permission', permissionResponse)
|
|
|
|
|
|
finish(context, start)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-12 13:24:17 +08:00
|
|
|
|
const missingPermissions = REQUIRED_PERMISSIONS.filter((permission) => !granted.has(permission))
|
|
|
|
|
|
if (missingPermissions.length > 0) {
|
2026-08-13 14:37:51 +08:00
|
|
|
|
fixture.root.reconciliationPrerequisite = { ready: false, category: 'permission-prerequisite',
|
|
|
|
|
|
required: missingPermissions, legacy: [] }
|
2026-08-12 13:24:17 +08:00
|
|
|
|
blocker(context, 'Finance 写权限门禁',
|
2026-08-13 14:37:51 +08:00
|
|
|
|
`缺少正式权限资源=${missingPermissions.join(',')}; 需由 IAM 角色初始化授予验收账号;对账接口将返回 A0403,禁止继续写入`,
|
2026-08-12 13:24:17 +08:00
|
|
|
|
'missing-finance-write-permissions', permissionResponse)
|
|
|
|
|
|
finish(context, start)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-15 17:52:46 +08:00
|
|
|
|
const incompleteContainerSources = fixture.uncreatedSources.filter((source) => isUnboundContainerCost(source.raw))
|
|
|
|
|
|
if (incompleteContainerSources.length > 0) {
|
|
|
|
|
|
const evidence = incompleteContainerSources.map((source) => {
|
2026-08-12 12:47:06 +08:00
|
|
|
|
const raw = source.raw
|
2026-08-12 13:24:17 +08:00
|
|
|
|
return `sourceType=${raw.sourceType ?? 'missing'}, billingBasis=${raw.billingBasis ?? 'missing'}, feeSourceId=${source.feeSourceId || 'missing'}, containerBookingId=${id(raw.containerBookingId) || 'missing'}`
|
2026-08-12 12:47:06 +08:00
|
|
|
|
}).join('; ')
|
2026-08-12 13:24:17 +08:00
|
|
|
|
context.addCoverageGap(MODULE, SCENARIO, '忽略不完整的柜次成本尝试',
|
|
|
|
|
|
`Finance 未返回 feeSourceId 或逐订单/运单 allocation 关联,不得作为费用来源消费:${evidence}`,
|
2026-08-15 17:52:46 +08:00
|
|
|
|
'incomplete-container-cost-attempt', { marker: context.marker, count: incompleteContainerSources.length })
|
|
|
|
|
|
}
|
|
|
|
|
|
const unpublishedSources = fixture.uncreatedSources.filter((source) => !isUnboundContainerCost(source.raw))
|
|
|
|
|
|
if (unpublishedSources.length > 0) {
|
|
|
|
|
|
blocker(context, '本轮费用来源进入 Finance 池',
|
|
|
|
|
|
`等待 Finance fee-pool 后仍有 ${unpublishedSources.length} 条 marker-owned 来源不可见;禁止忽略来源或改用 MANUAL_COST ID`,
|
|
|
|
|
|
'unpublished-marker-owned-finance-source')
|
|
|
|
|
|
finish(context, start)
|
|
|
|
|
|
return
|
2026-08-12 12:47:06 +08:00
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
|
const invalid = fixture.sources.filter((source) => !source.feeSourceId || !source.customerId
|
2026-08-15 17:52:46 +08:00
|
|
|
|
|| !source.businessDate || !source.waybillId || source.marker !== context.marker
|
|
|
|
|
|
|| (source.direction === 'PAYABLE' && (!source.supplierId || !source.supplierName)))
|
2026-08-12 10:59:39 +08:00
|
|
|
|
if (invalid.length > 0) {
|
2026-08-13 14:37:51 +08:00
|
|
|
|
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
|
|
|
|
|
|
invalidSourceCount: invalid.length }
|
2026-08-12 10:59:39 +08:00
|
|
|
|
blocker(context, 'Fixture 来源契约完整',
|
2026-08-15 17:52:46 +08:00
|
|
|
|
`存在 ${invalid.length} 条来源缺少 feeSourceId/customerId/businessDate/运单标识/marker/应付供应商快照`,
|
2026-08-12 10:59:39 +08:00
|
|
|
|
'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) {
|
2026-08-13 14:37:51 +08:00
|
|
|
|
fixture.root.reconciliationPrerequisite = { ready: false, category: 'finance-source-prerequisite',
|
|
|
|
|
|
receivableCount: receivables.length, businessDateCount: businessDates.size }
|
2026-08-12 10:59:39 +08:00
|
|
|
|
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')
|
2026-08-12 13:24:17 +08:00
|
|
|
|
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'),
|
2026-08-12 10:59:39 +08:00
|
|
|
|
`code=${currencyResponse.body?.code ?? 'missing'}`, OPTIONS)
|
|
|
|
|
|
|
|
|
|
|
|
const cutoffDate = [...businessDates].sort()[0]
|
|
|
|
|
|
const cutoffSources = receivables.filter((source) => source.businessDate === cutoffDate)
|
2026-08-12 12:35:29 +08:00
|
|
|
|
if (cutoffSources.length < 2) {
|
|
|
|
|
|
blocker(context, '日期筛选多行来源门禁',
|
|
|
|
|
|
`最早真实 businessDate=${cutoffDate} 仅有 ${cutoffSources.length} 条来源,无法同时验证日期排除和多行调整`,
|
|
|
|
|
|
'insufficient-same-date-receivable-sources')
|
2026-08-12 10:59:39 +08:00
|
|
|
|
finish(context, start)
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
2026-08-12 12:35:29 +08:00
|
|
|
|
// 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)]
|
|
|
|
|
|
})()]
|
2026-08-12 10:59:39 +08:00
|
|
|
|
const issuedStatements = []
|
|
|
|
|
|
for (let index = 0; index < groups.length; index += 1) {
|
|
|
|
|
|
const created = await createStatement(context, groups[index], `selected-${index + 1}`,
|
|
|
|
|
|
`创建第 ${index + 1} 张独立 SELECTED 对账单`)
|
2026-08-12 13:24:17 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-12 13:55:16 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-12 15:53:47 +08:00
|
|
|
|
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')
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
2026-08-13 14:03:54 +08:00
|
|
|
|
await verifyIssuedStatementReadback(context, fixture, issued, `第 ${index + 1} 张已出账对账单`)
|
2026-08-12 10:59:39 +08:00
|
|
|
|
issuedStatements.push(issued)
|
2026-08-12 12:35:29 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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')
|
2026-08-19 09:42:07 +08:00
|
|
|
|
payableSettlement = context.apiSuccess(MODULE, '按 Fixture PAYABLE 与 supplierId 生成应付结算单',
|
2026-08-12 10:59:39 +08:00
|
|
|
|
response, `sources=${payables.length}, supplierId=${supplierId}, supplierName=${supplierName ?? 'null'}`, OPTIONS)
|
2026-08-13 09:52:59 +08:00
|
|
|
|
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)
|
|
|
|
|
|
}
|
2026-08-12 10:59:39 +08:00
|
|
|
|
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 门禁',
|
2026-08-19 09:42:07 +08:00
|
|
|
|
`需要至少一条同客户 CNY PAYABLE,且全部提供相同 supplierId;sources=${payables.length}, suppliers=${payableSupplierIds.size}`,
|
2026-08-12 10:59:39 +08:00
|
|
|
|
'insufficient-payable-supplier-fixture')
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const statementRows = [
|
2026-08-12 12:35:29 +08:00
|
|
|
|
{ kind: 'SELECTED_DATE', statementId: statementId(issuedStatements[0]), status: issuedStatements[0].status,
|
|
|
|
|
|
version: issuedStatements[0].version, feeSourceIds: cutoffSources.map((source) => source.feeSourceId),
|
2026-08-12 10:59:39 +08:00
|
|
|
|
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,
|
2026-08-19 09:42:07 +08:00
|
|
|
|
supplierId: id(payableSettlement.supplierId),
|
2026-08-12 10:59:39 +08:00
|
|
|
|
supplierName: payableSettlement.supplierName ?? payables[0]?.supplierName ?? null,
|
|
|
|
|
|
status: payableSettlement.status,
|
|
|
|
|
|
paymentStatus: payableSettlement.paymentStatus, version: payableSettlement.version,
|
|
|
|
|
|
feeSourceIds: payables.map((source) => source.feeSourceId),
|
|
|
|
|
|
}] : []
|
2026-08-12 15:53:47 +08:00
|
|
|
|
fixture.root.sourceMappings = fixture.sourceMappings
|
2026-08-13 14:03:54 +08:00
|
|
|
|
await verifyCustomerAccountAndLedger(context, fixture)
|
2026-08-12 10:59:39 +08:00
|
|
|
|
finish(context, start)
|
|
|
|
|
|
}
|