Files
chenfuqiang a91ead3862
All checks were successful
技能自动化发布 / release (push) Successful in 7s
feat: 项目经费进度播报助手首版 v1.0.1(track-fund-progress-default)
2026-09-13 14:25:23 +08:00

323 lines
12 KiB
Python
Raw Permalink 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.
"""项目台账维护:录入(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,
}