feat: 结题报告初稿助手首版 v1.0.1(draft-closing-report-default)
All checks were successful
技能自动化发布 / release (push) Successful in 6s

This commit is contained in:
chenfuqiang
2026-09-13 16:04:01 +08:00
commit 7b24521b43
206 changed files with 29892 additions and 0 deletions

View File

@@ -0,0 +1,313 @@
"""结题项目 / 成果清单 / 决算明细 / 初稿记录 的维护与查询。"""
from __future__ import annotations
import json
from typing import Any, Optional
from db.connection import get_conn, init_db
from service.draft_service import _norm_achievement, _norm_expense, _norm_project
from util.logging_config import get_skill_logger
from util.timeutil import now_unix
_PROJECT_TEXT_FIELDS = ("project_name", "pi_name", "department", "program", "start_date", "end_date", "annual_report_excerpt", "status")
_KNOWN_STATUS = ("待结题", "初稿已生成", "已提交")
_KNOWN_ACH_TYPES = ("论文", "专利", "人才培养", "其他")
def _log():
try:
return get_skill_logger()
except RuntimeError:
return None
def _snake(name: str) -> str:
return "".join("_" + ch.lower() if ch.isupper() else ch for ch in str(name)).lstrip("_")
def _to_float(value: Any) -> Optional[float]:
if value is None or value == "":
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def upsert_project(fields: dict[str, Any]) -> dict[str, Any]:
fields = {_snake(k): v for k, v in _norm_project(fields).items()}
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 _PROJECT_TEXT_FIELDS:
if fields.get(key) not in (None, ""):
values[key] = str(fields[key]).strip()
if values.get("status") and values["status"] not in _KNOWN_STATUS:
raise ValueError(f"status 只接受 {'、'.join(_KNOWN_STATUS)}:{values['status']}")
if fields.get("expected_json") not in (None, ""):
raw = fields["expected_json"]
try:
expected = json.loads(raw) if isinstance(raw, str) else raw
except (TypeError, ValueError) as exc:
raise ValueError(f"预期指标 JSON 解析失败:{exc}") from exc
if not isinstance(expected, list):
raise ValueError("预期指标应为数组,如 [{\"type\":\"论文\",\"target\":3,\"unit\":\"篇\"}]")
values["expected_json"] = json.dumps(expected, ensure_ascii=False)
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT id FROM closing_projects WHERE project_no = ?", (project_no,))
row = cur.fetchone()
if row:
project_id = int(row[0])
set_clause = ", ".join(f"{k} = ?" for k in values if k != "project_no")
cur.execute(
f"UPDATE closing_projects SET {set_clause}, updated_at = ? WHERE id = ?",
[values[k] for k in values if k != "project_no"] + [now_unix(), project_id],
)
action = "updated"
else:
columns = ", ".join(values.keys())
placeholders = ", ".join("?" for _ in values)
cur.execute(
f"INSERT INTO closing_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()
log = _log()
if log:
log.info("closing_project_upserted project_no=%s action=%s stage=ledger", project_no, action)
return {"ok": True, "project_no": project_no, "id": project_id, "action": action}
def upsert_achievement(fields: dict[str, Any]) -> dict[str, Any]:
achievement = {_snake(k): v for k, v in _norm_achievement(fields).items()}
project_no = str(achievement.get("project_no") or "").strip()
title = str(achievement.get("title") or "").strip()
ach_type = str(achievement.get("achievement_type") or "").strip()
if not project_no:
raise ValueError("项目编号(project_no)不能为空")
if not ach_type:
raise ValueError("成果类型(achievement_type)不能为空")
if ach_type not in _KNOWN_ACH_TYPES:
raise ValueError(f"成果类型只接受 {'、'.join(_KNOWN_ACH_TYPES)}:{ach_type}")
if not title:
raise ValueError("成果名称(title)不能为空")
values = {
"project_no": project_no,
"achievement_type": ach_type,
"title": title,
"venue_or_no": str(achievement.get("venue_or_no") or "").strip(),
"ach_date": str(achievement.get("ach_date") or "").strip(),
"is_representative": 1 if str(achievement.get("is_representative") or "").strip() in ("1", "true", "True", "是") else 0,
"note": str(achievement.get("note") or "").strip(),
}
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT id FROM closing_projects WHERE project_no = ?", (project_no,))
if not cur.fetchone():
raise ValueError(f"项目不存在,请先录入结题项目:{project_no}")
cur.execute(
"""
INSERT INTO achievements
(project_no, achievement_type, title, venue_or_no, ach_date, is_representative, note, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
list(values.values()) + [now_unix(), now_unix()],
)
achievement_id = int(cur.lastrowid)
conn.commit()
finally:
conn.close()
log = _log()
if log:
log.info("achievement_added project_no=%s type=%s stage=ledger", project_no, ach_type)
return {"ok": True, "id": achievement_id, "project_no": project_no, "title": title}
def upsert_expense(fields: dict[str, Any]) -> dict[str, Any]:
expense = {_snake(k): v for k, v in _norm_expense(fields).items()}
project_no = str(expense.get("project_no") or "").strip()
category = str(expense.get("category") or "").strip()
amount = _to_float(expense.get("amount"))
if not project_no:
raise ValueError("项目编号(project_no)不能为空")
if not category:
raise ValueError("科目(category)不能为空")
if amount is None or amount < 0:
raise ValueError(f"金额必须是非负数字:{fields.get('amount')}")
values = {
"project_no": project_no,
"category": category,
"amount": round(amount, 2),
"occurred_date": str(expense.get("occurred_date") or "").strip(),
"description": str(expense.get("description") or "").strip(),
}
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT id FROM closing_projects WHERE project_no = ?", (project_no,))
if not cur.fetchone():
raise ValueError(f"项目不存在,请先录入结题项目:{project_no}")
cur.execute(
"""
INSERT INTO expense_items
(project_no, category, amount, occurred_date, description, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
list(values.values()) + [now_unix(), now_unix()],
)
expense_id = int(cur.lastrowid)
conn.commit()
finally:
conn.close()
log = _log()
if log:
log.info("expense_added project_no=%s category=%s stage=ledger", project_no, category)
return {"ok": True, "id": expense_id, "project_no": project_no, "category": category, "amount": values["amount"]}
def _import_generic(rows: Any, upsert, label: str) -> dict[str, Any]:
if not isinstance(rows, list) or not rows:
raise ValueError(f"导入内容应为非空 JSON 数组({label})")
if len(rows) > 500:
raise ValueError(f"单次导入条数过多({len(rows)}),上限 500 条")
results = []
for idx, raw in enumerate(rows):
if not isinstance(raw, dict):
raise ValueError(f"第 {idx + 1} 条不是 JSON 对象")
results.append(upsert(raw))
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"),
"items": results,
}
def import_projects(rows: list[dict[str, Any]]) -> dict[str, Any]:
return _import_generic(rows, lambda row: upsert_project({_snake(k): v for k, v in _norm_project(row).items()}), "结题项目")
def import_achievements(rows: list[dict[str, Any]]) -> dict[str, Any]:
return _import_generic(rows, lambda row: upsert_achievement({_snake(k): v for k, v in _norm_achievement(row).items()}), "成果清单")
def import_expenses(rows: list[dict[str, Any]]) -> dict[str, Any]:
return _import_generic(rows, lambda row: upsert_expense({_snake(k): v for k, v in _norm_expense(row).items()}), "决算明细")
def list_projects(status: str = "") -> list[dict[str, Any]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
if status:
cur.execute("SELECT * FROM closing_projects WHERE status = ? ORDER BY id", (status,))
else:
cur.execute("SELECT * FROM closing_projects 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
def list_achievements(project_no: str) -> list[dict[str, Any]]:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT * FROM achievements WHERE project_no = ? ORDER BY id", (project_no,))
columns = [d[0] for d in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
finally:
conn.close()
return rows
def list_expenses(project_no: str) -> list[dict[str, Any]]:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT * FROM expense_items WHERE project_no = ? ORDER BY id", (project_no,))
columns = [d[0] for d in cur.description]
rows = [dict(zip(columns, row)) for row in cur.fetchall()]
finally:
conn.close()
return rows
def list_drafts(limit: int = 20) -> list[dict[str, Any]]:
init_db()
limit = max(1, min(int(limit), 200))
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, batch_no, project_no, project_name, draft_file, preview_file, created_at FROM report_drafts 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_draft(draft_id: int) -> dict[str, Any]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT * FROM report_drafts WHERE id = ?", (draft_id,))
row = cur.fetchone()
if not row:
raise ValueError(f"初稿记录不存在:{draft_id}")
columns = [d[0] for d in cur.description]
draft = dict(zip(columns, row))
finally:
conn.close()
for key in ("completion_json", "narrative_points_json"):
try:
draft[key[:-5]] = json.loads(draft.get(key) or "[]")
except (TypeError, ValueError):
draft[key[:-5]] = []
return draft
def stats() -> dict[str, Any]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT status, COUNT(*) FROM closing_projects GROUP BY status")
by_status = {str(r[0]): int(r[1]) for r in cur.fetchall()}
cur.execute("SELECT COUNT(*) FROM achievements")
achievement_total = int(cur.fetchone()[0])
cur.execute("SELECT COUNT(*) FROM expense_items")
expense_total = int(cur.fetchone()[0])
cur.execute("SELECT COUNT(*) FROM report_drafts")
draft_total = int(cur.fetchone()[0])
finally:
conn.close()
return {
"ok": True,
"projects_by_status": by_status,
"achievement_total": achievement_total,
"expense_total": expense_total,
"draft_total": draft_total,
}