Files
chenfuqiang c5103e2650
All checks were successful
技能自动化发布 / release (push) Successful in 9s
chore: auto release commit (2026-09-14 15:11:41)
2026-09-14 15:11:41 +08:00

989 lines
39 KiB
Python
Raw Permalink 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.
"""任务编排层: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