From 0496b0e8078775203d51d62765766c5db3ebeb71 Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期三, 26 八月 2026 09:42:06 +0800
Subject: [PATCH] feat(erp): 采购来票新增发票OCR识别录入功能
---
src/views/erp/purchase/invoice/index.vue | 24 ++++
src/api/crm/saleQuotation/ai/index.ts | 4
src/views/erp/purchase/invoice/modules/form.vue | 31 +++++
src/views/crm/saleQuotation/index.vue | 4
src/api/erp/purchase/invoice/ai/index.ts | 33 ++++++
src/views/crm/saleQuotation/modules/ocr-upload.vue | 4
src/views/crm/saleQuotation/modules/form.vue | 2
src/views/erp/purchase/invoice/modules/ocr-upload.vue | 186 +++++++++++++++++++++++++++++++++++++
8 files changed, 279 insertions(+), 9 deletions(-)
diff --git a/src/api/crm/saleQuotation/ai/index.ts b/src/api/crm/saleQuotation/ai/index.ts
index a9b18fb..2409471 100644
--- a/src/api/crm/saleQuotation/ai/index.ts
+++ b/src/api/crm/saleQuotation/ai/index.ts
@@ -9,7 +9,7 @@
quotationPrice?: number;
}
- /** AI OCR 璇嗗埆鎶ヤ环鏂囦欢鍝嶅簲 */
+ /** 璇嗗埆鎶ヤ环鏂囦欢鍝嶅簲 */
export interface OcrResultVO {
name?: string;
customerName?: string;
@@ -23,7 +23,7 @@
}
}
-/** AI OCR 璇嗗埆鎶ヤ环鏂囦欢 */
+/** 璇嗗埆鎶ヤ环鏂囦欢 */
export function ocrSaleQuotation(blobId: number) {
return requestClient.post<CrmSaleQuotationAiApi.OcrResultVO>(
'/crm/sale-quotation/ai/ocr',
diff --git a/src/api/erp/purchase/invoice/ai/index.ts b/src/api/erp/purchase/invoice/ai/index.ts
new file mode 100644
index 0000000..3b7e308
--- /dev/null
+++ b/src/api/erp/purchase/invoice/ai/index.ts
@@ -0,0 +1,33 @@
+import { requestClient } from '#/api/request';
+
+export namespace ErpPurchaseInvoiceAiApi {
+ /** 璇嗗埆鍙戠エ鍝嶅簲 */
+ export interface OcrResultVO {
+ /** 鍙戠エ鍙风爜 */
+ invoiceNo?: string;
+ /** 鍙戠エ鎶ご锛堣喘涔版柟鍚嶇О锛� */
+ invoiceTitle?: string;
+ /** 閿�鍞柟鍚嶇О锛堟枃鏈紝渚涙牳瀵逛緵搴斿晢锛� */
+ supplierName?: string;
+ /** 鏉ョエ閲戦锛堜环绋庡悎璁★級锛屽崟浣嶏細鍏� */
+ price?: number;
+ /** 寮�绁ㄦ棩鏈燂紝鏍煎紡 yyyy-MM-dd */
+ invoiceTime?: string;
+ /** 鍙戠エ绫诲瀷 */
+ invoiceType?: string;
+ /** 绋庣巼锛堢櫨鍒嗘瘮锛� */
+ taxRate?: number;
+ /** 澶囨敞 */
+ remark?: string;
+ /** 鍘熷璇嗗埆鏂囨湰锛堣瘑鍒け璐ユ椂涓洪敊璇彁绀猴級 */
+ rawText?: string;
+ }
+}
+
+/** 璇嗗埆鍙戠エ鏂囦欢 */
+export function ocrPurchaseInvoice(blobId: number) {
+ return requestClient.post<ErpPurchaseInvoiceAiApi.OcrResultVO>(
+ '/erp/purchase-invoice/ai/ocr',
+ { blobId },
+ );
+}
diff --git a/src/views/crm/saleQuotation/index.vue b/src/views/crm/saleQuotation/index.vue
index 13297ae..9a221d3 100644
--- a/src/views/crm/saleQuotation/index.vue
+++ b/src/views/crm/saleQuotation/index.vue
@@ -43,7 +43,7 @@
formModalApi.setData(null).open();
}
-/** AI 璇嗗埆褰曞叆 */
+/** 璇嗗埆褰曞叆 */
function handleOcrCreate() {
ocrUploadRef.value?.open();
}
@@ -159,7 +159,7 @@
onClick: handleCreate,
},
{
- label: 'AI 璇嗗埆褰曞叆',
+ label: '璇嗗埆褰曞叆',
type: 'default',
auth: ['crm:sale-quotation:create'],
onClick: handleOcrCreate,
diff --git a/src/views/crm/saleQuotation/modules/form.vue b/src/views/crm/saleQuotation/modules/form.vue
index a52beb5..b2444c4 100644
--- a/src/views/crm/saleQuotation/modules/form.vue
+++ b/src/views/crm/saleQuotation/modules/form.vue
@@ -155,7 +155,7 @@
type="info"
show-icon
class="mx-4 mb-4"
- message="AI 璇嗗埆鍒板鎴峰悕绉帮紝璇峰湪瀹㈡埛閫夋嫨鍣ㄤ腑鎵嬪姩鍖归厤 CRM 瀹㈡埛"
+ message="璇嗗埆鍒板鎴峰悕绉帮紝璇峰湪瀹㈡埛閫夋嫨鍣ㄤ腑鎵嬪姩鍖归厤 CRM 瀹㈡埛"
:description="`璇嗗埆缁撴灉锛�${ocrCustomerName}`"
/>
<Form class="mx-4">
diff --git a/src/views/crm/saleQuotation/modules/ocr-upload.vue b/src/views/crm/saleQuotation/modules/ocr-upload.vue
index db35cb6..2da0826 100644
--- a/src/views/crm/saleQuotation/modules/ocr-upload.vue
+++ b/src/views/crm/saleQuotation/modules/ocr-upload.vue
@@ -49,7 +49,7 @@
onSuccess({ blobId }, file);
// 寮�濮� OCR 璇嗗埆
step.value = 1;
- processingTip.value = '姝e湪杩涜 AI OCR 璇嗗埆锛岃绋嶅��...';
+ processingTip.value = '姝e湪杩涜鎶ヤ环鍗曡瘑鍒紝璇风◢鍊�...';
const ocrData = await ocrSaleQuotation(blobId);
// 妫�鏌ラ敊璇�
if (ocrData.rawText && !ocrData.name && !ocrData.customerName && ocrData.items?.length === 0) {
@@ -101,7 +101,7 @@
</script>
<template>
- <Modal v-model:open="open" title="AI 璇嗗埆褰曞叆鎶ヤ环鍗�" width="680px" :footer="null" @cancel="handleClose">
+ <Modal v-model:open="open" title="璇嗗埆褰曞叆鎶ヤ环鍗�" width="680px" :footer="null" @cancel="handleClose">
<!-- Step 0: 涓婁紶 -->
<template v-if="step === 0">
<Upload.Dragger
diff --git a/src/views/erp/purchase/invoice/index.vue b/src/views/erp/purchase/invoice/index.vue
index 38fc81b..2878f23 100644
--- a/src/views/erp/purchase/invoice/index.vue
+++ b/src/views/erp/purchase/invoice/index.vue
@@ -1,11 +1,13 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
+import type { ErpPurchaseInvoiceAiApi } from '#/api/erp/purchase/invoice/ai';
import { Page, useVbenModal } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { Button, message, Tag } from 'ant-design-vue';
+import { nextTick, ref } from 'vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
@@ -17,6 +19,7 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
+import OcrUpload from './modules/ocr-upload.vue';
defineOptions({ name: 'ErpPurchaseInvoice' });
@@ -24,6 +27,8 @@
connectedComponent: Form,
destroyOnClose: true,
});
+
+const ocrUploadRef = ref<InstanceType<typeof OcrUpload>>();
/** 鍒锋柊琛ㄦ牸 */
function handleRefresh() {
@@ -40,6 +45,18 @@
/** 鍒涘缓鏉ョエ */
function handleCreate() {
formModalApi.setData({ formType: 'create' }).open();
+}
+
+/** 璇嗗埆褰曞叆 */
+function handleOcrCreate() {
+ ocrUploadRef.value?.open();
+}
+
+/** OCR 璇嗗埆缁撴灉纭 */
+function handleOcrSuccess(data: ErpPurchaseInvoiceAiApi.OcrResultVO) {
+ nextTick(() => {
+ formModalApi.setData({ formType: 'create', ocrData: data } as any).open();
+ });
}
/** 缂栬緫鏉ョエ */
@@ -101,6 +118,7 @@
<template>
<Page auto-content-height>
<FormModal @success="handleRefresh" />
+ <OcrUpload ref="ocrUploadRef" @success="handleOcrSuccess" />
<Grid table-title="鏉ョエ鍒楄〃">
<template #toolbar-tools>
<TableAction
@@ -113,6 +131,12 @@
onClick: handleCreate,
},
{
+ label: '璇嗗埆褰曞叆',
+ type: 'primary',
+ auth: ['erp:purchase-invoice:create'],
+ onClick: handleOcrCreate,
+ },
+ {
label: $t('ui.actionTitle.export'),
type: 'primary',
icon: ACTION_ICON.DOWNLOAD,
diff --git a/src/views/erp/purchase/invoice/modules/form.vue b/src/views/erp/purchase/invoice/modules/form.vue
index 74d0462..bf0e621 100644
--- a/src/views/erp/purchase/invoice/modules/form.vue
+++ b/src/views/erp/purchase/invoice/modules/form.vue
@@ -2,12 +2,13 @@
import type { FormType } from '../data';
import type { ErpPurchaseInvoiceApi } from '#/api/erp/purchase/invoice';
+import type { ErpPurchaseInvoiceAiApi } from '#/api/erp/purchase/invoice/ai';
import { computed, ref } from 'vue';
import { useVbenModal } from '@vben/common-ui';
-import { message } from 'ant-design-vue';
+import { Alert, message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
@@ -22,6 +23,7 @@
const emit = defineEmits(['success']);
const formData = ref<ErpPurchaseInvoiceApi.PurchaseInvoice>();
const formType = ref<FormType>('create');
+const ocrSupplierName = ref('');
const getTitle = computed(() => {
if (formType.value === 'create') {
@@ -71,13 +73,30 @@
async onOpenChange(isOpen: boolean) {
if (!isOpen) {
formData.value = undefined;
+ ocrSupplierName.value = '';
return;
}
- const data = modalApi.getData<{ formType: FormType; id?: number }>();
+ const data = modalApi.getData<{
+ formType: FormType;
+ id?: number;
+ ocrData?: ErpPurchaseInvoiceAiApi.OcrResultVO;
+ }>();
formType.value = data.formType;
formApi.setDisabled(formType.value === 'detail');
formApi.updateSchema(useFormSchema());
await formApi.resetForm();
+ // OCR 璇嗗埆鏁版嵁棰勫~锛堟柊寤哄満鏅紝缂栬緫/璇︽儏涓嶅鐞嗭級
+ const ocr = data?.ocrData;
+ ocrSupplierName.value = ocr?.supplierName || '';
+ if (ocr) {
+ await formApi.setValues({
+ invoiceNo: ocr.invoiceNo,
+ invoiceTitle: ocr.invoiceTitle,
+ price: ocr.price,
+ invoiceTime: ocr.invoiceTime,
+ remark: ocr.remark,
+ });
+ }
if (!data || !data.id) {
return;
}
@@ -105,6 +124,14 @@
<template>
<Modal :title="getTitle" class="w-3/5">
+ <Alert
+ v-if="ocrSupplierName"
+ type="info"
+ show-icon
+ class="mx-4 mb-4"
+ message="璇嗗埆鍒伴攢鍞柟鍚嶇О锛岃閫夋嫨閲囪喘璁㈠崟骞舵牳瀵硅鍗曚緵搴斿晢鏄惁涓�鑷�"
+ :description="`璇嗗埆缁撴灉锛�${ocrSupplierName}`"
+ />
<Form class="mx-4" />
</Modal>
</template>
diff --git a/src/views/erp/purchase/invoice/modules/ocr-upload.vue b/src/views/erp/purchase/invoice/modules/ocr-upload.vue
new file mode 100644
index 0000000..214c7e9
--- /dev/null
+++ b/src/views/erp/purchase/invoice/modules/ocr-upload.vue
@@ -0,0 +1,186 @@
+<script lang="ts" setup>
+import type { ErpPurchaseInvoiceAiApi } from '#/api/erp/purchase/invoice/ai';
+
+import { ref } from 'vue';
+
+import { IconifyIcon } from '@vben/icons';
+
+import {
+ Alert,
+ Button,
+ Descriptions,
+ Modal,
+ Tag,
+ Upload,
+ message,
+} from 'ant-design-vue';
+
+import { ocrPurchaseInvoice } from '#/api/erp/purchase/invoice/ai';
+import { uploadFile } from '#/api/system/storage';
+
+defineOptions({ name: 'ErpPurchaseInvoiceOcrUpload' });
+
+const emit = defineEmits<{
+ success: [data: ErpPurchaseInvoiceAiApi.OcrResultVO];
+}>();
+
+const open = ref(false);
+const step = ref(0); // 0=upload, 1=processing, 2=preview
+const fileList = ref<any[]>([]);
+const ocrResult = ref<ErpPurchaseInvoiceAiApi.OcrResultVO>();
+const processingTip = ref('');
+
+function handleOpen() {
+ open.value = true;
+ step.value = 0;
+ fileList.value = [];
+ ocrResult.value = undefined;
+}
+
+/** 鑷畾涔変笂浼� */
+async function handleUpload(options: any) {
+ const { file, onSuccess, onError } = options;
+ try {
+ const result = await uploadFile([file as File]);
+ const blobId = result?.[0]?.id;
+ if (!blobId) {
+ onError(new Error('涓婁紶澶辫触'));
+ return;
+ }
+ onSuccess({ blobId }, file);
+ // 寮�濮� OCR 璇嗗埆
+ step.value = 1;
+ processingTip.value = '姝e湪杩涜鍙戠エ璇嗗埆锛岃绋嶅��...';
+ const ocrData = await ocrPurchaseInvoice(blobId);
+ // 妫�鏌ラ敊璇細rawText 涓洪敊璇彁绀猴紝涓斾笟鍔″瓧娈靛叏绌�
+ const hasData = !!(
+ ocrData.invoiceNo ||
+ ocrData.invoiceTitle ||
+ ocrData.supplierName ||
+ ocrData.price != null ||
+ ocrData.invoiceTime ||
+ ocrData.invoiceType ||
+ ocrData.taxRate != null ||
+ ocrData.remark
+ );
+ if (ocrData.rawText && !hasData) {
+ processingTip.value = '';
+ step.value = 0;
+ message.error(ocrData.rawText);
+ return;
+ }
+ if (!hasData) {
+ processingTip.value = '';
+ step.value = 0;
+ message.warning('鏈瘑鍒埌鍙戠エ鏁版嵁锛岃鎵嬪姩褰曞叆鎴栭噸鏂颁笂浼�');
+ return;
+ }
+ ocrResult.value = ocrData;
+ step.value = 2;
+ } catch {
+ processingTip.value = '';
+ step.value = 0;
+ onError(new Error('涓婁紶澶辫触'));
+ }
+}
+
+function handleConfirm() {
+ if (ocrResult.value) {
+ emit('success', ocrResult.value);
+ }
+ handleClose();
+}
+
+function handleReset() {
+ step.value = 0;
+ fileList.value = [];
+ ocrResult.value = undefined;
+ processingTip.value = '';
+}
+
+function handleClose() {
+ open.value = false;
+ handleReset();
+}
+
+defineExpose({ open: handleOpen });
+</script>
+
+<template>
+ <Modal
+ v-model:open="open"
+ title="璇嗗埆褰曞叆鍙戠エ"
+ width="680px"
+ :footer="null"
+ @cancel="handleClose"
+ >
+ <!-- Step 0: 涓婁紶 -->
+ <template v-if="step === 0">
+ <Upload.Dragger
+ v-model:file-list="fileList"
+ :max-count="1"
+ :custom-request="handleUpload"
+ accept=".pdf,.png,.jpg,.jpeg"
+ @remove="handleReset"
+ >
+ <p class="text-4xl text-gray-400">
+ <IconifyIcon icon="ant-design:inbox-outlined" />
+ </p>
+ <p class="text-base text-gray-500">鐐瑰嚮鎴栨嫋鎷藉彂绁ㄦ枃浠跺埌姝ゅ尯鍩熶笂浼�</p>
+ <p class="text-sm text-gray-400">鏀寔 PDF銆丣PG銆丳NG 鏍煎紡锛屼笌鏉ョエ闄勪欢涓婁紶鏍煎紡涓�鑷�</p>
+ </Upload.Dragger>
+ </template>
+
+ <!-- Step 1: 璇嗗埆涓� -->
+ <template v-else-if="step === 1">
+ <div class="flex flex-col items-center gap-4 py-12">
+ <a-spin size="large" />
+ <span class="text-base text-gray-500">{{ processingTip }}</span>
+ </div>
+ </template>
+
+ <!-- Step 2: 棰勮缁撴灉 -->
+ <template v-else-if="step === 2 && ocrResult">
+ <div class="mb-4">
+ <Descriptions :column="2" bordered size="small">
+ <Descriptions.Item v-if="ocrResult.invoiceNo" label="鍙戠エ鍙风爜">
+ {{ ocrResult.invoiceNo }}
+ </Descriptions.Item>
+ <Descriptions.Item v-if="ocrResult.invoiceType" label="鍙戠エ绫诲瀷">
+ {{ ocrResult.invoiceType }}
+ </Descriptions.Item>
+ <Descriptions.Item v-if="ocrResult.invoiceTitle" label="鍙戠エ鎶ご">
+ {{ ocrResult.invoiceTitle }}
+ </Descriptions.Item>
+ <Descriptions.Item v-if="ocrResult.supplierName" label="閿�鍞柟锛堥渶鏍稿锛�">
+ <Tag color="orange">{{ ocrResult.supplierName }}</Tag>
+ </Descriptions.Item>
+ <Descriptions.Item v-if="ocrResult.price != null" label="鏉ョエ閲戦锛堝厓锛�">
+ {{ ocrResult.price }}
+ </Descriptions.Item>
+ <Descriptions.Item v-if="ocrResult.invoiceTime" label="寮�绁ㄦ棩鏈�">
+ {{ ocrResult.invoiceTime }}
+ </Descriptions.Item>
+ <Descriptions.Item v-if="ocrResult.taxRate != null" label="绋庣巼(%)">
+ {{ ocrResult.taxRate }}
+ </Descriptions.Item>
+ <Descriptions.Item v-if="ocrResult.remark" label="澶囨敞" :span="2">
+ {{ ocrResult.remark }}
+ </Descriptions.Item>
+ </Descriptions>
+ </div>
+
+ <Alert
+ message="閿�鍞柟鍚嶇О浠呬负鍙戠エ鏂囨湰璇嗗埆缁撴灉锛岃鍦ㄨ〃鍗曚腑閫夋嫨閲囪喘璁㈠崟鍚庢牳瀵硅鍗曚緵搴斿晢涓庨攢鍞柟鏄惁涓�鑷达紱璇嗗埆鏁版嵁鍙墜鍔ㄤ慨鏀广��"
+ type="info"
+ show-icon
+ class="mb-4"
+ />
+
+ <div class="flex justify-end gap-2">
+ <Button @click="handleReset">閲嶆柊涓婁紶</Button>
+ <Button type="primary" @click="handleConfirm">纭骞跺~鍏ヨ〃鍗�</Button>
+ </div>
+ </template>
+ </Modal>
+</template>
--
Gitblit v1.9.3