"""《经费执行简报》HTML 组装。 - 每项目一份 `report-{report_id}.html`(含执行率 vs 序时进度 CSS 柱状图) - 月度播报另生成汇总页 `brief-{batch_no}.html` 写入 `{skill_data}/html/`,宿主数据管理侧栏可预览;重建走固定 Action id `render-html`。 `{{BODY}}` 内数据一律经 html.escape 转义。 """ from __future__ import annotations import html import json from typing import Any from service.html_page_service import load_html_template, render_html_placeholders, write_html_page from util.logging_config import get_skill_logger from util.timeutil import unix_to_iso _PROGRESS_TEMPLATE_REL = "assets/html/progress-report.html" _BRIEF_TEMPLATE_REL = "assets/html/monthly-brief.html" _STATUS_BADGE = { "严重滞后": "tag-lag", "支出异常偏快": "tag-overshoot", "正常": "tag-normal", "待补录": "tag-gray", "未到账": "tag-gray", } def _esc(value: Any) -> str: return html.escape(str(value if value is not None else "—"), quote=True) def _pct(value: Any) -> str: try: return f"{float(value) * 100:.1f}%" except (TypeError, ValueError): return "—" def _wan(value: Any) -> str: try: return f"{float(value) / 10000:.2f} 万元" except (TypeError, ValueError): return "—" def _bar(label: str, value: Any, fill_class: str) -> str: """CSS 柱状图行:纯 div 宽度百分比,iframe 安全无 JS。""" try: ratio = max(0.0, min(float(value), 1.0)) width = f"{ratio * 100:.1f}%" except (TypeError, ValueError): width = "0%" return ( f'
{_esc(label)}' f'
' f'{_pct(value)}
' ) def _suggestion_list(suggestions: Any) -> str: if isinstance(suggestions, str): try: suggestions = json.loads(suggestions) except (TypeError, ValueError): suggestions = [] if not suggestions: return '

暂无特别建议。

' rows = "".join(f"
  • {_esc(s)}
  • " for s in suggestions) return f"
      {rows}
    " def _project_info_table(result: dict[str, Any]) -> str: fields = ( ("项目编号", "project_no"), ("项目类别", "project_type"), ("负责人", "pi_name"), ("所属学院", "department"), ("总立项经费", None), ("已到账金额", None), ("已报销金额", None), ("冻结/暂付款", "frozen_amount"), ("执行期", None), ) rows = [] for label, key in fields: if label == "总立项经费": value = _wan(result.get("total_approved")) elif label == "已到账金额": value = _wan(result.get("total_received")) elif label == "已报销金额": value = _wan(result.get("total_spent")) elif label == "执行期": value = f"{result.get('start_date') or '—'} ~ {result.get('end_date') or '—'}" else: value = result.get(key) or "—" rows.append(f"{_esc(label)}{_esc(value)}") return f'{"".join(rows)}
    ' def _reminder_section(result: dict[str, Any]) -> str: """§10 独立「暂付款 / 结题提醒」章节:取 N1/N2 规则命中明细(kernel 与重建同源 items_json)。""" reminders = [i for i in (result.get("items") or []) if i.get("rule_id") in ("N1", "N2")] if not reminders: return "" rows = [] for item in reminders: suggestion = item.get("suggestion") or "" tail = f'
    {_esc(suggestion)}' if suggestion else "" rows.append(f"
  • {_esc(item.get('name'))}:{_esc(item.get('detail'))}{tail}
  • ") return f'

    暂付款 / 结题提醒

    ' def build_progress_html(result: dict[str, Any], batch_no: str = "") -> str: """组装单个项目的《经费执行简报》HTML 文本。""" status = str(result.get("status_tag") or "") badge = _STATUS_BADGE.get(status, "tag-gray") mark = {"严重滞后": "🔴 严重滞后", "支出异常偏快": "🟠 支出异常偏快", "正常": "🟢 正常"}.get(status, f"⚪ {status}") summary = str(result.get("summary") or "") parts = [p for p in summary.split("\n") if p.strip()] summary_html = "".join(f'

    {_esc(p)}

    ' for p in parts) body = f"""
    {mark} 批次 {_esc(batch_no or result.get("batch_no"))} · 评估基准日 {_esc(result.get("as_of"))}

    项目信息

    {_project_info_table(result)}

    执行进度对比

    {_bar("实际支出率", result.get("spend_rate"), "fill-spend")} {_bar("序时进度", result.get("time_progress"), "fill-time")}

    偏差 = 实际支出率 − 序时进度 = {_esc(_pct(result.get("deviation")))}

    播报摘要

    {summary_html}

    建议

    {_suggestion_list(result.get("suggestions"))}
    {_reminder_section(result)} """ template = load_html_template(_PROGRESS_TEMPLATE_REL) return render_html_placeholders( template, { "TITLE": _esc(f"经费执行简报 · {result.get('project_name') or result.get('project_no') or ''}"), "GENERATED_AT": "", "BODY": body, }, ) def write_report_for_kernel(*, result: dict[str, Any], batch_no: str) -> str: """内核路径:用内存数据直接组装并写出单项目简报,返回文件路径。""" html_text = build_progress_html(result, batch_no) log = get_skill_logger() path = write_html_page(f"report-{result['report_id']}.html", html_text) log.info("progress_report_written report_id=%s path=%s stage=report", result["report_id"], path) return path def build_report_for_report_id(report_id: int) -> str: """按播报记录 id 从库中取数并重写 HTML(重建入口用)。""" from db.connection import get_conn log = get_skill_logger() log.info("report_render_start report_id=%s stage=render", report_id) conn = get_conn() try: cur = conn.cursor() cur.execute("SELECT * FROM progress_reports WHERE id = ?", (report_id,)) row = cur.fetchone() if not row: raise ValueError(f"播报记录不存在:{report_id}") columns = [d[0] for d in cur.description] record = dict(zip(columns, row)) finally: conn.close() result = _record_to_result(record) html_text = build_progress_html(result) path = write_html_page(f"report-{report_id}.html", html_text) log.info("report_render_done report_id=%s path=%s stage=render", report_id, path) return path def _record_to_result(record: dict[str, Any]) -> dict[str, Any]: """把播报记录行还原为简报渲染所需的结果结构(金额从台账补齐)。""" from service.project_service import get_project_by_id result = dict(record) try: project = get_project_by_id(int(record.get("project_id"))) except (TypeError, ValueError): project = {} for key in ( "project_type", "department", "total_approved", "total_received", "total_spent", "frozen_amount", "start_date", "end_date", ): result.setdefault(key, project.get(key)) try: result["suggestions"] = json.loads(record.get("suggestions_json") or "[]") except (TypeError, ValueError): result["suggestions"] = [] try: result["items"] = json.loads(record.get("items_json") or "[]") except (TypeError, ValueError): result["items"] = [] return result def build_monthly_brief(*, batch_no: str, results: list[dict[str, Any]], as_of: Any) -> str: """月度播报汇总页:全部项目的执行率/序时进度柱状图一览。""" log = get_skill_logger() rows = [] order = {"严重滞后": 0, "支出异常偏快": 1, "正常": 2, "待补录": 3, "未到账": 4} for result in sorted(results, key=lambda r: order.get(str(r.get("status_tag")), 9)): status = str(result.get("status_tag") or "") badge = _STATUS_BADGE.get(status, "tag-gray") rows.append( "" f'{_esc(result.get("project_no"))}' f'{_esc(result.get("project_name"))}
    {_esc(result.get("pi_name"))}' f'{_esc(status)}' f'{_bar("支出", result.get("spend_rate"), "fill-spend")}{_bar("序时", result.get("time_progress"), "fill-time")}' f'{_esc(_pct(result.get("deviation")))}' "" ) if not rows: rows.append('本期无可播报项目。') counts: dict[str, int] = {} for result in results: counts[str(result.get("status_tag"))] = counts.get(str(result.get("status_tag")), 0) + 1 chips = " ".join( f'{_esc(tag)} × {num}' for tag, num in sorted(counts.items(), key=lambda kv: order.get(kv[0], 9)) ) body = f"""
    {chips or '无'} 批次 {_esc(batch_no)} · 评估基准日 {_esc(as_of)}

    全部项目进度一览(按状态排序)

    {"".join(rows)}
    项目编号项目 / 负责人状态进度对比偏差
    """ template = load_html_template(_BRIEF_TEMPLATE_REL) page = render_html_placeholders( template, { "TITLE": "月度经费执行汇总简报", "GENERATED_AT": str(as_of), "BODY": body, }, ) path = write_html_page(f"brief-{batch_no}.html", page) log.info("monthly_brief_written batch_no=%s path=%s stage=report", batch_no, path) return path