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 实现类
|
*
|
* <p>
|
* 支持通过 AI 识别发票图片 / PDF / 文本文件,提取发票号码、抬头、金额、开票日期等结构化信息,
|
* 供前端预填"采购来票"新增表单。图片走多模态视觉识别,其余格式先抽取文本再走纯文本识别。
|
*/
|
@Slf4j
|
@Service
|
public class ErpPurchaseInvoiceAiServiceImpl implements ErpPurchaseInvoiceAiService {
|
|
private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(
|
"pdf", "png", "jpg", "jpeg", "gif", "bmp", "txt", "csv"
|
);
|
|
private static final Set<String> 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<String, Object> 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;
|
}
|
}
|
|
}
|