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