采购需求去掉横向滚动问题
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
package com.ruoyi.hrm.service.impl;
|
||||
|
||||
import cn.hutool.core.io.IoUtil;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ArrayNode;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import com.google.zxing.BinaryBitmap;
|
||||
import com.google.zxing.DecodeHintType;
|
||||
import com.google.zxing.MultiFormatReader;
|
||||
@@ -26,22 +30,34 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.EnumMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 发票识别服务实现:本地三段式管线,无任何外部 API 调用。
|
||||
* 单据识别服务实现:按附件类型分流。
|
||||
*
|
||||
* <p><b>图片单据</b>(jpg/png 等,可不是规范发票):直接调用小米 MiMo 多模态大模型识别,
|
||||
* 返回标题 + 金额(复用 application.yml 中 mimo.* 配置)。
|
||||
*
|
||||
* <p><b>PDF 电子发票</b>:本地三段式管线,无外部 API:
|
||||
* <ol>
|
||||
* <li>PDFBox 文本层抽取:原生电子发票直接搞定(毫秒级,几乎零算力)</li>
|
||||
* <li>ZXing 二维码识别:拍照/扫描发票 PDF,从二维码读结构化字段</li>
|
||||
@@ -57,11 +73,28 @@ import java.util.regex.Pattern;
|
||||
public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
|
||||
|
||||
private final SysOssMapper sysOssMapper;
|
||||
private final ObjectMapper jsonMapper = new ObjectMapper();
|
||||
|
||||
/** 可通过 application.yml 覆盖;默认 jar 同级目录下的 tessdata */
|
||||
@Value("${fad.ocr.tessdata-path:}")
|
||||
private String tessdataPathConfig;
|
||||
|
||||
// === 小米 MiMo 多模态大模型(识别图片单据,复用 application.yml 里的 mimo.* 配置)===
|
||||
@Value("${mimo.base-url:https://api.xiaomimimo.com/v1}")
|
||||
private String mimoBaseUrl;
|
||||
@Value("${mimo.api-key:}")
|
||||
private String mimoApiKey;
|
||||
@Value("${mimo.model:mimo-v2.5}")
|
||||
private String mimoModel;
|
||||
@Value("${mimo.max-tokens:8192}")
|
||||
private Integer mimoMaxTokens;
|
||||
@Value("${mimo.timeout:180}")
|
||||
private Integer mimoTimeout;
|
||||
|
||||
/** 走多模态识别的图片后缀 */
|
||||
private static final Set<String> IMAGE_SUFFIXES =
|
||||
new HashSet<>(Arrays.asList("jpg", "jpeg", "png", "webp", "bmp", "gif"));
|
||||
|
||||
private String tessdataPath;
|
||||
|
||||
@PostConstruct
|
||||
@@ -108,9 +141,6 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
|
||||
throw new ServiceException("附件不存在: " + ossId);
|
||||
}
|
||||
String suffix = StringUtils.defaultIfBlank(oss.getFileSuffix(), "").toLowerCase().replace(".", "");
|
||||
if (!"pdf".equals(suffix)) {
|
||||
throw new ServiceException("仅支持 PDF 电子发票,当前文件类型: " + suffix);
|
||||
}
|
||||
|
||||
byte[] fileBytes;
|
||||
try (InputStream in = OssFactory.instance().getObjectContent(oss.getUrl())) {
|
||||
@@ -119,6 +149,15 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
|
||||
throw new ServiceException("读取附件失败: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 图片单据:直接走小米多模态大模型识别(可识别非发票单据)
|
||||
if (IMAGE_SUFFIXES.contains(suffix)) {
|
||||
log.info("[Invoice] 图片单据走多模态识别, suffix={}", suffix);
|
||||
return recognizeImageByMiMo(fileBytes, suffix);
|
||||
}
|
||||
if (!"pdf".equals(suffix)) {
|
||||
throw new ServiceException("仅支持 PDF 电子发票或图片单据(jpg/png),当前文件类型: " + suffix);
|
||||
}
|
||||
|
||||
try (PDDocument doc = PDDocument.load(new ByteArrayInputStream(fileBytes))) {
|
||||
// 第一步:文本层
|
||||
String text = extractText(doc);
|
||||
@@ -342,4 +381,149 @@ public class HrmInvoiceOcrServiceImpl implements IHrmInvoiceOcrService {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 图片单据:小米多模态识别 ====================
|
||||
|
||||
private static final String VISION_SYSTEM_PROMPT =
|
||||
"你是财务单据识别助手。用户上传的是报销/拨款单据图片,可能是增值税发票、收据、出租车/火车票、"
|
||||
+ "餐饮小票、合同、对账单等,不一定是规范发票。请仔细识别图片中的费用条目与金额。";
|
||||
|
||||
private static final String VISION_USER_PROMPT =
|
||||
"请识别这张单据,只输出严格的 JSON(不要任何解释文字、不要 markdown 代码块),格式:"
|
||||
+ "{\"items\":[{\"title\":\"简洁的费用标题,如 滴滴出行-市内交通 / 餐饮-海底捞 / XX酒店住宿\",\"amount\":数字}],"
|
||||
+ "\"totalAmount\":数字}。amount 与 totalAmount 均为纯数字(不带货币符号/逗号);"
|
||||
+ "识别不到金额则填 0;一般一张单据对应一条 items,若单据内含多笔明细可拆成多条。";
|
||||
|
||||
/** 图片 → 多模态识别 → 结构化结果 */
|
||||
private HrmInvoiceOcrResultVo recognizeImageByMiMo(byte[] bytes, String suffix) {
|
||||
if (StringUtils.isBlank(mimoApiKey)) {
|
||||
throw new ServiceException("未配置 AI 识别服务(mimo.api-key),无法识别图片单据");
|
||||
}
|
||||
String mime = "jpg".equals(suffix) ? "jpeg" : suffix;
|
||||
String dataUri = "data:image/" + mime + ";base64,"
|
||||
+ java.util.Base64.getEncoder().encodeToString(bytes);
|
||||
String content = callMiMoVision(dataUri);
|
||||
return parseVisionJson(content);
|
||||
}
|
||||
|
||||
/** 调用小米 MiMo /chat/completions(OpenAI 兼容,多模态、非流式),返回 message.content */
|
||||
private String callMiMoVision(String imageDataUri) {
|
||||
ObjectNode body = jsonMapper.createObjectNode();
|
||||
body.put("model", mimoModel);
|
||||
body.put("max_completion_tokens", mimoMaxTokens);
|
||||
body.put("max_tokens", mimoMaxTokens);
|
||||
body.put("temperature", 0.2);
|
||||
body.put("stream", false);
|
||||
ArrayNode messages = body.putArray("messages");
|
||||
messages.addObject().put("role", "system").put("content", VISION_SYSTEM_PROMPT);
|
||||
ObjectNode userMsg = messages.addObject();
|
||||
userMsg.put("role", "user");
|
||||
ArrayNode contentArr = userMsg.putArray("content");
|
||||
contentArr.addObject().put("type", "text").put("text", VISION_USER_PROMPT);
|
||||
ObjectNode img = contentArr.addObject();
|
||||
img.put("type", "image_url");
|
||||
img.putObject("image_url").put("url", imageDataUri);
|
||||
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(mimoBaseUrl + "/chat/completions");
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(10_000);
|
||||
conn.setReadTimeout((mimoTimeout == null ? 180 : mimoTimeout) * 1000);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
conn.setRequestProperty("api-key", mimoApiKey);
|
||||
conn.setRequestProperty("Authorization", "Bearer " + mimoApiKey);
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(jsonMapper.writeValueAsString(body).getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
int code = conn.getResponseCode();
|
||||
if (code >= 400) {
|
||||
String err = readStream(conn.getErrorStream());
|
||||
throw new ServiceException("AI 识别服务返回 " + code + ":" + StringUtils.substring(err, 0, 300));
|
||||
}
|
||||
JsonNode root = jsonMapper.readTree(readStream(conn.getInputStream()));
|
||||
JsonNode message = root.path("choices").path(0).path("message");
|
||||
String content = message.path("content").asText("");
|
||||
if (StringUtils.isBlank(content)) {
|
||||
String finish = root.path("choices").path(0).path("finish_reason").asText("");
|
||||
if ("length".equals(finish)) {
|
||||
throw new ServiceException("AI 识别输出被截断,请重试或提高 mimo.max-tokens");
|
||||
}
|
||||
throw new ServiceException("AI 未返回有效识别内容");
|
||||
}
|
||||
return content;
|
||||
} catch (ServiceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("[Invoice] 调用 MiMo 多模态识别失败", e);
|
||||
throw new ServiceException("AI 识别服务调用失败:" + e.getMessage());
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析模型返回的 JSON(容错:去掉 markdown 代码块,截取首尾花括号) */
|
||||
private HrmInvoiceOcrResultVo parseVisionJson(String content) {
|
||||
HrmInvoiceOcrResultVo vo = new HrmInvoiceOcrResultVo();
|
||||
vo.setInvoiceType("AI图片识别");
|
||||
List<HrmInvoiceOcrResultVo.Item> items = new ArrayList<>();
|
||||
try {
|
||||
String jsonStr = extractJson(content);
|
||||
JsonNode root = jsonMapper.readTree(jsonStr);
|
||||
JsonNode arr = root.path("items");
|
||||
if (arr.isArray()) {
|
||||
for (JsonNode n : arr) {
|
||||
HrmInvoiceOcrResultVo.Item it = new HrmInvoiceOcrResultVo.Item();
|
||||
it.setItemName(StringUtils.trimToEmpty(n.path("title").asText("")));
|
||||
it.setAmount(parseBigDecimal(n.path("amount").asText(null)));
|
||||
items.add(it);
|
||||
}
|
||||
}
|
||||
JsonNode total = root.get("totalAmount");
|
||||
if (total != null && !total.isNull()) {
|
||||
vo.setTotalAmount(parseBigDecimal(total.asText(null)));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("[Invoice] 解析 AI 识别 JSON 失败,原始内容: {}", StringUtils.substring(content, 0, 300));
|
||||
}
|
||||
if (items.isEmpty()) {
|
||||
HrmInvoiceOcrResultVo.Item it = new HrmInvoiceOcrResultVo.Item();
|
||||
it.setItemName("");
|
||||
it.setAmount(vo.getTotalAmount());
|
||||
items.add(it);
|
||||
}
|
||||
// 无总额时用明细汇总
|
||||
if (vo.getTotalAmount() == null) {
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
for (HrmInvoiceOcrResultVo.Item it : items) {
|
||||
if (it.getAmount() != null) sum = sum.add(it.getAmount());
|
||||
}
|
||||
vo.setTotalAmount(sum);
|
||||
}
|
||||
vo.setItems(items);
|
||||
return vo;
|
||||
}
|
||||
|
||||
/** 从可能带 ```json 包裹或前后缀文字的字符串里截取 JSON 主体 */
|
||||
private static String extractJson(String raw) {
|
||||
if (raw == null) return "{}";
|
||||
String s = raw.trim();
|
||||
int begin = s.indexOf('{');
|
||||
int end = s.lastIndexOf('}');
|
||||
return (begin >= 0 && end > begin) ? s.substring(begin, end + 1) : s;
|
||||
}
|
||||
|
||||
private static String readStream(InputStream in) {
|
||||
if (in == null) return "";
|
||||
try (BufferedReader r = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
String l;
|
||||
while ((l = r.readLine()) != null) sb.append(l).append('\n');
|
||||
return sb.toString();
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,40 +53,47 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 拨款单据附件(仅 PDF 电子发票) -->
|
||||
<!-- 拨款单据附件(PDF 电子发票 / 单据图片,可多文件) -->
|
||||
<el-form-item label="拨款单据附件" prop="accessoryApplyIds">
|
||||
<file-upload v-model="form.accessoryApplyIds" :limit="50" :file-size="50"
|
||||
:file-type="['pdf']" multiple
|
||||
:file-type="['pdf', 'jpg', 'jpeg', 'png']" multiple
|
||||
@delete="onFileDelete" />
|
||||
<div class="hint-text">
|
||||
仅支持 PDF 电子发票(数电票 / 电子普通发票 / 电子专用发票),上传后自动解析金额与明细。<br/>
|
||||
扫描件 / 图片票 / 纸质票请先在开票平台下载 PDF 原件再上传。
|
||||
可上传多份单据:PDF 电子发票(数电票/电子普通/专用发票)走文字提取;<br/>
|
||||
单据图片(jpg/png,可不是发票样式,如收据/车票/小票)将自动调用 AI 识别金额与标题。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- OCR 识别中提示 -->
|
||||
<!-- 识别中提示 -->
|
||||
<div v-if="anyOcrLoading" class="ocr-thinking">
|
||||
<i class="el-icon-loading"></i>
|
||||
<span>正在解析发票 PDF…</span>
|
||||
<span>正在识别单据…</span>
|
||||
</div>
|
||||
|
||||
<!-- 发票明细条目表 -->
|
||||
<!-- 单据明细条目表 -->
|
||||
<div class="block-title">
|
||||
拨款明细
|
||||
<span class="block-title-hint">(上传 PDF 后自动解析;解析失败或无发票时可手动添加)</span>
|
||||
单据明细
|
||||
<span class="block-title-hint">(上传后自动识别标题与金额;事由请手动填写,可手动添加条目)</span>
|
||||
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addManualItem" style="margin-left:12px;vertical-align:middle">手动添加条目</el-button>
|
||||
</div>
|
||||
<div class="invoice-table" v-if="invoiceItems.length">
|
||||
<div class="invoice-table-header">
|
||||
<span class="col-reason">事由说明</span>
|
||||
<span class="col-title">标题</span>
|
||||
<span class="col-reason">事由</span>
|
||||
<span class="col-amount">金额(元)</span>
|
||||
<span class="col-file">附件</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.itemName"
|
||||
placeholder="识别标题(可编辑)"
|
||||
size="small"
|
||||
class="col-title"
|
||||
/>
|
||||
<el-input
|
||||
v-model="item.reason"
|
||||
:placeholder="item.itemName || '请填写事由'"
|
||||
placeholder="请手动填写事由"
|
||||
size="small"
|
||||
class="col-reason"
|
||||
/>
|
||||
@@ -104,7 +111,7 @@
|
||||
</el-tooltip>
|
||||
<el-upload v-else :action="uploadFileUrl" :headers="uploadHeaders" :show-file-list="false"
|
||||
:data="{ isPublic: 1 }" :on-success="(res) => onRowFileSuccess(res, idx)"
|
||||
accept=".pdf" class="row-upload">
|
||||
accept=".pdf,.jpg,.jpeg,.png" class="row-upload">
|
||||
<el-button size="mini" type="text" icon="el-icon-paperclip"></el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
@@ -375,26 +382,27 @@ export default {
|
||||
const res = await ocrAppropriationInvoice(ossId)
|
||||
if (res.code === 200 && res.data) {
|
||||
const { items, totalAmount, sellerName, invoiceDate, invoiceType } = res.data
|
||||
// 拼接发票头部信息作为事由前缀:发票类型 · 销售方 · 开票日期
|
||||
// 发票头部信息(类型 · 销售方 · 开票日期)作为标题前缀,PDF 票据才有
|
||||
const prefix = [invoiceType, sellerName, invoiceDate].filter(Boolean).join(' · ')
|
||||
if (items && items.length) {
|
||||
const startIdx = this.invoiceItems.length
|
||||
items.forEach((item, i) => {
|
||||
const reason = [prefix, item.itemName].filter(Boolean).join(' / ')
|
||||
// 标题 = 识别结果(图片单据直接为 AI 标题;PDF 票据拼接发票头部)
|
||||
const title = [prefix, item.itemName].filter(Boolean).join(' / ')
|
||||
this.invoiceItems.push({
|
||||
ossId: ossId,
|
||||
itemName: item.itemName || '',
|
||||
reason,
|
||||
itemName: title,
|
||||
reason: '', // 事由由用户手动填写
|
||||
amount: item.amount || 0,
|
||||
sortNo: startIdx + i
|
||||
})
|
||||
})
|
||||
} else if (totalAmount) {
|
||||
// 没有明细时用总金额创建一条,事由取发票头部信息
|
||||
// 没有明细时用总金额创建一条,标题取发票头部信息
|
||||
this.invoiceItems.push({
|
||||
ossId: ossId,
|
||||
itemName: sellerName || '',
|
||||
reason: prefix || '',
|
||||
itemName: prefix || sellerName || '',
|
||||
reason: '',
|
||||
amount: totalAmount,
|
||||
sortNo: this.invoiceItems.length
|
||||
})
|
||||
@@ -408,10 +416,10 @@ export default {
|
||||
this.invoiceItems.push({ ossId: ossId, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Invoice] 解析失败', e)
|
||||
console.error('[Invoice] 识别失败', e)
|
||||
const msg = (e && e.msg) || (e && e.message) || ''
|
||||
this.$message.warning(
|
||||
'发票解析失败,请确认上传的是开票平台下载的正规 PDF 电子发票(数电票 / 电子普通发票 / 电子专用发票)。' +
|
||||
'单据识别失败,已为你添加空白条目,可手动填写标题、事由与金额。' +
|
||||
(msg ? ' 错误信息:' + msg : '')
|
||||
)
|
||||
this.invoiceItems.push({ ossId: ossId, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
|
||||
@@ -551,8 +559,8 @@ export default {
|
||||
invoiceItems: this.invoiceItems.map((item, i) => ({
|
||||
ossId: item.ossId || null,
|
||||
sortNo: i,
|
||||
itemName: item.itemName || '',
|
||||
reason: item.reason || item.itemName || '',
|
||||
itemName: item.itemName || '', // 标题
|
||||
reason: item.reason || '', // 事由(手写)
|
||||
amount: item.amount || 0
|
||||
}))
|
||||
}
|
||||
@@ -688,11 +696,13 @@ export default {
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.col-title { flex: 1; }
|
||||
.col-reason { flex: 1; }
|
||||
.col-amount { width: 140px; flex-shrink: 0; }
|
||||
.col-file { width: 64px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.col-action { width: 32px; flex-shrink: 0; display: flex; justify-content: center; }
|
||||
|
||||
.invoice-table-header .col-title { flex: 1; }
|
||||
.invoice-table-header .col-reason { flex: 1; }
|
||||
.invoice-table-header .col-amount { width: 140px; }
|
||||
.invoice-table-header .col-file { width: 64px; }
|
||||
|
||||
@@ -26,11 +26,11 @@
|
||||
|
||||
<!-- 发票明细(含附件下载) -->
|
||||
<template v-if="detail.invoiceItems && detail.invoiceItems.length">
|
||||
<div class="block-title">发票明细</div>
|
||||
<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="120" />
|
||||
<el-table-column prop="itemName" label="标题" min-width="160" />
|
||||
<el-table-column prop="reason" label="事由" min-width="160" />
|
||||
<el-table-column prop="amount" label="金额(元)" width="110" align="right">
|
||||
<template slot-scope="{ row }">¥{{ row.amount }}</template>
|
||||
|
||||
@@ -53,40 +53,47 @@
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<!-- 报销单据附件(仅 PDF 电子发票) -->
|
||||
<!-- 报销单据附件(PDF 电子发票 / 单据图片,可多文件) -->
|
||||
<el-form-item label="报销单据附件" prop="accessoryApplyIds">
|
||||
<file-upload v-model="form.accessoryApplyIds" :limit="200" :file-size="50"
|
||||
:file-type="['pdf']" multiple
|
||||
:file-type="['pdf', 'jpg', 'jpeg', 'png']" multiple
|
||||
@delete="onFileDelete" />
|
||||
<div class="hint-text">
|
||||
仅支持 PDF 电子发票(含数电票/电子普通发票/电子专用发票),上传后自动解析金额与明细。<br/>
|
||||
扫描件 / 图片票 / 纸质票请先在开票平台下载 PDF 原件再上传。
|
||||
可上传多份单据:PDF 电子发票(数电票/电子普通/专用发票)走文字提取;<br/>
|
||||
单据图片(jpg/png,可不是发票样式,如收据/车票/小票)将自动调用 AI 识别金额与标题。
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<!-- 发票解析中提示 -->
|
||||
<!-- 识别中提示 -->
|
||||
<div v-if="anyOcrLoading" class="ocr-thinking">
|
||||
<i class="el-icon-loading"></i>
|
||||
<span>正在解析发票 PDF…</span>
|
||||
<span>正在识别单据…</span>
|
||||
</div>
|
||||
|
||||
<!-- 发票明细条目表 -->
|
||||
<!-- 单据明细条目表 -->
|
||||
<div class="block-title">
|
||||
发票明细
|
||||
<span class="block-title-hint">(上传 PDF 后自动解析;解析失败或无发票时可手动添加)</span>
|
||||
单据明细
|
||||
<span class="block-title-hint">(上传后自动识别标题与金额;事由请手动填写,可手动添加条目)</span>
|
||||
<el-button size="mini" type="primary" plain icon="el-icon-plus" @click="addManualItem" style="margin-left:12px;vertical-align:middle">手动添加条目</el-button>
|
||||
</div>
|
||||
<div class="invoice-table" v-if="invoiceItems.length">
|
||||
<div class="invoice-table-header">
|
||||
<span class="col-reason">事由说明</span>
|
||||
<span class="col-title">标题</span>
|
||||
<span class="col-reason">事由</span>
|
||||
<span class="col-amount">金额(元)</span>
|
||||
<span class="col-file">附件</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.itemName"
|
||||
placeholder="识别标题(可编辑)"
|
||||
size="small"
|
||||
class="col-title"
|
||||
/>
|
||||
<el-input
|
||||
v-model="item.reason"
|
||||
:placeholder="item.itemName || '请填写事由'"
|
||||
placeholder="请手动填写事由"
|
||||
size="small"
|
||||
class="col-reason"
|
||||
/>
|
||||
@@ -104,7 +111,7 @@
|
||||
</el-tooltip>
|
||||
<el-upload v-else :action="uploadFileUrl" :headers="uploadHeaders" :show-file-list="false"
|
||||
:data="{ isPublic: 1 }" :on-success="(res) => onRowFileSuccess(res, idx)"
|
||||
accept=".pdf" class="row-upload">
|
||||
accept=".pdf,.jpg,.jpeg,.png" class="row-upload">
|
||||
<el-button size="mini" type="text" icon="el-icon-paperclip"></el-button>
|
||||
</el-upload>
|
||||
</div>
|
||||
@@ -350,26 +357,27 @@ export default {
|
||||
const res = await ocrReimburseInvoice(ossId)
|
||||
if (res.code === 200 && res.data) {
|
||||
const { items, totalAmount, sellerName, invoiceDate, invoiceType } = res.data
|
||||
// 拼接发票头部信息作为事由前缀:发票类型 · 销售方 · 开票日期
|
||||
// 发票头部信息(类型 · 销售方 · 开票日期)作为标题前缀,PDF 票据才有
|
||||
const prefix = [invoiceType, sellerName, invoiceDate].filter(Boolean).join(' · ')
|
||||
if (items && items.length) {
|
||||
const startIdx = this.invoiceItems.length
|
||||
items.forEach((item, i) => {
|
||||
const reason = [prefix, item.itemName].filter(Boolean).join(' / ')
|
||||
// 标题 = 识别结果(图片单据直接为 AI 标题;PDF 票据拼接发票头部)
|
||||
const title = [prefix, item.itemName].filter(Boolean).join(' / ')
|
||||
this.invoiceItems.push({
|
||||
ossId: ossId,
|
||||
itemName: item.itemName || '',
|
||||
reason,
|
||||
itemName: title,
|
||||
reason: '', // 事由由用户手动填写
|
||||
amount: item.amount || 0,
|
||||
sortNo: startIdx + i
|
||||
})
|
||||
})
|
||||
} else if (totalAmount) {
|
||||
// 没有明细时用总金额创建一条,事由取发票头部信息
|
||||
// 没有明细时用总金额创建一条,标题取发票头部信息
|
||||
this.invoiceItems.push({
|
||||
ossId: ossId,
|
||||
itemName: sellerName || '',
|
||||
reason: prefix || '',
|
||||
itemName: prefix || sellerName || '',
|
||||
reason: '',
|
||||
amount: totalAmount,
|
||||
sortNo: this.invoiceItems.length
|
||||
})
|
||||
@@ -383,10 +391,10 @@ export default {
|
||||
this.invoiceItems.push({ ossId: ossId, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Invoice] 解析失败', e)
|
||||
console.error('[Invoice] 识别失败', e)
|
||||
const msg = (e && e.msg) || (e && e.message) || ''
|
||||
this.$message.warning(
|
||||
'发票解析失败,请确认上传的是开票平台下载的正规 PDF 电子发票(数电票 / 电子普通发票 / 电子专用发票)。' +
|
||||
'单据识别失败,已为你添加空白条目,可手动填写标题、事由与金额。' +
|
||||
(msg ? ' 错误信息:' + msg : '')
|
||||
)
|
||||
this.invoiceItems.push({ ossId: ossId, itemName: '', reason: '', amount: 0, sortNo: this.invoiceItems.length })
|
||||
@@ -523,8 +531,8 @@ export default {
|
||||
invoiceItems: this.invoiceItems.map((item, i) => ({
|
||||
ossId: item.ossId || null,
|
||||
sortNo: i,
|
||||
itemName: item.itemName || '',
|
||||
reason: item.reason || item.itemName || '',
|
||||
itemName: item.itemName || '', // 标题
|
||||
reason: item.reason || '', // 事由(手写)
|
||||
amount: item.amount || 0
|
||||
}))
|
||||
}
|
||||
@@ -661,6 +669,10 @@ export default {
|
||||
&:last-child { border-bottom: none; }
|
||||
}
|
||||
|
||||
.col-title {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.col-reason {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -685,6 +697,7 @@ export default {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.invoice-table-header .col-title { flex: 1; }
|
||||
.invoice-table-header .col-reason { flex: 1; }
|
||||
.invoice-table-header .col-amount { width: 140px; }
|
||||
.invoice-table-header .col-file { width: 64px; }
|
||||
|
||||
@@ -17,11 +17,11 @@
|
||||
|
||||
<!-- 发票明细(含附件下载) -->
|
||||
<template v-if="detail.invoiceItems && detail.invoiceItems.length">
|
||||
<div class="block-title">发票明细</div>
|
||||
<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="120" />
|
||||
<el-table-column prop="itemName" label="标题" min-width="160" />
|
||||
<el-table-column prop="reason" label="事由" min-width="160" />
|
||||
<el-table-column prop="amount" label="金额(元)" width="110" align="right">
|
||||
<template slot-scope="{ row }">¥{{ row.amount }}</template>
|
||||
|
||||
@@ -66,13 +66,47 @@
|
||||
|
||||
<!-- 新增提示组件 -->
|
||||
<el-alert title="提示:列表存在分页,部分信息需翻页查看" type="info" closable show-icon style="margin-bottom: 10px;" />
|
||||
<el-table v-loading="loading" :data="requirementsList" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="55" align="center" />
|
||||
<el-table-column label="需求标题" align="center" prop="title" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="需求方" align="center" prop="requesterNickName" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="负责人" align="center" prop="ownerNickName" width="100" show-overflow-tooltip />
|
||||
<el-table-column label="关联项目" align="center" prop="projectName" min-width="160" show-overflow-tooltip />
|
||||
<el-table-column label="采购物料" align="left" min-width="240">
|
||||
<el-table v-loading="loading" :data="requirementsList" style="width: 100%" @selection-change="handleSelectionChange">
|
||||
<el-table-column type="selection" width="45" align="center" />
|
||||
<el-table-column label="操作" align="center" width="130" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" icon="el-icon-view" @click="showDetail(scope.row)">详情</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-check" @click="handleComplete(scope.row)"
|
||||
v-if="scope.row.status === 1">完成</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
|
||||
v-if="scope.row.status === 0">修改</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="需求标题" align="center" prop="title" min-width="130">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tooltip effect="dark" :content="row.title || ''" placement="top" :disabled="!row.title">
|
||||
<div class="cell-2line">{{ row.title }}</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="需求方" align="center" prop="requesterNickName" width="80">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tooltip effect="dark" :content="row.requesterNickName || ''" placement="top" :disabled="!row.requesterNickName">
|
||||
<div class="cell-2line">{{ row.requesterNickName }}</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="负责人" align="center" prop="ownerNickName" width="80">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tooltip effect="dark" :content="row.ownerNickName || ''" placement="top" :disabled="!row.ownerNickName">
|
||||
<div class="cell-2line">{{ row.ownerNickName }}</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关联项目" align="center" prop="projectName" min-width="130">
|
||||
<template slot-scope="{ row }">
|
||||
<el-tooltip effect="dark" :content="row.projectName || ''" placement="top" :disabled="!row.projectName">
|
||||
<div class="cell-2line">{{ row.projectName }}</div>
|
||||
</el-tooltip>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="采购物料" align="left" min-width="170">
|
||||
<template slot-scope="{ row }">
|
||||
<template v-if="row.materials && row.materials.length">
|
||||
<div v-for="m in row.materials" :key="m.id" class="mat-row">
|
||||
@@ -85,24 +119,21 @@
|
||||
<span v-else style="color:#c0c4cc;">未关联</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="需求描述" align="center" prop="description" min-width="200" show-overflow-tooltip>
|
||||
<el-table-column label="需求描述" align="center" prop="description" min-width="150">
|
||||
<template slot-scope="{ row }">
|
||||
<span v-if="row.description" class="copyable-text" @click="copyText(row.description)"
|
||||
title="点击复制">{{ row.description }}</span>
|
||||
<el-tooltip v-if="row.description" effect="dark" :content="row.description" placement="top">
|
||||
<div class="cell-2line copyable-text" @click="copyText(row.description)" title="点击复制">{{ row.description }}</div>
|
||||
</el-tooltip>
|
||||
<span v-else style="color:#c0c4cc;">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="开始时间" align="center" prop="createTime" width="180">
|
||||
<el-table-column label="起止时间" align="center" width="110">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d}') }}</span>
|
||||
<div class="date-cell">{{ parseTime(scope.row.createTime, '{y}-{m}-{d}') }}</div>
|
||||
<div class="date-cell deadline">{{ parseTime(scope.row.deadline, '{y}-{m}-{d}') }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="截止日期" align="center" prop="deadline" width="180">
|
||||
<template slot-scope="scope">
|
||||
<span>{{ parseTime(scope.row.deadline, '{y}-{m}-{d}') }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="剩余时间" align="center">
|
||||
<el-table-column label="剩余时间" align="center" width="90">
|
||||
<template slot-scope="scope">
|
||||
<el-tag v-if="scope.row.status !== 2" :type="getRemainTagType(scope.row.deadline)"
|
||||
:style="getRemainTagStyle(scope.row.deadline)">
|
||||
@@ -131,7 +162,7 @@
|
||||
</el-select>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="附件" align="center" prop="accessoryFiles" width="180">
|
||||
<el-table-column label="附件" align="center" prop="accessoryFiles" width="120">
|
||||
<template slot-scope="{ row }">
|
||||
<template v-if="parseAccessoryFiles(row.accessoryFiles).length">
|
||||
<el-tooltip v-for="f in parseAccessoryFiles(row.accessoryFiles)" :key="f.ossId"
|
||||
@@ -145,16 +176,6 @@
|
||||
<span v-else style="color:#c0c4cc;">无</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" align="center" class-name="small-padding fixed-width">
|
||||
<template slot-scope="scope">
|
||||
<el-button size="mini" type="text" icon="el-icon-check" @click="handleComplete(scope.row)"
|
||||
v-if="scope.row.status === 1">完成</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)"
|
||||
v-if="scope.row.status === 0">修改</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-delete" @click="handleDelete(scope.row)">删除</el-button>
|
||||
<el-button size="mini" type="text" icon="el-icon-view" @click="showDetail(scope.row)">详情</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<pagination v-show="total > 0" :total="total" :page.sync="queryParams.pageNum" :limit.sync="queryParams.pageSize"
|
||||
@@ -857,6 +878,22 @@ export default {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cell-2line {
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
max-height: 2.8em;
|
||||
white-space: normal;
|
||||
}
|
||||
.date-cell {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
&.deadline { color: #e6a23c; }
|
||||
}
|
||||
.req-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user