feat: 细化差旅票据费用明细分类并自动计算出差补贴

将差旅费用明细拆分为火车票、机票、住宿票、乘车等细分类
型,根据票据字段自动生成行程/事由描述,结合规则引擎自
动计算出差补贴金额,前端适配费用明细编辑和差旅票据审
核交互,补充单元测试覆盖。
This commit is contained in:
caoxiaozhu
2026-05-21 10:57:06 +08:00
parent 8f65661809
commit b183b0bd5e
26 changed files with 2588 additions and 362 deletions

View File

@@ -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">