fix: 对照《财务处Skill002》验收整改并升版 v1.0.2
All checks were successful
技能自动化发布 / release (push) Successful in 9s
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 三处对齐)
This commit is contained in:
@@ -63,8 +63,10 @@ CREATE TABLE IF NOT EXISTS progress_reports (
|
||||
status_tag TEXT NOT NULL,
|
||||
summary TEXT,
|
||||
suggestions_json TEXT,
|
||||
items_json TEXT,
|
||||
report_file TEXT,
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
||||
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
|
||||
)
|
||||
"""
|
||||
|
||||
@@ -157,8 +159,10 @@ _PROGRESS_REPORTS_COLUMNS: tuple[dict[str, object], ...] = (
|
||||
),
|
||||
_col("summary", "播报摘要", "三段式播报文本(现状/诊断/建议)。", visible=0, display_type="textarea"),
|
||||
_col("suggestions_json", "建议清单", "建议条目(JSON 数组,默认隐藏)。", visible=0, display_type="textarea"),
|
||||
_col("items_json", "规则明细", "C/D/N 规则命中明细(JSON 数组,默认隐藏)。", visible=0, display_type="textarea"),
|
||||
_col("report_file", "简报文件", "《经费执行简报》HTML 文件路径。", visible=0),
|
||||
_col("created_at", "创建时间", "报告创建时间(Unix 秒级时间戳,自动生成)。"),
|
||||
_col("updated_at", "更新时间", "报告更新时间(Unix 秒级时间戳,自动维护)。"),
|
||||
)
|
||||
|
||||
_ALERT_RULES_COLUMNS: tuple[dict[str, object], ...] = (
|
||||
@@ -194,9 +198,41 @@ def _init_fund_projects_metadata(cursor) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_progress_reports_columns(cursor) -> None:
|
||||
"""旧库迁移:CREATE TABLE IF NOT EXISTS 不会给已有表加新字段。
|
||||
|
||||
SQLite ``ALTER TABLE ... ADD COLUMN`` 不允许非常量默认值(``unixepoch()``),
|
||||
因此缺 ``items_json`` / ``updated_at`` 时按当前标准 DDL 重建表并拷贝历史数据
|
||||
(progress_reports 为只读播报记录,量级小;无外键,重建安全)。
|
||||
"""
|
||||
cursor.execute("PRAGMA table_info(progress_reports)")
|
||||
cols = [str(row[1]) for row in cursor.fetchall()]
|
||||
target = (
|
||||
"id", "batch_no", "project_id", "project_no", "project_name", "pi_name",
|
||||
"as_of", "spend_rate", "time_progress", "deviation", "status_tag",
|
||||
"summary", "suggestions_json", "items_json", "report_file",
|
||||
"created_at", "updated_at",
|
||||
)
|
||||
if all(name in cols for name in target):
|
||||
return
|
||||
shared = [name for name in cols if name in target and name != "updated_at"]
|
||||
cursor.execute("ALTER TABLE progress_reports RENAME TO _progress_reports_legacy")
|
||||
cursor.execute(_PROGRESS_REPORTS_DDL)
|
||||
if shared:
|
||||
col_list = ", ".join(shared)
|
||||
cursor.execute(
|
||||
f"INSERT INTO progress_reports ({col_list}) "
|
||||
f"SELECT {col_list} FROM _progress_reports_legacy"
|
||||
)
|
||||
cursor.execute("DROP TABLE _progress_reports_legacy") # 连同其旧索引一并删除
|
||||
cursor.execute(_PROGRESS_REPORTS_INDEX)
|
||||
|
||||
|
||||
def _init_progress_reports_metadata(cursor) -> None:
|
||||
cursor.execute(_PROGRESS_REPORTS_DDL)
|
||||
cursor.execute(_PROGRESS_REPORTS_INDEX)
|
||||
_ensure_progress_reports_columns(cursor)
|
||||
init_updated_at_trigger(cursor, "progress_reports")
|
||||
upsert_table_metadata(
|
||||
cursor,
|
||||
table_name="progress_reports",
|
||||
|
||||
@@ -51,8 +51,6 @@ _KEY_ALIASES = {
|
||||
"remark": ("remark", "备注"),
|
||||
}
|
||||
|
||||
_STATUS_RANK = {STATUS_INCOMPLETE: 0, STATUS_NO_FUND: 1, STATUS_NORMAL: 2, STATUS_OVERSHOOT: 3, STATUS_LAG: 4}
|
||||
|
||||
|
||||
def _log():
|
||||
try:
|
||||
@@ -322,10 +320,14 @@ def compute_project(project: dict[str, Any], rules: dict[str, float], as_of: dat
|
||||
)
|
||||
)
|
||||
|
||||
# 状态修正:无法计算时归为未到账/待补录
|
||||
if spend_rate is None and status_tag == STATUS_NORMAL:
|
||||
has_no_fund = any(i["rule_id"] == "C1" for i in items)
|
||||
status_tag = STATUS_NO_FUND if has_no_fund else STATUS_INCOMPLETE
|
||||
# 状态标签优先级:严重滞后>支出异常偏快>正常(D3 已判,含 C3 轻微缺失)>待补录(C3 且 D 未判)>未到账(C1)
|
||||
if status_tag == STATUS_NORMAL and not any(i["rule_id"] == "D3" for i in items):
|
||||
if any(i["rule_id"] == "C3" for i in items):
|
||||
status_tag = STATUS_INCOMPLETE
|
||||
elif any(i["rule_id"] == "C1" for i in items):
|
||||
status_tag = STATUS_NO_FUND
|
||||
elif spend_rate is None:
|
||||
status_tag = STATUS_INCOMPLETE
|
||||
|
||||
suggestions = [i["suggestion"] for i in items if i.get("suggestion")]
|
||||
summary = _build_summary(project_no, project_name, total_approved, total_received, total_spent, as_of, spend_rate, time_progress, status_tag, items)
|
||||
@@ -408,8 +410,17 @@ def _write_report(result: dict[str, Any], batch_no: str) -> str:
|
||||
return write_report_for_kernel(result=result, batch_no=batch_no)
|
||||
|
||||
|
||||
def run_broadcast(projects_raw: list[dict[str, Any]], as_of: date, *, batch_no: str = "") -> dict[str, Any]:
|
||||
"""播报内核:逐项目三步计算 → 写库 → 生成《经费执行简报》。"""
|
||||
def run_broadcast(
|
||||
projects_raw: list[dict[str, Any]],
|
||||
as_of: date,
|
||||
*,
|
||||
batch_no: str = "",
|
||||
on_progress: Optional[Any] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""播报内核:逐项目三步计算 → 写库 → 生成《经费执行简报》。
|
||||
|
||||
``on_progress(index, total, result)``:每完成一个项目回调一次(任务中心增量进度)。
|
||||
"""
|
||||
from service.report_service import build_monthly_brief
|
||||
|
||||
log = _log()
|
||||
@@ -427,17 +438,23 @@ def run_broadcast(projects_raw: list[dict[str, Any]], as_of: date, *, batch_no:
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
for project in projects:
|
||||
total = len(projects)
|
||||
for index, project in enumerate(projects, start=1):
|
||||
result = compute_project(project, rules, as_of)
|
||||
try:
|
||||
project_id = int(project.get("id")) if project.get("id") not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
project_id = None
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO progress_reports
|
||||
(batch_no, project_no, project_name, pi_name, as_of, spend_rate,
|
||||
time_progress, deviation, status_tag, summary, suggestions_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
(batch_no, project_id, project_no, project_name, pi_name, as_of, spend_rate,
|
||||
time_progress, deviation, status_tag, summary, suggestions_json, items_json)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
batch_no,
|
||||
project_id,
|
||||
result["project_no"],
|
||||
result["project_name"],
|
||||
result["pi_name"],
|
||||
@@ -448,6 +465,7 @@ def run_broadcast(projects_raw: list[dict[str, Any]], as_of: date, *, batch_no:
|
||||
result["status_tag"],
|
||||
result["summary"],
|
||||
json.dumps(result["suggestions"], ensure_ascii=False),
|
||||
json.dumps(result["items"], ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
report_id = int(cur.lastrowid)
|
||||
@@ -457,6 +475,8 @@ def run_broadcast(projects_raw: list[dict[str, Any]], as_of: date, *, batch_no:
|
||||
cur.execute("UPDATE progress_reports SET report_file = ? WHERE id = ?", (report_file, report_id))
|
||||
result["report_file"] = report_file
|
||||
results.append(result)
|
||||
if on_progress is not None:
|
||||
on_progress(index, total, result)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
@@ -100,6 +100,19 @@ def _project_info_table(result: dict[str, Any]) -> str:
|
||||
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 "")
|
||||
@@ -114,22 +127,23 @@ def build_progress_html(result: dict[str, Any], batch_no: str = "") -> str:
|
||||
<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>{_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>
|
||||
{_reminder_section(result)}
|
||||
<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 ''}",
|
||||
"TITLE": _esc(f"经费执行简报 · {result.get('project_name') or result.get('project_no') or ''}"),
|
||||
"GENERATED_AT": "",
|
||||
"BODY": body,
|
||||
},
|
||||
@@ -193,6 +207,10 @@ def _record_to_result(record: dict[str, Any]) -> dict[str, Any]:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -446,13 +446,14 @@ def cmd_broadcast_monthly() -> int:
|
||||
print(f"ERROR:NO_PROJECTS {message}")
|
||||
return 1
|
||||
|
||||
result = run_broadcast(projects, date.today())
|
||||
for index, report in enumerate(result["reports"], start=1):
|
||||
def _emit_progress(index: int, total: int, report: dict[str, Any]) -> None:
|
||||
emit(
|
||||
f"[{index}/{len(result['reports'])}] {report['project_name']}:{report['status_tag']}",
|
||||
f"[{index}/{total}] {report['project_name']}:{report['status_tag']}",
|
||||
skill=SKILL_SLUG,
|
||||
stage="monthly",
|
||||
)
|
||||
|
||||
result = run_broadcast(projects, date.today(), on_progress=_emit_progress)
|
||||
_print_json(
|
||||
{
|
||||
"ok": True,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(项目经费进度播报助手)。"""
|
||||
|
||||
SKILL_SLUG = "track-fund-progress-default"
|
||||
SKILL_VERSION = "1.0.1"
|
||||
SKILL_VERSION = "1.0.2"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.track_fund_progress_default"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
Reference in New Issue
Block a user