feat(报销/拨款): 新增发票明细子表与OCR自动识别

- 新增 hrm_invoice_item 共享子表(biz_type区分报销/拨款),每条记录对应一张发票条目
- 新增 HrmInvoiceOcrService,上传附件后自动调用 ai-ocr Python服务识别发票,结果逐条回填表单
- 报销/拨款申请提交及更新时同步保存发票明细;queryById 返回关联发票条目列表
- 前端:附件上传后自动触发OCR,展示"模型思考中"状态,识别完成后自动填充金额
- 详情页新增发票明细只读表格展示,兼容无明细的历史记录
- application.yml 增加 fad.ocr 配置项

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 17:34:08 +08:00
parent 28a37f4105
commit c412f73b80
23 changed files with 1043 additions and 278 deletions

View File

@@ -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 = "";
}

View File

@@ -9,7 +9,9 @@ import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.helper.LoginHelper; import com.ruoyi.common.helper.LoginHelper;
import com.ruoyi.hrm.domain.bo.HrmAppropriationReqBo; import com.ruoyi.hrm.domain.bo.HrmAppropriationReqBo;
import com.ruoyi.hrm.domain.vo.HrmAppropriationReqVo; 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.IHrmAppropriationReqService;
import com.ruoyi.hrm.service.IHrmInvoiceOcrService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -29,6 +31,7 @@ import java.util.List;
public class HrmAppropriationReqController extends BaseController { public class HrmAppropriationReqController extends BaseController {
private final IHrmAppropriationReqService service; private final IHrmAppropriationReqService service;
private final IHrmInvoiceOcrService invoiceOcrService;
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo<HrmAppropriationReqVo> list(HrmAppropriationReqBo bo, PageQuery pageQuery) { public TableDataInfo<HrmAppropriationReqVo> list(HrmAppropriationReqBo bo, PageQuery pageQuery) {
@@ -64,5 +67,13 @@ public class HrmAppropriationReqController extends BaseController {
bo.setCreateBy(String.valueOf(LoginHelper.getUserId())); bo.setCreateBy(String.valueOf(LoginHelper.getUserId()));
return R.ok(service.queryList(bo)); return R.ok(service.queryList(bo));
} }
/**
* 通过ossId触发发票OCR识别返回识别条目不保存供前端实时回显
*/
@PostMapping("/ocr-by-oss")
public R<HrmInvoiceOcrResultVo> ocrByOss(@RequestParam @NotNull Long ossId) {
return R.ok(invoiceOcrService.recognizeByOssId(ossId));
}
} }

View File

@@ -8,7 +8,9 @@ import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.enums.BusinessType; import com.ruoyi.common.enums.BusinessType;
import com.ruoyi.common.helper.LoginHelper; import com.ruoyi.common.helper.LoginHelper;
import com.ruoyi.hrm.domain.bo.HrmReimburseReqBo; 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.domain.vo.HrmReimburseReqVo;
import com.ruoyi.hrm.service.IHrmInvoiceOcrService;
import com.ruoyi.hrm.service.IHrmReimburseReqService; import com.ruoyi.hrm.service.IHrmReimburseReqService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.validation.annotation.Validated; import org.springframework.validation.annotation.Validated;
@@ -29,6 +31,7 @@ import java.util.List;
public class HrmReimburseReqController extends BaseController { public class HrmReimburseReqController extends BaseController {
private final IHrmReimburseReqService service; private final IHrmReimburseReqService service;
private final IHrmInvoiceOcrService invoiceOcrService;
@GetMapping("/list") @GetMapping("/list")
public TableDataInfo<HrmReimburseReqVo> list(HrmReimburseReqBo bo, PageQuery pageQuery) { public TableDataInfo<HrmReimburseReqVo> list(HrmReimburseReqBo bo, PageQuery pageQuery) {
@@ -64,5 +67,13 @@ public class HrmReimburseReqController extends BaseController {
bo.setCreateBy(LoginHelper.getUsername()); bo.setCreateBy(LoginHelper.getUsername());
return R.ok(service.queryList(bo)); return R.ok(service.queryList(bo));
} }
/**
* 通过ossId触发发票OCR识别返回识别条目不保存供前端实时回显
*/
@PostMapping("/ocr-by-oss")
public R<HrmInvoiceOcrResultVo> ocrByOss(@RequestParam @NotNull Long ossId) {
return R.ok(invoiceOcrService.recognizeByOssId(ossId));
}
} }

View File

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

View File

@@ -6,6 +6,7 @@ import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List;
/** /**
* 拨款申请 Bo * 拨款申请 Bo
@@ -59,5 +60,8 @@ public class HrmAppropriationReqBo extends BaseEntity {
private String remark; private String remark;
private Long tplId; private Long tplId;
/** 发票条目列表(前端提交时携带) */
private List<HrmInvoiceItemBo> invoiceItems;
} }

View File

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

View File

@@ -6,6 +6,7 @@ import lombok.EqualsAndHashCode;
import javax.validation.constraints.NotNull; import javax.validation.constraints.NotNull;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List;
@Data @Data
@EqualsAndHashCode(callSuper = true) @EqualsAndHashCode(callSuper = true)
@@ -39,5 +40,8 @@ public class HrmReimburseReqBo extends BaseEntity {
private String remark; private String remark;
private Long tplId; private Long tplId;
/** 发票条目列表(前端提交时携带) */
private List<HrmInvoiceItemBo> invoiceItems;
} }

View File

@@ -6,6 +6,8 @@ import lombok.Data;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
import java.util.List;
import com.ruoyi.hrm.domain.HrmInvoiceItem;
/** /**
* 拨款申请 VO * 拨款申请 VO
@@ -108,5 +110,7 @@ public class HrmAppropriationReqVo implements Serializable {
/** 流程实例ID */ /** 流程实例ID */
private Long instId; private Long instId;
private List<HrmInvoiceItem> invoiceItems;
} }

View File

@@ -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<Item> items;
@Data
public static class Item {
/** OCR识别名称 */
private String itemName;
/** 金额(不含税) */
private BigDecimal amount;
/** 税率 */
private String taxRate;
}
}

View File

@@ -6,6 +6,8 @@ import lombok.Data;
import java.io.Serializable; import java.io.Serializable;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Date; import java.util.Date;
import java.util.List;
import com.ruoyi.hrm.domain.HrmInvoiceItem;
@Data @Data
public class HrmReimburseReqVo implements Serializable { public class HrmReimburseReqVo implements Serializable {
@@ -95,5 +97,7 @@ public class HrmReimburseReqVo implements Serializable {
private Date updateTime; private Date updateTime;
private Long instId; private Long instId;
private List<HrmInvoiceItem> invoiceItems;
} }

View File

@@ -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<HrmInvoiceItemMapper, HrmInvoiceItem, HrmInvoiceItem> {
}

View File

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

View File

@@ -9,11 +9,14 @@ import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.common.helper.LoginHelper; import com.ruoyi.common.helper.LoginHelper;
import com.ruoyi.hrm.domain.HrmAppropriationReq; import com.ruoyi.hrm.domain.HrmAppropriationReq;
import com.ruoyi.hrm.domain.HrmFlowTemplate; 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.HrmAppropriationReqBo;
import com.ruoyi.hrm.domain.bo.HrmFlowStartBo; 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.domain.vo.HrmAppropriationReqVo;
import com.ruoyi.hrm.mapper.HrmAppropriationReqMapper; import com.ruoyi.hrm.mapper.HrmAppropriationReqMapper;
import com.ruoyi.hrm.mapper.HrmFlowTemplateMapper; import com.ruoyi.hrm.mapper.HrmFlowTemplateMapper;
import com.ruoyi.hrm.mapper.HrmInvoiceItemMapper;
import com.ruoyi.hrm.service.IHrmAppropriationReqService; import com.ruoyi.hrm.service.IHrmAppropriationReqService;
import com.ruoyi.hrm.service.IHrmFlowInstanceService; import com.ruoyi.hrm.service.IHrmFlowInstanceService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@@ -33,10 +36,18 @@ public class HrmAppropriationReqServiceImpl implements IHrmAppropriationReqServi
private final HrmAppropriationReqMapper baseMapper; private final HrmAppropriationReqMapper baseMapper;
private final HrmFlowTemplateMapper flowTemplateMapper; private final HrmFlowTemplateMapper flowTemplateMapper;
private final IHrmFlowInstanceService flowInstanceService; private final IHrmFlowInstanceService flowInstanceService;
private final HrmInvoiceItemMapper invoiceItemMapper;
@Override @Override
public HrmAppropriationReqVo queryById(Long bizId) { public HrmAppropriationReqVo queryById(Long bizId) {
return baseMapper.selectVoWithProjectById(bizId); HrmAppropriationReqVo vo = baseMapper.selectVoWithProjectById(bizId);
if (vo != null) {
vo.setInvoiceItems(invoiceItemMapper.selectList(Wrappers.<HrmInvoiceItem>lambdaQuery()
.eq(HrmInvoiceItem::getBizType, "appropriation")
.eq(HrmInvoiceItem::getBizId, bizId)
.orderByAsc(HrmInvoiceItem::getSortNo)));
}
return vo;
} }
@Override @Override
@@ -64,6 +75,12 @@ public class HrmAppropriationReqServiceImpl implements IHrmAppropriationReqServi
boolean ok = baseMapper.insert(add) > 0; boolean ok = baseMapper.insert(add) > 0;
HrmAppropriationReqVo bean = BeanUtil.toBean(add, HrmAppropriationReqVo.class); 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())) { if (ok && "pending".equalsIgnoreCase(add.getStatus())) {
// 获取流程启动人ID // 获取流程启动人ID
Long startUserId = LoginHelper.getUserId(); Long startUserId = LoginHelper.getUserId();
@@ -108,7 +125,11 @@ public class HrmAppropriationReqServiceImpl implements IHrmAppropriationReqServi
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Boolean updateByBo(HrmAppropriationReqBo bo) { public Boolean updateByBo(HrmAppropriationReqBo bo) {
HrmAppropriationReq update = BeanUtil.toBean(bo, HrmAppropriationReq.class); 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 @Override
@@ -125,6 +146,24 @@ public class HrmAppropriationReqServiceImpl implements IHrmAppropriationReqServi
return baseMapper.updateById(req) > 0; return baseMapper.updateById(req) > 0;
} }
private void saveInvoiceItems(String bizType, Long bizId, List<HrmInvoiceItemBo> boList) {
invoiceItemMapper.delete(Wrappers.<HrmInvoiceItem>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") @SuppressWarnings("unused")
private LambdaQueryWrapper<HrmAppropriationReq> buildQueryWrapper(HrmAppropriationReqBo bo) { private LambdaQueryWrapper<HrmAppropriationReq> buildQueryWrapper(HrmAppropriationReqBo bo) {
LambdaQueryWrapper<HrmAppropriationReq> lqw = Wrappers.lambdaQuery(); LambdaQueryWrapper<HrmAppropriationReq> lqw = Wrappers.lambdaQuery();

View File

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

View File

@@ -8,11 +8,14 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.ruoyi.common.core.domain.PageQuery; import com.ruoyi.common.core.domain.PageQuery;
import com.ruoyi.common.core.page.TableDataInfo; import com.ruoyi.common.core.page.TableDataInfo;
import com.ruoyi.hrm.domain.HrmFlowTemplate; import com.ruoyi.hrm.domain.HrmFlowTemplate;
import com.ruoyi.hrm.domain.HrmInvoiceItem;
import com.ruoyi.hrm.domain.HrmReimburseReq; import com.ruoyi.hrm.domain.HrmReimburseReq;
import com.ruoyi.hrm.domain.bo.HrmFlowStartBo; 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.bo.HrmReimburseReqBo;
import com.ruoyi.hrm.domain.vo.HrmReimburseReqVo; import com.ruoyi.hrm.domain.vo.HrmReimburseReqVo;
import com.ruoyi.hrm.mapper.HrmFlowTemplateMapper; import com.ruoyi.hrm.mapper.HrmFlowTemplateMapper;
import com.ruoyi.hrm.mapper.HrmInvoiceItemMapper;
import com.ruoyi.hrm.mapper.HrmReimburseReqMapper; import com.ruoyi.hrm.mapper.HrmReimburseReqMapper;
import com.ruoyi.hrm.service.IHrmFlowInstanceService; import com.ruoyi.hrm.service.IHrmFlowInstanceService;
import com.ruoyi.hrm.service.IHrmReimburseReqService; import com.ruoyi.hrm.service.IHrmReimburseReqService;
@@ -30,10 +33,18 @@ public class HrmReimburseReqServiceImpl implements IHrmReimburseReqService {
private final HrmReimburseReqMapper baseMapper; private final HrmReimburseReqMapper baseMapper;
private final HrmFlowTemplateMapper flowTemplateMapper; private final HrmFlowTemplateMapper flowTemplateMapper;
private final IHrmFlowInstanceService flowInstanceService; private final IHrmFlowInstanceService flowInstanceService;
private final HrmInvoiceItemMapper invoiceItemMapper;
@Override @Override
public HrmReimburseReqVo queryById(Long bizId) { public HrmReimburseReqVo queryById(Long bizId) {
return baseMapper.selectVoWithProjectById(bizId); HrmReimburseReqVo vo = baseMapper.selectVoWithProjectById(bizId);
if (vo != null) {
vo.setInvoiceItems(invoiceItemMapper.selectList(Wrappers.<HrmInvoiceItem>lambdaQuery()
.eq(HrmInvoiceItem::getBizType, "reimburse")
.eq(HrmInvoiceItem::getBizId, bizId)
.orderByAsc(HrmInvoiceItem::getSortNo)));
}
return vo;
} }
@Override @Override
@@ -61,6 +72,12 @@ public class HrmReimburseReqServiceImpl implements IHrmReimburseReqService {
boolean ok = baseMapper.insert(add) > 0; boolean ok = baseMapper.insert(add) > 0;
HrmReimburseReqVo bean = BeanUtil.toBean(add, HrmReimburseReqVo.class); 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())) { if (ok && "pending".equalsIgnoreCase(add.getStatus())) {
Long startUserId = LoginHelper.getUserId(); Long startUserId = LoginHelper.getUserId();
@@ -105,7 +122,11 @@ public class HrmReimburseReqServiceImpl implements IHrmReimburseReqService {
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public Boolean updateByBo(HrmReimburseReqBo bo) { public Boolean updateByBo(HrmReimburseReqBo bo) {
HrmReimburseReq update = BeanUtil.toBean(bo, HrmReimburseReq.class); 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 @Override
@@ -124,6 +145,25 @@ public class HrmReimburseReqServiceImpl implements IHrmReimburseReqService {
return lqw; return lqw;
} }
private void saveInvoiceItems(String bizType, Long bizId, List<HrmInvoiceItemBo> boList) {
// 先清除旧数据,再插入新数据(更新场景兼容)
invoiceItemMapper.delete(Wrappers.<HrmInvoiceItem>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) { private String defaultStatus(String status) {
return status == null ? "draft" : status; return status == null ? "draft" : status;
} }

View File

@@ -327,4 +327,9 @@ fad:
# 新增的前端 Web 端使用的 Key 和安全密钥 # 新增的前端 Web 端使用的 Key 和安全密钥
webKey: 34bf20d1db5b183558b9bb85d6eed783 webKey: 34bf20d1db5b183558b9bb85d6eed783
securityKey: 6f9171724396deb5f8c42ef256b3cbc5 securityKey: 6f9171724396deb5f8c42ef256b3cbc5
ocr:
# 发票OCR服务地址ai-ocr Python服务
url: http://127.0.0.1:8000
# OCR服务 API Key
api-key: change-me-debug-key

View File

@@ -57,3 +57,14 @@ export function getAppropriationStats (query) {
params: query params: query
}) })
} }
/**
* 通过ossId触发发票OCR识别返回识别条目不保存
*/
export function ocrAppropriationInvoice (ossId) {
return request({
url: '/hrm/appropriation/ocr-by-oss',
method: 'post',
params: { ossId }
})
}

View File

@@ -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 }
})
}

View File

@@ -47,13 +47,63 @@
<el-col> <el-col>
<el-form-item label="项目" prop="projectId"> <el-form-item label="项目" prop="projectId">
<project-select v-model="form.projectId" placeholder="请选择项目" style="width: 100%" /> <project-select v-model="form.projectId" placeholder="请选择项目" style="width: 100%" />
<!-- <el-input v-model="form.projectId" placeholder="请输入项目ID" style="width: 100%" /> -->
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="拨款事由" prop="reason"> <!-- 拨款单据附件OCR触发区 -->
<el-input v-model="form.reason" type="textarea" :rows="4" placeholder="请说明拨款事由、费用用途等" show-word-limit <el-form-item label="拨款单据附件" prop="accessoryApplyIds">
<file-upload v-model="form.accessoryApplyIds" :limit="50" :file-size="50"
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple />
<div class="hint-text">上传发票收据付款截图等支持 PDF/图片上传后自动识别</div>
</el-form-item>
<!-- OCR 识别中提示 -->
<div v-if="anyOcrLoading" class="ocr-thinking">
<i class="el-icon-loading"></i>
<span>模型思考中正在识别发票内容</span>
</div>
<!-- 发票明细条目表 -->
<div class="block-title">
拨款明细
<span class="block-title-hint">上传发票后自动填充也可手动添加</span>
</div>
<div class="invoice-table">
<div class="invoice-table-header">
<span class="col-reason">事由说明</span>
<span class="col-amount">金额</span>
<span class="col-action"></span>
</div>
<div v-for="(item, idx) in invoiceItems" :key="idx" class="invoice-table-row">
<el-input
v-model="item.reason"
:placeholder="item.itemName || '请填写事由'"
size="small"
class="col-reason"
/>
<el-input-number
v-model="item.amount"
:min="0"
:precision="2"
size="small"
class="col-amount"
@change="recalcTotal"
/>
<div class="col-action">
<el-button size="mini" type="danger" plain icon="el-icon-delete" circle @click="removeInvoiceItem(idx)" />
</div>
</div>
<div class="invoice-table-footer">
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addInvoiceItem">添加条目</el-button>
<span class="total-hint" v-if="invoiceItems.length">
合计<b>¥{{ invoiceTotalFormatted }}</b>已自动更新拨款总金额
</span>
</div>
</div>
<el-form-item label="补充说明" prop="reason">
<el-input v-model="form.reason" type="textarea" :rows="2" placeholder="可选:填写整体说明或补充" show-word-limit
maxlength="1000" /> maxlength="1000" />
</el-form-item> </el-form-item>
@@ -80,12 +130,6 @@
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="拨款单据附件" prop="accessoryApplyIds">
<file-upload v-model="form.accessoryApplyIds" :limit="50" :file-size="50"
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple />
<div class="hint-text">上传发票收据付款截图等必填</div>
</el-form-item>
<el-form-item label="回执附件(可选)" prop="accessoryReceiptIds"> <el-form-item label="回执附件(可选)" prop="accessoryReceiptIds">
<file-upload v-model="form.accessoryReceiptIds" :limit="10" :file-size="50" <file-upload v-model="form.accessoryReceiptIds" :limit="10" :file-size="50"
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple /> :file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple />
@@ -106,14 +150,13 @@
</el-tag> </el-tag>
</div> </div>
</el-form-item> </el-form-item>
<!-- 抄送备注 -->
<el-form-item label="抄送备注" prop="ccRemark"> <el-form-item label="抄送备注" prop="ccRemark">
<el-input v-model="ccForm.remark" type="textarea" :rows="2" placeholder="可以填写抄送的目的或原因等信息" show-word-limit <el-input v-model="ccForm.remark" type="textarea" :rows="2" placeholder="可以填写抄送的目的或原因等信息" show-word-limit
maxlength="1000" /> maxlength="1000" />
</el-form-item> </el-form-item>
<UserMultiSelect ref="userMultiSelect" :init="ccUserIds" @onSelected="onCcUsersSelected" /> <UserMultiSelect ref="userMultiSelect" :init="ccUserIds" @onSelected="onCcUsersSelected" />
<!-- 审批方式模板/自选审批人 --> <!-- 审批方式 -->
<div class="block-title">审批方式</div> <div class="block-title">审批方式</div>
<div class="approve-mode"> <div class="approve-mode">
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange"> <el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
@@ -163,18 +206,11 @@
</template> </template>
</div> </div>
<!-- 模板模式 -->
<div v-if="approverMode === 'template'"> <div v-if="approverMode === 'template'">
<div v-if="flowNodes && flowNodes.length" class="flow-steps"> <div v-if="flowNodes && flowNodes.length" class="flow-steps">
<div class="flow-step"> <div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
<div class="dot"></div>
<div class="txt">填写申请</div>
</div>
<div class="line"></div> <div class="line"></div>
<div class="flow-step"> <div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
<div class="dot"></div>
<div class="txt">提交</div>
</div>
<template v-for="(n, idx) in flowNodes"> <template v-for="(n, idx) in flowNodes">
<div class="line"></div> <div class="line"></div>
<div class="flow-step"> <div class="flow-step">
@@ -188,22 +224,12 @@
</div> </div>
</div> </div>
<!-- 手动审批模式 -->
<div v-else class="flow-steps"> <div v-else class="flow-steps">
<div class="flow-step"> <div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
<div class="dot"></div>
<div class="txt">填写申请</div>
</div>
<div class="line"></div> <div class="line"></div>
<div class="flow-step"> <div class="flow-step"><div class="dot"></div><div class="txt">提交审批{{ assigneeUserName || '请选择' }}</div></div>
<div class="dot"></div>
<div class="txt">提交审批{{ assigneeUserName || '请选择' }}</div>
</div>
<div class="line"></div> <div class="line"></div>
<div class="flow-step"> <div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
<div class="dot success"></div>
<div class="txt">审批结束</div>
</div>
</div> </div>
</div> </div>
@@ -213,14 +239,13 @@
</div> </div>
<UserSelect ref="userSelect" @onSelected="onUserSelected" /> <UserSelect ref="userSelect" @onSelected="onUserSelected" />
</el-form> </el-form>
</el-card> </el-card>
</div> </div>
</template> </template>
<script> <script>
import { addAppropriationReq, listFlowNode, listFlowTemplate } from '@/api/hrm'; import { addAppropriationReq, listFlowNode, listFlowTemplate, ocrAppropriationInvoice } from '@/api/hrm';
import { getEmployeeByUserId } from '@/api/hrm/employee'; import { getEmployeeByUserId } from '@/api/hrm/employee';
import { ccFlowTask } from '@/api/hrm/flow'; import { ccFlowTask } from '@/api/hrm/flow';
import FileUpload from '@/components/FileUpload'; import FileUpload from '@/components/FileUpload';
@@ -243,6 +268,12 @@ export default {
assigneeUserId: null, assigneeUserId: null,
assigneeUserName: '', assigneeUserName: '',
appropriationTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'], appropriationTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
// 发票明细条目
invoiceItems: [],
// OCR加载状态 { ossId: true/false }
ocrLoadingMap: {},
// 已触发过OCR的ossId集合
ocrDoneSet: new Set(),
form: { form: {
empId: '', empId: '',
appropriationType: '', appropriationType: '',
@@ -256,7 +287,6 @@ export default {
bankAccount: '' bankAccount: ''
}, },
ccForm: { ccForm: {
// 默认抄送胡雪娇
selectedUsers: [{ selectedUsers: [{
userId: '1859249502579310593', userId: '1859249502579310593',
nickName: '胡雪娇', nickName: '胡雪娇',
@@ -270,9 +300,7 @@ export default {
}, },
rules: { rules: {
appropriationType: [{ required: true, message: '请选择/输入拨款类型', trigger: 'change' }], appropriationType: [{ required: true, message: '请选择/输入拨款类型', trigger: 'change' }],
amount: [{ required: true, message: '请填写拨款总金额', trigger: 'blur' }], amount: [{ required: true, message: '请填写拨款总金额', trigger: 'blur' }]
reason: [{ required: true, message: '请填写拨款事由', trigger: 'blur' }],
// accessoryApplyIds: [{ required: true, message: '请上传拨款单据附件', trigger: 'change' }]
} }
} }
}, },
@@ -295,11 +323,79 @@ export default {
ccUserIds () { ccUserIds () {
return this.ccForm.selectedUsers?.map(u => u.userId) || [] return this.ccForm.selectedUsers?.map(u => u.userId) || []
}, },
anyOcrLoading () {
return Object.values(this.ocrLoadingMap).some(v => v)
},
invoiceTotalFormatted () {
const total = this.invoiceItems.reduce((sum, item) => sum + (parseFloat(item.amount) || 0), 0)
return total.toFixed(2)
}
},
watch: {
'form.accessoryApplyIds' (newVal, oldVal) {
const newIds = newVal ? newVal.split(',').filter(Boolean) : []
const oldIds = oldVal ? oldVal.split(',').filter(Boolean) : []
const added = newIds.filter(id => !oldIds.includes(id))
added.forEach(id => this.triggerOcr(id))
}
}, },
methods: { methods: {
openUserMultiSelect () { this.$refs.userMultiSelect.open() }, openUserMultiSelect () { this.$refs.userMultiSelect.open() },
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] }, onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) }, removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) },
async triggerOcr (ossId) {
if (this.ocrDoneSet.has(ossId)) return
this.$set(this.ocrLoadingMap, ossId, true)
try {
const res = await ocrAppropriationInvoice(ossId)
if (res.code === 200 && res.data) {
const { items, totalAmount } = res.data
if (items && items.length) {
const startIdx = this.invoiceItems.length
items.forEach((item, i) => {
this.invoiceItems.push({
ossId: Number(ossId),
itemName: item.itemName || '',
reason: item.itemName || '',
amount: item.amount || 0,
sortNo: startIdx + i
})
})
} else if (totalAmount) {
this.invoiceItems.push({
ossId: Number(ossId),
itemName: '',
reason: '',
amount: totalAmount,
sortNo: this.invoiceItems.length
})
}
this.recalcTotal()
}
this.ocrDoneSet.add(ossId)
} catch (e) {
console.error('[OCR] 识别失败', e)
this.$message.warning('发票识别失败,请手动填写事由和金额')
} finally {
this.$set(this.ocrLoadingMap, ossId, false)
}
},
addInvoiceItem () {
this.invoiceItems.push({ ossId: null, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
},
removeInvoiceItem (idx) {
this.invoiceItems.splice(idx, 1)
this.recalcTotal()
},
recalcTotal () {
const total = this.invoiceItems.reduce((sum, item) => sum + (parseFloat(item.amount) || 0), 0)
this.form.amount = parseFloat(total.toFixed(2))
},
async loadTemplates () { async loadTemplates () {
try { try {
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'appropriation', enabled: 1 }) const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'appropriation', enabled: 1 })
@@ -315,16 +411,8 @@ export default {
async refreshFlowPreview () { async refreshFlowPreview () {
this.flowLoading = true this.flowLoading = true
try { try {
if (this.approverMode === 'manual') { if (this.approverMode === 'manual') { this.flowTpl = null; this.flowNodes = []; return }
this.flowTpl = null if (!this.tplId) { this.flowTpl = null; this.flowNodes = []; return }
this.flowNodes = []
return
}
if (!this.tplId) {
this.flowTpl = null
this.flowNodes = []
return
}
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId }) const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId })
this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0)) this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
@@ -356,10 +444,7 @@ export default {
}, },
async loadCurrentEmployee () { async loadCurrentEmployee () {
const userId = this.$store?.state?.user?.id const userId = this.$store?.state?.user?.id
if (!userId) { if (!userId) { this.$message.error('无法获取当前用户信息,请重新登录'); return }
this.$message.error('无法获取当前用户信息,请重新登录')
return
}
try { try {
const res = await getEmployeeByUserId(userId) const res = await getEmployeeByUserId(userId)
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
@@ -372,18 +457,13 @@ export default {
this.$message.error('加载员工信息失败') this.$message.error('加载员工信息失败')
} }
}, },
async onTplChange (val) { async onTplChange (val) { this.tplId = val; await this.refreshFlowPreview() },
this.tplId = val
await this.refreshFlowPreview()
},
onApproverModeChange (val) { onApproverModeChange (val) {
this.approverMode = val this.approverMode = val
if (val === 'manual') this.tplId = null if (val === 'manual') this.tplId = null
this.refreshFlowPreview() this.refreshFlowPreview()
}, },
openUserSelect () { openUserSelect () { this.$refs.userSelect.open() },
this.$refs.userSelect.open()
},
onUserSelected (row) { onUserSelected (row) {
if (row) { if (row) {
this.assigneeUserId = row.userId this.assigneeUserId = row.userId
@@ -392,7 +472,6 @@ export default {
} }
}, },
async submit () { async submit () {
console.log('提交申请')
try { try {
await this.$refs.formRef.validate() await this.$refs.formRef.validate()
if (this.approverMode === 'template' && !this.tplId) { if (this.approverMode === 'template' && !this.tplId) {
@@ -413,37 +492,40 @@ export default {
remark: this.form.remark, remark: this.form.remark,
status: 'pending', status: 'pending',
projectId: this.form.projectId, projectId: this.form.projectId,
// tplId: this.tplId,
manualAssigneeUserId: this.assigneeUserId, manualAssigneeUserId: this.assigneeUserId,
payeeName: this.form.payeeName, payeeName: this.form.payeeName,
bankName: this.form.bankName, bankName: this.form.bankName,
bankAccount: this.form.bankAccount, bankAccount: this.form.bankAccount,
invoiceItems: this.invoiceItems.map((item, i) => ({
ossId: item.ossId || null,
sortNo: i,
itemName: item.itemName || '',
reason: item.reason || item.itemName || '',
amount: item.amount || 0
}))
} }
if (this.approverMode === 'template') { if (this.approverMode === 'template') {
payload.tplId = this.tplId payload.tplId = this.tplId
} }
const { data: instance } = await addAppropriationReq(payload) const { data: instance } = await addAppropriationReq(payload)
console.log(instance, this.ccForm)
if (this.ccForm.selectedUsers.length && instance?.instId) { if (this.ccForm.selectedUsers.length && instance?.instId) {
const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId) const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId)
const fromUserId = this.$store?.state?.user?.id const fromUserId = this.$store?.state?.user?.id
const payload = { await ccFlowTask({
instId: instance.instId, instId: instance.instId,
bizId: instance.bizId, bizId: instance.bizId,
bizType: 'appropriation', bizType: 'appropriation',
ccUserIds: ccUserIds, ccUserIds,
remark: this.ccForm.remark, remark: this.ccForm.remark,
fromUserId, fromUserId,
nodeId: 0, nodeId: 0,
readFlag: 0, readFlag: 0,
nodeName: '节点#0' nodeName: '节点#0'
} })
await ccFlowTask(payload)
} }
this.$message.success('提交成功') this.$message.success('提交成功')
this.$router.push('/hrm/apply') this.$router.push('/hrm/apply')
} catch (err) { } catch (err) {
// no-op
console.log(err) console.log(err)
} finally { } finally {
this.submitting = false this.submitting = false
@@ -475,14 +557,8 @@ export default {
color: #2b2f36; color: #2b2f36;
} }
.actions { .actions { display: flex; gap: 8px; }
display: flex; .metal-form { padding-right: 8px; }
gap: 8px;
}
.metal-form {
padding-right: 8px;
}
.hint-text { .hint-text {
margin-top: 6px; margin-top: 6px;
@@ -503,32 +579,84 @@ export default {
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%); background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
} }
.summary-title { .summary-title { font-size: 16px; font-weight: 800; color: #2b2f36; }
font-size: 16px; .summary-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
font-weight: 800; .summary-right { display: flex; gap: 16px; }
color: #2b2f36; .summary-item .k { font-size: 12px; color: #8a8f99; }
} .summary-item .v { margin-top: 2px; font-weight: 700; color: #2b2f36; }
.summary-sub { /* OCR 思考中提示 */
margin-top: 4px; .ocr-thinking {
font-size: 12px;
color: #8a8f99;
}
.summary-right {
display: flex; display: flex;
gap: 16px; align-items: center;
gap: 8px;
padding: 8px 14px;
margin: 8px 0 12px;
border-radius: 8px;
background: #f0f7ff;
border: 1px solid #b3d8ff;
color: #409eff;
font-size: 13px;
font-weight: 600;
i { font-size: 16px; animation: spin 1s linear infinite; }
} }
.summary-item .k { @keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 发票明细表 */
.invoice-table {
margin-bottom: 16px;
border: 1px solid #e6e8ed;
border-radius: 8px;
overflow: hidden;
}
.invoice-table-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: #f5f7fa;
border-bottom: 1px solid #e6e8ed;
font-size: 12px; font-size: 12px;
color: #8a8f99; color: #606266;
font-weight: 600;
} }
.summary-item .v { .invoice-table-row {
margin-top: 2px; display: flex;
font-weight: 700; align-items: center;
color: #2b2f36; padding: 8px 12px;
gap: 10px;
border-bottom: 1px solid #f2f3f5;
&:last-child { border-bottom: none; }
}
.col-reason { flex: 1; }
.col-amount { width: 140px; flex-shrink: 0; }
.col-action { width: 32px; flex-shrink: 0; display: flex; justify-content: center; }
.invoice-table-header .col-reason { flex: 1; }
.invoice-table-header .col-amount { width: 140px; }
.invoice-table-header .col-action { width: 32px; }
.invoice-table-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 12px;
background: #fafafa;
border-top: 1px solid #f0f1f3;
}
.total-hint {
font-size: 13px;
color: #606266;
b { color: #e6a23c; font-size: 14px; }
} }
.flow-preview { .flow-preview {
@@ -539,16 +667,8 @@ export default {
background: #fcfdff; background: #fcfdff;
} }
.flow-title { .flow-title { font-weight: 800; color: #2b2f36; }
font-weight: 800; .flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
color: #2b2f36;
}
.flow-sub {
margin-top: 4px;
font-size: 12px;
color: #8a8f99;
}
.flow-steps { .flow-steps {
margin-top: 10px; margin-top: 10px;
@@ -568,28 +688,10 @@ export default {
background: #fff; background: #fff;
} }
.flow-step .dot { .flow-step .dot { width: 8px; height: 8px; border-radius: 50%; background: #9aa3b2; }
width: 8px; .flow-step .dot.success { background: #67c23a; }
height: 8px; .flow-step .txt { font-size: 12px; color: #2b2f36; font-weight: 600; }
border-radius: 50%; .flow-steps .line { width: 26px; height: 1px; background: #e6e8ed; }
background: #9aa3b2;
}
.flow-step .dot.success {
background: #67c23a;
}
.flow-step .txt {
font-size: 12px;
color: #2b2f36;
font-weight: 600;
}
.flow-steps .line {
width: 26px;
height: 1px;
background: #e6e8ed;
}
.form-actions { .form-actions {
display: flex; display: flex;
@@ -599,9 +701,7 @@ export default {
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.summary-right { .summary-right { display: none; }
display: none;
}
} }
.block-title { .block-title {
@@ -612,6 +712,13 @@ export default {
border-left: 3px solid #9aa3b2; border-left: 3px solid #9aa3b2;
} }
.block-title-hint {
font-size: 12px;
font-weight: 400;
color: #8a8f99;
margin-left: 6px;
}
.approve-mode { .approve-mode {
padding: 12px; padding: 12px;
border: 1px solid #e6e8ed; border: 1px solid #e6e8ed;
@@ -619,18 +726,9 @@ export default {
background: #fcfdff; background: #fcfdff;
} }
.approve-panel { .approve-panel { margin-top: 12px; }
margin-top: 12px; .approve-row { display: flex; align-items: center; gap: 12px; }
} .approve-row .k { font-size: 14px; color: #606266; }
.approve-row { .selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
display: flex;
align-items: center;
gap: 12px;
}
.approve-row .k {
font-size: 14px;
color: #606266;
}
</style> </style>

View File

@@ -31,6 +31,21 @@
<file-preview v-model="detail.accessoryApplyIds"></file-preview> <file-preview v-model="detail.accessoryApplyIds"></file-preview>
</el-card> </el-card>
<!-- 发票明细 -->
<template v-if="detail.invoiceItems && detail.invoiceItems.length">
<div class="block-title">发票明细</div>
<el-card class="inner-card" shadow="never">
<el-table :data="detail.invoiceItems" border size="small" style="width:100%">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="itemName" label="项目名称" min-width="140" />
<el-table-column prop="reason" label="事由" min-width="180" />
<el-table-column prop="amount" label="金额(元)" width="120" align="right">
<template slot-scope="{ row }">¥{{ row.amount }}</template>
</el-table-column>
</el-table>
</el-card>
</template>
<!-- 拨款理由说明 --> <!-- 拨款理由说明 -->
<div class="block-title">拨款理由说明</div> <div class="block-title">拨款理由说明</div>
<el-card class="inner-card" shadow="never"> <el-card class="inner-card" shadow="never">

View File

@@ -47,20 +47,64 @@
<el-col> <el-col>
<el-form-item label="项目" prop="projectId"> <el-form-item label="项目" prop="projectId">
<project-select v-model="form.projectId" placeholder="请选择项目" style="width: 100%" /> <project-select v-model="form.projectId" placeholder="请选择项目" style="width: 100%" />
<!-- <el-input v-model="form.projectId" placeholder="请输入项目ID" style="width: 100%" /> -->
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row> </el-row>
<el-form-item label="报销事由" prop="reason"> <!-- 报销单据附件OCR触发区 -->
<el-input v-model="form.reason" type="textarea" :rows="4" placeholder="请说明报销事由、费用用途等" show-word-limit
maxlength="3000" />
</el-form-item>
<el-form-item label="报销单据附件" prop="accessoryApplyIds"> <el-form-item label="报销单据附件" prop="accessoryApplyIds">
<file-upload v-model="form.accessoryApplyIds" :limit="200" :file-size="50" <file-upload v-model="form.accessoryApplyIds" :limit="200" :file-size="50"
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple /> :file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple />
<div class="hint-text">上传发票收据付款截图等必填</div> <div class="hint-text">上传发票收据付款截图等支持 PDF/图片上传后自动识别</div>
</el-form-item>
<!-- OCR 识别中提示 -->
<div v-if="anyOcrLoading" class="ocr-thinking">
<i class="el-icon-loading"></i>
<span>模型思考中正在识别发票内容</span>
</div>
<!-- 发票明细条目表 -->
<div class="block-title">
发票明细
<span class="block-title-hint">上传发票后自动填充也可手动添加</span>
</div>
<div class="invoice-table">
<div class="invoice-table-header">
<span class="col-reason">事由说明</span>
<span class="col-amount">金额</span>
<span class="col-action"></span>
</div>
<div v-for="(item, idx) in invoiceItems" :key="idx" class="invoice-table-row">
<el-input
v-model="item.reason"
:placeholder="item.itemName || '请填写事由'"
size="small"
class="col-reason"
/>
<el-input-number
v-model="item.amount"
:min="0"
:precision="2"
size="small"
class="col-amount"
@change="recalcTotal"
/>
<div class="col-action">
<el-button size="mini" type="danger" plain icon="el-icon-delete" circle @click="removeInvoiceItem(idx)" />
</div>
</div>
<div class="invoice-table-footer">
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addInvoiceItem">添加条目</el-button>
<span class="total-hint" v-if="invoiceItems.length">
合计<b>¥{{ invoiceTotalFormatted }}</b>已自动更新报销总金额
</span>
</div>
</div>
<el-form-item label="补充说明" prop="reason">
<el-input v-model="form.reason" type="textarea" :rows="2" placeholder="可选:填写整体说明或补充" show-word-limit
maxlength="3000" />
</el-form-item> </el-form-item>
<el-form-item label="回执附件(可选)" prop="accessoryReceiptIds"> <el-form-item label="回执附件(可选)" prop="accessoryReceiptIds">
@@ -83,14 +127,13 @@
</el-tag> </el-tag>
</div> </div>
</el-form-item> </el-form-item>
<!-- 抄送备注 -->
<el-form-item label="抄送备注" prop="ccRemark"> <el-form-item label="抄送备注" prop="ccRemark">
<el-input v-model="ccForm.remark" type="textarea" :rows="2" placeholder="可以填写抄送的目的或原因等信息" show-word-limit <el-input v-model="ccForm.remark" type="textarea" :rows="2" placeholder="可以填写抄送的目的或原因等信息" show-word-limit
maxlength="1000" /> maxlength="1000" />
</el-form-item> </el-form-item>
<UserMultiSelect ref="userMultiSelect" :init="ccUserIds" @onSelected="onCcUsersSelected" /> <UserMultiSelect ref="userMultiSelect" :init="ccUserIds" @onSelected="onCcUsersSelected" />
<!-- 审批方式模板/自选审批人 --> <!-- 审批方式 -->
<div class="block-title">审批方式</div> <div class="block-title">审批方式</div>
<div class="approve-mode"> <div class="approve-mode">
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange"> <el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
@@ -140,18 +183,11 @@
</template> </template>
</div> </div>
<!-- 模板模式 -->
<div v-if="approverMode === 'template'"> <div v-if="approverMode === 'template'">
<div v-if="flowNodes && flowNodes.length" class="flow-steps"> <div v-if="flowNodes && flowNodes.length" class="flow-steps">
<div class="flow-step"> <div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
<div class="dot"></div>
<div class="txt">填写申请</div>
</div>
<div class="line"></div> <div class="line"></div>
<div class="flow-step"> <div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
<div class="dot"></div>
<div class="txt">提交</div>
</div>
<template v-for="(n, idx) in flowNodes"> <template v-for="(n, idx) in flowNodes">
<div class="line"></div> <div class="line"></div>
<div class="flow-step"> <div class="flow-step">
@@ -165,22 +201,12 @@
</div> </div>
</div> </div>
<!-- 手动审批模式 -->
<div v-else class="flow-steps"> <div v-else class="flow-steps">
<div class="flow-step"> <div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
<div class="dot"></div>
<div class="txt">填写申请</div>
</div>
<div class="line"></div> <div class="line"></div>
<div class="flow-step"> <div class="flow-step"><div class="dot"></div><div class="txt">提交审批{{ assigneeUserName || '请选择' }}</div></div>
<div class="dot"></div>
<div class="txt">提交审批{{ assigneeUserName || '请选择' }}</div>
</div>
<div class="line"></div> <div class="line"></div>
<div class="flow-step"> <div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
<div class="dot success"></div>
<div class="txt">审批结束</div>
</div>
</div> </div>
</div> </div>
@@ -190,14 +216,13 @@
</div> </div>
<UserSelect ref="userSelect" @onSelected="onUserSelected" /> <UserSelect ref="userSelect" @onSelected="onUserSelected" />
</el-form> </el-form>
</el-card> </el-card>
</div> </div>
</template> </template>
<script> <script>
import { addReimburseReq, listFlowNode, listFlowTemplate } from '@/api/hrm'; import { addReimburseReq, listFlowNode, listFlowTemplate, ocrReimburseInvoice } from '@/api/hrm';
import { getEmployeeByUserId } from '@/api/hrm/employee'; import { getEmployeeByUserId } from '@/api/hrm/employee';
import { ccFlowTask } from '@/api/hrm/flow'; import { ccFlowTask } from '@/api/hrm/flow';
import FileUpload from '@/components/FileUpload'; import FileUpload from '@/components/FileUpload';
@@ -220,6 +245,12 @@ export default {
assigneeUserId: null, assigneeUserId: null,
assigneeUserName: '', assigneeUserName: '',
reimburseTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'], reimburseTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
// 发票明细条目
invoiceItems: [],
// OCR加载状态 { ossId: true/false }
ocrLoadingMap: {},
// 已触发过OCR的ossId集合
ocrDoneSet: new Set(),
form: { form: {
empId: '', empId: '',
reimburseType: '', reimburseType: '',
@@ -230,7 +261,6 @@ export default {
remark: '' remark: ''
}, },
ccForm: { ccForm: {
// 默认抄送胡雪娇
selectedUsers: [{ selectedUsers: [{
userId: '1859249502579310593', userId: '1859249502579310593',
nickName: '胡雪娇', nickName: '胡雪娇',
@@ -245,7 +275,6 @@ export default {
rules: { rules: {
reimburseType: [{ required: true, message: '请选择/输入报销类型', trigger: 'change' }], reimburseType: [{ required: true, message: '请选择/输入报销类型', trigger: 'change' }],
totalAmount: [{ required: true, message: '请填写报销总金额', trigger: 'blur' }], totalAmount: [{ required: true, message: '请填写报销总金额', trigger: 'blur' }],
reason: [{ required: true, message: '请填写报销事由', trigger: 'blur' }],
accessoryApplyIds: [{ required: true, message: '请上传报销单据附件', trigger: 'change' }] accessoryApplyIds: [{ required: true, message: '请上传报销单据附件', trigger: 'change' }]
} }
} }
@@ -269,11 +298,80 @@ export default {
ccUserIds () { ccUserIds () {
return this.ccForm.selectedUsers?.map(u => u.userId) || [] return this.ccForm.selectedUsers?.map(u => u.userId) || []
}, },
anyOcrLoading () {
return Object.values(this.ocrLoadingMap).some(v => v)
},
invoiceTotalFormatted () {
const total = this.invoiceItems.reduce((sum, item) => sum + (parseFloat(item.amount) || 0), 0)
return total.toFixed(2)
}
},
watch: {
'form.accessoryApplyIds' (newVal, oldVal) {
const newIds = newVal ? newVal.split(',').filter(Boolean) : []
const oldIds = oldVal ? oldVal.split(',').filter(Boolean) : []
const added = newIds.filter(id => !oldIds.includes(id))
added.forEach(id => this.triggerOcr(id))
}
}, },
methods: { methods: {
openUserMultiSelect () { this.$refs.userMultiSelect.open() }, openUserMultiSelect () { this.$refs.userMultiSelect.open() },
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] }, onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) }, removeCcUser (user) { this.ccForm.selectedUsers = this.ccForm.selectedUsers.filter(u => u.userId !== user.userId) },
async triggerOcr (ossId) {
if (this.ocrDoneSet.has(ossId)) return
this.$set(this.ocrLoadingMap, ossId, true)
try {
const res = await ocrReimburseInvoice(ossId)
if (res.code === 200 && res.data) {
const { items, totalAmount } = res.data
if (items && items.length) {
const startIdx = this.invoiceItems.length
items.forEach((item, i) => {
this.invoiceItems.push({
ossId: Number(ossId),
itemName: item.itemName || '',
reason: item.itemName || '',
amount: item.amount || 0,
sortNo: startIdx + i
})
})
} else if (totalAmount) {
// 没有明细时用总金额创建一条
this.invoiceItems.push({
ossId: Number(ossId),
itemName: '',
reason: '',
amount: totalAmount,
sortNo: this.invoiceItems.length
})
}
this.recalcTotal()
}
this.ocrDoneSet.add(ossId)
} catch (e) {
console.error('[OCR] 识别失败', e)
this.$message.warning('发票识别失败,请手动填写事由和金额')
} finally {
this.$set(this.ocrLoadingMap, ossId, false)
}
},
addInvoiceItem () {
this.invoiceItems.push({ ossId: null, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
},
removeInvoiceItem (idx) {
this.invoiceItems.splice(idx, 1)
this.recalcTotal()
},
recalcTotal () {
const total = this.invoiceItems.reduce((sum, item) => sum + (parseFloat(item.amount) || 0), 0)
this.form.totalAmount = parseFloat(total.toFixed(2))
},
async loadTemplates () { async loadTemplates () {
try { try {
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'reimburse', enabled: 1 }) const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'reimburse', enabled: 1 })
@@ -289,16 +387,8 @@ export default {
async refreshFlowPreview () { async refreshFlowPreview () {
this.flowLoading = true this.flowLoading = true
try { try {
if (this.approverMode === 'manual') { if (this.approverMode === 'manual') { this.flowTpl = null; this.flowNodes = []; return }
this.flowTpl = null if (!this.tplId) { this.flowTpl = null; this.flowNodes = []; return }
this.flowNodes = []
return
}
if (!this.tplId) {
this.flowTpl = null
this.flowNodes = []
return
}
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId }) const nodeRes = await listFlowNode({ pageNum: 1, pageSize: 500, tplId: this.tplId })
this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0)) this.flowNodes = (nodeRes.rows || nodeRes.data || []).filter(n => n.nodeType !== 'end').sort((a, b) => (a.orderNo || 0) - (b.orderNo || 0))
@@ -330,10 +420,7 @@ export default {
}, },
async loadCurrentEmployee () { async loadCurrentEmployee () {
const userId = this.$store?.state?.user?.id const userId = this.$store?.state?.user?.id
if (!userId) { if (!userId) { this.$message.error('无法获取当前用户信息,请重新登录'); return }
this.$message.error('无法获取当前用户信息,请重新登录')
return
}
try { try {
const res = await getEmployeeByUserId(userId) const res = await getEmployeeByUserId(userId)
if (res.code === 200 && res.data) { if (res.code === 200 && res.data) {
@@ -346,18 +433,13 @@ export default {
this.$message.error('加载员工信息失败') this.$message.error('加载员工信息失败')
} }
}, },
async onTplChange (val) { async onTplChange (val) { this.tplId = val; await this.refreshFlowPreview() },
this.tplId = val
await this.refreshFlowPreview()
},
onApproverModeChange (val) { onApproverModeChange (val) {
this.approverMode = val this.approverMode = val
if (val === 'manual') this.tplId = null if (val === 'manual') this.tplId = null
this.refreshFlowPreview() this.refreshFlowPreview()
}, },
openUserSelect () { openUserSelect () { this.$refs.userSelect.open() },
this.$refs.userSelect.open()
},
onUserSelected (row) { onUserSelected (row) {
if (row) { if (row) {
this.assigneeUserId = row.userId this.assigneeUserId = row.userId
@@ -366,7 +448,6 @@ export default {
} }
}, },
async submit () { async submit () {
console.log('提交申请')
try { try {
await this.$refs.formRef.validate() await this.$refs.formRef.validate()
if (this.approverMode === 'template' && !this.tplId) { if (this.approverMode === 'template' && !this.tplId) {
@@ -387,29 +468,33 @@ export default {
remark: this.form.remark, remark: this.form.remark,
status: 'pending', status: 'pending',
projectId: this.form.projectId, projectId: this.form.projectId,
// tplId: this.tplId, manualAssigneeUserId: this.assigneeUserId,
manualAssigneeUserId: this.assigneeUserId invoiceItems: this.invoiceItems.map((item, i) => ({
ossId: item.ossId || null,
sortNo: i,
itemName: item.itemName || '',
reason: item.reason || item.itemName || '',
amount: item.amount || 0
}))
} }
if (this.approverMode === 'template') { if (this.approverMode === 'template') {
payload.tplId = this.tplId payload.tplId = this.tplId
} }
const { data: instance } = await addReimburseReq(payload) const { data: instance } = await addReimburseReq(payload)
console.log(instance, this.ccForm)
if (this.ccForm.selectedUsers.length && instance?.instId) { if (this.ccForm.selectedUsers.length && instance?.instId) {
const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId) const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId)
const fromUserId = this.$store?.state?.user?.id const fromUserId = this.$store?.state?.user?.id
const payload = { await ccFlowTask({
instId: instance.instId, instId: instance.instId,
bizId: instance.bizId, bizId: instance.bizId,
bizType: 'reimburse', bizType: 'reimburse',
ccUserIds: ccUserIds, ccUserIds,
remark: this.ccForm.remark, remark: this.ccForm.remark,
fromUserId, fromUserId,
nodeId: 0, nodeId: 0,
readFlag: 0, readFlag: 0,
nodeName: '节点#0' nodeName: '节点#0'
} })
await ccFlowTask(payload)
} }
this.$message.success('提交成功') this.$message.success('提交成功')
this.$router.push('/hrm/apply') this.$router.push('/hrm/apply')
@@ -445,14 +530,9 @@ export default {
color: #2b2f36; color: #2b2f36;
} }
.actions { .actions { display: flex; gap: 8px; }
display: flex;
gap: 8px;
}
.metal-form { .metal-form { padding-right: 8px; }
padding-right: 8px;
}
.hint-text { .hint-text {
margin-top: 6px; margin-top: 6px;
@@ -473,32 +553,97 @@ export default {
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%); background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
} }
.summary-title { .summary-title { font-size: 16px; font-weight: 800; color: #2b2f36; }
font-size: 16px; .summary-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
font-weight: 800; .summary-right { display: flex; gap: 16px; }
color: #2b2f36; .summary-item .k { font-size: 12px; color: #8a8f99; }
} .summary-item .v { margin-top: 2px; font-weight: 700; color: #2b2f36; }
.summary-sub { /* OCR 思考中提示 */
margin-top: 4px; .ocr-thinking {
font-size: 12px;
color: #8a8f99;
}
.summary-right {
display: flex; display: flex;
gap: 16px; align-items: center;
gap: 8px;
padding: 8px 14px;
margin: 8px 0 12px;
border-radius: 8px;
background: #f0f7ff;
border: 1px solid #b3d8ff;
color: #409eff;
font-size: 13px;
font-weight: 600;
i { font-size: 16px; animation: spin 1s linear infinite; }
} }
.summary-item .k { @keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 发票明细表 */
.invoice-table {
margin-bottom: 16px;
border: 1px solid #e6e8ed;
border-radius: 8px;
overflow: hidden;
}
.invoice-table-header {
display: flex;
align-items: center;
padding: 8px 12px;
background: #f5f7fa;
border-bottom: 1px solid #e6e8ed;
font-size: 12px; font-size: 12px;
color: #8a8f99; color: #606266;
font-weight: 600;
} }
.summary-item .v { .invoice-table-row {
margin-top: 2px; display: flex;
font-weight: 700; align-items: center;
color: #2b2f36; padding: 8px 12px;
gap: 10px;
border-bottom: 1px solid #f2f3f5;
&:last-child { border-bottom: none; }
}
.col-reason {
flex: 1;
}
.col-amount {
width: 140px;
flex-shrink: 0;
}
.col-action {
width: 32px;
flex-shrink: 0;
display: flex;
justify-content: center;
}
.invoice-table-header .col-reason { flex: 1; }
.invoice-table-header .col-amount { width: 140px; }
.invoice-table-header .col-action { width: 32px; }
.invoice-table-footer {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 12px;
background: #fafafa;
border-top: 1px solid #f0f1f3;
}
.total-hint {
font-size: 13px;
color: #606266;
b { color: #e6a23c; font-size: 14px; }
} }
.flow-preview { .flow-preview {
@@ -509,16 +654,8 @@ export default {
background: #fcfdff; background: #fcfdff;
} }
.flow-title { .flow-title { font-weight: 800; color: #2b2f36; }
font-weight: 800; .flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
color: #2b2f36;
}
.flow-sub {
margin-top: 4px;
font-size: 12px;
color: #8a8f99;
}
.flow-steps { .flow-steps {
margin-top: 10px; margin-top: 10px;
@@ -545,21 +682,9 @@ export default {
background: #9aa3b2; background: #9aa3b2;
} }
.flow-step .dot.success { .flow-step .dot.success { background: #67c23a; }
background: #67c23a; .flow-step .txt { font-size: 12px; color: #2b2f36; font-weight: 600; }
} .flow-steps .line { width: 26px; height: 1px; background: #e6e8ed; }
.flow-step .txt {
font-size: 12px;
color: #2b2f36;
font-weight: 600;
}
.flow-steps .line {
width: 26px;
height: 1px;
background: #e6e8ed;
}
.form-actions { .form-actions {
display: flex; display: flex;
@@ -569,9 +694,7 @@ export default {
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
.summary-right { .summary-right { display: none; }
display: none;
}
} }
.block-title { .block-title {
@@ -582,6 +705,13 @@ export default {
border-left: 3px solid #9aa3b2; border-left: 3px solid #9aa3b2;
} }
.block-title-hint {
font-size: 12px;
font-weight: 400;
color: #8a8f99;
margin-left: 6px;
}
.approve-mode { .approve-mode {
padding: 12px; padding: 12px;
border: 1px solid #e6e8ed; border: 1px solid #e6e8ed;
@@ -589,18 +719,9 @@ export default {
background: #fcfdff; background: #fcfdff;
} }
.approve-panel { .approve-panel { margin-top: 12px; }
margin-top: 12px; .approve-row { display: flex; align-items: center; gap: 12px; }
} .approve-row .k { font-size: 14px; color: #606266; }
.approve-row { .selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
display: flex;
align-items: center;
gap: 12px;
}
.approve-row .k {
font-size: 14px;
color: #606266;
}
</style> </style>

View File

@@ -22,6 +22,21 @@
<file-preview v-model="detail.accessoryApplyIds"></file-preview> <file-preview v-model="detail.accessoryApplyIds"></file-preview>
</el-card> </el-card>
<!-- 发票明细 -->
<template v-if="detail.invoiceItems && detail.invoiceItems.length">
<div class="block-title">发票明细</div>
<el-card class="inner-card" shadow="never">
<el-table :data="detail.invoiceItems" border size="small" style="width:100%">
<el-table-column type="index" label="序号" width="55" align="center" />
<el-table-column prop="itemName" label="项目名称" min-width="140" />
<el-table-column prop="reason" label="事由" min-width="180" />
<el-table-column prop="amount" label="金额(元)" width="120" align="right">
<template slot-scope="{ row }">¥{{ row.amount }}</template>
</el-table-column>
</el-table>
</el-card>
</template>
<!-- 报销理由说明 --> <!-- 报销理由说明 -->
<div class="block-title">报销理由说明</div> <div class="block-title">报销理由说明</div>
<el-card class="inner-card" shadow="never"> <el-card class="inner-card" shadow="never">

15
sql/hrm_invoice_item.sql Normal file
View File

@@ -0,0 +1,15 @@
-- 发票条目子表(报销单/拨款单共用biz_type 区分)
CREATE TABLE IF NOT EXISTS hrm_invoice_item
(
id BIGINT NOT NULL PRIMARY KEY COMMENT '主键(雪花ID)',
biz_type VARCHAR(32) NOT NULL COMMENT '业务类型 reimburse/appropriation',
biz_id BIGINT NOT NULL COMMENT '关联业务单ID',
oss_id BIGINT COMMENT '来源附件ossId',
sort_no INT DEFAULT 0 COMMENT '排序序号',
item_name VARCHAR(256) COMMENT 'OCR识别项目名称',
reason VARCHAR(512) COMMENT '事由说明(用户可编辑)',
amount DECIMAL(12, 2) DEFAULT 0 COMMENT '金额',
del_flag TINYINT DEFAULT 0 COMMENT '删除标识 0正常 2删除'
) COMMENT = '发票条目子表';
CREATE INDEX idx_biz ON hrm_invoice_item (biz_type, biz_id);