"""电子发票二维码识别 国家税务总局规范的电子发票二维码内容格式 (8 字段逗号分隔): 01,,,,,,, 例: 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)