"""任务编排层:job_context / emit / finish、鉴权与错误转换。 领域业务在 ``service/prereview_service.py``(预审内核)、 ``service/standards_service.py``(标准库/白名单/查验回填)、 ``service/report_service.py``(HTML 意见单),由本模块调用; 多入口(CLI / Action / 对话)复用同一内核,禁止按入口分叉业务。 """ from __future__ import annotations import asyncio import json import os import uuid from typing import Any, Optional from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines from jiangchang_skill_core.activity import JobStopped, emit, finish, job_context from jiangchang_skill_core.rpa.video_session import RpaVideoSession from service.entitlement_service import check_entitlement from service.task_run_support import ( _print_video_summary, build_video_info, merge_video_into_result_summary, ) from util.constants import LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION from util.logging_config import get_skill_logger, setup_skill_logging from util.runtime_paths import get_db_path, get_input_dir, get_skill_root from db.connection import init_db def _get_task_logger(): try: return get_skill_logger() except RuntimeError: setup_skill_logging(SKILL_SLUG, LOG_LOGGER_NAME) return get_skill_logger() def _print_json(payload: dict[str, Any]) -> None: print(json.dumps(payload, ensure_ascii=False)) # --------------------------------------------------------------------------- # 模板演示入口(保留:满足 POLICY-RPA-003 录屏集成与 CLI run 契约;不承载本技能业务) # --------------------------------------------------------------------------- async def _run_template_demo(target: Optional[str], input_id: Optional[str]) -> tuple[int, dict[str, Any]]: """最小 RpaVideoSession 示范:不启动浏览器、不执行业务,仅演示录屏包裹范式。""" batch_id = uuid.uuid4().hex[:12] data_dir = get_skill_root() async with RpaVideoSession( skill_slug=SKILL_SLUG, skill_data_dir=data_dir, batch_id=batch_id, title="开始执行示例任务", closing_title="示例任务执行完成", ) as video: video.add_step("准备执行示例任务") if target: video.add_step(f"接收目标参数:{target}") if input_id: video.add_step(f"接收输入标识:{input_id}") video.add_step("示例任务执行完成") video_summary = video.summary() video_info = build_video_info(video_summary, {}, {}) return 1, video_info def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int: """模板演示入口:本技能主能力请使用 prereview 等业务命令。""" log = _get_task_logger() task_type = "demo" with job_context(skill=SKILL_SLUG): log.info( "task_start task_type=%s target_id=%s input_id=%s stage=run", task_type, target, input_id, ) emit("开始处理任务", skill=SKILL_SLUG, stage="run") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning( "entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED", task_type, ) emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth") finish( status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth", ) print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 log.info("entitlement_ok task_type=%s stage=auth", task_type) log.info("rpa_start stage=demo_prepare task_type=%s", task_type) log.info("task_run_demo_start target_id=%s input_id=%s stage=demo_prepare", target, input_id) rc, video_info = asyncio.run(_run_template_demo(target, input_id)) log.info("rpa_done stage=demo_prepare status=ok") log.info( "task_run_demo_done target_id=%s input_id=%s stage=demo_finish status=failed", target, input_id, ) _print_video_summary(video_info) summary_payload = merge_video_into_result_summary( { "template_demo": True, "target": target, "input_id": input_id, }, video_info, ) log.warning( "task_failed task_type=%s stage=demo_finish error_code=TEMPLATE_STUB", task_type, ) finish( status="failed", message="模板演示入口未承载本技能业务", skill=SKILL_SLUG, error_code="TEMPLATE_STUB", **summary_payload, ) print("ERROR:TEMPLATE_STUB 这是模板演示入口,请使用 prereview 命令执行票据合规预审。") return rc except JobStopped: raise except Exception: log.exception( "task_failed task_type=%s target_id=%s input_id=%s", task_type, target, input_id, ) emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run") finish( status="failed", message="任务执行异常,详见统一日志", skill=SKILL_SLUG, stage="run", ) return 1 # --------------------------------------------------------------------------- # 基础命令 # --------------------------------------------------------------------------- def cmd_config_path() -> int: example_path = os.path.join(get_skill_root(), ".env.example") env_path = config.get_env_file_path() or "" _print_json( { "skill": SKILL_SLUG, "env_path": env_path, "example_path": os.path.abspath(example_path), } ) return 0 def cmd_health() -> int: runtime = collect_runtime_diagnostics( skill_slug=SKILL_SLUG, platform_kit_min_version=PLATFORM_KIT_MIN_VERSION, skill_root=get_skill_root(), ) example_path = os.path.join(get_skill_root(), ".env.example") env_path = config.get_env_file_path() or "" env_exists = bool(env_path and os.path.isfile(env_path)) health_status = "failed" if runtime.has_fatal_issues else "ok" lines = [ f"{SKILL_SLUG} health: {health_status}", *format_runtime_health_lines(runtime), f"env_path: {env_path}", f"env_exists: {env_exists}", f"example_path: {os.path.abspath(example_path)}", ] for line in lines: print(line) return 1 if runtime.has_fatal_issues else 0 def cmd_version() -> int: _print_json({"version": SKILL_VERSION, "skill": SKILL_SLUG}) return 0 def cmd_init_db() -> int: """幂等创建/迁移本地 SQLite 与数据管理元数据(宿主 install/upgrade 可调用)。""" log = _get_task_logger() try: seeded = init_db() except Exception as exc: # noqa: BLE001 — CLI 边界,统一成可机读失败 print( json.dumps( {"ok": False, "skill": SKILL_SLUG, "error": str(exc)}, ensure_ascii=False, ) ) return 1 _print_json( { "ok": True, "skill": SKILL_SLUG, "db_path": get_db_path(), "seeded": seeded, } ) return 0 # --------------------------------------------------------------------------- # 预审主命令 # --------------------------------------------------------------------------- def _load_invoice_payload(invoice_json: str) -> list[dict[str, Any]]: """解析 --invoice-json:支持 '-'(stdin)、文件路径、内联 JSON。""" from service.prereview_service import _norm_invoice raw = (invoice_json or "").strip() if not raw: raise ValueError("缺少发票字段 JSON(--invoice-json)") if raw == "-": import sys raw = sys.stdin.read().strip() elif raw.startswith(("[", "{")): pass elif os.path.isfile(raw): with open(raw, encoding="utf-8") as f: raw = f.read().strip() else: raise ValueError(f"--invoice-json 不是有效文件路径或 JSON:{raw[:80]}") try: payload = json.loads(raw) except json.JSONDecodeError as exc: raise ValueError(f"发票字段 JSON 解析失败:{exc}") from exc if isinstance(payload, dict): # 兼容 {"invoices": [...]} 包装 if isinstance(payload.get("invoices"), list): payload = payload["invoices"] else: payload = [payload] if not isinstance(payload, list): raise ValueError("发票字段 JSON 应为对象或对象数组") return [_norm_invoice(item) for item in payload] def cmd_prereview( *, invoice_json: str, purpose: str = "", project_no: str = "", trip_start: str = "", trip_end: str = "", trip_city: str = "", staff_title: str = "", ) -> int: """票据合规预审主命令:四步审核 → JSON 结果 + 写库 + 《初审意见单》。""" log = _get_task_logger() from service.prereview_service import run_prereview context = { "purpose": purpose, "project_no": project_no, "trip_start": trip_start, "trip_end": trip_end, "trip_city": trip_city, "staff_title": staff_title, } with job_context(skill=SKILL_SLUG): log.info("task_start task_type=prereview stage=prereview") emit("开始票据合规预审", skill=SKILL_SLUG, stage="prereview") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=prereview stage=auth error_code=ENTITLEMENT_DENIED") emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("prereview_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 log.info("entitlement_ok task_type=prereview stage=auth") try: invoices = _load_invoice_payload(invoice_json) except ValueError as exc: log.warning("task_failed task_type=prereview stage=input error_code=INVOICE_INVALID detail=%s", exc) finish( status="failed", message=str(exc), skill=SKILL_SLUG, error_code="INVOICE_INVALID", stage="input", ) log.error("prereview_invalid_input stage=input") print(f"ERROR:INVOICE_INVALID {exc}") return 1 result = run_prereview(invoices, context) _print_json(result) counts = result["verdict_counts"] emit( f"预审完成:通过 {counts.get('通过', 0)},待确认 {counts.get('待确认', 0)},驳回 {counts.get('驳回', 0)}", skill=SKILL_SLUG, stage="prereview", ) finish( status="success", message=( f"预审完成 {result['count']} 张:通过 {counts.get('通过', 0)}," f"待确认 {counts.get('待确认', 0)},驳回 {counts.get('驳回', 0)}" ), skill=SKILL_SLUG, stage="prereview", batch_no=result["batch_no"], ) return 0 except JobStopped: raise except Exception: log.exception("task_failed task_type=prereview stage=prereview") emit("预审失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="prereview") finish(status="failed", message="预审执行异常,详见统一日志", skill=SKILL_SLUG, stage="prereview") return 1 def cmd_prereview_batch_input() -> int: """批量预审 input/ 目录下的发票 JSON 文件(async Action 入口,任务中心可见进度)。""" log = _get_task_logger() from service.prereview_service import run_prereview with job_context(skill=SKILL_SLUG): log.info("task_start task_type=prereview_batch stage=batch") emit("开始批量预审", skill=SKILL_SLUG, stage="batch") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=prereview_batch stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("prereview_batch_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 input_dir = get_input_dir() files = sorted( os.path.join(input_dir, name) for name in os.listdir(input_dir) if name.lower().endswith(".json") ) if not files: message = f"input 目录没有可预审的 JSON 文件:{input_dir}" log.warning("task_failed task_type=prereview_batch stage=batch error_code=INVOICE_INPUT_MISSING") finish(status="failed", message=message, skill=SKILL_SLUG, error_code="INVOICE_INPUT_MISSING", stage="batch") log.error("prereview_batch_no_input stage=batch") print(f"ERROR:INVOICE_INPUT_MISSING {message}") return 1 all_results = [] for index, path in enumerate(files, start=1): emit(f"处理 {index}/{len(files)}:{os.path.basename(path)}", skill=SKILL_SLUG, stage="batch") with open(path, encoding="utf-8") as f: payload = json.load(f) if isinstance(payload, dict): invoices = payload.get("invoices") or [payload] context = payload.get("context") or {} else: invoices, context = payload, {} result = run_prereview(invoices, {k: str(v or "") for k, v in context.items()}) all_results.append({"file": os.path.basename(path), "batch_no": result["batch_no"], "verdict_counts": result["verdict_counts"]}) _print_json({"ok": True, "files": len(all_results), "results": all_results}) finish(status="success", message=f"批量预审完成,共处理 {len(all_results)} 个文件", skill=SKILL_SLUG, stage="batch") return 0 except JobStopped: raise except json.JSONDecodeError as exc: log.exception("task_failed task_type=prereview_batch stage=batch error_code=INVOICE_INVALID") finish(status="failed", message=f"input 目录 JSON 解析失败:{exc}", skill=SKILL_SLUG, error_code="INVOICE_INVALID", stage="batch") print(f"ERROR:INVOICE_INVALID input 目录 JSON 解析失败:{exc}") return 1 except Exception: log.exception("task_failed task_type=prereview_batch stage=batch") emit("批量预审失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="batch") finish(status="failed", message="批量预审执行异常,详见统一日志", skill=SKILL_SLUG, stage="batch") return 1 # --------------------------------------------------------------------------- # 报告与查验回填 # --------------------------------------------------------------------------- def cmd_render_html(report_id: Optional[int] = None) -> int: """重建《初审意见单》HTML;缺省重建最近 10 份。""" log = _get_task_logger() from service.report_service import build_report_for_invoice from service.standards_service import list_reports with job_context(skill=SKILL_SLUG): log.info("task_start task_type=render_html report_id=%s stage=render", report_id) try: if report_id: targets = [int(report_id)] else: targets = [r["id"] for r in list_reports(limit=10)] if not targets: log.warning("task_failed task_type=render_html error_code=REPORT_NOT_FOUND stage=render") finish(status="failed", message="暂无可重建的初审报告", skill=SKILL_SLUG, error_code="REPORT_NOT_FOUND", stage="render") print("ERROR:REPORT_NOT_FOUND 暂无可重建的初审报告") return 1 written = [] for rid in targets: written.append({"report_id": rid, "path": build_report_for_invoice(rid, "")}) _print_json({"ok": True, "count": len(written), "reports": written}) finish(status="success", message=f"已重建 {len(written)} 份初审意见单", skill=SKILL_SLUG, stage="render") return 0 except JobStopped: raise except ValueError as exc: log.warning("task_failed task_type=render_html error_code=REPORT_NOT_FOUND detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="REPORT_NOT_FOUND", stage="render") print(f"ERROR:REPORT_NOT_FOUND {exc}") return 1 except Exception: log.exception("task_failed task_type=render_html stage=render") finish(status="failed", message="意见单重建异常,详见统一日志", skill=SKILL_SLUG, stage="render") return 1 def cmd_list_reports(limit: int = 20, verdict: Optional[str] = None) -> int: log = _get_task_logger() from service.standards_service import list_reports with job_context(skill=SKILL_SLUG): log.info("task_start task_type=list_reports limit=%s verdict=%s", limit, verdict) try: reports = list_reports(limit=limit, verdict=verdict) _print_json({"ok": True, "count": len(reports), "reports": reports}) return 0 except Exception: log.exception("task_failed task_type=list_reports") print("ERROR:QUERY_FAILED 初审记录查询异常,详见统一日志") return 1 def cmd_show_report(report_id: int) -> int: log = _get_task_logger() from service.standards_service import get_report with job_context(skill=SKILL_SLUG): log.info("task_start task_type=show_report report_id=%s", report_id) try: report = get_report(int(report_id)) _print_json({"ok": True, "report": report}) return 0 except ValueError as exc: log.warning("task_failed task_type=show_report error_code=REPORT_NOT_FOUND detail=%s", exc) print(f"ERROR:REPORT_NOT_FOUND {exc}") return 1 except Exception: log.exception("task_failed task_type=show_report") print("ERROR:QUERY_FAILED 初审报告查询异常,详见统一日志") return 1 def cmd_mark_verified(report_id: int, result: str, note: str = "") -> int: log = _get_task_logger() from service.standards_service import mark_verified with job_context(skill=SKILL_SLUG): log.info("task_start task_type=mark_verified report_id=%s result=%s", report_id, result) emit("开始回填查验结果", skill=SKILL_SLUG, stage="verify") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=mark_verified stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("mark_verified_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 payload = mark_verified(int(report_id), result, note) _print_json(payload) finish( status="success", message=f"报告 {report_id} 查验结果已回填:{payload['verify_status']}", skill=SKILL_SLUG, stage="verify", ) return 0 except ValueError as exc: log.warning("task_failed task_type=mark_verified error_code=VERIFY_INPUT_INVALID detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="VERIFY_INPUT_INVALID", stage="verify") print(f"ERROR:VERIFY_INPUT_INVALID {exc}") return 1 except JobStopped: raise except Exception: log.exception("task_failed task_type=mark_verified stage=verify") finish(status="failed", message="查验回填异常,详见统一日志", skill=SKILL_SLUG, stage="verify") return 1 # --------------------------------------------------------------------------- # 标准库 / 白名单 / 节假日 / 统计 # --------------------------------------------------------------------------- def cmd_view_standards(category: Optional[str] = None) -> int: log = _get_task_logger() from service.standards_service import list_standards with job_context(skill=SKILL_SLUG): log.info("task_start task_type=view_standards category=%s", category) try: standards = list_standards(category) _print_json({"ok": True, "count": len(standards), "standards": standards}) return 0 except Exception: log.exception("task_failed task_type=view_standards") print("ERROR:QUERY_FAILED 标准库查询异常,详见统一日志") return 1 def cmd_set_standard( *, category: str, city_tier: str, staff_title: str, max_amount: float, unit: str = "", basis_doc: str = "", clause: str = "", city_example: str = "", ) -> int: log = _get_task_logger() from service.standards_service import upsert_standard with job_context(skill=SKILL_SLUG): log.info("task_start task_type=set_standard category=%s tier=%s title=%s", category, city_tier, staff_title) emit("开始更新经费标准", skill=SKILL_SLUG, stage="standard") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=set_standard stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("set_standard_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 payload = upsert_standard( category=category, city_tier=city_tier, staff_title=staff_title, max_amount=float(max_amount), unit=unit, basis_doc=basis_doc, clause=clause, city_example=city_example, ) _print_json(payload) finish(status="success", message=f"标准已更新:{category}/{city_tier}/{staff_title}", skill=SKILL_SLUG, stage="standard") return 0 except ValueError as exc: log.warning("task_failed task_type=set_standard error_code=STANDARD_INPUT_INVALID detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="STANDARD_INPUT_INVALID", stage="standard") print(f"ERROR:STANDARD_INPUT_INVALID {exc}") return 1 except JobStopped: raise except Exception: log.exception("task_failed task_type=set_standard stage=standard") finish(status="failed", message="标准更新异常,详见统一日志", skill=SKILL_SLUG, stage="standard") return 1 def cmd_list_whitelist() -> int: log = _get_task_logger() from service.standards_service import list_whitelist with job_context(skill=SKILL_SLUG): log.info("task_start task_type=list_whitelist") try: items = list_whitelist() _print_json({"ok": True, "count": len(items), "items": items}) return 0 except Exception: log.exception("task_failed task_type=list_whitelist") print("ERROR:QUERY_FAILED 白名单查询异常,详见统一日志") return 1 def cmd_add_whitelist(tax_no: str, org_name: str, note: str = "") -> int: log = _get_task_logger() from service.standards_service import add_whitelist with job_context(skill=SKILL_SLUG): log.info("task_start task_type=add_whitelist tax_no=%s", tax_no) emit("开始新增白名单税号", skill=SKILL_SLUG, stage="whitelist") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=add_whitelist stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("add_whitelist_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 payload = add_whitelist(tax_no, org_name, note) _print_json(payload) finish(status="success", message=f"已新增税号 {payload['tax_no']}", skill=SKILL_SLUG, stage="whitelist") return 0 except ValueError as exc: log.warning("task_failed task_type=add_whitelist error_code=WHITELIST_INPUT_INVALID detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="WHITELIST_INPUT_INVALID", stage="whitelist") print(f"ERROR:WHITELIST_INPUT_INVALID {exc}") return 1 except JobStopped: raise except Exception: log.exception("task_failed task_type=add_whitelist stage=whitelist") finish(status="failed", message="白名单新增异常,详见统一日志", skill=SKILL_SLUG, stage="whitelist") return 1 def cmd_remove_whitelist(tax_no: str) -> int: log = _get_task_logger() from service.standards_service import remove_whitelist with job_context(skill=SKILL_SLUG): log.info("task_start task_type=remove_whitelist tax_no=%s", tax_no) emit("开始删除白名单税号", skill=SKILL_SLUG, stage="whitelist") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning("entitlement_failed task_type=remove_whitelist stage=auth") finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth") log.error("remove_whitelist_denied stage=auth") print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 payload = remove_whitelist(tax_no) _print_json(payload) finish(status="success", message=f"已删除税号 {payload['removed']}", skill=SKILL_SLUG, stage="whitelist") return 0 except ValueError as exc: log.warning("task_failed task_type=remove_whitelist error_code=WHITELIST_INPUT_INVALID detail=%s", exc) finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="WHITELIST_INPUT_INVALID", stage="whitelist") print(f"ERROR:WHITELIST_INPUT_INVALID {exc}") return 1 except JobStopped: raise except Exception: log.exception("task_failed task_type=remove_whitelist stage=whitelist") finish(status="failed", message="白名单删除异常,详见统一日志", skill=SKILL_SLUG, stage="whitelist") return 1 def cmd_list_holidays(year: Optional[str] = None) -> int: log = _get_task_logger() from service.standards_service import list_holidays with job_context(skill=SKILL_SLUG): log.info("task_start task_type=list_holidays year=%s", year) try: items = list_holidays(year) _print_json({"ok": True, "count": len(items), "holidays": items}) return 0 except Exception: log.exception("task_failed task_type=list_holidays") print("ERROR:QUERY_FAILED 节假日查询异常,详见统一日志") return 1 def cmd_stats() -> int: log = _get_task_logger() from service.standards_service import stats with job_context(skill=SKILL_SLUG): log.info("task_start task_type=stats") try: _print_json(stats()) return 0 except Exception: log.exception("task_failed task_type=stats") print("ERROR:QUERY_FAILED 统计查询异常,详见统一日志") return 1 # --------------------------------------------------------------------------- # RPA 命令:发票信息提取 # --------------------------------------------------------------------------- def cmd_extract_invoices( username: str, password: str, profile_dir: str, headless: bool = False, detail_mode: bool = False, ) -> int: """从网站提取发票信息。""" log = _get_task_logger() task_type = "extract_invoices" with job_context(skill=SKILL_SLUG): log.info( "task_start task_type=%s username=%s detail_mode=%s", task_type, username[:3] + "***", detail_mode, ) emit("开始提取发票信息", skill=SKILL_SLUG, stage="rpa_start") try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: log.warning( "entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED", task_type, ) emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth") finish( status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth", ) print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 log.info("entitlement_ok task_type=%s stage=auth", task_type) # 导入RPA模块 from service.rpa import run_invoice_rpa_with_video # 运行RPA result = asyncio.run( run_invoice_rpa_with_video( username=username, password=password, profile_dir=profile_dir, skill_slug=SKILL_SLUG, skill_data_dir=get_skill_root(), headless=headless, detail_mode=detail_mode, title="开始提取发票信息", closing_title="发票信息提取完成", ) ) if result.success: invoice_count = result.data.get("total_count", 0) log.info( "rpa_success task_type=%s invoice_count=%s stage=extraction", task_type, invoice_count, ) emit(f"成功提取 {invoice_count} 张发票信息", skill=SKILL_SLUG, stage="rpa_complete") # 保存到数据库 try: from service.rpa_invoice_service import save_rpa_invoice_data batch_id = uuid.uuid4().hex[:12] record_id = save_rpa_invoice_data( batch_id=batch_id, source_url="https://sandbox.jc2009.com/industries/campus/smartcampus/app/finance/invoice-precheck", invoice_data=result.data, username=username, status="extracted", ) log.info( "rpa_data_saved record_id=%s batch_id=%s", record_id, batch_id, ) # 添加记录ID到结果中 result.data["record_id"] = record_id result.data["batch_id"] = batch_id except Exception as save_error: log.warning("rpa_data_save_failed error=%s", str(save_error)) # 即使保存失败,也继续输出结果 # 输出结果 _print_json(result.data) finish( status="success", message=f"成功提取 {invoice_count} 张发票信息", skill=SKILL_SLUG, stage="extraction", ) return 0 else: log.warning( "rpa_failed task_type=%s error=%s stage=%s", task_type, result.error, result.stage, ) emit("发票信息提取失败", type="warn", skill=SKILL_SLUG, stage="rpa_failed") finish( status="failed", message=result.error or "发票信息提取失败", skill=SKILL_SLUG, error_code="RPA_FAILED", stage=result.stage or "unknown", ) print(f"ERROR:RPA_FAILED {result.error}") return 1 except JobStopped: raise except Exception: log.exception( "task_failed task_type=%s", task_type, ) emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="rpa_error") finish( status="failed", message="发票信息提取异常,详见统一日志", skill=SKILL_SLUG, stage="rpa_error", ) return 1 def cmd_list_rpa_invoices( limit: int = 20, batch_id: Optional[str] = None, username: Optional[str] = None, status: Optional[str] = None, ) -> int: """查询RPA提取的发票数据。""" log = _get_task_logger() task_type = "list_rpa_invoices" with job_context(skill=SKILL_SLUG): log.info( "task_start task_type=%s limit=%s batch_id=%s username=%s status=%s", task_type, limit, batch_id, username, status, ) try: from service.rpa_invoice_service import list_rpa_invoices records = list_rpa_invoices( limit=limit, batch_id=batch_id, username=username, status=status, ) _print_json({ "ok": True, "count": len(records), "records": records, }) return 0 except Exception as e: log.exception("task_failed task_type=%s", task_type) print(f"ERROR:QUERY_FAILED 查询RPA提取数据失败: {str(e)}") return 1 def cmd_show_rpa_invoice(record_id: int) -> int: """查看RPA提取的发票数据详情。""" log = _get_task_logger() task_type = "show_rpa_invoice" with job_context(skill=SKILL_SLUG): log.info("task_start task_type=%s record_id=%s", task_type, record_id) try: from service.rpa_invoice_service import get_rpa_invoice_data record = get_rpa_invoice_data(record_id) if not record: print(f"ERROR:RECORD_NOT_FOUND 未找到记录 ID={record_id}") return 1 _print_json(record) return 0 except Exception as e: log.exception("task_failed task_type=%s", task_type) print(f"ERROR:QUERY_FAILED 查询RPA发票详情失败: {str(e)}") return 1 def cmd_rpa_invoice_stats() -> int: """获取RPA提取统计信息。""" log = _get_task_logger() task_type = "rpa_invoice_stats" with job_context(skill=SKILL_SLUG): log.info("task_start task_type=%s", task_type) try: from service.rpa_invoice_service import get_rpa_invoice_stats stats = get_rpa_invoice_stats() _print_json(stats) return 0 except Exception as e: log.exception("task_failed task_type=%s", task_type) print(f"ERROR:QUERY_FAILED 获取RPA统计失败: {str(e)}") return 1 def cmd_test_rpa_connection( username: str, password: str, profile_dir: str, ) -> int: """测试RPA连接和登录。""" log = _get_task_logger() task_type = "test_rpa_connection" with job_context(skill=SKILL_SLUG): log.info( "task_start task_type=%s username=%s", task_type, username[:3] + "***", ) emit("测试RPA连接", skill=SKILL_SLUG, stage="test_start") try: # 导入RPA模块 from service.rpa import close_browser_context, start_browser_session from service.rpa.login_handler import login_if_needed # 启动浏览器 pw, context, page = asyncio.run( start_browser_session(profile_dir, headless=False) ) # 测试登录 login_success = asyncio.run( login_if_needed(page, username, password, wait_sec=30) ) # 关闭浏览器 asyncio.run(close_browser_context(pw, context)) if login_success: log.info("rpa_connection_test_success task_type=%s", task_type) emit("RPA连接测试成功", skill=SKILL_SLUG, stage="test_success") _print_json({"success": True, "message": "连接和登录测试成功"}) finish(status="success", message="RPA连接测试成功", skill=SKILL_SLUG, stage="test") return 0 else: log.warning("rpa_connection_test_failed task_type=%s", task_type) emit("RPA连接测试失败", type="warn", skill=SKILL_SLUG, stage="test_failed") _print_json({"success": False, "message": "登录失败"}) finish(status="failed", message="登录失败", skill=SKILL_SLUG, stage="test") return 1 except Exception as e: log.exception("task_failed task_type=%s", task_type) emit("RPA连接测试异常", type="warn", skill=SKILL_SLUG, stage="test_error") finish(status="failed", message=f"测试异常: {str(e)}", skill=SKILL_SLUG, stage="test") return 1