feat: 增加差旅报销标准测算和财务终审流程

新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分
直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层
缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流
交互并补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-21 09:28:33 +08:00
parent 002bf4f756
commit 8f65661809
43 changed files with 4366 additions and 410 deletions

View File

@@ -813,6 +813,24 @@
color: #1d4ed8;
}
.message-meta-chip.high {
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
}
.message-meta-chip.medium {
background: #fffbeb;
color: #b45309;
border: 1px solid #fde68a;
}
.message-meta-chip.low {
background: #eff6ff;
color: #1d4ed8;
border: 1px solid #bfdbfe;
}
.risk-chip,
.message-risk-chip {
background: #fff1f2;
@@ -1262,6 +1280,10 @@
position: relative;
}
.travel-calculator-anchor {
position: relative;
}
.tool-btn.composer-side-btn.active {
border-color: rgba(59, 130, 246, 0.42);
background: rgba(239, 246, 255, 0.96);
@@ -1286,6 +1308,84 @@
0 4px 12px rgba(15, 23, 42, 0.06);
}
.travel-calculator-popover {
position: absolute;
bottom: calc(100% + 10px);
left: 0;
z-index: 30;
width: min(300px, calc(100vw - 48px));
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.16),
0 4px 12px rgba(15, 23, 42, 0.06);
}
.travel-calculator-mini-head {
display: grid;
gap: 3px;
}
.travel-calculator-mini-head strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.travel-calculator-mini-head span {
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.travel-calculator-form {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 8px;
}
.travel-calculator-field {
display: grid;
gap: 6px;
min-width: 0;
}
.travel-calculator-field span {
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.travel-calculator-field input {
width: 100%;
min-height: 36px;
padding: 0 10px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 12px;
font-weight: 700;
}
.travel-calculator-field input:focus {
border-color: rgba(59, 130, 246, 0.46);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
outline: none;
}
.travel-calculator-error {
margin: 0;
color: #dc2626;
font-size: 11px;
font-weight: 750;
line-height: 1.5;
}
.composer-date-mode-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -1984,6 +2084,11 @@
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
}
.review-side-metric-card.wide {
grid-column: 1 / -1;
min-height: 104px;
}
.review-side-metric-card.invalid {
border-color: rgba(239, 68, 68, 0.34);
background: rgba(254, 242, 242, 0.72);
@@ -2038,6 +2143,14 @@
font-weight: 700;
}
.review-inline-textarea {
min-height: 82px;
padding: 9px 10px;
resize: vertical;
line-height: 1.55;
font-family: inherit;
}
.review-inline-input.invalid {
border-color: rgba(239, 68, 68, 0.4);
color: #b91c1c;
@@ -2225,16 +2338,6 @@
background: linear-gradient(180deg, rgba(255, 255, 255, 0.84) 0%, rgba(255, 249, 238, 0.8) 100%);
}
.review-side-risk-score {
color: #f97316;
font-size: 13px;
font-weight: 900;
}
.review-side-risk-score.empty {
color: #94a3b8;
}
.review-side-risk-summary {
margin: 0;
color: #334155;
@@ -2281,7 +2384,7 @@
font-size: 16px;
}
.review-side-risk-item.warning .review-side-risk-icon {
.review-side-risk-item.medium .review-side-risk-icon {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
@@ -2291,6 +2394,11 @@
color: #dc2626;
}
.review-side-risk-item.low .review-side-risk-icon {
background: rgba(14, 165, 233, 0.12);
color: #0284c7;
}
.review-side-risk-copy {
min-width: 0;
display: grid;
@@ -4201,93 +4309,6 @@
flex: 1 1 168px;
}
.review-risk-detail-modal {
width: min(560px, calc(100vw - 40px));
max-height: min(760px, calc(100vh - 48px));
display: grid;
grid-template-rows: auto minmax(0, 1fr);
overflow: hidden;
border-radius: 24px;
border: 1px solid #e7eef6;
background:
radial-gradient(circle at top right, rgba(245, 158, 11, 0.10), transparent 28%),
linear-gradient(180deg, #fbfdff 0%, #f6f9fc 100%);
box-shadow:
0 24px 80px rgba(15, 23, 42, 0.22),
0 2px 12px rgba(15, 23, 42, 0.08);
}
.review-risk-detail-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 18px;
border-bottom: 1px solid #eef2f7;
}
.review-risk-detail-head h3 {
margin: 12px 0 0;
color: #0f172a;
font-size: 21px;
font-weight: 900;
line-height: 1.35;
}
.review-risk-detail-body {
min-height: 0;
display: grid;
gap: 14px;
padding: 18px 24px 24px;
overflow-y: auto;
}
.review-risk-detail-level {
width: fit-content;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 30px;
padding: 0 11px;
border-radius: 999px;
background: rgba(14, 165, 233, 0.12);
color: #0284c7;
font-size: 12px;
font-weight: 900;
}
.review-risk-detail-level.warning {
background: rgba(245, 158, 11, 0.14);
color: #b45309;
}
.review-risk-detail-level.high {
background: rgba(239, 68, 68, 0.12);
color: #dc2626;
}
.review-risk-detail-section {
display: grid;
gap: 8px;
padding: 14px;
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
}
.review-risk-detail-section strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.review-risk-detail-section p {
margin: 0;
color: #475569;
font-size: 13px;
line-height: 1.7;
}
.review-edit-modal {
max-height: min(860px, calc(100vh - 48px));
display: grid;
@@ -4723,6 +4744,10 @@
min-height: 32px;
}
.travel-calculator-form {
grid-template-columns: 1fr;
}
.dialog-toolbar {
padding: 16px 16px 12px;
}

View File

@@ -21,7 +21,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
])
const REIMBURSEMENT_PROGRESS_LABELS = [
'保存草稿',
'创建单据',
'待提交',
'AI预审',
'直属领导审批',
@@ -270,6 +270,21 @@ function normalizeText(value) {
return String(value || '').trim()
}
function isEmailLike(value) {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizeText(value))
}
function resolveDisplayName(...values) {
for (const value of values) {
const normalized = normalizeText(value)
if (normalized && !isEmailLike(normalized)) {
return normalized
}
}
return ''
}
function getRiskFlags(claim) {
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
}
@@ -344,7 +359,7 @@ function buildCompletedStepMeta(claim, label) {
const stepLabel = normalizeText(label)
const employeeName = normalizeText(claim?.employee_name) || '申请人'
if (stepLabel === '保存草稿') {
if (stepLabel === '创建单据') {
const createdAt = formatDateTime(claim?.created_at)
return buildProgressStepMeta(`${employeeName}创建`, createdAt)
}
@@ -362,7 +377,12 @@ function buildCompletedStepMeta(claim, label) {
if (stepLabel === '直属领导审批' || stepLabel === '财务审批') {
const approvalEvent = findApprovalEventForStep(claim, stepLabel)
if (approvalEvent) {
const operator = normalizeText(approvalEvent.operator) || (stepLabel === '财务审批' ? '财务' : '审批人')
const operator = resolveDisplayName(
approvalEvent.operator,
approvalEvent.operator_name,
approvalEvent.operatorName,
stepLabel === '直属领导审批' ? claim?.manager_name : ''
) || (stepLabel === '财务审批' ? '财务' : '直属领导')
const approvedAt = formatDateTime(approvalEvent.created_at || approvalEvent.createdAt)
return buildProgressStepMeta(`${operator}通过`, approvedAt, `${operator}审批通过 ${approvedAt}`.trim())
}
@@ -383,7 +403,7 @@ function buildCompletedStepMeta(claim, label) {
function resolveCurrentStepStartedAt(claim, label) {
const stepLabel = normalizeText(label)
if (stepLabel === '保存草稿') {
if (stepLabel === '创建单据') {
return claim?.created_at
}
if (stepLabel === '待提交') {
@@ -539,7 +559,7 @@ export function mapExpenseClaimToRequest(claim) {
employeeName: String(claim?.employee_name || '').trim() || '待补充',
employeePosition: String(claim?.employee_position || '').trim(),
employeeGrade: String(claim?.employee_grade || '').trim(),
managerName: String(claim?.manager_name || '').trim(),
managerName: resolveDisplayName(claim?.manager_name),
roleLabels: Array.isArray(claim?.role_labels) ? claim.role_labels.filter(Boolean) : [],
entity: '',
typeCode,

View File

@@ -12,6 +12,13 @@ export function fetchExpenseClaimDetail(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
}
export function calculateTravelReimbursement(payload = {}) {
return apiRequest('/reimbursements/travel-calculator', {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function createExpenseClaimItem(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/items`, {
method: 'POST',

View File

@@ -19,8 +19,9 @@ const VIEW_ROLE_RULES = {
employees: ['manager'],
settings: ['manager']
}
const CLAIM_MANAGER_ROLE_CODES = new Set(['finance', 'executive'])
const CLAIM_MANAGER_ROLE_CODES = new Set(['executive'])
const CLAIM_RETURN_ROLE_CODES = new Set(['finance', 'executive', 'manager', 'approver'])
const CLAIM_LEADER_APPROVAL_ROLE_CODES = new Set(['manager', 'approver'])
function normalizedRoleCodes(user) {
if (!user) {
@@ -60,6 +61,14 @@ export function canReturnExpenseClaims(user) {
return normalizedRoleCodes(user).some((roleCode) => CLAIM_RETURN_ROLE_CODES.has(roleCode))
}
export function canApproveLeaderExpenseClaims(user) {
if (Boolean(user?.isAdmin)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false

View File

@@ -1,5 +1,9 @@
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { canManageExpenseClaims } from './accessControl.js'
import {
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
isFinanceUser
} from './accessControl.js'
export function canProcessApprovalRequest(request, currentUser) {
const node = String(request?.workflowNode || '').trim()
@@ -14,12 +18,18 @@ export function canProcessApprovalRequest(request, currentUser) {
return true
}
return (
if (isFinanceUser(currentUser) && node.includes('财务')) {
return true
}
const isLeaderApprovalNode = (
node.includes('直属领导')
|| node.includes('领导审批')
|| node.includes('部门负责人')
|| node.includes('负责人审批')
)
return canApproveLeaderExpenseClaims(currentUser) && isLeaderApprovalNode
}
export function listPendingApprovalRequests(claimsPayload, currentUser) {

View File

@@ -181,6 +181,21 @@ function normalizeRoleLabels(value) {
return text ? [text] : []
}
function isEmailLike(value) {
return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(String(value || '').trim())
}
function resolveDisplayName(...values) {
for (const value of values) {
const normalized = String(value || '').trim()
if (normalized && !isEmailLike(normalized)) {
return normalized
}
}
return ''
}
export function normalizeRequestForUi(request) {
if (!request) {
return null
@@ -255,7 +270,12 @@ export function normalizeRequestForUi(request) {
String(request.profilePosition || request.employeePosition || request.employee_position || request.position || '').trim()
|| '待补充',
profileGrade: String(request.profileGrade || request.employeeGrade || request.employee_grade || request.grade || '').trim() || '待补充',
profileManager: String(request.profileManager || request.managerName || request.manager_name || request.manager || '').trim() || '待补充',
profileManager: resolveDisplayName(
request.profileManager,
request.managerName,
request.manager_name,
request.manager
) || '待补充',
roleLabels,
profileAvatar:
String(request.person || request.applicant || request.employeeName || '申').trim().slice(0, 1) || '申'

View File

@@ -121,7 +121,14 @@
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
<span
v-for="item in message.meta"
:key="item"
class="message-meta-chip"
:class="message.metaTone"
>
{{ item }}
</span>
</div>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
@@ -548,6 +555,72 @@
</div>
</div>
</div>
<div class="travel-calculator-anchor">
<button
type="button"
class="tool-btn composer-side-btn travel-calculator-trigger"
:class="{ active: travelCalculatorOpen }"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="差旅计算器"
title="差旅计算器"
:aria-expanded="travelCalculatorOpen"
@click.stop="toggleTravelCalculator"
>
<i class="mdi mdi-calculator"></i>
</button>
<div
v-if="travelCalculatorOpen"
class="travel-calculator-popover"
role="dialog"
aria-label="差旅计算器"
@click.stop
>
<div class="travel-calculator-mini-head">
<strong>差旅计算器</strong>
<span>按规则中心差旅表测算</span>
</div>
<div class="travel-calculator-form">
<label class="travel-calculator-field">
<span>实际天数</span>
<input
v-model="travelCalculatorForm.days"
type="number"
min="1"
step="1"
inputmode="numeric"
:disabled="travelCalculatorBusy"
@keydown.enter.prevent="submitTravelCalculator"
/>
</label>
<label class="travel-calculator-field">
<span>出差地点</span>
<input
v-model="travelCalculatorForm.location"
type="text"
placeholder="例如:北京、成都"
:disabled="travelCalculatorBusy"
@keydown.enter.prevent="submitTravelCalculator"
/>
</label>
</div>
<p v-if="travelCalculatorError" class="travel-calculator-error">
{{ travelCalculatorError }}
</p>
<div class="composer-date-popover-actions">
<button type="button" class="composer-date-cancel-btn" :disabled="travelCalculatorBusy" @click="closeTravelCalculator">
取消
</button>
<button
type="button"
class="composer-date-apply-btn"
:disabled="!travelCalculatorCanSubmit"
@click="submitTravelCalculator"
>
{{ travelCalculatorBusy ? '计算中...' : 'AI计算' }}
</button>
</div>
</div>
</div>
</div>
<div class="composer-shell">
@@ -783,7 +856,8 @@
:class="{
editable: item.editor,
editing: reviewInlineEditorKey === item.key,
invalid: Boolean(reviewInlineErrors[item.key])
invalid: Boolean(reviewInlineErrors[item.key]),
wide: item.wide
}"
@click="openInlineReviewEditor(item.key)"
>
@@ -831,6 +905,19 @@
@keydown.enter.prevent="commitInlineReviewEditor"
/>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'textarea'">
<textarea
v-model="reviewInlineForm[item.modelKey]"
class="review-inline-input review-inline-textarea"
:class="{ invalid: Boolean(reviewInlineErrors[item.key]) }"
:placeholder="item.placeholder"
rows="3"
@click.stop
@input="clearInlineReviewFieldError(item.key)"
@blur="commitInlineReviewEditor"
@keydown.enter.stop
></textarea>
</template>
<template v-else-if="reviewInlineEditorKey === item.key && item.editor === 'select'">
<div class="review-inline-select-list" @click.stop>
<button
@@ -1091,12 +1178,9 @@
<section class="review-side-card review-side-risk-card">
<div class="review-side-head">
<div class="review-side-head-copy">
<strong>合规提 / 风险评分</strong>
<p>结合本体附件要求和识别结果集中查看当前票据风险</p>
<strong>差旅合规提</strong>
<p>结合票据识别结果与差旅规则逐项查看需要处理的风险</p>
</div>
<span class="review-side-risk-score" :class="{ empty: reviewRiskScore === null }">
{{ reviewRiskScore === null ? '无' : `${reviewRiskScore}/100` }}
</span>
</div>
<p class="review-side-risk-summary">{{ reviewRiskSummary }}</p>
<div v-if="reviewRiskItems.length" class="review-side-risk-list">
@@ -1106,9 +1190,9 @@
type="button"
class="review-side-risk-item"
:class="item.level"
@click="openReviewRiskDetail(item)"
@click="appendReviewRiskBriefToConversation(item)"
>
<span class="review-side-risk-icon">
<span class="review-side-risk-icon" :title="item.levelLabel">
<i :class="item.icon"></i>
</span>
<span class="review-side-risk-copy">
@@ -1116,7 +1200,6 @@
<p>{{ item.summary }}</p>
</span>
<span class="review-side-risk-meta">
{{ item.levelLabel }}
<i class="mdi mdi-chevron-right"></i>
</span>
</button>
@@ -1125,8 +1208,8 @@
<span class="review-side-empty-icon">
<i class="mdi mdi-shield-check-outline"></i>
</span>
<strong>暂无风险评分</strong>
<p>当前版本还没有返回结构化风险评分结果这里先不展示虚拟分数</p>
<strong>暂无风险提示</strong>
<p>当前没有需要额外处理的结构化风险点</p>
</div>
</section>
</template>
@@ -1222,41 +1305,6 @@
@confirm="confirmCancelReview"
/>
<Transition name="assistant-modal">
<div v-if="reviewRiskDetailDialog.open" class="assistant-overlay review-overlay">
<section class="review-risk-detail-modal">
<header class="review-risk-detail-head">
<div>
<span class="assistant-badge warning">{{ reviewRiskDetailDialog.item?.sourceLabel || 'AI预审' }}</span>
<h3>{{ reviewRiskDetailDialog.item?.title || '风险提示' }}</h3>
</div>
<button class="close-btn" type="button" aria-label="关闭风险说明" @click="closeReviewRiskDetail">
<i class="mdi mdi-close"></i>
</button>
</header>
<div class="review-risk-detail-body">
<div class="review-risk-detail-level" :class="reviewRiskDetailDialog.item?.level">
<i :class="reviewRiskDetailDialog.item?.icon || 'mdi mdi-information-outline'"></i>
<span>{{ reviewRiskDetailDialog.item?.levelLabel || '提示' }}</span>
</div>
<article class="review-risk-detail-section">
<strong>提示情况</strong>
<p>{{ reviewRiskDetailDialog.item?.summary }}</p>
</article>
<article class="review-risk-detail-section">
<strong>详细解释</strong>
<p>{{ reviewRiskDetailDialog.item?.detail }}</p>
</article>
<article class="review-risk-detail-section">
<strong>处理建议</strong>
<p>{{ reviewRiskDetailDialog.item?.suggestion }}</p>
</article>
</div>
</section>
</div>
</Transition>
<Transition name="assistant-modal">
<div v-if="uploadDecisionDialogOpen" class="assistant-overlay review-overlay">
<section class="review-confirm-modal review-upload-decision-modal">

View File

@@ -375,15 +375,15 @@
</article>
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
<h3>领导意见</h3>
<h3>{{ approvalOpinionTitle }}</h3>
<textarea
v-model="leaderOpinion"
maxlength="500"
placeholder="请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。"
aria-label="领导意见"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
<div class="leader-opinion-meta">
<span>审批通过后将流转至财务审批</span>
<span>{{ approvalOpinionHint }}</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</article>
@@ -620,10 +620,10 @@
<ConfirmDialog
:open="approveConfirmDialogOpen"
badge="领导审批"
:badge="approvalConfirmBadge"
badge-tone="info"
:title="`确认通过 ${request.id} 吗?`"
description="确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。"
:description="approvalConfirmDescription"
cancel-text="返回核对"
confirm-text="确认通过"
busy-text="通过中..."
@@ -644,10 +644,10 @@
</div>
<div class="submit-confirm-row">
<span>下一节点</span>
<strong>财务审批</strong>
<strong>{{ approvalNextStage }}</strong>
</div>
<div class="submit-confirm-row">
<span>领导意见</span>
<span>{{ approvalOpinionTitle }}</span>
<strong>{{ leaderOpinion.trim() || '未填写' }}</strong>
</div>
</div>

View File

@@ -15,6 +15,7 @@ import {
TRANSPORT_KEYWORD_PATTERN
} from '../../utils/reimbursementTextInference.js'
import {
calculateTravelReimbursement,
fetchExpenseClaimAttachmentAsset,
fetchExpenseClaimDetail,
fetchExpenseClaimItemAttachmentMeta,
@@ -55,15 +56,15 @@ const REVIEW_RISK_LEVEL_META = {
icon: 'mdi mdi-alert-octagon-outline',
suggestion: '建议先处理该项,再提交审批;如果属于合理业务场景,请补充说明或附件。'
},
warning: {
label: '需关注',
medium: {
label: '中风险',
icon: 'mdi mdi-alert-circle-outline',
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
},
info: {
label: '提示',
low: {
label: '低风险',
icon: 'mdi mdi-information-outline',
suggestion: '该项主要用于辅助判断,可结合当前单据情况继续核对。'
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
}
}
@@ -310,6 +311,7 @@ const FLOW_MISSING_SLOT_LABELS = {
participants: '参与人员',
attachments: '票据附件'
}
const DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS = ['历史报销画像', '用户画像', '制度注意事项', '制度注意']
let messageSeed = 0
function nowTime() {
@@ -1317,6 +1319,7 @@ function createEmptyInlineReviewState() {
return {
occurred_date: '',
amount: '',
transport_type: '',
scene_label: '',
reason_value: '',
customer_name: '',
@@ -1330,6 +1333,67 @@ function createEmptyInlineReviewState() {
}
}
function isTravelReviewPayload(reviewPayload, inlineState = createEmptyInlineReviewState()) {
const expenseType = resolveExpenseTypeCode(
inlineState?.expense_type ||
buildReviewSlotMap(reviewPayload).expense_type?.normalized_value ||
buildReviewSlotMap(reviewPayload).expense_type?.value ||
''
)
if (['travel', 'hotel', 'transport'].includes(expenseType)) {
return true
}
return (Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []).some((item) => {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
return (
['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) ||
['travel', 'hotel', 'transport'].includes(suggestedType)
)
})
}
function resolveReviewTravelTransportType(reviewPayload, fallbackText = '') {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
const labels = []
const appendLabel = (label) => {
if (label && !labels.includes(label)) {
labels.push(label)
}
}
for (const item of documents) {
const documentType = String(item?.document_type || '').trim().toLowerCase()
const text = [
item?.filename,
item?.summary,
item?.scene_label,
item?.suggested_expense_type,
...(Array.isArray(item?.fields) ? item.fields.map((field) => `${field?.label || ''}${field?.value || ''}`) : [])
].join(' ')
const compact = text.replace(/\s+/g, '')
if (documentType === 'flight_itinerary' || /飞机|机票|航班|登机牌/.test(compact)) {
appendLabel('飞机')
} else if (documentType === 'train_ticket' || /火车|高铁|动车|铁路|车票/.test(compact)) {
appendLabel('火车/高铁')
} else if (documentType === 'taxi_receipt' || /打车|网约车|出租车|滴滴|的士/.test(compact)) {
appendLabel('打车/网约车')
}
}
const fallback = String(fallbackText || '').replace(/\s+/g, '')
if (!labels.length) {
if (/飞机|机票|航班/.test(fallback)) appendLabel('飞机')
if (/火车|高铁|动车|铁路/.test(fallback)) appendLabel('火车/高铁')
if (/打车|网约车|出租车|滴滴|的士/.test(fallback)) appendLabel('打车/网约车')
}
return labels.join('、')
}
function buildClientTimeContext() {
const now = new Date()
const locale =
@@ -1434,7 +1498,11 @@ function resolveReviewMissingSlotCards(reviewPayload) {
}
function resolveReviewRiskBriefs(reviewPayload) {
return Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
return reviewPayload.risk_briefs.filter((item) => {
const title = String(item?.title || '').trim()
return !DEPRECATED_REVIEW_RISK_TITLE_KEYWORDS.some((keyword) => title.includes(keyword))
})
}
function formatConfidenceLabel(value) {
@@ -1792,7 +1860,7 @@ function buildReviewAlertChips(reviewPayload) {
chips.push({
key: item.key,
label: buildReviewAlertLabel(item.key, expenseTypeLabel),
tone: item.key === 'attachments' ? 'danger' : 'warning'
tone: 'warning'
})
}
@@ -1830,7 +1898,7 @@ function buildReviewTodoItems(reviewPayload) {
title: config.title || item.label,
hint: item.hint || config.hint || `请补充${item.label}`,
status: config.status || '待补充',
tone: item.key === 'attachments' ? 'danger' : 'warning'
tone: 'warning'
}
})
}
@@ -2075,6 +2143,9 @@ function buildInlineReviewState(reviewPayload) {
editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || ''
).trim()
const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType)
const transportType = String(
editFieldMap.transport_type?.value || resolveReviewTravelTransportType(reviewPayload, reasonValue)
).trim()
return {
occurred_date: String(
@@ -2083,6 +2154,7 @@ function buildInlineReviewState(reviewPayload) {
amount: normalizeAmountValue(
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
),
transport_type: transportType,
scene_label: sceneLabel,
reason_value:
sceneLabel === REVIEW_SCENE_OTHER_OPTION
@@ -2129,6 +2201,56 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
: totalAttachmentCount > 0
? `已上传 ${totalAttachmentCount}`
: buildReviewAttachmentStatus(reviewPayload)
if (isTravelReviewPayload(reviewPayload, inlineState)) {
return [
{
key: 'occurred_date',
label: '发生时间',
value: String(inlineState.occurred_date || '').trim() || '待补充',
icon: 'mdi mdi-calendar-month-outline',
editor: 'date',
modelKey: 'occurred_date',
placeholder: `例如 ${DATE_INPUT_FORMAT}`
},
{
key: 'amount',
label: '金额',
value: formatAmountDisplay(inlineState.amount) || '待补充',
icon: 'mdi mdi-cash',
editor: 'amount',
modelKey: 'amount',
placeholder: '例如 200.00'
},
{
key: 'transport_type',
label: '交通类型',
value: String(inlineState.transport_type || '').trim() || '待确认',
icon: 'mdi mdi-train-car',
editor: 'text',
modelKey: 'transport_type',
placeholder: '例如 火车/高铁、飞机'
},
{
key: 'hotel_name',
label: '酒店名称',
value: String(inlineState.merchant_name || '').trim() || '待补充',
icon: 'mdi mdi-bed-outline',
editor: 'text',
modelKey: 'merchant_name',
placeholder: '请输入酒店名称'
},
{
key: 'travel_purpose',
label: '出差事宜',
value: String(inlineState.reason_value || '').trim() || '待补充',
icon: 'mdi mdi-briefcase-edit-outline',
editor: 'textarea',
modelKey: 'reason_value',
placeholder: '请填写本次出差的具体工作内容或业务意图',
wide: true
}
]
}
const cards = [
{
key: 'occurred_date',
@@ -2319,14 +2441,6 @@ function buildReviewPanelConfidence(reviewPayload, inlineState = createEmptyInli
)
}
function buildReviewRiskScore(reviewPayload) {
const score = Number(reviewPayload?.risk_score)
if (!Number.isFinite(score) || score <= 0) {
return null
}
return Math.max(0, Math.min(100, Math.round(score)))
}
function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
if (slotKey === 'customer_name') {
return expenseTypeLabel === '业务招待费'
@@ -2353,17 +2467,30 @@ function buildMissingRiskLine(slotKey, expenseTypeLabel = '') {
function buildReviewRiskSummary(reviewPayload) {
if (resolveReviewRiskBriefs(reviewPayload).length) {
return '当前识别到了合规提醒,提交前建议逐项核对。'
return '当前识别到了风险提示,点击任一风险点会在主对话中展开规则依据和整改建议。'
}
return '当前版本暂未生成风险评分结果。'
return '当前没有需要额外处理的结构化风险点。'
}
function normalizeReviewRiskLevel(level) {
const normalized = String(level || '').trim().toLowerCase()
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
if (normalized === 'warn' || normalized === 'medium') return 'warning'
if (normalized === 'high' || normalized === 'warning' || normalized === 'info') return normalized
return 'info'
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low'
if (normalized === 'high') return normalized
return 'low'
}
function normalizeReviewRiskTitle(title, fallbackTitle) {
const normalized = String(title || '').trim()
const fallback = String(fallbackTitle || '风险提示').trim() || '风险提示'
if (!normalized) return fallback
const cleaned = normalized
.replace(/AI\s*预审\s*(暂未通过|未通过|不通过)?/g, '风险提示')
.replace(/(高风险|中风险|低风险)/g, '')
.replace(/^[:\-—\s]+|[:\-—\s]+$/g, '')
.trim()
return cleaned || fallback
}
function buildReviewRiskItems(reviewPayload) {
@@ -2374,9 +2501,9 @@ function buildReviewRiskItems(reviewPayload) {
const detail = String(brief?.detail || '').trim()
const suggestion = String(brief?.suggestion || '').trim()
const level = normalizeReviewRiskLevel(brief?.level)
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.info
const meta = REVIEW_RISK_LEVEL_META[level] || REVIEW_RISK_LEVEL_META.low
const fallbackTitle = content ? `风险提示 ${index + 1}` : '风险提示'
const normalizedTitle = title || fallbackTitle
const normalizedTitle = normalizeReviewRiskTitle(title, fallbackTitle)
const summary = content || normalizedTitle
if (!normalizedTitle && !summary) return null
@@ -2389,12 +2516,30 @@ function buildReviewRiskItems(reviewPayload) {
level,
levelLabel: meta.label,
icon: meta.icon,
sourceLabel: title === '历史报销画像' ? '历史记录' : 'AI预审',
sourceLabel: meta.label,
suggestion: suggestion || meta.suggestion
}
})
.filter(Boolean)
.slice(0, 6)
}
function buildReviewRiskConversationText(item) {
const title = String(item?.title || '风险提示').trim()
const summary = String(item?.summary || '').trim()
const detail = String(item?.detail || '').trim()
const suggestion = String(item?.suggestion || '').trim()
const lines = [`${title}`]
if (summary) {
lines.push('', `风险点:${summary}`)
}
if (detail && detail !== summary) {
lines.push('', `规则依据:${detail}`)
}
if (suggestion) {
lines.push('', `修改建议:${suggestion}`)
}
return lines.join('\n')
}
function resolveInlineReviewSlotValue(slotKey, inlineState = createEmptyInlineReviewState()) {
@@ -2489,6 +2634,7 @@ function normalizeInlineReviewComparableState(state) {
return {
occurred_date: String(source.occurred_date || '').trim(),
amount: String(source.amount || '').trim(),
transport_type: String(source.transport_type || '').trim(),
scene_label: String(source.scene_label || '').trim(),
reason_value: String(source.reason_value || '').trim(),
customer_name: String(source.customer_name || '').trim(),
@@ -2512,6 +2658,9 @@ function buildInlineReviewChangedLines(baseState, nextState, pendingFiles = [])
if (base.amount !== next.amount) {
lines.push(`金额 ${formatAmountDisplay(next.amount) || '待补充'}`)
}
if (base.transport_type !== next.transport_type) {
lines.push(`交通类型 ${next.transport_type || '待确认'}`)
}
if (base.scene_label !== next.scene_label) {
lines.push(`场景 ${next.scene_label || '待补充'}`)
}
@@ -2543,6 +2692,7 @@ function buildInlineReviewChangePhrases(baseState, nextState, pendingFiles = [])
const fieldConfigs = [
{ key: 'occurred_date', label: '发生时间', format: (value) => value || '待补充' },
{ key: 'amount', label: '金额', format: (value) => formatAmountDisplay(value) || '待补充' },
{ key: 'transport_type', label: '交通类型', format: (value) => value || '待确认' },
{ key: 'scene_label', label: '场景', format: (value) => value || '待补充' },
{ key: 'customer_name', label: '关联客户', format: (value) => value || '待补充' },
{ key: 'location', label: '业务地点', format: (value) => value || '待补充' },
@@ -2611,6 +2761,7 @@ function mergeInlineReviewFields(baseFields, inlineState) {
const merged = cloneReviewEditFields(baseFields)
const updateMap = {
expense_type: inlineState.expense_type,
transport_type: inlineState.transport_type,
occurred_date: inlineState.occurred_date,
amount: inlineState.amount,
customer_name: inlineState.customer_name,
@@ -2699,7 +2850,7 @@ function buildReviewRiskHint(reviewPayload) {
if (!riskBriefs.length) {
return ''
}
return '这些是我根据当前场景和历史记录给你的提醒,提交前建议顺手核对一下。'
return '这些是我根据当前单据信息、票据识别结果和规则口径给出的风险提示,提交前建议顺手核对一下。'
}
function buildReviewActionHint(reviewPayload) {
@@ -2839,6 +2990,14 @@ export default {
const composerRangeEndDate = ref(formatDateInputValue())
const composerBusinessTimeTags = ref([])
const composerBusinessTimeDraftTouched = ref(false)
const travelCalculatorOpen = ref(false)
const travelCalculatorBusy = ref(false)
const travelCalculatorError = ref('')
const travelCalculatorResult = ref(null)
const travelCalculatorForm = ref({
days: '1',
location: ''
})
const attachedFiles = ref([])
const composerFilesExpanded = ref(false)
const submitting = ref(false)
@@ -2882,10 +3041,6 @@ export default {
const activeReviewDocumentIndex = ref(0)
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
const insightPanelCollapsed = ref(false)
const reviewRiskDetailDialog = ref({
open: false,
item: null
})
const documentPreviewDialog = ref({
open: false,
filename: '',
@@ -2921,6 +3076,11 @@ export default {
&& composerRangeStartDate.value <= composerRangeEndDate.value
)
})
const travelCalculatorCanSubmit = computed(() =>
!travelCalculatorBusy.value
&& Number(travelCalculatorForm.value.days) >= 1
&& Boolean(String(travelCalculatorForm.value.location || '').trim())
)
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const completedFlowStepCount = computed(
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
@@ -3040,10 +3200,9 @@ export default {
).length > 0
)
const reviewPanelConfidence = computed(() => buildReviewPanelConfidence(activeReviewPayload.value, reviewInlineForm.value))
const reviewRiskScore = computed(() => buildReviewRiskScore(activeReviewPayload.value))
const reviewRiskSummary = computed(() => buildReviewRiskSummary(activeReviewPayload.value))
const reviewRiskItems = computed(() => buildReviewRiskItems(activeReviewPayload.value))
const reviewRiskEmpty = computed(() => reviewRiskScore.value === null && !reviewRiskItems.value.length)
const reviewRiskEmpty = computed(() => !reviewRiskItems.value.length)
const reviewDocumentDrawerAvailable = computed(() => reviewDocumentCount.value > 0)
const reviewRiskDrawerAvailable = computed(() => !reviewRiskEmpty.value)
const reviewFlowDrawerAvailable = computed(() => flowSteps.value.length > 0)
@@ -3301,7 +3460,9 @@ export default {
activeReviewDocumentIndex.value = nextDocumentDrafts.length
? Math.min(activeReviewDocumentIndex.value, nextDocumentDrafts.length - 1)
: 0
reviewDrawerMode.value = REVIEW_DRAWER_MODE_REVIEW
reviewDrawerMode.value = resolveReviewRiskBriefs(payload).length
? REVIEW_DRAWER_MODE_RISK
: REVIEW_DRAWER_MODE_REVIEW
reviewInlinePendingFiles.value = []
reviewInlineEditorKey.value = ''
reviewInlineErrors.value = {}
@@ -3975,6 +4136,9 @@ export default {
function toggleComposerDatePicker() {
composerDatePickerOpen.value = !composerDatePickerOpen.value
if (composerDatePickerOpen.value) {
travelCalculatorOpen.value = false
}
}
function closeComposerDatePicker() {
@@ -3998,13 +4162,21 @@ export default {
}
function handleComposerDatePickerOutside(event) {
if (!composerDatePickerOpen.value) {
if (!composerDatePickerOpen.value && !travelCalculatorOpen.value) {
return
}
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
return
}
composerDatePickerOpen.value = false
if (event.target instanceof Element && event.target.closest('.travel-calculator-anchor')) {
return
}
if (composerDatePickerOpen.value) {
composerDatePickerOpen.value = false
}
if (travelCalculatorOpen.value && !travelCalculatorBusy.value) {
travelCalculatorOpen.value = false
}
}
async function applyComposerDateSelection() {
@@ -4026,6 +4198,142 @@ export default {
composerTextareaRef.value?.focus()
}
function resolveTravelCalculatorInitialDays() {
const businessTimeContext = buildComposerBusinessTimeContext()
if (!businessTimeContext) {
return 1
}
const startDate = businessTimeContext.start_date
const endDate = businessTimeContext.end_date || startDate
if (!isValidIsoDateString(startDate) || !isValidIsoDateString(endDate) || startDate > endDate) {
return 1
}
const startAt = Date.parse(`${startDate}T00:00:00Z`)
const endAt = Date.parse(`${endDate}T00:00:00Z`)
if (!Number.isFinite(startAt) || !Number.isFinite(endAt)) {
return 1
}
return Math.max(1, Math.round((endAt - startAt) / 86400000) + 1)
}
function resolveTravelCalculatorInitialLocation() {
const slotMap = buildReviewSlotMap(activeReviewPayload.value)
const candidates = [
reviewInlineForm.value.location,
slotMap.business_location?.normalized_value,
slotMap.business_location?.value,
slotMap.location?.normalized_value,
slotMap.location?.value,
currentUser.value?.location
]
return String(candidates.find((item) => String(item || '').trim()) || '').trim()
}
function openTravelCalculator() {
closeComposerDatePicker()
travelCalculatorError.value = ''
travelCalculatorResult.value = null
travelCalculatorForm.value = {
days: String(resolveTravelCalculatorInitialDays()),
location: resolveTravelCalculatorInitialLocation()
}
travelCalculatorOpen.value = true
}
function toggleTravelCalculator() {
if (travelCalculatorOpen.value) {
closeTravelCalculator()
return
}
openTravelCalculator()
}
function closeTravelCalculator() {
if (travelCalculatorBusy.value) {
return
}
travelCalculatorOpen.value = false
}
function formatTravelCalculatorMoney(value) {
const amount = Number(value)
if (!Number.isFinite(amount)) {
return String(value || '0')
}
return amount.toFixed(2)
}
function buildTravelCalculatorResultText(result) {
const days = Number(result?.days) || 1
const location = String(result?.location || '').trim() || '未填写地点'
const matchedCity = String(result?.matched_city || location).trim()
const grade = String(result?.grade || '').trim() || '当前职级'
const gradeBandLabel = String(result?.grade_band_label || result?.grade_band || '').trim() || '对应档位'
const allowanceRegion = String(result?.allowance_region || '').trim() || '默认区域'
const ruleName = String(result?.rule_name || '').trim() || '公司差旅费报销规则'
const ruleVersion = String(result?.rule_version || '').trim()
const hotelRate = formatTravelCalculatorMoney(result?.hotel_rate)
const hotelAmount = formatTravelCalculatorMoney(result?.hotel_amount)
const mealRate = formatTravelCalculatorMoney(result?.meal_allowance_rate)
const basicRate = formatTravelCalculatorMoney(result?.basic_allowance_rate)
const allowanceRate = formatTravelCalculatorMoney(result?.total_allowance_rate)
const allowanceAmount = formatTravelCalculatorMoney(result?.allowance_amount)
const totalAmount = formatTravelCalculatorMoney(result?.total_amount)
const ruleVersionText = ruleVersion ? `${ruleVersion}` : ''
const user = currentUser.value || {}
const displayName = String(user.name || user.display_name || user.username || '').trim()
const greeting = displayName ? `您好,${displayName}` : '您好,'
return [
`${greeting}根据您输入的地点和天数,我匹配到您要出差的地区为:**${matchedCity}**,出差天数为:**${days} 天**,我根据公司的报销文件给您预估金额如下:`,
'',
`**参考可报销合计:${totalAmount} 元**`,
'',
'| 项目 | 标准口径 | 天数 | 小计 |',
'| --- | --- | ---: | ---: |',
`| 住宿费 | ${matchedCity} / ${grade}${gradeBandLabel})标准:${hotelRate} 元/天 | ${days} | ${hotelAmount} 元 |`,
`| 出差补贴 | ${allowanceRegion}:伙食 ${mealRate} 元 + 基本 ${basicRate} 元 = ${allowanceRate} 元/天 | ${days} | ${allowanceAmount} 元 |`,
'',
'**计算过程**',
`1. 住宿费:${hotelRate} × ${days} = ${hotelAmount}`,
`2. 出差补贴:(${mealRate} + ${basicRate}) × ${days} = ${allowanceRate} × ${days} = ${allowanceAmount}`,
`3. 合计:${hotelAmount} + ${allowanceAmount} = ${totalAmount}`,
'',
`**规则依据**${ruleName}${ruleVersionText}。出差地点“${location}”匹配为“${matchedCity}”,当前职级“${grade}”匹配“${gradeBandLabel}”档。`,
'',
'这个结果是提交前的规则测算参考,最终仍以实际票据、审批意见和财务复核口径为准。'
].join('\n')
}
async function submitTravelCalculator() {
if (!travelCalculatorCanSubmit.value) {
travelCalculatorError.value = '请填写出差天数和地点后再计算。'
return
}
travelCalculatorBusy.value = true
travelCalculatorError.value = ''
try {
const user = currentUser.value || {}
const payload = await calculateTravelReimbursement({
days: Math.max(1, Number.parseInt(String(travelCalculatorForm.value.days || '1'), 10) || 1),
location: String(travelCalculatorForm.value.location || '').trim(),
grade: String(user.grade || '').trim()
})
travelCalculatorResult.value = payload
messages.value.push(createMessage('assistant', buildTravelCalculatorResultText(payload), [], {
meta: ['差旅计算器'],
metaTone: 'low'
}))
travelCalculatorOpen.value = false
nextTick(scrollToBottom)
} catch (error) {
travelCalculatorError.value = error?.message || '差旅金额测算失败,请稍后重试。'
} finally {
travelCalculatorBusy.value = false
}
}
function rememberFilePreviews(filePreviews) {
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
}
@@ -4378,6 +4686,7 @@ export default {
...reviewInlineForm.value,
occurred_date: String(reviewInlineForm.value.occurred_date || '').trim(),
amount: String(reviewInlineForm.value.amount || '').trim(),
transport_type: String(reviewInlineForm.value.transport_type || '').trim(),
customer_name: String(reviewInlineForm.value.customer_name || '').trim(),
location: String(reviewInlineForm.value.location || '').trim(),
merchant_name: String(reviewInlineForm.value.merchant_name || '').trim(),
@@ -4473,19 +4782,13 @@ export default {
})
}
function openReviewRiskDetail(item) {
function appendReviewRiskBriefToConversation(item) {
if (!item) return
reviewRiskDetailDialog.value = {
open: true,
item
}
}
function closeReviewRiskDetail() {
reviewRiskDetailDialog.value = {
...reviewRiskDetailDialog.value,
open: false
}
messages.value.push(createMessage('assistant', buildReviewRiskConversationText(item), [], {
meta: [item.sourceLabel || item.levelLabel || '风险提示'],
metaTone: item.level || 'low'
}))
nextTick(scrollToBottom)
}
function goReviewDocument(direction) {
@@ -5267,11 +5570,9 @@ export default {
REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible,
reviewPanelConfidence,
reviewRiskScore,
reviewRiskSummary,
reviewRiskItems,
reviewRiskEmpty,
reviewRiskDetailDialog,
recognizedNarratives,
reviewRecognitionNotes,
reviewDocumentSummaries,
@@ -5281,6 +5582,12 @@ export default {
reviewCancelDialogOpen,
reviewEditDialogOpen,
uploadDecisionDialogOpen,
travelCalculatorOpen,
travelCalculatorBusy,
travelCalculatorError,
travelCalculatorResult,
travelCalculatorForm,
travelCalculatorCanSubmit,
deleteSessionDialogOpen,
reviewActionBusy,
deleteSessionBusy,
@@ -5331,6 +5638,10 @@ export default {
resolveFlowStepStatusLabel,
resolveFlowStepDetail,
toggleInsightPanel,
openTravelCalculator,
toggleTravelCalculator,
closeTravelCalculator,
submitTravelCalculator,
switchToReviewOverviewDrawer,
toggleReviewDocumentDrawer,
toggleReviewRiskDrawer,
@@ -5357,8 +5668,7 @@ export default {
selectReviewCategory,
selectReviewOtherCategory,
queryDraftByClaimNo,
openReviewRiskDetail,
closeReviewRiskDetail,
appendReviewRiskBriefToConversation,
goReviewDocument,
openActiveReviewDocumentPreview,
closeDocumentPreview,

View File

@@ -17,7 +17,12 @@ import {
uploadExpenseClaimItemAttachment,
updateExpenseClaimItem
} from '../../services/reimbursements.js'
import { canManageExpenseClaims, canReturnExpenseClaims } from '../../utils/accessControl.js'
import {
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isFinanceUser
} from '../../utils/accessControl.js'
import { normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
buildAiAdviceViewModel,
@@ -82,7 +87,7 @@ function resolveLocationDisplay(value, expenseType) {
function buildFallbackProgressSteps() {
return [
{ index: 1, label: '保存草稿', time: '已完成', done: true, active: true },
{ index: 1, label: '创建单据', time: '已完成', done: true, active: true },
{ index: 2, label: '待提交', time: '进行中', active: true, current: true },
{ index: 3, label: 'AI预审', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' },
@@ -486,20 +491,51 @@ export default {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '直属领导审批'
})
const showLeaderApprovalPanel = computed(() =>
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& isDirectManagerApprovalStage.value
&& Boolean(request.value.claimId)
)
const isFinanceApprovalStage = computed(() => {
const node = String(request.value.node || request.value.approvalStage || '').trim()
return node === '财务审批'
})
const canReturnRequest = computed(() =>
canReturnExpenseClaims(currentUser.value)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
)
const canApproveRequest = computed(() =>
showLeaderApprovalPanel.value
&& canReturnExpenseClaims(currentUser.value)
Boolean(props.approvalMode)
&& request.value.approvalKey === 'in_progress'
&& Boolean(request.value.claimId)
&& (
(
isDirectManagerApprovalStage.value
&& canApproveLeaderExpenseClaims(currentUser.value)
)
|| (
isFinanceApprovalStage.value
&& isFinanceUser(currentUser.value)
)
)
)
const showLeaderApprovalPanel = computed(() => canApproveRequest.value)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
const approvalOpinionPlaceholder = computed(() =>
isFinanceApprovalStage.value
? '请输入财务终审意见,可补充票据核验、金额一致性或入账关注点。'
: '请输入审批意见,可补充核实情况、费用合理性或后续财务关注点。'
)
const approvalOpinionHint = computed(() =>
isFinanceApprovalStage.value ? '审核通过后将进入归档入账。' : '审批通过后将流转至财务审批。'
)
const approvalConfirmBadge = computed(() => (isFinanceApprovalStage.value ? '财务终审' : '领导审批'))
const approvalConfirmDescription = computed(() =>
isFinanceApprovalStage.value
? '确认后该报销单会完成财务终审并进入归档入账,请确认票据、金额与财务意见无误。'
: '确认后该报销单会从直属领导审批流转到财务审批,请确认申请信息与领导意见无误。'
)
const approvalNextStage = computed(() => (isFinanceApprovalStage.value ? '归档入账' : '财务审批'))
const approvalSuccessToast = computed(() =>
isFinanceApprovalStage.value
? `${request.value.id} 已完成财务终审,进入归档入账。`
: `${request.value.id} 已审批通过,流转至财务审批。`
)
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据'))
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
@@ -564,7 +600,7 @@ export default {
},
{
key: 'date',
label: '日期',
label: '单据申请日期',
value: request.value.applyTime || request.value.occurredDisplay,
icon: 'mdi mdi-calendar-month-outline',
valueClass: ''
@@ -1011,12 +1047,23 @@ export default {
try {
const payload = await uploadExpenseClaimItemAttachment(request.value.claimId, item.id, file)
expenseAttachmentMeta[item.id] = payload?.attachment || null
applyLocalExpenseItemPatch(item.id, {
const recognizedItemAmount = Number(payload?.item_amount ?? payload?.itemAmount)
const itemPatch = {
invoiceId: String(payload?.invoice_id || '').trim(),
attachmentHint: String(payload?.attachment?.file_name || file.name || '').trim()
}
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
itemPatch.itemAmount = recognizedItemAmount
itemPatch.amount = formatCurrency(recognizedItemAmount)
}
applyLocalExpenseItemPatch(item.id, {
...itemPatch
})
if (editingExpenseId.value === item.id) {
expenseEditor.invoiceId = String(payload?.invoice_id || '').trim()
if (Number.isFinite(recognizedItemAmount) && recognizedItemAmount > 0) {
expenseEditor.itemAmount = String(recognizedItemAmount)
}
}
emit('request-updated', { claimId: request.value.claimId })
@@ -1322,7 +1369,7 @@ export default {
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
toast('当前节点不支持审批通过。')
return
}
@@ -1345,7 +1392,7 @@ export default {
}
if (!canApproveRequest.value) {
toast('当前节点不支持领导审批通过。')
toast('当前节点不支持审批通过。')
approveConfirmDialogOpen.value = false
return
}
@@ -1357,7 +1404,7 @@ export default {
})
approveConfirmDialogOpen.value = false
leaderOpinion.value = ''
toast(`${request.value.id} 已审批通过,流转至财务审批。`)
toast(approvalSuccessToast.value)
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '审批通过失败,请稍后重试。')
@@ -1396,6 +1443,12 @@ export default {
attachmentPreviewUrl,
approveBusy,
approveConfirmDialogOpen,
approvalConfirmBadge,
approvalConfirmDescription,
approvalNextStage,
approvalOpinionHint,
approvalOpinionPlaceholder,
approvalOpinionTitle,
canDeleteRequest,
canManageCurrentClaim,
canNavigateAttachmentPreview,

View File

@@ -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
)
})

View File

@@ -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()

View File

@@ -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, '待补充')
})

View File

@@ -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/)
})

View File

@@ -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')

View File

@@ -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/)

View File

@@ -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*'保存草稿'/)
})