4 天以前 83e1b4d0e661f11a407fd6ea86e906b9b87b7180
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
package cn.iocoder.yudao.module.crm.service.quotation.ai;
 
import cn.hutool.core.collection.CollUtil;
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.crm.controller.admin.quotation.ai.vo.CrmSaleQuotationOcrReqVO;
import cn.iocoder.yudao.module.crm.controller.admin.quotation.ai.vo.CrmSaleQuotationOcrRespVO;
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.apache.poi.ss.usermodel.*;
import org.apache.poi.xwpf.extractor.XWPFWordExtractor;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.springframework.stereotype.Service;
 
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.IOException;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.time.LocalDate;
import java.util.*;
import java.util.stream.Collectors;
 
@Slf4j
@Service
public class CrmSaleQuotationAiServiceImpl implements CrmSaleQuotationAiService {
 
    private static final Set<String> SUPPORTED_EXTENSIONS = Set.of(
            "pdf", "doc", "docx", "xls", "xlsx", "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. 对于物料明细,每一行提取:物料名称、规格型号、数量、单价
            3. 税率、折扣率等如文件中未明确标注,不填
            4. 日期统一转换为 yyyy-MM-dd 格式
            5. 数字字段仅保留数字,去掉单位
 
            ## 输出格式
            请按以下JSON格式返回(不要包含其他内容):
            {"name": "报价单名称", "customerName": "客户名称", "quotationTime": "2026-01-01", "validUntil": "2026-12-31", "taxRate": 13.0, "discountPercent": 5.0, "remark": "备注信息", "items": [{"itemName": "物料名称", "itemSpec": "规格型号", "count": 10.0, "quotationPrice": 100.0}]}
 
            所有字段均为可选,未提取到的字段不要返回。
            如果文件内容无法识别为报价单,返回空JSON:{}
            """;
 
    private static final String SYSTEM_PROMPT_IMAGE = """
            你是一位专业的销售单据OCR识别专家。你需要从报价文件图片中提取结构化信息。
 
            ## 提取规则
            1. 仔细识别图片中的文字信息
            2. 提取报价单相关信息:客户名称、报价日期、有效期、物料明细等
            3. 对于物料明细,每一行提取:物料名称、规格型号、数量、单价
            4. 税率、折扣率等如图片中未明确标注,不填
            5. 日期统一转换为 yyyy-MM-dd 格式
 
            ## 输出格式
            请按以下JSON格式返回(不要包含其他内容):
            {"name": "报价单名称", "customerName": "客户名称", "quotationTime": "2026-01-01", "validUntil": "2026-12-31", "taxRate": 13.0, "discountPercent": 5.0, "remark": "备注信息", "items": [{"itemName": "物料名称", "itemSpec": "规格型号", "count": 10.0, "quotationPrice": 100.0}]}
 
            所有字段均为可选,未提取到的字段不要返回。
            如果图片内容无法识别为报价单,返回空JSON:{}
            """;
 
    @Resource
    private SystemStorageBlobService blobService;
    @Resource
    private AiChatApi aiChatApi;
 
    @Override
    public CrmSaleQuotationOcrRespVO ocrQuotation(CrmSaleQuotationOcrReqVO reqVO) {
        File tempFile = null;
        try {
            // 1. 下载文件
            SystemStorageBlobDO blob = blobService.getStorageBlob(reqVO.getBlobId());
            if (blob == null) {
                CrmSaleQuotationOcrRespVO error = new CrmSaleQuotationOcrRespVO();
                error.setRawText("文件不存在");
                return error;
            }
 
            // 2. 校验文件类型
            String ext = FileUtil.extName(blob.getOriginalFilename()).toLowerCase();
            if (!SUPPORTED_EXTENSIONS.contains(ext)) {
                CrmSaleQuotationOcrRespVO error = new CrmSaleQuotationOcrRespVO();
                error.setRawText("不支持的文件类型,请上传 PDF/Word/Excel/图片文件");
                return error;
            }
 
            // 3. 获取文件字节
            tempFile = blobService.getPublicFile(blob.getUidFilename(), blob.getResourceKey());
            if (tempFile == null || !tempFile.exists()) {
                CrmSaleQuotationOcrRespVO error = new CrmSaleQuotationOcrRespVO();
                error.setRawText("文件读取失败");
                return error;
            }
            byte[] fileBytes = Files.readAllBytes(tempFile.toPath());
 
            // 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)) {
                    CrmSaleQuotationOcrRespVO error = new CrmSaleQuotationOcrRespVO();
                    error.setRawText("文件内容为空,无法识别");
                    return error;
                }
                aiResponse = aiChatApi.chat(systemPrompt, extractedText);
            }
            log.info("AI OCR 识别结果: blobId={}, file={}, response={}",
                    reqVO.getBlobId(), blob.getOriginalFilename(), aiResponse);
 
            // 5. 解析响应
            CrmSaleQuotationOcrRespVO result = parseOcrResponse(aiResponse);
            return result;
 
        } catch (Exception e) {
            log.warn("AI OCR 识别失败,blobId={}", reqVO.getBlobId(), e);
            CrmSaleQuotationOcrRespVO fallback = new CrmSaleQuotationOcrRespVO();
            fallback.setRawText("AI 识别暂时不可用,请手动录入");
            return fallback;
        } finally {
            if (tempFile != null && tempFile.exists()) {
                tempFile.delete();
            }
        }
    }
 
    private String extractTextByType(String ext, byte[] bytes) throws IOException {
        return switch (ext) {
            case "pdf" -> extractPdfText(bytes);
            case "docx", "doc" -> extractDocxText(bytes);
            case "xlsx", "xls" -> extractExcelText(bytes);
            case "txt", "csv" -> new String(bytes, java.nio.charset.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 extractDocxText(byte[] bytes) throws IOException {
        try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(bytes))) {
            try (XWPFWordExtractor extractor = new XWPFWordExtractor(doc)) {
                String text = extractor.getText();
                return StrUtil.isNotBlank(text) ? text.trim() : "";
            }
        }
    }
 
    private String extractExcelText(byte[] bytes) throws IOException {
        StringBuilder sb = new StringBuilder();
        try (Workbook wb = WorkbookFactory.create(new ByteArrayInputStream(bytes))) {
            for (int i = 0; i < wb.getNumberOfSheets(); i++) {
                Sheet sheet = wb.getSheetAt(i);
                if (i > 0) sb.append("\n--- Sheet: ").append(sheet.getSheetName()).append(" ---\n");
                for (Row row : sheet) {
                    List<String> cellValues = new ArrayList<>();
                    for (Cell cell : row) {
                        cellValues.add(getCellStringValue(cell));
                    }
                    sb.append(String.join("\t", cellValues)).append("\n");
                }
            }
        }
        return sb.toString().trim();
    }
 
    private String getCellStringValue(Cell cell) {
        return switch (cell.getCellType()) {
            case STRING -> cell.getStringCellValue().trim();
            case NUMERIC -> {
                if (DateUtil.isCellDateFormatted(cell)) {
                    yield cell.getLocalDateTimeCellValue().toLocalDate().toString();
                }
                double v = cell.getNumericCellValue();
                if (v == Math.floor(v) && !Double.isInfinite(v)) {
                    yield String.valueOf((long) v);
                }
                yield String.valueOf(v);
            }
            case BOOLEAN -> String.valueOf(cell.getBooleanCellValue());
            case FORMULA -> {
                try {
                    yield String.valueOf(cell.getNumericCellValue());
                } catch (Exception e) {
                    yield cell.getStringCellValue().trim();
                }
            }
            default -> "";
        };
    }
 
    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 CrmSaleQuotationOcrRespVO parseOcrResponse(String aiResponse) {
        CrmSaleQuotationOcrRespVO respVO = new CrmSaleQuotationOcrRespVO();
        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.setName((String) parsed.get("name"));
            respVO.setCustomerName((String) parsed.get("customerName"));
            respVO.setRemark((String) parsed.get("remark"));
 
            Object qt = parsed.get("quotationTime");
            if (qt instanceof String s && StrUtil.isNotBlank(s)) {
                try { respVO.setQuotationTime(LocalDate.parse(s)); } catch (Exception ignored) {}
            }
            Object vu = parsed.get("validUntil");
            if (vu instanceof String s && StrUtil.isNotBlank(s)) {
                try { respVO.setValidUntil(LocalDate.parse(s)); } catch (Exception ignored) {}
            }
            Object tr = parsed.get("taxRate");
            if (tr instanceof Number n) respVO.setTaxRate(BigDecimal.valueOf(n.doubleValue()));
            Object dp = parsed.get("discountPercent");
            if (dp instanceof Number n) respVO.setDiscountPercent(BigDecimal.valueOf(n.doubleValue()));
 
            @SuppressWarnings("unchecked")
            List<Map<String, Object>> items = (List<Map<String, Object>>) parsed.get("items");
            if (CollUtil.isNotEmpty(items)) {
                respVO.setItems(items.stream().map(m -> {
                    CrmSaleQuotationOcrRespVO.Item item = new CrmSaleQuotationOcrRespVO.Item();
                    item.setItemName((String) m.get("itemName"));
                    item.setItemSpec((String) m.get("itemSpec"));
                    Object count = m.get("count");
                    if (count instanceof Number n) item.setCount(BigDecimal.valueOf(n.doubleValue()));
                    Object price = m.get("quotationPrice");
                    if (price instanceof Number n) item.setQuotationPrice(BigDecimal.valueOf(n.doubleValue()));
                    return item;
                }).collect(Collectors.toList()));
            }
        } catch (Exception e) {
            log.warn("解析 AI OCR 响应失败: {}", aiResponse, e);
            respVO.setRawText(aiResponse);
        }
        return respVO;
    }
 
    private String truncate(String text, int maxLen) {
        if (text == null) return "";
        return text.length() > maxLen ? text.substring(0, maxLen) + "..." : text;
    }
 
}