Files
track-fund-progress-default/scripts/service/report_service.py
chenfuqiang a91ead3862
All checks were successful
技能自动化发布 / release (push) Successful in 7s
feat: 项目经费进度播报助手首版 v1.0.1(track-fund-progress-default)
2026-09-13 14:25:23 +08:00

252 lines
9.7 KiB
Python
Raw 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.
"""《经费执行简报》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