package cn.iocoder.yudao.module.finance.service.reimbursement.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.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrReqVO; import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrRespVO; 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.ArrayList; import java.util.Base64; import java.util.List; import java.util.Map; import java.util.Set; /** * 财务报销单 AI OCR Service 实现类 * *

* 支持识别发票图片 / PDF / 文本文件,提取发票号码、发票代码、发票类型、购买方、销售方、 * 价税合计、税额、开票日期等结构化信息,供前端自动回填报销明细。 * 图片走多模态视觉识别,PDF / 文本先抽取文本再走纯文本识别。 */ @Slf4j @Service public class FinanceReimbursementAiServiceImpl implements FinanceReimbursementAiService { 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"); /** 单个文件最大 20MB,避免超大文件拖垮识别服务 */ private static final long MAX_FILE_SIZE = 20L * 1024 * 1024; private static final String SYSTEM_PROMPT = """ 你是一位专业的中国发票信息提取专家。请从发票文件中提取结构化信息。 ## 提取规则 1. invoiceNo 为发票号码(数电票为 20 位号码),原样返回,保留前导 0 2. invoiceCode 为发票代码;数电票、电子发票没有发票代码时不要返回 3. invoiceType 为发票类型,如:增值税专用发票 / 增值税普通发票 / 增值税电子普通发票 / 电子发票(普通发票)/ 收据 / 火车票 / 其他 4. invoiceTitle 为购买方名称(发票抬头) 5. sellerName 为销售方名称(开票方),取"销售方"或"销方"信息 6. invoiceAmount 为价税合计(小写)金额,单位元,保留两位小数 7. taxAmount 为税额合计,单位元,保留两位小数;发票未标注税额时不返回 8. invoiceDate 统一转换为 yyyy-MM-dd 格式(兼容 2026年08月01日、2026/08/01 等写法) 9. 数字字段仅保留数字,去掉货币符号(¥、¥)和千分位逗号 10. remark 为发票备注栏内容,没有则不返回 ## 输出格式 请严格按以下 JSON 格式返回(不要包含 Markdown 代码块或其它内容): {"invoiceNo": "02564178", "invoiceCode": "044001900111", "invoiceType": "增值税电子普通发票", "invoiceTitle": "江苏商贸职业学院", "sellerName": "XX科技有限公司", "invoiceAmount": 500.00, "taxAmount": 57.52, "invoiceDate": "2026-08-01", "remark": "备注信息"} 所有字段均为可选,未提取到的字段不要返回。 如果文件内容无法识别为发票,返回空 JSON:{} """; private static final String SYSTEM_PROMPT_IMAGE = """ 你是一位专业的中国发票 OCR 识别专家。请从发票图片中提取结构化信息。 ## 提取规则 1. 仔细识别图片中的发票文字,注意发票可能倾斜、有印章遮挡或为拍照件 2. invoiceNo 为发票号码(数电票为 20 位号码),原样返回,保留前导 0 3. invoiceCode 为发票代码;数电票、电子发票没有发票代码时不要返回 4. invoiceType 为发票类型,如:增值税专用发票 / 增值税普通发票 / 增值税电子普通发票 / 电子发票(普通发票)/ 收据 / 火车票 / 其他 5. invoiceTitle 为购买方名称(发票抬头) 6. sellerName 为销售方名称(开票方),取"销售方"或"销方"信息 7. invoiceAmount 为价税合计(小写)金额,单位元,保留两位小数 8. taxAmount 为税额合计,单位元,保留两位小数;发票未标注税额时不返回 9. invoiceDate 统一转换为 yyyy-MM-dd 格式 10. 数字字段仅保留数字,去掉货币符号(¥、¥)和千分位逗号 11. remark 为发票备注栏内容,没有则不返回 ## 输出格式 请严格按以下 JSON 格式返回(不要包含 Markdown 代码块或其它内容): {"invoiceNo": "02564178", "invoiceCode": "044001900111", "invoiceType": "增值税电子普通发票", "invoiceTitle": "江苏商贸职业学院", "sellerName": "XX科技有限公司", "invoiceAmount": 500.00, "taxAmount": 57.52, "invoiceDate": "2026-08-01", "remark": "备注信息"} 所有字段均为可选,未提取到的字段不要返回。 如果图片内容无法识别为发票,返回空 JSON:{} """; @Resource private SystemStorageBlobService blobService; @Resource private AiChatApi aiChatApi; @Override public List ocrInvoices(FinanceReimbursementOcrReqVO reqVO) { List results = new ArrayList<>(reqVO.getBlobIds().size()); for (Long blobId : reqVO.getBlobIds()) { FinanceReimbursementOcrRespVO result = new FinanceReimbursementOcrRespVO(); result.setBlobId(blobId); try { ocrSingleInvoice(blobId, result); } catch (Exception e) { log.warn("AI OCR 识别报销发票失败,blobId={}", blobId, e); result.setRawText("AI 识别暂时不可用,请手动录入"); } results.add(result); } return results; } private void ocrSingleInvoice(Long blobId, FinanceReimbursementOcrRespVO result) throws IOException { // 1. 获取文件 SystemStorageBlobDO blob = blobService.getStorageBlob(blobId); if (blob == null) { result.setRawText("发票文件不存在或已被删除,请重新上传"); return; } // 2. 校验文件类型 String ext = StrUtil.isBlank(blob.getOriginalFilename()) ? "" : FileUtil.extName(blob.getOriginalFilename()).toLowerCase(); if (!SUPPORTED_EXTENSIONS.contains(ext)) { result.setRawText("不支持的文件类型,请上传 PDF 或图片文件"); return; } if (blob.getByteSize() != null && blob.getByteSize() > MAX_FILE_SIZE) { result.setRawText("文件超过 20MB,请压缩后重新上传"); return; } // 3. 读取文件字节(磁盘上真实存储的 blob,不能删除) File blobFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey()); if (blobFile == null || !blobFile.exists()) { result.setRawText("文件读取失败,请重新上传"); return; } byte[] fileBytes = Files.readAllBytes(blobFile.toPath()); if (fileBytes.length == 0) { result.setRawText("文件内容为空,无法识别"); return; } // 4. AI 识别(图片走多模态,文本走纯文本) boolean isImage = IMAGE_EXTENSIONS.contains(ext); String aiResponse; if (isImage) { String imageBase64 = Base64.getEncoder().encodeToString(fileBytes); aiResponse = aiChatApi.chatWithImage(SYSTEM_PROMPT_IMAGE, "请识别图片中的发票信息", imageBase64, getMimeType(ext)); } else { String extractedText = extractTextByType(ext, fileBytes); if (StrUtil.isEmpty(extractedText)) { result.setRawText("文件内容为空或为扫描件,无法识别,请上传发票图片"); return; } aiResponse = aiChatApi.chat(SYSTEM_PROMPT, extractedText); } log.info("AI OCR 识别报销发票结果: blobId={}, file={}, response={}", blobId, blob.getOriginalFilename(), aiResponse); // 5. 解析响应 parseOcrResponse(aiResponse, result); } 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 void parseOcrResponse(String aiResponse, FinanceReimbursementOcrRespVO result) { if (StrUtil.isBlank(aiResponse)) { result.setRawText("未识别到发票内容,请手动录入或重新上传"); return; } 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); result.setInvoiceNo(toStr(parsed.get("invoiceNo"))); result.setInvoiceCode(toStr(parsed.get("invoiceCode"))); result.setInvoiceType(toStr(parsed.get("invoiceType"))); result.setInvoiceTitle(toStr(parsed.get("invoiceTitle"))); result.setSellerName(toStr(parsed.get("sellerName"))); result.setRemark(toStr(parsed.get("remark"))); result.setInvoiceAmount(toBigDecimal(parsed.get("invoiceAmount"))); result.setTaxAmount(toBigDecimal(parsed.get("taxAmount"))); String invoiceDate = toStr(parsed.get("invoiceDate")); if (StrUtil.isNotBlank(invoiceDate)) { result.setInvoiceDate(parseDate(invoiceDate)); } if (!hasRecognizedField(result)) { result.setRawText("未识别到发票内容,请手动录入或重新上传"); } } catch (Exception e) { log.warn("解析 AI OCR 响应失败: {}", aiResponse, e); result.setRawText("识别结果解析失败,请手动录入"); } } /** * 判断是否识别出任何有效业务字段 */ private boolean hasRecognizedField(FinanceReimbursementOcrRespVO result) { return StrUtil.isNotBlank(result.getInvoiceNo()) || StrUtil.isNotBlank(result.getInvoiceCode()) || StrUtil.isNotBlank(result.getInvoiceType()) || StrUtil.isNotBlank(result.getInvoiceTitle()) || StrUtil.isNotBlank(result.getSellerName()) || StrUtil.isNotBlank(result.getRemark()) || result.getInvoiceAmount() != null || result.getTaxAmount() != null || result.getInvoiceDate() != null; } private String toStr(Object value) { return value == null ? null : StrUtil.trimToNull(String.valueOf(value)); } /** * 将 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(",", "").replace("¥", "").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; } } }