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 SUPPORTED_EXTENSIONS = Set.of( "pdf", "doc", "docx", "xls", "xlsx", "png", "jpg", "jpeg", "gif", "bmp", "txt", "csv" ); private static final Set 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 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 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> items = (List>) 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; } }