diff --git a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs index ee34f1406..cfdb8717c 100644 --- a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs @@ -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]*参与结算/) }) diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs index 9a1bc590c..7de156508 100644 --- a/scripts/admin-test-env-modules/finance-downloads.mjs +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -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 diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs index 6ea490102..5d887de7a 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full-contract.test.mjs @@ -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) } diff --git a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs index 767136478..709324f85 100644 --- a/scripts/admin-test-env-modules/finance-reconciliation-full.mjs +++ b/scripts/admin-test-env-modules/finance-reconciliation-full.mjs @@ -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), diff --git a/scripts/admin-test-env-modules/phase3-container-loading.mjs b/scripts/admin-test-env-modules/phase3-container-loading.mjs index e44d6cfcb..17a498f6f 100644 --- a/scripts/admin-test-env-modules/phase3-container-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-container-loading.mjs @@ -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) diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index ec8408736..743ca33d8 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -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/)