"""发票信息提取器:从票据合规预审页面提取发票数据。 根据 sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck 页面 DOM 结构: - 报销事由:文本输入框 - 预算项目:下拉选择框 - 差旅审批单:下拉选择框 - 选择发票:列表,每项包含 checkbox、发票号、金额、类型、公司名、日期、购方税号 """ from __future__ import annotations import logging import re from typing import Any, Dict, List, Optional logger = logging.getLogger(__name__) async def extract_invoice_list(page: Any) -> List[Dict[str, Any]]: """从票据合规预审页面提取发票列表。 页面中发票以列表项形式展示,每项包含: - Checkbox(勾选状态) - 发票号(8位数字) - 金额(¥xxx) - 类型(transport/lodging 等) - 公司名 - 日期 - 购方税号 - 验真状态("未验真" 标记) Returns: 发票信息字典列表 """ invoices: List[Dict[str, Any]] = [] try: # 等待发票区域加载(checkbox 出现即可开始提取) try: await page.locator('input[type="checkbox"]').first.wait_for(state="attached", timeout=10_000) except Exception: pass # 超时也继续尝试提取 # 方式一:通过 checkbox 定位发票项(最可靠) checkboxes = page.locator('input[type="checkbox"]') checkbox_count = await checkboxes.count() logger.info("found_checkboxes count=%s", checkbox_count) if checkbox_count > 0: # 每个 checkbox 对应一个发票项,向上找到父容器 for i in range(checkbox_count): try: checkbox = checkboxes.nth(i) # 获取 checkbox 所在的父级容器(卡片或行) parent = checkbox.locator("xpath=ancestor::div[contains(@class,'card') or contains(@class,'item') or contains(@class,'row') or contains(@class,'invoice')]").first if await parent.count() == 0: # fallback: 向上找 3 层 parent = checkbox.locator("xpath=ancestor::div[3]").first text = await parent.inner_text() if await parent.count() > 0 else "" is_checked = await checkbox.is_checked() invoice = _parse_invoice_text(text) if invoice: invoice["selected"] = is_checked invoices.append(invoice) except Exception as e: logger.warning("extract_checkbox_item_failed index=%s error=%s", i, str(e)) continue # 方式二:如果方式一没提取到,通过文本匹配 if not invoices: logger.info("fallback_to_text_extraction") invoices = await _extract_by_text_pattern(page) logger.info("extraction_complete total_invoices=%s", len(invoices)) return invoices except Exception as e: logger.error("extract_invoice_list_failed error=%s", str(e)) return [] async def _extract_by_text_pattern(page: Any) -> List[Dict[str, Any]]: """通过文本模式匹配提取发票信息(fallback)。""" invoices: List[Dict[str, Any]] = [] try: # 查找包含发票号(8位数字)的元素 invoice_pattern = page.locator("div, tr, li").filter( has_text=re.compile(r"\d{8}.*[¥¥]\s*[\d,]+") ) count = await invoice_pattern.count() logger.info("text_pattern_match count=%s", count) for i in range(count): try: element = invoice_pattern.nth(i) text = await element.inner_text() invoice = _parse_invoice_text(text) if invoice: # 检查是否被选中(附近有 checkbox) checkbox = element.locator('input[type="checkbox"]') if await checkbox.count() > 0: invoice["selected"] = await checkbox.first.is_checked() else: invoice["selected"] = False invoices.append(invoice) except Exception as e: logger.warning("text_pattern_extract_failed index=%s error=%s", i, str(e)) continue except Exception as e: logger.warning("text_pattern_extraction_error error=%s", str(e)) return invoices def _parse_invoice_text(text: str) -> Optional[Dict[str, Any]]: """从发票文本中解析结构化数据。 典型格式: 87654321 · ¥553 · transport 中国铁路北京局集团有限公司 · 2026-03-12 · 购方税号 121000004000012345 """ if not text or not text.strip(): return None lines = [ln.strip() for ln in text.splitlines() if ln.strip()] full_text = " ".join(lines) # 提取发票号(8位数字) invoice_no_match = re.search(r"(\d{8})", full_text) invoice_no = invoice_no_match.group(1) if invoice_no_match else "" if not invoice_no: return None # 提取金额(¥ 后跟数字) amount_match = re.search(r"[¥¥]\s*([\d,]+\.?\d*)", full_text) amount = 0.0 if amount_match: amount_str = amount_match.group(1).replace(",", "") try: amount = float(amount_str) except ValueError: pass # 提取类型(transport/lodging/meal/other) invoice_type = "" for tp in ["transport", "lodging", "meal", "other"]: if tp in full_text.lower(): invoice_type = tp break # 提取公司名(中文公司名,包含常见后缀) company_match = re.search( r"([\u4e00-\u9fa5]{2,}(?:公司|集团|酒店|商贸|有限|商店|宾馆|饭店))", full_text ) company = company_match.group(1) if company_match else "" # 提取日期(YYYY-MM-DD) date_match = re.search(r"(\d{4}-\d{2}-\d{2})", full_text) date_str = date_match.group(1) if date_match else "" # 提取购方税号 tax_no_match = re.search(r"购方税号\s*(\S+)", full_text) tax_no = tax_no_match.group(1) if tax_no_match else "" if not tax_no: # fallback: 查找 15-20 位字母数字混合税号 tax_no_fallback = re.search(r"([0-9A-Z]{15,20})", full_text) if tax_no_fallback: tax_no = tax_no_fallback.group(1) # 判断验真状态 verified = "未验真" not in full_text return { "invoice_no": invoice_no, "amount": amount, "invoice_type": invoice_type, "company": company, "date": date_str, "tax_no": tax_no, "verified": verified, } async def extract_invoice_detail(page: Any, invoice_index: int = 0) -> Optional[Dict[str, Any]]: """提取单个发票的详细信息(点击进入详情页后调用)。 Args: page: Playwright 页面对象 invoice_index: 发票索引(从0开始) Returns: 发票详细信息字典 """ try: rows = page.locator("tr").filter(has_text=re.compile(r"\d{6,}")) count = await rows.count() if invoice_index >= count: logger.warning("invoice_index_out_of_range index=%s total=%s", invoice_index, count) return None row = rows.nth(invoice_index) text = await row.inner_text() return _parse_invoice_text(text) except Exception as e: logger.error("extract_invoice_detail_failed error=%s", str(e)) return None async def extract_form_fields(page: Any) -> Dict[str, str]: """提取页面表单字段(报销事由、预算项目、差旅审批单)。""" result: Dict[str, str] = {} # 报销事由 try: reason_input = page.get_by_label("报销事由") if await reason_input.count() > 0: result["reimbursement_reason"] = (await reason_input.input_value()).strip() except Exception: pass # 预算项目(下拉框选中项) try: budget_selects = page.locator("select") select_count = await budget_selects.count() for i in range(select_count): sel = budget_selects.nth(i) text = await sel.inner_text() if "GDST" in text or "乡村振兴" in text or "预算" in text: # 获取选中的 option selected = sel.locator("option:checked") if await selected.count() > 0: result["budget_project"] = (await selected.first.inner_text()).strip() else: result["budget_project"] = text.strip()[:100] break except Exception: pass # 差旅审批单(下拉框选中项) try: travel_selects = page.locator("select") select_count = await travel_selects.count() for i in range(select_count): sel = travel_selects.nth(i) text = await sel.inner_text() if "CL-" in text or "差旅" in text or "北京" in text: selected = sel.locator("option:checked") if await selected.count() > 0: result["travel_approval"] = (await selected.first.inner_text()).strip() else: result["travel_approval"] = text.strip()[:100] break except Exception: pass return result def format_invoice_data(invoices: List[Dict[str, Any]]) -> Dict[str, Any]: """格式化发票数据为结构化格式。""" return { "total_count": len(invoices), "invoices": invoices, "extraction_complete": True, }