"""发票RPA主流程:协调登录、导航、提取等操作。 完整流程: 1. 启动浏览器 2. 登录 sandbox.jc2009.com(手机号+密码) 3. 找到"智慧校园"并点击进入 4. 登录智慧校园(默认 demo 账号) 5. 导航到票据合规预审页面 6. 提取发票数据 """ from __future__ import annotations import logging import uuid from typing import Any, Dict, Optional from jiangchang_skill_core.activity import interruptible_sleep, rpa_step from jiangchang_skill_core.rpa.video_session import RpaVideoSession from .browser_session import close_browser_context, start_browser_session from .invoice_extractor import extract_form_fields, extract_invoice_list, format_invoice_data from .login_handler import ( enter_smart_campus, login_sandbox, login_smart_campus, navigate_to_invoice_precheck, ) logger = logging.getLogger(__name__) def _get_active_page(context: Any) -> Any: """获取 context 中最新的活跃页面(处理新 tab 场景)。""" pages = context.pages if pages: return pages[-1] raise RuntimeError("ERROR:NO_PAGE 浏览器没有可用页面") class InvoiceRpaResult: """RPA执行结果。""" def __init__( self, success: bool, data: Optional[Dict[str, Any]] = None, error: Optional[str] = None, stage: Optional[str] = None, ): self.success = success self.data = data or {} self.error = error self.stage = stage def to_dict(self) -> Dict[str, Any]: return { "success": self.success, "data": self.data, "error": self.error, "stage": self.stage, } @rpa_step("启动浏览器") async def _start_browser(profile_dir: str, headless: bool = False) -> tuple[Any, Any, Any]: """启动浏览器会话。""" pw, context, page = await start_browser_session(profile_dir, headless=headless) return pw, context, page @rpa_step("登录 sandbox 门户") async def _login_sandbox_portal(page: Any) -> bool: """登录 sandbox.jc2009.com 门户(人工输入)。""" return await login_sandbox(page) @rpa_step("进入智慧校园") async def _enter_campus(page: Any) -> bool: """在平台首页找到并点击智慧校园。""" return await enter_smart_campus(page) @rpa_step("登录智慧校园") async def _login_campus(page: Any) -> bool: """登录智慧校园系统。""" return await login_smart_campus(page) @rpa_step("导航到票据合规预审") async def _navigate_to_invoice(page: Any) -> bool: """导航到票据合规预审页面。""" return await navigate_to_invoice_precheck(page) @rpa_step("提取发票数据") async def _extract_data(page: Any) -> Dict[str, Any]: """提取发票列表和表单字段。""" # 提取表单字段 form_fields = await extract_form_fields(page) logger.info("form_fields_extracted keys=%s", list(form_fields.keys())) # 提取发票列表 invoices = await extract_invoice_list(page) logger.info("invoices_extracted count=%s", len(invoices)) # 组合结果 result = format_invoice_data(invoices) result.update(form_fields) return result async def run_invoice_rpa( username: str, password: str, profile_dir: str, headless: bool = False, detail_mode: bool = False, data_dir: str = "", batch_id: str = "", ) -> InvoiceRpaResult: """运行发票RPA主流程。 Args: username: 手机号(sandbox 门户登录) password: 密码(sandbox 门户登录) profile_dir: 浏览器配置目录 headless: 是否无头模式 detail_mode: 是否提取详细信息(预留) data_dir: 数据目录 batch_id: 批次ID Returns: InvoiceRpaResult: 执行结果 """ if not batch_id: batch_id = uuid.uuid4().hex[:12] logger.info( "invoice_rpa_start username=%s*** batch_id=%s detail_mode=%s", (username or "")[:3], batch_id, detail_mode, ) pw = None context = None page = None try: # 步骤 1:启动浏览器 pw, context, page = await _start_browser(profile_dir, headless) # 步骤 2:登录 sandbox 门户 sandbox_ok = await _login_sandbox_portal(page) if not sandbox_ok: return InvoiceRpaResult( success=False, error="登录 sandbox 门户失败", stage="sandbox_login", ) # 步骤 3:进入智慧校园(可能打开新 tab,需要刷新 page) campus_ok = await _enter_campus(page) if not campus_ok: return InvoiceRpaResult( success=False, error="进入智慧校园失败", stage="enter_campus", ) # 刷新 page 引用(处理新 tab 场景) page = _get_active_page(context) logger.info("page_refreshed_after_enter_campus url=%s", page.url) # 步骤 4:登录智慧校园 login_ok = await _login_campus(page) if not login_ok: return InvoiceRpaResult( success=False, error="登录智慧校园失败", stage="campus_login", ) # 刷新 page 引用 page = _get_active_page(context) logger.info("page_refreshed_after_campus_login url=%s", page.url) # 步骤 5:导航到票据合规预审 nav_ok = await _navigate_to_invoice(page) if not nav_ok: return InvoiceRpaResult( success=False, error="导航到票据合规预审页面失败", stage="navigation", ) # 刷新 page 引用 page = _get_active_page(context) logger.info("page_refreshed_after_navigate url=%s", page.url) # 步骤 6:提取发票数据 result_data = await _extract_data(page) if "error" in result_data: return InvoiceRpaResult( success=False, error=result_data["error"], stage="extraction", ) logger.info( "invoice_rpa_success total_invoices=%s batch_id=%s", result_data.get("total_count", 0), batch_id, ) return InvoiceRpaResult( success=True, data=result_data, stage="completed", ) except Exception as e: logger.error("invoice_rpa_failed error=%s batch_id=%s", str(e), batch_id) return InvoiceRpaResult( success=False, error=str(e), stage="unknown", ) finally: # 关闭浏览器 if pw and context: await close_browser_context(pw, context) async def run_invoice_rpa_with_video( username: str, password: str, profile_dir: str, skill_slug: str, skill_data_dir: str, headless: bool = False, detail_mode: bool = False, title: str = "开始提取发票信息", closing_title: str = "发票信息提取完成", ) -> InvoiceRpaResult: """带录屏的发票RPA主流程。""" batch_id = uuid.uuid4().hex[:12] async with RpaVideoSession( skill_slug=skill_slug, skill_data_dir=skill_data_dir, batch_id=batch_id, title=title, closing_title=closing_title, ) as video: video.add_step("准备启动浏览器") # 运行RPA result = await run_invoice_rpa( username=username, password=password, profile_dir=profile_dir, headless=headless, detail_mode=detail_mode, data_dir=skill_data_dir, batch_id=batch_id, ) if result.success: video.add_step(f"成功提取 {result.data.get('total_count', 0)} 张发票信息") else: video.add_step(f"提取失败: {result.error}") return result