chore(jarca-ledger): finalize approved batch docs, dashboard, and CI smoke updates
This commit is contained in:
+561
-14
@@ -9,6 +9,7 @@ from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from .db import db, init_db, resolve_db_path
|
||||
from .kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
|
||||
@@ -18,6 +19,11 @@ from .seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_
|
||||
|
||||
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
|
||||
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
|
||||
VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"}
|
||||
VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"}
|
||||
RECONCILIATION_TARGET_STATUSES = {"manual_only", "partial_import"}
|
||||
BALANCE_RECONCILIATION_TOLERANCE_KRW = 1_000
|
||||
BALANCE_RECONCILIATION_REFERENCE_RATIO = 0.001
|
||||
VALID_CASHFLOW_KINDS = {"income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"}
|
||||
CASHFLOW_KIND_SQL = """COALESCE(lc.cashflow_kind, CASE
|
||||
WHEN t.category='수입' THEN 'income'
|
||||
@@ -33,6 +39,7 @@ END)"""
|
||||
CROSS_TRANSFER_MATCH_TAG = "cross_transfer_match:"
|
||||
CROSS_TRANSFER_REVIEW_TAG = "cross_transfer_review:"
|
||||
DEFAULT_LEDGER_BACKUP_DIR = Path.home() / ".local" / "share" / "jarca" / "backups" / "ledger"
|
||||
KST = ZoneInfo("Asia/Seoul")
|
||||
BACKUP_RESTORE_POLICY = {
|
||||
"mcp_restore_exposed": False,
|
||||
"restore_requires_explicit_user_approval": True,
|
||||
@@ -79,8 +86,20 @@ def _memo_has_tag(raw_memo: str, tag_prefix: str) -> bool:
|
||||
return tag_prefix in memo
|
||||
|
||||
|
||||
def _current_kst_datetime() -> datetime:
|
||||
return datetime.now(KST).replace(microsecond=0)
|
||||
|
||||
|
||||
def current_kst_date() -> date:
|
||||
return _current_kst_datetime().date()
|
||||
|
||||
|
||||
def current_kst_month() -> str:
|
||||
return current_kst_date().strftime("%Y-%m")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now().replace(microsecond=0).isoformat()
|
||||
return _current_kst_datetime().isoformat()
|
||||
|
||||
def new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid4().hex[:12]}"
|
||||
@@ -106,17 +125,51 @@ def row_to_dict(row: Any) -> dict[str, Any]:
|
||||
def normalize_date(value: str | None, default_today: bool = False) -> str:
|
||||
if not value:
|
||||
if default_today:
|
||||
return date.today().isoformat()
|
||||
return current_kst_date().isoformat()
|
||||
raise ValueError("date is required")
|
||||
return date.fromisoformat(value[:10]).isoformat()
|
||||
|
||||
def normalize_month(value: str | None) -> str:
|
||||
if not value:
|
||||
return date.today().strftime("%Y-%m")
|
||||
return current_kst_month()
|
||||
if re.fullmatch(r"\d{4}-\d{2}", value):
|
||||
return value
|
||||
return date.fromisoformat(value[:10]).strftime("%Y-%m")
|
||||
|
||||
|
||||
def normalize_statement_status(value: str | None) -> str:
|
||||
status = (value or "unknown").strip() or "unknown"
|
||||
if status not in VALID_STATEMENT_STATUSES:
|
||||
raise ValueError(f"statement_status must be one of {sorted(VALID_STATEMENT_STATUSES)}")
|
||||
return status
|
||||
|
||||
|
||||
def normalize_liquidity_bucket(value: str | None) -> str:
|
||||
bucket = (value or "other").strip() or "other"
|
||||
if bucket not in VALID_LIQUIDITY_BUCKETS:
|
||||
raise ValueError(f"liquidity_bucket must be one of {sorted(VALID_LIQUIDITY_BUCKETS)}")
|
||||
return bucket
|
||||
|
||||
|
||||
def normalize_snapshot_at(value: str | None, default_now: bool = True) -> str:
|
||||
if not value:
|
||||
if default_now:
|
||||
return now_iso()
|
||||
raise ValueError("baseline_at is required")
|
||||
raw = value.strip()
|
||||
if re.fullmatch(r"\d{4}-\d{2}", raw):
|
||||
month_start = date.fromisoformat(raw + "-01")
|
||||
if month_start.month == 12:
|
||||
next_month = date(month_start.year + 1, 1, 1)
|
||||
else:
|
||||
next_month = date(month_start.year, month_start.month + 1, 1)
|
||||
last_day = next_month - timedelta(days=1)
|
||||
return f"{last_day.isoformat()}T23:59:00+09:00"
|
||||
if "T" not in raw and len(raw) == 10:
|
||||
return f"{normalize_date(raw)}T23:59:00+09:00"
|
||||
datetime.fromisoformat(raw.replace("Z", "+00:00"))
|
||||
return raw
|
||||
|
||||
def normalize_tags(tags: Any) -> str:
|
||||
if tags is None or tags == "":
|
||||
return "[]"
|
||||
@@ -178,7 +231,7 @@ def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||
|
||||
|
||||
def _safe_monthly_aggregates(conn: sqlite3.Connection) -> dict[str, Any]:
|
||||
current_month = date.today().strftime("%Y-%m")
|
||||
current_month = current_kst_month()
|
||||
start, next_month = _month_range(current_month)
|
||||
rows = conn.execute(
|
||||
f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total, COUNT(*) AS count
|
||||
@@ -236,10 +289,13 @@ def _backup_file_metadata(path: Path, include_hash: bool = False) -> dict[str, A
|
||||
def _apply_backup_retention(backup_dir: Path, retention_days: int = 30, retention_count: int = 20) -> list[str]:
|
||||
backups = sorted(backup_dir.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True)
|
||||
keep_count = max(1, int(retention_count))
|
||||
cutoff = datetime.now() - timedelta(days=max(0, int(retention_days)))
|
||||
# Retention comparison needs sub-second precision for retention_days=0:
|
||||
# the timestamp used for backup names is second-truncated, but filesystem mtimes
|
||||
# can identify files created just before retention runs as older than "now".
|
||||
cutoff = datetime.now(KST) - timedelta(days=max(0, int(retention_days)))
|
||||
pruned: list[str] = []
|
||||
for idx, path in enumerate(backups):
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime)
|
||||
mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=KST)
|
||||
should_keep = idx < keep_count or mtime >= cutoff
|
||||
if should_keep:
|
||||
continue
|
||||
@@ -255,7 +311,7 @@ def create_ledger_backup(db_path: str | None = None, backup_dir: str | None = No
|
||||
return fail("not_found", f"ledger DB not found: {source_path}", db_path=str(source_path), exists=False)
|
||||
backup_root = _resolve_backup_dir(backup_dir)
|
||||
backup_root.mkdir(parents=True, exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f")
|
||||
timestamp = _current_kst_datetime().strftime("%Y%m%d%H%M%S%f")
|
||||
backup_path = backup_root / f"ledger_{timestamp}.sqlite3"
|
||||
try:
|
||||
# Use SQLite's online backup API so WAL-mode databases are copied consistently.
|
||||
@@ -294,15 +350,20 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_
|
||||
return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False)
|
||||
return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY)
|
||||
|
||||
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]:
|
||||
if not name.strip():
|
||||
return fail("validation_error", "account name is required")
|
||||
if type not in VALID_ACCOUNT_TYPES:
|
||||
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
|
||||
try:
|
||||
statement_status_norm = normalize_statement_status(statement_status)
|
||||
liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket)
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
ts = now_iso(); account_id = new_id("acct")
|
||||
with db(db_path) as conn:
|
||||
conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, ts, ts))
|
||||
conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts))
|
||||
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
return ok(account=row_to_dict(row))
|
||||
|
||||
@@ -312,6 +373,110 @@ def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) -
|
||||
rows = conn.execute(sql).fetchall()
|
||||
return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
|
||||
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Summarize assets/liabilities from account-level liquidity_bucket and last_balance only."""
|
||||
with db(db_path) as conn:
|
||||
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
|
||||
accounts = [row_to_dict(r) for r in rows]
|
||||
bucket_order = ["cash", "investment", "deposit", "real_estate", "other", "loan"]
|
||||
bucket_totals = {bucket: 0 for bucket in bucket_order}
|
||||
bucket_counts = {bucket: 0 for bucket in bucket_order}
|
||||
missing_balance_accounts: list[dict[str, Any]] = []
|
||||
invalid_bucket_accounts: list[dict[str, Any]] = []
|
||||
source_accounts: list[dict[str, Any]] = []
|
||||
|
||||
for account in accounts:
|
||||
raw_bucket = str(account.get("liquidity_bucket") or "other")
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(raw_bucket)
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
invalid_bucket_accounts.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
|
||||
|
||||
balance = account.get("last_balance")
|
||||
if balance is None:
|
||||
missing_balance_accounts.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": bucket})
|
||||
continue
|
||||
|
||||
amount = int(balance)
|
||||
bucket_counts[bucket] += 1
|
||||
bucket_totals[bucket] += amount
|
||||
source_accounts.append(
|
||||
{
|
||||
"id": account.get("id", ""),
|
||||
"name": account.get("name", ""),
|
||||
"type": account.get("type", ""),
|
||||
"liquidity_bucket": bucket,
|
||||
"last_balance": amount,
|
||||
"last_synced_at": account.get("last_synced_at", ""),
|
||||
"memo": account.get("memo", ""),
|
||||
}
|
||||
)
|
||||
|
||||
asset_buckets = ["cash", "investment", "deposit", "real_estate", "other"]
|
||||
total_assets = sum(bucket_totals[bucket] for bucket in asset_buckets)
|
||||
total_liabilities = abs(bucket_totals["loan"])
|
||||
net_worth = total_assets - total_liabilities
|
||||
buckets = []
|
||||
for bucket in bucket_order:
|
||||
amount = bucket_totals[bucket]
|
||||
denominator = total_liabilities if bucket == "loan" else total_assets
|
||||
buckets.append(
|
||||
{
|
||||
"liquidity_bucket": bucket,
|
||||
"amount": amount,
|
||||
"account_count": bucket_counts[bucket],
|
||||
"asset_share": round(amount / total_assets, 4) if bucket != "loan" and total_assets else None,
|
||||
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket == "loan" and total_liabilities else None,
|
||||
"share": round(abs(amount) / denominator, 4) if denominator else None,
|
||||
}
|
||||
)
|
||||
return ok(
|
||||
buckets=buckets,
|
||||
accounts=source_accounts,
|
||||
total_assets=total_assets,
|
||||
total_liabilities=total_liabilities,
|
||||
net_worth=net_worth,
|
||||
missing_balance_accounts=missing_balance_accounts,
|
||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||
source="accounts.last_balance",
|
||||
)
|
||||
|
||||
|
||||
def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
|
||||
allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"}
|
||||
updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
for k, v in fields.items():
|
||||
if k not in allowed or v is None:
|
||||
continue
|
||||
try:
|
||||
if k == "type" and v not in VALID_ACCOUNT_TYPES:
|
||||
return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}")
|
||||
if k == "statement_status":
|
||||
v = normalize_statement_status(str(v))
|
||||
if k == "liquidity_bucket":
|
||||
v = normalize_liquidity_bucket(str(v))
|
||||
if k == "active":
|
||||
v = int(bool(v))
|
||||
if k == "last_balance" and v != "":
|
||||
v = int(v)
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
updates.append(f"{k}=?")
|
||||
params.append(v)
|
||||
if not updates:
|
||||
return fail("validation_error", "no fields to update")
|
||||
updates.append("updated_at=?")
|
||||
params.extend([now_iso(), account_id])
|
||||
with db(db_path) as conn:
|
||||
cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params)
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "account not found")
|
||||
row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
return ok(account=row_to_dict(row))
|
||||
|
||||
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int | float = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: Any = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True, transaction_datetime: str = "", counterparty: str = "", balance_after: int | None = None, branch: str = "", raw_summary: str = "", raw_payload: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
tx_date = normalize_date(transaction_date, default_today=True)
|
||||
@@ -355,6 +520,97 @@ def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
|
||||
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
|
||||
if tx_type in {"income", "refund", "adjustment"}:
|
||||
return int(amount)
|
||||
if tx_type in {"expense", "transfer"}:
|
||||
return -int(amount)
|
||||
return 0
|
||||
|
||||
|
||||
def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]:
|
||||
date_cutoff = normalize_date(baseline_at[:10])
|
||||
rows = conn.execute(
|
||||
"""SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at
|
||||
FROM transactions
|
||||
WHERE status='active' AND account_id=? AND transaction_date<=?
|
||||
ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""",
|
||||
(account_id, date_cutoff),
|
||||
).fetchall()
|
||||
signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows)
|
||||
latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None)
|
||||
if latest_balance_row is not None:
|
||||
calculated_balance = int(latest_balance_row["balance_after"])
|
||||
basis = "latest_transaction_balance_after"
|
||||
basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"]
|
||||
else:
|
||||
calculated_balance = int(signed_total)
|
||||
basis = "net_transaction_delta_without_opening_balance"
|
||||
basis_at = baseline_at
|
||||
return {
|
||||
"calculated_balance": calculated_balance,
|
||||
"calculation_basis": basis,
|
||||
"calculation_basis_at": basis_at,
|
||||
"transaction_count": len(rows),
|
||||
"signed_transaction_total": int(signed_total),
|
||||
}
|
||||
|
||||
|
||||
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Preview manual/partial-import account balance reconciliation without mutating transactions."""
|
||||
try:
|
||||
baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None
|
||||
tolerance_int = int(tolerance)
|
||||
if tolerance_int < 0:
|
||||
raise ValueError("tolerance must be non-negative")
|
||||
baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
clauses = ["active=1"]
|
||||
params: list[Any] = []
|
||||
if account_id:
|
||||
clauses.append("id=?")
|
||||
params.append(account_id)
|
||||
else:
|
||||
placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES)
|
||||
clauses.append(f"statement_status IN ({placeholders})")
|
||||
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
|
||||
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
|
||||
items: list[dict[str, Any]] = []
|
||||
for account in accounts:
|
||||
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
|
||||
effective_at = baseline_at_norm or account["last_synced_at"] or now_iso()
|
||||
calc = _account_balance_row(conn, account["id"], effective_at)
|
||||
if effective_baseline is None:
|
||||
difference = None
|
||||
ratio = None
|
||||
status = "BASELINE_REQUIRED"
|
||||
else:
|
||||
difference = int(effective_baseline) - int(calc["calculated_balance"])
|
||||
ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None
|
||||
status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED"
|
||||
items.append({
|
||||
"account_id": account["id"],
|
||||
"account_name": account["name"],
|
||||
"statement_status": account["statement_status"],
|
||||
"is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES,
|
||||
"baseline_balance": effective_baseline,
|
||||
"baseline_at": effective_at,
|
||||
"calculated_balance": calc["calculated_balance"],
|
||||
"difference": difference,
|
||||
"tolerance": tolerance_int,
|
||||
"reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO,
|
||||
"difference_ratio": ratio,
|
||||
"status": status,
|
||||
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
|
||||
"source_note": source_note,
|
||||
"memo": memo,
|
||||
**calc,
|
||||
})
|
||||
return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required")
|
||||
|
||||
def update_ledger_transaction(transaction_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]:
|
||||
allowed = {"transaction_date","posted_date","type","amount","currency","category","subcategory","merchant","description","account_id","payment_method","person","project","tags","is_fixed","source","external_id","memo","status","transaction_datetime","counterparty","balance_after","branch","raw_summary","raw_payload"}
|
||||
updates=[]; params=[]
|
||||
@@ -1057,8 +1313,9 @@ def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path:
|
||||
source_date = normalize_date(source.get("transaction_date"), default_today=False)
|
||||
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
|
||||
source_holder = _normalize_counterparty_for_match(source.get("holder_name", ""))
|
||||
window_start = (date.fromisoformat(source_date) if source_date else date.today()).toordinal() - 1
|
||||
window_end = (date.fromisoformat(source_date) if source_date else date.today()).toordinal() + 1
|
||||
anchor_date = date.fromisoformat(source_date) if source_date else current_kst_date()
|
||||
window_start = anchor_date.toordinal() - 1
|
||||
window_end = anchor_date.toordinal() + 1
|
||||
candidate_rows = conn.execute(
|
||||
"""
|
||||
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
||||
@@ -2228,6 +2485,96 @@ def preview_categorization(limit: int = 1000, only_uncategorized: bool = True, d
|
||||
)
|
||||
|
||||
|
||||
|
||||
def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
|
||||
if not rule_id:
|
||||
return fail("validation_error", "rule_id is required")
|
||||
row_limit = max(1, min(int(limit), 50000))
|
||||
with db(db_path) as conn:
|
||||
rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone()
|
||||
if not rule_row:
|
||||
return fail("not_found", f"category rule not found: {rule_id}")
|
||||
rule = row_to_dict(rule_row)
|
||||
rows = [row_to_dict(r) for r in conn.execute(
|
||||
"""SELECT * FROM transactions
|
||||
WHERE status='active'
|
||||
ORDER BY transaction_datetime DESC, transaction_date DESC, created_at DESC
|
||||
LIMIT ?""",
|
||||
(row_limit,),
|
||||
).fetchall()]
|
||||
|
||||
matched = []
|
||||
uncategorized = []
|
||||
for tx in rows:
|
||||
if not _rule_matches(rule, tx):
|
||||
continue
|
||||
item = {
|
||||
"transaction_id": tx["id"],
|
||||
"transaction_date": tx.get("transaction_date", ""),
|
||||
"transaction_datetime": tx.get("transaction_datetime", ""),
|
||||
"type": tx.get("type", ""),
|
||||
"amount": tx.get("amount"),
|
||||
"merchant": tx.get("merchant", ""),
|
||||
"raw_summary": tx.get("raw_summary", ""),
|
||||
"counterparty": tx.get("counterparty", ""),
|
||||
"current_category": tx.get("category", "") or "",
|
||||
"current_subcategory": tx.get("subcategory", "") or "",
|
||||
"new_category": rule.get("category", ""),
|
||||
"new_subcategory": rule.get("subcategory", ""),
|
||||
"rule_id": rule_id,
|
||||
"rule_pattern": rule.get("pattern", ""),
|
||||
}
|
||||
matched.append(item)
|
||||
if not (tx.get("category") or ""):
|
||||
uncategorized.append(item)
|
||||
return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized)
|
||||
|
||||
|
||||
def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]:
|
||||
data = _category_rule_match_rows(rule_id, limit, db_path)
|
||||
if not data.get("success"):
|
||||
return data
|
||||
sample_n = max(1, min(int(sample_limit), 20))
|
||||
return ok(
|
||||
rule=data["rule"],
|
||||
scanned=data["scanned"],
|
||||
matched_count=len(data["matched"]),
|
||||
uncategorized_matched_count=len(data["uncategorized"]),
|
||||
sample=data["matched"][:sample_n],
|
||||
)
|
||||
|
||||
|
||||
def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]:
|
||||
data = _category_rule_match_rows(rule_id, limit, db_path)
|
||||
if not data.get("success"):
|
||||
return data
|
||||
rule = data["rule"]
|
||||
ts = now_iso()
|
||||
applied = 0
|
||||
with db(db_path) as conn:
|
||||
for m in data["uncategorized"]:
|
||||
conn.execute(
|
||||
"""UPDATE transactions
|
||||
SET category=?, subcategory=?,
|
||||
person=CASE WHEN ?<>'' THEN ? ELSE person END,
|
||||
is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END,
|
||||
updated_at=?
|
||||
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
|
||||
(
|
||||
rule.get("category", ""), rule.get("subcategory", ""),
|
||||
rule.get("person", ""), rule.get("person", ""),
|
||||
rule.get("is_fixed"), rule.get("is_fixed"),
|
||||
ts, m["transaction_id"],
|
||||
),
|
||||
)
|
||||
applied += 1
|
||||
return ok(
|
||||
applied_count=applied,
|
||||
matched_count=len(data["matched"]),
|
||||
uncategorized_matched_count=len(data["uncategorized"]),
|
||||
rule=rule,
|
||||
)
|
||||
|
||||
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
data = _categorization_preview_data(limit, only_uncategorized, db_path)
|
||||
preview = ok(
|
||||
@@ -2249,18 +2596,218 @@ def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = Tru
|
||||
|
||||
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND category='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
|
||||
rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall()
|
||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
|
||||
def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount
|
||||
FROM transactions WHERE status='active' AND category=''
|
||||
FROM transactions WHERE status='active' AND COALESCE(category,'')=''
|
||||
GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall()
|
||||
return ok(items=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
|
||||
def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]:
|
||||
"""Recommend a category for one transaction using deterministic, explainable signals."""
|
||||
for rule in rules:
|
||||
try:
|
||||
if _rule_matches(rule, tx):
|
||||
return {
|
||||
"category": rule.get("category", ""),
|
||||
"subcategory": rule.get("subcategory", ""),
|
||||
"confidence": round(float(rule.get("confidence") or 1.0), 2),
|
||||
"source": "rule",
|
||||
"reason": f"활성 규칙 `{rule.get('pattern', '')}` 매칭",
|
||||
"rule_id": rule.get("id", ""),
|
||||
}
|
||||
except re.error:
|
||||
continue
|
||||
|
||||
key = (tx.get("merchant", "") or "", tx.get("type", "") or "")
|
||||
candidates = history.get(key, [])
|
||||
if candidates:
|
||||
best = candidates[0]
|
||||
count = int(best.get("count") or 0)
|
||||
confidence = min(0.9, 0.55 + min(count, 5) * 0.07)
|
||||
return {
|
||||
"category": best.get("category", ""),
|
||||
"subcategory": best.get("subcategory", ""),
|
||||
"confidence": round(confidence, 2),
|
||||
"source": "history",
|
||||
"reason": f"같은 사용처의 기존 분류 {count}건",
|
||||
"rule_id": "",
|
||||
}
|
||||
|
||||
return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "추천 근거 없음", "rule_id": ""}
|
||||
|
||||
|
||||
def get_uncategorized_workbench(
|
||||
limit: int = 100,
|
||||
min_amount: int = 0,
|
||||
sort_by: str = "amount_desc",
|
||||
db_path: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row_limit = max(1, min(int(limit), 1000))
|
||||
minimum_amount = int(min_amount)
|
||||
order_by_map = {
|
||||
"amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC",
|
||||
"date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC",
|
||||
}
|
||||
order_by = order_by_map.get(sort_by, order_by_map["amount_desc"])
|
||||
with db(db_path) as conn:
|
||||
tx_rows = [row_to_dict(r) for r in conn.execute(
|
||||
f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary,
|
||||
counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?
|
||||
ORDER BY {order_by}
|
||||
LIMIT ?""",
|
||||
(minimum_amount, row_limit),
|
||||
).fetchall()]
|
||||
total_row = conn.execute(
|
||||
"""SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount,
|
||||
COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""",
|
||||
(minimum_amount,),
|
||||
).fetchone()
|
||||
history_rows = [row_to_dict(r) for r in conn.execute(
|
||||
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count,
|
||||
SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
||||
GROUP BY merchant, type, category, subcategory
|
||||
ORDER BY merchant, type, count DESC, total_amount DESC"""
|
||||
).fetchall()]
|
||||
rules = [row_to_dict(r) for r in conn.execute(
|
||||
"SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id"
|
||||
).fetchall()]
|
||||
|
||||
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for row in history_rows:
|
||||
history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row)
|
||||
|
||||
txs: list[dict[str, Any]] = []
|
||||
groups: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||
for tx in tx_rows:
|
||||
rec = _recommend_category_for_transaction(tx, rules, history)
|
||||
public_tx = {
|
||||
**tx,
|
||||
"recommendation": rec,
|
||||
"recommended_category": rec.get("category", ""),
|
||||
"recommended_subcategory": rec.get("subcategory", ""),
|
||||
"recommendation_confidence": rec.get("confidence", 0.0),
|
||||
"recommendation_reason": rec.get("reason", ""),
|
||||
"recommendation_source": rec.get("source", "none"),
|
||||
}
|
||||
txs.append(public_tx)
|
||||
gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "")
|
||||
group = groups.setdefault(gkey, {
|
||||
"merchant": gkey[0],
|
||||
"raw_summary": gkey[1],
|
||||
"type": gkey[2],
|
||||
"count": 0,
|
||||
"total_amount": 0,
|
||||
"max_amount": 0,
|
||||
"latest_date": "",
|
||||
"sample_transaction_id": tx.get("id", ""),
|
||||
"recommendation": rec,
|
||||
})
|
||||
group["count"] += 1
|
||||
group["total_amount"] += int(tx.get("amount") or 0)
|
||||
group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0))
|
||||
date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
|
||||
if date_value > (group.get("latest_date") or ""):
|
||||
group["latest_date"] = date_value
|
||||
if not group["recommendation"].get("category") and rec.get("category"):
|
||||
group["recommendation"] = rec
|
||||
|
||||
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
|
||||
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
|
||||
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
|
||||
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
|
||||
return ok(
|
||||
summary={
|
||||
"uncategorized_count": int(total.get("count") or 0),
|
||||
"uncategorized_total_amount": int(total.get("total_amount") or 0),
|
||||
"merchant_group_count": int(total.get("merchant_group_count") or 0),
|
||||
"recommendable_count": recommendable_count,
|
||||
"repeated_group_count": repeated_group_count,
|
||||
},
|
||||
groups=group_items,
|
||||
transactions=txs,
|
||||
count=len(txs),
|
||||
)
|
||||
|
||||
|
||||
def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
if not transaction_id:
|
||||
return fail("validation_error", "transaction_id is required")
|
||||
with db(db_path) as conn:
|
||||
row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone()
|
||||
if not row:
|
||||
return fail("not_found", "transaction not found")
|
||||
tx = row_to_dict(row)
|
||||
if tx.get("category"):
|
||||
return fail("validation_error", "transaction is already categorized")
|
||||
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
||||
history_rows = [row_to_dict(r) for r in conn.execute(
|
||||
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
||||
GROUP BY merchant, type, category, subcategory
|
||||
ORDER BY count DESC, total_amount DESC"""
|
||||
).fetchall()]
|
||||
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for h in history_rows:
|
||||
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
||||
rec = _recommend_category_for_transaction(tx, rules, history)
|
||||
if not rec.get("category"):
|
||||
return fail("no_recommendation", "추천 분류가 없습니다")
|
||||
cur = conn.execute(
|
||||
"""UPDATE transactions
|
||||
SET category=?, subcategory=?, updated_at=?
|
||||
WHERE id=? AND status='active' AND COALESCE(category,'')=''""",
|
||||
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id),
|
||||
)
|
||||
return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec)
|
||||
|
||||
|
||||
def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
sample = conn.execute(
|
||||
"""SELECT * FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?
|
||||
ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""",
|
||||
(merchant, raw_summary, transaction_type),
|
||||
).fetchone()
|
||||
if not sample:
|
||||
return fail("not_found", "uncategorized group not found")
|
||||
tx = row_to_dict(sample)
|
||||
rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()]
|
||||
history_rows = [row_to_dict(r) for r in conn.execute(
|
||||
"""SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount
|
||||
FROM transactions
|
||||
WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>''
|
||||
GROUP BY merchant, type, category, subcategory
|
||||
ORDER BY count DESC, total_amount DESC"""
|
||||
).fetchall()]
|
||||
history: dict[tuple[str, str], list[dict[str, Any]]] = {}
|
||||
for h in history_rows:
|
||||
history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h)
|
||||
rec = _recommend_category_for_transaction(tx, rules, history)
|
||||
if not rec.get("category"):
|
||||
return fail("no_recommendation", "추천 분류가 없습니다")
|
||||
cur = conn.execute(
|
||||
"""UPDATE transactions
|
||||
SET category=?, subcategory=?, updated_at=?
|
||||
WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""",
|
||||
(rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type),
|
||||
)
|
||||
return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec)
|
||||
|
||||
|
||||
DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [
|
||||
{"category": "수입", "subcategory": "급여", "sort_order": 10},
|
||||
{"category": "수입", "subcategory": "이자", "sort_order": 11},
|
||||
|
||||
Reference in New Issue
Block a user