增加发票识别差额功能
This commit is contained in:
Generated
+110
-2
@@ -23,6 +23,7 @@
|
|||||||
"os": "^0.1.2",
|
"os": "^0.1.2",
|
||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"v-viewer": "^3.0.11",
|
"v-viewer": "^3.0.11",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-img-cutter": "^3.0.7",
|
"vue-img-cutter": "^3.0.7",
|
||||||
@@ -3285,6 +3286,12 @@
|
|||||||
"url": "https://github.com/sponsors/antfu"
|
"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": {
|
"node_modules/braces": {
|
||||||
"version": "3.0.3",
|
"version": "3.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
|
||||||
@@ -4990,6 +4997,12 @@
|
|||||||
"node": ">=0.10.0"
|
"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": {
|
"node_modules/image-size": {
|
||||||
"version": "0.5.5",
|
"version": "0.5.5",
|
||||||
"resolved": "https://registry.npmmirror.com/image-size/-/image-size-0.5.5.tgz",
|
"resolved": "https://registry.npmmirror.com/image-size/-/image-size-0.5.5.tgz",
|
||||||
@@ -5363,6 +5376,12 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"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": {
|
"node_modules/is-weakmap": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
"resolved": "https://registry.npmmirror.com/is-weakmap/-/is-weakmap-2.0.2.tgz",
|
||||||
@@ -5951,6 +5970,26 @@
|
|||||||
"node": ">= 4.4.x"
|
"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": {
|
"node_modules/normalize-wheel-es": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
|
"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"
|
"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": {
|
"node_modules/orderedmap": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz",
|
"resolved": "https://registry.npmmirror.com/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||||
@@ -6581,8 +6629,7 @@
|
|||||||
"version": "0.13.11",
|
"version": "0.13.11",
|
||||||
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"optional": true
|
|
||||||
},
|
},
|
||||||
"node_modules/regexp.prototype.flags": {
|
"node_modules/regexp.prototype.flags": {
|
||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
@@ -7157,6 +7204,30 @@
|
|||||||
"node": ">=12.0.0"
|
"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": {
|
"node_modules/text-segmentation": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
@@ -7204,6 +7275,12 @@
|
|||||||
"node": ">=8.0"
|
"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": {
|
"node_modules/traverse": {
|
||||||
"version": "0.6.11",
|
"version": "0.6.11",
|
||||||
"resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.6.11.tgz",
|
"resolved": "https://registry.npmmirror.com/traverse/-/traverse-0.6.11.tgz",
|
||||||
@@ -7734,6 +7811,18 @@
|
|||||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
||||||
@@ -7741,6 +7830,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/which-boxed-primitive": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmmirror.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz",
|
"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"
|
"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": {
|
"node_modules/zod": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
|
"resolved": "https://registry.npmmirror.com/zod/-/zod-4.4.3.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"os": "^0.1.2",
|
"os": "^0.1.2",
|
||||||
"pdfjs-dist": "^6.2.108",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"tesseract.js": "^7.0.0",
|
||||||
"v-viewer": "^3.0.11",
|
"v-viewer": "^3.0.11",
|
||||||
"vue": "^3.5.22",
|
"vue": "^3.5.22",
|
||||||
"vue-img-cutter": "^3.0.7",
|
"vue-img-cutter": "^3.0.7",
|
||||||
|
|||||||
@@ -5,17 +5,81 @@
|
|||||||
<el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
|
<el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag>
|
||||||
</el-descriptions-item>
|
</el-descriptions-item>
|
||||||
<el-descriptions-item label="申请日期">{{ detail.apply_date || '-' }}</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-item label="备注说明" :span="3">{{ detail.description || '-' }}</el-descriptions-item>
|
||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<div class="section-header">
|
<div class="section-header">
|
||||||
<h4 class="detail-title">费用明细</h4>
|
<h4 class="detail-title">费用明细</h4>
|
||||||
<div class="section-actions" style="margin-bottom: 12px;">
|
<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>
|
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
|
||||||
</div>
|
</div>
|
||||||
</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 :data="detail?.items || []" border size="small">
|
||||||
<el-table-column prop="expense_type" label="费用类型" min-width="120" />
|
<el-table-column prop="expense_type" label="费用类型" min-width="120" />
|
||||||
<el-table-column prop="expense_date" label="发生日期" width="120" />
|
<el-table-column prop="expense_date" label="发生日期" width="120" />
|
||||||
@@ -27,6 +91,16 @@
|
|||||||
{{ itemHasReceipt(row) ? '有' : '无' }}({{ getItemReceiptFiles(row).length }})
|
{{ itemHasReceipt(row) ? '有' : '无' }}({{ getItemReceiptFiles(row).length }})
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</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 prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column label="操作" :width="isDraft ? 176 : 70" fixed="right">
|
<el-table-column label="操作" :width="isDraft ? 176 : 70" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
@@ -206,10 +280,17 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { uploadFile } from '@/api/file'
|
import { uploadFile } from '@/api/file'
|
||||||
import { deleteReimbursementItem, submitReimbursement, updateReimbursementItem, updateReimbursementStatus } from '@/api/reimburse'
|
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'
|
import ItemForm from './itemForm.vue'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -221,6 +302,8 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'changed'])
|
const emit = defineEmits(['update:modelValue', 'changed'])
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
{ value: 0, label: '草稿' },
|
{ value: 0, label: '草稿' },
|
||||||
{ value: 1, label: '审批中' },
|
{ value: 1, label: '审批中' },
|
||||||
@@ -237,6 +320,19 @@ const visible = computed({
|
|||||||
set: value => emit('update:modelValue', value)
|
set: value => emit('update:modelValue', value)
|
||||||
})
|
})
|
||||||
const isDraft = computed(() => props.detail?.status === 0)
|
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 submitting = ref(false)
|
||||||
const manualConfirmVisible = ref(false)
|
const manualConfirmVisible = ref(false)
|
||||||
const manualSaving = ref(false)
|
const manualSaving = ref(false)
|
||||||
@@ -252,12 +348,146 @@ const receiptPreviewUrl = ref('')
|
|||||||
const receiptPreviewType = ref('')
|
const receiptPreviewType = ref('')
|
||||||
const receiptPreviewItemId = ref('')
|
const receiptPreviewItemId = ref('')
|
||||||
const receiptTypeFilter = 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 getReceiptUrl = value => (value ? String(value).split(',').filter(Boolean)[0] || '' : '')
|
||||||
|
|
||||||
const getItemReceiptFiles = item => {
|
const getItemReceiptFiles = item => {
|
||||||
if (Array.isArray(item?.receipt_files) && item.receipt_files.length) {
|
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 || '')
|
const ids = String(item?.receipt_file_ids || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
@@ -268,10 +498,16 @@ const getItemReceiptFiles = item => {
|
|||||||
.map(v => v.trim())
|
.map(v => v.trim())
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
if (ids.length) {
|
if (ids.length) {
|
||||||
|
const amountParts = String(item?.receipt_amounts || '')
|
||||||
|
.split(',')
|
||||||
|
.map(v => v.trim())
|
||||||
return ids.map((id, index) => ({
|
return ids.map((id, index) => ({
|
||||||
file_id: Number(id),
|
file_id: Number(id),
|
||||||
url: urls[index] || urls[0] || '',
|
url: urls[index] || urls[0] || '',
|
||||||
name: ''
|
name: '',
|
||||||
|
amount: amountParts[index] !== undefined && amountParts[index] !== ''
|
||||||
|
? Number(amountParts[index])
|
||||||
|
: null
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
if (getReceiptUrl(item?.receipt_urls)) {
|
if (getReceiptUrl(item?.receipt_urls)) {
|
||||||
@@ -282,6 +518,77 @@ const getItemReceiptFiles = item => {
|
|||||||
|
|
||||||
const itemHasReceipt = item => getItemReceiptFiles(item).length > 0
|
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 receiptList = computed(() => {
|
||||||
const items = props.detail?.items || []
|
const items = props.detail?.items || []
|
||||||
const list = []
|
const list = []
|
||||||
@@ -436,20 +743,34 @@ const uploadFileToSystem = async rawFile => {
|
|||||||
return { id: fileId, url, name: rawFile.name }
|
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 existing = append ? getItemReceiptFiles(item) : []
|
||||||
const merged = [...existing]
|
const merged = [...existing]
|
||||||
const seen = new Set(merged.map(file => Number(file.file_id)).filter(Boolean))
|
const seen = new Set(merged.map(file => Number(file.file_id)).filter(Boolean))
|
||||||
|
const newlyAddedIds = []
|
||||||
|
|
||||||
files.forEach(file => {
|
files.forEach(file => {
|
||||||
const id = Number(file.id)
|
const id = Number(file.id)
|
||||||
if (!id || seen.has(id)) return
|
if (!id || seen.has(id)) return
|
||||||
seen.add(id)
|
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 ids = merged.map(file => file.file_id).filter(Boolean)
|
||||||
const urls = merged.map(file => file.url).filter(Boolean)
|
const urls = merged.map(file => file.url).filter(Boolean)
|
||||||
|
const receipt_amounts = formatItemReceiptAmountsPayload(merged)
|
||||||
await updateReimbursementItem(props.detail.id, item.id, {
|
await updateReimbursementItem(props.detail.id, item.id, {
|
||||||
expense_type: item.expense_type,
|
expense_type: item.expense_type,
|
||||||
expense_date: item.expense_date,
|
expense_date: item.expense_date,
|
||||||
@@ -459,8 +780,15 @@ const saveItemReceiptFiles = async (item, files, { append = true } = {}) => {
|
|||||||
description: item.description || '',
|
description: item.description || '',
|
||||||
receipt_flag: 1,
|
receipt_flag: 1,
|
||||||
receipt_file_ids: ids.join(','),
|
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) => {
|
const uploadFilesToItem = async (item, rawFiles) => {
|
||||||
@@ -468,8 +796,7 @@ const uploadFilesToItem = async (item, rawFiles) => {
|
|||||||
for (const file of rawFiles) {
|
for (const file of rawFiles) {
|
||||||
uploaded.push(await uploadFileToSystem(file))
|
uploaded.push(await uploadFileToSystem(file))
|
||||||
}
|
}
|
||||||
await saveItemReceiptFiles(item, uploaded, { append: true })
|
return saveItemReceiptFiles(item, uploaded, { append: true, autoRecognize: true })
|
||||||
return uploaded.length
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const uploadInvoicesFromRow = async (startRow, rawFiles) => {
|
const uploadInvoicesFromRow = async (startRow, rawFiles) => {
|
||||||
@@ -481,8 +808,20 @@ const uploadInvoicesFromRow = async (startRow, rawFiles) => {
|
|||||||
|
|
||||||
batchUploading.value = true
|
batchUploading.value = true
|
||||||
try {
|
try {
|
||||||
const count = await uploadFilesToItem(startRow, validFiles)
|
const { uploadedCount, recognizedCount, unrecognized } = await uploadFilesToItem(startRow, validFiles)
|
||||||
ElMessage.success(`已向「${startRow.expense_type || '当前费用项'}」追加 ${count} 张发票`)
|
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)
|
emit('changed', props.detail.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error?.message || '发票上传失败')
|
ElMessage.error(error?.message || '发票上传失败')
|
||||||
@@ -528,7 +867,8 @@ const removeReceipt = async receipt => {
|
|||||||
description: row.description || '',
|
description: row.description || '',
|
||||||
receipt_flag: 0,
|
receipt_flag: 0,
|
||||||
receipt_file_ids: '',
|
receipt_file_ids: '',
|
||||||
receipt_urls: ''
|
receipt_urls: '',
|
||||||
|
receipt_amounts: ''
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const ids = remaining.map(file => file.file_id).filter(Boolean)
|
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;
|
align-items: flex-start;
|
||||||
gap: 12px;
|
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>
|
</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"`
|
UnitPrice float64 `json:"unit_price"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
ReceiptFlag int8 `json:"receipt_flag"`
|
ReceiptFlag int8 `json:"receipt_flag"`
|
||||||
ReceiptFileIDs string `json:"receipt_file_ids"`
|
ReceiptFileIDs string `json:"receipt_file_ids"`
|
||||||
ReceiptURLs string `json:"receipt_urls"`
|
ReceiptURLs string `json:"receipt_urls"`
|
||||||
|
ReceiptAmounts *string `json:"receipt_amounts,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type reimbursementPayload struct {
|
type reimbursementPayload struct {
|
||||||
@@ -122,12 +123,77 @@ func joinReceiptFileIDs(ids []uint64) string {
|
|||||||
return strings.Join(parts, ",")
|
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{} {
|
func resolveReceiptFiles(scheme, host string, item models.BackendReimbursementItem) []map[string]interface{} {
|
||||||
ids := parseReceiptFileIDs(item.ReceiptFileIDs)
|
ids := parseReceiptFileIDs(item.ReceiptFileIDs)
|
||||||
if len(ids) == 0 {
|
if len(ids) == 0 {
|
||||||
return []map[string]interface{}{}
|
return []map[string]interface{}{}
|
||||||
}
|
}
|
||||||
urlParts := strings.Split(item.ReceiptURLs, ",")
|
urlParts := strings.Split(item.ReceiptURLs, ",")
|
||||||
|
amountParts := parseReceiptAmounts(item.ReceiptAmounts)
|
||||||
files := make([]map[string]interface{}, 0, len(ids))
|
files := make([]map[string]interface{}, 0, len(ids))
|
||||||
for i, id := range ids {
|
for i, id := range ids {
|
||||||
url := ""
|
url := ""
|
||||||
@@ -147,8 +213,14 @@ func resolveReceiptFiles(scheme, host string, item models.BackendReimbursementIt
|
|||||||
if name == "" && url != "" {
|
if name == "" && url != "" {
|
||||||
name = path.Base(strings.Split(url, "?")[0])
|
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{}{
|
files = append(files, map[string]interface{}{
|
||||||
"file_id": id, "url": url, "name": name,
|
"file_id": id, "url": url, "name": name, "amount": amount,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return files
|
return files
|
||||||
@@ -159,6 +231,7 @@ func syncReceiptFields(item *models.BackendReimbursementItem, scheme, host strin
|
|||||||
if len(files) == 0 {
|
if len(files) == 0 {
|
||||||
item.ReceiptFileIDs = ""
|
item.ReceiptFileIDs = ""
|
||||||
item.ReceiptURLs = ""
|
item.ReceiptURLs = ""
|
||||||
|
item.ReceiptAmounts = ""
|
||||||
item.ReceiptFlag = 0
|
item.ReceiptFlag = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -178,21 +251,32 @@ func syncReceiptFields(item *models.BackendReimbursementItem, scheme, host strin
|
|||||||
}
|
}
|
||||||
|
|
||||||
func applyReceiptToItem(item *models.BackendReimbursementItem, payload reimbursementItemPayload, scheme, host string) {
|
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) == "" {
|
if payload.ReceiptFlag == 0 && strings.TrimSpace(payload.ReceiptFileIDs) == "" {
|
||||||
item.ReceiptFileIDs = ""
|
item.ReceiptFileIDs = ""
|
||||||
item.ReceiptURLs = ""
|
item.ReceiptURLs = ""
|
||||||
|
item.ReceiptAmounts = ""
|
||||||
item.ReceiptFlag = 0
|
item.ReceiptFlag = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(payload.ReceiptFileIDs) != "" {
|
if strings.TrimSpace(payload.ReceiptFileIDs) != "" {
|
||||||
item.ReceiptFileIDs = strings.TrimSpace(payload.ReceiptFileIDs)
|
item.ReceiptFileIDs = strings.TrimSpace(payload.ReceiptFileIDs)
|
||||||
syncReceiptFields(item, scheme, host)
|
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
|
return
|
||||||
}
|
}
|
||||||
item.ReceiptFlag = payload.ReceiptFlag
|
item.ReceiptFlag = payload.ReceiptFlag
|
||||||
item.ReceiptURLs = strings.TrimSpace(payload.ReceiptURLs)
|
item.ReceiptURLs = strings.TrimSpace(payload.ReceiptURLs)
|
||||||
if item.ReceiptURLs == "" && item.ReceiptFlag == 0 {
|
if item.ReceiptURLs == "" && item.ReceiptFlag == 0 {
|
||||||
item.ReceiptFileIDs = ""
|
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,
|
"amount": item.Amount, "quantity": item.Quantity, "unit_price": item.UnitPrice,
|
||||||
"description": item.Description, "receipt_flag": receiptFlag,
|
"description": item.Description, "receipt_flag": receiptFlag,
|
||||||
"receipt_file_ids": item.ReceiptFileIDs,
|
"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{} {
|
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{}{
|
result := map[string]interface{}{
|
||||||
"id": row.ID, "tid": row.Tid, "uid": row.UID, "user_id": row.UserID,
|
"id": row.ID, "tid": row.Tid, "uid": row.UID, "user_id": row.UserID,
|
||||||
|
"user_name": userName,
|
||||||
"department_id": row.DepartmentID, "total_amount": row.TotalAmount,
|
"department_id": row.DepartmentID, "total_amount": row.TotalAmount,
|
||||||
"status": row.Status, "apply_date": row.ApplyDate.Format("2006-01-02"),
|
"status": row.Status, "apply_date": row.ApplyDate.Format("2006-01-02"),
|
||||||
"description": row.Description, "current_approver_id": row.CurrentApproverID,
|
"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"`
|
ReceiptFlag int8 `orm:"column(receipt_flag);default(0)" json:"receipt_flag"`
|
||||||
ReceiptFileIDs string `orm:"column(receipt_file_ids);size(500)" json:"receipt_file_ids"`
|
ReceiptFileIDs string `orm:"column(receipt_file_ids);size(500)" json:"receipt_file_ids"`
|
||||||
ReceiptURLs string `orm:"column(receipt_urls);size(500)" json:"receipt_urls"`
|
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"`
|
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"`
|
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"`
|
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