增加发票识别差额功能
This commit is contained in:
Generated
+110
-2
@@ -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",
|
||||
|
||||
@@ -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(',')
|
||||
}
|
||||
@@ -31,8 +31,9 @@ type reimbursementItemPayload struct {
|
||||
UnitPrice float64 `json:"unit_price"`
|
||||
Description string `json:"description"`
|
||||
ReceiptFlag int8 `json:"receipt_flag"`
|
||||
ReceiptFileIDs string `json:"receipt_file_ids"`
|
||||
ReceiptURLs string `json:"receipt_urls"`
|
||||
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,
|
||||
|
||||
@@ -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',说明字段已存在,无需重复执行。
|
||||
Reference in New Issue
Block a user