liu
17 小时以前 e65e22b0ce09b0e5746116c6cad0401e706024ea
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
package cn.iocoder.yudao.module.erp.service.purchase.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.erp.controller.admin.purchase.ai.vo.ErpPurchaseInvoiceOcrReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.ai.vo.ErpPurchaseInvoiceOcrRespVO;
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.Base64;
import java.util.Map;
import java.util.Set;
 
/**
 * ERP 采购来票 AI OCR Service 实现类
 *
 * <p>
 * 支持通过 AI 识别发票图片 / PDF / 文本文件,提取发票号码、抬头、金额、开票日期等结构化信息,
 * 供前端预填"采购来票"新增表单。图片走多模态视觉识别,其余格式先抽取文本再走纯文本识别。
 */
@Slf4j
@Service
public class ErpPurchaseInvoiceAiServiceImpl implements ErpPurchaseInvoiceAiService {
 
    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");
 
    private static final String SYSTEM_PROMPT = """
            你是一位专业的发票信息提取专家。请从发票文件中提取结构化信息。
 
            ## 提取规则
            1. 仔细识别发票中的信息:发票类型、发票号码、购买方名称、销售方名称、价税合计金额、开票日期、税率、备注等
            2. 发票号码为发票上"发票号码"栏的数字,原样返回(不要去掉前导 0)
            3. invoiceTitle 为"购买方"名称(发票抬头)
            4. price 为"价税合计(小写)"金额,单位为元,保留两位小数
            5. 日期统一转换为 yyyy-MM-dd 格式
            6. 数字字段仅保留数字,去掉货币符号和千分位逗号
            7. 税率(如 13%、9%、6%)如发票中未明确标注,不填
 
            ## 输出格式
            请按以下JSON格式返回(不要包含其他内容):
            {"invoiceNo": "02564178", "invoiceTitle": "购买方名称", "supplierName": "销售方名称", "price": 9000.00, "invoiceTime": "2026-08-01", "invoiceType": "增值税专用发票", "taxRate": 13.0, "remark": "备注信息"}
 
            所有字段均为可选,未提取到的字段不要返回。
            如果文件内容无法识别为发票,返回空JSON:{}
            """;
 
    private static final String SYSTEM_PROMPT_IMAGE = """
            你是一位专业的发票OCR识别专家。请从发票图片中提取结构化信息。
 
            ## 提取规则
            1. 仔细识别图片中的发票文字信息
            2. 提取发票相关信息:发票类型、发票号码、购买方名称、销售方名称、价税合计金额、开票日期、税率、备注等
            3. 发票号码为发票上"发票号码"栏的数字,原样返回(不要去掉前导 0)
            4. invoiceTitle 为"购买方"名称(发票抬头)
            5. price 为"价税合计(小写)"金额,单位为元,保留两位小数
            6. 日期统一转换为 yyyy-MM-dd 格式
            7. 税率如发票中未明确标注,不填
 
            ## 输出格式
            请按以下JSON格式返回(不要包含其他内容):
            {"invoiceNo": "02564178", "invoiceTitle": "购买方名称", "supplierName": "销售方名称", "price": 9000.00, "invoiceTime": "2026-08-01", "invoiceType": "增值税专用发票", "taxRate": 13.0, "remark": "备注信息"}
 
            所有字段均为可选,未提取到的字段不要返回。
            如果图片内容无法识别为发票,返回空JSON:{}
            """;
 
    @Resource
    private SystemStorageBlobService blobService;
 
    @Resource
    private AiChatApi aiChatApi;
 
    @Override
    public ErpPurchaseInvoiceOcrRespVO ocrInvoice(ErpPurchaseInvoiceOcrReqVO reqVO) {
        File tempFile = null;
        try {
            // 1. 下载文件
            SystemStorageBlobDO blob = blobService.getStorageBlob(reqVO.getBlobId());
            if (blob == null) {
                return errorResp("文件不存在");
            }
 
            // 2. 校验文件类型
            String ext = StrUtil.isBlank(blob.getOriginalFilename()) ? ""
                    : FileUtil.extName(blob.getOriginalFilename()).toLowerCase();
            if (!SUPPORTED_EXTENSIONS.contains(ext)) {
                return errorResp("不支持的文件类型,请上传 PDF/图片文件");
            }
 
            // 3. 获取文件字节
            tempFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (tempFile == null || !tempFile.exists()) {
                return errorResp("文件读取失败");
            }
            byte[] fileBytes = Files.readAllBytes(tempFile.toPath());
            if (fileBytes.length == 0) {
                return errorResp("文件内容为空,无法识别");
            }
 
            // 4. AI 识别(图片走多模态,文本走纯文本)
            boolean isImage = IMAGE_EXTENSIONS.contains(ext);
            String systemPrompt = isImage ? SYSTEM_PROMPT_IMAGE : SYSTEM_PROMPT;
            String aiResponse;
            if (isImage) {
                String imageBase64 = Base64.getEncoder().encodeToString(fileBytes);
                String mimeType = getMimeType(ext);
                aiResponse = aiChatApi.chatWithImage(systemPrompt, "请识别图片中的发票信息", imageBase64, mimeType);
            } else {
                String extractedText = extractTextByType(ext, fileBytes);
                if (StrUtil.isEmpty(extractedText)) {
                    return errorResp("文件内容为空,无法识别");
                }
                aiResponse = aiChatApi.chat(systemPrompt, extractedText);
            }
            log.info("AI OCR 识别发票结果: blobId={}, file={}, response={}",
                    reqVO.getBlobId(), blob.getOriginalFilename(), aiResponse);
 
            // 5. 解析响应
            return parseOcrResponse(aiResponse);
 
        } catch (Exception e) {
            log.warn("AI OCR 识别发票失败,blobId={}", reqVO.getBlobId(), e);
            return errorResp("AI 识别暂时不可用,请手动录入");
        } finally {
            if (tempFile != null && tempFile.exists()) {
                tempFile.delete();
            }
        }
    }
 
    private ErpPurchaseInvoiceOcrRespVO errorResp(String message) {
        ErpPurchaseInvoiceOcrRespVO error = new ErpPurchaseInvoiceOcrRespVO();
        error.setRawText(message);
        return error;
    }
 
    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 ErpPurchaseInvoiceOcrRespVO parseOcrResponse(String aiResponse) {
        ErpPurchaseInvoiceOcrRespVO respVO = new ErpPurchaseInvoiceOcrRespVO();
        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);
 
            respVO.setInvoiceNo((String) parsed.get("invoiceNo"));
            respVO.setInvoiceTitle((String) parsed.get("invoiceTitle"));
            respVO.setSupplierName((String) parsed.get("supplierName"));
            respVO.setInvoiceType((String) parsed.get("invoiceType"));
            respVO.setRemark((String) parsed.get("remark"));
 
            respVO.setPrice(toBigDecimal(parsed.get("price")));
            respVO.setTaxRate(toBigDecimal(parsed.get("taxRate")));
 
            Object it = parsed.get("invoiceTime");
            if (it instanceof String s && StrUtil.isNotBlank(s)) {
                respVO.setInvoiceTime(parseDate(s));
            }
        } catch (Exception e) {
            log.warn("解析 AI OCR 响应失败: {}", aiResponse, e);
            respVO.setRawText(aiResponse);
        }
        return respVO;
    }
 
    /**
     * 将 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(",", "").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;
        }
    }
 
}