All checks were successful
技能自动化发布 / release (push) Successful in 9s
- 状态标签:C3 触发且 D 未判 → 待补录,优先级对齐需求 §6.2 - broadcast-monthly 逐项目增量 emit,任务中心可见进行中进度 - progress_reports 回填 project_id;新增 items_json/updated_at;旧库重建式安全迁移(含回归测试) - 简报:TITLE 转义防注入、章节对齐 §10(独立「暂付款/结题提醒」)、页脚逐字统一 - 金标准测试 +17:±10%/20% 阈值恰界、C2 钳位、N2 反例、C3 逐字段、HTML 转义、async+cron 定位守护、迁移回归 - 文档 v1.1(开发文档 + REQUIREMENTS):ERROR:<CODE> <message>、reports_brief、--all-status、skill-detail 表外挂载、C1 level=warn - 落盘验收报告 acceptance-reports/20260924-105401-acceptance.md(338 测试全绿) - 版本 1.0.1 → 1.0.2(constants / SKILL.md / CHANGELOG 三处对齐)
270 lines
10 KiB
Python
270 lines
10 KiB
Python
"""《经费执行简报》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 _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'<br/><span class="muted">{_esc(suggestion)}</span>' if suggestion else ""
|
||
rows.append(f"<li><strong>{_esc(item.get('name'))}</strong>:{_esc(item.get('detail'))}{tail}</li>")
|
||
return f'<div class="section"><h2>暂付款 / 结题提醒</h2><ul>{"".join(rows)}</ul></div>'
|
||
|
||
|
||
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>{_project_info_table(result)}</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>{summary_html}</div>
|
||
<div class="section"><h2>建议</h2>{_suggestion_list(result.get("suggestions"))}</div>
|
||
{_reminder_section(result)}
|
||
<footer>播报由台账数据自动生成,请以财务系统账面为准。</footer>
|
||
"""
|
||
|
||
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(
|
||
"<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
|