Files
chenfuqiang f90f742c1e
All checks were successful
技能自动化发布 / release (push) Successful in 9s
fix: 对照《财务处Skill002》验收整改并升版 v1.0.2
- 状态标签:C3 触发且 D 未判 → 待补录,优先级对齐需求 §6.2
- broadcast-monthly 逐项目增量 emit,任务中心可见进行中进度
- progress_reports 回填 project_id;新增 items_json/updated_at;旧库重建式安全迁移(含回归测试)
- 简报:TITLE 转义防注入、章节对齐 §10(独立「暂付款/结题提醒」)、页脚逐字统一
- 金标准测试 +17:±10%/20% 阈值恰界、C2 钳位、N2 反例、C3 逐字段、HTML 转义、async+cron 定位守护、迁移回归
- 文档 v1.1(开发文档 + REQUIREMENTS):ERROR:<CODE> <message>、reports_brief、--all-status、skill-detail 表外挂载、C1 level=warn
- 落盘验收报告 acceptance-reports/20260924-105401-acceptance.md(338 测试全绿)
- 版本 1.0.1 → 1.0.2(constants / SKILL.md / CHANGELOG 三处对齐)
2026-09-24 17:19:30 +08:00

622 lines
26 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/fund_service.py``(播报内核)、
``service/project_service.py``(台账/规则/统计)、
``service/report_service.py``(HTML 简报),由本模块调用;
多入口(CLI / Action / 对话 / cron)复用同一内核,禁止按入口分叉业务。
"""
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_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:
"""模板演示入口:本技能主能力请使用 broadcast 等业务命令。"""
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 这是模板演示入口,请使用 broadcast 命令执行经费进度播报。")
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 cmd_add_project(
*,
project_no: str,
name: str = "",
project_type: str = "",
pi: str = "",
department: str = "",
total_approved: Optional[float] = None,
total_received: Optional[float] = None,
total_spent: Optional[float] = None,
frozen: Optional[float] = None,
frozen_since: str = "",
start: str = "",
end: str = "",
status: str = "",
remark: str = "",
) -> int:
log = _get_task_logger()
from service.project_service import upsert_project
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=add_project project_no=%s stage=project", project_no)
emit("开始写入项目台账", skill=SKILL_SLUG, stage="project")
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning("entitlement_failed task_type=add_project stage=auth")
finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth")
log.error("add_project_denied stage=auth")
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return 1
fields: dict[str, Any] = {"project_no": project_no}
if name:
fields["project_name"] = name
if project_type:
fields["project_type"] = project_type
if pi:
fields["pi_name"] = pi
if department:
fields["department"] = department
for key, value in (
("total_approved", total_approved),
("total_received", total_received),
("total_spent", total_spent),
("frozen_amount", frozen),
):
if value is not None:
fields[key] = value
for key, value in (("frozen_since", frozen_since), ("start_date", start), ("end_date", end), ("status", status), ("remark", remark)):
if value:
fields[key] = value
payload = upsert_project(fields)
_print_json(payload)
finish(status="success", message=f"项目 {project_no} 已{'更新' if payload['action'] == 'updated' else '录入'}", skill=SKILL_SLUG, stage="project")
return 0
except ValueError as exc:
log.warning("task_failed task_type=add_project error_code=PROJECT_INVALID detail=%s", exc)
finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="project")
print(f"ERROR:PROJECT_INVALID {exc}")
return 1
except JobStopped:
raise
except Exception:
log.exception("task_failed task_type=add_project stage=project")
finish(status="failed", message="台账写入异常,详见统一日志", skill=SKILL_SLUG, stage="project")
return 1
def cmd_import_projects(projects_json: str) -> int:
log = _get_task_logger()
from service.project_service import import_projects, parse_projects_json
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=import_projects stage=project")
emit("开始批量导入项目台账", skill=SKILL_SLUG, stage="project")
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning("entitlement_failed task_type=import_projects stage=auth")
finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth")
log.error("import_projects_denied stage=auth")
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return 1
projects = parse_projects_json(projects_json)
payload = import_projects(projects)
_print_json(payload)
finish(status="success", message=f"导入完成:新增 {payload['created']},更新 {payload['updated']}", skill=SKILL_SLUG, stage="project")
return 0
except ValueError as exc:
log.warning("task_failed task_type=import_projects error_code=PROJECT_INVALID detail=%s", exc)
finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="project")
print(f"ERROR:PROJECT_INVALID {exc}")
return 1
except JobStopped:
raise
except Exception:
log.exception("task_failed task_type=import_projects stage=project")
finish(status="failed", message="台账导入异常,详见统一日志", skill=SKILL_SLUG, stage="project")
return 1
def cmd_list_projects(pi: str = "", status: str = "") -> int:
log = _get_task_logger()
from service.project_service import list_projects
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=list_projects pi=%s status=%s", pi, status)
try:
projects = list_projects(pi_name=pi, status=status)
_print_json({"ok": True, "count": len(projects), "projects": projects})
return 0
except Exception:
log.exception("task_failed task_type=list_projects")
print("ERROR:QUERY_FAILED 台账查询异常,详见统一日志")
return 1
# ---------------------------------------------------------------------------
# 播报主命令
# ---------------------------------------------------------------------------
def cmd_broadcast(
*,
project_no: str = "",
pi: str = "",
as_of: str = "",
include_all_status: bool = False,
) -> int:
"""经费进度播报(同步):全部在研项目或指定项目。"""
log = _get_task_logger()
from datetime import date
from service.fund_service import query_projects, run_broadcast
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=broadcast project_no=%s pi=%s stage=broadcast", project_no, pi)
emit("开始经费进度播报", skill=SKILL_SLUG, stage="broadcast")
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning("entitlement_failed task_type=broadcast stage=auth")
finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth")
log.error("broadcast_denied stage=auth")
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return 1
as_of_date = date.fromisoformat(as_of) if as_of else date.today()
projects = query_projects(project_no=project_no, pi_name=pi, include_all_status=include_all_status)
if not projects:
message = "没有可播报的项目:请先录入台账(在研项目为空或筛选条件不匹配)"
log.warning("task_failed task_type=broadcast error_code=NO_PROJECTS stage=broadcast")
finish(status="failed", message=message, skill=SKILL_SLUG, error_code="NO_PROJECTS", stage="broadcast")
log.error("broadcast_no_projects stage=broadcast")
print(f"ERROR:NO_PROJECTS {message}")
return 1
result = run_broadcast(projects, as_of_date)
payload = {k: v for k, v in result.items() if k != "reports"}
payload["reports_brief"] = [
{k: r[k] for k in ("report_id", "project_no", "project_name", "pi_name", "status_tag", "spend_rate", "time_progress", "deviation", "report_file")}
for r in result["reports"]
]
_print_json(payload)
emit(
f"播报完成 {result['count']} 个项目:严重滞后 {result['status_counts'].get('严重滞后', 0)},"
f"异常偏快 {result['status_counts'].get('支出异常偏快', 0)}",
skill=SKILL_SLUG,
stage="broadcast",
)
finish(
status="success",
message=f"播报完成 {result['count']} 个项目,简报已生成",
skill=SKILL_SLUG,
stage="broadcast",
batch_no=result["batch_no"],
)
return 0
except JobStopped:
raise
except ValueError as exc:
log.warning("task_failed task_type=broadcast error_code=PROJECT_INVALID detail=%s", exc)
finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="PROJECT_INVALID", stage="broadcast")
print(f"ERROR:PROJECT_INVALID {exc}")
return 1
except Exception:
log.exception("task_failed task_type=broadcast stage=broadcast")
emit("播报失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="broadcast")
finish(status="failed", message="播报执行异常,详见统一日志", skill=SKILL_SLUG, stage="broadcast")
return 1
def cmd_broadcast_monthly() -> int:
"""月度播报(async Action 入口):全部在研项目逐项播报 + 汇总简报,任务中心可见进度。"""
log = _get_task_logger()
from datetime import date
from service.fund_service import query_projects, run_broadcast
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=broadcast_monthly stage=monthly")
emit("开始月度经费播报", skill=SKILL_SLUG, stage="monthly")
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning("entitlement_failed task_type=broadcast_monthly stage=auth")
finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth")
log.error("broadcast_monthly_denied stage=auth")
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return 1
projects = query_projects()
if not projects:
message = "台账中没有“在研”项目,月度播报跳过"
log.warning("task_failed task_type=broadcast_monthly error_code=NO_PROJECTS stage=monthly")
finish(status="failed", message=message, skill=SKILL_SLUG, error_code="NO_PROJECTS", stage="monthly")
log.error("broadcast_monthly_no_projects stage=monthly")
print(f"ERROR:NO_PROJECTS {message}")
return 1
def _emit_progress(index: int, total: int, report: dict[str, Any]) -> None:
emit(
f"[{index}/{total}] {report['project_name']}:{report['status_tag']}",
skill=SKILL_SLUG,
stage="monthly",
)
result = run_broadcast(projects, date.today(), on_progress=_emit_progress)
_print_json(
{
"ok": True,
"batch_no": result["batch_no"],
"as_of": result["as_of"],
"count": result["count"],
"status_counts": result["status_counts"],
"brief_file": result["brief_file"],
"reports_brief": [
{k: r[k] for k in ("report_id", "project_no", "project_name", "pi_name", "status_tag", "summary")}
for r in result["reports"]
],
}
)
finish(
status="success",
message=f"月度播报完成:{result['count']} 个项目,严重滞后 {result['status_counts'].get('严重滞后', 0)} 个",
skill=SKILL_SLUG,
stage="monthly",
batch_no=result["batch_no"],
)
return 0
except JobStopped:
raise
except Exception:
log.exception("task_failed task_type=broadcast_monthly stage=monthly")
emit("月度播报失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="monthly")
finish(status="failed", message="月度播报执行异常,详见统一日志", skill=SKILL_SLUG, stage="monthly")
return 1
# ---------------------------------------------------------------------------
# 播报记录与阈值
# ---------------------------------------------------------------------------
def cmd_list_reports(limit: int = 20, status_tag: str = "") -> int:
log = _get_task_logger()
from service.project_service import list_reports
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=list_reports limit=%s status_tag=%s", limit, status_tag)
try:
reports = list_reports(limit=limit, status_tag=status_tag)
_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.project_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_render_html(report_id: Optional[int] = None) -> int:
log = _get_task_logger()
from service.report_service import build_report_for_report_id
from service.project_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 = [{"report_id": rid, "path": build_report_for_report_id(rid)} for rid in targets]
_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_view_rules() -> int:
log = _get_task_logger()
from service.project_service import view_rules
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=view_rules stage=rules")
try:
_print_json({"ok": True, "rules": view_rules()})
return 0
except Exception:
log.exception("task_failed task_type=view_rules")
print("ERROR:QUERY_FAILED 预警规则查询异常,详见统一日志")
return 1
def cmd_set_rule(rule_key: str, value: float) -> int:
log = _get_task_logger()
from service.project_service import set_rule
with job_context(skill=SKILL_SLUG):
log.info("task_start task_type=set_rule rule_key=%s value=%s stage=rules", rule_key, value)
emit("开始更新预警阈值", skill=SKILL_SLUG, stage="rules")
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning("entitlement_failed task_type=set_rule stage=auth")
finish(status="failed", message=reason, skill=SKILL_SLUG, error_code="ENTITLEMENT_DENIED", stage="auth")
log.error("set_rule_denied stage=auth")
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return 1
payload = set_rule(rule_key, float(value))
_print_json(payload)
finish(status="success", message=f"阈值 {rule_key} 已更新为 {value}", skill=SKILL_SLUG, stage="rules")
return 0
except ValueError as exc:
log.warning("task_failed task_type=set_rule error_code=RULE_INVALID detail=%s", exc)
finish(status="failed", message=str(exc), skill=SKILL_SLUG, error_code="RULE_INVALID", stage="rules")
print(f"ERROR:RULE_INVALID {exc}")
return 1
except JobStopped:
raise
except Exception:
log.exception("task_failed task_type=set_rule stage=rules")
finish(status="failed", message="阈值更新异常,详见统一日志", skill=SKILL_SLUG, stage="rules")
return 1
def cmd_stats() -> int:
log = _get_task_logger()
from service.project_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