| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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; |
| | | } |
| | | } |
| | | |
| | | } |