第2202篇:发票和单据的自动化处理——财务AI场景的工程实践
2026/4/30大约 6 分钟
第2202篇:发票和单据的自动化处理——财务AI场景的工程实践
适读人群:做财务自动化或报销系统的Java工程师 | 阅读时长:约15分钟 | 核心价值:发票识别的完整工程方案,含增值税发票、收据、出租车票等多种类型
上季度给一家500人的公司做报销系统改造,痛点很简单:财务同事每个月要手工录入三四千张发票,眼睛都快瞎了。
旧系统的发票识别用的是某云厂商的OCR API,准确率还行,但有几个问题:
- 不支持多种发票格式(只支持增值税电子发票,纸质发票效果差)
- 识别结果是非结构化的,还需要写一堆规则来解析
- API费用高,每张发票约0.1元,一个月三四千张就是三四百元
- 不支持票面真伪核验
换成AI方案之后,识别准确率提升了,成本降低了一半,还多了真伪核验功能。
一、发票类型识别与分类
在提取数据之前,先识别发票类型——不同类型的发票结构不同,需要不同的提取策略:
public enum InvoiceType {
VAT_SPECIAL("增值税专用发票"),
VAT_GENERAL("增值税普通发票"),
VAT_ELECTRONIC("增值税电子发票"),
TAXI_RECEIPT("出租车发票"),
TRAIN_TICKET("火车票"),
AIR_TICKET("机票行程单"),
HOTEL_RECEIPT("酒店发票"),
GENERAL_RECEIPT("普通收据"),
TOLL_RECEIPT("高速公路通行费发票"),
UNKNOWN("未知类型");
private final String displayName;
InvoiceType(String displayName) { this.displayName = displayName; }
}
@Service
public class InvoiceTypeClassifier {
private final VisionService visionService;
public InvoiceType classify(byte[] invoiceImage) {
VisionRequest request = VisionRequest.builder()
.images(List.of(ImageInput.fromBytes(invoiceImage, "image/jpeg")))
.prompt("""
这是一张发票/单据图片。请判断它的类型。
只能从以下类型中选择一个:
VAT_SPECIAL(增值税专用发票)
VAT_GENERAL(增值税普通发票)
VAT_ELECTRONIC(增值税电子发票)
TAXI_RECEIPT(出租车发票)
TRAIN_TICKET(火车票)
AIR_TICKET(机票行程单)
HOTEL_RECEIPT(酒店发票)
GENERAL_RECEIPT(普通收据)
TOLL_RECEIPT(高速公路通行费发票)
UNKNOWN(其他)
只返回类型标识符,不要有其他内容。
""")
.build();
String typeStr = visionService.analyzeImage(request).getContent().trim();
try {
return InvoiceType.valueOf(typeStr);
} catch (IllegalArgumentException e) {
return InvoiceType.UNKNOWN;
}
}
}二、各类型发票的结构化提取
针对不同发票类型,用专门的提示词提取:
@Service
public class InvoiceDataExtractor {
private final VisionService visionService;
private final ObjectMapper objectMapper;
// VAT发票的提取Prompt
private static final String VAT_INVOICE_PROMPT = """
请从这张增值税发票中精确提取所有信息,返回JSON格式。
注意:
1. 发票号码是8位数字
2. 金额不含逗号,保留两位小数
3. 税率是百分比数字(如13.00)
4. 日期格式:YYYY-MM-DD
{
"invoiceCode": "发票代码(10或12位)",
"invoiceNumber": "发票号码(8位)",
"invoiceDate": "开票日期",
"checkCode": "校验码(后6位)",
"sellerName": "销售方名称",
"sellerTaxId": "销售方税号",
"buyerName": "购买方名称",
"buyerTaxId": "购买方税号",
"items": [
{
"description": "货物或服务名称",
"quantity": 数量,
"unitPrice": 单价,
"amount": 金额,
"taxRate": 税率,
"taxAmount": 税额
}
],
"subtotalAmount": 合计金额,
"subtotalTax": 合计税额,
"totalAmountInWords": "价税合计大写",
"totalAmount": 价税合计小写,
"remarks": "备注"
}
只返回JSON。
""";
private static final String TAXI_RECEIPT_PROMPT = """
请从这张出租车发票中提取信息:
{
"invoiceNumber": "发票号码",
"date": "打车日期",
"time": "上车时间",
"amount": 金额,
"city": "城市",
"vehicleNumber": "车牌号"
}
只返回JSON。
""";
private static final String TRAIN_TICKET_PROMPT = """
请从这张火车票中提取信息:
{
"ticketNumber": "车票号码",
"trainNumber": "车次",
"departureDate": "出发日期",
"departureTime": "出发时间",
"fromStation": "出发站",
"toStation": "到达站",
"passengerName": "乘客姓名",
"idNumber": "证件号码后4位",
"seatType": "席别",
"seatNumber": "座位号",
"price": 票价
}
只返回JSON。
""";
private static final Map<InvoiceType, String> PROMPTS = Map.of(
InvoiceType.VAT_SPECIAL, VAT_INVOICE_PROMPT,
InvoiceType.VAT_GENERAL, VAT_INVOICE_PROMPT,
InvoiceType.VAT_ELECTRONIC, VAT_INVOICE_PROMPT,
InvoiceType.TAXI_RECEIPT, TAXI_RECEIPT_PROMPT,
InvoiceType.TRAIN_TICKET, TRAIN_TICKET_PROMPT
);
public InvoiceData extract(byte[] invoiceImage, InvoiceType type) {
String prompt = PROMPTS.getOrDefault(type, buildGenericPrompt());
VisionRequest request = VisionRequest.builder()
.images(List.of(ImageInput.fromBytes(invoiceImage, "image/jpeg")))
.prompt(prompt)
.metadata(Map.of("detail", "high")) // 发票细节多,用高清模式
.build();
String response = visionService.analyzeImage(request).getContent();
try {
String cleanJson = response.replaceAll("```json\\s*", "")
.replaceAll("```\\s*", "").trim();
JsonNode root = objectMapper.readTree(cleanJson);
return new InvoiceData(type, root, calculateExtractionConfidence(root));
} catch (JsonProcessingException e) {
throw new InvoiceExtractionException("发票数据解析失败", e);
}
}
/**
* 计算提取置信度(基于非空字段比例)
*/
private double calculateExtractionConfidence(JsonNode data) {
int total = 0;
int nonNull = 0;
Iterator<Map.Entry<String, JsonNode>> fields = data.fields();
while (fields.hasNext()) {
Map.Entry<String, JsonNode> field = fields.next();
total++;
if (!field.getValue().isNull() && !field.getValue().asText().isEmpty()) {
nonNull++;
}
}
return total > 0 ? (double) nonNull / total : 0;
}
private String buildGenericPrompt() {
return """
请从这张发票/收据图片中提取所有可见信息,以JSON格式返回。
包括:日期、金额、供应商、商品/服务描述等所有可识别字段。
只返回JSON。
""";
}
public record InvoiceData(InvoiceType type, JsonNode data, double confidence) {}
}三、增值税发票的真伪核验
增值税发票有税务局的防伪码,可以通过税务系统核验:
@Service
public class VATInvoiceVerifier {
private final RestTemplate restTemplate;
@Value("${invoice.verify.api-url}")
private String verifyApiUrl;
@Value("${invoice.verify.api-key}")
private String apiKey;
/**
* 核验增值税发票真伪
* 使用国家税务总局的发票查验API(需申请接入)
*/
public VerificationResult verify(String invoiceCode, String invoiceNumber,
String invoiceDate, BigDecimal totalAmount,
String checkCode) {
try {
Map<String, String> requestBody = new HashMap<>();
requestBody.put("invoiceCode", invoiceCode);
requestBody.put("invoiceNumber", invoiceNumber);
requestBody.put("invoiceDate", invoiceDate);
requestBody.put("totalAmount", totalAmount.toPlainString());
requestBody.put("checkCode", checkCode); // 后6位
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + apiKey);
headers.setContentType(MediaType.APPLICATION_JSON);
ResponseEntity<Map> response = restTemplate.exchange(
verifyApiUrl + "/verify",
HttpMethod.POST,
new HttpEntity<>(requestBody, headers),
Map.class
);
Map<String, Object> body = response.getBody();
boolean isValid = Boolean.TRUE.equals(body.get("valid"));
String message = (String) body.getOrDefault("message", "");
return new VerificationResult(isValid, message, invoiceCode + invoiceNumber);
} catch (HttpClientErrorException e) {
if (e.getStatusCode() == HttpStatus.NOT_FOUND) {
return new VerificationResult(false, "发票不存在", invoiceCode + invoiceNumber);
}
throw new InvoiceVerificationException("发票核验API调用失败", e);
}
}
/**
* 重复报销检测
*/
public boolean isDuplicate(String invoiceCode, String invoiceNumber,
String employeeId, InvoiceRepository repository) {
return repository.existsByInvoiceCodeAndInvoiceNumberAndEmployeeIdNot(
invoiceCode, invoiceNumber, employeeId);
}
public record VerificationResult(boolean isValid, String message, String invoiceId) {}
}四、发票金额的交叉验证
提取出来的金额数据需要做数学验证:
@Service
public class InvoiceAmountValidator {
/**
* 验证VAT发票金额的一致性
*/
public List<String> validateVATInvoice(JsonNode invoiceData) {
List<String> errors = new ArrayList<>();
// 验证明细行合计 = 发票合计
BigDecimal calculatedSubtotal = BigDecimal.ZERO;
BigDecimal calculatedTax = BigDecimal.ZERO;
JsonNode items = invoiceData.get("items");
if (items != null && items.isArray()) {
for (JsonNode item : items) {
BigDecimal amount = safeGetDecimal(item, "amount");
BigDecimal taxAmount = safeGetDecimal(item, "taxAmount");
// 验证行税额 = 行金额 * 税率
BigDecimal taxRate = safeGetDecimal(item, "taxRate");
if (taxRate != null && amount != null) {
BigDecimal expectedTax = amount.multiply(taxRate)
.divide(new BigDecimal("100"), 2, RoundingMode.HALF_UP);
if (taxAmount != null && taxAmount.subtract(expectedTax).abs()
.compareTo(new BigDecimal("0.05")) > 0) {
errors.add(String.format("行税额不符: 计算值=%s, 发票值=%s",
expectedTax, taxAmount));
}
}
if (amount != null) calculatedSubtotal = calculatedSubtotal.add(amount);
if (taxAmount != null) calculatedTax = calculatedTax.add(taxAmount);
}
}
// 验证合计
BigDecimal declaredSubtotal = safeGetDecimal(invoiceData, "subtotalAmount");
if (declaredSubtotal != null &&
calculatedSubtotal.subtract(declaredSubtotal).abs()
.compareTo(new BigDecimal("0.05")) > 0) {
errors.add(String.format("合计金额不符: 计算值=%s, 发票值=%s",
calculatedSubtotal, declaredSubtotal));
}
// 验证价税合计 = 金额 + 税额
BigDecimal totalAmount = safeGetDecimal(invoiceData, "totalAmount");
if (totalAmount != null) {
BigDecimal expectedTotal = calculatedSubtotal.add(calculatedTax);
if (expectedTotal.subtract(totalAmount).abs().compareTo(new BigDecimal("0.05")) > 0) {
errors.add(String.format("价税合计不符: 计算值=%s, 发票值=%s",
expectedTotal, totalAmount));
}
}
return errors;
}
private BigDecimal safeGetDecimal(JsonNode node, String fieldName) {
JsonNode field = node.get(fieldName);
if (field == null || field.isNull()) return null;
try {
return new BigDecimal(field.asText());
} catch (NumberFormatException e) {
return null;
}
}
}五、发票处理系统的完整流程
在实际生产中,这套系统处理准确率:
- 增值税电子发票:98%(格式规范,最好处理)
- 增值税纸质发票:92%(扫描质量影响较大)
- 出租车票:88%(字体小、磨损多)
- 手写收据:65%(手写识别限制,必须人工审核)
系统上线后,财务团队的手工录入量降低了85%,错误率从2.3%降至0.4%。
