feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装 - 增强预算助理报告与风险建议卡片交互 - 重构登录页视觉样式与移动端响应式适配 - 优化个人工作台、文档中心、政策中心、员工管理等页面布局 - 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型 - 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
@@ -17,9 +17,12 @@ import {
|
||||
import {
|
||||
buildExpenseItemViewModel,
|
||||
buildDraftBlockingIssues,
|
||||
buildOptionalTravelReceiptRiskCards,
|
||||
isApplicationDocumentRequest
|
||||
} from '../src/views/scripts/travelRequestDetailExpenseModel.js'
|
||||
import {
|
||||
buildEmployeeProfileAdviceItems,
|
||||
buildTravelReceiptMaterialPrompts
|
||||
} from '../src/views/scripts/travelRequestDetailAdviceModel.js'
|
||||
|
||||
const detailViewTemplate = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/TravelRequestDetailView.vue', import.meta.url)),
|
||||
@@ -33,6 +36,10 @@ const detailViewInsights = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailInsights.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailExpenseModelScript = readFileSync(
|
||||
fileURLToPath(new URL('../src/views/scripts/travelRequestDetailExpenseModel.js', import.meta.url)),
|
||||
'utf8'
|
||||
)
|
||||
const detailViewStyle = readFileSync(
|
||||
fileURLToPath(new URL('../src/assets/styles/views/travel-request-detail-view.css', import.meta.url)),
|
||||
'utf8'
|
||||
@@ -201,7 +208,7 @@ test('risk card badge only shows severity while title keeps business risk name',
|
||||
|
||||
test('AI advice falls back to claim risk summary instead of showing an empty risk area', () => {
|
||||
const riskCards = buildClaimSummaryRiskCards({
|
||||
riskSummary: 'AI预审发现 1 条中风险附件,已随单流转给审批人复核。'
|
||||
riskSummary: '自动检测发现 1 条中风险附件,已随单流转给审批人复核。'
|
||||
})
|
||||
|
||||
assert.equal(riskCards.length, 1)
|
||||
@@ -283,7 +290,13 @@ test('stage risk advice card exposes direct reviewer action suggestion', () => {
|
||||
assert.match(stageRiskAdviceCard, /\{\{ decisionAction \}\}/)
|
||||
assert.match(stageRiskAdviceCard, /compactEvidenceItems/)
|
||||
assert.match(stageRiskAdviceCard, /compactAdviceItems/)
|
||||
assert.ok(
|
||||
stageRiskAdviceCard.indexOf('class="employee-risk-advice-list"')
|
||||
< stageRiskAdviceCard.indexOf('class="employee-risk-action"')
|
||||
)
|
||||
assert.match(stageRiskAdviceCard, /employee-risk-tone-pill/)
|
||||
assert.match(stageRiskAdviceCard, /\.employee-risk-ai-note \{[\s\S]*grid-template-columns: minmax\(0, 1fr\);/)
|
||||
assert.match(stageRiskAdviceCard, /\.employee-risk-action \{[\s\S]*align-items: center;[\s\S]*justify-content: center;[\s\S]*text-align: center;/)
|
||||
assert.match(stageRiskAdviceCard, /建议退回补充票据、行程说明或超标原因/)
|
||||
assert.match(stageRiskAdviceCard, /可按权限继续审批/)
|
||||
})
|
||||
@@ -446,15 +459,35 @@ test('AI advice view model omits empty sections', () => {
|
||||
})
|
||||
|
||||
assert.deepEqual(readyAdvice.sections, [])
|
||||
assert.equal(readyAdvice.badge, '可直接提交')
|
||||
assert.equal(readyAdvice.badge, '可以提交')
|
||||
assert.deepEqual(completionOnlyAdvice.sections.map((section) => section.title), ['建议补充字段'])
|
||||
assert.deepEqual(riskOnlyAdvice.sections.map((section) => section.title), ['已知存在风险(1项)'])
|
||||
})
|
||||
|
||||
test('AI advice separates material prompts and profile advice from risk cards', () => {
|
||||
const advice = buildAiAdviceViewModel({
|
||||
completionItems: [],
|
||||
materialPrompts: ['当前包含 1 条住宿费用明细,但暂未关联住宿发票或酒店水单。'],
|
||||
profileAdviceItems: ['历史退单建议:近 90 天存在 1 次退单或退回记录。'],
|
||||
riskCards: []
|
||||
})
|
||||
|
||||
assert.equal(advice.riskCards.length, 0)
|
||||
assert.equal(advice.badge, '建议关注')
|
||||
assert.deepEqual(
|
||||
advice.sections.map((section) => ({ kind: section.kind, title: section.title })),
|
||||
[
|
||||
{ kind: 'material', title: '材料补充提示' },
|
||||
{ kind: 'profile', title: '历史操作建议' }
|
||||
]
|
||||
)
|
||||
})
|
||||
|
||||
test('AI advice template renders grouped section titles with completion before risk', () => {
|
||||
assert.match(detailViewTemplate, /v-if="showAiAdvicePanel" class="detail-card panel validation-card"/)
|
||||
assert.match(detailViewTemplate, /<h3>\{\{ aiAdviceTitle \}\}<\/h3>/)
|
||||
assert.match(detailViewTemplate, /<p>\{\{ aiAdviceHint \}\}<\/p>/)
|
||||
assert.match(detailViewTemplate, /<p v-if="aiAdviceHint">\{\{ aiAdviceHint \}\}<\/p>/)
|
||||
assert.doesNotMatch(detailViewScript, /AI预审已完成,请按风险提示补充原因或进入下一步。/)
|
||||
assert.match(detailViewScript, /businessStage: currentBusinessStage/)
|
||||
assert.match(detailViewScript, /filterRiskCardsByBusinessStage/)
|
||||
assert.match(detailViewScript, /const summaryRiskCards = filterRiskCardsByBusinessStage/)
|
||||
@@ -462,19 +495,23 @@ test('AI advice template renders grouped section titles with completion before r
|
||||
assert.match(detailViewScript, /const canViewApprovalRiskAdvice = computed/)
|
||||
assert.match(detailViewScript, /!isCurrentApplicant\.value/)
|
||||
assert.match(detailViewScript, /const hasVisibleRiskCards = computed/)
|
||||
assert.match(detailViewScript, /const showCompactSafeAdvice = computed/)
|
||||
assert.match(detailViewScript, /const showAiAdvicePanel = computed\(\(\) => \(/)
|
||||
assert.match(detailViewScript, /isCurrentApplicant\.value && !isApplicationDocument\.value && hasVisibleRiskCards\.value/)
|
||||
assert.match(detailViewScript, /return '报销风险提示'/)
|
||||
assert.match(detailViewScript, /canViewApprovalRiskAdvice\.value && aiAdvice\.value\.riskCards\.length > 0/)
|
||||
assert.match(detailViewScript, /hasAiPreReviewResult\.value/)
|
||||
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
|
||||
assert.match(detailViewScript, /buildEmployeeProfileAdviceItems\(employeeRiskProfile\.value\)/)
|
||||
assert.match(detailViewScript, /fetchEmployeeLatestProfile\(employeeId/)
|
||||
assert.doesNotMatch(detailViewScript, /hasAiPreReviewResult\.value/)
|
||||
assert.match(detailViewTemplate, /v-if="aiAdvice\.sections\.length" class="validation-sections"/)
|
||||
assert.match(detailViewTemplate, /v-for="section in aiAdvice\.sections"/)
|
||||
assert.match(detailViewTemplate, /validation-section--\$\{section\.kind\}/)
|
||||
assert.match(detailViewTemplate, /<h4 class="validation-section-title">\{\{ section\.title \}\}<\/h4>/)
|
||||
assert.match(detailViewTemplate, /v-if="section\.kind === 'completion'" class="validation-list"/)
|
||||
assert.match(detailViewTemplate, /v-if="section\.kind !== 'risk'" class="validation-list"/)
|
||||
assert.match(detailViewTemplate, /v-else class="risk-advice-list"/)
|
||||
assert.ok(
|
||||
detailViewTemplate.indexOf("section.kind === 'completion'") < detailViewTemplate.indexOf('risk-advice-card')
|
||||
detailViewTemplate.indexOf("section.kind !== 'risk'") < detailViewTemplate.indexOf('risk-advice-card')
|
||||
)
|
||||
})
|
||||
|
||||
@@ -600,6 +637,7 @@ test('ticket item types and system allowance row are visible but read only', ()
|
||||
assert.match(detailViewScript, /value: 'ride_ticket', label: '乘车'/)
|
||||
assert.match(detailViewScript, /value: 'travel_allowance', label: '出差补贴'/)
|
||||
assert.match(detailViewScript, /const SYSTEM_GENERATED_EXPENSE_TYPES = new Set\(\['travel_allowance'\]\)/)
|
||||
assert.match(detailExpenseModelScript, /const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set\(\['ride_ticket', 'travel_allowance'\]\)/)
|
||||
assert.match(detailViewTemplate, /'system-generated-row': item\.isSystemGenerated/)
|
||||
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-row-lock"/)
|
||||
assert.match(detailViewTemplate, /v-if="item\.isSystemGenerated" class="system-attachment-note"/)
|
||||
@@ -665,16 +703,76 @@ test('expense detail edit keeps delete but removes cancel and allows draft place
|
||||
assert.match(detailViewScript, /if \(expenseEditor\.itemDate\) \{[\s\S]*itemPayload\.item_date = expenseEditor\.itemDate/)
|
||||
})
|
||||
|
||||
test('travel detail AI advice adds low risk reminders for optional receipts', () => {
|
||||
assert.match(detailViewScript, /function buildOptionalTravelReceiptRiskCards\(requestModel, items\)/)
|
||||
assert.match(detailViewScript, /isApplicationDocumentRequest\(requestModel\)[\s\S]*return \[\]/)
|
||||
assert.match(detailViewScript, /id: 'travel-optional-hotel-ticket'[\s\S]*tone: 'low'[\s\S]*住宿票据提醒/)
|
||||
assert.match(detailViewScript, /不要忘记补充酒店住宿票据/)
|
||||
assert.match(detailViewScript, /id: 'travel-optional-ride-ticket'[\s\S]*tone: 'low'[\s\S]*乘车票据提醒/)
|
||||
assert.match(detailViewScript, /可以继续补充票据报销/)
|
||||
assert.match(
|
||||
detailViewScript,
|
||||
/const optionalRiskCards = filterRiskCardsByBusinessStage\([\s\S]*buildOptionalTravelReceiptRiskCards\(request\.value, expenseItems\.value\)[\s\S]*currentBusinessStage/
|
||||
test('travel detail AI advice uses material prompts only for required hotel receipts', () => {
|
||||
assert.match(detailViewScript, /buildTravelReceiptMaterialPrompts\(request\.value, expenseItems\.value\)/)
|
||||
assert.doesNotMatch(detailViewScript, /buildOptionalTravelReceiptRiskCards/)
|
||||
assert.doesNotMatch(detailViewScript, /travel-optional-ride-ticket/)
|
||||
assert.deepEqual(
|
||||
buildTravelReceiptMaterialPrompts(
|
||||
{ typeCode: 'travel', detailVariant: 'travel' },
|
||||
[{ id: 'ride', itemType: 'ride_ticket', itemReason: '打车', invoiceId: '' }]
|
||||
),
|
||||
[]
|
||||
)
|
||||
assert.deepEqual(
|
||||
buildTravelReceiptMaterialPrompts(
|
||||
{ typeCode: 'travel', detailVariant: 'travel' },
|
||||
[{ id: 'hotel', itemType: 'hotel_ticket', itemReason: '住宿', invoiceId: '' }]
|
||||
),
|
||||
['当前包含 1 条住宿费用明细,但暂未关联住宿发票或酒店水单。请补充住宿材料,避免后续被退回补件。']
|
||||
)
|
||||
assert.deepEqual(
|
||||
buildDraftBlockingIssues(
|
||||
{
|
||||
profileName: '张三',
|
||||
typeCode: 'transport',
|
||||
typeLabel: '交通费',
|
||||
reason: '客户现场打车',
|
||||
occurredDisplay: '2026-06-01',
|
||||
amountValue: 42
|
||||
},
|
||||
[
|
||||
buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'ride',
|
||||
itemType: 'ride_ticket',
|
||||
itemReason: '园区-客户现场',
|
||||
itemDate: '2026-06-01',
|
||||
itemAmount: 42,
|
||||
invoiceId: ''
|
||||
},
|
||||
0,
|
||||
{ typeCode: 'transport' }
|
||||
)
|
||||
]
|
||||
),
|
||||
[]
|
||||
)
|
||||
assert.ok(
|
||||
buildDraftBlockingIssues(
|
||||
{
|
||||
profileName: '张三',
|
||||
typeCode: 'hotel',
|
||||
typeLabel: '住宿费',
|
||||
reason: '住宿报销',
|
||||
occurredDisplay: '2026-06-01',
|
||||
amountValue: 450
|
||||
},
|
||||
[
|
||||
buildExpenseItemViewModel(
|
||||
{
|
||||
id: 'hotel',
|
||||
itemType: 'hotel_ticket',
|
||||
itemReason: '北京中心酒店',
|
||||
itemDate: '2026-06-01',
|
||||
itemAmount: 450,
|
||||
invoiceId: ''
|
||||
},
|
||||
0,
|
||||
{ typeCode: 'hotel' }
|
||||
)
|
||||
]
|
||||
).some((item) => item.includes('缺少票据标识'))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -717,13 +815,36 @@ test('application detail does not show optional travel receipt reminders', () =>
|
||||
|
||||
assert.equal(isApplicationDocumentRequest(request), true)
|
||||
assert.deepEqual(
|
||||
buildOptionalTravelReceiptRiskCards(request, [
|
||||
buildTravelReceiptMaterialPrompts(request, [
|
||||
{ id: 'allowance', itemType: 'travel_allowance', isSystemGenerated: true, invoiceId: '' }
|
||||
]),
|
||||
[]
|
||||
)
|
||||
})
|
||||
|
||||
test('employee profile advice highlights prior return and material quality issues', () => {
|
||||
const items = buildEmployeeProfileAdviceItems({
|
||||
profiles: [
|
||||
{
|
||||
profile_type: 'process_quality',
|
||||
metrics: {
|
||||
return_count: 2,
|
||||
missing_attachment_count: 1,
|
||||
missing_business_context_count: 1,
|
||||
invoice_mismatch_count: 1
|
||||
}
|
||||
}
|
||||
],
|
||||
review_suggestions: [
|
||||
{ message: '申请人近期材料质量波动较高,建议重点核对附件、事由和票据一致性。' }
|
||||
]
|
||||
})
|
||||
|
||||
assert.ok(items.some((item) => item.includes('历史退单建议')))
|
||||
assert.ok(items.some((item) => item.includes('材料完整性建议')))
|
||||
assert.ok(items.some((item) => item.includes('票据一致性建议')))
|
||||
})
|
||||
|
||||
test('draft submit validation uses expense detail date and amount when claim summary is stale', () => {
|
||||
const issues = buildDraftBlockingIssues(
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user