Files

319 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.
"""结题项目 / 成果清单 / 决算明细 / 初稿记录 的维护与查询。"""
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("approved_budget") not in (None, ""):
approved = _to_float(fields.get("approved_budget"))
if approved is None or approved < 0:
raise ValueError(f"立项经费必须是非负数字(元):{fields.get('approved_budget')}")
values["approved_budget"] = round(approved, 2)
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,
}