test(finance): align full acceptance with current workflows
This commit is contained in:
@@ -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/)
|
||||
|
||||
Reference in New Issue
Block a user