Files
caiwuzongzhang/src/bank_importer/db.py
T
总工andmultica-agent 85e11a4f72 HEL-222: 整合自动提醒引擎(212b1f9)到现行测试版 d6f39e0
- 迁移链追加 0009_reminders_engine:旧 manual reminders 表改名
  reminders_legacy_manual 保留历史,新建 reminders/reminder_events/
  reminder_settings(append-only 触发器)
- server.py:新提醒 API(pending/scan/send/manual/resend/settings/
  公司端收件与状态流转)替换旧 manual-send 端点
- web:admin 待提醒清单+扫描+详情抽屉,公司端通知动态化+
  去处理事件委托;设置保存同步提醒扫描参数
- 移除被取代的 settings.pending_items/send_reminders 与旧 UI 逻辑
- 全量测试 346 项通过(5 项浏览器跳过与历史一致)

Co-authored-by: multica-agent <github@multica.ai>
2026-08-28 15:21:27 +00:00

1202 lines
53 KiB
Python

"""SQLite persistence: versioned migrations and connection helpers.
The database, migration runner and file storage choices are recorded in
``docs/decisions/002-persistence.md``. Migrations are plain SQL applied in
version order; each records itself in ``schema_migrations`` so re-running
``migrate`` on an existing database is a no-op. Every migration ships a
``down`` script so ``rollback`` can walk backwards for recovery and tests.
"""
from __future__ import annotations
import argparse
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
import sqlite3
DEFAULT_DB_PATH = Path("data/app.db")
def utc_now() -> str:
return datetime.now(timezone.utc).isoformat()
@dataclass(frozen=True)
class Migration:
version: int
name: str
up: str
down: str
MIGRATIONS: tuple[Migration, ...] = (
Migration(
version=1,
name="0001_core_persistence",
up="""
CREATE TABLE companies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE bank_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies (id),
account_number TEXT NOT NULL UNIQUE,
account_name TEXT,
bank_name TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'disabled')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE source_files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sha256 TEXT NOT NULL UNIQUE,
original_filename TEXT NOT NULL,
size_bytes INTEGER NOT NULL,
storage_path TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE import_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
source_file_id INTEGER NOT NULL REFERENCES source_files (id),
status TEXT NOT NULL
CHECK (status IN ('parsing', 'parsed', 'exception', 'failed', 'duplicate')),
duplicate_of_id INTEGER REFERENCES import_batches (id),
diagnostics TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE sheet_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
sheet_name TEXT NOT NULL,
bank_name TEXT NOT NULL,
template_id TEXT NOT NULL,
template_version INTEGER NOT NULL,
header_row INTEGER NOT NULL,
own_account TEXT,
own_name TEXT,
period_start TEXT,
period_end TEXT,
transaction_count INTEGER NOT NULL,
warnings TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
UNIQUE (import_batch_id, sheet_name)
);
CREATE TABLE source_rows (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sheet_batch_id INTEGER NOT NULL REFERENCES sheet_batches (id),
source_row INTEGER NOT NULL,
transaction_at TEXT NOT NULL,
income TEXT NOT NULL,
expense TEXT NOT NULL,
balance TEXT,
own_account TEXT,
own_name TEXT,
counterparty_account TEXT,
counterparty_name TEXT,
counterparty_bank TEXT,
summary TEXT,
purpose TEXT,
reference TEXT,
currency TEXT,
created_at TEXT NOT NULL,
UNIQUE (sheet_batch_id, source_row)
);
CREATE TABLE import_exceptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
stage TEXT NOT NULL,
message TEXT NOT NULL,
diagnostics TEXT,
created_at TEXT NOT NULL
);
CREATE TRIGGER source_files_no_update BEFORE UPDATE ON source_files
BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END;
CREATE TRIGGER source_files_no_delete BEFORE DELETE ON source_files
BEGIN SELECT RAISE (ABORT, 'source_files rows are immutable'); END;
CREATE TRIGGER sheet_batches_no_update BEFORE UPDATE ON sheet_batches
BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END;
CREATE TRIGGER sheet_batches_no_delete BEFORE DELETE ON sheet_batches
BEGIN SELECT RAISE (ABORT, 'sheet_batches rows are immutable'); END;
CREATE TRIGGER source_rows_no_update BEFORE UPDATE ON source_rows
BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END;
CREATE TRIGGER source_rows_no_delete BEFORE DELETE ON source_rows
BEGIN SELECT RAISE (ABORT, 'source_rows rows are immutable'); END;
""",
down="""
DROP TRIGGER IF EXISTS source_rows_no_delete;
DROP TRIGGER IF EXISTS source_rows_no_update;
DROP TRIGGER IF EXISTS sheet_batches_no_delete;
DROP TRIGGER IF EXISTS sheet_batches_no_update;
DROP TRIGGER IF EXISTS source_files_no_delete;
DROP TRIGGER IF EXISTS source_files_no_update;
DROP TABLE IF EXISTS import_exceptions;
DROP TABLE IF EXISTS source_rows;
DROP TABLE IF EXISTS sheet_batches;
DROP TABLE IF EXISTS import_batches;
DROP TABLE IF EXISTS source_files;
DROP TABLE IF EXISTS bank_accounts;
DROP TABLE IF EXISTS companies;
""",
),
Migration(
version=2,
name="0002_auth_and_sessions",
up="""
CREATE TABLE users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
role TEXT NOT NULL CHECK (role IN ('admin', 'company')),
company_id INTEGER REFERENCES companies (id),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled')),
must_change_password INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
CHECK (role != 'company' OR company_id IS NOT NULL),
CHECK (role != 'admin' OR company_id IS NULL)
);
CREATE TABLE sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
token_hash TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL REFERENCES users (id),
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE TABLE login_attempts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
ip TEXT NOT NULL,
success INTEGER NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
action TEXT NOT NULL,
target TEXT,
detail TEXT,
ip TEXT,
created_at TEXT NOT NULL
);
ALTER TABLE import_batches ADD COLUMN company_id INTEGER REFERENCES companies (id);
""",
down="""
DROP TABLE IF EXISTS audit_log;
DROP TABLE IF EXISTS login_attempts;
DROP TABLE IF EXISTS sessions;
DROP TABLE IF EXISTS users;
ALTER TABLE import_batches DROP COLUMN company_id;
""",
),
Migration(
version=3,
name="0003_dynamic_master_data",
# bank_accounts is rebuilt (SQLite cannot alter CHECK constraints):
# status gains 'returned', and the account gains type, effective
# interval and review fields. account_number stays UNIQUE and stores
# the normalized digits-only form (see master_data.normalize).
up="""
CREATE TABLE bank_accounts_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
account_number TEXT NOT NULL UNIQUE,
account_name TEXT,
bank_name TEXT NOT NULL DEFAULT '',
account_type TEXT NOT NULL DEFAULT '一般户'
CHECK (account_type IN ('基本户', '一般户', '专用户')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'returned', 'disabled')),
effective_from TEXT,
effective_to TEXT,
submitted_by INTEGER REFERENCES users (id),
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
review_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO bank_accounts_new (
id, company_id, account_number, account_name, bank_name,
status, created_at, updated_at
)
SELECT id, company_id, account_number, account_name,
COALESCE(bank_name, ''), status, created_at, updated_at
FROM bank_accounts WHERE company_id IS NOT NULL;
DROP TABLE bank_accounts;
ALTER TABLE bank_accounts_new RENAME TO bank_accounts;
CREATE TABLE account_aliases (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
alias_kind TEXT NOT NULL CHECK (alias_kind IN ('name', 'account')),
alias_value TEXT NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
effective_from TEXT,
effective_to TEXT,
created_by INTEGER REFERENCES users (id),
created_at TEXT NOT NULL,
UNIQUE (bank_account_id, alias_kind, alias_value)
);
CREATE TABLE master_data_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
ALTER TABLE companies ADD COLUMN credit_code TEXT;
ALTER TABLE companies ADD COLUMN cashier_name TEXT;
ALTER TABLE companies ADD COLUMN status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'preparing', 'disabled'));
""",
down="""
ALTER TABLE companies DROP COLUMN status;
ALTER TABLE companies DROP COLUMN cashier_name;
ALTER TABLE companies DROP COLUMN credit_code;
DROP TABLE IF EXISTS master_data_changes;
DROP TABLE IF EXISTS account_aliases;
CREATE TABLE bank_accounts_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER REFERENCES companies (id),
account_number TEXT NOT NULL UNIQUE,
account_name TEXT,
bank_name TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'disabled')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO bank_accounts_new (
id, company_id, account_number, account_name, bank_name,
status, created_at, updated_at
)
SELECT id, company_id, account_number, account_name, bank_name,
CASE WHEN status = 'returned' THEN 'pending' ELSE status END,
created_at, updated_at
FROM bank_accounts;
DROP TABLE bank_accounts;
ALTER TABLE bank_accounts_new RENAME TO bank_accounts;
""",
),
Migration(
version=4,
name="0004_per_sheet_reviews",
# Per-worksheet lifecycle: the parse outcome (parsed/exception/ignored)
# is immutable evidence captured at import time; the human decision
# (pending/confirmed/ignored) is the audit-gated gate that lets a
# worksheet participate in later matching and calculation.
up="""
CREATE TABLE sheet_reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
import_batch_id INTEGER NOT NULL REFERENCES import_batches (id),
sheet_name TEXT NOT NULL,
outcome TEXT NOT NULL
CHECK (outcome IN ('parsed', 'exception', 'ignored')),
message TEXT,
scanned_rows INTEGER,
candidate_headers TEXT,
sheet_batch_id INTEGER REFERENCES sheet_batches (id),
review_status TEXT NOT NULL DEFAULT 'pending'
CHECK (review_status IN ('pending', 'confirmed', 'ignored')),
review_reason TEXT,
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
created_at TEXT NOT NULL,
UNIQUE (import_batch_id, sheet_name)
);
CREATE INDEX idx_sheet_reviews_batch ON sheet_reviews (import_batch_id);
CREATE TRIGGER sheet_reviews_evidence_immutable BEFORE UPDATE ON sheet_reviews
BEGIN
SELECT RAISE (ABORT, 'sheet_reviews parse evidence is immutable')
WHERE OLD.outcome != NEW.outcome
OR OLD.message IS NOT NEW.message
OR OLD.scanned_rows IS NOT NEW.scanned_rows
OR OLD.candidate_headers IS NOT NEW.candidate_headers
OR OLD.sheet_batch_id IS NOT NEW.sheet_batch_id
OR OLD.import_batch_id IS NOT NEW.import_batch_id
OR OLD.sheet_name IS NOT NEW.sheet_name;
END;
""",
down="""
DROP TRIGGER IF EXISTS sheet_reviews_evidence_immutable;
DROP INDEX IF EXISTS idx_sheet_reviews_batch;
DROP TABLE IF EXISTS sheet_reviews;
""",
),
Migration(
version=5,
name="0005_canonical_transfer_matching",
# Canonical transfer event layer (B-43). Immutable source rows stay
# untouched; every matching decision, its participant/observation
# bindings and its candidate evidence are append-only history, and the
# current decision + row claims are a rebuildable projection that the
# B-44 balance layer reads through the eligible view.
up="""
ALTER TABLE import_batches ADD COLUMN upload_bank_account_id INTEGER REFERENCES bank_accounts (id);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias', 'personal_transit_mapping')),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
CREATE TABLE personal_transit_mappings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_number TEXT NOT NULL UNIQUE,
account_name TEXT,
represented_company_id INTEGER NOT NULL REFERENCES companies (id),
allowed_direction TEXT NOT NULL
CHECK (allowed_direction IN ('outgoing', 'incoming', 'both')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'active', 'returned', 'disabled')),
effective_from TEXT,
effective_to TEXT,
submitted_by INTEGER REFERENCES users (id),
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
review_reason TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE canonical_transfer_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lifecycle TEXT NOT NULL DEFAULT 'active'
CHECK (lifecycle IN ('active', 'superseded')),
created_at TEXT NOT NULL
);
CREATE TABLE transfer_match_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id),
revision INTEGER NOT NULL,
classification TEXT NOT NULL CHECK (classification IN (
'unresolved', 'needs_review', 'internal_single',
'intercompany', 'same_company', 'external'
)),
pairing TEXT NOT NULL
CHECK (pairing IN ('single', 'paired', 'not_applicable')),
amount TEXT,
currency TEXT,
effective_at TEXT,
mode TEXT NOT NULL CHECK (mode IN ('auto', 'manual', 'reversal')),
rule_version TEXT,
locked INTEGER NOT NULL DEFAULT 0 CHECK (locked IN (0, 1)),
reason TEXT,
idempotency_key TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
supersedes_decision_id INTEGER REFERENCES transfer_match_decisions (id),
created_at TEXT NOT NULL,
UNIQUE (event_id, revision)
);
CREATE TABLE transfer_decision_observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id),
source_row_id INTEGER NOT NULL REFERENCES source_rows (id),
role TEXT NOT NULL CHECK (role IN ('outgoing', 'incoming')),
created_at TEXT NOT NULL,
UNIQUE (decision_id, source_row_id)
);
CREATE TABLE transfer_decision_participants (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id),
role TEXT NOT NULL CHECK (role IN ('payer', 'payee')),
company_id INTEGER REFERENCES companies (id),
bank_account_id INTEGER REFERENCES bank_accounts (id),
resolve_method TEXT NOT NULL CHECK (resolve_method IN (
'own_exact', 'upload_account', 'counterparty_exact',
'account_alias', 'personal_mapping', 'manual'
)),
alias_id INTEGER REFERENCES account_aliases (id),
mapping_id INTEGER REFERENCES personal_transit_mappings (id),
evidence TEXT,
created_at TEXT NOT NULL,
UNIQUE (decision_id, role)
);
CREATE TABLE transfer_match_candidates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id),
source_row_id INTEGER NOT NULL REFERENCES source_rows (id),
rule_tier TEXT NOT NULL,
date_diff_days INTEGER,
account_mirror INTEGER NOT NULL DEFAULT 0,
reference_match TEXT,
summary_match INTEGER NOT NULL DEFAULT 0,
accepted INTEGER,
reason TEXT,
rule_version TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE current_transfer_decisions (
event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id),
decision_id INTEGER NOT NULL UNIQUE REFERENCES transfer_match_decisions (id)
);
CREATE TABLE transfer_observation_claims (
source_row_id INTEGER PRIMARY KEY REFERENCES source_rows (id),
event_id INTEGER NOT NULL REFERENCES canonical_transfer_events (id),
decision_id INTEGER NOT NULL REFERENCES transfer_match_decisions (id)
);
CREATE INDEX idx_obs_decision ON transfer_decision_observations (decision_id);
CREATE INDEX idx_participants_decision ON transfer_decision_participants (decision_id);
CREATE INDEX idx_candidates_decision ON transfer_match_candidates (decision_id);
CREATE INDEX idx_claims_event ON transfer_observation_claims (event_id);
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1);
CREATE TRIGGER canonical_transfer_events_no_delete BEFORE DELETE ON canonical_transfer_events
BEGIN SELECT RAISE (ABORT, 'canonical_transfer_events rows are immutable'); END;
CREATE TRIGGER canonical_transfer_events_no_update BEFORE UPDATE ON canonical_transfer_events
BEGIN
SELECT RAISE (ABORT, 'canonical_transfer_events only allow lifecycle changes')
WHERE OLD.lifecycle = NEW.lifecycle
OR OLD.id IS NOT NEW.id
OR OLD.created_at IS NOT NEW.created_at;
END;
CREATE TRIGGER transfer_match_decisions_no_update BEFORE UPDATE ON transfer_match_decisions
BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END;
CREATE TRIGGER transfer_match_decisions_no_delete BEFORE DELETE ON transfer_match_decisions
BEGIN SELECT RAISE (ABORT, 'transfer_match_decisions rows are immutable'); END;
CREATE TRIGGER transfer_decision_observations_no_update BEFORE UPDATE ON transfer_decision_observations
BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END;
CREATE TRIGGER transfer_decision_observations_no_delete BEFORE DELETE ON transfer_decision_observations
BEGIN SELECT RAISE (ABORT, 'transfer_decision_observations rows are immutable'); END;
CREATE TRIGGER transfer_decision_participants_no_update BEFORE UPDATE ON transfer_decision_participants
BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END;
CREATE TRIGGER transfer_decision_participants_no_delete BEFORE DELETE ON transfer_decision_participants
BEGIN SELECT RAISE (ABORT, 'transfer_decision_participants rows are immutable'); END;
CREATE TRIGGER transfer_match_candidates_no_update BEFORE UPDATE ON transfer_match_candidates
BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END;
CREATE TRIGGER transfer_match_candidates_no_delete BEFORE DELETE ON transfer_match_candidates
BEGIN SELECT RAISE (ABORT, 'transfer_match_candidates rows are immutable'); END;
""",
down="""
DROP VIEW IF EXISTS eligible_intercompany_events;
DROP TABLE IF EXISTS transfer_observation_claims;
DROP TABLE IF EXISTS current_transfer_decisions;
DROP TABLE IF EXISTS transfer_match_candidates;
DROP TABLE IF EXISTS transfer_decision_participants;
DROP TABLE IF EXISTS transfer_decision_observations;
DROP TABLE IF EXISTS transfer_match_decisions;
DROP TABLE IF EXISTS canonical_transfer_events;
DROP TABLE IF EXISTS personal_transit_mappings;
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN ('company', 'user', 'bank_account', 'account_alias')),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
ALTER TABLE import_batches DROP COLUMN upload_bank_account_id;
""",
),
Migration(
version=6,
name="0006_intercompany_ledger_events",
# B-44 canonical intercompany ledger layer. Manual records and the
# ledger event revision chain are append-only facts; current pointers
# (current revision per ledger event / manual decision, source claims)
# are rebuildable projections. Bank events enter only through
# ``eligible_intercompany_events``; nothing here rewrites bank rows.
up="""
CREATE TABLE manual_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
counterparty_company_id INTEGER NOT NULL REFERENCES companies (id),
occurred_at TEXT NOT NULL,
direction TEXT NOT NULL CHECK (direction IN ('outgoing', 'incoming')),
amount TEXT NOT NULL,
amount_scale INTEGER NOT NULL,
currency TEXT NOT NULL,
funding_source TEXT NOT NULL CHECK (funding_source IN (
'approved_bank_account', 'personal_transit', 'other'
)),
bank_account_id INTEGER REFERENCES bank_accounts (id),
personal_transit_mapping_id INTEGER REFERENCES personal_transit_mappings (id),
related_source_row_id INTEGER REFERENCES source_rows (id),
requested_subject TEXT NOT NULL CHECK (requested_subject IN (
'receivable', 'payable', 'other_receivable', 'other_payable'
)),
summary TEXT,
reason TEXT,
evidence_json TEXT,
request_key TEXT NOT NULL,
supersedes_record_id INTEGER REFERENCES manual_records (id),
submitted_by INTEGER REFERENCES users (id),
created_at TEXT NOT NULL,
UNIQUE (company_id, request_key),
CHECK (counterparty_company_id != company_id)
);
CREATE TABLE manual_record_decisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id INTEGER NOT NULL REFERENCES manual_records (id),
revision INTEGER NOT NULL,
state TEXT NOT NULL CHECK (state IN (
'pending', 'approved', 'returned', 'exception', 'reversed'
)),
action TEXT NOT NULL,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
idempotency_key TEXT,
supersedes_decision_id INTEGER REFERENCES manual_record_decisions (id),
created_at TEXT NOT NULL,
UNIQUE (record_id, revision)
);
CREATE TABLE current_manual_record_decisions (
record_id INTEGER PRIMARY KEY REFERENCES manual_records (id),
decision_id INTEGER NOT NULL UNIQUE REFERENCES manual_record_decisions (id)
);
CREATE TABLE ledger_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lifecycle TEXT NOT NULL DEFAULT 'active'
CHECK (lifecycle IN ('active', 'superseded')),
created_at TEXT NOT NULL
);
CREATE TABLE ledger_event_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
revision INTEGER NOT NULL,
state TEXT NOT NULL CHECK (state IN ('pending_subject', 'confirmed')),
effective_at TEXT NOT NULL,
amount TEXT NOT NULL,
amount_scale INTEGER NOT NULL,
currency TEXT NOT NULL,
payer_company_id INTEGER NOT NULL REFERENCES companies (id),
payee_company_id INTEGER NOT NULL REFERENCES companies (id),
perspective_company_id INTEGER REFERENCES companies (id),
subject_code TEXT CHECK (subject_code IN (
'receivable', 'payable', 'other_receivable', 'other_payable'
)),
source_kind TEXT NOT NULL CHECK (source_kind IN ('bank', 'manual', 'adjustment')),
source_revision_token TEXT,
posting_kind TEXT NOT NULL CHECK (posting_kind IN (
'normal', 'reversal', 'adjustment'
)),
reverses_ledger_event_id INTEGER REFERENCES ledger_events (id),
adjusts_ledger_event_id INTEGER REFERENCES ledger_events (id),
rule_version TEXT,
evidence_json TEXT,
idempotency_key TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
reason TEXT,
supersedes_revision_id INTEGER REFERENCES ledger_event_revisions (id),
created_at TEXT NOT NULL,
UNIQUE (ledger_event_id, revision),
CHECK (payer_company_id != payee_company_id),
CHECK (state = 'confirmed' OR subject_code IS NULL),
CHECK (state = 'confirmed' OR perspective_company_id IS NULL),
CHECK (
state != 'confirmed'
OR (perspective_company_id IS NOT NULL AND subject_code IS NOT NULL)
)
);
CREATE TABLE current_ledger_event_revisions (
ledger_event_id INTEGER PRIMARY KEY REFERENCES ledger_events (id),
revision_id INTEGER NOT NULL UNIQUE REFERENCES ledger_event_revisions (id)
);
CREATE TABLE ledger_event_bank_sources (
bank_event_id INTEGER PRIMARY KEY REFERENCES canonical_transfer_events (id),
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
UNIQUE (ledger_event_id, bank_event_id)
);
CREATE TABLE ledger_event_manual_sources (
manual_record_id INTEGER PRIMARY KEY REFERENCES manual_records (id),
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
UNIQUE (ledger_event_id, manual_record_id)
);
CREATE TABLE ledger_subject_suggestions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ledger_event_id INTEGER NOT NULL REFERENCES ledger_events (id),
source_revision_id INTEGER NOT NULL REFERENCES ledger_event_revisions (id),
suggested_perspective_company_id INTEGER NOT NULL REFERENCES companies (id),
suggested_subject_code TEXT NOT NULL CHECK (suggested_subject_code IN (
'receivable', 'payable', 'other_receivable', 'other_payable'
)),
rule_version TEXT,
evidence_json TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_ledger_revisions_event ON ledger_event_revisions (ledger_event_id, revision);
CREATE INDEX idx_ledger_revisions_effective ON ledger_event_revisions (effective_at);
CREATE INDEX idx_ledger_revisions_pair_currency
ON ledger_event_revisions (payer_company_id, payee_company_id, currency);
CREATE INDEX idx_ledger_revisions_state
ON ledger_event_revisions (state, effective_at);
CREATE INDEX idx_manual_records_company ON manual_records (company_id, occurred_at);
CREATE INDEX idx_manual_decisions_record ON manual_record_decisions (record_id, revision);
CREATE INDEX idx_manual_decisions_state ON manual_record_decisions (state);
CREATE INDEX idx_subject_suggestions_event ON ledger_subject_suggestions (ledger_event_id);
CREATE VIEW eligible_position_events AS
SELECT le.id AS ledger_event_id,
cur.revision_id AS ledger_revision_id,
r.effective_at AS effective_at,
r.amount AS amount, r.amount_scale AS amount_scale,
r.currency AS currency,
r.payer_company_id AS payer_company_id,
r.payee_company_id AS payee_company_id,
r.perspective_company_id AS perspective_company_id,
r.subject_code AS subject_code,
r.source_kind AS source_kind, r.posting_kind AS posting_kind,
r.reverses_ledger_event_id AS reverses_ledger_event_id,
r.adjusts_ledger_event_id AS adjusts_ledger_event_id,
COALESCE(
(SELECT bs.bank_event_id FROM ledger_event_bank_sources bs
WHERE bs.ledger_event_id = le.id LIMIT 1),
(SELECT ms.manual_record_id FROM ledger_event_manual_sources ms
WHERE ms.ledger_event_id = le.id LIMIT 1)
) AS source_id,
((SELECT COUNT(*) FROM ledger_event_bank_sources bs
WHERE bs.ledger_event_id = le.id)
+ (SELECT COUNT(*) FROM ledger_event_manual_sources ms
WHERE ms.ledger_event_id = le.id)) AS evidence_count
FROM ledger_events le
JOIN current_ledger_event_revisions cur ON cur.ledger_event_id = le.id
JOIN ledger_event_revisions r ON r.id = cur.revision_id
WHERE le.lifecycle = 'active' AND r.state = 'confirmed';
CREATE TRIGGER ledger_events_no_delete BEFORE DELETE ON ledger_events
BEGIN SELECT RAISE (ABORT, 'ledger_events rows are immutable'); END;
CREATE TRIGGER ledger_events_no_update BEFORE UPDATE ON ledger_events
BEGIN
SELECT RAISE (ABORT, 'ledger_events only allow lifecycle changes')
WHERE OLD.lifecycle = NEW.lifecycle
OR OLD.id IS NOT NEW.id
OR OLD.created_at IS NOT NEW.created_at;
END;
CREATE TRIGGER ledger_event_revisions_no_update BEFORE UPDATE ON ledger_event_revisions
BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END;
CREATE TRIGGER ledger_event_revisions_no_delete BEFORE DELETE ON ledger_event_revisions
BEGIN SELECT RAISE (ABORT, 'ledger_event_revisions rows are immutable'); END;
CREATE TRIGGER ledger_subject_suggestions_no_update BEFORE UPDATE ON ledger_subject_suggestions
BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END;
CREATE TRIGGER ledger_subject_suggestions_no_delete BEFORE DELETE ON ledger_subject_suggestions
BEGIN SELECT RAISE (ABORT, 'ledger_subject_suggestions rows are immutable'); END;
CREATE TRIGGER manual_records_no_update BEFORE UPDATE ON manual_records
BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END;
CREATE TRIGGER manual_records_no_delete BEFORE DELETE ON manual_records
BEGIN SELECT RAISE (ABORT, 'manual_records rows are immutable'); END;
CREATE TRIGGER manual_record_decisions_no_update BEFORE UPDATE ON manual_record_decisions
BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END;
CREATE TRIGGER manual_record_decisions_no_delete BEFORE DELETE ON manual_record_decisions
BEGIN SELECT RAISE (ABORT, 'manual_record_decisions rows are immutable'); END;
""",
down="""
DROP VIEW IF EXISTS eligible_position_events;
DROP TRIGGER IF EXISTS manual_record_decisions_no_delete;
DROP TRIGGER IF EXISTS manual_record_decisions_no_update;
DROP TRIGGER IF EXISTS manual_records_no_delete;
DROP TRIGGER IF EXISTS manual_records_no_update;
DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_delete;
DROP TRIGGER IF EXISTS ledger_subject_suggestions_no_update;
DROP TRIGGER IF EXISTS ledger_event_revisions_no_delete;
DROP TRIGGER IF EXISTS ledger_event_revisions_no_update;
DROP TRIGGER IF EXISTS ledger_events_no_update;
DROP TRIGGER IF EXISTS ledger_events_no_delete;
DROP TABLE IF EXISTS ledger_subject_suggestions;
DROP TABLE IF EXISTS ledger_event_manual_sources;
DROP TABLE IF EXISTS ledger_event_bank_sources;
DROP TABLE IF EXISTS current_ledger_event_revisions;
DROP TABLE IF EXISTS ledger_event_revisions;
DROP TABLE IF EXISTS ledger_events;
DROP TABLE IF EXISTS current_manual_record_decisions;
DROP TABLE IF EXISTS manual_record_decisions;
DROP TABLE IF EXISTS manual_records;
""",
),
Migration(
version=7,
name="0007_system_settings_and_reminders",
# System settings (closing day, global start date, auto-reminder) are
# persisted as a key/value table with an append-only change history
# (operator + before/after) for the audit requirement. Reminders are a
# separate append-only table so reminder history survives re-sends.
up="""
CREATE TABLE system_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_by INTEGER REFERENCES users (id),
updated_at TEXT NOT NULL
);
CREATE TABLE system_setting_changes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL,
before_value TEXT,
after_value TEXT NOT NULL,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
CREATE TABLE reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
kind TEXT NOT NULL,
content TEXT NOT NULL,
deadline TEXT,
source TEXT NOT NULL CHECK (source IN ('system', 'manual')),
status TEXT NOT NULL DEFAULT 'unread'
CHECK (status IN ('unread', 'done')),
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_reminders_company ON reminders (company_id);
""",
down="""
DROP INDEX IF EXISTS idx_reminders_company;
DROP TABLE IF EXISTS reminders;
DROP TABLE IF EXISTS system_setting_changes;
DROP TABLE IF EXISTS system_settings;
""",
),
Migration(
version=8,
name="0008_calculation_window",
# HEL-194/202: opening balances, coverage gaps, no-business attestations.
# Reuses system_settings / system_setting_changes from 0007; does not
# recreate them. Extends master_data_changes CHECK and overlays the
# calculation_start_date filter onto eligible_intercompany_events.
up="""
CREATE TABLE closed_periods (
year_month TEXT PRIMARY KEY,
closed_at TEXT NOT NULL,
closed_by INTEGER REFERENCES users (id)
);
CREATE TABLE opening_balance_revisions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id_low INTEGER NOT NULL REFERENCES companies (id),
company_id_high INTEGER NOT NULL REFERENCES companies (id),
amount TEXT NOT NULL,
currency TEXT NOT NULL DEFAULT 'CNY',
revision INTEGER NOT NULL,
status TEXT NOT NULL
CHECK (status IN ('draft', 'confirmed', 'superseded', 'void')),
reason TEXT NOT NULL,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
supersedes_id INTEGER REFERENCES opening_balance_revisions (id),
created_at TEXT NOT NULL,
UNIQUE (company_id_low, company_id_high, revision),
CHECK (company_id_low < company_id_high)
);
CREATE TABLE coverage_gaps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
gap_start TEXT NOT NULL,
gap_end TEXT NOT NULL,
gap_kind TEXT NOT NULL CHECK (gap_kind IN ('head', 'mid', 'tail')),
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'closed_attested')),
first_detected_at TEXT NOT NULL,
UNIQUE (bank_account_id, gap_start, gap_end)
);
CREATE TABLE no_business_attestations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
bank_account_id INTEGER NOT NULL REFERENCES bank_accounts (id),
gap_start TEXT NOT NULL,
gap_end TEXT NOT NULL,
reason TEXT NOT NULL,
evidence TEXT,
submitted_by INTEGER REFERENCES users (id),
company_id INTEGER NOT NULL REFERENCES companies (id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'approved', 'rejected')),
reviewed_by INTEGER REFERENCES users (id),
reviewed_at TEXT,
review_reason TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_opening_balance_pair ON opening_balance_revisions (company_id_low, company_id_high);
CREATE INDEX idx_coverage_gaps_account ON coverage_gaps (bank_account_id);
CREATE INDEX idx_attestations_company ON no_business_attestations (company_id);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN (
'company', 'user', 'bank_account', 'account_alias',
'personal_transit_mapping', 'system_setting', 'opening_balance'
)),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes;
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
DROP VIEW IF EXISTS eligible_intercompany_events;
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1)
AND (
(SELECT value FROM system_settings WHERE key = 'calculation_start_date') IS NULL
OR substr(d.effective_at, 1, 10) >= (
SELECT value FROM system_settings WHERE key = 'calculation_start_date'
)
);
""",
down="""
DROP VIEW IF EXISTS eligible_intercompany_events;
CREATE VIEW eligible_intercompany_events AS
SELECT e.id AS event_id, d.id AS decision_id, d.revision AS revision,
d.effective_at AS effective_at, d.amount AS amount, d.currency AS currency,
payer.company_id AS payer_company_id,
payer.bank_account_id AS payer_account_id,
payee.company_id AS payee_company_id,
payee.bank_account_id AS payee_account_id,
d.pairing AS pairing, d.rule_version AS rule_version,
(SELECT COUNT(*) FROM transfer_decision_observations o
WHERE o.decision_id = d.id) AS evidence_count
FROM current_transfer_decisions c
JOIN canonical_transfer_events e ON e.id = c.event_id
JOIN transfer_match_decisions d ON d.id = c.decision_id
JOIN transfer_decision_participants payer
ON payer.decision_id = d.id AND payer.role = 'payer'
JOIN transfer_decision_participants payee
ON payee.decision_id = d.id AND payee.role = 'payee'
WHERE e.lifecycle = 'active' AND d.classification = 'intercompany'
AND (d.pairing = 'paired' OR d.locked = 1);
CREATE TABLE master_data_changes_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL
CHECK (entity_type IN (
'company', 'user', 'bank_account', 'account_alias',
'personal_transit_mapping'
)),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL,
before_json TEXT,
after_json TEXT,
reason TEXT,
actor_user_id INTEGER REFERENCES users (id),
actor_username TEXT,
created_at TEXT NOT NULL
);
INSERT INTO master_data_changes_new SELECT * FROM master_data_changes
WHERE entity_type NOT IN ('system_setting', 'opening_balance');
DROP TABLE master_data_changes;
ALTER TABLE master_data_changes_new RENAME TO master_data_changes;
DROP INDEX IF EXISTS idx_attestations_company;
DROP INDEX IF EXISTS idx_coverage_gaps_account;
DROP INDEX IF EXISTS idx_opening_balance_pair;
DROP TABLE IF EXISTS no_business_attestations;
DROP TABLE IF EXISTS coverage_gaps;
DROP TABLE IF EXISTS opening_balance_revisions;
DROP TABLE IF EXISTS closed_periods;
""",
),
Migration(
version=9,
name="0009_reminders_engine",
# HEL-195/215 auto-reminder engine. The old manual reminders table
# from 0007 is preserved verbatim under reminders_legacy_manual so
# existing test-env history stays queryable; the new engine uses its
# own append-only reminders/reminder_events schema.
up="""
ALTER TABLE reminders RENAME TO reminders_legacy_manual;
DROP INDEX IF EXISTS idx_reminders_company;
CREATE INDEX idx_reminders_legacy_company
ON reminders_legacy_manual (company_id);
CREATE TABLE reminder_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL
);
INSERT INTO reminder_settings (key, value, updated_at) VALUES
('monthly_start_day', '5', datetime('now')),
('gap_days', '5', datetime('now')),
('scan_time', '08:00', datetime('now'));
CREATE TABLE reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
company_id INTEGER NOT NULL REFERENCES companies (id),
rule_key TEXT NOT NULL CHECK (rule_key IN (
'unsubmitted', 'gap', 'pending_review', 'manual'
)),
dedupe_key TEXT NOT NULL UNIQUE,
rule_params TEXT NOT NULL DEFAULT '{}',
title TEXT NOT NULL,
content TEXT NOT NULL,
deadline TEXT,
source TEXT NOT NULL CHECK (source IN ('auto', 'manual')),
status TEXT NOT NULL DEFAULT 'open'
CHECK (status IN ('open', 'acknowledged', 'resolved')),
send_count INTEGER NOT NULL DEFAULT 0,
first_sent_at TEXT,
last_sent_at TEXT,
created_by TEXT NOT NULL,
created_at TEXT NOT NULL,
action_link TEXT
);
CREATE INDEX idx_reminders_company ON reminders (company_id);
CREATE INDEX idx_reminders_status ON reminders (status);
CREATE TABLE reminder_events (
id INTEGER PRIMARY KEY AUTOINCREMENT,
reminder_id INTEGER NOT NULL REFERENCES reminders (id),
event_type TEXT NOT NULL CHECK (event_type IN (
'sent', 'acknowledged', 'resolved', 'escalated'
)),
actor TEXT NOT NULL,
detail TEXT,
created_at TEXT NOT NULL
);
CREATE INDEX idx_reminder_events_reminder ON reminder_events (reminder_id);
CREATE TRIGGER reminder_events_no_update BEFORE UPDATE ON reminder_events
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
CREATE TRIGGER reminder_events_no_delete BEFORE DELETE ON reminder_events
BEGIN SELECT RAISE (ABORT, 'reminder_events rows are append-only'); END;
CREATE TRIGGER reminders_no_delete BEFORE DELETE ON reminders
BEGIN SELECT RAISE (ABORT, 'reminders rows are immutable history'); END;
""",
down="""
DROP TRIGGER IF EXISTS reminders_no_delete;
DROP TRIGGER IF EXISTS reminder_events_no_delete;
DROP TRIGGER IF EXISTS reminder_events_no_update;
DROP INDEX IF EXISTS idx_reminder_events_reminder;
DROP TABLE IF EXISTS reminder_events;
DROP INDEX IF EXISTS idx_reminders_status;
DROP INDEX IF EXISTS idx_reminders_company;
DROP TABLE IF EXISTS reminders;
DROP TABLE IF EXISTS reminder_settings;
DROP INDEX IF EXISTS idx_reminders_legacy_company;
ALTER TABLE reminders_legacy_manual RENAME TO reminders;
CREATE INDEX idx_reminders_company ON reminders (company_id);
""",
),
)
def connect(path: str | Path) -> sqlite3.Connection:
db_path = Path(path)
if str(db_path) != ":memory:":
db_path.parent.mkdir(parents=True, exist_ok=True)
# Long busy timeout so concurrent uploads/confirmations wait for the
# single SQLite writer instead of surfacing "database is locked" 500s.
connection = sqlite3.connect(str(db_path), timeout=30)
connection.row_factory = sqlite3.Row
connection.execute("PRAGMA foreign_keys = ON")
return connection
def applied_versions(connection: sqlite3.Connection) -> list[int]:
exists = connection.execute(
"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'"
).fetchone()
if not exists:
return []
rows = connection.execute(
"SELECT version FROM schema_migrations ORDER BY version"
).fetchall()
return [row["version"] for row in rows]
def migrate(connection: sqlite3.Connection) -> list[int]:
"""Apply every pending migration; returns the versions applied now."""
connection.execute(
"""
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
name TEXT NOT NULL,
applied_at TEXT NOT NULL
)
"""
)
applied = set(applied_versions(connection))
newly_applied: list[int] = []
for migration in MIGRATIONS:
if migration.version in applied:
continue
with connection:
connection.executescript(migration.up)
connection.execute(
"INSERT INTO schema_migrations (version, name, applied_at) VALUES (?, ?, ?)",
(migration.version, migration.name, utc_now()),
)
newly_applied.append(migration.version)
return newly_applied
def rollback(connection: sqlite3.Connection, target_version: int = 0) -> list[int]:
"""Reverse migrations above ``target_version``; returns reversed versions."""
applied = applied_versions(connection)
reversed_versions: list[int] = []
for migration in sorted(MIGRATIONS, key=lambda item: item.version, reverse=True):
if migration.version <= target_version or migration.version not in applied:
continue
with connection:
connection.executescript(migration.down)
connection.execute(
"DELETE FROM schema_migrations WHERE version = ?",
(migration.version,),
)
reversed_versions.append(migration.version)
return reversed_versions
def main() -> int:
parser = argparse.ArgumentParser(description="Apply or roll back database migrations.")
parser.add_argument(
"db_path",
type=Path,
nargs="?",
default=DEFAULT_DB_PATH,
help="SQLite database path (default: data/app.db)",
)
parser.add_argument(
"--rollback-to",
type=int,
default=None,
metavar="VERSION",
help="Reverse migrations above VERSION instead of migrating forward",
)
args = parser.parse_args()
connection = connect(args.db_path)
try:
if args.rollback_to is None:
applied = migrate(connection)
print(f"applied migrations: {applied or 'none (already up to date)'}")
else:
reversed_versions = rollback(connection, args.rollback_to)
print(f"reversed migrations: {reversed_versions or 'none'}")
finally:
connection.close()
return 0
if __name__ == "__main__":
raise SystemExit(main())