Files
track-fund-progress-default/scripts/service/report_service.py
chenfuqiang f90f742c1e
All checks were successful
技能自动化发布 / release (push) Successful in 9s
fix: 对照《财务处Skill002》验收整改并升版 v1.0.2
- 状态标签: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 三处对齐)
2026-09-24 17:19:30 +08:00

270 lines
10 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 _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