Files
track-fund-progress-default/scripts/service/fund_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

532 lines
20 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.
"""项目经费执行监测与播报内核(唯一业务内核)。
三步计算(对齐《核心建设内容:各部门"Skill"深度需求规格书》财务处 Skill 002):
Step 1 执行率计算:实际支出率 = 已报销/已到账;序时进度 = 已过天数/总执行天数
Step 2 偏差诊断:低于序时进度超 lag_threshold → 严重滞后;高于 overshoot_threshold → 支出异常偏快
Step 3 提醒与建议:暂付款账龄、结题临近、三段式播报摘要(规则模板,数字全部本地计算,不编造)
阈值存 alert_rules 表(管理员可调);播报结果写 progress_reports 并生成《经费执行简报》。
播报仅作提醒,请以财务系统账面为准。
"""
from __future__ import annotations
import json
import re
import uuid
from datetime import date
from typing import Any, Optional
from db.connection import get_conn, init_db
from util.logging_config import get_skill_logger
from util.timeutil import now_unix
STATUS_LAG = "严重滞后"
STATUS_OVERSHOOT = "支出异常偏快"
STATUS_NORMAL = "正常"
STATUS_INCOMPLETE = "待补录"
STATUS_NO_FUND = "未到账"
_DEFAULT_RULES = {
"lag_threshold": 0.10,
"overshoot_threshold": 0.20,
"pending_aging_days": 90.0,
"closing_warn_days": 90.0,
}
_KEY_ALIASES = {
"projectNo": ("project_no", "项目编号", "编号"),
"projectName": ("project_name", "项目名称", "名称"),
"projectType": ("project_type", "项目类别", "类别"),
"piName": ("pi_name", "负责人", "pi"),
"department": ("department", "所属学院", "学院"),
"totalApproved": ("total_approved", "总立项经费", "立项经费"),
"totalReceived": ("total_received", "已到账金额", "已到账", "到账金额"),
"totalSpent": ("total_spent", "已报销金额", "已报销", "报销金额"),
"frozenAmount": ("frozen_amount", "冻结/暂付款", "暂付款金额", "冻结金额"),
"frozenSince": ("frozen_since", "暂付款最早发生日", "暂付款日期"),
"startDate": ("start_date", "执行期开始", "开始日期"),
"endDate": ("end_date", "执行期结束", "结束日期"),
"status": ("status", "项目状态", "状态"),
"remark": ("remark", "备注"),
}
def _log():
try:
return get_skill_logger()
except RuntimeError:
return None
def _norm_project(raw: dict[str, Any]) -> dict[str, Any]:
"""归一化台账键(兼容 camelCase / snake_case / 常见中文键)。"""
out: dict[str, Any] = {}
for key, value in raw.items():
camel = _camel(key)
if camel in _KEY_ALIASES:
out.setdefault(camel, value)
continue
matched = False
for canonical, aliases in _KEY_ALIASES.items():
if key in aliases:
out.setdefault(canonical, value)
matched = True
break
if not matched:
out.setdefault(camel, value)
return out
def _camel(name: str) -> str:
parts = re.split(r"[\s_]+", str(name).strip())
if len(parts) <= 1:
return str(name)
head, *rest = parts
return head + "".join(p[:1].upper() + p[1:] for p in rest)
def _to_float(value: Any) -> Optional[float]:
if value is None or value == "":
return None
if isinstance(value, (int, float)):
return float(value)
text = re.sub(r"[元,,\s]", "", str(value).strip())
try:
return float(text)
except ValueError:
return None
def _parse_date(value: Any) -> Optional[date]:
if value is None or value == "":
return None
if isinstance(value, date):
return value
text = str(value).strip()
for pattern in ("%Y-%m-%d", "%Y/%m/%d", "%Y.%m.%d", "%Y年%m月%d日"):
try:
from datetime import datetime
return datetime.strptime(text, pattern).date()
except ValueError:
continue
return None
def _fmt_date(value: Optional[date]) -> str:
return value.isoformat() if value else ""
def _wan(value: Optional[float]) -> str:
if value is None:
return "—"
return f"{value / 10000:.2f} 万元"
def _pct(value: Optional[float]) -> str:
return "—" if value is None else f"{value * 100:.1f}%"
def load_rules() -> dict[str, float]:
"""读取预警阈值(缺失时回退缺省值)。"""
rules = dict(_DEFAULT_RULES)
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT rule_key, threshold_value FROM alert_rules")
for rule_key, threshold_value in cur.fetchall():
try:
rules[str(rule_key)] = float(threshold_value)
except (TypeError, ValueError):
continue
finally:
conn.close()
return rules
def _item(rule_id: str, name: str, level: str, detail: str, suggestion: str = "") -> dict[str, str]:
return {"rule_id": rule_id, "name": name, "level": level, "detail": detail, "suggestion": suggestion}
def _time_progress(start: Optional[date], end: Optional[date], as_of: date) -> Optional[float]:
if not start or not end or end <= start:
return None
if as_of <= start:
return 0.0
if as_of >= end:
return 1.0
return round((as_of - start).days / (end - start).days, 4)
def compute_project(project: dict[str, Any], rules: dict[str, float], as_of: date) -> dict[str, Any]:
"""对单个项目执行三步计算,返回播报结果(不落库)。"""
items: list[dict[str, str]] = []
project_no = str(project.get("projectNo") or "").strip() or "未编号"
project_name = str(project.get("projectName") or "").strip() or project_no
total_received = _to_float(project.get("totalReceived"))
total_spent = _to_float(project.get("totalSpent"))
total_approved = _to_float(project.get("totalApproved"))
start = _parse_date(project.get("startDate"))
end = _parse_date(project.get("endDate"))
# C3 台账完整性
missing = [
label
for key, label in (
("totalApproved", "总立项经费"),
("totalReceived", "已到账金额"),
("totalSpent", "已报销金额"),
("startDate", "执行期开始"),
("endDate", "执行期结束"),
)
if (
_to_float(project.get(key)) is None
if key.startswith("total")
else project.get(key) in (None, "")
)
]
if missing:
items.append(
_item(
"C3",
"台账完整性",
"warn",
f"以下台账字段缺失:{'、'.join(missing)},本期数字可能失真,请补录。",
"请在数据管理“项目台账”中补齐后重新播报。",
)
)
# C1 实际支出率
spend_rate: Optional[float] = None
if total_received is not None and total_received <= 0:
items.append(
_item(
"C1",
"实际支出率",
"warn",
"经费尚未到账,无法计算执行率。",
"到账后自动纳入正常播报;如长期未到账请联系财务处核实拨款进度。",
)
)
elif total_received is not None and total_spent is not None:
spend_rate = round(total_spent / total_received, 4)
# C2 序时进度
time_progress = _time_progress(start, end, as_of)
if time_progress is None:
items.append(
_item(
"C2",
"序时进度",
"warn",
"执行期起止缺失或无效,无法计算序时进度。",
"请在项目台账补录执行期起止日期。",
)
)
# D1/D2/D3 偏差诊断
status_tag = STATUS_NORMAL
deviation: Optional[float] = None
if spend_rate is not None and time_progress is not None:
deviation = round(spend_rate - time_progress, 4)
lag = float(rules.get("lag_threshold", _DEFAULT_RULES["lag_threshold"]))
overshoot = float(rules.get("overshoot_threshold", _DEFAULT_RULES["overshoot_threshold"]))
if deviation < -lag:
status_tag = STATUS_LAG
items.append(
_item(
"D1",
"严重滞后",
"alert",
f"实际支出率 {_pct(spend_rate)} 低于序时进度 {_pct(time_progress)} 超过 {_pct(lag)},偏差 {_pct(deviation)}。",
"请对照研究计划排查滞后原因;如确需调整,请尽早与财务处沟通预算调剂或申请延期。",
)
)
elif deviation > overshoot:
status_tag = STATUS_OVERSHOOT
items.append(
_item(
"D2",
"支出异常偏快",
"warn",
f"实际支出率 {_pct(spend_rate)} 高于序时进度 {_pct(time_progress)} 超过 {_pct(overshoot)},偏差 +{_pct(deviation)},存在年底经费不足风险。",
"请合理安排后续支出节奏,避免年底突击花钱或经费提前耗尽。",
)
)
else:
status_tag = STATUS_NORMAL
items.append(
_item(
"D3",
"执行正常",
"pass",
f"实际支出率 {_pct(spend_rate)} 与序时进度 {_pct(time_progress)} 基本匹配(偏差 {_pct(deviation)})。",
"",
)
)
# N1 暂付款账龄
frozen_amount = _to_float(project.get("frozenAmount"))
frozen_since = _parse_date(project.get("frozenSince"))
if frozen_amount is not None and frozen_amount > 0:
aging_days = (as_of - frozen_since).days if frozen_since else None
pending_days = float(rules.get("pending_aging_days", _DEFAULT_RULES["pending_aging_days"]))
if aging_days is None:
items.append(
_item(
"N1",
"暂付款冲销提醒",
"info",
f"项目有暂付款 {_wan(frozen_amount)},但台账未登记最早发生日,无法计算账龄。",
"请补录暂付款发生日期,以便跟踪冲销时限。",
)
)
elif aging_days > pending_days:
items.append(
_item(
"N1",
"暂付款冲销提醒",
"info",
f"暂付款 {_wan(frozen_amount)} 最早发生于 {frozen_since.isoformat()},账龄已 {aging_days} 天(超 {pending_days:.0f} 天)。",
"请及时办理暂付款冲销或核销手续。",
)
)
# N2 结题临近
if end:
days_left = (end - as_of).days
closing_days = float(rules.get("closing_warn_days", _DEFAULT_RULES["closing_warn_days"]))
if days_left < 0:
items.append(
_item(
"N2",
"结题决算提醒",
"info",
f"项目执行期已于 {end.isoformat()} 结束。",
"请尽快完成支出归集与结题决算。",
)
)
elif days_left <= closing_days and (spend_rate is None or spend_rate < 0.8):
rate_text = _pct(spend_rate) if spend_rate is not None else "暂无支出率"
items.append(
_item(
"N2",
"结题临近提醒",
"info",
f"距执行期结束({end.isoformat()})仅剩 {days_left} 天,当前支出率 {rate_text}。",
"请尽快完成剩余科目报销,避免结题时经费大量沉淀。",
)
)
# 状态标签优先级:严重滞后>支出异常偏快>正常(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)
return {
"project_no": project_no,
"project_name": project_name,
"pi_name": str(project.get("piName") or "").strip(),
"department": str(project.get("department") or "").strip(),
"project_type": str(project.get("projectType") or "").strip(),
"total_approved": total_approved,
"total_received": total_received,
"total_spent": total_spent,
"frozen_amount": frozen_amount,
"frozen_since": _fmt_date(frozen_since),
"start_date": _fmt_date(start),
"end_date": _fmt_date(end),
"as_of": as_of.isoformat(),
"spend_rate": spend_rate,
"time_progress": time_progress,
"deviation": deviation,
"status_tag": status_tag,
"items": items,
"suggestions": suggestions,
"summary": summary,
}
def _join_sentences(segments: list[str]) -> str:
"""拼接句子:去掉各段尾部句号,统一以“;”分隔并以“。”结尾。"""
cleaned = [s.strip().rstrip("。").strip() for s in segments if s and s.strip()]
return ";".join(cleaned) + "。" if cleaned else ""
def _build_summary(
project_no: str,
project_name: str,
total_approved: Optional[float],
total_received: Optional[float],
total_spent: Optional[float],
as_of: date,
spend_rate: Optional[float],
time_progress: Optional[float],
status_tag: str,
items: list[dict[str, str]],
) -> str:
"""三段式播报摘要(模板注入,数字全部来自计算)。"""
part1 = (
f"您负责的《{project_name}》({project_no}):总立项经费 {_wan(total_approved)},"
f"已到账 {_wan(total_received)},已报销 {_wan(total_spent)},"
f"截至 {as_of.isoformat()} 实际支出率 {_pct(spend_rate)},序时进度 {_pct(time_progress)},"
f"执行状态{status_tag}。"
)
diagnostics = [i for i in items if i["rule_id"] in ("D1", "D2", "D3", "N1", "N2", "C1", "C3")]
if diagnostics:
part2 = "诊断:" + _join_sentences([i["detail"] for i in diagnostics])
else:
part2 = "诊断:暂无特别事项。"
suggestions = [i["suggestion"] for i in items if i.get("suggestion")]
if suggestions:
part3 = "建议:" + _join_sentences(suggestions[:3])
else:
part3 = "建议:按研究计划均衡安排支出,保持执行进度与时间进度匹配。"
return f"{part1}\n{part2}\n{part3}"
def _validate_projects(projects: list[dict[str, Any]]) -> None:
if not projects:
raise ValueError("项目台账为空:请先录入项目(add-project / import-projects)")
if len(projects) > 500:
raise ValueError(f"单次播报项目数过多({len(projects)} 个),上限 500 个")
for idx, project in enumerate(projects):
if not isinstance(project, dict):
raise ValueError(f"第 {idx + 1} 个项目不是 JSON 对象")
def _write_report(result: dict[str, Any], batch_no: str) -> str:
from service.report_service import write_report_for_kernel
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 = "",
on_progress: Optional[Any] = None,
) -> dict[str, Any]:
"""播报内核:逐项目三步计算 → 写库 → 生成《经费执行简报》。
``on_progress(index, total, result)``:每完成一个项目回调一次(任务中心增量进度)。
"""
from service.report_service import build_monthly_brief
log = _log()
init_db()
projects = [_norm_project(p) for p in projects_raw]
_validate_projects(projects)
batch_no = batch_no or now_unix().to_bytes(5, "big").hex() + uuid.uuid4().hex[:6]
rules = load_rules()
if log:
log.info("broadcast_kernel_start batch_no=%s count=%s as_of=%s stage=kernel", batch_no, len(projects), as_of.isoformat())
results = []
conn = get_conn()
try:
cur = conn.cursor()
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_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"],
result["as_of"],
result["spend_rate"],
result["time_progress"],
result["deviation"],
result["status_tag"],
result["summary"],
json.dumps(result["suggestions"], ensure_ascii=False),
json.dumps(result["items"], ensure_ascii=False),
),
)
report_id = int(cur.lastrowid)
result["report_id"] = report_id
result["batch_no"] = batch_no
report_file = _write_report(result, 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()
counts: dict[str, int] = {}
for result in results:
counts[result["status_tag"]] = counts.get(result["status_tag"], 0) + 1
brief_file = build_monthly_brief(batch_no=batch_no, results=results, as_of=as_of)
payload = {
"ok": True,
"batch_no": batch_no,
"as_of": as_of.isoformat(),
"count": len(results),
"status_counts": counts,
"brief_file": brief_file,
"disclaimer": "播报仅作提醒,金额与执行进度请以财务系统账面为准。",
"reports": results,
}
if log:
log.info(
"broadcast_kernel_done batch_no=%s count=%s lag=%s overshoot=%s stage=kernel",
batch_no,
len(results),
counts.get(STATUS_LAG, 0),
counts.get(STATUS_OVERSHOOT, 0),
)
return payload
def query_projects(project_no: str = "", pi_name: str = "", include_all_status: bool = False) -> list[dict[str, Any]]:
"""从台账取待播报项目(缺省全部在研;可按项目编号/负责人筛选)。"""
conn = get_conn()
try:
cur = conn.cursor()
sql = "SELECT * FROM fund_projects WHERE 1=1"
params: list[Any] = []
if not include_all_status:
sql += " AND status = '在研'"
if project_no:
sql += " AND project_no = ?"
params.append(project_no)
if pi_name:
sql += " AND pi_name LIKE ?"
params.append(f"%{pi_name}%")
sql += " ORDER BY id"
cur.execute(sql, params)
columns = [d[0] for d in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
finally:
conn.close()
return rows