推送项目重构代码

This commit is contained in:
2026-05-31 14:19:15 +08:00
parent a28ea44cab
commit dcc66aa4a9
30 changed files with 1112 additions and 1021 deletions

View File

@@ -68,11 +68,6 @@ public class HrmAppropriationReqController extends BaseController {
return R.ok(service.queryList(bo));
}
@GetMapping("/ocr-health")
public R<Boolean> ocrHealth() {
return R.ok(invoiceOcrService.isAlive());
}
@PostMapping("/ocr-by-oss")
public R<HrmInvoiceOcrResultVo> ocrByOss(@RequestParam @NotNull Long ossId) {
return R.ok(invoiceOcrService.recognizeByOssId(ossId));

View File

@@ -68,11 +68,6 @@ public class HrmReimburseReqController extends BaseController {
return R.ok(service.queryList(bo));
}
@GetMapping("/ocr-health")
public R<Boolean> ocrHealth() {
return R.ok(invoiceOcrService.isAlive());
}
@PostMapping("/ocr-by-oss")
public R<HrmInvoiceOcrResultVo> ocrByOss(@RequestParam @NotNull Long ossId) {
return R.ok(invoiceOcrService.recognizeByOssId(ossId));

View File

@@ -0,0 +1,23 @@
package com.ruoyi.hrm.event;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 申请提交后产生的事件:通知监听方(如 IM 推送)给当前待办的审批人发提醒。
* 由 fad-hrm 发布ruoyi-oa 监听后调 ImSendService 推送。
*/
@Getter
@AllArgsConstructor
public class ApprovalRequestedEvent {
/** 业务类型seal / leave / travel / reimburse / appropriation */
private final String bizType;
/** 业务主键 */
private final Long bizId;
/** 流程实例ID */
private final Long instId;
/** 待审批人 OA userId */
private final Long assigneeUserId;
/** 申请发起人 OA userId */
private final Long startUserId;
}

View File

@@ -3,17 +3,10 @@ package com.ruoyi.hrm.service;
import com.ruoyi.hrm.domain.vo.HrmInvoiceOcrResultVo;
/**
* 发票OCR识别服务调用Python OCR微服务
* 发票识别服务:本地解析电子发票 PDF。
*/
public interface IHrmInvoiceOcrService {
/**
* 通过ossId识别发票
*/
/** 根据 ossId 解析发票 PDF */
HrmInvoiceOcrResultVo recognizeByOssId(Long ossId);
/**
* 检查OCR服务是否存活
*/
boolean isAlive();
}

View File

@@ -17,9 +17,11 @@ import com.ruoyi.hrm.domain.bo.HrmFlowInstanceBo;
import com.ruoyi.hrm.domain.vo.HrmFlowInstanceVo;
import com.ruoyi.hrm.domain.vo.HrmFlowTaskVo;
import com.ruoyi.hrm.domain.vo.HrmTravelReqVo;
import com.ruoyi.hrm.event.ApprovalRequestedEvent;
import com.ruoyi.hrm.mapper.*;
import com.ruoyi.hrm.service.IHrmFlowInstanceService;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -40,6 +42,7 @@ public class HrmFlowInstanceServiceImpl implements IHrmFlowInstanceService {
private final UserService userService;
private final HrmFlowCcMapper ccMapper;
private final HrmTravelReqMapper travelReqMapper;
private final ApplicationEventPublisher eventPublisher;
@Override
public HrmFlowInstanceVo queryById(Long instId) {
@@ -84,6 +87,11 @@ public class HrmFlowInstanceServiceImpl implements IHrmFlowInstanceService {
task.setBizId(bo.getBizId());
taskMapper.insert(task);
// 发布事件 → ruoyi-oa 监听后推 IM 通知 + 快捷跳转
eventPublisher.publishEvent(new ApprovalRequestedEvent(
bo.getBizType(), bo.getBizId(), inst.getInstId(),
bo.getManualAssigneeUserId(), bo.getStartUserId()));
return inst.getInstId();
}
@@ -131,6 +139,11 @@ public class HrmFlowInstanceServiceImpl implements IHrmFlowInstanceService {
task.setBizId(bo.getBizId());
taskMapper.insert(task);
// 发布事件 → ruoyi-oa 监听后推 IM 通知 + 快捷跳转
eventPublisher.publishEvent(new ApprovalRequestedEvent(
bo.getBizType(), bo.getBizId(), inst.getInstId(),
assignees.get(0), bo.getStartUserId()));
return inst.getInstId();
}

View File

@@ -1,49 +1,61 @@
package com.ruoyi.hrm.service.impl;
import cn.hutool.core.io.IoUtil;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.hrm.domain.vo.HrmInvoiceOcrResultVo;
import com.ruoyi.hrm.service.IHrmInvoiceOcrService;
import com.ruoyi.oss.factory.OssFactory;
import com.ruoyi.system.mapper.SysOssMapper;
import com.ruoyi.system.domain.vo.SysOssVo;
import com.ruoyi.system.mapper.SysOssMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.http.*;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 发票OCR识别服务实现调用Python OCR微服务
* 发票识别服务实现:直接解析电子发票 PDF 文本,无外部模型依赖。
* 仅支持 PDF电子普通发票 / 电子专用发票 / 全电数电票)。
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
@Value("${fad.ocr.url}")
String ocrUrl;
@Value("${fad.ocr.api-key}")
String apiKey;
private final SysOssMapper sysOssMapper;
/** "价税合计小写¥123.45" 这种小写金额 */
private static final Pattern P_TOTAL = Pattern.compile(
"(?:价税合计|小写)[^0-9¥¥]{0,30}[¥¥]?\\s*([0-9,]+\\.[0-9]{2})");
/** 开票日期2024年01月01日 或 2024-01-01 */
private static final Pattern P_DATE = Pattern.compile(
"开票日期[: ]*([0-9]{4}[年\\-/][0-9]{1,2}[月\\-/][0-9]{1,2}日?)");
/** 发票类型抬头 */
private static final Pattern P_TYPE = Pattern.compile(
"(电子(?:普通)?发票|增值税电子(?:普通|专用)发票|电子发票([^]+|数电(?:普通)?发票|普通发票|专用发票)");
/** 销售方名称:兼顾 "销售方名称xxx"、"销 售 方 名称xxx"、新版"销售方信息名称xxx" */
private static final Pattern P_SELLER = Pattern.compile(
"\\s*售\\s*方[^名]*名\\s*称[: ]*([^\\n\\r]+?)(?=\\s{2,}|纳税人|统一社会|地址|开户|$)");
/** 明细行金额(行末两列:金额 税率% 税额 或 金额 税率% 价税合计) */
private static final Pattern P_LINE_AMOUNT = Pattern.compile(
"([\\u4e00-\\u9fa5A-Za-z0-9()\\-·.\\*\\s]{2,40}?)\\s+" + // 名称
"([0-9,]+\\.[0-9]{2})\\s+" + // 金额(不含税)
"(\\d{1,2}%|免税|不征税|\\*)\\s+" + // 税率
"([0-9,]+\\.[0-9]{2})"); // 税额
@Override
public HrmInvoiceOcrResultVo recognizeByOssId(Long ossId) {
SysOssVo oss = sysOssMapper.selectVoById(ossId);
@@ -51,6 +63,11 @@ 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())) {
fileBytes = IoUtil.readBytes(in);
@@ -58,170 +75,101 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
throw new ServiceException("读取附件失败: " + e.getMessage());
}
String fileName = StringUtils.defaultIfBlank(oss.getOriginalName(), oss.getFileName());
return callOcrService(fileBytes, fileName, oss.getFileSuffix());
return parsePdf(fileBytes);
}
private HrmInvoiceOcrResultVo callOcrService(byte[] fileBytes, String fileName, String fileSuffix) {
if (StringUtils.isBlank(ocrUrl)) {
throw new ServiceException("OCR服务地址未配置请检查 fad.ocr.url");
}
// 推断 content-type
String suffix = StringUtils.defaultIfBlank(fileSuffix, "").toLowerCase().replace(".", "");
String contentType;
switch (suffix) {
case "pdf":
contentType = "application/pdf";
break;
case "png":
contentType = "image/png";
break;
case "jpg":
case "jpeg":
contentType = "image/jpeg";
break;
default:
contentType = "application/octet-stream";
}
// 构建 multipart 请求
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.MULTIPART_FORM_DATA);
if (StringUtils.isNotBlank(apiKey)) {
headers.set("X-API-Key", apiKey);
}
final String finalContentType = contentType;
final String finalFileName = StringUtils.defaultIfBlank(fileName, "invoice" + "." + suffix);
ByteArrayResource fileResource = new ByteArrayResource(fileBytes) {
@Override
public String getFilename() {
return finalFileName;
}
};
HttpHeaders fileHeaders = new HttpHeaders();
fileHeaders.setContentType(MediaType.parseMediaType(finalContentType));
HttpEntity<ByteArrayResource> filePart = new HttpEntity<>(fileResource, fileHeaders);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", filePart);
HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> response;
try {
response = restTemplate.postForEntity(ocrUrl + "/v1/invoice/ocr", requestEntity, String.class);
/** 直接从 PDF 中抽文本并按发票常见版面解析字段 */
private HrmInvoiceOcrResultVo parsePdf(byte[] bytes) {
String text;
try (PDDocument doc = PDDocument.load(new ByteArrayInputStream(bytes))) {
PDFTextStripper stripper = new PDFTextStripper();
stripper.setSortByPosition(true);
stripper.setLineSeparator("\n");
text = stripper.getText(doc);
} catch (Exception e) {
log.error("[OCR] 调用OCR服务失败 url={} error={}", ocrUrl, e.getMessage());
throw new ServiceException("OCR服务调用失败: " + e.getMessage());
log.error("[Invoice] PDF 解析失败", e);
throw new ServiceException("PDF 解析失败: " + e.getMessage()
+ "。若为扫描件,请提供电子发票原始 PDF。");
}
if (StringUtils.isBlank(text)) {
throw new ServiceException("无法从 PDF 提取文本,可能为扫描件,请上传电子发票原始 PDF。");
}
if (!response.getStatusCode().is2xxSuccessful() || response.getBody() == null) {
throw new ServiceException("OCR服务返回异常: " + response.getStatusCode());
}
return parseOcrResponse(response.getBody());
}
private HrmInvoiceOcrResultVo parseOcrResponse(String responseBody) {
HrmInvoiceOcrResultVo result = new HrmInvoiceOcrResultVo();
try {
JSONObject root = JSON.parseObject(responseBody);
JSONObject data = root.getJSONObject("data");
if (data == null) {
return result;
}
result.setInvoiceType(firstGroup(P_TYPE, text));
result.setInvoiceDate(firstGroup(P_DATE, text));
result.setSellerName(cleanSeller(firstGroup(P_SELLER, text)));
result.setTotalAmount(parseBigDecimal(firstGroup(P_TOTAL, text)));
result.setInvoiceType(getFieldValue(data, "invoice_type"));
result.setSellerName(getFieldValue(data, "seller_name"));
result.setInvoiceDate(getFieldValue(data, "invoice_date"));
List<HrmInvoiceOcrResultVo.Item> items = parseLineItems(text);
String totalAmountStr = getFieldValue(data, "amount_with_tax");
if (StringUtils.isBlank(totalAmountStr)) {
totalAmountStr = getFieldValue(data, "total_amount");
}
result.setTotalAmount(parseBigDecimal(totalAmountStr));
// 解析明细行
List<HrmInvoiceOcrResultVo.Item> items = new ArrayList<>();
JSONArray lineItems = data.getJSONArray("line_items");
if (lineItems != null) {
for (int i = 0; i < lineItems.size(); i++) {
JSONObject li = lineItems.getJSONObject(i);
if (li == null) continue;
String name = getStringOrFieldValue(li, "project_name");
if (StringUtils.isBlank(name)) continue;
HrmInvoiceOcrResultVo.Item item = new HrmInvoiceOcrResultVo.Item();
item.setItemName(name);
// 行项目金额取价税合计:税前金额 + 税额
BigDecimal preAmt = parseBigDecimal(getStringOrFieldValue(li, "amount"));
BigDecimal taxAmt = parseBigDecimal(getStringOrFieldValue(li, "tax_amount"));
BigDecimal withTax = null;
if (preAmt != null || taxAmt != null) {
withTax = (preAmt != null ? preAmt : BigDecimal.ZERO)
.add(taxAmt != null ? taxAmt : BigDecimal.ZERO);
}
item.setAmount(withTax != null ? withTax : preAmt);
item.setTaxRate(getStringOrFieldValue(li, "tax_rate"));
items.add(item);
}
}
// 如果没有明细行但有总金额,生成一个汇总条目
if (items.isEmpty() && result.getTotalAmount() != null) {
HrmInvoiceOcrResultVo.Item item = new HrmInvoiceOcrResultVo.Item();
String seller = StringUtils.defaultIfBlank(result.getSellerName(), "发票款项");
item.setItemName(seller);
item.setAmount(result.getTotalAmount());
items.add(item);
}
result.setItems(items);
} catch (Exception e) {
log.warn("[OCR] 解析OCR响应失败: {}", e.getMessage());
// 兜底:解析不到明细但有总价 → 生成一条汇总
if (items.isEmpty() && result.getTotalAmount() != null) {
HrmInvoiceOcrResultVo.Item item = new HrmInvoiceOcrResultVo.Item();
item.setItemName(StringUtils.defaultIfBlank(result.getSellerName(), "发票款项"));
item.setAmount(result.getTotalAmount());
items.add(item);
}
result.setItems(items);
return result;
}
/** 从 {value: "...", confidence: 0.9} 结构取值,或直接取字符串 */
private String getFieldValue(JSONObject obj, String key) {
Object val = obj.get(key);
if (val == null) return null;
if (val instanceof JSONObject) {
return ((JSONObject) val).getString("value");
/** 抽明细行:在"货物名称 … 合计"之间逐行匹配 */
private List<HrmInvoiceOcrResultVo.Item> parseLineItems(String text) {
List<HrmInvoiceOcrResultVo.Item> items = new ArrayList<>();
// 定位明细表区间,找不到也没关系,直接全文匹配也能跑
int begin = indexOfAny(text, "项目名称", "货物或应税劳务", "货物名称");
int end = indexOfAny(text, "\\s*计", "价税合计", "(大写)", "(大写)");
String area = (begin >= 0 && end > begin) ? text.substring(begin, end) : text;
for (String line : area.split("\\n")) {
line = line.trim();
if (line.length() < 6) continue;
Matcher m = P_LINE_AMOUNT.matcher(line);
if (!m.find()) continue;
HrmInvoiceOcrResultVo.Item item = new HrmInvoiceOcrResultVo.Item();
String name = m.group(1).trim().replaceAll("^\\*[^*]+\\*", ""); // 去掉 *类别* 前缀
BigDecimal preTax = parseBigDecimal(m.group(2));
String rate = m.group(3);
BigDecimal tax = parseBigDecimal(m.group(4));
BigDecimal withTax = (preTax != null ? preTax : BigDecimal.ZERO)
.add(tax != null ? tax : BigDecimal.ZERO);
item.setItemName(name);
item.setTaxRate(rate);
item.setAmount(withTax);
items.add(item);
}
return val.toString();
return items;
}
@Override
public boolean isAlive() {
if (StringUtils.isBlank(ocrUrl)) return false;
try {
RestTemplate restTemplate = new RestTemplate();
ResponseEntity<String> resp = restTemplate.getForEntity(ocrUrl + "/health", String.class);
return resp.getStatusCode().is2xxSuccessful();
} catch (Exception e) {
log.warn("[OCR] 健康检查失败: {}", e.getMessage());
return false;
}
private static String firstGroup(Pattern p, String s) {
if (s == null) return null;
Matcher m = p.matcher(s);
return m.find() ? m.group(1).trim() : null;
}
private String getStringOrFieldValue(JSONObject obj, String key) {
Object val = obj.get(key);
if (val == null) return null;
if (val instanceof JSONObject) {
return ((JSONObject) val).getString("value");
private static int indexOfAny(String text, String... patterns) {
int min = -1;
for (String p : patterns) {
Matcher m = Pattern.compile(p).matcher(text);
if (m.find()) {
int idx = m.start();
if (min < 0 || idx < min) min = idx;
}
}
return val.toString();
return min;
}
private BigDecimal parseBigDecimal(String raw) {
private static String cleanSeller(String s) {
if (s == null) return null;
// 去掉抬头里常见的"名称:"残留 + 末尾空白和半角空格序列
s = s.replaceAll("^[:\\s]+", "").trim();
// 截断到第一个非中文/字母/数字/常见公司符号块
String[] tail = s.split("\\s{2,}");
return tail.length > 0 ? tail[0].trim() : s;
}
private static BigDecimal parseBigDecimal(String raw) {
if (StringUtils.isBlank(raw)) return null;
try {
return new BigDecimal(raw.replace(",", "").replace("¥", "").replace("", "").trim());
@@ -229,4 +177,5 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
return null;
}
}
}

View File

@@ -121,6 +121,9 @@ public class HrmSealReqServiceImpl implements IHrmSealReqService {
return baseMapper.selectVoWithProjectList(bo);
}
/** 用印申请固定审批人陆永强信息化部userId=1858417253738815490 */
private static final Long SEAL_FIXED_APPROVER_USER_ID = 1858417253738815490L;
@Override
@Transactional(rollbackFor = Exception.class)
public HrmSealReqVo insertByBo(HrmSealReqBo bo) {
@@ -129,22 +132,18 @@ public class HrmSealReqServiceImpl implements IHrmSealReqService {
validEntityBeforeSave(add);
boolean ok = baseMapper.insert(add) > 0;
// 只要传入了 tplId 或 manualAssigneeUserId就代表需要启动流程
Long tplId = bo.getTplId() != null ? bo.getTplId() : bo.getFlowTplId();
boolean shouldStartFlow = tplId != null || bo.getManualAssigneeUserId() != null;
HrmSealReqVo bean = BeanUtil.toBean(add, HrmSealReqVo.class);
if (ok && shouldStartFlow) {
if (ok) {
// 用印申请审批人写死成陆永强,忽略前端传的 tplId / manualAssigneeUserId
HrmFlowStartBo start = new HrmFlowStartBo();
start.setTplId(tplId);
start.setManualAssigneeUserId(bo.getManualAssigneeUserId());
start.setTplId(null);
start.setManualAssigneeUserId(SEAL_FIXED_APPROVER_USER_ID);
start.setBizType("seal");
start.setBizId(add.getBizId());
start.setStartUserId(LoginHelper.getUserId());
start.setContentJson(bo.getContentJson());
Long instId = flowInstanceService.startInstance(start);
// 更新状态为流转中
updateStatus(add.getBizId(), "running");
bean.setInstId(instId);
}