"""《经费执行简报》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''
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"))}
执行进度对比
{_bar("实际支出率", result.get("spend_rate"), "fill-spend")}
{_bar("序时进度", result.get("time_progress"), "fill-time")}
偏差 = 实际支出率 − 序时进度 = {_esc(_pct(result.get("deviation")))}
项目信息
{_project_info_table(result)}
播报摘要
{summary_html}
建议
{_suggestion_list(result.get("suggestions"))}
"""
template = load_html_template(_PROGRESS_TEMPLATE_REL)
return render_html_placeholders(
template,
{
"TITLE": 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"] = []
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