feat: 项目经费进度播报助手首版 v1.0.1(track-fund-progress-default)
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
chenfuqiang
2026-09-13 14:25:23 +08:00
commit a91ead3862
207 changed files with 30383 additions and 0 deletions

View File

@@ -0,0 +1,620 @@
"""任务编排层: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
result = run_broadcast(projects, date.today())
for index, report in enumerate(result["reports"], start=1):
emit(
f"[{index}/{len(result['reports'])}] {report['project_name']}:{report['status_tag']}",
skill=SKILL_SLUG,
stage="monthly",
)
_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