liu
19 小时以前 e65e22b0ce09b0e5746116c6cad0401e706024ea
feat(erp): 采购来票新增发票OCR识别接口

- 新增 POST /erp/purchase-invoice/ai/ocr 接口,支持图片多模态/PDF文本识别发票
- 新增 ErpPurchaseInvoiceAiService/Controller/ReqVO/RespVO(参照 CRM 报价单 OCR 实现)
- pom 新增 yudao-module-ai-api、pdfbox 3.0.4 依赖

Co-Authored-By: Claude <noreply@anthropic.com>
已添加5个文件
已修改1个文件
376 ■■■■■ 文件已修改
yudao-module-erp/pom.xml 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ai/ErpPurchaseInvoiceAiController.java 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ai/vo/ErpPurchaseInvoiceOcrReqVO.java 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ai/vo/ErpPurchaseInvoiceOcrRespVO.java 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ai/ErpPurchaseInvoiceAiService.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ai/ErpPurchaseInvoiceAiServiceImpl.java 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
yudao-module-erp/pom.xml
@@ -66,6 +66,13 @@
            <version>${revision}</version>
        </dependency>
        <!-- AI å¤§æ¨¡åž‹ API -->
        <dependency>
            <groupId>cn.iocoder.boot</groupId>
            <artifactId>yudao-module-ai-api</artifactId>
            <version>${revision}</version>
        </dependency>
        <!-- ä¸šåŠ¡ç»„ä»¶ -->
        <!-- Web ç›¸å…³ -->
@@ -102,6 +109,13 @@
            <artifactId>yudao-spring-boot-starter-test</artifactId>
        </dependency>
        <!-- PDF è§£æž -->
        <dependency>
            <groupId>org.apache.pdfbox</groupId>
            <artifactId>pdfbox</artifactId>
            <version>3.0.4</version>
        </dependency>
    </dependencies>
</project>
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ai/ErpPurchaseInvoiceAiController.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.ai;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
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.erp.service.purchase.ai.ErpPurchaseInvoiceAiService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@Tag(name = "管理后台 - ERP é‡‡è´­æ¥ç¥¨ AI OCR")
@RestController
@RequestMapping("/erp/purchase-invoice/ai")
@Validated
public class ErpPurchaseInvoiceAiController {
    @Resource
    private ErpPurchaseInvoiceAiService invoiceAiService;
    @PostMapping("/ocr")
    @Operation(summary = "AI OCR è¯†åˆ«å‘票文件")
    @PreAuthorize("@ss.hasPermission('erp:purchase-invoice:create')")
    public CommonResult<ErpPurchaseInvoiceOcrRespVO> ocrInvoice(@Valid @RequestBody ErpPurchaseInvoiceOcrReqVO reqVO) {
        return success(invoiceAiService.ocrInvoice(reqVO));
    }
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ai/vo/ErpPurchaseInvoiceOcrReqVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.ai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - AI OCR è¯†åˆ«é‡‡è´­æ¥ç¥¨å‘票请求 VO")
@Data
public class ErpPurchaseInvoiceOcrReqVO {
    @Schema(description = "已上传文件的 blob ID", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
    @NotNull(message = "blob ID ä¸èƒ½ä¸ºç©º")
    private Long blobId;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/controller/admin/purchase/ai/vo/ErpPurchaseInvoiceOcrRespVO.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,40 @@
package cn.iocoder.yudao.module.erp.controller.admin.purchase.ai.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Schema(description = "管理后台 - AI OCR è¯†åˆ«é‡‡è´­æ¥ç¥¨å‘票响应 VO")
@Data
public class ErpPurchaseInvoiceOcrRespVO {
    @Schema(description = "发票号码", example = "02564178")
    private String invoiceNo;
    @Schema(description = "发票抬头(购买方名称)", example = "XX科技有限公司")
    private String invoiceTitle;
    @Schema(description = "销售方名称(文本,供核对供应商是否匹配)", example = "小番茄公司")
    private String supplierName;
    @Schema(description = "来票金额(价税合计),单位:元", example = "9000.00")
    private BigDecimal price;
    @Schema(description = "开票日期", example = "2026-08-01")
    private LocalDate invoiceTime;
    @Schema(description = "发票类型(如增值税专用发票、增值税普通发票)", example = "增值税专用发票")
    private String invoiceType;
    @Schema(description = "税率(百分比),如图中未标注则为空", example = "13.0")
    private BigDecimal taxRate;
    @Schema(description = "备注", example = "含税含运费")
    private String remark;
    @Schema(description = "原始识别文本(前端可选展示用于调试/参考;识别失败时为错误提示)")
    private String rawText;
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ai/ErpPurchaseInvoiceAiService.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,19 @@
package cn.iocoder.yudao.module.erp.service.purchase.ai;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.ai.vo.ErpPurchaseInvoiceOcrReqVO;
import cn.iocoder.yudao.module.erp.controller.admin.purchase.ai.vo.ErpPurchaseInvoiceOcrRespVO;
/**
 * ERP é‡‡è´­æ¥ç¥¨ AI OCR Service æŽ¥å£
 */
public interface ErpPurchaseInvoiceAiService {
    /**
     * AI OCR è¯†åˆ«å‘票文件,返回结构化信息
     *
     * @param reqVO è¯†åˆ«è¯·æ±‚
     * @return è¯†åˆ«ç»“æžœ
     */
    ErpPurchaseInvoiceOcrRespVO ocrInvoice(ErpPurchaseInvoiceOcrReqVO reqVO);
}
yudao-module-erp/src/main/java/cn/iocoder/yudao/module/erp/service/purchase/ai/ErpPurchaseInvoiceAiServiceImpl.java
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,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;
        }
    }
}