liu
3 天以前 0496b0e8078775203d51d62765766c5db3ebeb71
feat(erp): 采购来票新增发票OCR识别录入功能

- 新增 AI 识别录入流程:列表页「识别录入」按钮 + 上传弹窗 + 识别预览 + 表单预填
- 新增 src/api/erp/purchase/invoice/ai/index.ts、modules/ocr-upload.vue
- 来票 form.vue 支持 OCR 结果预填并提示核对销售方
- CRM 报价单同步统一文案(识别录入),去掉界面AI字眼

Co-Authored-By: Claude <noreply@anthropic.com>
已添加2个文件
已修改6个文件
288 ■■■■■ 文件已修改
src/api/crm/saleQuotation/ai/index.ts 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/erp/purchase/invoice/ai/index.ts 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/index.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/form.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/crm/saleQuotation/modules/ocr-upload.vue 4 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/index.vue 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/modules/form.vue 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/erp/purchase/invoice/modules/ocr-upload.vue 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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',
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 },
  );
}
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,
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">
src/views/crm/saleQuotation/modules/ocr-upload.vue
@@ -49,7 +49,7 @@
    onSuccess({ blobId }, file);
    // å¼€å§‹ OCR è¯†åˆ«
    step.value = 1;
    processingTip.value = '正在进行 AI OCR è¯†åˆ«ï¼Œè¯·ç¨å€™...';
    processingTip.value = '正在进行报价单识别,请稍候...';
    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
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,
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>
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 = '正在进行发票识别,请稍候...';
    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、JPG、PNG æ ¼å¼ï¼Œä¸Žæ¥ç¥¨é™„件上传格式一致</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>