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 实现类
|
*
|
* <p>
|
* 支持识别发票图片 / PDF / 文本文件,提取发票号码、发票代码、发票类型、购买方、销售方、
|
* 价税合计、税额、开票日期等结构化信息,供前端自动回填报销明细。
|
* 图片走多模态视觉识别,PDF / 文本先抽取文本再走纯文本识别。
|
*/
|
@Slf4j
|
@Service
|
public class FinanceReimbursementAiServiceImpl implements FinanceReimbursementAiService {
|
|
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");
|
|
/** 单个文件最大 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<FinanceReimbursementOcrRespVO> ocrInvoices(FinanceReimbursementOcrReqVO reqVO) {
|
List<FinanceReimbursementOcrRespVO> 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<String, Object> 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;
|
}
|
}
|
|
}
|