feat: 细化差旅票据费用明细分类并自动计算出差补贴
将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类 型,根据票据字段自动生成行程/事由描述,结合规则引擎自 动计算出差补贴金额,前端适配费用明细编辑和差旅票据审 核交互,补充单元测试覆盖。
This commit is contained in:
@@ -561,6 +561,46 @@
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.detail-note.readonly {
|
||||
background: #f8fafc;
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.detail-note-editor {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.detail-note-editor textarea {
|
||||
min-height: 92px;
|
||||
border-color: rgba(16, 185, 129, .28);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.detail-note-editor textarea:focus {
|
||||
border-color: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, .12);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.detail-note-editor-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.detail-note-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
flex-shrink: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.leader-approval-card {
|
||||
border-color: rgba(5, 150, 105, .18);
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f7fdfb 100%);
|
||||
@@ -633,6 +673,15 @@
|
||||
background: #fbfefd;
|
||||
}
|
||||
|
||||
.detail-expense-table tbody tr.system-generated-row td {
|
||||
background: #f0fdf4;
|
||||
border-bottom-color: #bbf7d0;
|
||||
}
|
||||
|
||||
.detail-expense-table tbody tr.system-generated-row:hover td {
|
||||
background: #ecfdf5;
|
||||
}
|
||||
|
||||
.detail-expense-table .col-time { width: 11%; }
|
||||
.detail-expense-table .col-filled-at { width: 15%; }
|
||||
.detail-expense-table .col-type { width: 13%; }
|
||||
@@ -756,6 +805,36 @@
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.over-tag.system {
|
||||
background: #dcfce7;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.expense-total-under-table {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid #d1fae5;
|
||||
border-radius: 8px;
|
||||
background: #f0fdf4;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.expense-total-under-table span {
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.expense-total-under-table strong {
|
||||
color: #047857;
|
||||
font-size: 17px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.attachment-action-group {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -932,6 +1011,36 @@
|
||||
min-width: 128px;
|
||||
}
|
||||
|
||||
.system-row-lock {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border-radius: 8px;
|
||||
background: #dcfce7;
|
||||
color: #047857;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.system-attachment-note {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 28px;
|
||||
padding: 0 9px;
|
||||
border-radius: 8px;
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-action-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -1332,8 +1441,9 @@
|
||||
}
|
||||
|
||||
.validation-card {
|
||||
border: 1px solid #e6f0eb;
|
||||
background: linear-gradient(180deg, #fcfffd 0%, #f7fbf9 100%);
|
||||
border: 1px solid #e5e7eb;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
|
||||
}
|
||||
|
||||
.validation-head {
|
||||
@@ -1341,11 +1451,14 @@
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.validation-head h3 {
|
||||
margin-bottom: 4px;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.validation-head p {
|
||||
@@ -1356,28 +1469,32 @@
|
||||
}
|
||||
|
||||
.validation-pill {
|
||||
min-height: 26px;
|
||||
min-height: 24px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.validation-pill.ready {
|
||||
background: #dcfce7;
|
||||
color: #047857;
|
||||
background: #f0fdf4;
|
||||
border-color: #bbf7d0;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.validation-pill.pending {
|
||||
background: #fff7ed;
|
||||
border-color: #fed7aa;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.validation-pill.warning {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
border-color: #fecaca;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.validation-summary {
|
||||
@@ -1387,29 +1504,155 @@
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.validation-sections {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.validation-section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.validation-section:first-child {
|
||||
padding-top: 0;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.validation-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.validation-section-title::before {
|
||||
content: '';
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: #10b981;
|
||||
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.validation-section--risk .validation-section-title {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.validation-section--risk .validation-section-title::before {
|
||||
background: #ef4444;
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
.validation-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
padding-left: 18px;
|
||||
color: #b45309;
|
||||
margin: 0;
|
||||
padding: 0 0 0 18px;
|
||||
color: #0f766e;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.risk-advice-list {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 14px;
|
||||
.validation-list li::marker {
|
||||
color: #14b8a6;
|
||||
}
|
||||
|
||||
.risk-advice-card {
|
||||
.validation-section--risk .risk-advice-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px;
|
||||
border: 1px solid #fee2e2;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px 12px 11px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 1px 1px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card.medium {
|
||||
border-color: #f3e8d9;
|
||||
background: #fffcf7;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card-head span {
|
||||
min-height: 20px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-radius: 999px;
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card.medium .risk-advice-card-head span {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-card-head strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-point {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-meta > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 8px 9px;
|
||||
border-radius: 8px;
|
||||
background: #fffafa;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-meta span {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.validation-section--risk .risk-advice-meta ul,
|
||||
.validation-section--risk .risk-advice-meta p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 11px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.risk-advice-card.medium {
|
||||
|
||||
@@ -4,6 +4,11 @@ import { fetchExpenseClaims } from '../services/reimbursements.js'
|
||||
|
||||
const EXPENSE_TYPE_LABELS = {
|
||||
travel: '差旅费',
|
||||
train_ticket: '火车票',
|
||||
flight_ticket: '机票',
|
||||
hotel_ticket: '住宿票',
|
||||
ride_ticket: '乘车',
|
||||
travel_allowance: '出差补贴',
|
||||
entertainment: '业务招待费',
|
||||
office: '办公费',
|
||||
meeting: '会务费',
|
||||
@@ -16,10 +21,17 @@ const EXPENSE_TYPE_LABELS = {
|
||||
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'train_ticket',
|
||||
'flight_ticket',
|
||||
'hotel_ticket',
|
||||
'ride_ticket',
|
||||
'meeting',
|
||||
'entertainment'
|
||||
])
|
||||
|
||||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||
|
||||
const REIMBURSEMENT_PROGRESS_LABELS = [
|
||||
'创建单据',
|
||||
'待提交',
|
||||
@@ -123,6 +135,57 @@ function resolveLocationDisplay(location, typeCode) {
|
||||
return isLocationRequiredExpenseType(typeCode) ? '待补充' : '非必填'
|
||||
}
|
||||
|
||||
function resolveExpenseItemViewId(item, index, claim) {
|
||||
return String(item?.id || `${claim?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
function buildTravelTimeLabelMap(items, claim) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
|
||||
return {
|
||||
id: resolveExpenseItemViewId(item, index, claim),
|
||||
index,
|
||||
itemType,
|
||||
itemDate: formatDate(item?.item_date),
|
||||
isSystemGenerated: Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
})
|
||||
.filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
|
||||
.sort((left, right) => {
|
||||
const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
|
||||
return dateCompare || left.index - right.index
|
||||
})
|
||||
|
||||
const labels = new Map()
|
||||
travelItems.forEach((item, index) => {
|
||||
if (index === 0) {
|
||||
labels.set(item.id, '出发时间')
|
||||
} else if (index === travelItems.length - 1) {
|
||||
labels.set(item.id, '返回时间')
|
||||
} else {
|
||||
labels.set(item.id, '中转时间')
|
||||
}
|
||||
})
|
||||
return labels
|
||||
}
|
||||
|
||||
function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, claim, travelTimeLabelMap }) {
|
||||
if (isSystemGenerated) {
|
||||
return '系统自动计算'
|
||||
}
|
||||
if (travelTimeLabelMap?.has(id)) {
|
||||
return travelTimeLabelMap.get(id)
|
||||
}
|
||||
if (itemType === 'ride_ticket') {
|
||||
return '乘车时间'
|
||||
}
|
||||
if (itemType === 'hotel_ticket') {
|
||||
return '住宿时间'
|
||||
}
|
||||
return claim?.expense_type === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
function resolveAttachmentDisplayName(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!normalized) {
|
||||
@@ -498,11 +561,20 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
return []
|
||||
}
|
||||
|
||||
return claim.items.map((item, index) => {
|
||||
const sortedItems = [...claim.items].sort((left, right) => {
|
||||
const leftType = normalizeExpenseType(left?.item_type)
|
||||
const rightType = normalizeExpenseType(right?.item_type)
|
||||
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
|
||||
})
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
|
||||
|
||||
return sortedItems.map((item, index) => {
|
||||
const invoiceId = String(item?.invoice_id || '').trim()
|
||||
const attachmentName = resolveAttachmentDisplayName(invoiceId)
|
||||
const attachments = invoiceId ? [attachmentName || invoiceId] : []
|
||||
const itemType = normalizeExpenseType(item?.item_type || claim?.expense_type)
|
||||
const isSystemGenerated = Boolean(item?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
const id = resolveExpenseItemViewId(item, index, claim)
|
||||
const itemTypeLabel = resolveTypeLabel(itemType)
|
||||
const itemLocation = String(item?.item_location || '').trim()
|
||||
const itemReason = String(item?.item_reason || '').trim()
|
||||
@@ -510,7 +582,7 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
|
||||
|
||||
return {
|
||||
id: String(item?.id || `${claim?.id || 'claim'}-item-${index}`),
|
||||
id,
|
||||
time: formatDate(item?.item_date) || '待补充',
|
||||
itemDate: formatDate(item?.item_date) || '',
|
||||
filledAt: formatDateTime(item?.created_at) || '待同步',
|
||||
@@ -519,17 +591,24 @@ function buildExpenseItems(claim, riskSummary) {
|
||||
itemLocation,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
dayLabel: claim?.expense_type === 'travel' ? `第 ${index + 1} 项` : '业务发生项',
|
||||
isSystemGenerated,
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
claim,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: itemTypeLabel,
|
||||
category: itemTypeLabel,
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
amount: itemAmountDisplay,
|
||||
status: attachments.length ? '已识别' : '待补充',
|
||||
tone: attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
|
||||
attachmentTone: attachments.length ? 'ok' : 'missing',
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachments[0] : '仅支持上传 1 张 JPG、PNG、PDF 单据',
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: riskSummary === '无' ? '无' : '待关注',
|
||||
riskText: riskSummary === '无' ? '' : riskSummary,
|
||||
|
||||
@@ -12,6 +12,13 @@ export function fetchExpenseClaimDetail(claimId) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`)
|
||||
}
|
||||
|
||||
export function updateExpenseClaim(claimId, payload = {}) {
|
||||
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
}
|
||||
|
||||
export function calculateTravelReimbursement(payload = {}) {
|
||||
return apiRequest('/reimbursements/travel-calculator', {
|
||||
method: 'POST',
|
||||
|
||||
@@ -5,6 +5,36 @@ const REQUEST_TYPE_META = {
|
||||
tone: 'travel',
|
||||
secondaryStatusLabel: '行程状态'
|
||||
},
|
||||
train_ticket: {
|
||||
label: '火车票',
|
||||
detailVariant: 'travel',
|
||||
tone: 'travel',
|
||||
secondaryStatusLabel: '行程状态'
|
||||
},
|
||||
flight_ticket: {
|
||||
label: '机票',
|
||||
detailVariant: 'travel',
|
||||
tone: 'travel',
|
||||
secondaryStatusLabel: '行程状态'
|
||||
},
|
||||
hotel_ticket: {
|
||||
label: '住宿票',
|
||||
detailVariant: 'travel',
|
||||
tone: 'travel',
|
||||
secondaryStatusLabel: '票据状态'
|
||||
},
|
||||
ride_ticket: {
|
||||
label: '乘车',
|
||||
detailVariant: 'travel',
|
||||
tone: 'travel',
|
||||
secondaryStatusLabel: '票据状态'
|
||||
},
|
||||
travel_allowance: {
|
||||
label: '出差补贴',
|
||||
detailVariant: 'travel',
|
||||
tone: 'travel',
|
||||
secondaryStatusLabel: '系统计算'
|
||||
},
|
||||
entertainment: {
|
||||
label: '业务招待费',
|
||||
detailVariant: 'general',
|
||||
|
||||
@@ -352,6 +352,7 @@
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="shouldShowReviewUploadButton(message.reviewPayload)"
|
||||
type="button"
|
||||
class="review-footer-btn"
|
||||
:disabled="submitting || reviewActionBusy"
|
||||
|
||||
@@ -88,6 +88,46 @@
|
||||
|
||||
<div class="detail-grid">
|
||||
<section class="detail-left">
|
||||
<article class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
<h3>附加说明</h3>
|
||||
<p>用于说明本次出差或办事目的,例如去哪里、拜访谁、处理什么事项。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canEditDetailNote" class="detail-note-editor">
|
||||
<textarea
|
||||
v-model="detailNoteEditor"
|
||||
maxlength="500"
|
||||
placeholder="例如:去北京客户现场出差,拜访 XX 客户并处理项目验收事项"
|
||||
aria-label="附加说明"
|
||||
></textarea>
|
||||
<div class="detail-note-editor-meta">
|
||||
<span>仅草稿待提交状态可编辑,提交后将作为明确说明展示。</span>
|
||||
<div class="detail-note-actions">
|
||||
<button
|
||||
v-if="detailNoteDirty"
|
||||
class="inline-action"
|
||||
type="button"
|
||||
:disabled="savingDetailNote"
|
||||
@click="resetDetailNote"
|
||||
>
|
||||
恢复
|
||||
</button>
|
||||
<button
|
||||
class="inline-action primary"
|
||||
type="button"
|
||||
:disabled="!detailNoteDirty || savingDetailNote"
|
||||
@click="saveDetailNote"
|
||||
>
|
||||
{{ savingDetailNote ? '保存中' : '保存说明' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="detail-note readonly">{{ detailNote }}</div>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel">
|
||||
<div class="detail-card-head">
|
||||
<div>
|
||||
@@ -129,7 +169,7 @@
|
||||
</thead>
|
||||
<tbody>
|
||||
<template v-for="item in expenseItems" :key="item.id">
|
||||
<tr>
|
||||
<tr :class="{ 'system-generated-row': item.isSystemGenerated }">
|
||||
<td class="expense-time col-time">
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor">
|
||||
@@ -200,8 +240,8 @@
|
||||
<template v-if="editingExpenseId === item.id">
|
||||
<div class="cell-editor editor-stack">
|
||||
<div class="attachment-action-group">
|
||||
<button
|
||||
v-if="isEditableRequest && !item.invoiceId"
|
||||
<button
|
||||
v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
|
||||
class="icon-action upload"
|
||||
type="button"
|
||||
title="上传单据"
|
||||
@@ -221,8 +261,8 @@
|
||||
>
|
||||
<i class="mdi mdi-eye-outline"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="isEditableRequest && item.invoiceId"
|
||||
<button
|
||||
v-if="isEditableRequest && item.invoiceId && !item.isSystemGenerated"
|
||||
class="icon-action danger"
|
||||
type="button"
|
||||
title="删除附件"
|
||||
@@ -236,9 +276,13 @@
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="attachment-action-group">
|
||||
<div v-if="item.isSystemGenerated" class="system-attachment-note">
|
||||
<i class="mdi mdi-calculator-variant-outline"></i>
|
||||
<span>无需附件</span>
|
||||
</div>
|
||||
<div v-else class="attachment-action-group">
|
||||
<button
|
||||
v-if="isEditableRequest && !item.invoiceId"
|
||||
v-if="isEditableRequest && !item.invoiceId && !item.isSystemGenerated"
|
||||
class="icon-action upload"
|
||||
type="button"
|
||||
title="上传单据"
|
||||
@@ -259,7 +303,7 @@
|
||||
<i class="mdi mdi-eye-outline"></i>
|
||||
</button>
|
||||
<button
|
||||
v-if="isEditableRequest && item.invoiceId"
|
||||
v-if="isEditableRequest && item.invoiceId && !item.isSystemGenerated"
|
||||
class="icon-action danger"
|
||||
type="button"
|
||||
title="删除附件"
|
||||
@@ -273,7 +317,11 @@
|
||||
</template>
|
||||
</td>
|
||||
<td v-if="isEditableRequest" class="expense-action-cell col-action">
|
||||
<div v-if="editingExpenseId === item.id" class="row-action-group">
|
||||
<div v-if="item.isSystemGenerated" class="system-row-lock">
|
||||
<i class="mdi mdi-lock-outline"></i>
|
||||
<span>系统计算</span>
|
||||
</div>
|
||||
<div v-else-if="editingExpenseId === item.id" class="row-action-group">
|
||||
<button
|
||||
class="inline-action primary"
|
||||
type="button"
|
||||
@@ -328,6 +376,10 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div v-if="expenseItems.length" class="expense-total-under-table">
|
||||
<span>金额合计</span>
|
||||
<strong>{{ expenseTotal }}</strong>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<article v-if="isEditableRequest" class="detail-card panel validation-card">
|
||||
@@ -339,39 +391,43 @@
|
||||
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
|
||||
</div>
|
||||
<p class="validation-summary">{{ aiAdvice.summary }}</p>
|
||||
<div v-if="aiAdvice.riskCards.length" class="risk-advice-list">
|
||||
<article
|
||||
v-for="card in aiAdvice.riskCards"
|
||||
:key="card.id"
|
||||
:class="['risk-advice-card', card.tone]"
|
||||
<div v-if="aiAdvice.sections.length" class="validation-sections">
|
||||
<section
|
||||
v-for="section in aiAdvice.sections"
|
||||
:key="section.kind"
|
||||
:class="['validation-section', `validation-section--${section.kind}`]"
|
||||
>
|
||||
<div class="risk-advice-card-head">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
<h4 class="validation-section-title">{{ section.title }}</h4>
|
||||
<ul v-if="section.kind === 'completion'" class="validation-list">
|
||||
<li v-for="item in section.items" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
<div v-else class="risk-advice-list">
|
||||
<article
|
||||
v-for="card in section.items"
|
||||
:key="card.id"
|
||||
:class="['risk-advice-card', card.tone]"
|
||||
>
|
||||
<div class="risk-advice-card-head">
|
||||
<span>{{ card.label }}</span>
|
||||
<strong>{{ card.title }}</strong>
|
||||
</div>
|
||||
<p class="risk-advice-point">{{ card.risk }}</p>
|
||||
<div class="risk-advice-meta">
|
||||
<div>
|
||||
<span>规则依据</span>
|
||||
<ul>
|
||||
<li v-for="basis in card.ruleBasis" :key="basis">{{ basis }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span>修改建议</span>
|
||||
<p>{{ card.suggestion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
<p class="risk-advice-point">{{ card.risk }}</p>
|
||||
<div class="risk-advice-meta">
|
||||
<div>
|
||||
<span>规则依据</span>
|
||||
<ul>
|
||||
<li v-for="basis in card.ruleBasis" :key="basis">{{ basis }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<span>修改建议</span>
|
||||
<p>{{ card.suggestion }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
<ul v-if="aiAdvice.items.length" class="validation-list">
|
||||
<li v-for="item in aiAdvice.items" :key="item">{{ item }}</li>
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article class="detail-card panel">
|
||||
<h3>附加说明</h3>
|
||||
<div class="detail-note">{{ detailNote }}</div>
|
||||
</article>
|
||||
|
||||
<article v-if="showLeaderApprovalPanel" class="detail-card panel leader-approval-card">
|
||||
|
||||
@@ -1497,6 +1497,20 @@ function resolveReviewMissingSlotCards(reviewPayload) {
|
||||
: []
|
||||
}
|
||||
|
||||
function resolveReviewExtraMissingLabels(reviewPayload) {
|
||||
const labels = Array.isArray(reviewPayload?.missing_slots)
|
||||
? reviewPayload.missing_slots.map((item) => String(item || '').trim()).filter(Boolean)
|
||||
: []
|
||||
if (!labels.length) return []
|
||||
|
||||
const slotLabels = new Set(
|
||||
(Array.isArray(reviewPayload?.slot_cards) ? reviewPayload.slot_cards : [])
|
||||
.map((item) => String(item?.label || item?.key || '').trim())
|
||||
.filter(Boolean)
|
||||
)
|
||||
return labels.filter((label) => !slotLabels.has(label))
|
||||
}
|
||||
|
||||
function resolveReviewRiskBriefs(reviewPayload) {
|
||||
if (!Array.isArray(reviewPayload?.risk_briefs)) return []
|
||||
return reviewPayload.risk_briefs.filter((item) => {
|
||||
@@ -1762,7 +1776,7 @@ function buildExpenseQueryHint(queryPayload) {
|
||||
}
|
||||
|
||||
function countReviewPendingItems(reviewPayload) {
|
||||
return resolveReviewMissingSlotCards(reviewPayload).length
|
||||
return resolveReviewMissingSlotCards(reviewPayload).length + resolveReviewExtraMissingLabels(reviewPayload).length
|
||||
}
|
||||
|
||||
function countReviewRiskItems(reviewPayload) {
|
||||
@@ -1825,12 +1839,12 @@ function shouldOpenReviewDisclosure(reviewPayload) {
|
||||
}
|
||||
|
||||
function buildReviewTodoSectionTitle(reviewPayload) {
|
||||
return resolveReviewMissingSlotCards(reviewPayload).length ? '待补充内容' : '已识别信息'
|
||||
return countReviewPendingItems(reviewPayload) ? '待补充内容' : '已识别信息'
|
||||
}
|
||||
|
||||
function buildReviewTodoSectionMeta(reviewPayload) {
|
||||
const count = buildReviewTodoItems(reviewPayload).length
|
||||
if (resolveReviewMissingSlotCards(reviewPayload).length) {
|
||||
if (countReviewPendingItems(reviewPayload)) {
|
||||
return count ? `${count} 项` : '待确认'
|
||||
}
|
||||
return count ? `${count} 项` : '已齐全'
|
||||
@@ -1864,6 +1878,17 @@ function buildReviewAlertChips(reviewPayload) {
|
||||
})
|
||||
}
|
||||
|
||||
if (chips.length < 3) {
|
||||
for (const label of resolveReviewExtraMissingLabels(reviewPayload)) {
|
||||
chips.push({
|
||||
key: label,
|
||||
label,
|
||||
tone: 'warning'
|
||||
})
|
||||
if (chips.length >= 3) break
|
||||
}
|
||||
}
|
||||
|
||||
if (chips.length < 3) {
|
||||
for (const risk of resolveReviewRiskBriefs(reviewPayload)) {
|
||||
if (chips.some((item) => item.label === risk.title)) continue
|
||||
@@ -1889,8 +1914,10 @@ function buildReviewAlertChips(reviewPayload) {
|
||||
|
||||
function buildReviewTodoItems(reviewPayload) {
|
||||
const missingItems = resolveReviewMissingSlotCards(reviewPayload)
|
||||
if (missingItems.length) {
|
||||
return missingItems.map((item) => {
|
||||
const extraMissingLabels = resolveReviewExtraMissingLabels(reviewPayload)
|
||||
if (missingItems.length || extraMissingLabels.length) {
|
||||
return [
|
||||
...missingItems.map((item) => {
|
||||
const config = REVIEW_SLOT_CONFIG[item.key] || {}
|
||||
return {
|
||||
key: item.key,
|
||||
@@ -1900,7 +1927,18 @@ function buildReviewTodoItems(reviewPayload) {
|
||||
status: config.status || '待补充',
|
||||
tone: 'warning'
|
||||
}
|
||||
})
|
||||
}),
|
||||
...extraMissingLabels.map((label, index) => ({
|
||||
key: `extra-missing-${index}-${label}`,
|
||||
icon: label.includes('酒店') || label.includes('住宿') ? 'mdi mdi-bed-outline' : 'mdi mdi-file-alert-outline',
|
||||
title: label,
|
||||
hint: label.includes('必须')
|
||||
? '该票据属于当前差旅提交的必备材料,补齐后才能继续下一步。'
|
||||
: '可以继续补充该材料;如暂时没有,也可以按当前信息处理。',
|
||||
status: label.includes('必须') ? '必须补齐' : '可选补充',
|
||||
tone: 'warning'
|
||||
}))
|
||||
]
|
||||
}
|
||||
|
||||
return resolveReviewRecognizedSlotCards(reviewPayload)
|
||||
@@ -2571,8 +2609,18 @@ function buildLocallySyncedReviewActions(reviewPayload, canProceed) {
|
||||
return actions
|
||||
}
|
||||
|
||||
const syncedActions = actions.filter((item) => String(item?.action_type || '').trim() !== 'next_step')
|
||||
if (!syncedActions.some((item) => String(item?.action_type || '').trim() === 'save_draft')) {
|
||||
syncedActions.push({
|
||||
label: '保存为草稿',
|
||||
action_type: 'save_draft',
|
||||
description: '先暂存当前已识别信息,稍后仍可继续补充或提交。',
|
||||
emphasis: 'secondary'
|
||||
})
|
||||
}
|
||||
|
||||
return [
|
||||
...actions.filter((item) => !['save_draft', 'next_step'].includes(String(item?.action_type || '').trim())),
|
||||
...syncedActions,
|
||||
{
|
||||
label: '继续下一步',
|
||||
action_type: 'next_step',
|
||||
@@ -2607,12 +2655,17 @@ function buildLocallySyncedReviewPayload(reviewPayload, inlineState = createEmpt
|
||||
const missingSlots = nextSlotCards
|
||||
.filter((slot) => slot.required && slot.status === 'missing')
|
||||
.map((slot) => slot.label || slot.key)
|
||||
const canProceed = missingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
|
||||
const extraMissingSlots = resolveReviewExtraMissingLabels({
|
||||
...reviewPayload,
|
||||
slot_cards: nextSlotCards
|
||||
})
|
||||
const allMissingSlots = [...missingSlots, ...extraMissingSlots]
|
||||
const canProceed = allMissingSlots.length === 0 && (Array.isArray(reviewPayload.claim_groups) ? reviewPayload.claim_groups.length > 0 : true)
|
||||
|
||||
return {
|
||||
...reviewPayload,
|
||||
can_proceed: canProceed,
|
||||
missing_slots: missingSlots,
|
||||
missing_slots: allMissingSlots,
|
||||
slot_cards: nextSlotCards,
|
||||
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
|
||||
}
|
||||
@@ -2821,22 +2874,24 @@ function buildReviewDocumentSummaries(reviewPayload) {
|
||||
}
|
||||
|
||||
function buildReviewDecisionHint(reviewPayload) {
|
||||
const missingSlots = resolveReviewMissingSlotCards(reviewPayload)
|
||||
const pendingCount = countReviewPendingItems(reviewPayload)
|
||||
const riskBriefs = resolveReviewRiskBriefs(reviewPayload)
|
||||
if (reviewPayload?.can_proceed) {
|
||||
if (shouldShowReviewUploadButton(reviewPayload)) {
|
||||
return '必需信息已整理好;如还有非必需票据可以继续上传,也可以直接进入下一步或保存草稿。'
|
||||
}
|
||||
return riskBriefs.length
|
||||
? `我已经把信息整理好了。你可以直接进入下一步,提交前再看一下下方 ${riskBriefs.length} 条提醒。`
|
||||
: '我已经把信息整理好了。你确认无误后,可以直接进入下一步。'
|
||||
}
|
||||
if (missingSlots.length) {
|
||||
return `我先完成了当前这轮识别,还差 ${missingSlots.length} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。`
|
||||
if (pendingCount) {
|
||||
return `我先完成了当前这轮识别,还差 ${pendingCount} 项关键信息。你可以继续补充;如果暂时拿不全,也可以先保存草稿。`
|
||||
}
|
||||
return '如果你觉得识别结果有偏差,点“修改识别信息”直接校正,我会按新内容重新识别。'
|
||||
}
|
||||
|
||||
function buildReviewMissingHint(reviewPayload) {
|
||||
const missingSlots = resolveReviewMissingSlotCards(reviewPayload)
|
||||
if (!missingSlots.length) {
|
||||
if (!countReviewPendingItems(reviewPayload)) {
|
||||
return ''
|
||||
}
|
||||
if (reviewPayload?.can_proceed) {
|
||||
@@ -2860,8 +2915,19 @@ function buildReviewActionHint(reviewPayload) {
|
||||
return '如果现在信息还不完整,可以先保存草稿;识别错了就点“修改识别信息”。'
|
||||
}
|
||||
|
||||
function shouldShowReviewUploadButton(reviewPayload) {
|
||||
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
|
||||
if (!documents.length) return true
|
||||
if (countReviewPendingItems(reviewPayload)) return true
|
||||
|
||||
return resolveReviewRiskBriefs(reviewPayload).some((brief) => {
|
||||
const text = `${brief?.title || ''} ${brief?.content || ''} ${brief?.suggestion || ''}`
|
||||
return /差旅票据待补充|待上传|可继续上传|可继续提供/.test(text)
|
||||
})
|
||||
}
|
||||
|
||||
function buildReviewStatusTag(reviewPayload) {
|
||||
const missingCount = resolveReviewMissingSlotCards(reviewPayload).length
|
||||
const missingCount = countReviewPendingItems(reviewPayload)
|
||||
if (reviewPayload?.can_proceed) {
|
||||
return '可继续处理'
|
||||
}
|
||||
@@ -5607,6 +5673,7 @@ export default {
|
||||
buildReviewTodoSectionMeta,
|
||||
buildReviewAlertChips,
|
||||
buildReviewTodoItems,
|
||||
shouldShowReviewUploadButton,
|
||||
resolveReviewSubmitActions,
|
||||
resolveReviewPrimaryAction,
|
||||
resolveReviewEditAction,
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
returnExpenseClaim,
|
||||
submitExpenseClaim,
|
||||
uploadExpenseClaimItemAttachment,
|
||||
updateExpenseClaim,
|
||||
updateExpenseClaimItem
|
||||
} from '../../services/reimbursements.js'
|
||||
import {
|
||||
@@ -32,6 +33,10 @@ import {
|
||||
|
||||
const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'travel', label: '差旅费' },
|
||||
{ value: 'train_ticket', label: '火车票' },
|
||||
{ value: 'flight_ticket', label: '机票' },
|
||||
{ value: 'hotel_ticket', label: '住宿票' },
|
||||
{ value: 'ride_ticket', label: '乘车' },
|
||||
{ value: 'entertainment', label: '业务招待费' },
|
||||
{ value: 'office', label: '办公费' },
|
||||
{ value: 'meeting', label: '会务费' },
|
||||
@@ -39,15 +44,23 @@ const EXPENSE_TYPE_OPTIONS = [
|
||||
{ value: 'hotel', label: '住宿费' },
|
||||
{ value: 'transport', label: '交通费' },
|
||||
{ value: 'meal', label: '餐费' },
|
||||
{ value: 'travel_allowance', label: '出差补贴' },
|
||||
{ value: 'other', label: '其他费用' }
|
||||
]
|
||||
|
||||
const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||
'travel',
|
||||
'train_ticket',
|
||||
'flight_ticket',
|
||||
'hotel_ticket',
|
||||
'ride_ticket',
|
||||
'meeting',
|
||||
'entertainment'
|
||||
])
|
||||
|
||||
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
|
||||
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
|
||||
|
||||
function parseCurrency(value) {
|
||||
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
|
||||
}
|
||||
@@ -69,6 +82,11 @@ function resolveExpenseTypeLabel(value) {
|
||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
||||
}
|
||||
|
||||
function isSystemGeneratedExpenseItemSource(source) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
|
||||
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
|
||||
}
|
||||
|
||||
function isLocationRequiredExpenseType(value) {
|
||||
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
|
||||
}
|
||||
@@ -135,6 +153,11 @@ function isPlaceholderValue(value) {
|
||||
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
function normalizeDetailNoteDraftValue(value) {
|
||||
const text = String(value || '').trim()
|
||||
return isPlaceholderValue(text) ? '' : text
|
||||
}
|
||||
|
||||
function isValidIsoDate(value) {
|
||||
const normalized = String(value || '').trim()
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(normalized)) {
|
||||
@@ -213,8 +236,65 @@ function extractAttachmentDisplayName(value) {
|
||||
return normalized.split('/').filter(Boolean).pop() || normalized
|
||||
}
|
||||
|
||||
function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
function resolveExpenseItemViewId(source, index, requestModel) {
|
||||
return String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`)
|
||||
}
|
||||
|
||||
function buildTravelTimeLabelMap(items, requestModel) {
|
||||
const travelItems = items
|
||||
.map((item, index) => {
|
||||
const itemType = normalizeExpenseType(item?.itemType || item?.item_type || requestModel?.typeCode || 'other')
|
||||
return {
|
||||
id: resolveExpenseItemViewId(item, index, requestModel),
|
||||
index,
|
||||
itemType,
|
||||
itemDate: normalizeIsoDateValue(item?.itemDate ?? item?.item_date),
|
||||
isSystemGenerated: isSystemGeneratedExpenseItemSource({ ...item, itemType })
|
||||
}
|
||||
})
|
||||
.filter((item) => !item.isSystemGenerated && LONG_DISTANCE_TRAVEL_EXPENSE_TYPES.has(item.itemType))
|
||||
.sort((left, right) => {
|
||||
const dateCompare = String(left.itemDate || '').localeCompare(String(right.itemDate || ''))
|
||||
return dateCompare || left.index - right.index
|
||||
})
|
||||
|
||||
const labels = new Map()
|
||||
if (!travelItems.length) {
|
||||
return labels
|
||||
}
|
||||
|
||||
travelItems.forEach((item, index) => {
|
||||
if (index === 0) {
|
||||
labels.set(item.id, '出发时间')
|
||||
} else if (index === travelItems.length - 1) {
|
||||
labels.set(item.id, '返回时间')
|
||||
} else {
|
||||
labels.set(item.id, '中转时间')
|
||||
}
|
||||
})
|
||||
return labels
|
||||
}
|
||||
|
||||
function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, requestModel, travelTimeLabelMap }) {
|
||||
if (isSystemGenerated) {
|
||||
return '系统自动计算'
|
||||
}
|
||||
if (travelTimeLabelMap?.has(id)) {
|
||||
return travelTimeLabelMap.get(id)
|
||||
}
|
||||
if (itemType === 'ride_ticket') {
|
||||
return '乘车时间'
|
||||
}
|
||||
if (itemType === 'hotel_ticket') {
|
||||
return '住宿时间'
|
||||
}
|
||||
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
|
||||
}
|
||||
|
||||
function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
|
||||
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
|
||||
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
|
||||
const id = resolveExpenseItemViewId(source, index, requestModel)
|
||||
const itemReason = String(source?.itemReason ?? source?.item_reason ?? '').trim()
|
||||
const itemLocation = String(source?.itemLocation ?? source?.item_location ?? '').trim()
|
||||
const itemDate = normalizeIsoDateValue(source?.itemDate ?? source?.item_date)
|
||||
@@ -232,26 +312,33 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
)
|
||||
|
||||
return {
|
||||
id: String(source?.id || `${requestModel?.claimId || requestModel?.id || 'claim'}-item-${index}`),
|
||||
id,
|
||||
itemDate,
|
||||
itemType,
|
||||
itemReason,
|
||||
itemLocation,
|
||||
itemAmount,
|
||||
invoiceId,
|
||||
isSystemGenerated,
|
||||
time: itemDate || '待补充',
|
||||
filledAt: filledAt || '待同步',
|
||||
dayLabel: requestModel?.detailVariant === 'travel' ? `第 ${index + 1} 项` : '业务发生项',
|
||||
dayLabel: resolveExpenseTimeLabel({
|
||||
id,
|
||||
itemType,
|
||||
isSystemGenerated,
|
||||
requestModel,
|
||||
travelTimeLabelMap
|
||||
}),
|
||||
name: resolveExpenseTypeLabel(itemType),
|
||||
category: resolveExpenseTypeLabel(itemType),
|
||||
desc: itemReason || '待补充',
|
||||
detail: resolveLocationDisplay(itemLocation, itemType),
|
||||
amount: amountDisplay,
|
||||
status: attachments.length ? '已识别' : '待补充',
|
||||
tone: attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: attachments.length ? 'ok' : 'missing',
|
||||
status: isSystemGenerated ? '系统计算' : attachments.length ? '已识别' : '待补充',
|
||||
tone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'bad',
|
||||
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
|
||||
attachmentHint: isSystemGenerated ? '根据出差天数与职级自动测算' : attachments.length ? attachmentName || attachments[0] : resolveExpenseUploadHint(),
|
||||
attachmentTone: isSystemGenerated ? 'system' : attachments.length ? 'ok' : 'missing',
|
||||
attachments,
|
||||
riskLabel: String(source?.riskLabel || '').trim() || '无',
|
||||
riskText,
|
||||
@@ -260,11 +347,17 @@ function buildExpenseItemViewModel(source, index, requestModel) {
|
||||
}
|
||||
|
||||
function rebuildExpenseItems(items, requestModel) {
|
||||
return items.map((item, index) => buildExpenseItemViewModel(item, index, requestModel))
|
||||
const sortedItems = [...items]
|
||||
.sort((left, right) => Number(isSystemGeneratedExpenseItemSource(left)) - Number(isSystemGeneratedExpenseItemSource(right)))
|
||||
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, requestModel)
|
||||
return sortedItems.map((item, index) => buildExpenseItemViewModel(item, index, requestModel, travelTimeLabelMap))
|
||||
}
|
||||
|
||||
function buildExpenseDraftIssues(item) {
|
||||
const issues = []
|
||||
if (item.isSystemGenerated) {
|
||||
return issues
|
||||
}
|
||||
const locationRequired = isLocationRequiredExpenseType(item.itemType)
|
||||
|
||||
if (!isValidIsoDate(item.itemDate)) {
|
||||
@@ -441,6 +534,8 @@ export default {
|
||||
itemAmount: '',
|
||||
invoiceId: ''
|
||||
})
|
||||
const detailNoteEditor = ref('')
|
||||
const savingDetailNote = ref(false)
|
||||
|
||||
const request = computed(() => {
|
||||
const normalized = normalizeRequestForUi(props.request)
|
||||
@@ -654,7 +749,7 @@ export default {
|
||||
}
|
||||
|
||||
const expenseTotal = computed(() => {
|
||||
const total = expenseItems.value.reduce((sum, item) => sum + parseCurrency(item.amount), 0)
|
||||
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0)
|
||||
return formatCurrency(total)
|
||||
})
|
||||
|
||||
@@ -662,10 +757,21 @@ export default {
|
||||
const expenseTableColumnCount = computed(
|
||||
() => 6 + (isEditableRequest.value ? 1 : 0)
|
||||
)
|
||||
const detailNote = computed(
|
||||
() =>
|
||||
request.value.note
|
||||
|| '暂无附加说明。可在这里补充特殊背景、例外原因、补件计划或其他需要财务和审批人重点关注的信息。'
|
||||
const canEditDetailNote = computed(() => isDraftRequest.value)
|
||||
const detailNoteSource = computed(() => normalizeDetailNoteDraftValue(request.value.note))
|
||||
const detailNote = computed(() => {
|
||||
if (detailNoteSource.value) {
|
||||
return detailNoteSource.value
|
||||
}
|
||||
return '暂无附加说明。请补充本次出差或办事事由,例如“去北京客户现场出差,拜访 XX 客户并处理项目验收事项”。'
|
||||
})
|
||||
const detailNoteDirty = computed(() => detailNoteEditor.value.trim() !== detailNoteSource.value)
|
||||
watch(
|
||||
() => [request.value.claimId, detailNoteSource.value],
|
||||
([, nextNote]) => {
|
||||
detailNoteEditor.value = nextNote
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
const draftBlockingIssues = computed(() =>
|
||||
isEditableRequest.value ? buildDraftBlockingIssues(request.value, expenseItems.value) : []
|
||||
@@ -873,10 +979,44 @@ export default {
|
||||
})
|
||||
})
|
||||
|
||||
function resetDetailNote() {
|
||||
detailNoteEditor.value = detailNoteSource.value
|
||||
}
|
||||
|
||||
async function saveDetailNote() {
|
||||
if (!canEditDetailNote.value || savingDetailNote.value) {
|
||||
return
|
||||
}
|
||||
if (!request.value.claimId) {
|
||||
toast('当前草稿缺少 claimId,暂时无法保存附加说明。')
|
||||
return
|
||||
}
|
||||
if (!detailNoteDirty.value) {
|
||||
return
|
||||
}
|
||||
|
||||
savingDetailNote.value = true
|
||||
try {
|
||||
await updateExpenseClaim(request.value.claimId, {
|
||||
reason: detailNoteEditor.value.trim()
|
||||
})
|
||||
toast('附加说明已保存。')
|
||||
emit('request-updated', { claimId: request.value.claimId })
|
||||
} catch (error) {
|
||||
toast(error?.message || '附加说明保存失败,请稍后重试。')
|
||||
} finally {
|
||||
savingDetailNote.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function startExpenseEdit(item) {
|
||||
if (!isEditableRequest.value || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
if (item?.isSystemGenerated) {
|
||||
toast('系统自动计算的补贴行不能手动编辑。')
|
||||
return
|
||||
}
|
||||
|
||||
editingExpenseId.value = item.id
|
||||
expenseEditor.itemDate = item.itemDate || ''
|
||||
@@ -954,6 +1094,11 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.isSystemGenerated) {
|
||||
toast('系统自动计算的补贴行无需上传附件。')
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
return
|
||||
@@ -1036,6 +1181,10 @@ export default {
|
||||
if (!item || !file) {
|
||||
return
|
||||
}
|
||||
if (item?.isSystemGenerated) {
|
||||
toast('系统自动计算的补贴行无需上传附件。')
|
||||
return
|
||||
}
|
||||
|
||||
if (item?.invoiceId) {
|
||||
toast('每条费用明细只能关联一张单据,如需更换请先删除当前单据。')
|
||||
@@ -1138,6 +1287,10 @@ export default {
|
||||
if (!request.value.claimId || !item?.id || actionBusy.value) {
|
||||
return
|
||||
}
|
||||
if (item?.isSystemGenerated) {
|
||||
toast('系统自动计算的补贴行不能删除。')
|
||||
return
|
||||
}
|
||||
|
||||
deletingExpenseId.value = item.id
|
||||
try {
|
||||
@@ -1468,18 +1621,21 @@ export default {
|
||||
confirmReturnRequest,
|
||||
currentAttachmentPreviewInsight,
|
||||
currentAttachmentPreviewRiskCards,
|
||||
currentProgressRingMotion,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
deleteDialogDescription,
|
||||
deleteDialogOpen,
|
||||
deleteDialogTitle,
|
||||
deletingAttachmentId,
|
||||
deletingExpenseId,
|
||||
detailNote,
|
||||
draftBlockingIssues,
|
||||
editingExpenseId,
|
||||
creatingExpense,
|
||||
currentProgressRingMotion,
|
||||
canEditDetailNote,
|
||||
deleteActionLabel,
|
||||
deleteBusy,
|
||||
deleteDialogDescription,
|
||||
deleteDialogOpen,
|
||||
deleteDialogTitle,
|
||||
deletingAttachmentId,
|
||||
deletingExpenseId,
|
||||
detailNote,
|
||||
detailNoteDirty,
|
||||
detailNoteEditor,
|
||||
draftBlockingIssues,
|
||||
editingExpenseId,
|
||||
creatingExpense,
|
||||
expenseEditor,
|
||||
expenseItems,
|
||||
expenseTableColumnCount,
|
||||
@@ -1502,18 +1658,21 @@ export default {
|
||||
goToPreviousAttachmentPreview,
|
||||
profile,
|
||||
progressSteps,
|
||||
request,
|
||||
leaderOpinion,
|
||||
removeExpenseAttachment,
|
||||
removeExpenseItem,
|
||||
resolveAttachmentDisplayName,
|
||||
resolveAttachmentPreviewTitle,
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
request,
|
||||
leaderOpinion,
|
||||
removeExpenseAttachment,
|
||||
removeExpenseItem,
|
||||
resetDetailNote,
|
||||
resolveAttachmentDisplayName,
|
||||
resolveAttachmentPreviewTitle,
|
||||
resolveAttachmentRecognition,
|
||||
resolveExpenseRiskState,
|
||||
resolveExpenseIssues,
|
||||
returnBusy,
|
||||
returnDialogOpen,
|
||||
savingExpenseId,
|
||||
returnDialogOpen,
|
||||
saveDetailNote,
|
||||
savingDetailNote,
|
||||
savingExpenseId,
|
||||
showLeaderApprovalPanel,
|
||||
showExpenseRisk,
|
||||
startExpenseEdit,
|
||||
|
||||
@@ -265,19 +265,44 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
|
||||
|
||||
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) {
|
||||
const items = [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
]
|
||||
|
||||
return {
|
||||
tone: 'ready',
|
||||
badge: '可直接提交',
|
||||
summary: 'AI判断当前草稿已具备提交条件,可以直接发起审批。',
|
||||
items: [
|
||||
'点击右下角“提交审批”进入流程。',
|
||||
'提交前再核对一次合计金额与各条费用明细金额是否一致。',
|
||||
'如有特殊业务背景或例外情况,可在下方附加说明中补充。'
|
||||
],
|
||||
riskCards: []
|
||||
items,
|
||||
riskCards: [],
|
||||
sections: [
|
||||
{
|
||||
kind: 'completion',
|
||||
title: '建议补充字段',
|
||||
items
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const sections = []
|
||||
if (normalizedCompletionItems.length) {
|
||||
sections.push({
|
||||
kind: 'completion',
|
||||
title: '建议补充字段',
|
||||
items: normalizedCompletionItems
|
||||
})
|
||||
}
|
||||
if (normalizedRiskCards.length) {
|
||||
sections.push({
|
||||
kind: 'risk',
|
||||
title: '已知存在风险',
|
||||
items: normalizedRiskCards
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
tone: hasHighRisk ? 'warning' : 'pending',
|
||||
badge: hasHighRisk ? '优先整改' : '待核对',
|
||||
@@ -285,6 +310,7 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
|
||||
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,请逐项核对规则依据和修改建议。`
|
||||
: '建议先补齐必填信息,完成后即可提交审批。',
|
||||
items: normalizedCompletionItems,
|
||||
riskCards: normalizedRiskCards
|
||||
riskCards: normalizedRiskCards,
|
||||
sections
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user