采购需求去掉横向滚动问题

This commit is contained in:
2026-06-26 13:29:09 +08:00
parent c6a3b6723f
commit 046f4c5e1b
6 changed files with 327 additions and 83 deletions

View File

@@ -1,6 +1,10 @@
package com.ruoyi.hrm.service.impl;
import cn.hutool.core.io.IoUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.DecodeHintType;
import com.google.zxing.MultiFormatReader;
@@ -26,22 +30,34 @@ import org.springframework.stereotype.Service;
import javax.annotation.PostConstruct;
import java.awt.image.BufferedImage;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.EnumMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 发票识别服务实现:本地三段式管线,无任何外部 API 调用
* 单据识别服务实现:按附件类型分流
*
* <p><b>图片单据</b>jpg/png 等,可不是规范发票):直接调用小米 MiMo 多模态大模型识别,
* 返回标题 + 金额(复用 application.yml 中 mimo.* 配置)。
*
* <p><b>PDF 电子发票</b>:本地三段式管线,无外部 API
* <ol>
* <li>PDFBox 文本层抽取:原生电子发票直接搞定(毫秒级,几乎零算力)</li>
* <li>ZXing 二维码识别:拍照/扫描发票 PDF从二维码读结构化字段</li>
@@ -57,11 +73,28 @@ import java.util.regex.Pattern;
public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
private final SysOssMapper sysOssMapper;
private final ObjectMapper jsonMapper = new ObjectMapper();
/** 可通过 application.yml 覆盖;默认 jar 同级目录下的 tessdata */
@Value("${fad.ocr.tessdata-path:}")
private String tessdataPathConfig;
// === 小米 MiMo 多模态大模型(识别图片单据,复用 application.yml 里的 mimo.* 配置)===
@Value("${mimo.base-url:https://api.xiaomimimo.com/v1}")
private String mimoBaseUrl;
@Value("${mimo.api-key:}")
private String mimoApiKey;
@Value("${mimo.model:mimo-v2.5}")
private String mimoModel;
@Value("${mimo.max-tokens:8192}")
private Integer mimoMaxTokens;
@Value("${mimo.timeout:180}")
private Integer mimoTimeout;
/** 走多模态识别的图片后缀 */
private static final Set<String> IMAGE_SUFFIXES =
new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "webp", "bmp", "gif"));
private String tessdataPath;
@PostConstruct
@@ -108,9 +141,6 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
throw new ServiceException("附件不存在: " + ossId);
}
String suffix = StringUtils.defaultIfBlank(oss.getFileSuffix(), "").toLowerCase().replace(".", "");
if (!"pdf".equals(suffix)) {
throw new ServiceException("仅支持 PDF 电子发票,当前文件类型: " + suffix);
}
byte[] fileBytes;
try (InputStream in = OssFactory.instance().getObjectContent(oss.getUrl())) {
@@ -119,6 +149,15 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
throw new ServiceException("读取附件失败: " + e.getMessage());
}
// 图片单据:直接走小米多模态大模型识别(可识别非发票单据)
if (IMAGE_SUFFIXES.contains(suffix)) {
log.info("[Invoice] 图片单据走多模态识别, suffix={}", suffix);
return recognizeImageByMiMo(fileBytes, suffix);
}
if (!"pdf".equals(suffix)) {
throw new ServiceException("仅支持 PDF 电子发票或图片单据(jpg/png),当前文件类型: " + suffix);
}
try (PDDocument doc = PDDocument.load(new ByteArrayInputStream(fileBytes))) {
// 第一步:文本层
String text = extractText(doc);
@@ -342,4 +381,149 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
return null;
}
}
// ==================== 图片单据:小米多模态识别 ====================
private static final String VISION_SYSTEM_PROMPT =
"你是财务单据识别助手。用户上传的是报销/拨款单据图片,可能是增值税发票、收据、出租车/火车票、"
+ "餐饮小票、合同、对账单等,不一定是规范发票。请仔细识别图片中的费用条目与金额。";
private static final String VISION_USER_PROMPT =
"请识别这张单据,只输出严格的 JSON不要任何解释文字、不要 markdown 代码块),格式:"
+ "{\"items\":[{\"title\":\"简洁的费用标题,如 滴滴出行-市内交通 / 餐饮-海底捞 / XX酒店住宿\",\"amount\":数字}],"
+ "\"totalAmount\":数字}。amount 与 totalAmount 均为纯数字(不带货币符号/逗号);"
+ "识别不到金额则填 0一般一张单据对应一条 items若单据内含多笔明细可拆成多条。";
/** 图片 → 多模态识别 → 结构化结果 */
private HrmInvoiceOcrResultVo recognizeImageByMiMo(byte[] bytes, String suffix) {
if (StringUtils.isBlank(mimoApiKey)) {
throw new ServiceException("未配置 AI 识别服务mimo.api-key无法识别图片单据");
}
String mime = "jpg".equals(suffix) ? "jpeg" : suffix;
String dataUri = "data:image/" + mime + ";base64,"
+ java.util.Base64.getEncoder().encodeToString(bytes);
String content = callMiMoVision(dataUri);
return parseVisionJson(content);
}
/** 调用小米 MiMo /chat/completionsOpenAI 兼容,多模态、非流式),返回 message.content */
private String callMiMoVision(String imageDataUri) {
ObjectNode body = jsonMapper.createObjectNode();
body.put("model", mimoModel);
body.put("max_completion_tokens", mimoMaxTokens);
body.put("max_tokens", mimoMaxTokens);
body.put("temperature", 0.2);
body.put("stream", false);
ArrayNode messages = body.putArray("messages");
messages.addObject().put("role", "system").put("content", VISION_SYSTEM_PROMPT);
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
ArrayNode contentArr = userMsg.putArray("content");
contentArr.addObject().put("type", "text").put("text", VISION_USER_PROMPT);
ObjectNode img = contentArr.addObject();
img.put("type", "image_url");
img.putObject("image_url").put("url", imageDataUri);
HttpURLConnection conn = null;
try {
URL url = new URL(mimoBaseUrl + "/chat/completions");
conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("POST");
conn.setDoOutput(true);
conn.setConnectTimeout(10_000);
conn.setReadTimeout((mimoTimeout == null ? 180 : mimoTimeout) * 1000);
conn.setRequestProperty("Content-Type", "application/json");
conn.setRequestProperty("api-key", mimoApiKey);
conn.setRequestProperty("Authorization", "Bearer " + mimoApiKey);
try (OutputStream os = conn.getOutputStream()) {
os.write(jsonMapper.writeValueAsString(body).getBytes(StandardCharsets.UTF_8));
}
int code = conn.getResponseCode();
if (code >= 400) {
String err = readStream(conn.getErrorStream());
throw new ServiceException("AI 识别服务返回 " + code + "" + StringUtils.substring(err, 0, 300));
}
JsonNode root = jsonMapper.readTree(readStream(conn.getInputStream()));
JsonNode message = root.path("choices").path(0).path("message");
String content = message.path("content").asText("");
if (StringUtils.isBlank(content)) {
String finish = root.path("choices").path(0).path("finish_reason").asText("");
if ("length".equals(finish)) {
throw new ServiceException("AI 识别输出被截断,请重试或提高 mimo.max-tokens");
}
throw new ServiceException("AI 未返回有效识别内容");
}
return content;
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("[Invoice] 调用 MiMo 多模态识别失败", e);
throw new ServiceException("AI 识别服务调用失败:" + e.getMessage());
} finally {
if (conn != null) conn.disconnect();
}
}
/** 解析模型返回的 JSON容错去掉 markdown 代码块,截取首尾花括号) */
private HrmInvoiceOcrResultVo parseVisionJson(String content) {
HrmInvoiceOcrResultVo vo = new HrmInvoiceOcrResultVo();
vo.setInvoiceType("AI图片识别");
List<HrmInvoiceOcrResultVo.Item> items = new ArrayList<>();
try {
String jsonStr = extractJson(content);
JsonNode root = jsonMapper.readTree(jsonStr);
JsonNode arr = root.path("items");
if (arr.isArray()) {
for (JsonNode n : arr) {
HrmInvoiceOcrResultVo.Item it = new HrmInvoiceOcrResultVo.Item();
it.setItemName(StringUtils.trimToEmpty(n.path("title").asText("")));
it.setAmount(parseBigDecimal(n.path("amount").asText(null)));
items.add(it);
}
}
JsonNode total = root.get("totalAmount");
if (total != null && !total.isNull()) {
vo.setTotalAmount(parseBigDecimal(total.asText(null)));
}
} catch (Exception e) {
log.warn("[Invoice] 解析 AI 识别 JSON 失败,原始内容: {}", StringUtils.substring(content, 0, 300));
}
if (items.isEmpty()) {
HrmInvoiceOcrResultVo.Item it = new HrmInvoiceOcrResultVo.Item();
it.setItemName("");
it.setAmount(vo.getTotalAmount());
items.add(it);
}
// 无总额时用明细汇总
if (vo.getTotalAmount() == null) {
BigDecimal sum = BigDecimal.ZERO;
for (HrmInvoiceOcrResultVo.Item it : items) {
if (it.getAmount() != null) sum = sum.add(it.getAmount());
}
vo.setTotalAmount(sum);
}
vo.setItems(items);
return vo;
}
/** 从可能带 ```json 包裹或前后缀文字的字符串里截取 JSON 主体 */
private static String extractJson(String raw) {
if (raw == null) return "{}";
String s = raw.trim();
int begin = s.indexOf('{');
int end = s.lastIndexOf('}');
return (begin >= 0 && end > begin) ? s.substring(begin, end + 1) : s;
}
private static String readStream(InputStream in) {
if (in == null) return "";
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
StringBuilder sb = new StringBuilder();
String l;
while ((l = r.readLine()) != null) sb.append(l).append('\n');
return sb.toString();
} catch (Exception e) {
return "";
}
}
}