liu
10 小时以前 5d34bdd3c426bb2ca5f6627424ae67984f745603
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
package cn.iocoder.yudao.module.finance.service.reimbursement.ai;
 
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.module.ai.api.chat.AiChatApi;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrReqVO;
import cn.iocoder.yudao.module.finance.controller.admin.reimbursement.ai.vo.FinanceReimbursementOcrRespVO;
import cn.iocoder.yudao.module.system.dal.dataobject.storage.SystemStorageBlobDO;
import cn.iocoder.yudao.module.system.service.storage.SystemStorageBlobService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.springframework.stereotype.Service;
 
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.Set;
 
/**
 * 财务报销单 AI OCR Service 实现类
 *
 * <p>
 * 支持识别发票图片 / PDF / 文本文件,提取发票号码、发票代码、发票类型、购买方、销售方、
 * 价税合计、税额、开票日期等结构化信息,供前端自动回填报销明细。
 * 图片走多模态视觉识别,PDF / 文本先抽取文本再走纯文本识别。
 */
@Slf4j
@Service
public class FinanceReimbursementAiServiceImpl implements FinanceReimbursementAiService {
 
    private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(
            "pdf", "png", "jpg", "jpeg", "gif", "bmp", "txt", "csv"
    );
 
    private static final Set<String> IMAGE_EXTENSIONS = Set.of("png", "jpg", "jpeg", "gif", "bmp");
 
    /** 单个文件最大 20MB,避免超大文件拖垮识别服务 */
    private static final long MAX_FILE_SIZE = 20L * 1024 * 1024;
 
    private static final String SYSTEM_PROMPT = """
            你是一位专业的中国发票信息提取专家。请从发票文件中提取结构化信息。
 
            ## 提取规则
            1. invoiceNo 为发票号码(数电票为 20 位号码),原样返回,保留前导 0
            2. invoiceCode 为发票代码;数电票、电子发票没有发票代码时不要返回
            3. invoiceType 为发票类型,如:增值税专用发票 / 增值税普通发票 / 增值税电子普通发票 / 电子发票(普通发票)/ 收据 / 火车票 / 其他
            4. invoiceTitle 为购买方名称(发票抬头)
            5. sellerName 为销售方名称(开票方),取"销售方"或"销方"信息
            6. invoiceAmount 为价税合计(小写)金额,单位元,保留两位小数
            7. taxAmount 为税额合计,单位元,保留两位小数;发票未标注税额时不返回
            8. invoiceDate 统一转换为 yyyy-MM-dd 格式(兼容 2026年08月01日、2026/08/01 等写法)
            9. 数字字段仅保留数字,去掉货币符号(¥、¥)和千分位逗号
            10. remark 为发票备注栏内容,没有则不返回
 
            ## 输出格式
            请严格按以下 JSON 格式返回(不要包含 Markdown 代码块或其它内容):
            {"invoiceNo": "02564178", "invoiceCode": "044001900111", "invoiceType": "增值税电子普通发票", "invoiceTitle": "江苏商贸职业学院", "sellerName": "XX科技有限公司", "invoiceAmount": 500.00, "taxAmount": 57.52, "invoiceDate": "2026-08-01", "remark": "备注信息"}
 
            所有字段均为可选,未提取到的字段不要返回。
            如果文件内容无法识别为发票,返回空 JSON:{}
            """;
 
    private static final String SYSTEM_PROMPT_IMAGE = """
            你是一位专业的中国发票 OCR 识别专家。请从发票图片中提取结构化信息。
 
            ## 提取规则
            1. 仔细识别图片中的发票文字,注意发票可能倾斜、有印章遮挡或为拍照件
            2. invoiceNo 为发票号码(数电票为 20 位号码),原样返回,保留前导 0
            3. invoiceCode 为发票代码;数电票、电子发票没有发票代码时不要返回
            4. invoiceType 为发票类型,如:增值税专用发票 / 增值税普通发票 / 增值税电子普通发票 / 电子发票(普通发票)/ 收据 / 火车票 / 其他
            5. invoiceTitle 为购买方名称(发票抬头)
            6. sellerName 为销售方名称(开票方),取"销售方"或"销方"信息
            7. invoiceAmount 为价税合计(小写)金额,单位元,保留两位小数
            8. taxAmount 为税额合计,单位元,保留两位小数;发票未标注税额时不返回
            9. invoiceDate 统一转换为 yyyy-MM-dd 格式
            10. 数字字段仅保留数字,去掉货币符号(¥、¥)和千分位逗号
            11. remark 为发票备注栏内容,没有则不返回
 
            ## 输出格式
            请严格按以下 JSON 格式返回(不要包含 Markdown 代码块或其它内容):
            {"invoiceNo": "02564178", "invoiceCode": "044001900111", "invoiceType": "增值税电子普通发票", "invoiceTitle": "江苏商贸职业学院", "sellerName": "XX科技有限公司", "invoiceAmount": 500.00, "taxAmount": 57.52, "invoiceDate": "2026-08-01", "remark": "备注信息"}
 
            所有字段均为可选,未提取到的字段不要返回。
            如果图片内容无法识别为发票,返回空 JSON:{}
            """;
 
    @Resource
    private SystemStorageBlobService blobService;
 
    @Resource
    private AiChatApi aiChatApi;
 
    @Override
    public List<FinanceReimbursementOcrRespVO> ocrInvoices(FinanceReimbursementOcrReqVO reqVO) {
        List<FinanceReimbursementOcrRespVO> results = new ArrayList<>(reqVO.getBlobIds().size());
        for (Long blobId : reqVO.getBlobIds()) {
            FinanceReimbursementOcrRespVO result = new FinanceReimbursementOcrRespVO();
            result.setBlobId(blobId);
            try {
                ocrSingleInvoice(blobId, result);
            } catch (Exception e) {
                log.warn("AI OCR 识别报销发票失败,blobId={}", blobId, e);
                result.setRawText("AI 识别暂时不可用,请手动录入");
            }
            results.add(result);
        }
        return results;
    }
 
    private void ocrSingleInvoice(Long blobId, FinanceReimbursementOcrRespVO result) throws IOException {
        // 1. 获取文件
        SystemStorageBlobDO blob = blobService.getStorageBlob(blobId);
        if (blob == null) {
            result.setRawText("发票文件不存在或已被删除,请重新上传");
            return;
        }
 
        // 2. 校验文件类型
        String ext = StrUtil.isBlank(blob.getOriginalFilename()) ? ""
                : FileUtil.extName(blob.getOriginalFilename()).toLowerCase();
        if (!SUPPORTED_EXTENSIONS.contains(ext)) {
            result.setRawText("不支持的文件类型,请上传 PDF 或图片文件");
            return;
        }
        if (blob.getByteSize() != null && blob.getByteSize() > MAX_FILE_SIZE) {
            result.setRawText("文件超过 20MB,请压缩后重新上传");
            return;
        }
 
        // 3. 读取文件字节(磁盘上真实存储的 blob,不能删除)
        File blobFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
        if (blobFile == null || !blobFile.exists()) {
            result.setRawText("文件读取失败,请重新上传");
            return;
        }
        byte[] fileBytes = Files.readAllBytes(blobFile.toPath());
        if (fileBytes.length == 0) {
            result.setRawText("文件内容为空,无法识别");
            return;
        }
 
        // 4. AI 识别(图片走多模态,文本走纯文本)
        boolean isImage = IMAGE_EXTENSIONS.contains(ext);
        String aiResponse;
        if (isImage) {
            String imageBase64 = Base64.getEncoder().encodeToString(fileBytes);
            aiResponse = aiChatApi.chatWithImage(SYSTEM_PROMPT_IMAGE,
                    "请识别图片中的发票信息", imageBase64, getMimeType(ext));
        } else {
            String extractedText = extractTextByType(ext, fileBytes);
            if (StrUtil.isEmpty(extractedText)) {
                result.setRawText("文件内容为空或为扫描件,无法识别,请上传发票图片");
                return;
            }
            aiResponse = aiChatApi.chat(SYSTEM_PROMPT, extractedText);
        }
        log.info("AI OCR 识别报销发票结果: blobId={}, file={}, response={}",
                blobId, blob.getOriginalFilename(), aiResponse);
 
        // 5. 解析响应
        parseOcrResponse(aiResponse, result);
    }
 
    private String extractTextByType(String ext, byte[] bytes) throws IOException {
        return switch (ext) {
            case "pdf" -> extractPdfText(bytes);
            case "txt", "csv" -> new String(bytes, StandardCharsets.UTF_8);
            default -> "";
        };
    }
 
    private String extractPdfText(byte[] bytes) throws IOException {
        try (PDDocument doc = Loader.loadPDF(bytes)) {
            PDFTextStripper stripper = new PDFTextStripper();
            stripper.setSortByPosition(true);
            String text = stripper.getText(doc);
            return StrUtil.isNotBlank(text) ? text.trim() : "";
        }
    }
 
    private String getMimeType(String ext) {
        return switch (ext) {
            case "jpg", "jpeg" -> "image/jpeg";
            case "gif" -> "image/gif";
            case "bmp" -> "image/bmp";
            default -> "image/png";
        };
    }
 
    private void parseOcrResponse(String aiResponse, FinanceReimbursementOcrRespVO result) {
        if (StrUtil.isBlank(aiResponse)) {
            result.setRawText("未识别到发票内容,请手动录入或重新上传");
            return;
        }
        try {
            String json = aiResponse;
            int start = json.indexOf("{");
            int end = json.lastIndexOf("}");
            if (start >= 0 && end > start) {
                json = json.substring(start, end + 1);
            }
            Map<String, Object> parsed = JSONUtil.toBean(json, Map.class);
 
            result.setInvoiceNo(toStr(parsed.get("invoiceNo")));
            result.setInvoiceCode(toStr(parsed.get("invoiceCode")));
            result.setInvoiceType(toStr(parsed.get("invoiceType")));
            result.setInvoiceTitle(toStr(parsed.get("invoiceTitle")));
            result.setSellerName(toStr(parsed.get("sellerName")));
            result.setRemark(toStr(parsed.get("remark")));
            result.setInvoiceAmount(toBigDecimal(parsed.get("invoiceAmount")));
            result.setTaxAmount(toBigDecimal(parsed.get("taxAmount")));
 
            String invoiceDate = toStr(parsed.get("invoiceDate"));
            if (StrUtil.isNotBlank(invoiceDate)) {
                result.setInvoiceDate(parseDate(invoiceDate));
            }
 
            if (!hasRecognizedField(result)) {
                result.setRawText("未识别到发票内容,请手动录入或重新上传");
            }
        } catch (Exception e) {
            log.warn("解析 AI OCR 响应失败: {}", aiResponse, e);
            result.setRawText("识别结果解析失败,请手动录入");
        }
    }
 
    /**
     * 判断是否识别出任何有效业务字段
     */
    private boolean hasRecognizedField(FinanceReimbursementOcrRespVO result) {
        return StrUtil.isNotBlank(result.getInvoiceNo())
                || StrUtil.isNotBlank(result.getInvoiceCode())
                || StrUtil.isNotBlank(result.getInvoiceType())
                || StrUtil.isNotBlank(result.getInvoiceTitle())
                || StrUtil.isNotBlank(result.getSellerName())
                || StrUtil.isNotBlank(result.getRemark())
                || result.getInvoiceAmount() != null
                || result.getTaxAmount() != null
                || result.getInvoiceDate() != null;
    }
 
    private String toStr(Object value) {
        return value == null ? null : StrUtil.trimToNull(String.valueOf(value));
    }
 
    /**
     * 将 AI 返回的数字(可能是 Number 或数字字符串)转换为 BigDecimal
     */
    private BigDecimal toBigDecimal(Object value) {
        if (value == null) {
            return null;
        }
        if (value instanceof Number n) {
            return BigDecimal.valueOf(n.doubleValue());
        }
        if (value instanceof String s) {
            s = s.replace(",", "").replace("¥", "").replace("¥", "").trim();
            if (StrUtil.isBlank(s)) {
                return null;
            }
            try {
                return new BigDecimal(s);
            } catch (NumberFormatException ignored) {
                return null;
            }
        }
        return null;
    }
 
    /**
     * 解析日期,兼容 yyyy-MM-dd、yyyy/MM/dd、yyyy年MM月dd日 等常见格式
     */
    private LocalDate parseDate(String value) {
        String s = value.trim()
                .replace("年", "-")
                .replace("月", "-")
                .replace("日", "")
                .replace("/", "-");
        try {
            return LocalDate.parse(s);
        } catch (Exception ignored) {
            return null;
        }
    }
 
}