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;
}
}
}