Files
track-fund-progress-default/scripts/db/connection.py
chenfuqiang f90f742c1e
All checks were successful
技能自动化发布 / release (push) Successful in 9s
fix: 对照《财务处Skill002》验收整改并升版 v1.0.2
- 状态标签:C3 触发且 D 未判 → 待补录,优先级对齐需求 §6.2
- broadcast-monthly 逐项目增量 emit,任务中心可见进行中进度
- progress_reports 回填 project_id;新增 items_json/updated_at;旧库重建式安全迁移(含回归测试)
- 简报:TITLE 转义防注入、章节对齐 §10(独立「暂付款/结题提醒」)、页脚逐字统一
- 金标准测试 +17:±10%/20% 阈值恰界、C2 钳位、N2 反例、C3 逐字段、HTML 转义、async+cron 定位守护、迁移回归
- 文档 v1.1(开发文档 + REQUIREMENTS):ERROR:<CODE> <message>、reports_brief、--all-status、skill-detail 表外挂载、C1 level=warn
- 落盘验收报告 acceptance-reports/20260924-105401-acceptance.md(338 测试全绿)
- 版本 1.0.1 → 1.0.2(constants / SKILL.md / CHANGELOG 三处对齐)
2026-09-24 17:19:30 +08:00

289 lines
13 KiB
Python
Raw 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.
"""SQLite 连接与幂等初始化(项目经费进度播报助手)。
业务表 3 张:
- fund_projects(项目台账,金额/日期/状态字段可编辑)
- progress_reports(播报记录,只读,播报自动写入)
- alert_rules(预警规则,阈值可编辑)
表间禁止 SQLite 外键,仅存业务 id(见 references/SCHEMA.md)。
"""
from __future__ import annotations
import sqlite3
from db.display_metadata import (
init_display_metadata,
seed_column_metadata_batch,
upsert_table_metadata,
)
from db.seed_data import seed_all
from db.timestamp_columns import init_updated_at_trigger
from util.runtime_paths import get_db_path
def get_conn() -> sqlite3.Connection:
return sqlite3.connect(get_db_path())
_FUND_PROJECTS_DDL = """
CREATE TABLE IF NOT EXISTS fund_projects (
id INTEGER PRIMARY KEY AUTOINCREMENT,
project_no TEXT NOT NULL UNIQUE,
project_name TEXT,
project_type TEXT,
pi_name TEXT,
department TEXT,
total_approved REAL,
total_received REAL,
total_spent REAL,
frozen_amount REAL,
frozen_since TEXT,
start_date TEXT,
end_date TEXT,
status TEXT NOT NULL DEFAULT '在研',
remark TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
_PROGRESS_REPORTS_DDL = """
CREATE TABLE IF NOT EXISTS progress_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_no TEXT NOT NULL,
project_id INTEGER,
project_no TEXT,
project_name TEXT,
pi_name TEXT,
as_of TEXT,
spend_rate REAL,
time_progress REAL,
deviation REAL,
status_tag TEXT NOT NULL,
summary TEXT,
suggestions_json TEXT,
items_json TEXT,
report_file TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
_PROGRESS_REPORTS_INDEX = """
CREATE INDEX IF NOT EXISTS idx_progress_reports_batch
ON progress_reports(batch_no)
"""
_ALERT_RULES_DDL = """
CREATE TABLE IF NOT EXISTS alert_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rule_key TEXT NOT NULL UNIQUE,
display_name TEXT,
threshold_value REAL NOT NULL,
description TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
_TS_COLUMN_NAMES = {"created_at", "updated_at"}
def _col(column_name: str, display_name: str, description: str, **extra: object) -> dict[str, object]:
base: dict[str, object] = {
"column_name": column_name,
"display_name": display_name,
"description": description,
"visible": 1,
"searchable": 0,
"editable": 0,
}
base.update(extra)
if column_name in _TS_COLUMN_NAMES:
base["display_type"] = "datetime_unix_seconds"
return base
_FUND_PROJECTS_COLUMNS: tuple[dict[str, object], ...] = (
_col("id", "编号", "主键", searchable=1),
_col("project_no", "项目编号", "项目唯一编号(财务/科研系统口径)。", searchable=1),
_col("project_name", "项目名称", "项目全称。", searchable=1, editable=1),
_col(
"project_type",
"项目类别",
"纵向 / 横向 / 其他。",
searchable=1,
editable=1,
options_json='[{"value":"纵向","label":"纵向"},{"value":"横向","label":"横向"},{"value":"其他","label":"其他"}]',
),
_col("pi_name", "负责人", "项目负责人(PI)姓名。", searchable=1, editable=1),
_col("department", "所属学院", "二级学院/单位。", searchable=1, editable=1),
_col("total_approved", "总立项经费", "立项批复总经费(元)。", searchable=1, editable=1, display_type="number"),
_col("total_received", "已到账金额", "已到账经费(元),执行率分母。", searchable=1, editable=1, display_type="number"),
_col("total_spent", "已报销金额", "已报销支出(元),执行率分子。", searchable=1, editable=1, display_type="number"),
_col("frozen_amount", "冻结/暂付款", "冻结或暂付款金额(元)。", searchable=1, editable=1, display_type="number"),
_col("frozen_since", "暂付款最早发生日", "最早一笔暂付款发生日期(YYYY-MM-DD),用于账龄提醒。", editable=1),
_col("start_date", "执行期开始", "项目执行期开始日期(YYYY-MM-DD)。", searchable=1, editable=1),
_col("end_date", "执行期结束", "项目执行期结束日期(YYYY-MM-DD)。", searchable=1, editable=1),
_col(
"status",
"项目状态",
"在研 / 已结题 / 暂缓;播报默认只处理“在研”。",
searchable=1,
editable=1,
options_json='[{"value":"在研","label":"在研"},{"value":"已结题","label":"已结题"},{"value":"暂缓","label":"暂缓"}]',
),
_col("remark", "备注", "维护说明。", editable=1),
_col("created_at", "创建时间", "记录创建时间(Unix 秒级时间戳,自动生成)。"),
_col("updated_at", "更新时间", "记录最后更新时间(Unix 秒级时间戳,自动维护)。"),
)
_PROGRESS_REPORTS_COLUMNS: tuple[dict[str, object], ...] = (
_col("id", "报告编号", "主键,播报记录编号。", searchable=1),
_col("batch_no", "播报批次号", "同一次播报请求的批次号。", searchable=1),
_col("project_id", "项目编号(id)", "关联项目台账主键。", searchable=1),
_col("project_no", "项目编号", "项目唯一编号。", searchable=1),
_col("project_name", "项目名称", "项目全称。", searchable=1),
_col("pi_name", "负责人", "项目负责人姓名。", searchable=1),
_col("as_of", "评估基准日", "本次播报的数据截止日期(YYYY-MM-DD)。", searchable=1),
_col("spend_rate", "实际支出率", "已报销/已到账(小数)。", searchable=1, display_type="number"),
_col("time_progress", "序时进度", "已过天数/总执行天数(小数)。", searchable=1, display_type="number"),
_col("deviation", "偏差", "实际支出率 − 序时进度(小数,正=偏快,负=偏慢)。", searchable=1, display_type="number"),
_col(
"status_tag",
"执行状态",
"严重滞后 / 支出异常偏快 / 正常 / 待补录 / 未到账。",
searchable=1,
options_json='[{"value":"严重滞后","label":"🔴 严重滞后"},{"value":"支出异常偏快","label":"🟠 支出异常偏快"},{"value":"正常","label":"🟢 正常"},{"value":"待补录","label":"⚪ 待补录"},{"value":"未到账","label":"⚪ 未到账"}]',
),
_col("summary", "播报摘要", "三段式播报文本(现状/诊断/建议)。", visible=0, display_type="textarea"),
_col("suggestions_json", "建议清单", "建议条目(JSON 数组,默认隐藏)。", visible=0, display_type="textarea"),
_col("items_json", "规则明细", "C/D/N 规则命中明细(JSON 数组,默认隐藏)。", visible=0, display_type="textarea"),
_col("report_file", "简报文件", "《经费执行简报》HTML 文件路径。", visible=0),
_col("created_at", "创建时间", "报告创建时间(Unix 秒级时间戳,自动生成)。"),
_col("updated_at", "更新时间", "报告更新时间(Unix 秒级时间戳,自动维护)。"),
)
_ALERT_RULES_COLUMNS: tuple[dict[str, object], ...] = (
_col("id", "编号", "主键", searchable=1),
_col("rule_key", "规则键", "阈值键名(lag_threshold / overshoot_threshold / pending_aging_days / closing_warn_days)。", searchable=1),
_col("display_name", "名称", "阈值中文名。", searchable=1),
_col("threshold_value", "阈值", "阈值数值(比例为小数,天数为正整数)。", searchable=1, editable=1, display_type="number"),
_col("description", "说明", "阈值口径说明。", searchable=1, editable=1),
_col("created_at", "创建时间", "记录创建时间(Unix 秒级,自动生成)。"),
_col("updated_at", "更新时间", "记录最后更新时间(Unix 秒级,自动维护)。"),
)
def _init_fund_projects_metadata(cursor) -> None:
cursor.execute(_FUND_PROJECTS_DDL)
init_updated_at_trigger(cursor, "fund_projects")
upsert_table_metadata(
cursor,
table_name="fund_projects",
display_name="项目台账",
description="PI 名下项目的基础台账:立项/到账/报销/暂付款与执行周期;播报的数据源。",
grid_help="金额单位为元;顶栏按钮可录入项目,行内可直接编辑金额与日期;播报默认只处理“在研”项目。",
visible=1,
readonly=0,
default_sort_json='[{"column":"id","direction":"desc"}]',
default_visible_columns_json='["project_no","project_name","pi_name","department","total_approved","total_received","total_spent","status"]',
)
seed_column_metadata_batch(
cursor,
table_name="fund_projects",
columns=_FUND_PROJECTS_COLUMNS,
valid_column_names=[str(c["column_name"]) for c in _FUND_PROJECTS_COLUMNS],
)
def _ensure_progress_reports_columns(cursor) -> None:
"""旧库迁移:CREATE TABLE IF NOT EXISTS 不会给已有表加新字段。
SQLite ``ALTER TABLE ... ADD COLUMN`` 不允许非常量默认值(``unixepoch()``),
因此缺 ``items_json`` / ``updated_at`` 时按当前标准 DDL 重建表并拷贝历史数据
(progress_reports 为只读播报记录,量级小;无外键,重建安全)。
"""
cursor.execute("PRAGMA table_info(progress_reports)")
cols = [str(row[1]) for row in cursor.fetchall()]
target = (
"id", "batch_no", "project_id", "project_no", "project_name", "pi_name",
"as_of", "spend_rate", "time_progress", "deviation", "status_tag",
"summary", "suggestions_json", "items_json", "report_file",
"created_at", "updated_at",
)
if all(name in cols for name in target):
return
shared = [name for name in cols if name in target and name != "updated_at"]
cursor.execute("ALTER TABLE progress_reports RENAME TO _progress_reports_legacy")
cursor.execute(_PROGRESS_REPORTS_DDL)
if shared:
col_list = ", ".join(shared)
cursor.execute(
f"INSERT INTO progress_reports ({col_list}) "
f"SELECT {col_list} FROM _progress_reports_legacy"
)
cursor.execute("DROP TABLE _progress_reports_legacy") # 连同其旧索引一并删除
cursor.execute(_PROGRESS_REPORTS_INDEX)
def _init_progress_reports_metadata(cursor) -> None:
cursor.execute(_PROGRESS_REPORTS_DDL)
cursor.execute(_PROGRESS_REPORTS_INDEX)
_ensure_progress_reports_columns(cursor)
init_updated_at_trigger(cursor, "progress_reports")
upsert_table_metadata(
cursor,
table_name="progress_reports",
display_name="播报记录",
description="每次播报的项目执行状态、偏差与三段式摘要;可重建 HTML 简报。",
grid_help="“严重滞后/支出异常偏快”的行建议由科研秘书或财务处跟进;简报可用顶栏按钮重建。",
visible=1,
readonly=1,
default_sort_json='[{"column":"id","direction":"desc"}]',
default_visible_columns_json='["id","as_of","project_no","project_name","pi_name","spend_rate","time_progress","deviation","status_tag"]',
)
seed_column_metadata_batch(
cursor,
table_name="progress_reports",
columns=_PROGRESS_REPORTS_COLUMNS,
valid_column_names=[str(c["column_name"]) for c in _PROGRESS_REPORTS_COLUMNS],
)
def _init_alert_rules_metadata(cursor) -> None:
cursor.execute(_ALERT_RULES_DDL)
init_updated_at_trigger(cursor, "alert_rules")
upsert_table_metadata(
cursor,
table_name="alert_rules",
display_name="预警规则",
description="偏差诊断与提醒阈值(严重滞后/异常偏快/暂付款账龄/结题预警)。",
grid_help="比例为小数(0.10=10个百分点),天数为正整数;修改后下一次播报即生效。",
visible=1,
readonly=0,
)
seed_column_metadata_batch(
cursor,
table_name="alert_rules",
columns=_ALERT_RULES_COLUMNS,
valid_column_names=[str(c["column_name"]) for c in _ALERT_RULES_COLUMNS],
)
def init_db() -> dict[str, int]:
"""幂等创建/迁移本地 SQLite、数据管理元数据与业务种子,返回种子统计。"""
conn = get_conn()
try:
cur = conn.cursor()
init_display_metadata(cur)
_init_fund_projects_metadata(cur)
_init_progress_reports_metadata(cur)
_init_alert_rules_metadata(cur)
seeded = seed_all(cur)
conn.commit()
return seeded
finally:
conn.close()