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;
|
}
|
|
}
|