feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { canManageExpenseClaims, canReturnExpenseClaims } from '../src/utils/accessControl.js'
|
||||
import {
|
||||
canApproveLeaderExpenseClaims,
|
||||
canManageExpenseClaims,
|
||||
canReturnExpenseClaims
|
||||
} from '../src/utils/accessControl.js'
|
||||
import { canProcessApprovalRequest } from '../src/utils/approvalInbox.js'
|
||||
|
||||
test('direct approvers can return claims without receiving delete permissions', () => {
|
||||
const managerUser = { roleCodes: ['manager'] }
|
||||
@@ -9,13 +14,42 @@ test('direct approvers can return claims without receiving delete permissions',
|
||||
|
||||
assert.equal(canReturnExpenseClaims(managerUser), true)
|
||||
assert.equal(canReturnExpenseClaims(approverUser), true)
|
||||
assert.equal(canApproveLeaderExpenseClaims(managerUser), true)
|
||||
assert.equal(canApproveLeaderExpenseClaims(approverUser), true)
|
||||
assert.equal(canManageExpenseClaims(managerUser), false)
|
||||
assert.equal(canManageExpenseClaims(approverUser), false)
|
||||
})
|
||||
|
||||
test('finance and executives can return and manage claims', () => {
|
||||
test('finance can return and final approve, but only executives can manage delete permissions', () => {
|
||||
assert.equal(canReturnExpenseClaims({ roleCodes: ['finance'] }), true)
|
||||
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), true)
|
||||
assert.equal(canApproveLeaderExpenseClaims({ roleCodes: ['finance'] }), false)
|
||||
assert.equal(canManageExpenseClaims({ roleCodes: ['finance'] }), false)
|
||||
assert.equal(canReturnExpenseClaims({ roleCodes: ['executive'] }), true)
|
||||
assert.equal(canManageExpenseClaims({ roleCodes: ['executive'] }), true)
|
||||
})
|
||||
|
||||
test('finance approval inbox only processes finance-stage requests', () => {
|
||||
const financeUser = { roleCodes: ['finance'], name: '财务' }
|
||||
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, financeUser),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeUser),
|
||||
false
|
||||
)
|
||||
})
|
||||
|
||||
test('users with both finance and manager roles can process both relevant stages', () => {
|
||||
const financeManagerUser = { roleCodes: ['finance', 'manager'], name: '李经理' }
|
||||
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '财务审批', person: '张三' }, financeManagerUser),
|
||||
true
|
||||
)
|
||||
assert.equal(
|
||||
canProcessApprovalRequest({ workflowNode: '直属领导审批', person: '张三' }, financeManagerUser),
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
@@ -39,7 +39,9 @@ test('progress steps show approval operator time and current stay duration', ()
|
||||
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
||||
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
|
||||
const aiStep = request.progressSteps.find((step) => step.label === 'AI预审')
|
||||
const firstStep = request.progressSteps[0]
|
||||
|
||||
assert.equal(firstStep.label, '创建单据')
|
||||
assert.equal(leaderStep.time, '李经理通过')
|
||||
assert.match(leaderStep.detail, /2026-05-20/)
|
||||
assert.match(leaderStep.title, /李经理审批通过/)
|
||||
@@ -52,6 +54,96 @@ test('progress steps show approval operator time and current stay duration', ()
|
||||
}
|
||||
})
|
||||
|
||||
test('progress steps do not expose approver email when manager name is available', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:00:00.000Z').getTime()
|
||||
|
||||
try {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-email-operator',
|
||||
claim_no: 'EXP-202605-003',
|
||||
employee_name: '张三',
|
||||
department_name: '市场部',
|
||||
manager_name: '李经理',
|
||||
expense_type: 'transport',
|
||||
reason: '交通报销',
|
||||
location: '上海',
|
||||
amount: 88,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-20T01:00:00.000Z',
|
||||
submitted_at: '2026-05-20T02:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T03:30:00.000Z',
|
||||
status: 'submitted',
|
||||
approval_stage: '财务审批',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
operator: 'manager@example.com',
|
||||
operator_username: 'manager@example.com',
|
||||
previous_approval_stage: '直属领导审批',
|
||||
next_approval_stage: '财务审批',
|
||||
created_at: '2026-05-20T03:30:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
const leaderStep = request.progressSteps.find((step) => step.label === '直属领导审批')
|
||||
|
||||
assert.equal(leaderStep.time, '李经理通过')
|
||||
assert.ok(!leaderStep.title.includes('manager@example.com'))
|
||||
} finally {
|
||||
Date.now = originalNow
|
||||
}
|
||||
})
|
||||
|
||||
test('completed finance approval marks finance and archive progress steps', () => {
|
||||
const request = mapExpenseClaimToRequest({
|
||||
id: 'claim-finance-completed',
|
||||
claim_no: 'EXP-202605-004',
|
||||
employee_name: '张三',
|
||||
department_name: '市场部',
|
||||
expense_type: 'transport',
|
||||
reason: '交通报销',
|
||||
location: '上海',
|
||||
amount: 88,
|
||||
invoice_count: 1,
|
||||
occurred_at: '2026-05-20T01:00:00.000Z',
|
||||
submitted_at: '2026-05-20T02:00:00.000Z',
|
||||
created_at: '2026-05-20T01:30:00.000Z',
|
||||
updated_at: '2026-05-20T04:00:00.000Z',
|
||||
status: 'approved',
|
||||
approval_stage: '归档入账',
|
||||
risk_flags_json: [
|
||||
{
|
||||
source: 'manual_approval',
|
||||
operator: '李经理',
|
||||
previous_approval_stage: '直属领导审批',
|
||||
next_approval_stage: '财务审批',
|
||||
created_at: '2026-05-20T03:00:00.000Z'
|
||||
},
|
||||
{
|
||||
source: 'finance_approval',
|
||||
operator: '财务复核',
|
||||
previous_approval_stage: '财务审批',
|
||||
next_approval_stage: '归档入账',
|
||||
created_at: '2026-05-20T04:00:00.000Z'
|
||||
}
|
||||
],
|
||||
items: []
|
||||
})
|
||||
|
||||
const financeStep = request.progressSteps.find((step) => step.label === '财务审批')
|
||||
const archiveStep = request.progressSteps.find((step) => step.label === '归档入账')
|
||||
|
||||
assert.equal(request.workflowNode, '归档入账')
|
||||
assert.equal(financeStep.time, '财务复核通过')
|
||||
assert.match(financeStep.detail, /2026-05-20/)
|
||||
assert.equal(archiveStep.time, '归档入账')
|
||||
assert.equal(archiveStep.done, true)
|
||||
})
|
||||
|
||||
test('current direct manager step shows how long the claim has stayed there', () => {
|
||||
const originalNow = Date.now
|
||||
Date.now = () => new Date('2026-05-20T05:15:00.000Z').getTime()
|
||||
|
||||
@@ -31,3 +31,17 @@ test('normalizes returned backend claims as editable pending submission', () =>
|
||||
assert.equal(request.approvalStatus, '待提交')
|
||||
assert.equal(request.node, '待提交')
|
||||
})
|
||||
|
||||
test('does not show manager email as direct supervisor name', () => {
|
||||
const request = normalizeRequestForUi({
|
||||
id: 'EXP-202605-003',
|
||||
claim_id: 'claim-3',
|
||||
status: 'submitted',
|
||||
approval_stage: '直属领导审批',
|
||||
expense_type: 'transport',
|
||||
amount: 66,
|
||||
manager_name: 'manager@example.com'
|
||||
})
|
||||
|
||||
assert.equal(request.profileManager, '待补充')
|
||||
})
|
||||
|
||||
@@ -11,6 +11,10 @@ const createViewScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/TravelReimbursementCreateView.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const reimbursementService = readFileSync(
|
||||
fileURLToPath(new URL('../src/services/reimbursements.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
||||
assert.match(createViewTemplate, /title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||||
@@ -35,3 +39,74 @@ test('review drawer tool buttons switch modes instead of toggling the active mod
|
||||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_RISK\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||||
})
|
||||
|
||||
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
|
||||
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
|
||||
assert.ok(riskItemsBlock, 'risk item builder should be present')
|
||||
|
||||
assert.doesNotMatch(createViewTemplate, /review-side-risk-score/)
|
||||
assert.doesNotMatch(createViewTemplate, /风险评分/)
|
||||
assert.doesNotMatch(createViewTemplate, /暂无风险评分/)
|
||||
assert.doesNotMatch(createViewScript, /function buildReviewRiskScore/)
|
||||
assert.doesNotMatch(createViewScript, /const reviewRiskScore/)
|
||||
assert.doesNotMatch(riskItemsBlock[0], /\.slice\(0,\s*6\)/)
|
||||
assert.match(createViewScript, /const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = \[[\s\S]*'历史报销画像'[\s\S]*'制度注意事项'/)
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/function resolveReviewRiskBriefs\(reviewPayload\) \{[\s\S]*DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS\.some/
|
||||
)
|
||||
|
||||
assert.match(
|
||||
createViewTemplate,
|
||||
/class="review-side-risk-item"[\s\S]*@click="appendReviewRiskBriefToConversation\(item\)"/
|
||||
)
|
||||
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
|
||||
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
||||
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
||||
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
||||
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
|
||||
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
|
||||
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
||||
assert.match(createViewScript, /sourceLabel:\s*meta\.label/)
|
||||
assert.doesNotMatch(createViewScript, /normalizedTitle\.includes\('AI预审'\)/)
|
||||
assert.match(createViewScript, /metaTone:\s*item\.level \|\| 'low'/)
|
||||
assert.doesNotMatch(createViewTemplate, /@click="openReviewRiskDetail\(item\)"/)
|
||||
assert.doesNotMatch(createViewTemplate, /review-risk-detail-modal/)
|
||||
assert.doesNotMatch(createViewScript, /reviewRiskDetailDialog/)
|
||||
assert.doesNotMatch(createViewScript, /function openReviewRiskDetail/)
|
||||
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
||||
)
|
||||
})
|
||||
|
||||
test('review payload with risks opens risk drawer and travel overview uses travel-specific fields', () => {
|
||||
assert.match(
|
||||
createViewScript,
|
||||
/reviewDrawerMode\.value = resolveReviewRiskBriefs\(payload\)\.length[\s\S]*\? REVIEW_DRAWER_MODE_RISK[\s\S]*: REVIEW_DRAWER_MODE_REVIEW/
|
||||
)
|
||||
assert.match(createViewScript, /function isTravelReviewPayload\(reviewPayload/)
|
||||
assert.match(createViewScript, /function resolveReviewTravelTransportType\(reviewPayload/)
|
||||
assert.match(createViewScript, /label: '交通类型'[\s\S]*modelKey: 'transport_type'/)
|
||||
assert.match(createViewScript, /label: '酒店名称'[\s\S]*modelKey: 'merchant_name'/)
|
||||
assert.match(createViewScript, /label: '出差事宜'[\s\S]*editor: 'textarea'[\s\S]*wide: true/)
|
||||
assert.match(createViewTemplate, /item\.editor === 'textarea'[\s\S]*<textarea/)
|
||||
assert.match(createViewTemplate, /wide: item\.wide/)
|
||||
})
|
||||
|
||||
test('composer exposes travel calculator and posts spreadsheet-backed result into conversation', () => {
|
||||
assert.match(createViewTemplate, /class="tool-btn composer-side-btn travel-calculator-trigger"[\s\S]*差旅计算器/)
|
||||
assert.match(createViewTemplate, /class="travel-calculator-popover"[\s\S]*v-model="travelCalculatorForm\.days"[\s\S]*v-model="travelCalculatorForm\.location"/)
|
||||
assert.doesNotMatch(createViewTemplate, /travel-calculator-modal/)
|
||||
assert.doesNotMatch(createViewTemplate, /travelCalculatorResult\.total_amount/)
|
||||
assert.match(createViewScript, /calculateTravelReimbursement/)
|
||||
assert.match(createViewScript, /function toggleTravelCalculator\(\)/)
|
||||
assert.match(createViewScript, /function submitTravelCalculator\(\) \{[\s\S]*calculateTravelReimbursement\(\{[\s\S]*grade: String\(user\.grade/)
|
||||
assert.match(createViewScript, /根据您输入的地点和天数/)
|
||||
assert.match(createViewScript, /匹配到您要出差的地区为/)
|
||||
assert.match(createViewScript, /参考可报销合计/)
|
||||
assert.match(createViewScript, /住宿费:\$\{hotelRate\} × \$\{days\} = \$\{hotelAmount\} 元/)
|
||||
assert.match(createViewScript, /messages\.value\.push\(createMessage\('assistant', buildTravelCalculatorResultText\(payload\)/)
|
||||
assert.match(reimbursementService, /export function calculateTravelReimbursement\(payload = \{\}\) \{[\s\S]*\/reimbursements\/travel-calculator/)
|
||||
})
|
||||
|
||||
@@ -44,14 +44,24 @@ test('approval-mode detail collects leader opinion and confirms approval before
|
||||
assert.match(detailScript, /const leaderOpinion = ref\(''\)/)
|
||||
assert.match(detailScript, /const approveConfirmDialogOpen = ref\(false\)/)
|
||||
assert.match(detailScript, /const canApproveRequest = computed/)
|
||||
assert.match(detailScript, /canApproveLeaderExpenseClaims/)
|
||||
assert.match(detailScript, /isFinanceApprovalStage/)
|
||||
assert.match(detailScript, /approvalOpinionTitle/)
|
||||
assert.match(detailScript, /approvalConfirmDescription/)
|
||||
assert.match(detailScript, /approvalNextStage/)
|
||||
assert.match(detailScript, /approveExpenseClaim\(request\.value\.claimId, \{[\s\S]*opinion: leaderOpinion\.value\.trim\(\)/)
|
||||
assert.match(detailScript, /toast\(approvalSuccessToast\.value\)/)
|
||||
|
||||
assert.match(detailTemplate, /v-if="showLeaderApprovalPanel"/)
|
||||
assert.match(detailTemplate, /领导意见/)
|
||||
assert.match(detailTemplate, /\{\{ approvalOpinionTitle \}\}/)
|
||||
assert.match(detailTemplate, /v-model="leaderOpinion"/)
|
||||
assert.match(detailTemplate, /:placeholder="approvalOpinionPlaceholder"/)
|
||||
assert.match(detailTemplate, /@click="handleApproveRequest"/)
|
||||
assert.match(detailTemplate, /:open="approveConfirmDialogOpen"/)
|
||||
assert.match(detailTemplate, /:badge="approvalConfirmBadge"/)
|
||||
assert.match(detailTemplate, /:description="approvalConfirmDescription"/)
|
||||
assert.match(detailTemplate, /confirm-text="确认通过"/)
|
||||
assert.match(detailTemplate, /\{\{ approvalNextStage \}\}/)
|
||||
assert.match(detailTemplate, /@confirm="confirmApproveRequest"/)
|
||||
|
||||
const handleApproveRequest = extractFunction(detailScript, 'handleApproveRequest')
|
||||
|
||||
@@ -172,6 +172,13 @@ test('expense item upload remains limited to one receipt per detail row', () =>
|
||||
assert.match(detailViewScript, /fileCount > 1[\s\S]*一条费用明细只能上传一张单据/)
|
||||
})
|
||||
|
||||
test('expense item upload patches OCR amount into the visible detail row', () => {
|
||||
assert.match(detailViewScript, /const recognizedItemAmount = Number\(payload\?\.item_amount \?\? payload\?\.itemAmount\)/)
|
||||
assert.match(detailViewScript, /itemPatch\.itemAmount = recognizedItemAmount/)
|
||||
assert.match(detailViewScript, /itemPatch\.amount = formatCurrency\(recognizedItemAmount\)/)
|
||||
assert.match(detailViewScript, /expenseEditor\.itemAmount = String\(recognizedItemAmount\)/)
|
||||
})
|
||||
|
||||
test('return reason dialog is wired into approval and detail return actions', () => {
|
||||
assert.match(returnReasonDialog, /missing_attachment/)
|
||||
assert.match(returnReasonDialog, /invoice_mismatch/)
|
||||
|
||||
@@ -52,3 +52,9 @@ test('detail submit opens a confirmation dialog before calling submit API', () =
|
||||
assert.doesNotMatch(handleSubmit, /submitExpenseClaim/)
|
||||
assert.match(confirmSubmitRequest, /submitExpenseClaim\(request\.value\.claimId\)/)
|
||||
})
|
||||
|
||||
test('detail header and fallback progress use reimbursement wording', () => {
|
||||
assert.match(detailViewScript, /label:\s*'单据申请日期'/)
|
||||
assert.match(detailViewScript, /label:\s*'创建单据'/)
|
||||
assert.doesNotMatch(detailViewScript, /label:\s*'保存草稿'/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user