增加发票识别差额功能

This commit is contained in:
2026-08-13 18:00:46 +08:00
parent f818ecb74f
commit a344aaedf3
7 changed files with 921 additions and 19 deletions
+110 -2
View File
@@ -23,6 +23,7 @@
"os": "^0.1.2",
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
"tesseract.js": "^7.0.0",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
"vue-img-cutter": "^3.0.7",
@@ -3285,6 +3286,12 @@
"url": "https://github.com/sponsors/antfu"
}
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmmirror.com/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
@@ -4990,6 +4997,12 @@
"node": ">=0.10.0"
}
},
"node_modules/idb-keyval": {
"version": "6.3.0",
"resolved": "https://registry.npmmirror.com/idb-keyval/-/idb-keyval-6.3.0.tgz",
"integrity": "sha512-um+2dgAWmYsu615EXpWVwSmapJhON0G43t3Ka/EVaohzPQXSMqKEqeDK/oIW3Ow+BXaF2PvSc+oBTFp793A5Ow==",
"license": "Apache-2.0"
},
"node_modules/image-size": {
"version": "0.5.5",
"resolved": "https://registry.npmmirror.com/image-size/-/image-size-0.5.5.tgz",
@@ -5363,6 +5376,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmmirror.com/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz",
@@ -5951,6 +5970,26 @@
"node": ">= 4.4.x"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/normalize-wheel-es": {
"version": "1.2.0",
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
@@ -6004,6 +6043,15 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz",
@@ -6581,8 +6629,7 @@
"version": "0.13.11",
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/regexp.prototype.flags": {
"version": "1.5.4",
@@ -7157,6 +7204,30 @@
"node": ">=12.0.0"
}
},
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
@@ -7204,6 +7275,12 @@
"node": ">=8.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmmirror.com/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/traverse": {
"version": "0.6.11",
"resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.6.11.tgz",
@@ -7734,6 +7811,18 @@
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wasm-feature-detect": {
"version": "1.9.0",
"resolved": "https://registry.npmmirror.com/wasm-feature-detect/-/wasm-feature-detect-1.9.0.tgz",
"integrity": "sha512-zonE+xlIIYtxPy++L24ow0hAD8CICb4+FgPyROd3buyXIqsJvUEDkBgfCCoXOd1Hu3DUr0GOfnPIdcGV+YpNaA==",
"license": "Apache-2.0"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
@@ -7741,6 +7830,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmmirror.com/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which-boxed-primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
@@ -7932,6 +8031,15 @@
"url": "https://github.com/sponsors/dmonad"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmmirror.com/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
+1
View File
@@ -24,6 +24,7 @@
"os": "^0.1.2",
"pdfjs-dist": "^6.2.108",
"pinia": "^3.0.3",
"tesseract.js": "^7.0.0",
"v-viewer": "^3.0.11",
"vue": "^3.5.22",
"vue-img-cutter": "^3.0.7",
@@ -5,17 +5,81 @@
<el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="申请日期">{{ detail.apply_date || '-' }}</el-descriptions-item>
<el-descriptions-item label="合计金额">¥ {{ money(detail.total_amount) }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ detailApplicantName }}</el-descriptions-item>
<el-descriptions-item label="报销金额">¥ {{ money(detail.total_amount) }}</el-descriptions-item>
<el-descriptions-item label="发票金额">{{ formatDetailReceiptTotal }}</el-descriptions-item>
<el-descriptions-item label="发票差额">
<span :class="detailReceiptDiffClass">{{ formatDetailReceiptDiff }}</span>
</el-descriptions-item>
<el-descriptions-item label="备注说明" :span="3">{{ detail.description || '-' }}</el-descriptions-item>
</el-descriptions>
<div class="section-header">
<h4 class="detail-title">费用明细</h4>
<div class="section-actions" style="margin-bottom: 12px;">
<el-button size="small" :loading="amountComparing" @click="handleCompareAmounts">
核对发票金额
</el-button>
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
</div>
</div>
<div v-if="amountCompare" class="amount-compare-card">
<div class="amount-compare-summary">
<div class="compare-item">
<span>报销合计</span>
<strong>¥ {{ money(compareSummary.claimTotal) }}</strong>
</div>
<div class="compare-item">
<span>发票金额合计</span>
<strong>¥ {{ money(compareSummary.invoiceTotal) }}</strong>
</div>
<div class="compare-item">
<span>差额</span>
<strong :class="diffClass">{{ diffText }}</strong>
</div>
</div>
<p class="compare-tip">
已填写 {{ compareSummary.recognizedCount }} / {{ compareSummary.totalReceiptCount }} 张发票金额
<template v-if="compareSummary.unmatchedCount > 0">
{{ compareSummary.unmatchedCount }} 张待填写
</template>
识别结果可修改确认后请保存
</p>
<el-table :data="amountCompare.details" border size="small" max-height="280">
<el-table-column prop="expense_type" label="费用类型" min-width="100" />
<el-table-column prop="name" label="发票文件" min-width="160" show-overflow-tooltip />
<el-table-column label="发票金额" width="150">
<template #default="{ row }">
<el-input-number
v-model="row.amount"
:min="0"
:precision="2"
:step="0.01"
controls-position="right"
size="small"
placeholder="请填写"
style="width: 130px"
/>
</template>
</el-table-column>
<el-table-column label="识别来源" width="100">
<template #default="{ row }">{{ row.source || row.error || '-' }}</template>
</el-table-column>
<el-table-column label="操作" width="80" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openReceiptFromCompare(row)">查看</el-button>
</template>
</el-table-column>
</el-table>
<div class="compare-actions">
<el-button v-if="isDraft" type="primary" size="small" :loading="amountSaving" @click="handleSaveReceiptAmounts">
保存发票金额
</el-button>
<el-button size="small" @click="closeAmountCompare">取消</el-button>
</div>
</div>
<el-table :data="detail?.items || []" border size="small">
<el-table-column prop="expense_type" label="费用类型" min-width="120" />
<el-table-column prop="expense_date" label="发生日期" width="120" />
@@ -27,6 +91,16 @@
{{ itemHasReceipt(row) ? '有' : '无' }}{{ getItemReceiptFiles(row).length }}
</template>
</el-table-column>
<el-table-column label="发票金额" width="110">
<template #default="{ row }">
{{ formatItemReceiptTotal(row) }}
</template>
</el-table-column>
<el-table-column label="发票差额" width="110">
<template #default="{ row }">
<span :class="itemReceiptDiffClass(row)">{{ formatItemReceiptDiff(row) }}</span>
</template>
</el-table-column>
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
<el-table-column label="操作" :width="isDraft ? 176 : 70" fixed="right">
<template #default="{ row }">
@@ -206,10 +280,17 @@
</template>
<script setup>
import { computed, ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { uploadFile } from '@/api/file'
import { deleteReimbursementItem, submitReimbursement, updateReimbursementItem, updateReimbursementStatus } from '@/api/reimburse'
import { useAuthStore } from '@/stores/auth'
import {
compareReimbursementAmounts,
formatItemReceiptAmountsPayload,
recognizeReceiptFileAmounts,
summarizeReceiptAmountRows
} from '../utils/invoiceAmount'
import ItemForm from './itemForm.vue'
const props = defineProps({
@@ -221,6 +302,8 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'changed'])
const authStore = useAuthStore()
const statusOptions = [
{ value: 0, label: '草稿' },
{ value: 1, label: '审批中' },
@@ -237,6 +320,19 @@ const visible = computed({
set: value => emit('update:modelValue', value)
})
const isDraft = computed(() => props.detail?.status === 0)
const detailApplicantName = computed(() => {
const name = props.detail?.user_name
if (name) return name
if (
props.detail?.user_id &&
authStore.user?.id &&
Number(props.detail.user_id) === Number(authStore.user.id)
) {
return authStore.user.name || authStore.user.account || '-'
}
return '-'
})
const submitting = ref(false)
const manualConfirmVisible = ref(false)
const manualSaving = ref(false)
@@ -252,12 +348,146 @@ const receiptPreviewUrl = ref('')
const receiptPreviewType = ref('')
const receiptPreviewItemId = ref('')
const receiptTypeFilter = ref('')
const amountComparing = ref(false)
const amountSaving = ref(false)
const amountCompare = ref(null)
const pendingAmountCompare = ref(false)
const compareSummary = computed(() => {
if (!amountCompare.value?.details) {
return {
claimTotal: 0,
invoiceTotal: 0,
diff: 0,
recognizedCount: 0,
totalReceiptCount: 0,
unmatchedCount: 0
}
}
const claimTotal = (props.detail?.items || []).reduce(
(sum, item) => sum + Number(item.amount || 0),
0
)
return summarizeReceiptAmountRows(amountCompare.value.details, claimTotal)
})
watch(
() => props.detail,
async () => {
if (pendingAmountCompare.value && props.detail?.items?.length) {
pendingAmountCompare.value = false
try {
amountCompare.value = await compareReimbursementAmounts(props.detail, { recognizeMissing: false })
} catch {
amountCompare.value = null
}
return
}
amountCompare.value = null
},
{ deep: true }
)
const diffText = computed(() => {
const diff = Number(compareSummary.value.diff || 0)
if (Math.abs(diff) < 0.005) return '一致'
if (diff > 0) return `还差 ¥ ${money(diff)} 的票`
return `发票超出 ¥ ${money(Math.abs(diff))}`
})
const diffClass = computed(() => {
const diff = Number(compareSummary.value.diff || 0)
if (Math.abs(diff) < 0.005) return 'is-match'
return diff > 0 ? 'is-short' : 'is-over'
})
const handleCompareAmounts = async () => {
if (!props.detail?.items?.length) {
ElMessage.warning('暂无费用明细,无法核对')
return
}
amountComparing.value = true
try {
amountCompare.value = await compareReimbursementAmounts(props.detail)
if (!amountCompare.value.totalReceiptCount) {
ElMessage.warning('当前报销单暂无发票附件')
} else {
ElMessage.success('发票金额核对完成')
}
} catch (error) {
ElMessage.error(error?.message || '发票金额核对失败')
} finally {
amountComparing.value = false
}
}
const openReceiptFromCompare = row => {
receiptPreviewUrl.value = row.url
receiptPreviewType.value = row.isImage ? 'image/*' : 'application/pdf'
receiptPreviewItemId.value = `${row.itemId}-${row.fileId}`
receiptTypeFilter.value = row.expense_type || ''
receiptPreviewVisible.value = true
}
const buildReceiptAmountsPayload = (files, rows) => {
const amountMap = new Map(rows.map(row => [Number(row.fileId), row.amount]))
return files
.map(file => {
const amount = amountMap.get(Number(file.file_id))
if (amount == null || amount === '') return ''
const value = Number(amount)
return value > 0 ? value.toFixed(2) : ''
})
.join(',')
}
const handleSaveReceiptAmounts = async () => {
if (!amountCompare.value?.details?.length || !props.detail?.id) return
amountSaving.value = true
try {
const grouped = new Map()
amountCompare.value.details.forEach(row => {
if (!grouped.has(row.itemId)) grouped.set(row.itemId, [])
grouped.get(row.itemId).push(row)
})
for (const [itemId, rows] of grouped.entries()) {
const item = props.detail.items.find(entry => entry.id === itemId)
if (!item) continue
const files = getItemReceiptFiles(item)
await updateReimbursementItem(props.detail.id, itemId, {
expense_type: item.expense_type,
expense_date: item.expense_date,
amount: item.amount,
quantity: item.quantity || 1,
unit_price: item.unit_price || 0,
description: item.description || '',
receipt_flag: files.length ? 1 : 0,
receipt_file_ids: item.receipt_file_ids || files.map(file => file.file_id).filter(Boolean).join(','),
receipt_urls: item.receipt_urls || files.map(file => file.url).filter(Boolean).join(','),
receipt_amounts: buildReceiptAmountsPayload(files, rows)
})
}
ElMessage.success('发票金额已保存')
emit('changed', props.detail.id)
} catch (error) {
ElMessage.error(error?.message || '保存发票金额失败')
} finally {
amountSaving.value = false
}
}
const closeAmountCompare = () => {
amountCompare.value = null
}
const getReceiptUrl = value => (value ? String(value).split(',').filter(Boolean)[0] || '' : '')
const getItemReceiptFiles = item => {
if (Array.isArray(item?.receipt_files) && item.receipt_files.length) {
return item.receipt_files
return item.receipt_files.map(file => ({
...file,
amount: file.amount != null && file.amount !== '' ? Number(file.amount) : null
}))
}
const ids = String(item?.receipt_file_ids || '')
.split(',')
@@ -268,10 +498,16 @@ const getItemReceiptFiles = item => {
.map(v => v.trim())
.filter(Boolean)
if (ids.length) {
const amountParts = String(item?.receipt_amounts || '')
.split(',')
.map(v => v.trim())
return ids.map((id, index) => ({
file_id: Number(id),
url: urls[index] || urls[0] || '',
name: ''
name: '',
amount: amountParts[index] !== undefined && amountParts[index] !== ''
? Number(amountParts[index])
: null
}))
}
if (getReceiptUrl(item?.receipt_urls)) {
@@ -282,6 +518,77 @@ const getItemReceiptFiles = item => {
const itemHasReceipt = item => getItemReceiptFiles(item).length > 0
const getItemReceiptAmountTotal = item => {
const amounts = getItemReceiptFiles(item)
.map(file => file.amount)
.filter(amount => amount != null && amount !== '' && !Number.isNaN(Number(amount)))
if (!amounts.length) return null
return amounts.reduce((sum, amount) => sum + Number(amount), 0)
}
const getItemReceiptDiff = item => {
const receiptTotal = getItemReceiptAmountTotal(item)
if (receiptTotal == null) return null
return receiptTotal - Number(item?.amount || 0)
}
const formatItemReceiptTotal = item => {
const total = getItemReceiptAmountTotal(item)
return total == null ? '-' : `¥ ${money(total)}`
}
const formatItemReceiptDiff = item => {
const diff = getItemReceiptDiff(item)
if (diff == null) return '-'
if (Math.abs(diff) < 0.005) return '0.00'
if (diff > 0) return `+${money(diff)}`
return money(diff)
}
const itemReceiptDiffClass = item => {
const diff = getItemReceiptDiff(item)
if (diff == null || Math.abs(diff) < 0.005) return ''
return diff > 0 ? 'receipt-diff-over' : 'receipt-diff-short'
}
const detailReceiptTotal = computed(() => {
const items = props.detail?.items || []
let hasAny = false
const total = items.reduce((sum, item) => {
const itemTotal = getItemReceiptAmountTotal(item)
if (itemTotal != null) {
hasAny = true
return sum + itemTotal
}
return sum
}, 0)
return hasAny ? total : null
})
const detailReceiptDiff = computed(() => {
if (detailReceiptTotal.value == null) return null
return detailReceiptTotal.value - Number(props.detail?.total_amount || 0)
})
const formatDetailReceiptTotal = computed(() => {
const total = detailReceiptTotal.value
return total == null ? '-' : `¥ ${money(total)}`
})
const formatDetailReceiptDiff = computed(() => {
const diff = detailReceiptDiff.value
if (diff == null) return '-'
if (Math.abs(diff) < 0.005) return '0.00'
if (diff > 0) return `+${money(diff)}`
return money(diff)
})
const detailReceiptDiffClass = computed(() => {
const diff = detailReceiptDiff.value
if (diff == null || Math.abs(diff) < 0.005) return ''
return diff > 0 ? 'receipt-diff-over' : 'receipt-diff-short'
})
const receiptList = computed(() => {
const items = props.detail?.items || []
const list = []
@@ -436,20 +743,34 @@ const uploadFileToSystem = async rawFile => {
return { id: fileId, url, name: rawFile.name }
}
const saveItemReceiptFiles = async (item, files, { append = true } = {}) => {
const saveItemReceiptFiles = async (item, files, { append = true, autoRecognize = true } = {}) => {
const existing = append ? getItemReceiptFiles(item) : []
const merged = [...existing]
const seen = new Set(merged.map(file => Number(file.file_id)).filter(Boolean))
const newlyAddedIds = []
files.forEach(file => {
const id = Number(file.id)
if (!id || seen.has(id)) return
seen.add(id)
merged.push({ file_id: id, url: file.url, name: file.name })
merged.push({ file_id: id, url: file.url, name: file.name, amount: null })
newlyAddedIds.push(id)
})
if (!merged.length) return
if (!merged.length) {
return { uploadedCount: 0, recognizedCount: 0, unrecognized: [] }
}
let recognizedCount = 0
let unrecognized = []
if (autoRecognize && newlyAddedIds.length) {
const result = await recognizeReceiptFileAmounts(merged, { fileIds: newlyAddedIds })
recognizedCount = result.recognizedCount
unrecognized = result.unrecognized
}
const ids = merged.map(file => file.file_id).filter(Boolean)
const urls = merged.map(file => file.url).filter(Boolean)
const receipt_amounts = formatItemReceiptAmountsPayload(merged)
await updateReimbursementItem(props.detail.id, item.id, {
expense_type: item.expense_type,
expense_date: item.expense_date,
@@ -459,8 +780,15 @@ const saveItemReceiptFiles = async (item, files, { append = true } = {}) => {
description: item.description || '',
receipt_flag: 1,
receipt_file_ids: ids.join(','),
receipt_urls: urls.join(',')
receipt_urls: urls.join(','),
receipt_amounts
})
return {
uploadedCount: newlyAddedIds.length,
recognizedCount,
unrecognized
}
}
const uploadFilesToItem = async (item, rawFiles) => {
@@ -468,8 +796,7 @@ const uploadFilesToItem = async (item, rawFiles) => {
for (const file of rawFiles) {
uploaded.push(await uploadFileToSystem(file))
}
await saveItemReceiptFiles(item, uploaded, { append: true })
return uploaded.length
return saveItemReceiptFiles(item, uploaded, { append: true, autoRecognize: true })
}
const uploadInvoicesFromRow = async (startRow, rawFiles) => {
@@ -481,8 +808,20 @@ const uploadInvoicesFromRow = async (startRow, rawFiles) => {
batchUploading.value = true
try {
const count = await uploadFilesToItem(startRow, validFiles)
ElMessage.success(`已向「${startRow.expense_type || '当前费用项'}」追加 ${count} 张发票`)
const { uploadedCount, recognizedCount, unrecognized } = await uploadFilesToItem(startRow, validFiles)
const expenseLabel = startRow.expense_type || '当前费用项'
if (unrecognized.length) {
pendingAmountCompare.value = true
ElMessage.warning(
`${unrecognized.length} 张发票未能自动识别金额,请在下方核对区手动填写后保存`
)
} else if (recognizedCount > 0) {
ElMessage.success(`已向「${expenseLabel}」上传 ${uploadedCount} 张发票,已自动识别并保存 ${recognizedCount} 张金额`)
} else {
ElMessage.success(`已向「${expenseLabel}」追加 ${uploadedCount} 张发票`)
}
emit('changed', props.detail.id)
} catch (error) {
ElMessage.error(error?.message || '发票上传失败')
@@ -528,7 +867,8 @@ const removeReceipt = async receipt => {
description: row.description || '',
receipt_flag: 0,
receipt_file_ids: '',
receipt_urls: ''
receipt_urls: '',
receipt_amounts: ''
})
} else {
const ids = remaining.map(file => file.file_id).filter(Boolean)
@@ -719,4 +1059,70 @@ const formatDateTime = value => value ? new Date(value).toLocaleString('zh-CN',
align-items: flex-start;
gap: 12px;
}
.amount-compare-card {
margin-bottom: 16px;
padding: 14px 16px;
border: 1px solid var(--el-border-color-light);
border-radius: 6px;
background: #fafafa;
}
.amount-compare-summary {
display: flex;
flex-wrap: wrap;
gap: 20px 32px;
margin-bottom: 10px;
}
.compare-item {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 13px;
span {
color: #909399;
}
strong {
font-size: 18px;
color: #303133;
}
strong.is-match {
color: #67c23a;
}
strong.is-short {
color: #e6a23c;
}
strong.is-over {
color: #f56c6c;
}
}
.compare-tip {
margin: 0 0 12px;
color: #909399;
font-size: 12px;
}
.compare-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 12px;
}
.receipt-diff-over {
color: #f56c6c;
font-weight: 600;
}
.receipt-diff-short {
color: #67c23a;
font-weight: 600;
}
</style>
@@ -0,0 +1,287 @@
import * as pdfjsLib from 'pdfjs-dist'
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url'
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker
const MAX_SINGLE_INVOICE = 99999.99
const roundAmount = value => Math.round(Number(value) * 100) / 100
const resolveUrl = url => {
if (!url) return ''
if (/^https?:\/\//i.test(url) || url.startsWith('blob:') || url.startsWith('data:')) return url
return `${window.location.origin}/${String(url).replace(/^\/+/, '')}`
}
const isImageUrl = url => /\.(jpe?g|png|gif|bmp|webp)(\?|#|$)/i.test(url || '')
const isPdfUrl = url => /\.pdf(\?|#|$)/i.test(String(url || '').toLowerCase())
const isReasonableAmount = value => value > 0 && value <= MAX_SINGLE_INVOICE
const sanitizeInvoiceText = text =>
String(text || '')
.replace(/\s+/g, ' ')
.replace(/\d{12,}/g, ' ')
export const pickInvoiceAmountFromText = text => {
if (!text) return null
const normalized = sanitizeInvoiceText(text)
const priorityPatterns = [
/价税合计[^0-9]{0,40}?[(]?小写[)]?[^0-9¥¥]{0,12}[¥¥]\s*(\d{1,5}\.\d{2})(?!\d)/,
/(?:价税合计|合计金额|总计|应付(?:金额)?|实付(?:金额)?|支付金额)[^0-9¥¥]{0,24}[¥¥]\s*(\d{1,5}\.\d{2})(?!\d)/,
/小写[^0-9¥¥]{0,12}[¥¥]\s*(\d{1,5}\.\d{2})(?!\d)/
]
for (const pattern of priorityPatterns) {
const match = normalized.match(pattern)
if (!match) continue
const amount = roundAmount(match[1])
if (isReasonableAmount(amount)) return amount
}
const amounts = [...normalized.matchAll(/(?<![0-9])[¥¥]\s*(\d{1,5}\.\d{2})(?![0-9])/g)]
.map(item => roundAmount(item[1]))
.filter(isReasonableAmount)
if (!amounts.length) return null
const underLimit = amounts.filter(value => value <= 50000)
if (underLimit.length) return Math.max(...underLimit)
return Math.max(...amounts)
}
const fetchAuthorizedBlob = async url => {
const token = localStorage.getItem('token')
const response = await fetch(resolveUrl(url), {
headers: token ? { Authorization: `Bearer ${token}` } : {}
})
if (!response.ok) {
throw new Error(`文件加载失败 (${response.status})`)
}
return response.blob()
}
const blobToDataUrl = blob =>
new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result)
reader.onerror = reject
reader.readAsDataURL(blob)
})
const extractPdfText = async url => {
const blob = await fetchAuthorizedBlob(url)
const buffer = await blob.arrayBuffer()
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
let text = ''
for (let pageNo = 1; pageNo <= pdf.numPages; pageNo += 1) {
const page = await pdf.getPage(pageNo)
const content = await page.getTextContent()
text += `${content.items.map(item => item.str).join(' ')}\n`
}
return text
}
const renderPdfFirstPageDataUrl = async url => {
const blob = await fetchAuthorizedBlob(url)
const buffer = await blob.arrayBuffer()
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
const page = await pdf.getPage(1)
const viewport = page.getViewport({ scale: 2 })
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = viewport.width
canvas.height = viewport.height
await page.render({ canvasContext: context, viewport }).promise
return canvas.toDataURL('image/png')
}
const extractTextByOcr = async dataUrl => {
const { default: Tesseract } = await import('tesseract.js')
const result = await Tesseract.recognize(dataUrl, 'chi_sim+eng', {
logger: () => {}
})
return result?.data?.text || ''
}
export const extractInvoiceAmount = async file => {
const url = file?.url || file?.rawUrl || ''
const name = file?.name || url.split('/').pop()?.split('?')[0] || '发票'
try {
if (isPdfUrl(url)) {
const text = await extractPdfText(url)
const fromPdfText = pickInvoiceAmountFromText(text)
if (fromPdfText != null) {
return { amount: fromPdfText, source: 'PDF文本', name }
}
const preview = await renderPdfFirstPageDataUrl(url)
const ocrText = await extractTextByOcr(preview)
const fromPdfOcr = pickInvoiceAmountFromText(ocrText)
if (fromPdfOcr != null) {
return { amount: fromPdfOcr, source: 'PDF识别', name }
}
} else if (isImageUrl(url)) {
const blob = await fetchAuthorizedBlob(url)
const dataUrl = await blobToDataUrl(blob)
const ocrText = await extractTextByOcr(dataUrl)
const fromImageOcr = pickInvoiceAmountFromText(ocrText)
if (fromImageOcr != null) {
return { amount: fromImageOcr, source: '图片识别', name }
}
}
} catch (error) {
return { amount: null, source: '', name, error: error?.message || '识别失败' }
}
return { amount: null, source: '', name, error: '未能识别金额' }
}
const getItemReceiptFiles = item => {
if (Array.isArray(item?.receipt_files) && item.receipt_files.length) {
return item.receipt_files
}
const ids = String(item?.receipt_file_ids || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
const urls = String(item?.receipt_urls || '')
.split(',')
.map(v => v.trim())
.filter(Boolean)
const amounts = String(item?.receipt_amounts || '')
.split(',')
.map(v => v.trim())
if (ids.length) {
return ids.map((id, index) => ({
file_id: Number(id),
url: urls[index] || urls[0] || '',
name: '',
amount: amounts[index] !== undefined && amounts[index] !== '' ? roundAmount(amounts[index]) : null
}))
}
return []
}
export const buildReceiptAmountRows = async (detail, { recognizeMissing = true } = {}) => {
const items = detail?.items || []
const rows = []
for (const item of items) {
const files = getItemReceiptFiles(item)
for (const file of files) {
const rawUrl = file.url || ''
if (!rawUrl) continue
const savedAmount = file.amount != null && !Number.isNaN(Number(file.amount))
? roundAmount(file.amount)
: null
let amount = savedAmount
let source = savedAmount != null ? '已保存' : ''
let error = ''
if (amount == null && recognizeMissing) {
const result = await extractInvoiceAmount({
url: resolveUrl(rawUrl),
rawUrl,
name: file.name || rawUrl.split('/').pop()?.split('?')[0] || '发票'
})
amount = result.amount
source = result.source || ''
error = result.error || ''
}
rows.push({
itemId: item.id,
fileId: file.file_id || 0,
expense_type: item.expense_type || '-',
item_amount: roundAmount(item.amount),
name: file.name || rawUrl.split('/').pop()?.split('?')[0] || '发票',
url: resolveUrl(rawUrl),
rawUrl,
isImage: isImageUrl(rawUrl),
amount,
source,
error
})
}
}
return rows
}
export const summarizeReceiptAmountRows = (rows, claimTotal) => {
const claim = roundAmount(claimTotal)
const recognized = rows.filter(row => row.amount != null && !Number.isNaN(Number(row.amount)))
const invoiceTotal = roundAmount(recognized.reduce((sum, row) => sum + Number(row.amount), 0))
const diff = roundAmount(claim - invoiceTotal)
return {
claimTotal: claim,
invoiceTotal,
diff,
recognizedCount: recognized.length,
totalReceiptCount: rows.length,
unmatchedCount: rows.length - recognized.length
}
}
export const compareReimbursementAmounts = async (detail, { recognizeMissing = true } = {}) => {
const claimTotal = (detail?.items || []).reduce((sum, item) => sum + Number(item.amount || 0), 0)
const details = await buildReceiptAmountRows(detail, { recognizeMissing })
return {
...summarizeReceiptAmountRows(details, claimTotal),
details
}
}
export const recognizeReceiptFileAmounts = async (files, { fileIds = null } = {}) => {
const targetIds = fileIds ? new Set(fileIds.map(Number)) : null
const unrecognized = []
let recognizedCount = 0
for (const file of files) {
if (targetIds && !targetIds.has(Number(file.file_id))) continue
const hasAmount = file.amount != null && file.amount !== '' && !Number.isNaN(Number(file.amount)) && Number(file.amount) > 0
if (hasAmount) continue
const rawUrl = file.url || ''
const result = await extractInvoiceAmount({
url: resolveUrl(rawUrl),
rawUrl,
name: file.name || rawUrl.split('/').pop()?.split('?')[0] || '发票'
})
if (result.amount != null) {
file.amount = result.amount
recognizedCount += 1
} else {
unrecognized.push({
file_id: file.file_id,
name: file.name || result.name,
error: result.error || '未能识别金额'
})
}
}
return { recognizedCount, unrecognized }
}
export const formatItemReceiptAmountsPayload = files =>
files
.map(file => {
if (file.amount != null && file.amount !== '' && !Number.isNaN(Number(file.amount))) {
const value = roundAmount(file.amount)
return value > 0 ? value.toFixed(2) : ''
}
return ''
})
.join(',')
export const formatReceiptAmountsPayload = rows => {
const sorted = [...rows]
return sorted
.map(row => {
if (row.amount == null || row.amount === '') return ''
const value = roundAmount(row.amount)
return value > 0 ? value.toFixed(2) : ''
})
.join(',')
}
+89 -2
View File
@@ -33,6 +33,7 @@ type reimbursementItemPayload struct {
ReceiptFlag int8 `json:"receipt_flag"`
ReceiptFileIDs string `json:"receipt_file_ids"`
ReceiptURLs string `json:"receipt_urls"`
ReceiptAmounts *string `json:"receipt_amounts,omitempty"`
}
type reimbursementPayload struct {
@@ -122,12 +123,77 @@ func joinReceiptFileIDs(ids []uint64) string {
return strings.Join(parts, ",")
}
func parseReceiptAmounts(raw string) []string {
raw = strings.TrimSpace(raw)
if raw == "" {
return []string{}
}
parts := strings.Split(raw, ",")
result := make([]string, len(parts))
for i, part := range parts {
result[i] = strings.TrimSpace(part)
}
return result
}
func joinReceiptAmounts(parts []string) string {
if len(parts) == 0 {
return ""
}
return strings.Join(parts, ",")
}
func normalizeReceiptAmounts(raw string, idCount int) string {
parts := parseReceiptAmounts(raw)
if idCount <= 0 {
return ""
}
if len(parts) < idCount {
padding := make([]string, idCount-len(parts))
parts = append(parts, padding...)
} else if len(parts) > idCount {
parts = parts[:idCount]
}
normalized := make([]string, idCount)
for i := 0; i < idCount; i++ {
part := strings.TrimSpace(parts[i])
if part == "" {
normalized[i] = ""
continue
}
value, err := strconv.ParseFloat(part, 64)
if err != nil || value < 0 {
normalized[i] = ""
continue
}
normalized[i] = fmt.Sprintf("%.2f", value)
}
return joinReceiptAmounts(normalized)
}
func remapReceiptAmounts(oldIDs []uint64, oldAmounts []string, newIDs []uint64) string {
oldMap := map[uint64]string{}
for i, id := range oldIDs {
if i < len(oldAmounts) {
oldMap[id] = oldAmounts[i]
}
}
parts := make([]string, len(newIDs))
for i, id := range newIDs {
if amount, ok := oldMap[id]; ok {
parts[i] = amount
}
}
return joinReceiptAmounts(parts)
}
func resolveReceiptFiles(scheme, host string, item models.BackendReimbursementItem) []map[string]interface{} {
ids := parseReceiptFileIDs(item.ReceiptFileIDs)
if len(ids) == 0 {
return []map[string]interface{}{}
}
urlParts := strings.Split(item.ReceiptURLs, ",")
amountParts := parseReceiptAmounts(item.ReceiptAmounts)
files := make([]map[string]interface{}, 0, len(ids))
for i, id := range ids {
url := ""
@@ -147,8 +213,14 @@ func resolveReceiptFiles(scheme, host string, item models.BackendReimbursementIt
if name == "" && url != "" {
name = path.Base(strings.Split(url, "?")[0])
}
var amount interface{}
if i < len(amountParts) && amountParts[i] != "" {
if value, err := strconv.ParseFloat(amountParts[i], 64); err == nil {
amount = value
}
}
files = append(files, map[string]interface{}{
"file_id": id, "url": url, "name": name,
"file_id": id, "url": url, "name": name, "amount": amount,
})
}
return files
@@ -159,6 +231,7 @@ func syncReceiptFields(item *models.BackendReimbursementItem, scheme, host strin
if len(files) == 0 {
item.ReceiptFileIDs = ""
item.ReceiptURLs = ""
item.ReceiptAmounts = ""
item.ReceiptFlag = 0
return
}
@@ -178,21 +251,32 @@ func syncReceiptFields(item *models.BackendReimbursementItem, scheme, host strin
}
func applyReceiptToItem(item *models.BackendReimbursementItem, payload reimbursementItemPayload, scheme, host string) {
oldIDs := parseReceiptFileIDs(item.ReceiptFileIDs)
oldAmounts := parseReceiptAmounts(item.ReceiptAmounts)
if payload.ReceiptFlag == 0 && strings.TrimSpace(payload.ReceiptFileIDs) == "" {
item.ReceiptFileIDs = ""
item.ReceiptURLs = ""
item.ReceiptAmounts = ""
item.ReceiptFlag = 0
return
}
if strings.TrimSpace(payload.ReceiptFileIDs) != "" {
item.ReceiptFileIDs = strings.TrimSpace(payload.ReceiptFileIDs)
syncReceiptFields(item, scheme, host)
newIDs := parseReceiptFileIDs(item.ReceiptFileIDs)
if payload.ReceiptAmounts != nil {
item.ReceiptAmounts = normalizeReceiptAmounts(*payload.ReceiptAmounts, len(newIDs))
} else {
item.ReceiptAmounts = remapReceiptAmounts(oldIDs, oldAmounts, newIDs)
}
return
}
item.ReceiptFlag = payload.ReceiptFlag
item.ReceiptURLs = strings.TrimSpace(payload.ReceiptURLs)
if item.ReceiptURLs == "" && item.ReceiptFlag == 0 {
item.ReceiptFileIDs = ""
item.ReceiptAmounts = ""
}
}
@@ -216,13 +300,16 @@ func reimbursementItemDTO(item models.BackendReimbursementItem, scheme, host str
"amount": item.Amount, "quantity": item.Quantity, "unit_price": item.UnitPrice,
"description": item.Description, "receipt_flag": receiptFlag,
"receipt_file_ids": item.ReceiptFileIDs,
"receipt_urls": receiptURLs, "receipt_files": receiptFiles,
"receipt_urls": receiptURLs, "receipt_amounts": item.ReceiptAmounts,
"receipt_files": receiptFiles,
}
}
func reimbursementDTO(row models.BackendReimbursement, items []models.BackendReimbursementItem, scheme, host string) map[string]interface{} {
_, userName := services.OperationLogUser(&row.Tid, row.UserID)
result := map[string]interface{}{
"id": row.ID, "tid": row.Tid, "uid": row.UID, "user_id": row.UserID,
"user_name": userName,
"department_id": row.DepartmentID, "total_amount": row.TotalAmount,
"status": row.Status, "apply_date": row.ApplyDate.Format("2006-01-02"),
"description": row.Description, "current_approver_id": row.CurrentApproverID,
+1
View File
@@ -83,6 +83,7 @@ type BackendReimbursementItem struct {
ReceiptFlag int8 `orm:"column(receipt_flag);default(0)" json:"receipt_flag"`
ReceiptFileIDs string `orm:"column(receipt_file_ids);size(500)" json:"receipt_file_ids"`
ReceiptURLs string `orm:"column(receipt_urls);size(500)" json:"receipt_urls"`
ReceiptAmounts string `orm:"column(receipt_amounts);size(500)" json:"receipt_amounts"`
IsDeleted int8 `orm:"column(is_deleted);default(0)" json:"is_deleted"`
CreateTime time.Time `orm:"column(create_time);auto_now_add;type(datetime)" json:"create_time"`
UpdateTime *time.Time `orm:"column(update_time);null;type(datetime)" json:"update_time"`
@@ -0,0 +1,12 @@
-- 报销明细:每张发票对应一个确认金额,与 receipt_file_ids 顺序一一对应(逗号分隔,空位可留空)
-- 示例:receipt_file_ids=357,358 receipt_amounts=98.00,145.00
-- 若已有 receipt_urls 列,用这一条:
ALTER TABLE `yz_backend_reimbursement_items`
ADD COLUMN `receipt_amounts` varchar(500) NOT NULL DEFAULT '' COMMENT '票据金额,逗号分隔,与 receipt_file_ids 对应' AFTER `receipt_urls`;
-- 若上面报错 Unknown column 'receipt_urls',改用这一条(放在 receipt_file_ids 后面):
-- ALTER TABLE `yz_backend_reimbursement_items`
-- ADD COLUMN `receipt_amounts` varchar(500) NOT NULL DEFAULT '' COMMENT '票据金额,逗号分隔,与 receipt_file_ids 对应' AFTER `receipt_file_ids`;
-- 若报错 Duplicate column name 'receipt_amounts',说明字段已存在,无需重复执行。