package cn.iocoder.yudao.module.erp.service.purchase.ai; import cn.hutool.core.io.FileUtil; import cn.hutool.core.util.StrUtil; import cn.hutool.json.JSONUtil; import cn.iocoder.yudao.module.ai.api.chat.AiChatApi; import cn.iocoder.yudao.module.erp.controller.admin.purchase.ai.vo.ErpPurchaseInvoiceOcrReqVO; import cn.iocoder.yudao.module.erp.controller.admin.purchase.ai.vo.ErpPurchaseInvoiceOcrRespVO; import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO; import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.apache.pdfbox.Loader; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.text.PDFTextStripper; import org.springframework.stereotype.Service; import java.io.File; import java.io.IOException; import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.time.LocalDate; import java.util.Base64; import java.util.Map; import java.util.Set; /** * ERP 采购来票 AI OCR Service 实现类 * *

* 支持通过 AI 识别发票图片 / PDF / 文本文件,提取发票号码、抬头、金额、开票日期等结构化信息, * 供前端预填"采购来票"新增表单。图片走多模态视觉识别,其余格式先抽取文本再走纯文本识别。 */ @Slf4j @Service public class ErpPurchaseInvoiceAiServiceImpl implements ErpPurchaseInvoiceAiService { private static final Set SUPPORTED_EXTENSIONS = Set.of( "pdf", "png", "jpg", "jpeg", "gif", "bmp", "txt", "csv" ); private static final Set IMAGE_EXTENSIONS = Set.of("png", "jpg", "jpeg", "gif", "bmp"); private static final String SYSTEM_PROMPT = """ 你是一位专业的发票信息提取专家。请从发票文件中提取结构化信息。 ## 提取规则 1. 仔细识别发票中的信息:发票类型、发票号码、购买方名称、销售方名称、价税合计金额、开票日期、税率、备注等 2. 发票号码为发票上"发票号码"栏的数字,原样返回(不要去掉前导 0) 3. invoiceTitle 为"购买方"名称(发票抬头) 4. price 为"价税合计(小写)"金额,单位为元,保留两位小数 5. 日期统一转换为 yyyy-MM-dd 格式 6. 数字字段仅保留数字,去掉货币符号和千分位逗号 7. 税率(如 13%、9%、6%)如发票中未明确标注,不填 ## 输出格式 请按以下JSON格式返回(不要包含其他内容): {"invoiceNo": "02564178", "invoiceTitle": "购买方名称", "supplierName": "销售方名称", "price": 9000.00, "invoiceTime": "2026-08-01", "invoiceType": "增值税专用发票", "taxRate": 13.0, "remark": "备注信息"} 所有字段均为可选,未提取到的字段不要返回。 如果文件内容无法识别为发票,返回空JSON:{} """; private static final String SYSTEM_PROMPT_IMAGE = """ 你是一位专业的发票OCR识别专家。请从发票图片中提取结构化信息。 ## 提取规则 1. 仔细识别图片中的发票文字信息 2. 提取发票相关信息:发票类型、发票号码、购买方名称、销售方名称、价税合计金额、开票日期、税率、备注等 3. 发票号码为发票上"发票号码"栏的数字,原样返回(不要去掉前导 0) 4. invoiceTitle 为"购买方"名称(发票抬头) 5. price 为"价税合计(小写)"金额,单位为元,保留两位小数 6. 日期统一转换为 yyyy-MM-dd 格式 7. 税率如发票中未明确标注,不填 ## 输出格式 请按以下JSON格式返回(不要包含其他内容): {"invoiceNo": "02564178", "invoiceTitle": "购买方名称", "supplierName": "销售方名称", "price": 9000.00, "invoiceTime": "2026-08-01", "invoiceType": "增值税专用发票", "taxRate": 13.0, "remark": "备注信息"} 所有字段均为可选,未提取到的字段不要返回。 如果图片内容无法识别为发票,返回空JSON:{} """; @Resource private SystemStorageBlobService blobService; @Resource private AiChatApi aiChatApi; @Override public ErpPurchaseInvoiceOcrRespVO ocrInvoice(ErpPurchaseInvoiceOcrReqVO reqVO) { File tempFile = null; try { // 1. 下载文件 SystemStorageBlobDO blob = blobService.getStorageBlob(reqVO.getBlobId()); if (blob == null) { return errorResp("文件不存在"); } // 2. 校验文件类型 String ext = StrUtil.isBlank(blob.getOriginalFilename()) ? "" : FileUtil.extName(blob.getOriginalFilename()).toLowerCase(); if (!SUPPORTED_EXTENSIONS.contains(ext)) { return errorResp("不支持的文件类型,请上传 PDF/图片文件"); } // 3. 获取文件字节 tempFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey()); if (tempFile == null || !tempFile.exists()) { return errorResp("文件读取失败"); } byte[] fileBytes = Files.readAllBytes(tempFile.toPath()); if (fileBytes.length == 0) { return errorResp("文件内容为空,无法识别"); } // 4. AI 识别(图片走多模态,文本走纯文本) boolean isImage = IMAGE_EXTENSIONS.contains(ext); String systemPrompt = isImage ? SYSTEM_PROMPT_IMAGE : SYSTEM_PROMPT; String aiResponse; if (isImage) { String imageBase64 = Base64.getEncoder().encodeToString(fileBytes); String mimeType = getMimeType(ext); aiResponse = aiChatApi.chatWithImage(systemPrompt, "请识别图片中的发票信息", imageBase64, mimeType); } else { String extractedText = extractTextByType(ext, fileBytes); if (StrUtil.isEmpty(extractedText)) { return errorResp("文件内容为空,无法识别"); } aiResponse = aiChatApi.chat(systemPrompt, extractedText); } log.info("AI OCR 识别发票结果: blobId={}, file={}, response={}", reqVO.getBlobId(), blob.getOriginalFilename(), aiResponse); // 5. 解析响应 return parseOcrResponse(aiResponse); } catch (Exception e) { log.warn("AI OCR 识别发票失败,blobId={}", reqVO.getBlobId(), e); return errorResp("AI 识别暂时不可用,请手动录入"); } finally { if (tempFile != null && tempFile.exists()) { tempFile.delete(); } } } private ErpPurchaseInvoiceOcrRespVO errorResp(String message) { ErpPurchaseInvoiceOcrRespVO error = new ErpPurchaseInvoiceOcrRespVO(); error.setRawText(message); return error; } private String extractTextByType(String ext, byte[] bytes) throws IOException { return switch (ext) { case "pdf" -> extractPdfText(bytes); case "txt", "csv" -> new String(bytes, StandardCharsets.UTF_8); default -> ""; }; } private String extractPdfText(byte[] bytes) throws IOException { try (PDDocument doc = Loader.loadPDF(bytes)) { PDFTextStripper stripper = new PDFTextStripper(); stripper.setSortByPosition(true); String text = stripper.getText(doc); return StrUtil.isNotBlank(text) ? text.trim() : ""; } } private String getMimeType(String ext) { return switch (ext) { case "jpg", "jpeg" -> "image/jpeg"; case "gif" -> "image/gif"; case "bmp" -> "image/bmp"; default -> "image/png"; }; } private ErpPurchaseInvoiceOcrRespVO parseOcrResponse(String aiResponse) { ErpPurchaseInvoiceOcrRespVO respVO = new ErpPurchaseInvoiceOcrRespVO(); try { String json = aiResponse; int start = json.indexOf("{"); int end = json.lastIndexOf("}"); if (start >= 0 && end > start) { json = json.substring(start, end + 1); } Map parsed = JSONUtil.toBean(json, Map.class); respVO.setInvoiceNo((String) parsed.get("invoiceNo")); respVO.setInvoiceTitle((String) parsed.get("invoiceTitle")); respVO.setSupplierName((String) parsed.get("supplierName")); respVO.setInvoiceType((String) parsed.get("invoiceType")); respVO.setRemark((String) parsed.get("remark")); respVO.setPrice(toBigDecimal(parsed.get("price"))); respVO.setTaxRate(toBigDecimal(parsed.get("taxRate"))); Object it = parsed.get("invoiceTime"); if (it instanceof String s && StrUtil.isNotBlank(s)) { respVO.setInvoiceTime(parseDate(s)); } } catch (Exception e) { log.warn("解析 AI OCR 响应失败: {}", aiResponse, e); respVO.setRawText(aiResponse); } return respVO; } /** * 将 AI 返回的数字(可能是 Number 或数字字符串)转换为 BigDecimal */ private BigDecimal toBigDecimal(Object value) { if (value == null) { return null; } if (value instanceof Number n) { return BigDecimal.valueOf(n.doubleValue()); } if (value instanceof String s) { s = s.replace(",", "").trim(); if (StrUtil.isBlank(s)) { return null; } try { return new BigDecimal(s); } catch (NumberFormatException ignored) { return null; } } return null; } /** * 解析日期,兼容 yyyy-MM-dd、yyyy/MM/dd、yyyy年MM月dd日 等常见格式 */ private LocalDate parseDate(String value) { String s = value.trim() .replace("年", "-") .replace("月", "-") .replace("日", "") .replace("/", "-"); try { return LocalDate.parse(s); } catch (Exception ignored) { return null; } } }