From c412f73b80d577c8eff567545ee70f3fbbdd7531 Mon Sep 17 00:00:00 2001 From: wangyu <823267011@qq.com> Date: Fri, 8 May 2026 17:34:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E6=8A=A5=E9=94=80/=E6=8B=A8=E6=AC=BE):=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=8F=91=E7=A5=A8=E6=98=8E=E7=BB=86=E5=AD=90?= =?UTF-8?q?=E8=A1=A8=E4=B8=8EOCR=E8=87=AA=E5=8A=A8=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 hrm_invoice_item 共享子表(biz_type区分报销/拨款),每条记录对应一张发票条目 - 新增 HrmInvoiceOcrService,上传附件后自动调用 ai-ocr Python服务识别发票,结果逐条回填表单 - 报销/拨款申请提交及更新时同步保存发票明细;queryById 返回关联发票条目列表 - 前端:附件上传后自动触发OCR,展示"模型思考中"状态,识别完成后自动填充金额 - 详情页新增发票明细只读表格展示,兼容无明细的历史记录 - application.yml 增加 fad.ocr 配置项 Co-Authored-By: Claude Sonnet 4.6 --- .../ruoyi/hrm/config/HrmOcrProperties.java | 20 + .../HrmAppropriationReqController.java | 11 + .../controller/HrmReimburseReqController.java | 11 + .../com/ruoyi/hrm/domain/HrmInvoiceItem.java | 43 ++ .../hrm/domain/bo/HrmAppropriationReqBo.java | 4 + .../ruoyi/hrm/domain/bo/HrmInvoiceItemBo.java | 27 ++ .../hrm/domain/bo/HrmReimburseReqBo.java | 4 + .../hrm/domain/vo/HrmAppropriationReqVo.java | 4 + .../hrm/domain/vo/HrmInvoiceOcrResultVo.java | 38 ++ .../hrm/domain/vo/HrmReimburseReqVo.java | 4 + .../hrm/mapper/HrmInvoiceItemMapper.java | 7 + .../hrm/service/IHrmInvoiceOcrService.java | 17 + .../impl/HrmAppropriationReqServiceImpl.java | 43 +- .../impl/HrmInvoiceOcrServiceImpl.java | 205 ++++++++++ .../impl/HrmReimburseReqServiceImpl.java | 44 +- .../src/main/resources/application.yml | 5 + ruoyi-ui/src/api/hrm/appropriation.js | 11 + ruoyi-ui/src/api/hrm/reimburse.js | 11 + .../src/views/hrm/requests/appropriation.vue | 384 +++++++++++------- .../hrm/requests/appropriationDetail.vue | 15 + ruoyi-ui/src/views/hrm/requests/reimburse.vue | 383 +++++++++++------ .../views/hrm/requests/reimburseDetail.vue | 15 + sql/hrm_invoice_item.sql | 15 + 23 files changed, 1043 insertions(+), 278 deletions(-) create mode 100644 fad-hrm/src/main/java/com/ruoyi/hrm/config/HrmOcrProperties.java create mode 100644 fad-hrm/src/main/java/com/ruoyi/hrm/domain/HrmInvoiceItem.java create mode 100644 fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmInvoiceItemBo.java create mode 100644 fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmInvoiceOcrResultVo.java create mode 100644 fad-hrm/src/main/java/com/ruoyi/hrm/mapper/HrmInvoiceItemMapper.java create mode 100644 fad-hrm/src/main/java/com/ruoyi/hrm/service/IHrmInvoiceOcrService.java create mode 100644 fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmInvoiceOcrServiceImpl.java create mode 100644 sql/hrm_invoice_item.sql diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/config/HrmOcrProperties.java b/fad-hrm/src/main/java/com/ruoyi/hrm/config/HrmOcrProperties.java new file mode 100644 index 0000000..eefd788 --- /dev/null +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/config/HrmOcrProperties.java @@ -0,0 +1,20 @@ +package com.ruoyi.hrm.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +/** + * 发票OCR服务配置 + */ +@Data +@Component +@ConfigurationProperties(prefix = "fad.ocr") +public class HrmOcrProperties { + + /** OCR服务地址,如 http://127.0.0.1:8000 */ + private String url = "http://127.0.0.1:8000"; + + /** OCR服务 API Key */ + private String apiKey = ""; +} diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmAppropriationReqController.java b/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmAppropriationReqController.java index 1ddbc8e..5473c46 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmAppropriationReqController.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmAppropriationReqController.java @@ -9,7 +9,9 @@ import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.helper.LoginHelper; import com.ruoyi.hrm.domain.bo.HrmAppropriationReqBo; import com.ruoyi.hrm.domain.vo.HrmAppropriationReqVo; +import com.ruoyi.hrm.domain.vo.HrmInvoiceOcrResultVo; import com.ruoyi.hrm.service.IHrmAppropriationReqService; +import com.ruoyi.hrm.service.IHrmInvoiceOcrService; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -29,6 +31,7 @@ import java.util.List; public class HrmAppropriationReqController extends BaseController { private final IHrmAppropriationReqService service; + private final IHrmInvoiceOcrService invoiceOcrService; @GetMapping("/list") public TableDataInfo list(HrmAppropriationReqBo bo, PageQuery pageQuery) { @@ -64,5 +67,13 @@ public class HrmAppropriationReqController extends BaseController { bo.setCreateBy(String.valueOf(LoginHelper.getUserId())); return R.ok(service.queryList(bo)); } + + /** + * 通过ossId触发发票OCR识别,返回识别条目(不保存,供前端实时回显) + */ + @PostMapping("/ocr-by-oss") + public R ocrByOss(@RequestParam @NotNull Long ossId) { + return R.ok(invoiceOcrService.recognizeByOssId(ossId)); + } } diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmReimburseReqController.java b/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmReimburseReqController.java index 7b477f2..48599f7 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmReimburseReqController.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/controller/HrmReimburseReqController.java @@ -8,7 +8,9 @@ import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.helper.LoginHelper; import com.ruoyi.hrm.domain.bo.HrmReimburseReqBo; +import com.ruoyi.hrm.domain.vo.HrmInvoiceOcrResultVo; import com.ruoyi.hrm.domain.vo.HrmReimburseReqVo; +import com.ruoyi.hrm.service.IHrmInvoiceOcrService; import com.ruoyi.hrm.service.IHrmReimburseReqService; import lombok.RequiredArgsConstructor; import org.springframework.validation.annotation.Validated; @@ -29,6 +31,7 @@ import java.util.List; public class HrmReimburseReqController extends BaseController { private final IHrmReimburseReqService service; + private final IHrmInvoiceOcrService invoiceOcrService; @GetMapping("/list") public TableDataInfo list(HrmReimburseReqBo bo, PageQuery pageQuery) { @@ -64,5 +67,13 @@ public class HrmReimburseReqController extends BaseController { bo.setCreateBy(LoginHelper.getUsername()); return R.ok(service.queryList(bo)); } + + /** + * 通过ossId触发发票OCR识别,返回识别条目(不保存,供前端实时回显) + */ + @PostMapping("/ocr-by-oss") + public R ocrByOss(@RequestParam @NotNull Long ossId) { + return R.ok(invoiceOcrService.recognizeByOssId(ossId)); + } } diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/HrmInvoiceItem.java b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/HrmInvoiceItem.java new file mode 100644 index 0000000..9109c21 --- /dev/null +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/HrmInvoiceItem.java @@ -0,0 +1,43 @@ +package com.ruoyi.hrm.domain; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 发票条目子表(报销单/拨款单共用) + */ +@Data +@TableName("hrm_invoice_item") +public class HrmInvoiceItem { + + @TableId + private Long id; + + /** 业务类型 reimburse / appropriation */ + private String bizType; + + /** 关联业务单ID */ + private Long bizId; + + /** 来源附件ossId */ + private Long ossId; + + /** 排序序号 */ + private Integer sortNo; + + /** OCR识别项目名称 */ + private String itemName; + + /** 事由说明(用户可编辑) */ + private String reason; + + /** 金额 */ + private BigDecimal amount; + + @TableLogic + private Integer delFlag; +} diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmAppropriationReqBo.java b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmAppropriationReqBo.java index 5fcdbf6..4ab4c7a 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmAppropriationReqBo.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmAppropriationReqBo.java @@ -6,6 +6,7 @@ import lombok.EqualsAndHashCode; import javax.validation.constraints.NotNull; import java.math.BigDecimal; +import java.util.List; /** * 拨款申请 Bo @@ -59,5 +60,8 @@ public class HrmAppropriationReqBo extends BaseEntity { private String remark; private Long tplId; + + /** 发票条目列表(前端提交时携带) */ + private List invoiceItems; } diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmInvoiceItemBo.java b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmInvoiceItemBo.java new file mode 100644 index 0000000..13aa0d8 --- /dev/null +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmInvoiceItemBo.java @@ -0,0 +1,27 @@ +package com.ruoyi.hrm.domain.bo; + +import lombok.Data; + +import java.math.BigDecimal; + +/** + * 发票条目 BO(用于表单提交) + */ +@Data +public class HrmInvoiceItemBo { + + /** 来源附件ossId */ + private Long ossId; + + /** 排序序号 */ + private Integer sortNo; + + /** OCR识别项目名称 */ + private String itemName; + + /** 事由说明 */ + private String reason; + + /** 金额 */ + private BigDecimal amount; +} diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmReimburseReqBo.java b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmReimburseReqBo.java index cef827d..24a801d 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmReimburseReqBo.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/bo/HrmReimburseReqBo.java @@ -6,6 +6,7 @@ import lombok.EqualsAndHashCode; import javax.validation.constraints.NotNull; import java.math.BigDecimal; +import java.util.List; @Data @EqualsAndHashCode(callSuper = true) @@ -39,5 +40,8 @@ public class HrmReimburseReqBo extends BaseEntity { private String remark; private Long tplId; + + /** 发票条目列表(前端提交时携带) */ + private List invoiceItems; } diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmAppropriationReqVo.java b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmAppropriationReqVo.java index e41072f..8b0effc 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmAppropriationReqVo.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmAppropriationReqVo.java @@ -6,6 +6,8 @@ import lombok.Data; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; +import java.util.List; +import com.ruoyi.hrm.domain.HrmInvoiceItem; /** * 拨款申请 VO @@ -108,5 +110,7 @@ public class HrmAppropriationReqVo implements Serializable { /** 流程实例ID */ private Long instId; + + private List invoiceItems; } diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmInvoiceOcrResultVo.java b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmInvoiceOcrResultVo.java new file mode 100644 index 0000000..c56d759 --- /dev/null +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmInvoiceOcrResultVo.java @@ -0,0 +1,38 @@ +package com.ruoyi.hrm.domain.vo; + +import lombok.Data; + +import java.math.BigDecimal; +import java.util.List; + +/** + * 单张发票OCR识别结果(返回给前端用于填充发票条目) + */ +@Data +public class HrmInvoiceOcrResultVo { + + /** 发票类型 */ + private String invoiceType; + + /** 销售方名称 */ + private String sellerName; + + /** 开票日期 */ + private String invoiceDate; + + /** 价税合计 */ + private BigDecimal totalAmount; + + /** 识别出的条目列表 */ + private List items; + + @Data + public static class Item { + /** OCR识别名称 */ + private String itemName; + /** 金额(不含税) */ + private BigDecimal amount; + /** 税率 */ + private String taxRate; + } +} diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmReimburseReqVo.java b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmReimburseReqVo.java index cb42a66..31d9900 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmReimburseReqVo.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/domain/vo/HrmReimburseReqVo.java @@ -6,6 +6,8 @@ import lombok.Data; import java.io.Serializable; import java.math.BigDecimal; import java.util.Date; +import java.util.List; +import com.ruoyi.hrm.domain.HrmInvoiceItem; @Data public class HrmReimburseReqVo implements Serializable { @@ -95,5 +97,7 @@ public class HrmReimburseReqVo implements Serializable { private Date updateTime; private Long instId; + + private List invoiceItems; } diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/mapper/HrmInvoiceItemMapper.java b/fad-hrm/src/main/java/com/ruoyi/hrm/mapper/HrmInvoiceItemMapper.java new file mode 100644 index 0000000..38767b0 --- /dev/null +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/mapper/HrmInvoiceItemMapper.java @@ -0,0 +1,7 @@ +package com.ruoyi.hrm.mapper; + +import com.ruoyi.common.core.mapper.BaseMapperPlus; +import com.ruoyi.hrm.domain.HrmInvoiceItem; + +public interface HrmInvoiceItemMapper extends BaseMapperPlus { +} diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/service/IHrmInvoiceOcrService.java b/fad-hrm/src/main/java/com/ruoyi/hrm/service/IHrmInvoiceOcrService.java new file mode 100644 index 0000000..3e13b99 --- /dev/null +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/service/IHrmInvoiceOcrService.java @@ -0,0 +1,17 @@ +package com.ruoyi.hrm.service; + +import com.ruoyi.hrm.domain.vo.HrmInvoiceOcrResultVo; + +/** + * 发票OCR识别服务(调用Python OCR微服务) + */ +public interface IHrmInvoiceOcrService { + + /** + * 通过ossId识别发票 + * + * @param ossId 附件ID + * @return 识别结果 + */ + HrmInvoiceOcrResultVo recognizeByOssId(Long ossId); +} diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmAppropriationReqServiceImpl.java b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmAppropriationReqServiceImpl.java index 874a3f7..3fff719 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmAppropriationReqServiceImpl.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmAppropriationReqServiceImpl.java @@ -9,11 +9,14 @@ import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.helper.LoginHelper; import com.ruoyi.hrm.domain.HrmAppropriationReq; import com.ruoyi.hrm.domain.HrmFlowTemplate; +import com.ruoyi.hrm.domain.HrmInvoiceItem; import com.ruoyi.hrm.domain.bo.HrmAppropriationReqBo; import com.ruoyi.hrm.domain.bo.HrmFlowStartBo; +import com.ruoyi.hrm.domain.bo.HrmInvoiceItemBo; import com.ruoyi.hrm.domain.vo.HrmAppropriationReqVo; import com.ruoyi.hrm.mapper.HrmAppropriationReqMapper; import com.ruoyi.hrm.mapper.HrmFlowTemplateMapper; +import com.ruoyi.hrm.mapper.HrmInvoiceItemMapper; import com.ruoyi.hrm.service.IHrmAppropriationReqService; import com.ruoyi.hrm.service.IHrmFlowInstanceService; import lombok.RequiredArgsConstructor; @@ -33,10 +36,18 @@ public class HrmAppropriationReqServiceImpl implements IHrmAppropriationReqServi private final HrmAppropriationReqMapper baseMapper; private final HrmFlowTemplateMapper flowTemplateMapper; private final IHrmFlowInstanceService flowInstanceService; + private final HrmInvoiceItemMapper invoiceItemMapper; @Override public HrmAppropriationReqVo queryById(Long bizId) { - return baseMapper.selectVoWithProjectById(bizId); + HrmAppropriationReqVo vo = baseMapper.selectVoWithProjectById(bizId); + if (vo != null) { + vo.setInvoiceItems(invoiceItemMapper.selectList(Wrappers.lambdaQuery() + .eq(HrmInvoiceItem::getBizType, "appropriation") + .eq(HrmInvoiceItem::getBizId, bizId) + .orderByAsc(HrmInvoiceItem::getSortNo))); + } + return vo; } @Override @@ -64,6 +75,12 @@ public class HrmAppropriationReqServiceImpl implements IHrmAppropriationReqServi boolean ok = baseMapper.insert(add) > 0; HrmAppropriationReqVo bean = BeanUtil.toBean(add, HrmAppropriationReqVo.class); + + // 保存发票条目 + if (ok && bo.getInvoiceItems() != null && !bo.getInvoiceItems().isEmpty()) { + saveInvoiceItems("appropriation", add.getBizId(), bo.getInvoiceItems()); + } + if (ok && "pending".equalsIgnoreCase(add.getStatus())) { // 获取流程启动人ID Long startUserId = LoginHelper.getUserId(); @@ -108,7 +125,11 @@ public class HrmAppropriationReqServiceImpl implements IHrmAppropriationReqServi @Transactional(rollbackFor = Exception.class) public Boolean updateByBo(HrmAppropriationReqBo bo) { HrmAppropriationReq update = BeanUtil.toBean(bo, HrmAppropriationReq.class); - return baseMapper.updateById(update) > 0; + boolean updated = baseMapper.updateById(update) > 0; + if (updated && bo.getInvoiceItems() != null) { + saveInvoiceItems("appropriation", bo.getBizId(), bo.getInvoiceItems()); + } + return updated; } @Override @@ -125,6 +146,24 @@ public class HrmAppropriationReqServiceImpl implements IHrmAppropriationReqServi return baseMapper.updateById(req) > 0; } + private void saveInvoiceItems(String bizType, Long bizId, List boList) { + invoiceItemMapper.delete(Wrappers.lambdaQuery() + .eq(HrmInvoiceItem::getBizType, bizType) + .eq(HrmInvoiceItem::getBizId, bizId)); + for (int i = 0; i < boList.size(); i++) { + HrmInvoiceItemBo bo = boList.get(i); + HrmInvoiceItem item = new HrmInvoiceItem(); + item.setBizType(bizType); + item.setBizId(bizId); + item.setOssId(bo.getOssId()); + item.setSortNo(bo.getSortNo() != null ? bo.getSortNo() : i); + item.setItemName(bo.getItemName()); + item.setReason(bo.getReason()); + item.setAmount(bo.getAmount()); + invoiceItemMapper.insert(item); + } + } + @SuppressWarnings("unused") private LambdaQueryWrapper buildQueryWrapper(HrmAppropriationReqBo bo) { LambdaQueryWrapper lqw = Wrappers.lambdaQuery(); diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmInvoiceOcrServiceImpl.java b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmInvoiceOcrServiceImpl.java new file mode 100644 index 0000000..f2ad035 --- /dev/null +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmInvoiceOcrServiceImpl.java @@ -0,0 +1,205 @@ +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.config.HrmOcrProperties; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +/** + * 发票OCR识别服务实现(调用Python OCR微服务) + */ +@Slf4j +@RequiredArgsConstructor +@Service +public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService { + + private final HrmOcrProperties ocrProperties; + private final SysOssMapper sysOssMapper; + + @Override + public HrmInvoiceOcrResultVo recognizeByOssId(Long ossId) { + SysOssVo oss = sysOssMapper.selectVoById(ossId); + if (oss == null) { + throw new ServiceException("附件不存在: " + ossId); + } + + byte[] fileBytes; + try (InputStream in = OssFactory.instance().getObjectContent(oss.getUrl())) { + fileBytes = IoUtil.readBytes(in); + } catch (Exception e) { + throw new ServiceException("读取附件失败: " + e.getMessage()); + } + + String fileName = StringUtils.defaultIfBlank(oss.getOriginalName(), oss.getFileName()); + return callOcrService(fileBytes, fileName, oss.getFileSuffix()); + } + + private HrmInvoiceOcrResultVo callOcrService(byte[] fileBytes, String fileName, String fileSuffix) { + String ocrUrl = ocrProperties.getUrl(); + String apiKey = ocrProperties.getApiKey(); + + 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 filePart = new HttpEntity<>(fileResource, fileHeaders); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("file", filePart); + + HttpEntity> requestEntity = new HttpEntity<>(body, headers); + + RestTemplate restTemplate = new RestTemplate(); + ResponseEntity response; + try { + response = restTemplate.postForEntity(ocrUrl + "/v1/invoice/ocr", requestEntity, String.class); + } catch (Exception e) { + log.error("[OCR] 调用OCR服务失败 url={} error={}", ocrUrl, e.getMessage()); + throw new ServiceException("OCR服务调用失败: " + e.getMessage()); + } + + 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(getFieldValue(data, "invoice_type")); + result.setSellerName(getFieldValue(data, "seller_name")); + result.setInvoiceDate(getFieldValue(data, "invoice_date")); + + String totalAmountStr = getFieldValue(data, "amount_with_tax"); + if (StringUtils.isBlank(totalAmountStr)) { + totalAmountStr = getFieldValue(data, "total_amount"); + } + result.setTotalAmount(parseBigDecimal(totalAmountStr)); + + // 解析明细行 + List 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); + item.setAmount(parseBigDecimal(getStringOrFieldValue(li, "amount"))); + 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()); + } + 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"); + } + return val.toString(); + } + + 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"); + } + return val.toString(); + } + + private BigDecimal parseBigDecimal(String raw) { + if (StringUtils.isBlank(raw)) return null; + try { + return new BigDecimal(raw.replace(",", "").replace("¥", "").replace("¥", "").trim()); + } catch (Exception e) { + return null; + } + } +} diff --git a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmReimburseReqServiceImpl.java b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmReimburseReqServiceImpl.java index 5d8b60f..c1a3424 100644 --- a/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmReimburseReqServiceImpl.java +++ b/fad-hrm/src/main/java/com/ruoyi/hrm/service/impl/HrmReimburseReqServiceImpl.java @@ -8,11 +8,14 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.ruoyi.common.core.domain.PageQuery; import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.hrm.domain.HrmFlowTemplate; +import com.ruoyi.hrm.domain.HrmInvoiceItem; import com.ruoyi.hrm.domain.HrmReimburseReq; import com.ruoyi.hrm.domain.bo.HrmFlowStartBo; +import com.ruoyi.hrm.domain.bo.HrmInvoiceItemBo; import com.ruoyi.hrm.domain.bo.HrmReimburseReqBo; import com.ruoyi.hrm.domain.vo.HrmReimburseReqVo; import com.ruoyi.hrm.mapper.HrmFlowTemplateMapper; +import com.ruoyi.hrm.mapper.HrmInvoiceItemMapper; import com.ruoyi.hrm.mapper.HrmReimburseReqMapper; import com.ruoyi.hrm.service.IHrmFlowInstanceService; import com.ruoyi.hrm.service.IHrmReimburseReqService; @@ -30,10 +33,18 @@ public class HrmReimburseReqServiceImpl implements IHrmReimburseReqService { private final HrmReimburseReqMapper baseMapper; private final HrmFlowTemplateMapper flowTemplateMapper; private final IHrmFlowInstanceService flowInstanceService; + private final HrmInvoiceItemMapper invoiceItemMapper; @Override public HrmReimburseReqVo queryById(Long bizId) { - return baseMapper.selectVoWithProjectById(bizId); + HrmReimburseReqVo vo = baseMapper.selectVoWithProjectById(bizId); + if (vo != null) { + vo.setInvoiceItems(invoiceItemMapper.selectList(Wrappers.lambdaQuery() + .eq(HrmInvoiceItem::getBizType, "reimburse") + .eq(HrmInvoiceItem::getBizId, bizId) + .orderByAsc(HrmInvoiceItem::getSortNo))); + } + return vo; } @Override @@ -61,6 +72,12 @@ public class HrmReimburseReqServiceImpl implements IHrmReimburseReqService { boolean ok = baseMapper.insert(add) > 0; HrmReimburseReqVo bean = BeanUtil.toBean(add, HrmReimburseReqVo.class); + + // 保存发票条目 + if (ok && bo.getInvoiceItems() != null && !bo.getInvoiceItems().isEmpty()) { + saveInvoiceItems("reimburse", add.getBizId(), bo.getInvoiceItems()); + } + if (ok && "pending".equalsIgnoreCase(add.getStatus())) { Long startUserId = LoginHelper.getUserId(); @@ -105,7 +122,11 @@ public class HrmReimburseReqServiceImpl implements IHrmReimburseReqService { @Transactional(rollbackFor = Exception.class) public Boolean updateByBo(HrmReimburseReqBo bo) { HrmReimburseReq update = BeanUtil.toBean(bo, HrmReimburseReq.class); - return baseMapper.updateById(update) > 0; + boolean updated = baseMapper.updateById(update) > 0; + if (updated && bo.getInvoiceItems() != null) { + saveInvoiceItems("reimburse", bo.getBizId(), bo.getInvoiceItems()); + } + return updated; } @Override @@ -124,6 +145,25 @@ public class HrmReimburseReqServiceImpl implements IHrmReimburseReqService { return lqw; } + private void saveInvoiceItems(String bizType, Long bizId, List boList) { + // 先清除旧数据,再插入新数据(更新场景兼容) + invoiceItemMapper.delete(Wrappers.lambdaQuery() + .eq(HrmInvoiceItem::getBizType, bizType) + .eq(HrmInvoiceItem::getBizId, bizId)); + for (int i = 0; i < boList.size(); i++) { + HrmInvoiceItemBo bo = boList.get(i); + HrmInvoiceItem item = new HrmInvoiceItem(); + item.setBizType(bizType); + item.setBizId(bizId); + item.setOssId(bo.getOssId()); + item.setSortNo(bo.getSortNo() != null ? bo.getSortNo() : i); + item.setItemName(bo.getItemName()); + item.setReason(bo.getReason()); + item.setAmount(bo.getAmount()); + invoiceItemMapper.insert(item); + } + } + private String defaultStatus(String status) { return status == null ? "draft" : status; } diff --git a/ruoyi-admin/src/main/resources/application.yml b/ruoyi-admin/src/main/resources/application.yml index bd87293..0f869b1 100644 --- a/ruoyi-admin/src/main/resources/application.yml +++ b/ruoyi-admin/src/main/resources/application.yml @@ -327,4 +327,9 @@ fad: # 新增的前端 Web 端使用的 Key 和安全密钥 webKey: 34bf20d1db5b183558b9bb85d6eed783 securityKey: 6f9171724396deb5f8c42ef256b3cbc5 + ocr: + # 发票OCR服务地址(ai-ocr Python服务) + url: http://127.0.0.1:8000 + # OCR服务 API Key + api-key: change-me-debug-key diff --git a/ruoyi-ui/src/api/hrm/appropriation.js b/ruoyi-ui/src/api/hrm/appropriation.js index 716fbab..a7f2f66 100644 --- a/ruoyi-ui/src/api/hrm/appropriation.js +++ b/ruoyi-ui/src/api/hrm/appropriation.js @@ -57,3 +57,14 @@ export function getAppropriationStats (query) { params: query }) } + +/** + * 通过ossId触发发票OCR识别(返回识别条目,不保存) + */ +export function ocrAppropriationInvoice (ossId) { + return request({ + url: '/hrm/appropriation/ocr-by-oss', + method: 'post', + params: { ossId } + }) +} diff --git a/ruoyi-ui/src/api/hrm/reimburse.js b/ruoyi-ui/src/api/hrm/reimburse.js index 67f2159..7fbc4f0 100644 --- a/ruoyi-ui/src/api/hrm/reimburse.js +++ b/ruoyi-ui/src/api/hrm/reimburse.js @@ -47,3 +47,14 @@ export function allReimburseReq(query) { }) } +/** + * 通过ossId触发发票OCR识别(返回识别条目,不保存) + */ +export function ocrReimburseInvoice(ossId) { + return request({ + url: '/hrm/reimburse/ocr-by-oss', + method: 'post', + params: { ossId } + }) +} + diff --git a/ruoyi-ui/src/views/hrm/requests/appropriation.vue b/ruoyi-ui/src/views/hrm/requests/appropriation.vue index 2b5d723..ff1a3ad 100644 --- a/ruoyi-ui/src/views/hrm/requests/appropriation.vue +++ b/ruoyi-ui/src/views/hrm/requests/appropriation.vue @@ -47,13 +47,63 @@ - - - + + +
上传发票、收据、付款截图等(支持 PDF/图片,上传后自动识别)
+
+ + +
+ + 模型思考中,正在识别发票内容… +
+ + +
+ 拨款明细 + (上传发票后自动填充,也可手动添加) +
+
+
+ 事由说明 + 金额(元) + +
+
+ + +
+ +
+
+ +
+ + + @@ -80,12 +130,6 @@ - - -
上传发票、收据、付款截图等(必填)
-
- @@ -106,14 +150,13 @@ - - +
审批方式
@@ -163,18 +206,11 @@
-
-
-
-
填写申请
-
+
填写申请
-
-
-
提交
-
+
提交