feat(报销/拨款): 新增发票明细子表与OCR自动识别

- 新增 hrm_invoice_item 共享子表(biz_type区分报销/拨款),每条记录对应一张发票条目
- 新增 HrmInvoiceOcrService,上传附件后自动调用 ai-ocr Python服务识别发票,结果逐条回填表单
- 报销/拨款申请提交及更新时同步保存发票明细;queryById 返回关联发票条目列表
- 前端:附件上传后自动触发OCR,展示"模型思考中"状态,识别完成后自动填充金额
- 详情页新增发票明细只读表格展示,兼容无明细的历史记录
- application.yml 增加 fad.ocr 配置项

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 17:34:08 +08:00
parent 28a37f4105
commit c412f73b80
23 changed files with 1043 additions and 278 deletions

View File

@@ -57,3 +57,14 @@ export function getAppropriationStats (query) {
params: query
})
}
/**
* 通过ossId触发发票OCR识别返回识别条目不保存
*/
export function ocrAppropriationInvoice (ossId) {
return request({
url: '/hrm/appropriation/ocr-by-oss',
method: 'post',
params: { ossId }
})
}

View File

@@ -47,3 +47,14 @@ export function allReimburseReq(query) {
})
}
/**
* 通过ossId触发发票OCR识别返回识别条目不保存
*/
export function ocrReimburseInvoice(ossId) {
return request({
url: '/hrm/reimburse/ocr-by-oss',
method: 'post',
params: { ossId }
})
}

View File

@@ -47,13 +47,63 @@
<el-col>
<el-form-item label="项目" prop="projectId">
<project-select v-model="form.projectId" placeholder="请选择项目" style="width: 100%" />
<!-- <el-input v-model="form.projectId" placeholder="请输入项目ID" style="width: 100%" /> -->
</el-form-item>
</el-col>
</el-row>
<el-form-item label="拨款事由" prop="reason">
<el-input v-model="form.reason" type="textarea" :rows="4" placeholder="请说明拨款事由、费用用途等" show-word-limit
<!-- 拨款单据附件OCR触发区 -->
<el-form-item label="拨款单据附件" prop="accessoryApplyIds">
<file-upload v-model="form.accessoryApplyIds" :limit="50" :file-size="50"
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple />
<div class="hint-text">上传发票收据付款截图等支持 PDF/图片上传后自动识别</div>
</el-form-item>
<!-- OCR 识别中提示 -->
<div v-if="anyOcrLoading" class="ocr-thinking">
<i class="el-icon-loading"></i>
<span>模型思考中正在识别发票内容</span>
</div>
<!-- 发票明细条目表 -->
<div class="block-title">
拨款明细
<span class="block-title-hint">上传发票后自动填充也可手动添加</span>
</div>
<div class="invoice-table">
<div class="invoice-table-header">
<span class="col-reason">事由说明</span>
<span class="col-amount">金额</span>
<span class="col-action"></span>
</div>
<div v-for="(item, idx) in invoiceItems" :key="idx" class="invoice-table-row">
<el-input
v-model="item.reason"
:placeholder="item.itemName || '请填写事由'"
size="small"
class="col-reason"
/>
<el-input-number
v-model="item.amount"
:min="0"
:precision="2"
size="small"
class="col-amount"
@change="recalcTotal"
/>
<div class="col-action">
<el-button size="mini" type="danger" plain icon="el-icon-delete" circle @click="removeInvoiceItem(idx)" />
</div>
</div>
<div class="invoice-table-footer">
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addInvoiceItem">添加条目</el-button>
<span class="total-hint" v-if="invoiceItems.length">
合计<b>¥{{ invoiceTotalFormatted }}</b>已自动更新拨款总金额
</span>
</div>
</div>
<el-form-item label="补充说明" prop="reason">
<el-input v-model="form.reason" type="textarea" :rows="2" placeholder="可选:填写整体说明或补充" show-word-limit
maxlength="1000" />
</el-form-item>
@@ -80,12 +130,6 @@
</el-col>
</el-row>
<el-form-item label="拨款单据附件" prop="accessoryApplyIds">
<file-upload v-model="form.accessoryApplyIds" :limit="50" :file-size="50"
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple />
<div class="hint-text">上传发票收据付款截图等必填</div>
</el-form-item>
<el-form-item label="回执附件(可选)" prop="accessoryReceiptIds">
<file-upload v-model="form.accessoryReceiptIds" :limit="10" :file-size="50"
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple />
@@ -106,14 +150,13 @@
</el-tag>
</div>
</el-form-item>
<!-- 抄送备注 -->
<el-form-item label="抄送备注" prop="ccRemark">
<el-input v-model="ccForm.remark" type="textarea" :rows="2" placeholder="可以填写抄送的目的或原因等信息" show-word-limit
maxlength="1000" />
</el-form-item>
<UserMultiSelect ref="userMultiSelect" :init="ccUserIds" @onSelected="onCcUsersSelected" />
<!-- 审批方式模板/自选审批人 -->
<!-- 审批方式 -->
<div class="block-title">审批方式</div>
<div class="approve-mode">
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
@@ -163,18 +206,11 @@
</template>
</div>
<!-- 模板模式 -->
<div v-if="approverMode === 'template'">
<div v-if="flowNodes && flowNodes.length" class="flow-steps">
<div class="flow-step">
<div class="dot"></div>
<div class="txt">填写申请</div>
</div>
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
<div class="line"></div>
<div class="flow-step">
<div class="dot"></div>
<div class="txt">提交</div>
</div>
<div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
<template v-for="(n, idx) in flowNodes">
<div class="line"></div>
<div class="flow-step">
@@ -188,22 +224,12 @@
</div>
</div>
<!-- 手动审批模式 -->
<div v-else class="flow-steps">
<div class="flow-step">
<div class="dot"></div>
<div class="txt">填写申请</div>
</div>
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
<div class="line"></div>
<div class="flow-step">
<div class="dot"></div>
<div class="txt">提交审批{{ assigneeUserName || '请选择' }}</div>
</div>
<div class="flow-step"><div class="dot"></div><div class="txt">提交审批{{ assigneeUserName || '请选择' }}</div></div>
<div class="line"></div>
<div class="flow-step">
<div class="dot success"></div>
<div class="txt">审批结束</div>
</div>
<div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
</div>
</div>
@@ -213,14 +239,13 @@
</div>
<UserSelect ref="userSelect" @onSelected="onUserSelected" />
</el-form>
</el-card>
</div>
</template>
<script>
import { addAppropriationReq, listFlowNode, listFlowTemplate } from '@/api/hrm';
import { addAppropriationReq, listFlowNode, listFlowTemplate, ocrAppropriationInvoice } from '@/api/hrm';
import { getEmployeeByUserId } from '@/api/hrm/employee';
import { ccFlowTask } from '@/api/hrm/flow';
import FileUpload from '@/components/FileUpload';
@@ -243,6 +268,12 @@ export default {
assigneeUserId: null,
assigneeUserName: '',
appropriationTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
// 发票明细条目
invoiceItems: [],
// OCR加载状态 { ossId: true/false }
ocrLoadingMap: {},
// 已触发过OCR的ossId集合
ocrDoneSet: new Set(),
form: {
empId: '',
appropriationType: '',
@@ -256,7 +287,6 @@ export default {
bankAccount: ''
},
ccForm: {
// 默认抄送胡雪娇
selectedUsers: [{
userId: '1859249502579310593',
nickName: '胡雪娇',
@@ -270,9 +300,7 @@ export default {
},
rules: {
appropriationType: [{ required: true, message: '请选择/输入拨款类型', trigger: 'change' }],
amount: [{ required: true, message: '请填写拨款总金额', trigger: 'blur' }],
reason: [{ required: true, message: '请填写拨款事由', trigger: 'blur' }],
// accessoryApplyIds: [{ required: true, message: '请上传拨款单据附件', trigger: 'change' }]
amount: [{ required: true, message: '请填写拨款总金额', trigger: 'blur' }]
}
}
},
@@ -295,11 +323,79 @@ export default {
ccUserIds () {
return this.ccForm.selectedUsers?.map(u => u.userId) || []
},
anyOcrLoading () {
return Object.values(this.ocrLoadingMap).some(v => v)
},
invoiceTotalFormatted () {
const total = this.invoiceItems.reduce((sum, item) => sum + (parseFloat(item.amount) || 0), 0)
return total.toFixed(2)
}
},
watch: {
'form.accessoryApplyIds' (newVal, oldVal) {
const newIds = newVal ? newVal.split(',').filter(Boolean) : []
const oldIds = oldVal ? oldVal.split(',').filter(Boolean) : []
const added = newIds.filter(id => !oldIds.includes(id))
added.forEach(id => this.triggerOcr(id))
}
},
methods: {
openUserMultiSelect () { this.$refs.userMultiSelect.open() },
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) },
async triggerOcr (ossId) {
if (this.ocrDoneSet.has(ossId)) return
this.$set(this.ocrLoadingMap, ossId, true)
try {
const res = await ocrAppropriationInvoice(ossId)
if (res.code === 200 && res.data) {
const { items, totalAmount } = res.data
if (items && items.length) {
const startIdx = this.invoiceItems.length
items.forEach((item, i) => {
this.invoiceItems.push({
ossId: Number(ossId),
itemName: item.itemName || '',
reason: item.itemName || '',
amount: item.amount || 0,
sortNo: startIdx + i
})
})
} else if (totalAmount) {
this.invoiceItems.push({
ossId: Number(ossId),
itemName: '',
reason: '',
amount: totalAmount,
sortNo: this.invoiceItems.length
})
}
this.recalcTotal()
}
this.ocrDoneSet.add(ossId)
} catch (e) {
console.error('[OCR] 识别失败', e)
this.$message.warning('发票识别失败,请手动填写事由和金额')
} finally {
this.$set(this.ocrLoadingMap, ossId, false)
}
},
addInvoiceItem () {
this.invoiceItems.push({ ossId: null, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
},
removeInvoiceItem (idx) {
this.invoiceItems.splice(idx, 1)
this.recalcTotal()
},
recalcTotal () {
const total = this.invoiceItems.reduce((sum, item) => sum + (parseFloat(item.amount) || 0), 0)
this.form.amount = parseFloat(total.toFixed(2))
},
async loadTemplates () {
try {
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'appropriation', enabled: 1 })
@@ -315,16 +411,8 @@ export default {
async refreshFlowPreview () {
this.flowLoading = true
try {
if (this.approverMode === 'manual') {
this.flowTpl = null
this.flowNodes = []
return
}
if (!this.tplId) {
this.flowTpl = null
this.flowNodes = []
return
}
if (this.approverMode === 'manual') { this.flowTpl = null; this.flowNodes = []; return }
if (!this.tplId) { this.flowTpl = null; this.flowNodes = []; return }
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId })
this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
@@ -356,10 +444,7 @@ export default {
},
async loadCurrentEmployee () {
const userId = this.$store?.state?.user?.id
if (!userId) {
this.$message.error('无法获取当前用户信息,请重新登录')
return
}
if (!userId) { this.$message.error('无法获取当前用户信息,请重新登录'); return }
try {
const res = await getEmployeeByUserId(userId)
if (res.code === 200 && res.data) {
@@ -372,18 +457,13 @@ export default {
this.$message.error('加载员工信息失败')
}
},
async onTplChange (val) {
this.tplId = val
await this.refreshFlowPreview()
},
async onTplChange (val) { this.tplId = val; await this.refreshFlowPreview() },
onApproverModeChange (val) {
this.approverMode = val
if (val === 'manual') this.tplId = null
this.refreshFlowPreview()
},
openUserSelect () {
this.$refs.userSelect.open()
},
openUserSelect () { this.$refs.userSelect.open() },
onUserSelected (row) {
if (row) {
this.assigneeUserId = row.userId
@@ -392,7 +472,6 @@ export default {
}
},
async submit () {
console.log('提交申请')
try {
await this.$refs.formRef.validate()
if (this.approverMode === 'template' && !this.tplId) {
@@ -413,37 +492,40 @@ export default {
remark: this.form.remark,
status: 'pending',
projectId: this.form.projectId,
// tplId: this.tplId,
manualAssigneeUserId: this.assigneeUserId,
payeeName: this.form.payeeName,
bankName: this.form.bankName,
bankAccount: this.form.bankAccount,
invoiceItems: this.invoiceItems.map((item, i) => ({
ossId: item.ossId || null,
sortNo: i,
itemName: item.itemName || '',
reason: item.reason || item.itemName || '',
amount: item.amount || 0
}))
}
if (this.approverMode === 'template') {
payload.tplId = this.tplId
}
const { data: instance } = await addAppropriationReq(payload)
console.log(instance, this.ccForm)
if (this.ccForm.selectedUsers.length && instance?.instId) {
const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId)
const fromUserId = this.$store?.state?.user?.id
const payload = {
await ccFlowTask({
instId: instance.instId,
bizId: instance.bizId,
bizType: 'appropriation',
ccUserIds: ccUserIds,
ccUserIds,
remark: this.ccForm.remark,
fromUserId,
nodeId: 0,
readFlag: 0,
nodeName: '节点#0'
}
await ccFlowTask(payload)
})
}
this.$message.success('提交成功')
this.$router.push('/hrm/apply')
} catch (err) {
// no-op
console.log(err)
} finally {
this.submitting = false
@@ -475,14 +557,8 @@ export default {
color: #2b2f36;
}
.actions {
display: flex;
gap: 8px;
}
.metal-form {
padding-right: 8px;
}
.actions { display: flex; gap: 8px; }
.metal-form { padding-right: 8px; }
.hint-text {
margin-top: 6px;
@@ -503,32 +579,84 @@ export default {
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
}
.summary-title {
font-size: 16px;
font-weight: 800;
color: #2b2f36;
}
.summary-title { font-size: 16px; font-weight: 800; color: #2b2f36; }
.summary-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
.summary-right { display: flex; gap: 16px; }
.summary-item .k { font-size: 12px; color: #8a8f99; }
.summary-item .v { margin-top: 2px; font-weight: 700; color: #2b2f36; }
.summary-sub {
margin-top: 4px;
font-size: 12px;
color: #8a8f99;
}
.summary-right {
/* OCR 思考中提示 */
.ocr-thinking {
display: flex;
gap: 16px;
align-items: center;
gap: 8px;
padding: 8px 14px;
margin: 8px 0 12px;
border-radius: 8px;
background: #f0f7ff;
border: 1px solid #b3d8ff;
color: #409eff;
font-size: 13px;
font-weight: 600;
i { font-size: 16px; animation: spin 1s linear infinite; }
}
.summary-item .k {
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 发票明细表 */
.invoice-table {
margin-bottom: 16px;
border: 1px solid #e6e8ed;
border-radius: 8px;
overflow: hidden;
}
.invoice-table-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: #f5f7fa;
border-bottom: 1px solid #e6e8ed;
font-size: 12px;
color: #8a8f99;
color: #606266;
font-weight: 600;
}
.summary-item .v {
margin-top: 2px;
font-weight: 700;
color: #2b2f36;
.invoice-table-row {
display: flex;
align-items: center;
padding: 8px 12px;
gap: 10px;
border-bottom: 1px solid #f2f3f5;
&:last-child { border-bottom: none; }
}
.col-reason { flex: 1; }
.col-amount { width: 140px; flex-shrink: 0; }
.col-action { width: 32px; flex-shrink: 0; display: flex; justify-content: center; }
.invoice-table-header .col-reason { flex: 1; }
.invoice-table-header .col-amount { width: 140px; }
.invoice-table-header .col-action { width: 32px; }
.invoice-table-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 12px;
background: #fafafa;
border-top: 1px solid #f0f1f3;
}
.total-hint {
font-size: 13px;
color: #606266;
b { color: #e6a23c; font-size: 14px; }
}
.flow-preview {
@@ -539,16 +667,8 @@ export default {
background: #fcfdff;
}
.flow-title {
font-weight: 800;
color: #2b2f36;
}
.flow-sub {
margin-top: 4px;
font-size: 12px;
color: #8a8f99;
}
.flow-title { font-weight: 800; color: #2b2f36; }
.flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
.flow-steps {
margin-top: 10px;
@@ -568,28 +688,10 @@ export default {
background: #fff;
}
.flow-step .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9aa3b2;
}
.flow-step .dot.success {
background: #67c23a;
}
.flow-step .txt {
font-size: 12px;
color: #2b2f36;
font-weight: 600;
}
.flow-steps .line {
width: 26px;
height: 1px;
background: #e6e8ed;
}
.flow-step .dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa3b2; }
.flow-step .dot.success { background: #67c23a; }
.flow-step .txt { font-size: 12px; color: #2b2f36; font-weight: 600; }
.flow-steps .line { width: 26px; height: 1px; background: #e6e8ed; }
.form-actions {
display: flex;
@@ -599,9 +701,7 @@ export default {
}
@media (max-width: 1200px) {
.summary-right {
display: none;
}
.summary-right { display: none; }
}
.block-title {
@@ -612,6 +712,13 @@ export default {
border-left: 3px solid #9aa3b2;
}
.block-title-hint {
font-size: 12px;
font-weight: 400;
color: #8a8f99;
margin-left: 6px;
}
.approve-mode {
padding: 12px;
border: 1px solid #e6e8ed;
@@ -619,18 +726,9 @@ export default {
background: #fcfdff;
}
.approve-panel {
margin-top: 12px;
}
.approve-panel { margin-top: 12px; }
.approve-row { display: flex; align-items: center; gap: 12px; }
.approve-row .k { font-size: 14px; color: #606266; }
.approve-row {
display: flex;
align-items: center;
gap: 12px;
}
.approve-row .k {
font-size: 14px;
color: #606266;
}
.selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
</style>

View File

@@ -31,6 +31,21 @@
<file-preview v-model="detail.accessoryApplyIds"></file-preview>
</el-card>
<!-- 发票明细 -->
<template v-if="detail.invoiceItems && detail.invoiceItems.length">
<div class="block-title">发票明细</div>
<el-card class="inner-card" shadow="never">
<el-table :data="detail.invoiceItems" border size="small" style="width:100%">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="itemName" label="项目名称" min-width="140" />
<el-table-column prop="reason" label="事由" min-width="180" />
<el-table-column prop="amount" label="金额(元)" width="120" align="right">
<template slot-scope="{ row }">¥{{ row.amount }}</template>
</el-table-column>
</el-table>
</el-card>
</template>
<!-- 拨款理由说明 -->
<div class="block-title">拨款理由说明</div>
<el-card class="inner-card" shadow="never">

View File

@@ -47,20 +47,64 @@
<el-col>
<el-form-item label="项目" prop="projectId">
<project-select v-model="form.projectId" placeholder="请选择项目" style="width: 100%" />
<!-- <el-input v-model="form.projectId" placeholder="请输入项目ID" style="width: 100%" /> -->
</el-form-item>
</el-col>
</el-row>
<el-form-item label="报销事由" prop="reason">
<el-input v-model="form.reason" type="textarea" :rows="4" placeholder="请说明报销事由、费用用途等" show-word-limit
maxlength="3000" />
</el-form-item>
<!-- 报销单据附件OCR触发区 -->
<el-form-item label="报销单据附件" prop="accessoryApplyIds">
<file-upload v-model="form.accessoryApplyIds" :limit="200" :file-size="50"
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple />
<div class="hint-text">上传发票收据付款截图等必填</div>
<div class="hint-text">上传发票收据付款截图等支持 PDF/图片上传后自动识别</div>
</el-form-item>
<!-- OCR 识别中提示 -->
<div v-if="anyOcrLoading" class="ocr-thinking">
<i class="el-icon-loading"></i>
<span>模型思考中正在识别发票内容</span>
</div>
<!-- 发票明细条目表 -->
<div class="block-title">
发票明细
<span class="block-title-hint">上传发票后自动填充也可手动添加</span>
</div>
<div class="invoice-table">
<div class="invoice-table-header">
<span class="col-reason">事由说明</span>
<span class="col-amount">金额</span>
<span class="col-action"></span>
</div>
<div v-for="(item, idx) in invoiceItems" :key="idx" class="invoice-table-row">
<el-input
v-model="item.reason"
:placeholder="item.itemName || '请填写事由'"
size="small"
class="col-reason"
/>
<el-input-number
v-model="item.amount"
:min="0"
:precision="2"
size="small"
class="col-amount"
@change="recalcTotal"
/>
<div class="col-action">
<el-button size="mini" type="danger" plain icon="el-icon-delete" circle @click="removeInvoiceItem(idx)" />
</div>
</div>
<div class="invoice-table-footer">
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addInvoiceItem">添加条目</el-button>
<span class="total-hint" v-if="invoiceItems.length">
合计<b>¥{{ invoiceTotalFormatted }}</b>已自动更新报销总金额
</span>
</div>
</div>
<el-form-item label="补充说明" prop="reason">
<el-input v-model="form.reason" type="textarea" :rows="2" placeholder="可选:填写整体说明或补充" show-word-limit
maxlength="3000" />
</el-form-item>
<el-form-item label="回执附件(可选)" prop="accessoryReceiptIds">
@@ -83,14 +127,13 @@
</el-tag>
</div>
</el-form-item>
<!-- 抄送备注 -->
<el-form-item label="抄送备注" prop="ccRemark">
<el-input v-model="ccForm.remark" type="textarea" :rows="2" placeholder="可以填写抄送的目的或原因等信息" show-word-limit
maxlength="1000" />
</el-form-item>
<UserMultiSelect ref="userMultiSelect" :init="ccUserIds" @onSelected="onCcUsersSelected" />
<!-- 审批方式模板/自选审批人 -->
<!-- 审批方式 -->
<div class="block-title">审批方式</div>
<div class="approve-mode">
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
@@ -140,18 +183,11 @@
</template>
</div>
<!-- 模板模式 -->
<div v-if="approverMode === 'template'">
<div v-if="flowNodes && flowNodes.length" class="flow-steps">
<div class="flow-step">
<div class="dot"></div>
<div class="txt">填写申请</div>
</div>
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
<div class="line"></div>
<div class="flow-step">
<div class="dot"></div>
<div class="txt">提交</div>
</div>
<div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
<template v-for="(n, idx) in flowNodes">
<div class="line"></div>
<div class="flow-step">
@@ -165,22 +201,12 @@
</div>
</div>
<!-- 手动审批模式 -->
<div v-else class="flow-steps">
<div class="flow-step">
<div class="dot"></div>
<div class="txt">填写申请</div>
</div>
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
<div class="line"></div>
<div class="flow-step">
<div class="dot"></div>
<div class="txt">提交审批{{ assigneeUserName || '请选择' }}</div>
</div>
<div class="flow-step"><div class="dot"></div><div class="txt">提交审批{{ assigneeUserName || '请选择' }}</div></div>
<div class="line"></div>
<div class="flow-step">
<div class="dot success"></div>
<div class="txt">审批结束</div>
</div>
<div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
</div>
</div>
@@ -190,14 +216,13 @@
</div>
<UserSelect ref="userSelect" @onSelected="onUserSelected" />
</el-form>
</el-card>
</div>
</template>
<script>
import { addReimburseReq, listFlowNode, listFlowTemplate } from '@/api/hrm';
import { addReimburseReq, listFlowNode, listFlowTemplate, ocrReimburseInvoice } from '@/api/hrm';
import { getEmployeeByUserId } from '@/api/hrm/employee';
import { ccFlowTask } from '@/api/hrm/flow';
import FileUpload from '@/components/FileUpload';
@@ -220,6 +245,12 @@ export default {
assigneeUserId: null,
assigneeUserName: '',
reimburseTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
// 发票明细条目
invoiceItems: [],
// OCR加载状态 { ossId: true/false }
ocrLoadingMap: {},
// 已触发过OCR的ossId集合
ocrDoneSet: new Set(),
form: {
empId: '',
reimburseType: '',
@@ -230,7 +261,6 @@ export default {
remark: ''
},
ccForm: {
// 默认抄送胡雪娇
selectedUsers: [{
userId: '1859249502579310593',
nickName: '胡雪娇',
@@ -245,7 +275,6 @@ export default {
rules: {
reimburseType: [{ required: true, message: '请选择/输入报销类型', trigger: 'change' }],
totalAmount: [{ required: true, message: '请填写报销总金额', trigger: 'blur' }],
reason: [{ required: true, message: '请填写报销事由', trigger: 'blur' }],
accessoryApplyIds: [{ required: true, message: '请上传报销单据附件', trigger: 'change' }]
}
}
@@ -269,11 +298,80 @@ export default {
ccUserIds () {
return this.ccForm.selectedUsers?.map(u => u.userId) || []
},
anyOcrLoading () {
return Object.values(this.ocrLoadingMap).some(v => v)
},
invoiceTotalFormatted () {
const total = this.invoiceItems.reduce((sum, item) => sum + (parseFloat(item.amount) || 0), 0)
return total.toFixed(2)
}
},
watch: {
'form.accessoryApplyIds' (newVal, oldVal) {
const newIds = newVal ? newVal.split(',').filter(Boolean) : []
const oldIds = oldVal ? oldVal.split(',').filter(Boolean) : []
const added = newIds.filter(id => !oldIds.includes(id))
added.forEach(id => this.triggerOcr(id))
}
},
methods: {
openUserMultiSelect () { this.$refs.userMultiSelect.open() },
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) },
async triggerOcr (ossId) {
if (this.ocrDoneSet.has(ossId)) return
this.$set(this.ocrLoadingMap, ossId, true)
try {
const res = await ocrReimburseInvoice(ossId)
if (res.code === 200 && res.data) {
const { items, totalAmount } = res.data
if (items && items.length) {
const startIdx = this.invoiceItems.length
items.forEach((item, i) => {
this.invoiceItems.push({
ossId: Number(ossId),
itemName: item.itemName || '',
reason: item.itemName || '',
amount: item.amount || 0,
sortNo: startIdx + i
})
})
} else if (totalAmount) {
// 没有明细时用总金额创建一条
this.invoiceItems.push({
ossId: Number(ossId),
itemName: '',
reason: '',
amount: totalAmount,
sortNo: this.invoiceItems.length
})
}
this.recalcTotal()
}
this.ocrDoneSet.add(ossId)
} catch (e) {
console.error('[OCR] 识别失败', e)
this.$message.warning('发票识别失败,请手动填写事由和金额')
} finally {
this.$set(this.ocrLoadingMap, ossId, false)
}
},
addInvoiceItem () {
this.invoiceItems.push({ ossId: null, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
},
removeInvoiceItem (idx) {
this.invoiceItems.splice(idx, 1)
this.recalcTotal()
},
recalcTotal () {
const total = this.invoiceItems.reduce((sum, item) => sum + (parseFloat(item.amount) || 0), 0)
this.form.totalAmount = parseFloat(total.toFixed(2))
},
async loadTemplates () {
try {
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'reimburse', enabled: 1 })
@@ -289,16 +387,8 @@ export default {
async refreshFlowPreview () {
this.flowLoading = true
try {
if (this.approverMode === 'manual') {
this.flowTpl = null
this.flowNodes = []
return
}
if (!this.tplId) {
this.flowTpl = null
this.flowNodes = []
return
}
if (this.approverMode === 'manual') { this.flowTpl = null; this.flowNodes = []; return }
if (!this.tplId) { this.flowTpl = null; this.flowNodes = []; return }
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId })
this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
@@ -330,10 +420,7 @@ export default {
},
async loadCurrentEmployee () {
const userId = this.$store?.state?.user?.id
if (!userId) {
this.$message.error('无法获取当前用户信息,请重新登录')
return
}
if (!userId) { this.$message.error('无法获取当前用户信息,请重新登录'); return }
try {
const res = await getEmployeeByUserId(userId)
if (res.code === 200 && res.data) {
@@ -346,18 +433,13 @@ export default {
this.$message.error('加载员工信息失败')
}
},
async onTplChange (val) {
this.tplId = val
await this.refreshFlowPreview()
},
async onTplChange (val) { this.tplId = val; await this.refreshFlowPreview() },
onApproverModeChange (val) {
this.approverMode = val
if (val === 'manual') this.tplId = null
this.refreshFlowPreview()
},
openUserSelect () {
this.$refs.userSelect.open()
},
openUserSelect () { this.$refs.userSelect.open() },
onUserSelected (row) {
if (row) {
this.assigneeUserId = row.userId
@@ -366,7 +448,6 @@ export default {
}
},
async submit () {
console.log('提交申请')
try {
await this.$refs.formRef.validate()
if (this.approverMode === 'template' && !this.tplId) {
@@ -387,29 +468,33 @@ export default {
remark: this.form.remark,
status: 'pending',
projectId: this.form.projectId,
// tplId: this.tplId,
manualAssigneeUserId: this.assigneeUserId
manualAssigneeUserId: this.assigneeUserId,
invoiceItems: this.invoiceItems.map((item, i) => ({
ossId: item.ossId || null,
sortNo: i,
itemName: item.itemName || '',
reason: item.reason || item.itemName || '',
amount: item.amount || 0
}))
}
if (this.approverMode === 'template') {
payload.tplId = this.tplId
}
const { data: instance } = await addReimburseReq(payload)
console.log(instance, this.ccForm)
if (this.ccForm.selectedUsers.length && instance?.instId) {
const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId)
const fromUserId = this.$store?.state?.user?.id
const payload = {
await ccFlowTask({
instId: instance.instId,
bizId: instance.bizId,
bizType: 'reimburse',
ccUserIds: ccUserIds,
ccUserIds,
remark: this.ccForm.remark,
fromUserId,
nodeId: 0,
readFlag: 0,
nodeName: '节点#0'
}
await ccFlowTask(payload)
})
}
this.$message.success('提交成功')
this.$router.push('/hrm/apply')
@@ -445,14 +530,9 @@ export default {
color: #2b2f36;
}
.actions {
display: flex;
gap: 8px;
}
.actions { display: flex; gap: 8px; }
.metal-form {
padding-right: 8px;
}
.metal-form { padding-right: 8px; }
.hint-text {
margin-top: 6px;
@@ -473,32 +553,97 @@ export default {
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
}
.summary-title {
font-size: 16px;
font-weight: 800;
color: #2b2f36;
}
.summary-title { font-size: 16px; font-weight: 800; color: #2b2f36; }
.summary-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
.summary-right { display: flex; gap: 16px; }
.summary-item .k { font-size: 12px; color: #8a8f99; }
.summary-item .v { margin-top: 2px; font-weight: 700; color: #2b2f36; }
.summary-sub {
margin-top: 4px;
font-size: 12px;
color: #8a8f99;
}
.summary-right {
/* OCR 思考中提示 */
.ocr-thinking {
display: flex;
gap: 16px;
align-items: center;
gap: 8px;
padding: 8px 14px;
margin: 8px 0 12px;
border-radius: 8px;
background: #f0f7ff;
border: 1px solid #b3d8ff;
color: #409eff;
font-size: 13px;
font-weight: 600;
i { font-size: 16px; animation: spin 1s linear infinite; }
}
.summary-item .k {
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 发票明细表 */
.invoice-table {
margin-bottom: 16px;
border: 1px solid #e6e8ed;
border-radius: 8px;
overflow: hidden;
}
.invoice-table-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: #f5f7fa;
border-bottom: 1px solid #e6e8ed;
font-size: 12px;
color: #8a8f99;
color: #606266;
font-weight: 600;
}
.summary-item .v {
margin-top: 2px;
font-weight: 700;
color: #2b2f36;
.invoice-table-row {
display: flex;
align-items: center;
padding: 8px 12px;
gap: 10px;
border-bottom: 1px solid #f2f3f5;
&:last-child { border-bottom: none; }
}
.col-reason {
flex: 1;
}
.col-amount {
width: 140px;
flex-shrink: 0;
}
.col-action {
width: 32px;
flex-shrink: 0;
display: flex;
justify-content: center;
}
.invoice-table-header .col-reason { flex: 1; }
.invoice-table-header .col-amount { width: 140px; }
.invoice-table-header .col-action { width: 32px; }
.invoice-table-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 12px;
background: #fafafa;
border-top: 1px solid #f0f1f3;
}
.total-hint {
font-size: 13px;
color: #606266;
b { color: #e6a23c; font-size: 14px; }
}
.flow-preview {
@@ -509,16 +654,8 @@ export default {
background: #fcfdff;
}
.flow-title {
font-weight: 800;
color: #2b2f36;
}
.flow-sub {
margin-top: 4px;
font-size: 12px;
color: #8a8f99;
}
.flow-title { font-weight: 800; color: #2b2f36; }
.flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
.flow-steps {
margin-top: 10px;
@@ -545,21 +682,9 @@ export default {
background: #9aa3b2;
}
.flow-step .dot.success {
background: #67c23a;
}
.flow-step .txt {
font-size: 12px;
color: #2b2f36;
font-weight: 600;
}
.flow-steps .line {
width: 26px;
height: 1px;
background: #e6e8ed;
}
.flow-step .dot.success { background: #67c23a; }
.flow-step .txt { font-size: 12px; color: #2b2f36; font-weight: 600; }
.flow-steps .line { width: 26px; height: 1px; background: #e6e8ed; }
.form-actions {
display: flex;
@@ -569,9 +694,7 @@ export default {
}
@media (max-width: 1200px) {
.summary-right {
display: none;
}
.summary-right { display: none; }
}
.block-title {
@@ -582,6 +705,13 @@ export default {
border-left: 3px solid #9aa3b2;
}
.block-title-hint {
font-size: 12px;
font-weight: 400;
color: #8a8f99;
margin-left: 6px;
}
.approve-mode {
padding: 12px;
border: 1px solid #e6e8ed;
@@ -589,18 +719,9 @@ export default {
background: #fcfdff;
}
.approve-panel {
margin-top: 12px;
}
.approve-panel { margin-top: 12px; }
.approve-row { display: flex; align-items: center; gap: 12px; }
.approve-row .k { font-size: 14px; color: #606266; }
.approve-row {
display: flex;
align-items: center;
gap: 12px;
}
.approve-row .k {
font-size: 14px;
color: #606266;
}
.selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
</style>

View File

@@ -22,6 +22,21 @@
<file-preview v-model="detail.accessoryApplyIds"></file-preview>
</el-card>
<!-- 发票明细 -->
<template v-if="detail.invoiceItems && detail.invoiceItems.length">
<div class="block-title">发票明细</div>
<el-card class="inner-card" shadow="never">
<el-table :data="detail.invoiceItems" border size="small" style="width:100%">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="itemName" label="项目名称" min-width="140" />
<el-table-column prop="reason" label="事由" min-width="180" />
<el-table-column prop="amount" label="金额(元)" width="120" align="right">
<template slot-scope="{ row }">¥{{ row.amount }}</template>
</el-table-column>
</el-table>
</el-card>
</template>
<!-- 报销理由说明 -->
<div class="block-title">报销理由说明</div>
<el-card class="inner-card" shadow="never">