"""结题项目 / 成果清单 / 决算明细 / 初稿记录 的维护与查询。""" 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, }