feat: 完善审批退回流程与报销申请关联

后端优化报销单访问策略和常量定义,增强退回原因和审批状态
流转,前端完善退回对话框和审批交互组件,新增报销申请关联
模型,优化文档中心行数据和审批收件箱工具函数,增强引导
流程和会话模型,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-27 14:35:17 +08:00
parent 7d32eae74e
commit cbb98f4469
30 changed files with 1794 additions and 250 deletions

View File

@@ -691,41 +691,6 @@
gap: 8px;
}
.leader-approval-card {
border-color: rgba(var(--theme-primary-rgb), .18);
background: linear-gradient(180deg, #ffffff 0%, var(--theme-primary-soft) 100%);
}
.leader-approval-card textarea {
min-height: 96px;
background: #fff;
color: #0f172a;
}
.leader-approval-card textarea:focus {
outline: 0;
border-color: rgba(var(--theme-primary-rgb), .5);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
}
.leader-opinion-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 8px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.leader-opinion-meta strong {
flex: 0 0 auto;
color: var(--theme-primary-active);
font-weight: 850;
}
.application-leader-opinion {
display: grid;
gap: 10px;
@@ -763,14 +728,103 @@
font-size: 14px;
}
.inline-leader-opinion {
padding: 0;
border: 0;
background: transparent;
.application-leader-opinion-timeline {
position: relative;
display: grid;
gap: 10px;
padding-left: 18px;
}
.application-leader-opinion-display {
min-height: 64px;
.application-leader-opinion-timeline::before {
content: "";
position: absolute;
top: 6px;
bottom: 6px;
left: 5px;
width: 1px;
background: #dbe4ee;
}
.application-leader-opinion-event {
position: relative;
display: grid;
gap: 8px;
padding: 12px 14px;
border: 1px solid #dbe4ee;
border-radius: 8px;
background: #ffffff;
}
.application-leader-opinion-event::before {
content: "";
position: absolute;
top: 17px;
left: -18px;
width: 9px;
height: 9px;
border: 2px solid #ffffff;
border-radius: 999px;
background: var(--theme-primary, #3a7ca5);
box-shadow: 0 0 0 1px rgba(var(--theme-primary-rgb, 58, 124, 165), .34);
}
.application-leader-opinion-event.danger::before {
background: #dc2626;
box-shadow: 0 0 0 1px rgba(220, 38, 38, .32);
}
.application-leader-opinion-event.success::before {
background: #16a34a;
box-shadow: 0 0 0 1px rgba(22, 163, 74, .32);
}
.application-leader-opinion-event-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.application-leader-opinion-event-head span {
display: inline-flex;
align-items: center;
gap: 6px;
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.application-leader-opinion-event-head i {
color: var(--theme-primary-active, #255b7d);
font-size: 16px;
}
.application-leader-opinion-event.danger .application-leader-opinion-event-head i {
color: #dc2626;
}
.application-leader-opinion-event.success .application-leader-opinion-event-head i {
color: #16a34a;
}
.application-leader-opinion-event-head time,
.application-leader-opinion-event footer {
color: #64748b;
font-size: 12px;
font-weight: 720;
}
.application-leader-opinion-event p {
margin: 0;
color: #334155;
font-size: 13px;
line-height: 1.65;
}
.application-leader-opinion-event footer {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.detail-expense-table {

View File

@@ -1,7 +1,7 @@
<template>
<ConfirmDialog
:open="open"
badge="退回单据"
:badge="dialogBadge"
badge-tone="warning"
:title="title"
:description="description"
@@ -17,8 +17,8 @@
>
<div class="return-reason-dialog">
<div class="return-reason-section">
<span>默认风险点</span>
<div class="return-reason-options" role="group" aria-label="默认退回风险点">
<span>{{ optionsTitle }}</span>
<div class="return-reason-options" role="group" :aria-label="optionsAriaLabel">
<label
v-for="option in options"
:key="option.code"
@@ -29,11 +29,13 @@
type="checkbox"
:value="option.code"
:disabled="busy"
@change="handleOptionChange"
/>
<i :class="option.icon"></i>
<strong>{{ option.label }}</strong>
</label>
</div>
<small v-if="selectionError" class="error">{{ selectionError }}</small>
</div>
<label class="return-reason-section">
@@ -42,11 +44,11 @@
v-model="reasonText"
rows="4"
:disabled="busy"
placeholder="请写清楚需要申请人补充或修改的内容,例如:发票金额与明细金额不一致,请重新上传正确票据。"
:placeholder="reasonPlaceholder"
@input="touched = true"
></textarea>
<small :class="{ error: reasonError }">
{{ reasonError || '会同步记录到退单埋点,并展示给申请人。' }}
{{ validationMessage }}
</small>
</label>
</div>
@@ -58,7 +60,7 @@ import { computed, ref, watch } from 'vue'
import ConfirmDialog from './ConfirmDialog.vue'
const RETURN_REASON_OPTIONS = [
const CLAIM_RETURN_REASON_OPTIONS = [
{ code: 'missing_attachment', label: '附件缺失或不清晰', icon: 'mdi mdi-paperclip-alert' },
{ code: 'invoice_mismatch', label: '票据类型/金额与明细不一致', icon: 'mdi mdi-file-compare' },
{ code: 'over_policy', label: '超出制度标准或缺少超标说明', icon: 'mdi mdi-scale-unbalanced' },
@@ -67,10 +69,44 @@ const RETURN_REASON_OPTIONS = [
{ code: 'approval_question', label: '审批人需要补充说明', icon: 'mdi mdi-comment-question-outline' }
]
const APPLICATION_RETURN_REASON_OPTIONS = [
{
code: 'application_info_incomplete',
label: '申请信息不完整',
icon: 'mdi mdi-form-textbox',
defaultReason: '请补充出差时间、地点、事由、天数或出行方式等关键信息后重新提交。'
},
{
code: 'application_business_need_unclear',
label: '业务必要性说明不足',
icon: 'mdi mdi-briefcase-question-outline',
defaultReason: '请说明本次申请对应的项目、客户或任务背景,以及必须现场处理的原因。'
},
{
code: 'application_budget_basis_missing',
label: '预算测算依据不足',
icon: 'mdi mdi-calculator-variant-outline',
defaultReason: '请补充预计住宿、交通、补贴等费用构成及测算依据,便于判断预算合理性。'
},
{
code: 'application_policy_mismatch',
label: '制度口径不匹配',
icon: 'mdi mdi-scale-balance',
defaultReason: '当前申请与差旅制度口径存在不一致,请核对职级、目的地、天数或费用标准后调整。'
},
{
code: 'application_attachment_needed',
label: '前置材料需补充',
icon: 'mdi mdi-file-document-plus-outline',
defaultReason: '请补充会议通知、客户邀约、项目安排或其他能支撑申请必要性的材料。'
}
]
const props = defineProps({
open: { type: Boolean, default: false },
busy: { type: Boolean, default: false },
claimNo: { type: String, default: '' },
application: { type: Boolean, default: false },
title: { type: String, default: '确认退回该单据吗?' },
description: {
type: String,
@@ -83,15 +119,40 @@ const emit = defineEmits(['close', 'confirm'])
const selectedCodes = ref([])
const reasonText = ref('')
const touched = ref(false)
const selectionTouched = ref(false)
const lastAutoReason = ref('')
const options = computed(() => RETURN_REASON_OPTIONS)
const options = computed(() => (props.application ? APPLICATION_RETURN_REASON_OPTIONS : CLAIM_RETURN_REASON_OPTIONS))
const dialogBadge = computed(() => (props.application ? '退回申请' : '退回单据'))
const optionsTitle = computed(() => (props.application ? '退单选项' : '默认风险点'))
const optionsAriaLabel = computed(() => (props.application ? '申请退单选项' : '默认退回风险点'))
const reasonPlaceholder = computed(() => (
props.application
? '请选择退单选项,系统会自动带入默认理由。领导可按实际情况继续修改。'
: '请写清楚需要申请人补充或修改的内容,例如:发票金额与明细金额不一致,请重新上传正确票据。'
))
const trimmedReason = computed(() => reasonText.value.trim())
const selectionError = computed(() => {
if (!props.application || !selectionTouched.value || selectedCodes.value.length > 0) {
return ''
}
return '请选择至少一个退单选项,便于后续看板统计。'
})
const reasonError = computed(() => {
if (!touched.value || trimmedReason.value.length >= 6) {
return ''
}
return '请至少填写 6 个字的明确退单理由。'
})
const validationMessage = computed(() => (
selectionError.value
|| reasonError.value
|| (
props.application
? '退单选项会写入结构化埋点,理由会展示给申请人。'
: '会同步记录到退单埋点,并展示给申请人。'
)
))
watch(
() => props.open,
@@ -100,10 +161,33 @@ watch(
selectedCodes.value = []
reasonText.value = ''
touched.value = false
selectionTouched.value = false
lastAutoReason.value = ''
}
}
)
watch(selectedCodes, () => {
if (!props.application) {
return
}
const defaultReason = selectedCodes.value
.map((code) => options.value.find((option) => option.code === code)?.defaultReason || '')
.filter(Boolean)
.join('\n')
const canAutoFill = !touched.value || !reasonText.value.trim() || reasonText.value === lastAutoReason.value
if (canAutoFill) {
reasonText.value = defaultReason
}
lastAutoReason.value = defaultReason
})
function handleOptionChange() {
selectionTouched.value = true
}
function handleClose() {
if (!props.busy) {
emit('close')
@@ -112,7 +196,8 @@ function handleClose() {
function handleConfirm() {
touched.value = true
if (trimmedReason.value.length < 6 || props.busy) {
selectionTouched.value = true
if ((props.application && selectedCodes.value.length === 0) || trimmedReason.value.length < 6 || props.busy) {
return
}

View File

@@ -27,11 +27,27 @@
<span>{{ summaryLabel }}</span>
<strong>{{ nextStage }}</strong>
</div>
<div class="submit-confirm-row">
<span>{{ opinionTitle }}</span>
<strong>{{ normalizedOpinion }}</strong>
</div>
</div>
<label class="approval-opinion-field">
<span>
{{ opinionTitle }}
<em v-if="opinionRequired">必填</em>
</span>
<textarea
:value="currentOpinion"
maxlength="500"
:required="opinionRequired"
:disabled="busy"
:placeholder="opinionPlaceholder"
:aria-label="opinionTitle"
@input="handleOpinionInput"
></textarea>
<small>
<span>{{ opinionHint }}</span>
<strong>{{ currentOpinion.length }}/500</strong>
</small>
</label>
</ConfirmDialog>
</template>
@@ -53,10 +69,83 @@ const props = defineProps({
summaryLabel: { type: String, required: true },
nextStage: { type: String, required: true },
opinionTitle: { type: String, required: true },
opinion: { type: String, default: '' }
opinion: { type: String, default: '' },
opinionPlaceholder: { type: String, default: '' },
opinionHint: { type: String, default: '' },
opinionRequired: { type: Boolean, default: false }
})
const emit = defineEmits(['close', 'confirm'])
const emit = defineEmits(['close', 'confirm', 'update:opinion'])
const normalizedOpinion = computed(() => props.opinion.trim() || '未填写')
const currentOpinion = computed(() => String(props.opinion || ''))
function handleOpinionInput(event) {
emit('update:opinion', event.target.value)
}
</script>
<style scoped>
.approval-opinion-field {
display: grid;
gap: 8px;
margin-top: 14px;
}
.approval-opinion-field > span {
display: inline-flex;
align-items: center;
gap: 8px;
color: #334155;
font-size: 12px;
font-weight: 850;
}
.approval-opinion-field em {
border-radius: 999px;
padding: 2px 7px;
background: rgba(var(--theme-primary-rgb), .1);
color: var(--theme-primary-active);
font-style: normal;
font-size: 11px;
font-weight: 850;
}
.approval-opinion-field textarea {
width: 100%;
min-height: 96px;
resize: vertical;
padding: 12px;
border: 1px solid #d7e0ea;
border-radius: 8px;
background: #fff;
color: #0f172a;
font-size: 13px;
line-height: 1.6;
outline: none;
}
.approval-opinion-field textarea:focus {
border-color: rgba(var(--theme-primary-rgb), .5);
box-shadow: 0 0 0 3px var(--theme-focus-ring);
}
.approval-opinion-field small {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.approval-opinion-field small span {
min-width: 0;
}
.approval-opinion-field small strong {
flex: 0 0 auto;
color: var(--theme-primary-active);
font-weight: 850;
}
</style>

View File

@@ -4,6 +4,7 @@
:title="title"
:description="description"
:busy="busy"
:application="application"
@close="emit('close')"
@confirm="emit('confirm', $event)"
/>
@@ -16,7 +17,8 @@ defineProps({
open: { type: Boolean, required: true },
title: { type: String, required: true },
description: { type: String, required: true },
busy: { type: Boolean, required: true }
busy: { type: Boolean, required: true },
application: { type: Boolean, default: false }
})
const emit = defineEmits(['close', 'confirm'])

View File

@@ -426,6 +426,29 @@ function resolveDisplayName(...values) {
return ''
}
function resolveApplicationApproverName(claim) {
return resolveDisplayName(
claim?.manager_name,
claim?.managerName,
claim?.profile_manager,
claim?.profileManager,
claim?.direct_manager_name,
claim?.directManagerName
) || '直属领导'
}
function resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta) {
if (
documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& approvalMeta.key !== 'completed'
&& normalizeText(label) === '直属领导审批'
) {
return `等待 ${resolveApplicationApproverName(claim)} 批复`
}
return label
}
function getRiskFlags(claim) {
return Array.isArray(claim?.risk_flags_json) ? claim.risk_flags_json : []
}
@@ -488,6 +511,25 @@ function findLatestReturnEvent(claim) {
)
}
function findLatestApplicationReturnEvent(claim) {
return getLatestEvent(
getRiskFlags(claim).filter((flag) => {
if (!flag || typeof flag !== 'object' || normalizeText(flag.source) !== 'manual_return') {
return false
}
const eventType = normalizeText(flag.event_type || flag.eventType)
const returnStage = normalizeText(flag.return_stage || flag.returnStage || flag.previous_approval_stage)
const stageKey = normalizeText(flag.return_stage_key || flag.returnStageKey)
return (
eventType === 'expense_application_return'
|| stageKey === 'direct_manager'
|| returnStage.includes('直属领导')
|| returnStage.includes('领导审批')
)
})
)
}
function buildProgressStepMeta(time, detail = '', title = '') {
return {
time,
@@ -532,6 +574,28 @@ function buildCompletedStepMeta(claim, label) {
const updatedAt = formatDateTime(claim?.updated_at)
return buildProgressStepMeta('财务通过', updatedAt, `财务审批通过 ${updatedAt}`.trim())
}
if (stepLabel === '直属领导审批') {
const returnEvent = findLatestApplicationReturnEvent(claim)
if (returnEvent) {
const handledAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
return buildProgressStepMeta('已处理', handledAt, `直属领导已处理 ${handledAt}`.trim())
}
}
}
if (stepLabel === '退回') {
const returnEvent = findLatestApplicationReturnEvent(claim) || findLatestReturnEvent(claim)
if (returnEvent) {
const operator = resolveDisplayName(
returnEvent.operator,
returnEvent.operator_name,
returnEvent.operatorName,
claim?.manager_name
) || '直属领导'
const returnedAt = formatDateTime(returnEvent.created_at || returnEvent.createdAt)
return buildProgressStepMeta(`${operator}退回`, returnedAt, `${operator}退回 ${returnedAt}`.trim())
}
}
if (stepLabel === '归档入账') {
@@ -574,13 +638,22 @@ function resolveCurrentStepStartedAt(claim, label) {
function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}) {
const documentTypeCode = String(options.documentTypeCode || '').trim()
const hasApplicationReturnStep = (
documentTypeCode === DOCUMENT_TYPE_APPLICATION
&& Boolean(findLatestApplicationReturnEvent(claim))
&& approvalMeta.key === 'supplement'
)
const progressLabels =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? APPLICATION_PROGRESS_LABELS
? hasApplicationReturnStep
? ['创建申请', '直属领导审批', '退回', '待提交']
: APPLICATION_PROGRESS_LABELS
: REIMBURSEMENT_PROGRESS_LABELS
const currentIndex =
documentTypeCode === DOCUMENT_TYPE_APPLICATION
? resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
? hasApplicationReturnStep
? 3
: resolveApplicationProgressCurrentIndex(approvalMeta, workflowNode)
: resolveProgressCurrentIndex(approvalMeta, workflowNode)
const currentTime =
approvalMeta.key === 'completed'
@@ -592,11 +665,13 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
: '进行中'
return progressLabels.map((label, index) => {
const displayLabel = resolveProgressDisplayLabel(label, documentTypeCode, claim, approvalMeta)
if (approvalMeta.key === 'completed') {
const stepMeta = buildCompletedStepMeta(claim, label)
return {
index: index + 1,
label,
label: displayLabel,
rawLabel: label,
time: stepMeta.time,
detail: stepMeta.detail,
title: stepMeta.title,
@@ -610,7 +685,8 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
const stepMeta = buildCompletedStepMeta(claim, label)
return {
index: index + 1,
label,
label: displayLabel,
rawLabel: label,
time: stepMeta.time,
detail: stepMeta.detail,
title: stepMeta.title,
@@ -624,10 +700,11 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
const stayDuration = formatDurationFrom(resolveCurrentStepStartedAt(claim, label))
return {
index: index + 1,
label,
label: displayLabel,
rawLabel: label,
time: stayDuration ? `停留 ${stayDuration}` : currentTime,
detail: '',
title: stayDuration ? `当前${label}已停留 ${stayDuration}` : currentTime,
title: stayDuration ? `当前${displayLabel}已停留 ${stayDuration}` : currentTime,
done: false,
active: true,
current: true
@@ -636,7 +713,8 @@ function buildProgressSteps(approvalMeta, workflowNode, claim = {}, options = {}
return {
index: index + 1,
label,
label: displayLabel,
rawLabel: label,
time: '待处理',
detail: '',
title: '待处理',
@@ -758,9 +836,13 @@ export function mapExpenseClaimToRequest(claim) {
approvalTone: approvalMeta.tone,
secondaryStatusLabel: isApplicationDocument ? '申请材料' : (typeCode === 'travel' ? '行程状态' : '票据状态'),
secondaryStatusValue: isApplicationDocument
? '已进入审批流程'
? approvalMeta.key === 'supplement'
? '领导已退回,待重新提交'
: '已进入审批流程'
: (invoiceCount > 0 ? `已关联 ${invoiceCount} 张票据` : '待上传票据'),
secondaryStatusTone: isApplicationDocument ? 'success' : (invoiceCount > 0 ? 'success' : 'warning'),
secondaryStatusTone: isApplicationDocument
? approvalMeta.key === 'supplement' ? 'warning' : 'success'
: (invoiceCount > 0 ? 'success' : 'warning'),
riskSummary,
attachmentSummary: isApplicationDocument ? '申请单' : (invoiceCount > 0 ? `${invoiceCount} 张票据` : '无'),
expenseTableSummary: isApplicationDocument

View File

@@ -40,6 +40,21 @@ function normalizeRoleCode(value) {
return roleCode === 'auditor' ? 'budget_monitor' : roleCode
}
function normalizeComparableText(value) {
return String(value || '').trim()
}
function collectIdentityNames(...values) {
return values
.map((value) => normalizeComparableText(value))
.filter(Boolean)
}
function identityIntersects(leftValues, rightValues) {
const rightSet = new Set(rightValues)
return leftValues.some((item) => rightSet.has(item))
}
function hasPlatformAdminIdentity(user) {
if (!user) {
return false
@@ -111,10 +126,53 @@ export function canApproveLeaderExpenseClaims(user) {
if (isPlatformAdminUser(user)) {
return true
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
return normalizedRoleCodes(user).some((roleCode) => CLAIM_LEADER_APPROVAL_ROLE_CODES.has(roleCode))
}
export function isCurrentRequestApplicant(request, user) {
const applicantNames = collectIdentityNames(
request?.person,
request?.employeeName,
request?.employee_name,
request?.profileName,
request?.applicant
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return applicantNames.length > 0 && identityIntersects(applicantNames, currentNames)
}
export function isCurrentDirectManagerForRequest(request, user) {
if (isCurrentRequestApplicant(request, user)) {
return false
}
const managerNames = collectIdentityNames(
request?.profileManager,
request?.managerName,
request?.manager_name,
request?.directManagerName,
request?.direct_manager_name,
request?.manager
)
const currentNames = collectIdentityNames(
user?.name,
user?.username,
user?.email,
user?.employeeNo,
user?.employee_no
)
return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
}
export function canAccessAppView(user, viewId) {
if (!viewId || !user) {
return false

View File

@@ -51,27 +51,86 @@ function getLatestEvent(events) {
return sortedEvents.length ? sortedEvents[sortedEvents.length - 1] : null
}
export function findLeaderApprovalEvent(request) {
return getLatestEvent(
getRiskFlags(request).filter((flag) => {
const source = normalizeText(flag?.source)
const eventType = normalizeText(flag?.event_type || flag?.eventType)
const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage)
const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage)
return (
source === 'manual_approval'
&& (
eventType === 'expense_application_approval'
|| previousStage.includes('直属领导')
|| previousStage.includes('领导审批')
|| nextStage.includes('财务')
|| nextStage.includes('审批完成')
)
)
})
function isLeaderApprovalEvent(flag) {
const source = normalizeText(flag?.source)
const eventType = normalizeText(flag?.event_type || flag?.eventType)
const previousStage = normalizeText(flag?.previous_approval_stage || flag?.previousApprovalStage)
const nextStage = normalizeText(flag?.next_approval_stage || flag?.nextApprovalStage)
return (
source === 'manual_approval'
&& (
eventType === 'expense_application_approval'
|| previousStage.includes('直属领导')
|| previousStage.includes('领导审批')
|| nextStage.includes('财务')
|| nextStage.includes('审批完成')
)
)
}
function isLeaderReturnEvent(flag) {
const source = normalizeText(flag?.source)
const eventType = normalizeText(flag?.event_type || flag?.eventType)
const returnStage = normalizeText(flag?.return_stage || flag?.returnStage || flag?.previous_approval_stage)
const stageKey = normalizeText(flag?.return_stage_key || flag?.returnStageKey)
return (
source === 'manual_return'
&& (
eventType === 'expense_application_return'
|| stageKey === 'direct_manager'
|| returnStage.includes('直属领导')
|| returnStage.includes('领导审批')
)
)
}
export function findLeaderApprovalEvent(request) {
return getLatestEvent(getRiskFlags(request).filter(isLeaderApprovalEvent))
}
export function buildLeaderApprovalEvents(request) {
return getRiskFlags(request)
.filter((flag) => isLeaderApprovalEvent(flag) || isLeaderReturnEvent(flag))
.map((event) => {
const returned = isLeaderReturnEvent(event)
const rawTime = event.created_at || event.createdAt
const operator = resolveDisplayName(
event.operator,
event.operator_name,
event.operatorName,
request?.profileManager,
request?.managerName
) || '直属领导'
const time = formatDateTime(rawTime)
const opinion = normalizeText(event.opinion)
|| normalizeText(event.leader_opinion || event.leaderOpinion)
|| normalizeText(event.reason)
|| normalizeText(event.message)
|| (returned ? '已退回申请,请申请人补充后重新提交。' : '已审批通过。')
const returnCount = Number(event.return_count || event.returnCount || 0)
return {
id: normalizeText(event.return_event_id || event.returnEventId || event.approval_event_id || event.approvalEventId)
|| `${returned ? 'return' : 'approval'}-${event.created_at || event.createdAt || opinion}`,
type: returned ? 'returned' : 'approved',
tone: returned ? 'danger' : 'success',
title: returned ? '领导退回' : '领导审批通过',
operator,
time,
sortAt: rawTime,
opinion,
returnCount,
meta: [operator ? `${operator}${returned ? '退回' : '通过'}` : '', time].filter(Boolean).join(' · ')
}
})
.sort((left, right) => {
const leftDate = toDate(left.sortAt)
const rightDate = toDate(right.sortAt)
if (!leftDate || !rightDate) return 0
return leftDate.getTime() - rightDate.getTime()
})
.map(({ sortAt, ...event }) => event)
}
export function buildLeaderApprovalInfo(request) {
const event = findLeaderApprovalEvent(request)
if (!event) {

View File

@@ -1,23 +1,18 @@
import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import {
canApproveLeaderExpenseClaims,
canManageExpenseClaims,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
isFinanceUser
} from './accessControl.js'
export function canProcessApprovalRequest(request, currentUser) {
const node = String(request?.workflowNode || '').trim()
const currentName = String(currentUser?.name || '').trim()
const applicantName = String(request?.person || request?.employeeName || '').trim()
if (currentName && applicantName && currentName === applicantName) {
if (isCurrentRequestApplicant(request, currentUser)) {
return false
}
if (canManageExpenseClaims(currentUser)) {
return true
}
if (isFinanceUser(currentUser) && node.includes('财务')) {
return true
}
@@ -29,7 +24,11 @@ export function canProcessApprovalRequest(request, currentUser) {
|| node.includes('负责人审批')
)
return canApproveLeaderExpenseClaims(currentUser) && isLeaderApprovalNode
return (
canApproveLeaderExpenseClaims(currentUser)
&& isLeaderApprovalNode
&& isCurrentDirectManagerForRequest(request, currentUser)
)
}
export function listPendingApprovalRequests(claimsPayload, currentUser) {

View File

@@ -45,3 +45,22 @@ export function isArchivedDocumentRow(row) {
export function excludeArchivedDocumentRows(rows) {
return (Array.isArray(rows) ? rows : []).filter((row) => !isArchivedDocumentRow(row))
}
export function isApplicationApprovalRow(row) {
if (!row) {
return false
}
const statusGroup = String(row.statusGroup || '').trim()
return statusGroup === 'in_progress' && isApplicationRequestLike(row.rawRequest || row)
}
export function filterApplicationScopeNewRows(rows) {
return (Array.isArray(rows) ? rows : []).filter((row) => !isApplicationApprovalRow(row))
}
export function prepareApplicationScopeRows(rows) {
return (Array.isArray(rows) ? rows : [])
.filter((row) => isApplicationRequestLike(row.rawRequest || row))
.map((row) => (isApplicationApprovalRow(row) ? { ...row, isNewDocument: false } : row))
}

View File

@@ -240,7 +240,6 @@
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import EnterpriseSelect from '../components/shared/EnterpriseSelect.vue'
import TableEmptyState from '../components/shared/TableEmptyState.vue'
import TableLoadingState from '../components/shared/TableLoadingState.vue'
@@ -248,9 +247,8 @@ import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
import { excludeArchivedDocumentRows, isArchivedDocumentRow } from '../utils/documentCenterRows.js'
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js'
const DOCUMENT_TYPE_ALL = 'all'
const DOCUMENT_TYPE_APPLICATION = 'application'
const DOCUMENT_TYPE_REIMBURSEMENT = 'reimbursement'
@@ -260,7 +258,6 @@ const DOCUMENT_SCOPE_APPLICATION = '申请单'
const DOCUMENT_SCOPE_REIMBURSEMENT = '报销单'
const DOCUMENT_SCOPE_REVIEW = '审核单'
const DOCUMENT_SCOPE_ARCHIVE = '归档'
const scopeTabs = [DOCUMENT_SCOPE_ALL, DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT, DOCUMENT_SCOPE_REVIEW, DOCUMENT_SCOPE_ARCHIVE]
const statusTabs = ['全部', '草稿', '待提交', '审批中', '待补充', '已完成']
const FILTER_CONFIG_BY_SCOPE = {
@@ -311,14 +308,12 @@ const documentTypeOptions = [
{ value: DOCUMENT_TYPE_APPLICATION, label: '申请单' },
{ value: DOCUMENT_TYPE_REIMBURSEMENT, label: '报销单' }
]
const props = defineProps({
filteredRequests: { type: Array, required: true },
hasData: { type: Boolean, default: false },
loading: { type: Boolean, default: false },
error: { type: String, default: '' }
})
const emit = defineEmits([
'open-document',
'create-request',
@@ -326,7 +321,6 @@ const emit = defineEmits([
'reload',
'summary-change'
])
const activeScopeTab = ref(readDocumentScope(DOCUMENT_SCOPE_ALL, scopeTabs))
const activeStatusTab = ref('全部')
const activeDocumentType = ref(DOCUMENT_TYPE_ALL)
@@ -345,17 +339,13 @@ const approvalRows = ref([])
const supportingLoading = ref(false)
const supportingError = ref('')
const viewedDocumentKeys = ref(readViewedDocumentKeys())
const activeFilterConfig = computed(() =>
FILTER_CONFIG_BY_SCOPE[activeScopeTab.value] || FILTER_CONFIG_BY_SCOPE[DOCUMENT_SCOPE_APPLICATION]
)
const showDocumentTypeFilter = computed(() => Boolean(activeFilterConfig.value.showDocumentType))
const documentTypeFilterLabel = computed(() =>
documentTypeOptions.find((item) => item.value === activeDocumentType.value)?.label || '单据类型'
)
const statusFilterOptions = computed(() =>
activeFilterConfig.value.statusTabs.map((tab) => ({
value: tab,
@@ -380,10 +370,11 @@ const ownedRows = computed(() =>
)
const nonArchivedRows = computed(() => mergeDocumentRows([...ownedRows.value, ...approvalRows.value]))
const applicationScopeRows = computed(() => prepareApplicationScopeRows(ownedRows.value))
const scopeNewCountMap = computed(() => ({
[DOCUMENT_SCOPE_ALL]: countNewDocuments(nonArchivedRows.value, viewedDocumentKeys.value),
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION), viewedDocumentKeys.value),
[DOCUMENT_SCOPE_APPLICATION]: countNewDocuments(filterApplicationScopeNewRows(applicationScopeRows.value), viewedDocumentKeys.value),
[DOCUMENT_SCOPE_REIMBURSEMENT]: countNewDocuments(ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT), viewedDocumentKeys.value),
[DOCUMENT_SCOPE_REVIEW]: countNewDocuments(approvalRows.value, viewedDocumentKeys.value),
[DOCUMENT_SCOPE_ARCHIVE]: countNewDocuments(archiveRows.value, viewedDocumentKeys.value)
@@ -401,7 +392,7 @@ const activeScopeRows = computed(() => {
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
if (activeScopeTab.value === DOCUMENT_SCOPE_APPLICATION) {
return nonArchivedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_APPLICATION)
return applicationScopeRows.value
}
if (activeScopeTab.value === DOCUMENT_SCOPE_REIMBURSEMENT) {

View File

@@ -182,21 +182,26 @@
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
<strong v-if="leaderApprovalReadonlyMeta">{{ leaderApprovalReadonlyMeta }}</strong>
</div>
<div v-if="showApplicationLeaderOpinionInput" class="leader-approval-card inline-leader-opinion">
<textarea
v-model="leaderOpinion"
maxlength="500"
:required="requiresApprovalOpinion"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
<div class="leader-opinion-meta">
<span>{{ approvalOpinionHint }}</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</div>
<div v-else class="detail-note readonly application-leader-opinion-display">
<p>{{ leaderApprovalReadonlyText }}</p>
<div v-if="hasLeaderApprovalEvents" class="application-leader-opinion-timeline" aria-label="领导批复事件流">
<article
v-for="event in leaderApprovalEvents"
:key="event.id"
class="application-leader-opinion-event"
:class="event.tone"
>
<div class="application-leader-opinion-event-head">
<span>
<i :class="event.type === 'returned' ? 'mdi mdi-arrow-u-left-top' : 'mdi mdi-check-circle-outline'"></i>
{{ event.title }}
</span>
<time v-if="event.time">{{ event.time }}</time>
</div>
<p>{{ event.opinion }}</p>
<footer>
<span>{{ event.operator }}</span>
<span v-if="event.returnCount"> {{ event.returnCount }} 次退回</span>
</footer>
</article>
</div>
</div>
@@ -475,20 +480,6 @@
</div>
</article>
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
<h3>{{ approvalOpinionTitle }}</h3>
<textarea
v-model="leaderOpinion"
maxlength="500"
:required="requiresApprovalOpinion"
:placeholder="approvalOpinionPlaceholder"
:aria-label="approvalOpinionTitle"
></textarea>
<div class="leader-opinion-meta">
<span>{{ approvalOpinionHint }}</span>
<strong>{{ leaderOpinion.length }}/500</strong>
</div>
</article>
</section>
</div>
</div>
@@ -774,7 +765,10 @@
:summary-label="approvalConfirmSummaryLabel"
:next-stage="approvalNextStage"
:opinion-title="approvalOpinionTitle"
:opinion="leaderOpinion"
v-model:opinion="leaderOpinion"
:opinion-placeholder="approvalOpinionPlaceholder"
:opinion-hint="approvalOpinionHint"
:opinion-required="requiresApprovalOpinion"
@close="closeApproveConfirmDialog"
@confirm="confirmApproveRequest"
/>
@@ -784,6 +778,7 @@
:title="`确认退回 ${request.id} 吗?`"
:description="returnDialogDescription"
:busy="returnBusy"
:application="isApplicationDocument"
@close="closeReturnDialog"
@confirm="confirmReturnRequest"
/>

View File

@@ -176,6 +176,7 @@ import {
buildWelcomeInsight,
createMessage,
filterAssistantSessionModes,
hasMeaningfulSessionMessages,
resolveAssistantSessionMode,
resolveKnowledgeRankLabel,
resolveKnowledgeRankTone,
@@ -718,7 +719,7 @@ export default {
return labels[currentInsight.value.intent] ?? 'AI 处理中'
})
const canDeleteCurrentSession = computed(
() => Boolean(conversationId.value) || messages.value.some((item) => item.role === 'user')
() => Boolean(conversationId.value) || hasMeaningfulSessionMessages(messages.value)
)
const latestReviewMessage = computed(() =>
[...messages.value].reverse().find((item) =>
@@ -1029,6 +1030,7 @@ export default {
handleGuidedShortcut,
handleGuidedComposerSubmit,
handleGuidedSuggestedAction,
handleSceneSelectionApplicationGate,
resetGuidedFlowState
} = useTravelReimbursementGuidedFlow({
guidedFlowState,
@@ -1470,6 +1472,7 @@ export default {
if (message?.suggestedActionsLocked) return
if (applySuggestedActionPrefill(action)) return
if (await handleGuidedSuggestedAction(message, action)) return
if (await handleSceneSelectionApplicationGate(message, action)) return
if (actionType === ASSISTANT_SCOPE_ACTION_SWITCH) {
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}

View File

@@ -26,9 +26,15 @@ import {
canDeleteArchivedExpenseClaims,
canManageExpenseClaims,
canReturnExpenseClaims,
isCurrentDirectManagerForRequest,
isCurrentRequestApplicant,
isFinanceUser
} from '../../utils/accessControl.js'
import { buildLeaderApprovalInfo, resolveGeneratedDraftClaimNo } from '../../utils/applicationApproval.js'
import {
buildLeaderApprovalEvents,
buildLeaderApprovalInfo,
resolveGeneratedDraftClaimNo
} from '../../utils/applicationApproval.js'
import { buildApplicationDetailFactItems } from '../../utils/expenseApplicationDetail.js'
import { isArchivedRequestView, normalizeRequestForUi } from '../../utils/requestViewModel.js'
import {
@@ -484,11 +490,26 @@ export default {
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 isCurrentApplicant = computed(() => isCurrentRequestApplicant(request.value, currentUser.value))
const isCurrentDirectManagerApprover = computed(() => (
canApproveLeaderExpenseClaims(currentUser.value)
&& isCurrentDirectManagerForRequest(request.value, currentUser.value)
))
const canProcessFinanceApprovalStage = computed(() => (
!isApplicationDocument.value
&& isFinanceApprovalStage.value
&& isFinanceUser(currentUser.value)
&& !isCurrentApplicant.value
))
const canReturnRequest = computed(() => {
if (request.value.approvalKey !== 'in_progress' || !request.value.claimId || !canReturnExpenseClaims(currentUser.value)) {
return false
}
if (isDirectManagerApprovalStage.value) {
return isCurrentDirectManagerApprover.value
}
return canProcessFinanceApprovalStage.value
})
const canApproveRequest = computed(() =>
(Boolean(props.approvalMode) || isApplicationDocument.value)
&& request.value.approvalKey === 'in_progress'
@@ -496,32 +517,16 @@ export default {
&& (
(
isDirectManagerApprovalStage.value
&& canApproveLeaderExpenseClaims(currentUser.value)
)
|| (
!isApplicationDocument.value
&& isFinanceApprovalStage.value
&& isFinanceUser(currentUser.value)
&& isCurrentDirectManagerApprover.value
)
|| canProcessFinanceApprovalStage.value
)
)
const showApplicationLeaderOpinionInput = computed(() => (
isApplicationDocument.value
&& canApproveRequest.value
&& isDirectManagerApprovalStage.value
))
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
const leaderApprovalReadonlyText = computed(() => {
if (leaderApprovalInfo.value.opinion) {
return leaderApprovalInfo.value.opinion
}
return isApplicationDocument.value ? '待直属领导填写审批意见。' : ''
})
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
const leaderApprovalReadonlyMeta = computed(() => {
const pieces = [
leaderApprovalInfo.value.operator ? `${leaderApprovalInfo.value.operator}确认` : '',
leaderApprovalInfo.value.time
].filter(Boolean)
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
if (leaderApprovalInfo.value.generatedDraftClaimNo) {
pieces.push(`已生成报销草稿 ${leaderApprovalInfo.value.generatedDraftClaimNo}`)
}
@@ -529,12 +534,8 @@ export default {
})
const showApplicationLeaderOpinion = computed(() => (
isApplicationDocument.value
&& (
showApplicationLeaderOpinionInput.value
|| leaderApprovalReadonlyText.value
)
&& hasLeaderApprovalEvents.value
))
const showLeaderApprovalPanel = computed(() => canApproveRequest.value && !showApplicationLeaderOpinionInput.value)
const requiresApprovalOpinion = computed(() => isDirectManagerApprovalStage.value)
const approvalOpinionTitle = computed(() => (isFinanceApprovalStage.value ? '财务意见' : '领导意见'))
const approvalOpinionPlaceholder = computed(() => {
@@ -1726,11 +1727,6 @@ export default {
return
}
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('请先填写领导意见,填写后才能确认审核。')
return
}
approveConfirmDialogOpen.value = true
}
@@ -1757,7 +1753,6 @@ export default {
if (requiresApprovalOpinion.value && !leaderOpinion.value.trim()) {
toast('请先填写领导意见,填写后才能确认审核。')
approveConfirmDialogOpen.value = false
return
}
@@ -1833,15 +1828,15 @@ export default {
isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
leaderApprovalReadonlyMeta, leaderApprovalReadonlyText,
hasLeaderApprovalEvents, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
requiresApprovalOpinion,
riskOverrideReasons, saveDetailNote, savingDetailNote, savingExpenseId,
showAiAdvicePanel, showApplicationLeaderOpinion, showApplicationLeaderOpinionInput,
showLeaderApprovalPanel, showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
showAiAdvicePanel, showApplicationLeaderOpinion,
showExpenseRisk, startExpenseEdit, submitBusy, submitConfirmDialogOpen,
submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
}

View File

@@ -0,0 +1,294 @@
const REQUIRED_APPLICATION_EXPENSE_TYPES = new Set(['travel', 'meal'])
const APPLICATION_TYPE_ALIASES = {
travel: new Set(['travel', 'travel_application']),
meal: new Set([
'meal',
'entertainment',
'meal_application',
'entertainment_application',
'business_entertainment_application',
'hospitality_application'
])
}
const GENERIC_APPLICATION_TYPES = new Set(['application', 'expense_application'])
const BLOCKED_APPLICATION_STATUSES = new Set(['draft', 'returned', 'rejected', 'cancelled', 'canceled', 'deleted'])
const STATUS_LABELS = {
submitted: '审批中',
approved: '已审批',
completed: '已完成',
archived: '已归档',
paid: '已入账'
}
const EXPENSE_TYPE_LABELS = {
travel: '差旅费',
meal: '业务招待费'
}
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeLower(value) {
return normalizeText(value).toLowerCase()
}
function uniqueValues(values) {
return Array.from(new Set((Array.isArray(values) ? values : []).map(normalizeText).filter(Boolean)))
}
function normalizeClaimNo(claim) {
return normalizeText(claim?.claim_no || claim?.claimNo).toUpperCase()
}
function normalizeExpenseType(claim) {
return normalizeLower(claim?.expense_type || claim?.expenseType || claim?.type_code || claim?.typeCode)
}
function normalizeApplicationStatus(claim) {
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
}
function normalizeDocumentType(claim) {
return normalizeLower(
claim?.document_type_code
|| claim?.documentTypeCode
|| claim?.document_type
|| claim?.documentType
)
}
function normalizeApplicationDate(claim) {
return normalizeText(
claim?.submitted_at
|| claim?.submittedAt
|| claim?.created_at
|| claim?.createdAt
|| claim?.occurred_at
|| claim?.occurredAt
)
}
function toTimestamp(value) {
const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime()
}
function formatAmount(value) {
const numberValue = Number(String(value ?? '').replace(/[^\d.-]/g, ''))
if (!Number.isFinite(numberValue) || numberValue <= 0) {
return ''
}
return `¥${new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: Number.isInteger(numberValue) ? 0 : 2,
maximumFractionDigits: 2
}).format(numberValue)}`
}
function includesAny(text, keywords) {
const normalized = normalizeText(text)
return keywords.some((keyword) => normalized.includes(keyword))
}
function buildApplicationKeywordText(claim) {
return [
claim?.reason,
claim?.business_reason,
claim?.title,
claim?.summary,
claim?.description,
claim?.location,
claim?.business_location,
claim?.expense_type_label,
claim?.expenseTypeLabel
].map(normalizeText).filter(Boolean).join(' ')
}
function matchesGenericApplicationByText(claim, expenseType) {
const haystack = buildApplicationKeywordText(claim)
if (expenseType === 'travel') {
return includesAny(haystack, ['差旅', '出差', '住宿', '交通', '行程'])
}
if (expenseType === 'meal') {
return includesAny(haystack, ['招待', '客户', '接待', '宴请', '用餐', '餐饮'])
}
return false
}
export function requiresApplicationBeforeReimbursement(expenseType) {
return REQUIRED_APPLICATION_EXPENSE_TYPES.has(normalizeLower(expenseType))
}
export function getRequiredApplicationExpenseLabel(expenseType) {
return EXPENSE_TYPE_LABELS[normalizeLower(expenseType)] || '报销'
}
export function isExpenseApplicationClaim(claim) {
const documentType = normalizeDocumentType(claim)
const expenseType = normalizeExpenseType(claim)
const claimNo = normalizeClaimNo(claim)
return documentType === 'application'
|| documentType === 'expense_application'
|| claimNo.startsWith('AP-')
|| claimNo.startsWith('APP-')
|| expenseType === 'application'
|| expenseType.endsWith('_application')
}
export function matchesRequiredApplicationExpenseType(claim, expenseType) {
const normalizedExpenseType = normalizeLower(expenseType)
const claimExpenseType = normalizeExpenseType(claim)
const aliases = APPLICATION_TYPE_ALIASES[normalizedExpenseType] || new Set()
if (aliases.has(claimExpenseType)) {
return true
}
return GENERIC_APPLICATION_TYPES.has(claimExpenseType)
&& matchesGenericApplicationByText(claim, normalizedExpenseType)
}
export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
const userIds = uniqueValues([
currentUser.id,
currentUser.employeeId,
currentUser.employee_id,
currentUser.employeeNo,
currentUser.employee_no,
currentUser.username,
currentUser.email
])
const claimIds = uniqueValues([
claim?.employee_id,
claim?.employeeId,
claim?.employee_no,
claim?.employeeNo,
claim?.username,
claim?.user_id,
claim?.userId
])
if (userIds.length && claimIds.length && claimIds.some((item) => userIds.includes(item))) {
return true
}
const userNames = uniqueValues([
currentUser.name,
currentUser.user_name,
currentUser.employeeName,
currentUser.employee_name,
currentUser.username
])
const claimNames = uniqueValues([
claim?.employee_name,
claim?.employeeName,
claim?.applicant,
claim?.applicant_name,
claim?.applicantName
])
if (userNames.length && claimNames.length) {
return claimNames.some((item) => userNames.includes(item))
}
return true
}
export function isUsableRequiredApplicationClaim(claim) {
const status = normalizeApplicationStatus(claim)
return !BLOCKED_APPLICATION_STATUSES.has(status)
}
export function normalizeRequiredApplicationCandidate(claim) {
const claimNo = normalizeText(claim?.claim_no || claim?.claimNo)
const location = normalizeText(claim?.location || claim?.business_location || claim?.businessLocation)
const amountText = formatAmount(claim?.amount || claim?.budget_amount || claim?.budgetAmount)
const status = normalizeApplicationStatus(claim)
return {
id: normalizeText(claim?.id || claim?.claim_id || claim?.claimId),
claim_no: claimNo,
expense_type: normalizeExpenseType(claim),
reason: normalizeText(claim?.reason || claim?.business_reason || claim?.description || claim?.title),
location,
amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount),
amount_label: amountText,
status,
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
application_date: normalizeApplicationDate(claim)
}
}
export function filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser = {}) {
const claims = Array.isArray(claimsPayload)
? claimsPayload
: Array.isArray(claimsPayload?.items)
? claimsPayload.items
: Array.isArray(claimsPayload?.claims)
? claimsPayload.claims
: []
return claims
.filter((claim) => (
isExpenseApplicationClaim(claim)
&& isUsableRequiredApplicationClaim(claim)
&& isClaimOwnedByCurrentUser(claim, currentUser)
&& matchesRequiredApplicationExpenseType(claim, expenseType)
))
.map(normalizeRequiredApplicationCandidate)
.sort((left, right) => toTimestamp(right.application_date) - toTimestamp(left.application_date))
}
export function buildRequiredApplicationActions(applications, actionType) {
return (Array.isArray(applications) ? applications : []).map((application) => {
const claimNo = normalizeText(application.claim_no) || '未编号申请单'
const description = [
application.status_label,
application.location && `地点:${application.location}`,
application.amount_label && `预算:${application.amount_label}`,
application.reason && `事由:${application.reason}`
].filter(Boolean).join(' · ')
return {
label: claimNo,
description,
icon: 'mdi mdi-file-link-outline',
action_type: actionType,
payload: {
application_claim_id: application.id,
application_claim_no: application.claim_no,
application_expense_type: application.expense_type,
application_reason: application.reason,
application_location: application.location,
application_amount: application.amount,
application_amount_label: application.amount_label,
application_status: application.status,
application_status_label: application.status_label,
application_date: application.application_date
}
}
})
}
export function buildRequiredApplicationSelectionText(expenseType, applications) {
const label = getRequiredApplicationExpenseLabel(expenseType)
return [
`发起“${label}”报销前,需要先关联对应的申请单。`,
'',
`我查到 ${applications.length} 个可关联申请单,请先选择其中一个。`,
'选择后,我再继续向你收集本次报销依据。'
].join('\n')
}
export function buildRequiredApplicationMissingText(expenseType) {
const label = getRequiredApplicationExpenseLabel(expenseType)
return [
`发起“${label}”报销前,需要先关联对应的申请单。`,
'',
`我没有查到你名下可关联的“${label}”申请单,所以当前不能继续这类报销流程。`,
'请先切换到申请助手发起相关申请;申请单存在后,再回到报销助手继续。'
].join('\n')
}

View File

@@ -906,7 +906,10 @@ export function hasMeaningfulSessionMessages(messages) {
|| message.reviewPayload
|| message.queryPayload
|| message.draftPayload
|| message.applicationPreview
|| message.budgetReport
|| message.pendingAttachmentAssociation
|| (Array.isArray(message.riskFlags) && message.riskFlags.length)
)
})
}

View File

@@ -7,6 +7,7 @@ export const GUIDED_ACTION_START_APPLICATION = 'start_guided_application'
export const GUIDED_ACTION_START_STATUS_QUERY = 'start_guided_status_query'
export const GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR = 'open_travel_calculator'
export const GUIDED_ACTION_SELECT_EXPENSE_TYPE = 'guided_select_expense_type'
export const GUIDED_ACTION_SELECT_REQUIRED_APPLICATION = 'guided_select_required_application'
export const GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW = 'guided_confirm_reimbursement_review'
export const GUIDED_ACTION_CONTINUE_FILLING = 'guided_continue_filling'
export const GUIDED_ACTION_PROCESS_INTERRUPTION = 'guided_process_interruption'
@@ -109,13 +110,36 @@ function normalizeValues(values) {
}, {})
}
function normalizeApplicationCandidates(applications) {
if (!Array.isArray(applications)) {
return []
}
return applications
.map((item) => (item && typeof item === 'object' ? item : null))
.filter(Boolean)
.map((item) => ({
id: normalizeText(item.id || item.application_claim_id),
claim_no: normalizeText(item.claim_no || item.application_claim_no),
expense_type: normalizeText(item.expense_type || item.application_expense_type),
reason: normalizeText(item.reason || item.application_reason),
location: normalizeText(item.location || item.application_location),
amount: normalizeText(item.amount || item.application_amount),
amount_label: normalizeText(item.amount_label || item.application_amount_label),
status: normalizeText(item.status || item.application_status),
status_label: normalizeText(item.status_label || item.application_status_label),
application_date: normalizeText(item.application_date)
}))
.filter((item) => item.id || item.claim_no)
}
export function createEmptyGuidedFlowState() {
return {
mode: GUIDED_FLOW_MODE_NONE,
stepKey: '',
expenseType: '',
values: {},
pendingInterruptionText: ''
pendingInterruptionText: '',
applicationCandidates: []
}
}
@@ -134,7 +158,8 @@ export function normalizeGuidedFlowState(state) {
stepKey: normalizeText(source.stepKey),
expenseType: normalizeText(source.expenseType),
values: normalizeValues(source.values),
pendingInterruptionText: normalizeText(source.pendingInterruptionText)
pendingInterruptionText: normalizeText(source.pendingInterruptionText),
applicationCandidates: normalizeApplicationCandidates(source.applicationCandidates)
}
}
@@ -191,7 +216,44 @@ export function selectGuidedExpenseType(state, expenseType) {
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
expenseType: type.key,
stepKey: steps[0]?.key || 'summary',
pendingInterruptionText: ''
pendingInterruptionText: '',
applicationCandidates: []
}
}
export function waitForGuidedApplicationSelection(state, expenseType, applications = []) {
const type = getGuidedExpenseType(expenseType)
if (!type) {
return normalizeGuidedFlowState(state)
}
return {
...normalizeGuidedFlowState(state),
mode: GUIDED_FLOW_MODE_REIMBURSEMENT,
expenseType: type.key,
stepKey: 'application_selection',
pendingInterruptionText: '',
applicationCandidates: normalizeApplicationCandidates(applications)
}
}
export function selectGuidedRequiredApplication(state, application = {}) {
const current = normalizeGuidedFlowState(state)
const steps = getGuidedReimbursementSteps(current.expenseType)
return {
...current,
values: normalizeValues({
...current.values,
application_claim_id: application.application_claim_id || application.id || '',
application_claim_no: application.application_claim_no || application.claim_no || '',
application_reason: application.application_reason || application.reason || '',
application_location: application.application_location || application.location || '',
application_amount: application.application_amount || application.amount || '',
application_amount_label: application.application_amount_label || application.amount_label || '',
application_status_label: application.application_status_label || application.status_label || ''
}),
stepKey: steps[0]?.key || 'summary',
pendingInterruptionText: '',
applicationCandidates: []
}
}
@@ -290,6 +352,16 @@ export function buildGuidedReimbursementSummaryText(state) {
'请核查下面的关键信息:'
]
if (current.values.application_claim_no) {
const applicationParts = [
current.values.application_claim_no,
current.values.application_reason,
current.values.application_location,
current.values.application_amount_label
].filter(Boolean)
lines.push(`- 关联申请单:${applicationParts.join(' / ')}`)
}
steps.forEach((step) => {
const value = step.key === 'attachments'
? (current.values.attachment_names?.length
@@ -324,6 +396,9 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
: values[step.key]
return `${step.summaryLabel}${value || '待补充'}`
})
if (values.application_claim_no) {
fieldLines.unshift(`关联申请单:${values.application_claim_no}`)
}
const rawText = [
`报销类型:${typeLabel}`,
...fieldLines
@@ -340,7 +415,12 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
time_range: values.time_range || '',
business_time: values.time_range || '',
amount: values.amount || '',
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : []
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
application_claim_id: values.application_claim_id || '',
application_claim_no: values.application_claim_no || '',
application_reason: values.application_reason || '',
application_location: values.application_location || '',
application_amount: values.application_amount || ''
}
return {
@@ -355,7 +435,9 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
expense_scene_selection: {
expense_type: type?.key || current.expenseType || 'other',
expense_type_label: typeLabel,
original_message: rawText
original_message: rawText,
application_claim_id: values.application_claim_id || '',
application_claim_no: values.application_claim_no || ''
},
review_form_values: reviewFormValues
}

View File

@@ -4,6 +4,14 @@ import {
buildApplicationTemplatePreview,
buildLocalApplicationPreviewMessage
} from '../../utils/expenseApplicationPreview.js'
import { fetchExpenseClaims } from '../../services/reimbursements.js'
import {
buildRequiredApplicationActions,
buildRequiredApplicationMissingText,
buildRequiredApplicationSelectionText,
filterRequiredApplicationCandidates,
requiresApplicationBeforeReimbursement
} from './travelReimbursementApplicationLinkModel.js'
import {
GUIDED_ACTION_START_APPLICATION,
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
@@ -11,6 +19,7 @@ import {
GUIDED_ACTION_OPEN_TRAVEL_CALCULATOR,
GUIDED_ACTION_PROCESS_INTERRUPTION,
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION,
GUIDED_ACTION_SELECT_QUERY_MODE,
GUIDED_ACTION_SELECT_QUERY_STATUS,
GUIDED_ACTION_START_REIMBURSEMENT,
@@ -41,8 +50,10 @@ import {
resolveGuidedExpenseTypeFromText,
resolveGuidedQueryModeFromText,
selectGuidedExpenseType,
selectGuidedRequiredApplication,
selectGuidedQueryMode,
shouldConfirmGuidedInterruption
shouldConfirmGuidedInterruption,
waitForGuidedApplicationSelection
} from './travelReimbursementGuidedFlowModel.js'
function normalizeText(value) {
@@ -211,7 +222,98 @@ export function useTravelReimbursementGuidedFlow({
})
}
function handleReimbursementAnswer(answerText, files) {
async function selectExpenseTypeForGuidedReimbursement(currentState, expenseType, options = {}) {
const nextState = options.pendingSceneSelection
? {
...currentState,
values: {
...currentState.values,
pending_scene_original_message: normalizeText(options.pendingSceneSelection.originalMessage),
pending_scene_expense_type_label: normalizeText(options.pendingSceneSelection.expenseTypeLabel)
}
}
: currentState
if (!requiresApplicationBeforeReimbursement(expenseType)) {
guidedFlowState.value = selectGuidedExpenseType(nextState, expenseType)
pushNextReimbursementPrompt()
return
}
let claimsPayload = null
try {
claimsPayload = await fetchExpenseClaims()
} catch (error) {
console.warn('Fetch reimbursement applications failed:', error)
pushAssistant('查询可关联申请单时出现异常,请稍后再试。为避免直接报销,我先暂停当前流程。', {
meta: ['申请单查询失败']
})
toast?.('申请单查询失败,请稍后再试')
return
}
const applications = filterRequiredApplicationCandidates(claimsPayload, expenseType, currentUser?.value || {})
if (!applications.length) {
guidedFlowState.value = createGuidedReimbursementState()
pushAssistant(buildRequiredApplicationMissingText(expenseType), {
meta: ['缺少可关联申请单'],
suggestedActions: buildGuidedExpenseTypeActions()
})
return
}
guidedFlowState.value = waitForGuidedApplicationSelection(nextState, expenseType, applications)
pushAssistant(buildRequiredApplicationSelectionText(expenseType, applications), {
meta: ['等待关联申请单'],
suggestedActions: buildRequiredApplicationActions(applications, GUIDED_ACTION_SELECT_REQUIRED_APPLICATION)
})
}
function buildPendingSceneSubmitOptions(state) {
const current = normalizeGuidedFlowState(state)
const originalMessage = normalizeText(current.values.pending_scene_original_message)
const expenseTypeLabel = normalizeText(current.values.pending_scene_expense_type_label)
const applicationNo = normalizeText(current.values.application_claim_no)
const applicationId = normalizeText(current.values.application_claim_id)
if (!originalMessage || !expenseTypeLabel || !applicationNo) {
return null
}
const rawText = [
originalMessage,
`用户选择报销场景:${expenseTypeLabel}`,
`关联申请单:${applicationNo}`
].join('\n')
return {
rawText,
userText: `关联申请单 ${applicationNo}`,
pendingText: `已关联申请单,正在按${expenseTypeLabel}识别...`,
systemGenerated: true,
skipUserMessage: true,
extraContext: {
draft_claim_id: '',
user_input_text: originalMessage,
expense_scene_selection: {
expense_type: current.expenseType || 'other',
expense_type_label: expenseTypeLabel,
original_message: originalMessage,
application_claim_id: applicationId,
application_claim_no: applicationNo
},
review_form_values: {
expense_type: expenseTypeLabel,
application_claim_id: applicationId,
application_claim_no: applicationNo,
application_reason: current.values.application_reason || '',
application_location: current.values.application_location || '',
application_amount: current.values.application_amount || ''
}
}
}
}
async function handleReimbursementAnswer(answerText, files) {
const currentState = normalizeGuidedFlowState(guidedFlowState.value)
const currentStep = getCurrentGuidedStep(currentState)
const fileNames = buildFileNames(files)
@@ -225,8 +327,18 @@ export function useTravelReimbursementGuidedFlow({
})
return
}
guidedFlowState.value = selectGuidedExpenseType(currentState, expenseType)
pushNextReimbursementPrompt()
await selectExpenseTypeForGuidedReimbursement(currentState, expenseType)
return
}
if (currentState.stepKey === 'application_selection') {
pushAssistant('请先点击上方列出的申请单完成关联。关联后,我再继续询问报销依据。', {
meta: ['等待关联申请单'],
suggestedActions: buildRequiredApplicationActions(
currentState.applicationCandidates,
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION
)
})
return
}
@@ -338,7 +450,7 @@ export function useTravelReimbursementGuidedFlow({
}
if (currentState.mode === GUIDED_FLOW_MODE_REIMBURSEMENT) {
handleReimbursementAnswer(answerText, files)
await handleReimbursementAnswer(answerText, files)
clearComposerRuntime()
persistAndScroll()
return true
@@ -361,6 +473,7 @@ export function useTravelReimbursementGuidedFlow({
}
const guidedActionTypes = new Set([
GUIDED_ACTION_SELECT_EXPENSE_TYPE,
GUIDED_ACTION_SELECT_REQUIRED_APPLICATION,
GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW,
GUIDED_ACTION_CONTINUE_FILLING,
GUIDED_ACTION_PROCESS_INTERRUPTION,
@@ -380,8 +493,23 @@ export function useTravelReimbursementGuidedFlow({
if (actionType === GUIDED_ACTION_SELECT_EXPENSE_TYPE) {
const expenseType = normalizeText(action?.payload?.expense_type)
const expenseTypeLabel = normalizeText(action?.payload?.expense_type_label || action?.label)
guidedFlowState.value = selectGuidedExpenseType(guidedFlowState.value, expenseType)
pushUser(`选择${expenseTypeLabel || '报销类型'}`)
await selectExpenseTypeForGuidedReimbursement(guidedFlowState.value, expenseType)
persistAndScroll()
return true
}
if (actionType === GUIDED_ACTION_SELECT_REQUIRED_APPLICATION) {
const applicationNo = normalizeText(action?.payload?.application_claim_no || action?.label)
pushUser(`关联申请单 ${applicationNo || ''}`.trim())
guidedFlowState.value = selectGuidedRequiredApplication(guidedFlowState.value, action?.payload || {})
const pendingSceneSubmitOptions = buildPendingSceneSubmitOptions(guidedFlowState.value)
if (pendingSceneSubmitOptions) {
resetGuidedFlowState()
persistAndScroll()
await submitExistingComposer(pendingSceneSubmitOptions)
return true
}
pushNextReimbursementPrompt()
persistAndScroll()
return true
@@ -450,10 +578,42 @@ export function useTravelReimbursementGuidedFlow({
return false
}
async function handleSceneSelectionApplicationGate(message, action) {
const actionType = normalizeText(action?.action_type)
if (actionType !== 'select_expense_type') {
return false
}
const actionPayload = action?.payload && typeof action.payload === 'object' ? action.payload : {}
const expenseType = normalizeText(actionPayload.expense_type)
if (!requiresApplicationBeforeReimbursement(expenseType)) {
return false
}
const expenseTypeLabel = normalizeText(actionPayload.expense_type_label || action?.label)
const originalMessage = normalizeText(actionPayload.original_message || message?.text)
if (!expenseTypeLabel || !originalMessage) {
return false
}
if (!lockSuggestedActionMessage(message, action)) {
return true
}
guidedPendingFiles.value = []
pushUser(`选择${expenseTypeLabel}`)
await selectExpenseTypeForGuidedReimbursement(createGuidedReimbursementState(), expenseType, {
pendingSceneSelection: {
originalMessage,
expenseTypeLabel
}
})
persistAndScroll()
return true
}
return {
handleGuidedShortcut,
handleGuidedComposerSubmit,
handleGuidedSuggestedAction,
handleSceneSelectionApplicationGate,
resetGuidedFlowState
}
}