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,251 @@
"""《经费执行简报》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'<div class="bar-row"><span class="bar-label">{_esc(label)}</span>'
f'<div class="bar-track"><div class="bar-fill {fill_class}" style="width:{width}"></div></div>'
f'<span class="bar-value">{_pct(value)}</span></div>'
)
def _suggestion_list(suggestions: Any) -> str:
if isinstance(suggestions, str):
try:
suggestions = json.loads(suggestions)
except (TypeError, ValueError):
suggestions = []
if not suggestions:
return '<p class="muted">暂无特别建议。</p>'
rows = "".join(f"<li>{_esc(s)}</li>" for s in suggestions)
return f"<ol>{rows}</ol>"
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"<tr><th>{_esc(label)}</th><td>{_esc(value)}</td></tr>")
return f'<table class="info">{"".join(rows)}</table>'
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'<p class="para">{_esc(p)}</p>' for p in parts)
body = f"""
<div class="verdict-line">
<span class="tag {badge}">{mark}</span>
<span class="muted">批次 {_esc(batch_no or result.get("batch_no"))} · 评估基准日 {_esc(result.get("as_of"))}</span>
</div>
<div class="section"><h2>执行进度对比</h2>
{_bar("实际支出率", result.get("spend_rate"), "fill-spend")}
{_bar("序时进度", result.get("time_progress"), "fill-time")}
<p class="muted">偏差 = 实际支出率 − 序时进度 = {_esc(_pct(result.get("deviation")))}</p>
</div>
<div class="section"><h2>项目信息</h2>{_project_info_table(result)}</div>
<div class="section"><h2>播报摘要</h2>{summary_html}</div>
<div class="section"><h2>建议</h2>{_suggestion_list(result.get("suggestions"))}</div>
<footer>本简报由项目台账数据自动生成,仅作提醒;金额与执行进度请以财务系统账面为准。</footer>
"""
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(
"<tr>"
f'<td class="nowrap">{_esc(result.get("project_no"))}</td>'
f'<td>{_esc(result.get("project_name"))}<br/><span class="muted">{_esc(result.get("pi_name"))}</span></td>'
f'<td class="nowrap"><span class="tag {badge}">{_esc(status)}</span></td>'
f'<td>{_bar("支出", result.get("spend_rate"), "fill-spend")}{_bar("序时", result.get("time_progress"), "fill-time")}</td>'
f'<td class="nowrap">{_esc(_pct(result.get("deviation")))}</td>'
"</tr>"
)
if not rows:
rows.append('<tr><td colspan="5" class="muted">本期无可播报项目。</td></tr>')
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'<span class="tag {_STATUS_BADGE.get(tag, "tag-gray")}">{_esc(tag)} × {num}</span>'
for tag, num in sorted(counts.items(), key=lambda kv: order.get(kv[0], 9))
)
body = f"""
<div class="verdict-line">
{chips or '<span class="muted">无</span>'}
<span class="muted">批次 {_esc(batch_no)} · 评估基准日 {_esc(as_of)}</span>
</div>
<div class="section"><h2>全部项目进度一览(按状态排序)</h2>
<table>
<tr><th>项目编号</th><th>项目 / 负责人</th><th>状态</th><th>进度对比</th><th>偏差</th></tr>
{"".join(rows)}
</table>
</div>
<footer>本汇总由月度播报自动生成,仅作提醒;金额与执行进度请以财务系统账面为准。</footer>
"""
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