feat: 增加差旅报销标准测算和财务终审流程
新增差旅报销测算接口及 Spreadsheet 规则解析,审批流程拆分 直属领导审批与财务终审两阶段并细分权限,修复 PDF 文本层 缺失时自动回退 OCR,提交后清理关联会话,前端适配审批流 交互并补充单元测试。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) || '申'
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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