feat(expenses): add authoritative pre-review workflow
This commit is contained in:
@@ -243,6 +243,44 @@ async function testRejectsWithCustomTimeoutMessage() {
|
||||
)
|
||||
}
|
||||
|
||||
async function testPreservesStructuredPreReviewConflict() {
|
||||
const review = {
|
||||
review_id: 'review-conflict-001',
|
||||
decision: 'needs_fix',
|
||||
message: '住宿费超过标准,请先调整。',
|
||||
findings: [{ risk_id: 'risk-001', severity: 'high' }]
|
||||
}
|
||||
global.fetch = async () => ({
|
||||
ok: false,
|
||||
status: 409,
|
||||
async json() {
|
||||
return {
|
||||
detail: {
|
||||
code: 'PRE_REVIEW_NEEDS_FIX',
|
||||
message: '预审发现需整改风险。',
|
||||
review
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
() => apiRequest('/reimbursements/claims/claim-001/submit', {
|
||||
method: 'POST',
|
||||
body: '{}'
|
||||
}),
|
||||
(error) => {
|
||||
assert.equal(error.status, 409)
|
||||
assert.equal(error.code, 'PRE_REVIEW_NEEDS_FIX')
|
||||
assert.equal(error.message, '预审发现需整改风险。')
|
||||
assert.deepEqual(error.review, review)
|
||||
assert.deepEqual(error.detail.review, review)
|
||||
assert.deepEqual(error.payload.detail.review, review)
|
||||
return true
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
await testUsesCustomContentTypeHeader()
|
||||
await testSupportsBlobResponses()
|
||||
@@ -251,6 +289,7 @@ async function run() {
|
||||
await testRejectsCustomAuthorizationOverride()
|
||||
await testUnauthorizedResponsePublishesSessionExpiredEvent()
|
||||
await testFormatsValidationErrors()
|
||||
await testPreservesStructuredPreReviewConflict()
|
||||
await testRejectsWithCustomTimeoutMessage()
|
||||
console.log('api-request tests passed')
|
||||
}
|
||||
|
||||
@@ -100,6 +100,48 @@ test('expense case timeline gives returned events an actionable reason', () => {
|
||||
assert.equal(timeline.events[0].summary, '住宿票据缺少入住人信息')
|
||||
})
|
||||
|
||||
test('expense case timeline explains receipt collection, association and AI pre-review', () => {
|
||||
const timeline = buildExpenseCaseTimelineViewModel({
|
||||
events: [
|
||||
{
|
||||
id: 'receipt-1',
|
||||
event_type: 'receipt_received',
|
||||
actor_id: 'employee@example.com',
|
||||
occurred_at: '2026-07-13T01:00:00Z',
|
||||
payload_json: { file_name: '上海出差高铁票.pdf' }
|
||||
},
|
||||
{
|
||||
id: 'association-1',
|
||||
event_type: 'attachment_associated',
|
||||
actor_id: 'employee@example.com',
|
||||
occurred_at: '2026-07-13T01:01:00Z',
|
||||
payload_json: { claim_no: 'BX-20260713-001' }
|
||||
},
|
||||
{
|
||||
id: 'pre-review-1',
|
||||
event_type: 'claim_pre_review_completed',
|
||||
actor_id: 'employee@example.com',
|
||||
occurred_at: '2026-07-13T01:02:00Z',
|
||||
payload_json: {
|
||||
passed: false,
|
||||
blocking_risk_count: 1,
|
||||
message: '自动检测发现 1 条重大风险,请逐条填写原因后再提交审批。'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.deepEqual(timeline.events.map((event) => event.label), [
|
||||
'票据已进入费用事件',
|
||||
'票据已归集到报销草稿',
|
||||
'报销单 AI 预审已完成'
|
||||
])
|
||||
assert.match(timeline.events[0].summary, /上海出差高铁票\.pdf/)
|
||||
assert.match(timeline.events[1].summary, /BX-20260713-001/)
|
||||
assert.equal(timeline.events[2].tone, 'warning')
|
||||
assert.match(timeline.events[2].summary, /1 条重大风险/)
|
||||
})
|
||||
|
||||
test('expense case timeline explains a historical import without pretending it is a business action', () => {
|
||||
const timeline = buildExpenseCaseTimelineViewModel({
|
||||
events: [
|
||||
|
||||
51
web/tests/reimbursement-pre-review-service.test.mjs
Normal file
51
web/tests/reimbursement-pre-review-service.test.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
preReviewExpenseClaim,
|
||||
submitExpenseClaim
|
||||
} from '../src/services/reimbursements.js'
|
||||
|
||||
test('reimbursement service sends pre-review request identity and submit handshake payload', async () => {
|
||||
const requests = []
|
||||
const originalFetch = globalThis.fetch
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
requests.push({ url: String(url), options })
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async json() {
|
||||
return { ok: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await preReviewExpenseClaim('claim / 001', 'pre-review:test-request-001')
|
||||
await submitExpenseClaim('claim / 001', {
|
||||
pre_review_id: 'review-ready-001',
|
||||
input_fingerprint: 'sha256:ready-input'
|
||||
})
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.equal(requests.length, 2)
|
||||
assert.equal(
|
||||
requests[0].url,
|
||||
'/api/v1/reimbursements/claims/claim%20%2F%20001/pre-review'
|
||||
)
|
||||
assert.equal(requests[0].options.method, 'POST')
|
||||
assert.equal(requests[0].options.headers['X-Request-ID'], 'pre-review:test-request-001')
|
||||
assert.equal(requests[0].options.body, '{}')
|
||||
|
||||
assert.equal(
|
||||
requests[1].url,
|
||||
'/api/v1/reimbursements/claims/claim%20%2F%20001/submit'
|
||||
)
|
||||
assert.equal(requests[1].options.method, 'POST')
|
||||
assert.deepEqual(JSON.parse(requests[1].options.body), {
|
||||
pre_review_id: 'review-ready-001',
|
||||
input_fingerprint: 'sha256:ready-input'
|
||||
})
|
||||
})
|
||||
33
web/tests/travel-request-detail-pre-review.test.mjs
Normal file
33
web/tests/travel-request-detail-pre-review.test.mjs
Normal file
@@ -0,0 +1,33 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { buildAttachmentRiskCards } from '../src/views/scripts/travelRequestDetailInsights.js'
|
||||
import { mapPreReviewFindingsToRiskFlags } from '../src/views/scripts/travelRequestDetailPreReview.js'
|
||||
|
||||
|
||||
test('structured pre-review findings remain visible as actionable risk cards', () => {
|
||||
const flags = mapPreReviewFindingsToRiskFlags({
|
||||
findings: [
|
||||
{
|
||||
risk_id: 'risk-visible-001',
|
||||
source: 'platform_risk',
|
||||
severity: 'high',
|
||||
actionability: 'fixable_by_submitter',
|
||||
business_stage: 'reimbursement',
|
||||
message: '发票抬头不匹配,请重新上传。',
|
||||
item_ids: ['item-001']
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
assert.equal(flags[0].source, 'platform_risk')
|
||||
assert.notEqual(flags[0].source, 'ai_pre_review')
|
||||
const cards = buildAttachmentRiskCards({
|
||||
expenseItems: [{ id: 'item-001', itemNote: '' }],
|
||||
claimRiskFlags: flags,
|
||||
businessStage: 'reimbursement'
|
||||
})
|
||||
assert.equal(cards.length, 1)
|
||||
assert.equal(cards[0].risk, '发票抬头不匹配,请重新上传。')
|
||||
assert.equal(cards[0].actionability, 'fixable_by_submitter')
|
||||
})
|
||||
@@ -59,9 +59,9 @@ const deleteDialogComponent = readFileSync(
|
||||
)
|
||||
|
||||
function extractFunction(source, name) {
|
||||
let signatureIndex = source.indexOf(`function ${name}(`)
|
||||
let signatureIndex = source.indexOf(`async function ${name}(`)
|
||||
if (signatureIndex === -1) {
|
||||
signatureIndex = source.indexOf(`async function ${name}(`)
|
||||
signatureIndex = source.indexOf(`function ${name}(`)
|
||||
}
|
||||
assert.notEqual(signatureIndex, -1, `${name} should exist`)
|
||||
|
||||
@@ -84,6 +84,46 @@ function extractFunction(source, name) {
|
||||
assert.fail(`${name} body should be closed`)
|
||||
}
|
||||
|
||||
function createSubmitRunner({
|
||||
claimId = 'claim-001',
|
||||
preReviewExpenseClaim,
|
||||
submitExpenseClaim,
|
||||
applyClaimRiskFlagsPayload = () => {},
|
||||
toast = () => {},
|
||||
emit = () => {}
|
||||
}) {
|
||||
const runSubmitRequest = extractFunction(detailViewScript, 'runSubmitRequest')
|
||||
const factory = new Function(
|
||||
'preReviewExpenseClaim',
|
||||
'submitExpenseClaim',
|
||||
'applyClaimRiskFlagsPayload',
|
||||
'toast',
|
||||
'emit',
|
||||
'request',
|
||||
'submitConfirmDialogOpen',
|
||||
'submitBusy',
|
||||
'submitTaskSeq',
|
||||
`${runSubmitRequest}; return runSubmitRequest`
|
||||
)
|
||||
const submitConfirmDialogOpen = { value: true }
|
||||
const submitBusy = { value: true }
|
||||
return {
|
||||
run: factory(
|
||||
preReviewExpenseClaim,
|
||||
submitExpenseClaim,
|
||||
applyClaimRiskFlagsPayload,
|
||||
toast,
|
||||
emit,
|
||||
{ value: { claimId } },
|
||||
submitConfirmDialogOpen,
|
||||
submitBusy,
|
||||
1
|
||||
),
|
||||
submitConfirmDialogOpen,
|
||||
submitBusy
|
||||
}
|
||||
}
|
||||
|
||||
test('detail submit opens a confirmation dialog before calling submit API', () => {
|
||||
assert.match(detailViewTemplate, /<ConfirmDialog[\s\S]*:open="submitConfirmDialogOpen"[\s\S]*:confirm-text="submitConfirmText"[\s\S]*@close="closeSubmitConfirmDialog"[\s\S]*@confirm="confirmSubmitRequest"/)
|
||||
assert.match(detailViewTemplate, /:secondary-text="submitConfirmSecondaryText"/)
|
||||
@@ -94,7 +134,7 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
|
||||
assert.match(detailViewScript, /const submitConfirmDialogOpen = ref\(false\)/)
|
||||
assert.match(detailViewTemplate, /v-if="submitBusy" class="expense-recognition-banner submit-progress-banner"/)
|
||||
assert.doesNotMatch(detailViewScript, /preReviewExpenseClaim\(request\.value\.claimId\)/)
|
||||
assert.match(detailViewScript, /preReviewExpenseClaim/)
|
||||
assert.match(detailViewScript, /const submitActionLabel = computed/)
|
||||
assert.match(detailViewScript, /const submitConfirmSecondaryText = computed/)
|
||||
assert.match(detailViewScript, /submitConfirmDialogOpen\.value = true/)
|
||||
@@ -111,10 +151,120 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
assert.doesNotMatch(confirmSubmitRequest, /await /)
|
||||
assert.doesNotMatch(confirmSubmitRequest, /submitExpenseClaim/)
|
||||
const runSubmitRequest = extractFunction(detailViewScript, 'runSubmitRequest')
|
||||
assert.match(runSubmitRequest, /submitExpenseClaim\(claimId\)/)
|
||||
const preReviewIndex = runSubmitRequest.indexOf('preReviewExpenseClaim(claimId)')
|
||||
const submitIndex = runSubmitRequest.indexOf('submitExpenseClaim(claimId,')
|
||||
assert.ok(preReviewIndex > -1)
|
||||
assert.ok(submitIndex > preReviewIndex)
|
||||
assert.match(runSubmitRequest, /pre_review_id:[\s\S]*preReview\?\.review_id/)
|
||||
assert.match(runSubmitRequest, /input_fingerprint:[\s\S]*preReview\?\.input_fingerprint/)
|
||||
assert.match(runSubmitRequest, /taskSeq !== submitTaskSeq/)
|
||||
})
|
||||
|
||||
test('detail submit uses the ready pre-review identity and fingerprint', async () => {
|
||||
const calls = []
|
||||
const appliedPayloads = []
|
||||
const { run, submitBusy } = createSubmitRunner({
|
||||
preReviewExpenseClaim: async (claimId) => {
|
||||
calls.push(['pre-review', claimId])
|
||||
return {
|
||||
claim_risk_flags: [],
|
||||
pre_review: {
|
||||
decision: 'ready',
|
||||
review_id: 'review-ready-001',
|
||||
input_fingerprint: 'sha256:ready-input'
|
||||
}
|
||||
}
|
||||
},
|
||||
submitExpenseClaim: async (claimId, payload) => {
|
||||
calls.push(['submit', claimId, payload])
|
||||
return { status: 'submitted', approval_stage: 'manager' }
|
||||
},
|
||||
applyClaimRiskFlagsPayload: (payload) => appliedPayloads.push(payload)
|
||||
})
|
||||
|
||||
await run('claim-001', 'BX-001', false, 1)
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
['pre-review', 'claim-001'],
|
||||
[
|
||||
'submit',
|
||||
'claim-001',
|
||||
{
|
||||
pre_review_id: 'review-ready-001',
|
||||
input_fingerprint: 'sha256:ready-input'
|
||||
}
|
||||
]
|
||||
])
|
||||
assert.equal(appliedPayloads.length, 1)
|
||||
assert.equal(submitBusy.value, false)
|
||||
})
|
||||
|
||||
test('detail submit stops after needs_fix and refreshes visible risks', async () => {
|
||||
let submitCalls = 0
|
||||
const appliedPayloads = []
|
||||
const emitted = []
|
||||
const toasts = []
|
||||
const reviewedClaim = {
|
||||
claim_risk_flags: [{ source: 'policy', severity: 'high' }],
|
||||
pre_review: {
|
||||
decision: 'needs_fix',
|
||||
message: '住宿费超标,请先按标准调整。'
|
||||
}
|
||||
}
|
||||
const { run, submitBusy } = createSubmitRunner({
|
||||
preReviewExpenseClaim: async () => reviewedClaim,
|
||||
submitExpenseClaim: async () => {
|
||||
submitCalls += 1
|
||||
return { status: 'submitted' }
|
||||
},
|
||||
applyClaimRiskFlagsPayload: (payload) => appliedPayloads.push(payload),
|
||||
toast: (message) => toasts.push(message),
|
||||
emit: (...args) => emitted.push(args)
|
||||
})
|
||||
|
||||
await run('claim-001', 'BX-001', false, 1)
|
||||
|
||||
assert.equal(submitCalls, 0)
|
||||
assert.deepEqual(appliedPayloads, [reviewedClaim])
|
||||
assert.deepEqual(emitted, [['request-updated', { claimId: 'claim-001' }]])
|
||||
assert.deepEqual(toasts, ['住宿费超标,请先按标准调整。'])
|
||||
assert.equal(submitBusy.value, false)
|
||||
})
|
||||
|
||||
test('structured pre-review 409 requests detail refresh so backend risks are refilled', async () => {
|
||||
const emitted = []
|
||||
const toasts = []
|
||||
const structuredError = Object.assign(new Error('提交前预审已失效。'), {
|
||||
code: 'PRE_REVIEW_NEEDS_FIX',
|
||||
review: {
|
||||
decision: 'needs_fix',
|
||||
message: '发票抬头不匹配,请重新上传。',
|
||||
findings: [{ risk_id: 'risk-001', severity: 'high' }]
|
||||
}
|
||||
})
|
||||
const { run, submitBusy } = createSubmitRunner({
|
||||
preReviewExpenseClaim: async () => ({
|
||||
claim_risk_flags: [],
|
||||
pre_review: {
|
||||
decision: 'ready',
|
||||
review_id: 'review-stale',
|
||||
input_fingerprint: 'sha256:stale'
|
||||
}
|
||||
}),
|
||||
submitExpenseClaim: async () => {
|
||||
throw structuredError
|
||||
},
|
||||
toast: (message) => toasts.push(message),
|
||||
emit: (...args) => emitted.push(args)
|
||||
})
|
||||
|
||||
await run('claim-001', 'BX-001', false, 1)
|
||||
|
||||
assert.deepEqual(toasts, ['发票抬头不匹配,请重新上传。'])
|
||||
assert.deepEqual(emitted, [['request-updated', { claimId: 'claim-001' }]])
|
||||
assert.equal(submitBusy.value, false)
|
||||
})
|
||||
|
||||
test('detail submit warns on missing risk explanation and supports standard adjustment', () => {
|
||||
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"/)
|
||||
assert.match(detailViewTemplate, /:open="riskOverrideDialogOpen"[\s\S]*size="review"/)
|
||||
|
||||
Reference in New Issue
Block a user