HEL-269: 完成月结锁账、撤演示数据与代码收尾

锁账后写保护与闭期补录留痕;重开须审批并按版本链再结;列表/导出改走真实 API。

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
总工
2026-08-30 21:42:22 +08:00
co-authored by Cursor multica-agent
parent f5e0915f63
commit e5e326514d
26 changed files with 3455 additions and 487 deletions
+104
View File
@@ -1096,6 +1096,106 @@ MIGRATIONS: tuple[Migration, ...] = (
CREATE INDEX idx_reminders_company ON reminders (company_id);
""",
),
Migration(
version=10,
name="00010_period_close_reopen",
# HEL-196/269: monthly close snapshots, reopen approval, late arrivals
# after lock, and an append-only period audit trail. closed_periods
# (from 0008) remains the live lock index; this table is the versioned
# report history.
up="""
CREATE TABLE period_close_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year_month TEXT NOT NULL,
version INTEGER NOT NULL,
status TEXT NOT NULL CHECK (status IN (
'pending', 'closing', 'closed', 'failed', 'reopened'
)),
snapshot_json TEXT,
snapshot_hash TEXT,
report_no TEXT,
blockers_json TEXT,
fail_reason TEXT,
closed_at TEXT,
closed_by INTEGER REFERENCES users (id),
closed_by_username TEXT,
reopen_window_end TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (year_month, version)
);
CREATE TABLE period_reopen_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
period_close_id INTEGER NOT NULL REFERENCES period_close_runs (id),
year_month TEXT NOT NULL,
reason TEXT NOT NULL,
companies_note TEXT,
window_days INTEGER NOT NULL DEFAULT 3,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
requester_id INTEGER REFERENCES users (id),
requester_username TEXT,
requested_at TEXT NOT NULL,
reviewer_id INTEGER REFERENCES users (id),
reviewer_username TEXT,
reviewed_at TEXT,
review_comment TEXT,
before_json TEXT,
after_json TEXT
);
CREATE TABLE period_late_arrivals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
year_month TEXT NOT NULL,
source_row_id INTEGER NOT NULL UNIQUE REFERENCES source_rows (id),
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'absorbed')),
created_at TEXT NOT NULL,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT
);
CREATE TABLE period_audit_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at TEXT NOT NULL,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
actor_role TEXT,
action TEXT NOT NULL,
year_month TEXT,
object_label TEXT,
reason TEXT,
before_json TEXT,
after_json TEXT,
report_no TEXT,
related_id INTEGER
);
CREATE INDEX idx_period_close_month ON period_close_runs (year_month, version);
CREATE INDEX idx_period_reopen_status ON period_reopen_requests (status, year_month);
CREATE INDEX idx_period_audit_created ON period_audit_events (created_at);
CREATE TRIGGER period_audit_no_update BEFORE UPDATE ON period_audit_events
BEGIN SELECT RAISE (ABORT, 'period_audit_events rows are append-only'); END;
CREATE TRIGGER period_audit_no_delete BEFORE DELETE ON period_audit_events
BEGIN SELECT RAISE (ABORT, 'period_audit_events rows are immutable history'); END;
CREATE TRIGGER period_close_snapshot_no_update BEFORE UPDATE ON period_close_runs
WHEN OLD.snapshot_json IS NOT NULL AND NEW.snapshot_json IS NOT OLD.snapshot_json
BEGIN SELECT RAISE (ABORT, 'closed snapshots cannot be rewritten'); END;
""",
down="""
DROP TRIGGER IF EXISTS period_close_snapshot_no_update;
DROP TRIGGER IF EXISTS period_audit_no_delete;
DROP TRIGGER IF EXISTS period_audit_no_update;
DROP INDEX IF EXISTS idx_period_audit_created;
DROP INDEX IF EXISTS idx_period_reopen_status;
DROP INDEX IF EXISTS idx_period_close_month;
DROP TABLE IF EXISTS period_audit_events;
DROP TABLE IF EXISTS period_late_arrivals;
DROP TABLE IF EXISTS period_reopen_requests;
DROP TABLE IF EXISTS period_close_runs;
""",
),
)
@@ -1108,6 +1208,10 @@ def connect(path: str | Path) -> sqlite3.Connection:
connection = sqlite3.connect(str(db_path), timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
# WAL lowers write-lock contention on file databases. Skip :memory:
# because WAL requires a real file.
if str(db_path) != ":memory:":
connection.execute("PRAGMA journal_mode=WAL")
return connection