feat: OCR 服务 + 会议材料模块

ry-ocr/ (新)
  本地发票识别微服务 (PaddleOCR 3.x + FastAPI, 8801)
  - QR 优先: 扫到二维码即取开票时间/发票号/金额; 没扫到/格式不合法直接判非发票, 不跑 OCR
  - 配置 QR_FULL_OCR 控制快路径(false, 0.2s)还是全字段(true, 4.5s)
  - /recognize/invoice (multipart) + /recognize/invoice/by-path (本地路径, 白名单) + /recognize/text
  - is_invoice / from_qr / qr_raw / qr_error / error_code 字段
  - 12 字段发票抽取 (regex + 启发式, 左右主体识别)
  - 超时保护 (15s 单页 / 60s 总流程) + PaddleOCR 单例 + ThreadPoolExecutor

ry-api/ruoyi-business/
  - pom.xml: 加 hutool-http/json/core 5.8.27, lombok 1.18.30 (OcrClient @Slf4j 所需)
  - ocr/: OcrClient + InvoiceResult/Fields/Line + ZipExtractor + InvoiceOcrScheduler
  - oss/: OssUploader + OssConfMeta (OCR 识别后重传 OSS)
  - config/: OcrConfig + OcrExecutorConfig (后台线程池)
  - service/impl/InvoiceOcrService: 后台提交 OCR, ZIP 路径解压识别, 替换场景先清旧
  - 会议材料 CRUD 全套 (BizMeetingAuditLog/Executor/Invoice/Material/Supervisor):
    controller + service + mapper + domain + xml

ry-vue3/
  - MeetingDetail.vue (新建): 会议详情页 (含评分维度章节, 改只读)
  - Meetings.vue / OssFileUploader.vue / router / Login.vue: 适配新字段

ry-api/ruoyi-admin/
  - RuoYiApplication.java + application.yml: 启用 @Async 异步支持

_self/
  - manager_meetings.md / manager_meeting_detail.md: 文档
This commit is contained in:
郭庆泰
2026-08-22 00:23:22 +08:00
parent edfaf4e7f5
commit c3eb8ed9c3
88 changed files with 6710 additions and 240 deletions
+10
View File
@@ -0,0 +1,10 @@
"""业务服务"""
from .invoice_extractor import extract as extract_invoice
from .qr_decoder import decode_qr, QRDecodeResult, has_any_field as qr_has_any_field
__all__ = [
"extract_invoice",
"decode_qr",
"QRDecodeResult",
"qr_has_any_field",
]
+299
View File
@@ -0,0 +1,299 @@
"""从 OCR 文本/行里抽取发票字段
适配中国大陆 增值税发票(电子普票 / 专票 / 电子专票 / 数电票)
关键策略:
- 主体(销售方/购买方)按 OCR box **坐标**判断归属(左右两栏)
- 名称提取加 stop word,避免 OCR 单行文本混淆
"""
from __future__ import annotations
import re
from typing import List, Optional, Tuple
from app.models import InvoiceFields, OCRLine
from app.utils import (
amount_consistent,
extract_cn_amount,
extract_num_amount,
extract_pretax_amount,
extract_tax_amount,
extract_total_amount,
)
# ---------- 发票类型 ----------
_INVOICE_TYPES = [
"增值税电子专用发票",
"增值税电子普通发票",
"增值税专用发票",
"增值税普通发票",
"通用机打发票",
"数电票",
"电子发票",
]
# ---------- 发票号码 ----------
_NO_PATTERN = re.compile(
r"(?:发\s*票\s*号\s*码|号\s*码|No\.?|号)\s*[:]?\s*(\d{8,20})",
re.IGNORECASE,
)
# ---------- 发票代码 ----------
_CODE_PATTERN = re.compile(
r"(?:发\s*票\s*代\s*码|代\s*码)\s*[:]?\s*(\d{10,12}|\d{8,12})",
)
# ---------- 开票日期 ----------
_DATE_PATTERN = re.compile(
r"(?:开\s*票\s*日\s*期|日\s*期)\s*[:]?\s*"
r"(\d{4})\s*[年/\.]\s*(\d{1,2})\s*[月/\.]\s*(\d{1,2})",
)
# ---------- 纳税人识别号(必须至少含 1 个字母,排除纯数字发票号)----------
_TAX_NO_PATTERN = re.compile(r"((?=[0-9A-Z]*[A-Z])[0-9A-Z]{18})")
# ---------- 主体标签 ----------
_BUYER_LABEL = re.compile(r"\s*买\s*方\s*(?:信\s*息|名\s*称|)")
_SELLER_LABEL = re.compile(r"\s*售\s*方\s*(?:信\s*息|名\s*称|)")
# ---------- 名称(带 stop word 截断)----------
_NAME_STOP = r"(?:销售方|购买方|统一社会信用|纳税人|项目名称|规格型号|^单位$|^数量$|^单价$|^金额|^税率|^税额|备注|收款人|复核|开票人|价税合计|小写|大写)"
_NAME_PATTERN = re.compile(
rf"\s*称\s*[:]\s*"
rf"((?:(?!{_NAME_STOP})[^\n\r]){{2,60}}?(?:公司|商店|厂|店|部|中心|工作室))"
)
# ---------- 工具函数 ----------
def _norm(text: str) -> str:
return re.sub(r"\s+", " ", text or "").strip()
def _box_center(box) -> Tuple[float, float]:
"""box: [[x1,y1], [x2,y2], [x3,y3], [x4,y4]] → (cx, cy)"""
if not box or len(box) < 4:
return (0.0, 0.0)
xs = [p[0] for p in box]
ys = [p[1] for p in box]
return ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
def _detect_invoice_type(text: str) -> Optional[str]:
for t in _INVOICE_TYPES:
if t in text:
return t
return None
def _extract_invoice_no(text: str) -> Optional[str]:
m = _NO_PATTERN.search(text)
return m.group(1) if m else None
def _extract_invoice_code(text: str) -> Optional[str]:
m = _CODE_PATTERN.search(text)
return m.group(1) if m else None
def _extract_date(text: str) -> Optional[str]:
m = _DATE_PATTERN.search(text)
if not m:
return None
y, mo, d = m.groups()
return f"{int(y):04d}-{int(mo):02d}-{int(d):02d}"
def _clean_name(name: str) -> str:
name = re.sub(r"^[\s:,,。、]+", "", name)
name = name.split("纳税人")[0].split("统一社会")[0]
name = re.sub(r"[^一-龥A-Za-z0-9()()·\-]", "", name)
return name.rstrip(":;,,。、 ").strip()
def _extract_parties_from_lines(lines: List[OCRLine]) -> tuple:
"""基于 box 坐标的主体识别
1) 找到 "购买方信息"/"销售方信息" 标签所在位置(box 中心)
2) 把每个 "名称: xxx" 行按 x 坐标归属到对应栏
3) 兜底:左右栏无法区分时按 y 坐标(top/bottom
"""
# 找标签坐标
buyer_label_box = None
seller_label_box = None
for line in lines:
if _BUYER_LABEL.search(line.text) and not buyer_label_box:
buyer_label_box = line.box
if _SELLER_LABEL.search(line.text) and not seller_label_box:
seller_label_box = line.box
by_x = buyer_label_box is not None and seller_label_box is not None and \
abs(_box_center(buyer_label_box)[0] - _box_center(seller_label_box)[0]) > 50
# 找所有 "名称: ..." 行的 box
name_lines = []
for line in lines:
m = _NAME_PATTERN.search(line.text)
if m:
name_lines.append((line, _clean_name(m.group(1))))
# 找所有税号
tax_lines = []
for line in lines:
m = _TAX_NO_PATTERN.search(line.text)
if m:
tax_lines.append((line, m.group(1)))
seller_name = buyer_name = None
seller_tax = buyer_tax = None
if by_x and buyer_label_box and seller_label_box:
# 左右栏布局:用 x 坐标归属
bx, _ = _box_center(buyer_label_box)
sx, _ = _box_center(seller_label_box)
mid = (bx + sx) / 2
for line, name in name_lines:
cx, _ = _box_center(line.box)
if cx < mid and not buyer_name:
buyer_name = name
elif cx >= mid and not seller_name:
seller_name = name
for line, tax in tax_lines:
cx, _ = _box_center(line.box)
if cx < mid and not buyer_tax:
buyer_tax = tax
elif cx >= mid and not seller_tax:
seller_tax = tax
else:
# 上下栏 / 单栏:按 y 坐标(第一个 = 购买方)
name_lines.sort(key=lambda x: (_box_center(x[0].box)[1], _box_center(x[0].box)[0]))
tax_lines.sort(key=lambda x: (_box_center(x[0].box)[1], _box_center(x[0].box)[0]))
if len(name_lines) >= 1:
buyer_name = name_lines[0][1]
if len(name_lines) >= 2:
seller_name = name_lines[1][1]
if len(tax_lines) >= 1:
buyer_tax = tax_lines[0][1]
if len(tax_lines) >= 2:
seller_tax = tax_lines[1][1]
return seller_name, seller_tax, buyer_name, buyer_tax
def _extract_parties_from_text(text: str) -> tuple:
"""无 box 信息时的 fallback:按显式标签分段"""
seller_name = buyer_name = None
seller_tax = buyer_tax = None
for m in _BUYER_LABEL.finditer(text):
chunk = text[m.end():m.end() + 200]
nm = _NAME_PATTERN.search(chunk)
tm = _TAX_NO_PATTERN.search(chunk[:100])
if nm and not buyer_name:
buyer_name = _clean_name(nm.group(1))
if tm and not buyer_tax:
buyer_tax = tm.group(1)
if buyer_name and buyer_tax:
break
for m in _SELLER_LABEL.finditer(text):
chunk = text[m.end():m.end() + 200]
nm = _NAME_PATTERN.search(chunk)
tm = _TAX_NO_PATTERN.search(chunk[:100])
if nm and not seller_name:
seller_name = _clean_name(nm.group(1))
if tm and not seller_tax:
seller_tax = tm.group(1)
if seller_name and seller_tax:
break
if not buyer_name:
name_positions = [(m.start(), m.group(1)) for m in _NAME_PATTERN.finditer(text)]
if len(name_positions) >= 1:
buyer_name = _clean_name(name_positions[0][1])
if len(name_positions) >= 2:
seller_name = _clean_name(name_positions[1][1])
tax_positions = _TAX_NO_PATTERN.findall(text)
if not buyer_tax and len(tax_positions) >= 1:
buyer_tax = tax_positions[0]
if not seller_tax and len(tax_positions) >= 2:
seller_tax = tax_positions[1]
return seller_name, seller_tax, buyer_name, buyer_tax
def extract(text: str, lines: Optional[List[OCRLine]] = None) -> InvoiceFields:
raw = _norm(text)
fields = InvoiceFields()
fields.invoice_type = _detect_invoice_type(raw)
fields.invoice_code = _extract_invoice_code(raw)
fields.invoice_no = _extract_invoice_no(raw)
fields.invoice_date = _extract_date(raw)
# 金额
total = extract_total_amount(raw)
tax = extract_tax_amount(raw)
pretax = extract_pretax_amount(raw)
# 兜底:从所有 .xx 数字中找满足 tax + pretax = total 的组合
if (tax is None or pretax is None) and total is not None:
candidates = sorted(
{float(x) for x in re.findall(r"(\d+\.\d{2})", raw) if float(x) < total},
reverse=True,
)
for i, a in enumerate(candidates):
for b in candidates[i + 1:]:
if abs(a + b - total) < 0.011:
if pretax is None:
pretax = round(a, 2)
if tax is None:
tax = round(b, 2)
break
if tax is not None and pretax is not None:
break
# 单数字兜底:如果 total 不在候选里,只剩一个候选时
if (tax is None or pretax is None) and len(candidates) == 1:
only = round(candidates[0], 2)
if pretax is None and tax is None:
pretax = only
tax = round(total - only, 2)
elif tax is None:
tax = only
elif pretax is None:
pretax = only
# 二次兜底:合计 - 任一 = 另一
if tax is None and total is not None and pretax is not None:
tax = round(total - pretax, 2)
if pretax is None and total is not None and tax is not None:
pretax = round(total - tax, 2)
fields.amount = total
fields.tax_amount = tax
fields.amount_pretax = pretax
fields.amount_cn = extract_cn_amount(raw)
fields.amount_match = amount_consistent(fields.amount_cn, fields.amount)
# 主体
if lines:
seller_name, seller_tax, buyer_name, buyer_tax = _extract_parties_from_lines(lines)
if not (seller_name and buyer_name):
sn, st, bn, bt = _extract_parties_from_text(raw)
seller_name = seller_name or sn
buyer_name = buyer_name or bn
seller_tax = seller_tax or st
buyer_tax = buyer_tax or bt
else:
seller_name, seller_tax, buyer_name, buyer_tax = _extract_parties_from_text(raw)
fields.seller_name = seller_name
fields.seller_tax_no = seller_tax
fields.buyer_name = buyer_name
fields.buyer_tax_no = buyer_tax
return fields
+138
View File
@@ -0,0 +1,138 @@
"""电子发票二维码识别
国家税务总局规范的电子发票二维码内容格式 (8 字段逗号分隔):
01,<type>,<invoice_code>,<invoice_no>,<amount>,<date>,<check_code>,<reserved>
例: 01,31,,24922000000006110014,39500.00,20240202,,A371
"""
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Optional, Tuple
import cv2
import numpy as np
from loguru import logger
@dataclass
class QRDecodeResult:
"""二维码识别结果"""
invoice_no: Optional[str] = None # 发票号码
amount: Optional[float] = None # 金额(小写)
invoice_date: Optional[str] = None # 开票日期 YYYY-MM-DD
raw: str = "" # 二维码原始文本
def _try_detect(img: np.ndarray, qd: cv2.QRCodeDetector) -> str:
"""opencv 单图识别 + 多二维码识别, 任一成功即返回文本"""
data, _, _ = qd.detectAndDecode(img)
if data:
return data
try:
retval, decoded_info, _, _ = qd.detectAndDecodeMulti(img)
if decoded_info:
# 取第一个非空的
for d in decoded_info:
if d:
return d
except Exception:
pass
return ""
def _detect_qr(image_path: Path) -> str:
"""从图片里解二维码; 全图 + 四象限各扫一遍"""
img = cv2.imread(str(image_path))
if img is None:
return ""
qd = cv2.QRCodeDetector()
# 1) 全图
txt = _try_detect(img, qd)
if txt:
return txt
# 2) 四象限 (二维码常在票面边角)
h, w = img.shape[:2]
for name, crop in [
("left-top", img[: h // 2, : w // 2]),
("right-top", img[: h // 2, w // 2 :]),
("left-bottom", img[h // 2 :, : w // 2]),
("right-bottom", img[h // 2 :, w // 2 :]),
]:
txt = _try_detect(crop, qd)
if txt:
logger.debug("QR found in {}", name)
return txt
# 3) 放大再试 (二维码像素过小的情况)
scaled = cv2.resize(img, None, fx=2.0, fy=2.0, interpolation=cv2.INTER_CUBIC)
return _try_detect(scaled, qd)
def _parse_qr_payload(raw: str) -> QRDecodeResult:
"""解析电子发票二维码内容 → QRDecodeResult
格式不合规返回空对象 (字段全 None), 由调用方判定为「格式不正确」并 fallback。
"""
if not raw:
return QRDecodeResult(raw=raw or "")
parts = raw.split(",")
if len(parts) != 8:
logger.debug("QR 字段数 {} != 8, 视为格式不正确", len(parts))
return QRDecodeResult(raw=raw)
result = QRDecodeResult(raw=raw)
# parts[3] = 发票号
invoice_no = parts[3].strip()
if invoice_no and (10 <= len(invoice_no) <= 30):
result.invoice_no = invoice_no
# parts[4] = 金额
amt_str = parts[4].strip()
if amt_str:
try:
result.amount = round(float(amt_str), 2)
except ValueError:
pass
# parts[5] = 开票日期 (YYYYMMDD)
date_str = parts[5].strip()
if len(date_str) == 8 and date_str.isdigit():
result.invoice_date = f"{date_str[:4]}-{date_str[4:6]}-{date_str[6:8]}"
return result
def decode_qr(image_path: Path) -> QRDecodeResult:
"""从图片文件解电子发票二维码
返回 QRDecodeResult; 字段全 None 表示无二维码或格式不正确。
调用方应据此判定 fallback。
"""
try:
raw = _detect_qr(image_path)
except Exception as e:
logger.warning("QR 检测异常 {}: {}", image_path.name, e)
return QRDecodeResult()
if not raw:
return QRDecodeResult()
parsed = _parse_qr_payload(raw)
# 任一关键字段解出即视为「格式正确」
if parsed.invoice_no or parsed.amount or parsed.invoice_date:
logger.info("QR 解码成功: no={}, amt={}, date={}",
parsed.invoice_no, parsed.amount, parsed.invoice_date)
else:
logger.debug("QR 解出但字段无效: raw={}", raw[:80])
return parsed
def has_any_field(qr: QRDecodeResult) -> bool:
"""QR 是否解出了至少一个核心字段"""
return bool(qr.invoice_no or qr.amount or qr.invoice_date)
+237
View File
@@ -0,0 +1,237 @@
"""端到端识别流水:文件 → QR → (可选)OCR → 字段抽取 → InvoiceResult
整体有 OCR_TOTAL_TIMEOUT_S 兜底;单页有 OCR_PAGE_TIMEOUT_S 兜底。
任一超时立即返回失败,不再死等。
QR 流程 (config: QR_FULL_OCR):
- 先扫二维码取 开票时间/发票号/金额 (国家税务总局 8 字段逗号规范)
- 扫到 + qr_full_ocr=false → 直接返回这 3 个字段 (快路径, 跳过 OCR)
- 扫到 + qr_full_ocr=true → 继续跑 OCR, QR 的 3 字段覆盖 OCR 抽取结果
- 没扫到 / 格式不合法 → 直接判定为非发票 (不再跑 OCR)
"""
from __future__ import annotations
import shutil
import tempfile
import time
from pathlib import Path
from typing import List
from loguru import logger
from app.config import settings
from app.core import OCRTimeout, auto_rotate, enhance, pdf_to_images, recognize
from app.models import InvoiceResult, InvoiceFields, OCRLine
from app.services import decode_qr, extract_invoice, qr_has_any_field
_IMG_EXTS = {".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff"}
def _save_upload(upload_bytes: bytes, suffix: str) -> Path:
tmp = Path(tempfile.mkstemp(suffix=suffix)[1])
tmp.write_bytes(upload_bytes)
return tmp
def _ocr_image(image_path: Path) -> List[OCRLine]:
img = auto_rotate(image_path)
img = enhance(img)
return recognize(img)
def _overlay_qr_fields(fields: InvoiceFields, qr) -> InvoiceFields:
"""QR 解出的 3 字段优先, 没解到的保持 OCR 结果"""
data = fields.model_dump()
if qr.invoice_no:
data["invoice_no"] = qr.invoice_no
if qr.amount is not None:
data["amount"] = qr.amount
if qr.invoice_date:
data["invoice_date"] = qr.invoice_date
return InvoiceFields(**data)
def _build_qr_only_result(qr, elapsed_ms: int, page_count: int) -> InvoiceResult:
"""快路径: 只用 QR 字段, 跳过 OCR"""
fields = InvoiceFields(
invoice_no=qr.invoice_no,
amount=qr.amount,
invoice_date=qr.invoice_date,
)
return InvoiceResult(
success=True,
is_invoice=True,
raw_text=f"[QR only] {qr.raw}",
lines=[],
fields=fields,
page_count=page_count,
engine="qr",
elapsed_ms=elapsed_ms,
from_qr=True,
qr_raw=qr.raw,
)
def recognize_file(filename: str, content: bytes) -> InvoiceResult:
"""识别入口(从字节流):自动判断 PDF / 图片;任何环节超时立即返回失败"""
t0 = time.time()
suffix = Path(filename).suffix.lower()
tmp_path = _save_upload(content, suffix or ".bin")
try:
return recognize_path(tmp_path, delete_after=True)
except Exception:
raise
def recognize_path(file_path: Path, *, delete_after: bool = False) -> InvoiceResult:
"""识别入口(从磁盘路径):不做上传字节校验,直接读本地文件
Args:
file_path: 已在白名单校验过的本地文件路径
delete_after: True 表示临时文件用完删除(上传流场景),False 保留(原文件场景)
"""
t0 = time.time()
suffix = file_path.suffix.lower()
total_deadline = t0 + settings.ocr_total_timeout_s
def _elapsed_ms() -> int:
return int((time.time() - t0) * 1000)
try:
# ---------- 1. PDF / 图片 → 图片列表 ----------
try:
if suffix == ".pdf":
page_imgs = pdf_to_images(file_path)
elif suffix in _IMG_EXTS:
page_imgs = [file_path]
else:
return InvoiceResult(
success=False,
is_invoice=False,
error=f"不支持的文件类型: {suffix}(仅支持 PDF / 图片)",
error_code="unsupported",
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
)
except Exception as e:
logger.exception("PDF/图片处理失败: {}", e)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"PDF/图片处理失败: {e}",
error_code="process_failed",
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
)
# ---------- 2. QR 优先识别 ----------
qr = decode_qr(page_imgs[0])
qr_ok = qr_has_any_field(qr)
if qr_ok and not settings.qr_full_ocr:
# 快路径: 扫到 QR 且配置为 fast, 直接返回
logger.info("QR 快路径: {} 耗时 {}ms", file_path.name, _elapsed_ms())
return _build_qr_only_result(qr, _elapsed_ms(), len(page_imgs))
if not qr_ok:
# 没扫到 / 格式不合法 → 直接认为非发票, 不再跑 OCR
reason = "未识别到发票二维码" if not qr.raw else "二维码格式不合法"
logger.info("非发票 (无有效 QR): {} 耗时 {}ms", file_path.name, _elapsed_ms())
return InvoiceResult(
success=False,
is_invoice=False,
error=f"{reason}(可能不是发票图片)",
error_code="not_invoice",
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=False,
qr_raw=qr.raw or None,
qr_error="no_qr" if not qr.raw else "bad_format",
)
# ---------- 3. 每页 OCR(单页超时 + 总超时双重保护)----------
# 到这里 qr_ok=True, OCR 仅为补充信息; QR 已保证是发票
all_lines: List[OCRLine] = []
for idx, img_path in enumerate(page_imgs, start=1):
remaining = total_deadline - time.time()
if remaining <= 0:
logger.warning("达到总超时 ({:.1f}s), 中断 OCR", settings.ocr_total_timeout_s)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"达到总超时 ({settings.ocr_total_timeout_s}秒), 已识别 {idx-1}/{len(page_imgs)}",
error_code="timeout",
raw_text="\n".join(l.text for l in all_lines),
lines=all_lines,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=True,
qr_raw=qr.raw,
)
page_timeout = min(settings.ocr_page_timeout_s, remaining)
try:
all_lines.extend(_ocr_image(img_path))
except OCRTimeout as e:
logger.warning("{} 页 OCR 超时: {}", idx, e)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"{idx} 页识别超时 ({page_timeout:.1f}秒)",
error_code="timeout",
raw_text="\n".join(l.text for l in all_lines),
lines=all_lines,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=True,
qr_raw=qr.raw,
)
except Exception as e:
logger.exception("{} 页 OCR 失败: {}", idx, e)
return InvoiceResult(
success=False,
is_invoice=False,
error=f"{idx} 页识别失败: {e}",
error_code="ocr_failed",
raw_text="\n".join(l.text for l in all_lines),
lines=all_lines,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=_elapsed_ms(),
from_qr=True,
qr_raw=qr.raw,
)
# ---------- 4. 字段抽取 + QR 字段覆盖 ----------
raw_text = "\n".join(l.text for l in all_lines)
fields = extract_invoice(raw_text, all_lines)
fields = _overlay_qr_fields(fields, qr)
elapsed = _elapsed_ms()
logger.info("识别完成: {} 页={}, from_qr=true, 字段数={}, 耗时={}ms",
file_path.name, len(page_imgs),
sum(1 for f in fields.model_dump().values() if f), elapsed)
return InvoiceResult(
success=True,
is_invoice=True,
raw_text=raw_text,
lines=all_lines,
fields=fields,
page_count=len(page_imgs),
engine="paddleocr",
elapsed_ms=elapsed,
from_qr=True,
qr_raw=qr.raw,
)
finally:
if delete_after:
try:
file_path.unlink(missing_ok=True)
if suffix == ".pdf":
shutil.rmtree(file_path.parent / f".{file_path.stem}_pages", ignore_errors=True)
except Exception:
pass