Files
verify-invoice-default/scripts/service/rpa/invoice_rpa.py
chenfuqiang 39eacc4c4a
All checks were successful
技能自动化发布 / release (push) Successful in 18s
chore: auto release commit (2026-09-15 10:19:55)
2026-09-15 10:19:55 +08:00

278 lines
7.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""发票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