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:
@@ -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 = "";
|
||||
}
|
||||
@@ -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<HrmAppropriationReqVo> 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<HrmInvoiceOcrResultVo> ocrByOss(@RequestParam @NotNull Long ossId) {
|
||||
return R.ok(invoiceOcrService.recognizeByOssId(ossId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HrmReimburseReqVo> 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<HrmInvoiceOcrResultVo> ocrByOss(@RequestParam @NotNull Long ossId) {
|
||||
return R.ok(invoiceOcrService.recognizeByOssId(ossId));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HrmInvoiceItemBo> invoiceItems;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HrmInvoiceItemBo> invoiceItems;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<HrmInvoiceItem> invoiceItems;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<HrmInvoiceItem> invoiceItems;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.<HrmInvoiceItem>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<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")
|
||||
private LambdaQueryWrapper<HrmAppropriationReq> buildQueryWrapper(HrmAppropriationReqBo bo) {
|
||||
LambdaQueryWrapper<HrmAppropriationReq> lqw = Wrappers.lambdaQuery();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.<HrmInvoiceItem>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<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) {
|
||||
return status == null ? "draft" : status;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -47,13 +47,63 @@
|
||||
<el-col>
|
||||
<el-form-item label="项目" prop="projectId">
|
||||
<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-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="拨款事由" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="4" placeholder="请说明拨款事由、费用用途等" show-word-limit
|
||||
<!-- 拨款单据附件(OCR触发区) -->
|
||||
<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" />
|
||||
</el-form-item>
|
||||
|
||||
@@ -80,12 +130,6 @@
|
||||
</el-col>
|
||||
</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">
|
||||
<file-upload v-model="form.accessoryReceiptIds" :limit="10" :file-size="50"
|
||||
:file-type="['pdf', 'jpg', 'jpeg', 'png', 'doc', 'docx']" multiple />
|
||||
@@ -106,14 +150,13 @@
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<!-- 抄送备注 -->
|
||||
<el-form-item label="抄送备注" prop="ccRemark">
|
||||
<el-input v-model="ccForm.remark" type="textarea" :rows="2" placeholder="可以填写抄送的目的或原因等信息" show-word-limit
|
||||
maxlength="1000" />
|
||||
</el-form-item>
|
||||
<UserMultiSelect ref="userMultiSelect" :init="ccUserIds" @onSelected="onCcUsersSelected" />
|
||||
|
||||
<!-- 审批方式(模板/自选审批人) -->
|
||||
<!-- 审批方式 -->
|
||||
<div class="block-title">审批方式</div>
|
||||
<div class="approve-mode">
|
||||
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
|
||||
@@ -163,18 +206,11 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 模板模式 -->
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div v-if="flowNodes && flowNodes.length" class="flow-steps">
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">填写申请</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">提交</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
|
||||
<template v-for="(n, idx) in flowNodes">
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
@@ -188,22 +224,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动审批模式 -->
|
||||
<div v-else class="flow-steps">
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">填写申请</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">提交审批({{ assigneeUserName || '请选择' }})</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交审批({{ assigneeUserName || '请选择' }})</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
<div class="dot success"></div>
|
||||
<div class="txt">审批结束</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -213,14 +239,13 @@
|
||||
</div>
|
||||
|
||||
<UserSelect ref="userSelect" @onSelected="onUserSelected" />
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { addAppropriationReq, listFlowNode, listFlowTemplate } from '@/api/hrm';
|
||||
import { addAppropriationReq, listFlowNode, listFlowTemplate, ocrAppropriationInvoice } from '@/api/hrm';
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee';
|
||||
import { ccFlowTask } from '@/api/hrm/flow';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
@@ -243,6 +268,12 @@ export default {
|
||||
assigneeUserId: null,
|
||||
assigneeUserName: '',
|
||||
appropriationTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
|
||||
// 发票明细条目
|
||||
invoiceItems: [],
|
||||
// OCR加载状态 { ossId: true/false }
|
||||
ocrLoadingMap: {},
|
||||
// 已触发过OCR的ossId集合
|
||||
ocrDoneSet: new Set(),
|
||||
form: {
|
||||
empId: '',
|
||||
appropriationType: '',
|
||||
@@ -256,7 +287,6 @@ export default {
|
||||
bankAccount: ''
|
||||
},
|
||||
ccForm: {
|
||||
// 默认抄送胡雪娇
|
||||
selectedUsers: [{
|
||||
userId: '1859249502579310593',
|
||||
nickName: '胡雪娇',
|
||||
@@ -270,9 +300,7 @@ export default {
|
||||
},
|
||||
rules: {
|
||||
appropriationType: [{ required: true, message: '请选择/输入拨款类型', trigger: 'change' }],
|
||||
amount: [{ required: true, message: '请填写拨款总金额', trigger: 'blur' }],
|
||||
reason: [{ required: true, message: '请填写拨款事由', trigger: 'blur' }],
|
||||
// accessoryApplyIds: [{ required: true, message: '请上传拨款单据附件', trigger: 'change' }]
|
||||
amount: [{ required: true, message: '请填写拨款总金额', trigger: 'blur' }]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -295,11 +323,79 @@ export default {
|
||||
ccUserIds () {
|
||||
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: {
|
||||
openUserMultiSelect () { this.$refs.userMultiSelect.open() },
|
||||
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
|
||||
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 () {
|
||||
try {
|
||||
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'appropriation', enabled: 1 })
|
||||
@@ -315,16 +411,8 @@ export default {
|
||||
async refreshFlowPreview () {
|
||||
this.flowLoading = true
|
||||
try {
|
||||
if (this.approverMode === 'manual') {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
if (!this.tplId) {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
if (this.approverMode === 'manual') { this.flowTpl = null; this.flowNodes = []; return }
|
||||
if (!this.tplId) { this.flowTpl = null; this.flowNodes = []; return }
|
||||
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
|
||||
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))
|
||||
@@ -356,10 +444,7 @@ export default {
|
||||
},
|
||||
async loadCurrentEmployee () {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
if (!userId) {
|
||||
this.$message.error('无法获取当前用户信息,请重新登录')
|
||||
return
|
||||
}
|
||||
if (!userId) { this.$message.error('无法获取当前用户信息,请重新登录'); return }
|
||||
try {
|
||||
const res = await getEmployeeByUserId(userId)
|
||||
if (res.code === 200 && res.data) {
|
||||
@@ -372,18 +457,13 @@ export default {
|
||||
this.$message.error('加载员工信息失败')
|
||||
}
|
||||
},
|
||||
async onTplChange (val) {
|
||||
this.tplId = val
|
||||
await this.refreshFlowPreview()
|
||||
},
|
||||
async onTplChange (val) { this.tplId = val; await this.refreshFlowPreview() },
|
||||
onApproverModeChange (val) {
|
||||
this.approverMode = val
|
||||
if (val === 'manual') this.tplId = null
|
||||
this.refreshFlowPreview()
|
||||
},
|
||||
openUserSelect () {
|
||||
this.$refs.userSelect.open()
|
||||
},
|
||||
openUserSelect () { this.$refs.userSelect.open() },
|
||||
onUserSelected (row) {
|
||||
if (row) {
|
||||
this.assigneeUserId = row.userId
|
||||
@@ -392,7 +472,6 @@ export default {
|
||||
}
|
||||
},
|
||||
async submit () {
|
||||
console.log('提交申请')
|
||||
try {
|
||||
await this.$refs.formRef.validate()
|
||||
if (this.approverMode === 'template' && !this.tplId) {
|
||||
@@ -413,37 +492,40 @@ export default {
|
||||
remark: this.form.remark,
|
||||
status: 'pending',
|
||||
projectId: this.form.projectId,
|
||||
// tplId: this.tplId,
|
||||
manualAssigneeUserId: this.assigneeUserId,
|
||||
payeeName: this.form.payeeName,
|
||||
bankName: this.form.bankName,
|
||||
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') {
|
||||
payload.tplId = this.tplId
|
||||
}
|
||||
const { data: instance } = await addAppropriationReq(payload)
|
||||
console.log(instance, this.ccForm)
|
||||
if (this.ccForm.selectedUsers.length && instance?.instId) {
|
||||
const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId)
|
||||
const fromUserId = this.$store?.state?.user?.id
|
||||
const payload = {
|
||||
await ccFlowTask({
|
||||
instId: instance.instId,
|
||||
bizId: instance.bizId,
|
||||
bizType: 'appropriation',
|
||||
ccUserIds: ccUserIds,
|
||||
ccUserIds,
|
||||
remark: this.ccForm.remark,
|
||||
fromUserId,
|
||||
nodeId: 0,
|
||||
readFlag: 0,
|
||||
nodeName: '节点#0'
|
||||
}
|
||||
await ccFlowTask(payload)
|
||||
})
|
||||
}
|
||||
this.$message.success('提交成功')
|
||||
this.$router.push('/hrm/apply')
|
||||
} catch (err) {
|
||||
// no-op
|
||||
console.log(err)
|
||||
} finally {
|
||||
this.submitting = false
|
||||
@@ -475,14 +557,8 @@ export default {
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metal-form {
|
||||
padding-right: 8px;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; }
|
||||
.metal-form { padding-right: 8px; }
|
||||
|
||||
.hint-text {
|
||||
margin-top: 6px;
|
||||
@@ -503,32 +579,84 @@ export default {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
.summary-title { font-size: 16px; font-weight: 800; color: #2b2f36; }
|
||||
.summary-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
|
||||
.summary-right { display: flex; gap: 16px; }
|
||||
.summary-item .k { font-size: 12px; color: #8a8f99; }
|
||||
.summary-item .v { margin-top: 2px; font-weight: 700; color: #2b2f36; }
|
||||
|
||||
.summary-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.summary-right {
|
||||
/* OCR 思考中提示 */
|
||||
.ocr-thinking {
|
||||
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;
|
||||
color: #8a8f99;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-item .v {
|
||||
margin-top: 2px;
|
||||
font-weight: 700;
|
||||
color: #2b2f36;
|
||||
.invoice-table-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -539,16 +667,8 @@ export default {
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.flow-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
.flow-title { font-weight: 800; color: #2b2f36; }
|
||||
.flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
|
||||
|
||||
.flow-steps {
|
||||
margin-top: 10px;
|
||||
@@ -568,28 +688,10 @@ export default {
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.flow-step .dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
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;
|
||||
}
|
||||
.flow-step .dot { width: 8px; height: 8px; border-radius: 50%; 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 {
|
||||
display: flex;
|
||||
@@ -599,9 +701,7 @@ export default {
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-right {
|
||||
display: none;
|
||||
}
|
||||
.summary-right { display: none; }
|
||||
}
|
||||
|
||||
.block-title {
|
||||
@@ -612,6 +712,13 @@ export default {
|
||||
border-left: 3px solid #9aa3b2;
|
||||
}
|
||||
|
||||
.block-title-hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #8a8f99;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.approve-mode {
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
@@ -619,18 +726,9 @@ export default {
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.approve-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.approve-panel { margin-top: 12px; }
|
||||
.approve-row { display: flex; align-items: center; gap: 12px; }
|
||||
.approve-row .k { font-size: 14px; color: #606266; }
|
||||
|
||||
.approve-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approve-row .k {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -31,6 +31,21 @@
|
||||
<file-preview v-model="detail.accessoryApplyIds"></file-preview>
|
||||
</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>
|
||||
<el-card class="inner-card" shadow="never">
|
||||
|
||||
@@ -47,20 +47,64 @@
|
||||
<el-col>
|
||||
<el-form-item label="项目" prop="projectId">
|
||||
<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-col>
|
||||
</el-row>
|
||||
|
||||
<el-form-item label="报销事由" prop="reason">
|
||||
<el-input v-model="form.reason" type="textarea" :rows="4" placeholder="请说明报销事由、费用用途等" show-word-limit
|
||||
maxlength="3000" />
|
||||
</el-form-item>
|
||||
|
||||
<!-- 报销单据附件(OCR触发区) -->
|
||||
<el-form-item label="报销单据附件" prop="accessoryApplyIds">
|
||||
<file-upload v-model="form.accessoryApplyIds" :limit="200" :file-size="50"
|
||||
: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 label="回执附件(可选)" prop="accessoryReceiptIds">
|
||||
@@ -83,14 +127,13 @@
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-form-item>
|
||||
<!-- 抄送备注 -->
|
||||
<el-form-item label="抄送备注" prop="ccRemark">
|
||||
<el-input v-model="ccForm.remark" type="textarea" :rows="2" placeholder="可以填写抄送的目的或原因等信息" show-word-limit
|
||||
maxlength="1000" />
|
||||
</el-form-item>
|
||||
<UserMultiSelect ref="userMultiSelect" :init="ccUserIds" @onSelected="onCcUsersSelected" />
|
||||
|
||||
<!-- 审批方式(模板/自选审批人) -->
|
||||
<!-- 审批方式 -->
|
||||
<div class="block-title">审批方式</div>
|
||||
<div class="approve-mode">
|
||||
<el-radio-group v-model="approverMode" size="small" @change="onApproverModeChange">
|
||||
@@ -140,18 +183,11 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- 模板模式 -->
|
||||
<div v-if="approverMode === 'template'">
|
||||
<div v-if="flowNodes && flowNodes.length" class="flow-steps">
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">填写申请</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">提交</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交</div></div>
|
||||
<template v-for="(n, idx) in flowNodes">
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
@@ -165,22 +201,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动审批模式 -->
|
||||
<div v-else class="flow-steps">
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">填写申请</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">填写申请</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
<div class="dot"></div>
|
||||
<div class="txt">提交审批({{ assigneeUserName || '请选择' }})</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot"></div><div class="txt">提交审批({{ assigneeUserName || '请选择' }})</div></div>
|
||||
<div class="line"></div>
|
||||
<div class="flow-step">
|
||||
<div class="dot success"></div>
|
||||
<div class="txt">审批结束</div>
|
||||
</div>
|
||||
<div class="flow-step"><div class="dot success"></div><div class="txt">审批结束</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -190,14 +216,13 @@
|
||||
</div>
|
||||
|
||||
<UserSelect ref="userSelect" @onSelected="onUserSelected" />
|
||||
|
||||
</el-form>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { addReimburseReq, listFlowNode, listFlowTemplate } from '@/api/hrm';
|
||||
import { addReimburseReq, listFlowNode, listFlowTemplate, ocrReimburseInvoice } from '@/api/hrm';
|
||||
import { getEmployeeByUserId } from '@/api/hrm/employee';
|
||||
import { ccFlowTask } from '@/api/hrm/flow';
|
||||
import FileUpload from '@/components/FileUpload';
|
||||
@@ -220,6 +245,12 @@ export default {
|
||||
assigneeUserId: null,
|
||||
assigneeUserName: '',
|
||||
reimburseTypeOptions: ['差旅费', '招待费', '办公费', '交通费', '通讯费', '其他'],
|
||||
// 发票明细条目
|
||||
invoiceItems: [],
|
||||
// OCR加载状态 { ossId: true/false }
|
||||
ocrLoadingMap: {},
|
||||
// 已触发过OCR的ossId集合
|
||||
ocrDoneSet: new Set(),
|
||||
form: {
|
||||
empId: '',
|
||||
reimburseType: '',
|
||||
@@ -230,7 +261,6 @@ export default {
|
||||
remark: ''
|
||||
},
|
||||
ccForm: {
|
||||
// 默认抄送胡雪娇
|
||||
selectedUsers: [{
|
||||
userId: '1859249502579310593',
|
||||
nickName: '胡雪娇',
|
||||
@@ -245,7 +275,6 @@ export default {
|
||||
rules: {
|
||||
reimburseType: [{ required: true, message: '请选择/输入报销类型', trigger: 'change' }],
|
||||
totalAmount: [{ required: true, message: '请填写报销总金额', trigger: 'blur' }],
|
||||
reason: [{ required: true, message: '请填写报销事由', trigger: 'blur' }],
|
||||
accessoryApplyIds: [{ required: true, message: '请上传报销单据附件', trigger: 'change' }]
|
||||
}
|
||||
}
|
||||
@@ -269,11 +298,80 @@ export default {
|
||||
ccUserIds () {
|
||||
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: {
|
||||
openUserMultiSelect () { this.$refs.userMultiSelect.open() },
|
||||
onCcUsersSelected (users) { this.ccForm.selectedUsers = users || [] },
|
||||
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 () {
|
||||
try {
|
||||
const res = await listFlowTemplate({ pageNum: 1, pageSize: 200, bizType: 'reimburse', enabled: 1 })
|
||||
@@ -289,16 +387,8 @@ export default {
|
||||
async refreshFlowPreview () {
|
||||
this.flowLoading = true
|
||||
try {
|
||||
if (this.approverMode === 'manual') {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
if (!this.tplId) {
|
||||
this.flowTpl = null
|
||||
this.flowNodes = []
|
||||
return
|
||||
}
|
||||
if (this.approverMode === 'manual') { this.flowTpl = null; this.flowNodes = []; return }
|
||||
if (!this.tplId) { this.flowTpl = null; this.flowNodes = []; return }
|
||||
this.flowTpl = this.availableTpls.find(t => t.tplId === this.tplId) || null
|
||||
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))
|
||||
@@ -330,10 +420,7 @@ export default {
|
||||
},
|
||||
async loadCurrentEmployee () {
|
||||
const userId = this.$store?.state?.user?.id
|
||||
if (!userId) {
|
||||
this.$message.error('无法获取当前用户信息,请重新登录')
|
||||
return
|
||||
}
|
||||
if (!userId) { this.$message.error('无法获取当前用户信息,请重新登录'); return }
|
||||
try {
|
||||
const res = await getEmployeeByUserId(userId)
|
||||
if (res.code === 200 && res.data) {
|
||||
@@ -346,18 +433,13 @@ export default {
|
||||
this.$message.error('加载员工信息失败')
|
||||
}
|
||||
},
|
||||
async onTplChange (val) {
|
||||
this.tplId = val
|
||||
await this.refreshFlowPreview()
|
||||
},
|
||||
async onTplChange (val) { this.tplId = val; await this.refreshFlowPreview() },
|
||||
onApproverModeChange (val) {
|
||||
this.approverMode = val
|
||||
if (val === 'manual') this.tplId = null
|
||||
this.refreshFlowPreview()
|
||||
},
|
||||
openUserSelect () {
|
||||
this.$refs.userSelect.open()
|
||||
},
|
||||
openUserSelect () { this.$refs.userSelect.open() },
|
||||
onUserSelected (row) {
|
||||
if (row) {
|
||||
this.assigneeUserId = row.userId
|
||||
@@ -366,7 +448,6 @@ export default {
|
||||
}
|
||||
},
|
||||
async submit () {
|
||||
console.log('提交申请')
|
||||
try {
|
||||
await this.$refs.formRef.validate()
|
||||
if (this.approverMode === 'template' && !this.tplId) {
|
||||
@@ -387,29 +468,33 @@ export default {
|
||||
remark: this.form.remark,
|
||||
status: 'pending',
|
||||
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') {
|
||||
payload.tplId = this.tplId
|
||||
}
|
||||
const { data: instance } = await addReimburseReq(payload)
|
||||
console.log(instance, this.ccForm)
|
||||
if (this.ccForm.selectedUsers.length && instance?.instId) {
|
||||
const ccUserIds = this.ccForm.selectedUsers.map(u => u.userId)
|
||||
const fromUserId = this.$store?.state?.user?.id
|
||||
const payload = {
|
||||
await ccFlowTask({
|
||||
instId: instance.instId,
|
||||
bizId: instance.bizId,
|
||||
bizType: 'reimburse',
|
||||
ccUserIds: ccUserIds,
|
||||
ccUserIds,
|
||||
remark: this.ccForm.remark,
|
||||
fromUserId,
|
||||
nodeId: 0,
|
||||
readFlag: 0,
|
||||
nodeName: '节点#0'
|
||||
}
|
||||
await ccFlowTask(payload)
|
||||
})
|
||||
}
|
||||
this.$message.success('提交成功')
|
||||
this.$router.push('/hrm/apply')
|
||||
@@ -445,14 +530,9 @@ export default {
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.actions { display: flex; gap: 8px; }
|
||||
|
||||
.metal-form {
|
||||
padding-right: 8px;
|
||||
}
|
||||
.metal-form { padding-right: 8px; }
|
||||
|
||||
.hint-text {
|
||||
margin-top: 6px;
|
||||
@@ -473,32 +553,97 @@ export default {
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcfe 100%);
|
||||
}
|
||||
|
||||
.summary-title {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
.summary-title { font-size: 16px; font-weight: 800; color: #2b2f36; }
|
||||
.summary-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
|
||||
.summary-right { display: flex; gap: 16px; }
|
||||
.summary-item .k { font-size: 12px; color: #8a8f99; }
|
||||
.summary-item .v { margin-top: 2px; font-weight: 700; color: #2b2f36; }
|
||||
|
||||
.summary-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
|
||||
.summary-right {
|
||||
/* OCR 思考中提示 */
|
||||
.ocr-thinking {
|
||||
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;
|
||||
color: #8a8f99;
|
||||
color: #606266;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.summary-item .v {
|
||||
margin-top: 2px;
|
||||
font-weight: 700;
|
||||
color: #2b2f36;
|
||||
.invoice-table-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 {
|
||||
@@ -509,16 +654,8 @@ export default {
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.flow-title {
|
||||
font-weight: 800;
|
||||
color: #2b2f36;
|
||||
}
|
||||
|
||||
.flow-sub {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #8a8f99;
|
||||
}
|
||||
.flow-title { font-weight: 800; color: #2b2f36; }
|
||||
.flow-sub { margin-top: 4px; font-size: 12px; color: #8a8f99; }
|
||||
|
||||
.flow-steps {
|
||||
margin-top: 10px;
|
||||
@@ -545,21 +682,9 @@ export default {
|
||||
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;
|
||||
}
|
||||
.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 {
|
||||
display: flex;
|
||||
@@ -569,9 +694,7 @@ export default {
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.summary-right {
|
||||
display: none;
|
||||
}
|
||||
.summary-right { display: none; }
|
||||
}
|
||||
|
||||
.block-title {
|
||||
@@ -582,6 +705,13 @@ export default {
|
||||
border-left: 3px solid #9aa3b2;
|
||||
}
|
||||
|
||||
.block-title-hint {
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: #8a8f99;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.approve-mode {
|
||||
padding: 12px;
|
||||
border: 1px solid #e6e8ed;
|
||||
@@ -589,18 +719,9 @@ export default {
|
||||
background: #fcfdff;
|
||||
}
|
||||
|
||||
.approve-panel {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.approve-panel { margin-top: 12px; }
|
||||
.approve-row { display: flex; align-items: center; gap: 12px; }
|
||||
.approve-row .k { font-size: 14px; color: #606266; }
|
||||
|
||||
.approve-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approve-row .k {
|
||||
font-size: 14px;
|
||||
color: #606266;
|
||||
}
|
||||
.selected-users { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,21 @@
|
||||
<file-preview v-model="detail.accessoryApplyIds"></file-preview>
|
||||
</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>
|
||||
<el-card class="inner-card" shadow="never">
|
||||
|
||||
15
sql/hrm_invoice_item.sql
Normal file
15
sql/hrm_invoice_item.sql
Normal 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);
|
||||
Reference in New Issue
Block a user