test(finance): align full acceptance with current workflows

This commit is contained in:
2026-09-01 10:17:03 +08:00
parent 505188c3fd
commit 3d6216910f
6 changed files with 80 additions and 38 deletions
@@ -37,6 +37,7 @@ test('验收只使用本轮 marker fixture,解析真实 XLSX 并覆盖门禁
assert.match(source, /无 Finance export 权限拒绝创建/)
assert.match(source, /Provider 失败信息可见/)
assert.match(source, /9223372036854775806/)
assert.match(source, /find\(\(item\) => item\?\.direction === 'RECEIVABLE'\)/)
assert.doesNotMatch(source, /exportType:\s*['"]CUSTOMER_LIST/)
assert.doesNotMatch(source, /FINANCE_FEE_SOURCE_LIST:[^\n]*参与结算/)
})
@@ -233,7 +233,10 @@ export async function run(context) {
const ar = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(arId)}`, '读取应收结算导出事实')
const ap = await official(context, `/api/platform/finance/v1/settlements/${encodeURIComponent(apId)}`, '读取应付结算导出事实')
const fee = await official(context, `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=ALL`, '读取费用来源导出事实')
const feeRow = values(fee.data)[0]
// PAYABLE rows are removed from the payable pool once an AP settlement owns
// them. Use a receivable row so the export filter still has an authoritative
// post-settlement match instead of asserting against an intentionally consumed row.
const feeRow = values(fee.data).find((item) => item?.direction === 'RECEIVABLE') || values(fee.data)[0]
const statementNo = statement.data?.statementNo
const arSummary = ar.data?.summary || ar.data
const apSummary = ap.data?.summary || ap.data
@@ -16,6 +16,7 @@ test('Finance AP consumes only published fin_fee_source rows and never substitut
})
const financePermissions = [
'platform:finance:customer-account:list', 'platform:finance:customer-arap:list',
'platform:finance:fee-source:edit',
'platform:finance:fee-source:review', 'platform:finance:customer-statement:list',
'platform:finance:reconciliation:create', 'platform:finance:reconciliation:review',
'platform:finance:reconciliation:issue', 'platform:finance:reconciliation:adjust',
@@ -61,6 +62,7 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
const calls = []
const steps = []
const coverageGaps = []
const submittedSourceIds = new Set()
const reviewedSourceIds = new Set()
function responseForStatement(statement) { return structuredClone(statement) }
@@ -124,14 +126,24 @@ function fakeContext(withFixture = true, permissions = financePermissions, denyF
const feeSourceDetailMatch = requestPath.match(/\/fee-sources\/(\d+)$/)
if (method === 'GET' && feeSourceDetailMatch) {
const localId = feeSourceDetailMatch[1]
return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED' : 'PENDING',
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: reviewedSourceIds.has(localId) ? 1 : 0 })
return ok({ feeSourceId: localId, reviewStatus: reviewedSourceIds.has(localId) ? 'APPROVED'
: submittedSourceIds.has(localId) ? 'PENDING' : 'DRAFT',
sourceStatus: 'AVAILABLE', statementGenerated: false,
operationVersion: reviewedSourceIds.has(localId) ? 2 : submittedSourceIds.has(localId) ? 1 : 0 })
}
const feeSourceSubmitMatch = requestPath.match(/\/fee-sources\/(\d+)\/submit$/)
if (method === 'POST' && feeSourceSubmitMatch) {
submittedSourceIds.add(feeSourceSubmitMatch[1])
const submitted = { feeSourceId: feeSourceSubmitMatch[1], reviewStatus: 'PENDING',
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 }
if (idem) idempotency.set(idem, submitted)
return ok(submitted)
}
const feeSourceReviewMatch = requestPath.match(/\/fee-sources\/(\d+)\/review$/)
if (method === 'POST' && feeSourceReviewMatch) {
reviewedSourceIds.add(feeSourceReviewMatch[1])
const reviewed = { feeSourceId: feeSourceReviewMatch[1], reviewStatus: 'APPROVED',
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 1 }
sourceStatus: 'AVAILABLE', statementGenerated: false, operationVersion: 2 }
if (idem) idempotency.set(idem, reviewed)
return ok(reviewed)
}
@@ -8,6 +8,7 @@ const LEGACY_STATEMENT_VIEW_PERMISSION = 'platform:finance:bill:view'
const REQUIRED_PERMISSIONS = [
'platform:finance:customer-account:list',
'platform:finance:customer-arap:list',
'platform:finance:fee-source:edit',
'platform:finance:fee-source:review',
STATEMENT_LIST_PERMISSION,
'platform:finance:reconciliation:create',
@@ -172,6 +173,16 @@ async function approveFixtureSources(context, fixture) {
let detail = context.apiSuccess(MODULE, '回读本轮 Finance 费用来源审核状态', detailResponse,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
if (!detail) return false
if (detail.reviewStatus === 'DRAFT' || detail.reviewStatus === 'REJECTED') {
const submitResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/submit`, {
reason: context.marker, expectedVersion: Number(detail.operationVersion),
}, `fee-source-submit-${source.feeSourceId}`)
detail = context.apiSuccess(MODULE, '提交本轮 Finance 费用来源审核', submitResponse,
`feeSourceId=${source.feeSourceId}`, OPTIONS)
context.assert(MODULE, '费用来源提交后进入待审核', submitResponse,
detail?.reviewStatus === 'PENDING' && detail?.sourceStatus === 'AVAILABLE',
`feeSourceId=${source.feeSourceId}, reviewStatus=${detail?.reviewStatus ?? 'missing'}, sourceStatus=${detail?.sourceStatus ?? 'missing'}`, OPTIONS)
}
if (detail.reviewStatus === 'PENDING') {
const reviewResponse = await write(context, 'POST', `${BASE}/fee-sources/${encodeURIComponent(source.feeSourceId)}/review`, {
action: 'APPROVE', reason: context.marker, expectedVersion: Number(detail.operationVersion),
@@ -22,6 +22,21 @@ function failure(response) {
}
function records(page) { return Array.isArray(page?.records) ? page.records : [] }
async function waitForCompletedTask(context, taskId, warehouseId, attempts = 45) {
let response = null
let task = null
for (let attempt = 1; attempt <= attempts; attempt += 1) {
response = await context.request('GET',
`${PDA}/tasks/${taskId}?warehouseId=${encodeURIComponent(warehouseId)}`)
task = response?.http === 200 && codeOf(response) === '200' ? response.body?.data : null
if (task?.taskStatus === 'COMPLETED' && task?.workflowStatus === 'COMPLETED') {
return { response, task, attempts: attempt }
}
if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, 1_000))
}
return { response, task, attempts }
}
async function request(context, method, path, body, headers = {}) {
try {
const response = await fetch(`${context.options.gateway}${path}`, {
@@ -43,15 +58,21 @@ async function request(context, method, path, body, headers = {}) {
}
}
async function uploadLoadingEvidence(context, tenantId, inboundId, evidenceType) {
const path = `/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}?evidenceType=${encodeURIComponent(evidenceType)}`
async function uploadLoadingEvidence(context, taskId, warehouseId, evidenceType, sealNo) {
const path = `${PDA}/tasks/${taskId}/evidence`
const form = new FormData()
const png = Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130])
form.append('warehouseId', String(warehouseId))
form.append('expectedVersion', '0')
form.append('evidenceType', evidenceType)
if (evidenceType === 'SEAL') form.append('sealNo', sealNo)
form.append('remark', context.marker)
form.append('file', new Blob([png], { type: 'image/png' }), `${context.marker}-${evidenceType.toLowerCase()}.png`)
try {
const response = await fetch(`${context.options.gateway}${path}`, {
method: 'POST',
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}` },
headers: { Accept: 'application/json', Authorization: `Bearer ${context.token}`,
'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) },
body: form,
signal: AbortSignal.timeout(60_000),
})
@@ -66,14 +87,6 @@ async function uploadLoadingEvidence(context, tenantId, inboundId, evidenceType)
}
}
async function bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo) {
return request(context, 'POST',
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`,
{ expectedVersion: 0, evidenceType, fileId, fileName: 'client-must-not-override.png',
sealNo: evidenceType === 'SEAL' ? sealNo : null, remark: context.marker },
{ 'Idempotency-Key': `${context.marker}-cab-evidence-${evidenceType}`.slice(0, 128) })
}
export function selectContainerLoadingFixture(context) {
const candidates = [
context.createdResources?.orchestrationFixtures?.phase3Container,
@@ -721,30 +734,22 @@ export async function run(context) {
const evidenceTypes = ['PHOTO_STEP_1', 'PHOTO_STEP_2', 'PHOTO_STEP_3', 'PHOTO_STEP_4', 'SEAL']
const sealNo = `SEAL-${suffix}`.slice(0, 64)
for (const evidenceType of evidenceTypes) {
const uploadResponse = await uploadLoadingEvidence(context, tenantId, inboundId, evidenceType)
const uploaded = context.apiSuccess(MODULE, `上传柜次 ${evidenceType} 真实证据文件`, uploadResponse,
const uploadResponse = await uploadLoadingEvidence(context, taskId, warehouseId, evidenceType, sealNo)
const bound = context.apiSuccess(MODULE, `上传并绑定柜次 ${evidenceType} 真实证据文件`, uploadResponse,
responseEvidence(uploadResponse), OPTIONS)
const fileId = id(uploaded?.fileId)
context.assert(MODULE, `${evidenceType} 文件类型、归属和二进制元数据完整`, uploadResponse,
Boolean(fileId) && uploaded?.businessType === `LOADING_${evidenceType}`
&& uploaded?.contentType === 'image/png' && Number(uploaded?.sizeBytes) > 0,
`fileId=${fileId || 'missing'}, businessType=${uploaded?.businessType || 'missing'}, size=${uploaded?.sizeBytes ?? 'missing'}`, OPTIONS)
if (!fileId) return
const bindResponse = await bindLoadingEvidence(context, inboundId, tenantId, evidenceType, fileId, sealNo)
const bound = context.apiSuccess(MODULE, `绑定柜次 ${evidenceType} 装柜证据`, bindResponse,
responseEvidence(bindResponse), OPTIONS)
const boundItem = bound?.evidences?.evidences?.find?.((item) =>
item.evidenceType === evidenceType && id(item.fileId) === fileId && item.status !== 'VOID')
context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, bindResponse,
bound?.mainStatus === 'LOADING' && Boolean(boundItem?.id)
const boundItem = bound?.evidences?.find?.((item) => item.evidenceType === evidenceType && item.status !== 'VOID')
const evidenceId = id(boundItem?.evidenceId)
const fileId = id(boundItem?.fileId)
context.assert(MODULE, `${evidenceType} 有效证据归属稳定且锁码门禁完整`, uploadResponse,
id(bound?.taskId) === taskId && id(bound?.containerBookingId) === containerId
&& Boolean(evidenceId) && Boolean(fileId)
&& boundItem?.remark === context.marker
&& (evidenceType !== 'SEAL' || boundItem?.sealNo === sealNo),
`mainStatus=${bound?.mainStatus || 'missing'}, evidenceId=${id(boundItem?.id) || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS)
if (!boundItem?.id) return
`taskId=${id(bound?.taskId) || 'missing'}, containerBookingId=${id(bound?.containerBookingId) || 'missing'}, evidenceId=${evidenceId || 'missing'}, sealNo=${boundItem?.sealNo || 'n/a'}`, OPTIONS)
if (!evidenceId) return
const downloadResponse = await context.requestBinary('GET',
`/api/platform/v1/files/loading-evidence/${tenantId}/${inboundId}/${fileId}`)
`${PDA}/tasks/${taskId}/evidence/${encodeURIComponent(evidenceId)}/content?warehouseId=${encodeURIComponent(warehouseId)}`)
context.assert(MODULE, `下载柜次 ${evidenceType} 证据为非空受控二进制`, downloadResponse,
downloadResponse.http === 200 && downloadResponse.bytes.length > 0
&& downloadResponse.contentType.toLowerCase().includes('image/png'),
@@ -752,7 +757,7 @@ export async function run(context) {
}
const evidenceListResponse = await context.request('GET',
`/api/platform/v1/inbounds/${inboundId}/loading/evidence?tenantId=${encodeURIComponent(tenantId)}`)
`${PDA}/tasks/${taskId}/evidence?warehouseId=${encodeURIComponent(warehouseId)}`)
const evidenceList = context.apiSuccess(MODULE, 'PDA 完成前回查五类有效装柜证据与锁码', evidenceListResponse,
'', OPTIONS)
const activeEvidence = Array.isArray(evidenceList?.evidences)
@@ -771,13 +776,19 @@ export async function run(context) {
remark: context.marker,
}
const completeResponse = await request(context, 'POST', completePath, completePayload)
const completed = context.apiSuccess(MODULE, 'PDA 人工确认装柜完成', completeResponse, '', OPTIONS)
const acceptedCompletion = context.apiSuccess(MODULE, 'PDA 提交人工确认装柜完成', completeResponse, '', OPTIONS)
context.assert(MODULE, '装柜完成命令已接受并进入处理或终态', completeResponse,
['COMPLETING', 'COMPLETED'].includes(acceptedCompletion?.taskStatus),
`taskStatus=${acceptedCompletion?.taskStatus || 'missing'}, workflow=${acceptedCompletion?.workflowStatus || 'missing'}`, OPTIONS)
const completion = await waitForCompletedTask(context, taskId, warehouseId)
const completed = context.apiSuccess(MODULE, '等待 PDA 装柜完成异步收敛', completion.response,
`attempts=${completion.attempts}`, OPTIONS)
const directPdaCompletion = completed?.taskStatus === 'COMPLETED'
&& completed?.workflowStatus === 'COMPLETED'
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completeResponse,
context.assert(MODULE, '人工确认后 PDA 任务进入 COMPLETED', completion.response,
directPdaCompletion
&& completed?.items?.every?.((item) => Number(item.pickedPackageCount) === Number(item.plannedPackageCount)),
`taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}`, OPTIONS)
`taskStatus=${completed?.taskStatus || 'missing'}, workflow=${completed?.workflowStatus || 'missing'}, attempts=${completion.attempts}`, OPTIONS)
const operationsResponse = await request(context, 'GET', `${PDA}/tasks/${taskId}/operations`)
const operations = context.apiSuccess(MODULE, '回查完成后的 PDA 操作审计', operationsResponse, '', OPTIONS)
@@ -97,7 +97,11 @@ test('PDA task opens only after no-number push, release, four-field link and pla
assert.match(containerLoadingAcceptance, /订舱放舱为 RELEASED 且不预生成真实货柜/)
assert.match(containerLoadingAcceptance, /订舱 RELEASED 但预装未 CONFIRMED 不生成 PDA 任务/)
assert.match(containerLoadingAcceptance, /loading-plans\/\$\{planId\}\/confirm/)
assert.match(containerLoadingAcceptance, /PDA}\/tasks\/\$\{taskId\}\/evidence/)
assert.match(containerLoadingAcceptance, /evidence\/\$\{encodeURIComponent\(evidenceId\)\}\/content/)
assert.match(containerLoadingAcceptance, /计划级原子确认整份预装并触发唯一 PDA 任务/)
assert.match(containerLoadingAcceptance, /waitForCompletedTask/)
assert.match(containerLoadingAcceptance, /taskStatus === 'COMPLETED' && task\?\.workflowStatus === 'COMPLETED'/)
assert.match(containerLoadingAcceptance, /confirmationStatus === 'CONFIRMED'/)
assert.match(containerLoadingAcceptance, /readiness\?\.readyForPda === true/)
assert.match(containerLoadingAcceptance, /taskId === confirmedTaskId/)