233 lines
14 KiB
Python
233 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
import sqlite3
|
|
|
|
from .connection import connect, resolve_db_path
|
|
from .schema import ACCOUNT_ADDITIVE_COLUMNS, SCHEMA_SQL, SCHEMA_VERSION, TRANSACTION_ADDITIVE_COLUMNS
|
|
|
|
def _existing_columns(conn: sqlite3.Connection, table: str) -> set[str]:
|
|
return {row["name"] for row in conn.execute(f"PRAGMA table_info({table})")}
|
|
|
|
|
|
def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str, str]) -> None:
|
|
existing = _existing_columns(conn, table)
|
|
for name, definition in columns.items():
|
|
if name not in existing:
|
|
conn.execute(f"ALTER TABLE {table} ADD COLUMN {name} {definition}")
|
|
|
|
|
|
def migrate_db(conn: sqlite3.Connection) -> None:
|
|
"""Apply additive, idempotent migrations only."""
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts (
|
|
id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL,
|
|
status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL,
|
|
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '',
|
|
create_key TEXT NOT NULL UNIQUE, correction_of_run_id TEXT NOT NULL DEFAULT '',
|
|
correction_reason TEXT NOT NULL DEFAULT '', final_run_id TEXT NOT NULL DEFAULT '',
|
|
final_response TEXT NOT NULL DEFAULT '', cancelled_by TEXT NOT NULL DEFAULT '',
|
|
cancellation_reason TEXT NOT NULL DEFAULT '', cancelled_at TEXT NOT NULL DEFAULT '',
|
|
approval_fingerprint TEXT NOT NULL DEFAULT '', approval_version INTEGER NOT NULL DEFAULT 0,
|
|
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
|
|
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
|
|
approval_flags TEXT NOT NULL DEFAULT '{}'
|
|
)""")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_settlement_drafts_active_month ON settlement_drafts(month) WHERE status='active'")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_drafts_month ON settlement_drafts(month,created_at)")
|
|
_add_missing_columns(conn, "settlement_drafts", {"original_request_hash": "TEXT NOT NULL DEFAULT ''", "cashflow_policy_json": "TEXT NOT NULL DEFAULT '{}'", "aggregation_version": "TEXT NOT NULL DEFAULT 'comprehensive-v1'"})
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_draft_items (
|
|
id TEXT PRIMARY KEY, draft_id TEXT NOT NULL REFERENCES settlement_drafts(id),
|
|
source_type TEXT NOT NULL, source_id TEXT NOT NULL, valuation_id TEXT NOT NULL DEFAULT '',
|
|
selection_mode TEXT NOT NULL DEFAULT 'exact', selection_reason TEXT NOT NULL DEFAULT '',
|
|
included INTEGER NOT NULL DEFAULT 1, exclusion_reason TEXT NOT NULL DEFAULT '',
|
|
version INTEGER NOT NULL DEFAULT 1, selected_by TEXT NOT NULL DEFAULT '',
|
|
selected_at TEXT NOT NULL DEFAULT '', evidence_json TEXT NOT NULL DEFAULT '{}',
|
|
UNIQUE(draft_id,source_type,source_id)
|
|
)""")
|
|
_add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS)
|
|
_add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS)
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)")
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS category_rules (
|
|
id TEXT PRIMARY KEY,
|
|
priority INTEGER NOT NULL DEFAULT 100,
|
|
match_field TEXT NOT NULL,
|
|
match_type TEXT NOT NULL DEFAULT 'contains',
|
|
pattern TEXT NOT NULL,
|
|
amount_min INTEGER,
|
|
amount_max INTEGER,
|
|
day_of_month INTEGER,
|
|
transaction_type TEXT NOT NULL DEFAULT '',
|
|
category TEXT NOT NULL,
|
|
subcategory TEXT NOT NULL DEFAULT '',
|
|
person TEXT NOT NULL DEFAULT '',
|
|
is_fixed INTEGER,
|
|
confidence REAL NOT NULL DEFAULT 1.0,
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
memo TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)""")
|
|
_add_missing_columns(conn, "category_rules", {"amount_min": "INTEGER", "amount_max": "INTEGER", "day_of_month": "INTEGER"})
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_category_rules_active_priority ON category_rules(active, priority)")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_category_rules_unique ON category_rules(match_field, match_type, pattern, transaction_type, category, subcategory, person)")
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS ledger_categories (
|
|
id TEXT PRIMARY KEY,
|
|
category TEXT NOT NULL,
|
|
subcategory TEXT NOT NULL DEFAULT '',
|
|
cashflow_kind TEXT NOT NULL DEFAULT 'expense',
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
sort_order INTEGER NOT NULL DEFAULT 100,
|
|
memo TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)""")
|
|
_add_missing_columns(conn, "ledger_categories", {"cashflow_kind": "TEXT NOT NULL DEFAULT 'expense'"})
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory)")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory)")
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS manual_assets (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
|
|
valuation_amount INTEGER,
|
|
valuation_date TEXT NOT NULL DEFAULT '',
|
|
currency TEXT NOT NULL DEFAULT 'KRW',
|
|
source_note TEXT NOT NULL DEFAULT '',
|
|
memo TEXT NOT NULL DEFAULT '',
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)""")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_active_bucket ON manual_assets(active, liquidity_bucket, name)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_manual_assets_valuation_date ON manual_assets(valuation_date)")
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshots (
|
|
id TEXT PRIMARY KEY,
|
|
basis_date TEXT NOT NULL,
|
|
source_type TEXT NOT NULL,
|
|
source_id TEXT NOT NULL DEFAULT '',
|
|
name TEXT NOT NULL,
|
|
amount INTEGER,
|
|
currency TEXT NOT NULL DEFAULT 'KRW',
|
|
liquidity_bucket TEXT NOT NULL DEFAULT 'other',
|
|
valuation_source TEXT NOT NULL DEFAULT '',
|
|
source_note TEXT NOT NULL DEFAULT '',
|
|
memo TEXT NOT NULL DEFAULT '',
|
|
snapshot_run_id TEXT NOT NULL DEFAULT '',
|
|
correction_of_run_id TEXT NOT NULL DEFAULT '',
|
|
correction_reason TEXT NOT NULL DEFAULT '',
|
|
is_correction INTEGER NOT NULL DEFAULT 0,
|
|
closed_at TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
)""")
|
|
_add_missing_columns(conn, "asset_snapshots", {
|
|
"snapshot_run_id": "TEXT NOT NULL DEFAULT ''",
|
|
"correction_of_run_id": "TEXT NOT NULL DEFAULT ''",
|
|
"correction_reason": "TEXT NOT NULL DEFAULT ''",
|
|
"is_correction": "INTEGER NOT NULL DEFAULT 0",
|
|
"closed_at": "TEXT NOT NULL DEFAULT ''",
|
|
"valuation_id": "TEXT NOT NULL DEFAULT ''",
|
|
"selection_policy": "TEXT NOT NULL DEFAULT 'exact'",
|
|
"verification_status": "TEXT NOT NULL DEFAULT 'unverified'",
|
|
"evidence_kind": "TEXT NOT NULL DEFAULT 'applied'",
|
|
"exclusion_reason": "TEXT NOT NULL DEFAULT ''",
|
|
})
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)")
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS asset_snapshot_runs (
|
|
id TEXT PRIMARY KEY, basis_date TEXT NOT NULL, close_status TEXT NOT NULL,
|
|
selection_policy TEXT NOT NULL DEFAULT 'exact', idempotency_key TEXT NOT NULL DEFAULT '',
|
|
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
|
|
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
|
|
correction_of_run_id TEXT NOT NULL DEFAULT '', correction_reason TEXT NOT NULL DEFAULT '',
|
|
applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
|
|
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
|
|
)""")
|
|
_add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''", "draft_id": "TEXT NOT NULL DEFAULT ''", "draft_version": "INTEGER NOT NULL DEFAULT 0", "review_fingerprint": "TEXT NOT NULL DEFAULT ''"})
|
|
_add_missing_columns(conn, "asset_snapshot_runs", {
|
|
"settlement_month": "TEXT",
|
|
"currency": "TEXT",
|
|
"income_total": "INTEGER",
|
|
"expense_total": "INTEGER",
|
|
"net_cashflow": "INTEGER",
|
|
"total_assets": "INTEGER",
|
|
"total_liabilities": "INTEGER",
|
|
"net_worth": "INTEGER",
|
|
"cashflow_policy_json": "TEXT",
|
|
"aggregation_version": "TEXT",
|
|
"snapshot_schema_version": "INTEGER",
|
|
"source_snapshot_json": "TEXT",
|
|
})
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_cashflow_snapshots (
|
|
id TEXT PRIMARY KEY,
|
|
settlement_run_id TEXT NOT NULL REFERENCES asset_snapshot_runs(id) ON DELETE RESTRICT,
|
|
cashflow_kind TEXT NOT NULL CHECK (cashflow_kind IN ('income', 'expense', 'excluded')),
|
|
category TEXT NOT NULL DEFAULT '',
|
|
subcategory TEXT NOT NULL DEFAULT '',
|
|
amount INTEGER NOT NULL CHECK (typeof(amount) = 'integer'),
|
|
transaction_count INTEGER NOT NULL CHECK (typeof(transaction_count) = 'integer' AND transaction_count > 0),
|
|
currency TEXT NOT NULL,
|
|
UNIQUE (settlement_run_id, cashflow_kind, category, subcategory, currency)
|
|
)""")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_cashflow_snapshots_run ON settlement_cashflow_snapshots(settlement_run_id)")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''")
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions (
|
|
id TEXT PRIMARY KEY,
|
|
target_type TEXT NOT NULL,
|
|
target_id TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
decision_type TEXT NOT NULL DEFAULT 'unknown',
|
|
reason_code TEXT NOT NULL DEFAULT '',
|
|
memo TEXT NOT NULL DEFAULT '',
|
|
operator TEXT NOT NULL DEFAULT 'operator',
|
|
decided_at TEXT NOT NULL DEFAULT '',
|
|
review_after TEXT NOT NULL DEFAULT '',
|
|
target_payload TEXT NOT NULL DEFAULT '{}',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)""")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at)")
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS reconciliation_sessions (
|
|
id TEXT PRIMARY KEY,
|
|
account_id TEXT NOT NULL,
|
|
basis_date TEXT NOT NULL,
|
|
expected_balance INTEGER,
|
|
actual_balance INTEGER,
|
|
difference INTEGER,
|
|
status TEXT NOT NULL DEFAULT 'open',
|
|
closed_at TEXT NOT NULL DEFAULT '',
|
|
source_note TEXT NOT NULL DEFAULT '',
|
|
memo TEXT NOT NULL DEFAULT '',
|
|
operator TEXT NOT NULL DEFAULT 'operator',
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
)""")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)")
|
|
conn.execute("""CREATE TABLE IF NOT EXISTS account_valuations (
|
|
id TEXT PRIMARY KEY, account_id TEXT NOT NULL REFERENCES accounts(id), basis_date TEXT NOT NULL,
|
|
amount INTEGER NOT NULL, currency TEXT NOT NULL DEFAULT 'KRW', valuation_kind TEXT NOT NULL DEFAULT 'balance',
|
|
source_type TEXT NOT NULL DEFAULT 'manual', source_ref TEXT NOT NULL DEFAULT '',
|
|
verification_status TEXT NOT NULL DEFAULT 'unverified', recorded_at TEXT NOT NULL,
|
|
memo TEXT NOT NULL DEFAULT '', idempotency_key TEXT NOT NULL DEFAULT '',
|
|
supersedes_id TEXT REFERENCES account_valuations(id), correction_reason TEXT NOT NULL DEFAULT '',
|
|
as_of_at TEXT, timezone TEXT, cost_basis INTEGER, net_contributed INTEGER,
|
|
reported_principal_kind TEXT, reported_principal_label TEXT, profit INTEGER,
|
|
return_rate REAL, return_basis TEXT, principal INTEGER, interest INTEGER, fee INTEGER,
|
|
representative_liability_definition TEXT
|
|
)""")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_account_date ON account_valuations(account_id, basis_date, recorded_at)")
|
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_account_valuations_status_date ON account_valuations(verification_status, basis_date)")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_idempotency ON account_valuations(account_id, idempotency_key) WHERE idempotency_key <> ''")
|
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_account_valuations_single_correction ON account_valuations(supersedes_id) WHERE supersedes_id IS NOT NULL")
|
|
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
|
|
|
|
|
|
def init_db(db_path: str | None = None) -> str:
|
|
path = resolve_db_path(db_path)
|
|
with connect(str(path)) as conn:
|
|
conn.executescript(SCHEMA_SQL)
|
|
migrate_db(conn)
|
|
return str(path)
|