feat: 项目经费进度播报助手首版 v1.0.1(track-fund-progress-default)
All checks were successful
技能自动化发布 / release (push) Successful in 7s

This commit is contained in:
chenfuqiang
2026-09-13 14:25:23 +08:00
commit a91ead3862
207 changed files with 30383 additions and 0 deletions

View File

@@ -0,0 +1,322 @@
"""项目台账维护:录入(upsert)、批量导入、查询。
台账是播报的唯一数据源;金额单位元,日期 YYYY-MM-DD。
project_no 唯一,重复导入按项目编号幂等更新(不覆盖未提供字段)。
"""
from __future__ import annotations
import json
from typing import Any
from db.connection import get_conn, init_db
from service.fund_service import _norm_project, _parse_date, _to_float
from util.logging_config import get_skill_logger
from util.timeutil import now_unix
_DATE_FIELDS = ("frozen_since", "start_date", "end_date")
_NUMERIC_FIELDS = ("total_approved", "total_received", "total_spent", "frozen_amount")
_TEXT_FIELDS = ("project_name", "project_type", "pi_name", "department", "status", "remark")
def _log():
try:
return get_skill_logger()
except RuntimeError:
return None
def _snake(name: str) -> str:
"""camelCase → snake_case(_norm_project 输出 canonical camelCase 键)。"""
return "".join("_" + ch.lower() if ch.isupper() else ch for ch in str(name)).lstrip("_")
def upsert_project(fields: dict[str, Any]) -> dict[str, Any]:
"""按 project_no 幂等写入台账;未提供的字段不覆盖已有值。"""
project_no = str(fields.get("project_no") or "").strip()
if not project_no:
raise ValueError("项目编号(project_no)不能为空")
values: dict[str, Any] = {"project_no": project_no}
for key in _NUMERIC_FIELDS:
if key in fields and fields[key] not in (None, ""):
number = _to_float(fields[key])
if number is None or number < 0:
raise ValueError(f"字段 {key} 必须是非负数字:{fields[key]}")
values[key] = number
for key in _DATE_FIELDS:
if key in fields and fields[key] not in (None, ""):
parsed = _parse_date(fields[key])
if parsed is None:
raise ValueError(f"字段 {key} 日期格式无效(应为 YYYY-MM-DD):{fields[key]}")
values[key] = parsed.isoformat()
for key in _TEXT_FIELDS:
if key in fields and fields[key] is not None:
values[key] = str(fields[key]).strip()
if values.get("status") and values["status"] not in ("在研", "已结题", "暂缓"):
raise ValueError(f"status 只接受 在研/已结题/暂缓:{values['status']}")
if values.get("start_date") and values.get("end_date") and values["end_date"] <= values["start_date"]:
raise ValueError("执行期结束日期必须晚于开始日期")
init_db()
log = _log()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT id FROM fund_projects WHERE project_no = ?", (project_no,))
row = cur.fetchone()
if row:
project_id = int(row[0])
set_clause = ", ".join(f"{key} = ?" for key in values if key != "project_no")
params = [values[key] for key in values if key != "project_no"] + [now_unix(), project_id]
cur.execute(
f"UPDATE fund_projects SET {set_clause}, updated_at = ? WHERE id = ?",
params,
)
action = "updated"
else:
columns = ", ".join(values.keys())
placeholders = ", ".join("?" for _ in values)
cur.execute(
f"INSERT INTO fund_projects ({columns}, created_at, updated_at) VALUES ({placeholders}, ?, ?)",
list(values.values()) + [now_unix(), now_unix()],
)
project_id = int(cur.lastrowid)
action = "created"
conn.commit()
finally:
conn.close()
if log:
log.info("project_upserted project_no=%s action=%s stage=project", project_no, action)
return {"ok": True, "project_no": project_no, "id": project_id, "action": action}
def import_projects(projects_raw: list[dict[str, Any]]) -> dict[str, Any]:
"""批量导入台账(upsert),返回统计。"""
if not isinstance(projects_raw, list) or not projects_raw:
raise ValueError("导入内容应为非空 JSON 数组")
if len(projects_raw) > 500:
raise ValueError(f"单次导入项目数过多({len(projects_raw)}),上限 500 个")
results = []
for idx, raw in enumerate(projects_raw):
if not isinstance(raw, dict):
raise ValueError(f"第 {idx + 1} 个项目不是 JSON 对象")
normalized = {_snake(canonical): value for canonical, value in _norm_project(raw).items()}
results.append(upsert_project(normalized))
return {
"ok": True,
"count": len(results),
"created": sum(1 for r in results if r["action"] == "created"),
"updated": sum(1 for r in results if r["action"] == "updated"),
"projects": results,
}
def list_projects(pi_name: str = "", status: str = "") -> list[dict[str, Any]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
sql = "SELECT * FROM fund_projects WHERE 1=1"
params: list[Any] = []
if pi_name:
sql += " AND pi_name LIKE ?"
params.append(f"%{pi_name}%")
if status:
sql += " AND status = ?"
params.append(status)
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
def get_project_by_id(project_id: int) -> dict[str, Any]:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT * FROM fund_projects WHERE id = ?", (project_id,))
row = cur.fetchone()
if not row:
raise ValueError(f"项目不存在:{project_id}")
columns = [d[0] for d in cur.description]
return dict(zip(columns, row))
finally:
conn.close()
def parse_projects_json(raw: str) -> list[dict[str, Any]]:
"""解析导入 JSON:'-'(stdin)/ 文件路径 / 内联数组或 {"projects": [...]} 包装。"""
import os
import sys
text = (raw or "").strip()
if not text:
raise ValueError("缺少导入 JSON(--projects-json)")
if text == "-":
text = sys.stdin.read().strip()
elif text.startswith(("[", "{")):
pass
elif os.path.isfile(text):
with open(text, encoding="utf-8") as f:
text = f.read().strip()
else:
raise ValueError(f"--projects-json 不是有效文件路径或 JSON:{text[:80]}")
try:
payload = json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(f"导入 JSON 解析失败:{exc}") from exc
if isinstance(payload, dict) and isinstance(payload.get("projects"), list):
payload = payload["projects"]
if not isinstance(payload, list):
raise ValueError("导入 JSON 应为对象数组或 {\"projects\": [...]}")
return payload
def list_reports(limit: int = 20, status_tag: str = "") -> list[dict[str, Any]]:
init_db()
limit = max(1, min(int(limit), 200))
conn = get_conn()
try:
cur = conn.cursor()
if status_tag:
cur.execute(
"""
SELECT id, batch_no, as_of, project_no, project_name, pi_name,
spend_rate, time_progress, deviation, status_tag, summary, report_file, created_at
FROM progress_reports WHERE status_tag = ? ORDER BY id DESC LIMIT ?
""",
(status_tag, limit),
)
else:
cur.execute(
"""
SELECT id, batch_no, as_of, project_no, project_name, pi_name,
spend_rate, time_progress, deviation, status_tag, summary, report_file, created_at
FROM progress_reports ORDER BY id DESC LIMIT ?
""",
(limit,),
)
columns = [d[0] for d in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
finally:
conn.close()
return rows
def get_report(report_id: int) -> dict[str, Any]:
init_db()
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]
report = dict(zip(columns, row))
finally:
conn.close()
try:
report["suggestions"] = json.loads(report.get("suggestions_json") or "[]")
except (TypeError, ValueError):
report["suggestions"] = []
return report
def view_rules() -> list[dict[str, Any]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, rule_key, display_name, threshold_value, description, updated_at FROM alert_rules ORDER BY id"
)
columns = [d[0] for d in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
finally:
conn.close()
return rows
_KNOWN_RULE_KEYS = ("lag_threshold", "overshoot_threshold", "pending_aging_days", "closing_warn_days")
def set_rule(rule_key: str, threshold_value: float) -> dict[str, Any]:
"""按 rule_key 更新阈值(须为已知规则键,数值须为正)。"""
rule_key = (rule_key or "").strip()
if rule_key not in _KNOWN_RULE_KEYS:
raise ValueError(f"未知规则键:{rule_key}(可选:{'、'.join(_KNOWN_RULE_KEYS)})")
threshold_value = float(threshold_value)
if threshold_value <= 0 or threshold_value >= 1.5:
raise ValueError(f"阈值必须是 0~1.5 之间的正数:{threshold_value}")
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT id FROM alert_rules WHERE rule_key = ?", (rule_key,))
row = cur.fetchone()
if not row:
raise ValueError(f"规则键尚未初始化:{rule_key}(请先执行 init-db)")
cur.execute(
"UPDATE alert_rules SET threshold_value = ?, updated_at = ? WHERE id = ?",
(threshold_value, now_unix(), int(row[0])),
)
conn.commit()
finally:
conn.close()
log = _log()
if log:
log.info("rule_updated rule_key=%s value=%s stage=rules", rule_key, threshold_value)
return {"ok": True, "rule_key": rule_key, "threshold_value": threshold_value}
def stats() -> dict[str, Any]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM fund_projects")
project_total = int(cur.fetchone()[0])
cur.execute("SELECT status, COUNT(*) FROM fund_projects GROUP BY status")
by_status = {str(r[0]): int(r[1]) for r in cur.fetchall()}
cur.execute("SELECT status_tag, COUNT(*) FROM progress_reports GROUP BY status_tag")
reports_by_tag = {str(r[0]): int(r[1]) for r in cur.fetchall()}
cur.execute("SELECT COUNT(*) FROM progress_reports")
report_total = int(cur.fetchone()[0])
cur.execute(
"""
SELECT project_no, project_name, pi_name, status_tag, spend_rate, time_progress, as_of
FROM progress_reports
WHERE status_tag = '严重滞后'
ORDER BY deviation ASC LIMIT 5
"""
)
lag_top = [
{
"project_no": r[0],
"project_name": r[1],
"pi_name": r[2],
"status_tag": r[3],
"spend_rate": r[4],
"time_progress": r[5],
"as_of": r[6],
}
for r in cur.fetchall()
]
cur.execute("SELECT COALESCE(SUM(frozen_amount), 0) FROM fund_projects WHERE status = '在研'")
frozen_total = float(cur.fetchone()[0])
finally:
conn.close()
return {
"ok": True,
"project_total": project_total,
"projects_by_status": by_status,
"report_total": report_total,
"reports_by_tag": reports_by_tag,
"lag_top": lag_top,
"frozen_total": frozen_total,
}