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