Refactor import and workflow services plus adapter seams
This commit is contained in:
@@ -4,7 +4,7 @@ from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
from ... import service
|
||||
from . import tool_contracts as contracts
|
||||
|
||||
load_dotenv(Path(__file__).resolve().parents[3] / ".env")
|
||||
mcp = FastMCP("jarca-ledger-mcp")
|
||||
@@ -12,282 +12,282 @@ mcp = FastMCP("jarca-ledger-mcp")
|
||||
@mcp.tool()
|
||||
def ledger_health_check() -> dict:
|
||||
"""Verify the Jarca Ledger MCP server and SQLite database are reachable."""
|
||||
return service.health_check()
|
||||
return contracts.health_check()
|
||||
|
||||
@mcp.tool()
|
||||
def get_ledger_db_status(include_hash: bool = True, include_integrity: bool = True) -> dict:
|
||||
"""Return non-sensitive DB status evidence: path, size, mtime, checksum, table row counts, and aggregate totals."""
|
||||
return service.get_ledger_db_status(include_hash=include_hash, include_integrity=include_integrity)
|
||||
return contracts.get_ledger_db_status(include_hash=include_hash, include_integrity=include_integrity)
|
||||
|
||||
@mcp.tool()
|
||||
def create_ledger_backup(backup_dir: str = "", retention_days: int = 30, retention_count: int = 20) -> dict:
|
||||
"""Create a timestamped ledger DB backup. Restore is intentionally not exposed as an MCP tool."""
|
||||
return service.create_ledger_backup(backup_dir=backup_dir or None, retention_days=retention_days, retention_count=retention_count)
|
||||
return contracts.create_ledger_backup(backup_dir=backup_dir or None, retention_days=retention_days, retention_count=retention_count)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_backups(backup_dir: str = "", include_hash: bool = False, limit: int = 50) -> dict:
|
||||
"""List ledger DB backups with metadata-only evidence."""
|
||||
return service.list_ledger_backups(backup_dir=backup_dir or None, include_hash=include_hash, limit=limit)
|
||||
return contracts.list_ledger_backups(backup_dir=backup_dir or None, include_hash=include_hash, limit=limit)
|
||||
|
||||
@mcp.tool()
|
||||
def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_integrity: bool = True) -> dict:
|
||||
"""Return metadata-only status and integrity evidence for one backup file."""
|
||||
return service.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
|
||||
return contracts.get_ledger_backup_status(file_path, include_hash=include_hash, include_integrity=include_integrity)
|
||||
|
||||
@mcp.tool()
|
||||
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") -> dict:
|
||||
"""Create an account/card/cash/investment/loan ledger account."""
|
||||
return service.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket)
|
||||
return contracts.create_ledger_account(name, type, institution, currency, active, memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status, liquidity_bucket)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_accounts(active_only: bool = True) -> dict:
|
||||
"""List ledger accounts and cards."""
|
||||
return service.list_ledger_accounts(active_only)
|
||||
return contracts.list_ledger_accounts(active_only)
|
||||
|
||||
@mcp.tool()
|
||||
def update_ledger_account(account_id: str, name: str | None = None, type: str | None = None, institution: str | None = None, currency: str | None = None, active: bool | None = None, memo: str | None = None, account_number_masked: str | None = None, holder_name: str | None = None, last_balance: int | None = None, last_synced_at: str | None = None, statement_status: str | None = None, liquidity_bucket: str | None = None) -> dict:
|
||||
"""Update selected account fields, including statement_status/liquidity_bucket for manual reconciliation."""
|
||||
return service.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket)
|
||||
return contracts.update_ledger_account(account_id, name=name, type=type, institution=institution, currency=currency, active=active, memo=memo, account_number_masked=account_number_masked, holder_name=holder_name, last_balance=last_balance, last_synced_at=last_synced_at, statement_status=statement_status, liquidity_bucket=liquidity_bucket)
|
||||
|
||||
@mcp.tool()
|
||||
def get_asset_liquidity_report() -> dict:
|
||||
"""Return asset liquidity, debt, and net worth from shared account/manual valuation sources."""
|
||||
return service.get_asset_liquidity_report()
|
||||
return contracts.get_asset_liquidity_report()
|
||||
|
||||
@mcp.tool()
|
||||
def list_manual_assets(active_only: bool = True) -> dict:
|
||||
"""List manual non-transaction assets/liabilities used for net-worth valuation."""
|
||||
return service.list_manual_assets(active_only=active_only)
|
||||
return contracts.list_manual_assets(active_only=active_only)
|
||||
|
||||
@mcp.tool()
|
||||
def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict:
|
||||
"""Create a manual non-transaction asset/liability valuation row."""
|
||||
return service.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
|
||||
return contracts.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
|
||||
|
||||
@mcp.tool()
|
||||
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None) -> dict:
|
||||
"""Update selected fields on a manual asset/liability valuation row."""
|
||||
return service.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
|
||||
return contracts.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active)
|
||||
|
||||
@mcp.tool()
|
||||
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict:
|
||||
"""Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup."""
|
||||
return service.deactivate_manual_asset(asset_id, hard_delete=hard_delete)
|
||||
return contracts.deactivate_manual_asset(asset_id, hard_delete=hard_delete)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_asset_snapshot_close(basis_date: str = "") -> dict:
|
||||
"""Preview a month-end asset snapshot close without mutating the database."""
|
||||
return service.preview_asset_snapshot_close(basis_date=basis_date)
|
||||
return contracts.preview_asset_snapshot_close(basis_date=basis_date)
|
||||
|
||||
@mcp.tool()
|
||||
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict:
|
||||
"""Close an asset snapshot for a basis date; same-date re-close appends correction rows."""
|
||||
return service.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason)
|
||||
return contracts.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason)
|
||||
|
||||
@mcp.tool()
|
||||
def get_asset_snapshot_report(basis_date: str = "") -> dict:
|
||||
"""Return basis-date asset snapshot/net-worth report with live-authority fallback."""
|
||||
return service.get_asset_snapshot_report(basis_date=basis_date)
|
||||
return contracts.get_asset_snapshot_report(basis_date=basis_date)
|
||||
|
||||
@mcp.tool()
|
||||
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12) -> dict:
|
||||
"""Return net-worth timeseries from stored asset snapshot rows."""
|
||||
return service.get_net_worth_timeseries(start_date=start_date, end_date=end_date, months=months)
|
||||
return contracts.get_net_worth_timeseries(start_date=start_date, end_date=end_date, months=months)
|
||||
|
||||
@mcp.tool()
|
||||
def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = 1000) -> dict:
|
||||
"""Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB."""
|
||||
return service.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance)
|
||||
return contracts.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance)
|
||||
|
||||
@mcp.tool()
|
||||
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict:
|
||||
"""Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction."""
|
||||
return service.create_ledger_transaction(transaction_date, type, amount, category, subcategory, merchant, description, account_id, payment_method, person, project, tags, is_fixed, currency, posted_date, source, external_id, memo, dedupe)
|
||||
return contracts.create_ledger_transaction(transaction_date, type, amount, category, subcategory, merchant, description, account_id, payment_method, person, project, tags, is_fixed, currency, posted_date, source, external_id, memo, dedupe)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str = "", category: str = "", person: str = "", account_id: str = "", include_deleted: bool = False, limit: int = 100) -> dict:
|
||||
"""List ledger transactions with date/category/person/account filters."""
|
||||
return service.list_ledger_transactions(start_date, end_date, type, category, person, account_id, include_deleted, limit)
|
||||
return contracts.list_ledger_transactions(start_date, end_date, type, category, person, account_id, include_deleted, limit)
|
||||
|
||||
@mcp.tool()
|
||||
def update_ledger_transaction(transaction_id: str, transaction_date: str | None = None, type: str | None = None, amount: int | None = None, category: str | None = None, subcategory: str | None = None, merchant: str | None = None, description: str | None = None, account_id: str | None = None, payment_method: str | None = None, person: str | None = None, project: str | None = None, tags: list[str] | str | None = None, is_fixed: bool | None = None, memo: str | None = None, status: str | None = None) -> dict:
|
||||
"""Update selected fields on a ledger transaction."""
|
||||
return service.update_ledger_transaction(transaction_id, transaction_date=transaction_date, type=type, amount=amount, category=category, subcategory=subcategory, merchant=merchant, description=description, account_id=account_id, payment_method=payment_method, person=person, project=project, tags=tags, is_fixed=is_fixed, memo=memo, status=status)
|
||||
return contracts.update_ledger_transaction(transaction_id, transaction_date=transaction_date, type=type, amount=amount, category=category, subcategory=subcategory, merchant=merchant, description=description, account_id=account_id, payment_method=payment_method, person=person, project=project, tags=tags, is_fixed=is_fixed, memo=memo, status=status)
|
||||
|
||||
@mcp.tool()
|
||||
def delete_ledger_transaction(transaction_id: str, hard_delete: bool = False) -> dict:
|
||||
"""Soft-delete a ledger transaction by default, or permanently delete with hard_delete=true."""
|
||||
return service.delete_ledger_transaction(transaction_id, hard_delete)
|
||||
return contracts.delete_ledger_transaction(transaction_id, hard_delete)
|
||||
|
||||
@mcp.tool()
|
||||
def create_ledger_budget(month: str, category: str, amount: int, subcategory: str = "", person: str = "", currency: str = "KRW", memo: str = "") -> dict:
|
||||
"""Create or update a monthly category/person budget."""
|
||||
return service.create_ledger_budget(month, category, amount, subcategory, person, currency, memo)
|
||||
return contracts.create_ledger_budget(month, category, amount, subcategory, person, currency, memo)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_budgets(month: str = "") -> dict:
|
||||
"""List ledger budgets, optionally for a month YYYY-MM."""
|
||||
return service.list_ledger_budgets(month)
|
||||
return contracts.list_ledger_budgets(month)
|
||||
|
||||
@mcp.tool()
|
||||
def get_monthly_cashflow_report(month: str = "") -> dict:
|
||||
"""Return income, expenses, net cashflow, and savings rate for a month."""
|
||||
return service.get_monthly_cashflow_report(month)
|
||||
return contracts.get_monthly_cashflow_report(month)
|
||||
|
||||
@mcp.tool()
|
||||
def get_category_spending_report(month: str = "", start_date: str = "", end_date: str = "", person: str = "") -> dict:
|
||||
"""Return category/subcategory spending for a month or date range."""
|
||||
return service.get_category_spending_report(month, start_date, end_date, person)
|
||||
return contracts.get_category_spending_report(month, start_date, end_date, person)
|
||||
|
||||
@mcp.tool()
|
||||
def get_fixed_cost_report(month: str = "") -> dict:
|
||||
"""Return fixed expense transactions and total fixed cost for a month."""
|
||||
return service.get_fixed_cost_report(month)
|
||||
return contracts.get_fixed_cost_report(month)
|
||||
|
||||
@mcp.tool()
|
||||
def get_budget_vs_actual_report(month: str = "") -> dict:
|
||||
"""Compare monthly budgets against actual spending."""
|
||||
return service.get_budget_vs_actual_report(month)
|
||||
return contracts.get_budget_vs_actual_report(month)
|
||||
|
||||
@mcp.tool()
|
||||
def get_investable_cashflow_report(month: str = "", minimum_cash_buffer: int = 0, planned_extra_expense: int = 0) -> dict:
|
||||
"""Estimate cashflow available for investing after buffer and planned extra expenses."""
|
||||
return service.get_investable_cashflow_report(month, minimum_cash_buffer, planned_extra_expense)
|
||||
return contracts.get_investable_cashflow_report(month, minimum_cash_buffer, planned_extra_expense)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_ledger_csv_import(file_path: str, source: str = "generic") -> dict:
|
||||
"""Preview a UTF-8/UTF-8-BOM CSV import before saving transactions."""
|
||||
return service.preview_ledger_csv_import(file_path, source)
|
||||
return contracts.preview_ledger_csv_import(file_path, source)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20) -> dict:
|
||||
"""Preview transactions from a password-protected KB Kookmin Bank account-history PDF without saving them."""
|
||||
return service.preview_kb_bank_pdf_import(file_path, password, preview_limit)
|
||||
return contracts.preview_kb_bank_pdf_import(file_path, password, preview_limit)
|
||||
|
||||
@mcp.tool()
|
||||
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, skip_validation: bool = False) -> dict:
|
||||
"""Import transactions from a KB Kookmin Bank account-history PDF into the ledger database."""
|
||||
return service.import_kb_bank_pdf(file_path, password, account_id, skip_duplicates, skip_validation=skip_validation)
|
||||
return contracts.import_kb_bank_pdf(file_path, password, account_id, skip_duplicates, skip_validation=skip_validation)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20) -> dict:
|
||||
"""Preview transactions from a SeoulPay usage-history XLSX before saving them."""
|
||||
return service.preview_seoulpay_xlsx_import(file_path, password, preview_limit)
|
||||
return contracts.preview_seoulpay_xlsx_import(file_path, password, preview_limit)
|
||||
|
||||
@mcp.tool()
|
||||
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True) -> dict:
|
||||
"""Import transactions from a SeoulPay usage-history XLSX into the ledger database."""
|
||||
return service.import_seoulpay_xlsx(file_path, password, account_id, skip_duplicates)
|
||||
return contracts.import_seoulpay_xlsx(file_path, password, account_id, skip_duplicates)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
|
||||
"""Preview transactions from a Toss Securities transaction-history PDF without saving them."""
|
||||
return service.preview_toss_securities_pdf_import(file_path, preview_limit)
|
||||
return contracts.preview_toss_securities_pdf_import(file_path, preview_limit)
|
||||
|
||||
@mcp.tool()
|
||||
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
|
||||
"""Import transactions from a Toss Securities transaction-history PDF into the ledger database."""
|
||||
return service.import_toss_securities_pdf(file_path, account_id, skip_duplicates)
|
||||
return contracts.import_toss_securities_pdf(file_path, account_id, skip_duplicates)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20) -> dict:
|
||||
"""Preview transactions from a Toss Bank transaction-history PDF without saving them."""
|
||||
return service.preview_toss_bank_pdf_import(file_path, preview_limit)
|
||||
return contracts.preview_toss_bank_pdf_import(file_path, preview_limit)
|
||||
|
||||
@mcp.tool()
|
||||
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True) -> dict:
|
||||
"""Import transactions from a Toss Bank transaction-history PDF into the ledger database."""
|
||||
return service.import_toss_bank_pdf(file_path, account_id, skip_duplicates)
|
||||
return contracts.import_toss_bank_pdf(file_path, account_id, skip_duplicates)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50) -> dict:
|
||||
"""List CSV/PDF/API import batches."""
|
||||
return service.list_ledger_import_batches(source, include_rolled_back, limit)
|
||||
return contracts.list_ledger_import_batches(source, include_rolled_back, limit)
|
||||
|
||||
@mcp.tool()
|
||||
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False) -> dict:
|
||||
"""Roll back all transactions created by one import batch. Soft-deletes by default."""
|
||||
return service.rollback_ledger_import_batch(import_batch_id, hard_delete)
|
||||
return contracts.rollback_ledger_import_batch(import_batch_id, hard_delete)
|
||||
|
||||
@mcp.tool()
|
||||
def create_category_rule(match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None) -> dict:
|
||||
"""Create or update a ledger transaction categorization rule."""
|
||||
return service.create_category_rule(match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month)
|
||||
return contracts.create_category_rule(match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month)
|
||||
|
||||
@mcp.tool()
|
||||
def seed_default_category_rules(overwrite: bool = False) -> dict:
|
||||
"""Seed default Jarca category rules for common Korean household ledger patterns."""
|
||||
return service.seed_default_category_rules(overwrite)
|
||||
return contracts.seed_default_category_rules(overwrite)
|
||||
|
||||
@mcp.tool()
|
||||
def list_category_rules(active_only: bool = True) -> dict:
|
||||
"""List transaction categorization rules."""
|
||||
return service.list_category_rules(active_only)
|
||||
return contracts.list_category_rules(active_only)
|
||||
|
||||
@mcp.tool()
|
||||
def update_category_rule(rule_id: str, match_field: str, pattern: str, category: str, subcategory: str = "", transaction_type: str = "", match_type: str = "contains", priority: int = 100, person: str = "", is_fixed: bool | None = None, confidence: float = 1.0, memo: str = "", amount_min: int | None = None, amount_max: int | None = None, day_of_month: int | None = None, active: bool = True) -> dict:
|
||||
"""Update an existing ledger transaction categorization rule."""
|
||||
return service.update_category_rule(rule_id, match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month, active)
|
||||
return contracts.update_category_rule(rule_id, match_field, pattern, category, subcategory, transaction_type, match_type, priority, person, is_fixed, confidence, memo, amount_min, amount_max, day_of_month, active)
|
||||
|
||||
@mcp.tool()
|
||||
def delete_category_rule(rule_id: str) -> dict:
|
||||
"""Delete a transaction categorization rule."""
|
||||
return service.delete_category_rule(rule_id)
|
||||
return contracts.delete_category_rule(rule_id)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_categorization(limit: int = 1000, only_uncategorized: bool = True) -> dict:
|
||||
"""Preview category-rule matches before writing categories to transactions."""
|
||||
return service.preview_categorization(limit, only_uncategorized)
|
||||
return contracts.preview_categorization(limit, only_uncategorized)
|
||||
|
||||
@mcp.tool()
|
||||
def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True) -> dict:
|
||||
"""Apply active category rules to matching ledger transactions."""
|
||||
return service.apply_categorization_rules(limit, only_uncategorized)
|
||||
return contracts.apply_categorization_rules(limit, only_uncategorized)
|
||||
|
||||
@mcp.tool()
|
||||
def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0) -> dict:
|
||||
"""List active transactions that still have no category."""
|
||||
return service.list_uncategorized_transactions(limit, min_amount)
|
||||
return contracts.list_uncategorized_transactions(limit, min_amount)
|
||||
|
||||
@mcp.tool()
|
||||
def get_uncategorized_merchant_summary(limit: int = 50) -> dict:
|
||||
"""Summarize uncategorized transactions by merchant/raw_summary/type for user review."""
|
||||
return service.get_uncategorized_merchant_summary(limit)
|
||||
return contracts.get_uncategorized_merchant_summary(limit)
|
||||
|
||||
@mcp.tool()
|
||||
def create_ledger_category(category: str, subcategory: str = "", sort_order: int = 100, memo: str = "", cashflow_kind: str = "") -> dict:
|
||||
"""Create or reactivate a ledger category/subcategory used by the dashboard and transactions."""
|
||||
return service.create_ledger_category(category, subcategory, sort_order, memo, cashflow_kind)
|
||||
return contracts.create_ledger_category(category, subcategory, sort_order, memo, cashflow_kind)
|
||||
|
||||
@mcp.tool()
|
||||
def seed_default_ledger_categories(overwrite: bool = False) -> dict:
|
||||
"""Seed default ledger categories and subcategories."""
|
||||
return service.seed_default_ledger_categories(overwrite)
|
||||
return contracts.seed_default_ledger_categories(overwrite)
|
||||
|
||||
@mcp.tool()
|
||||
def list_ledger_categories(active_only: bool = True, include_usage: bool = True) -> dict:
|
||||
"""List ledger categories and subcategories, optionally with usage counts."""
|
||||
return service.list_ledger_categories(active_only, include_usage)
|
||||
return contracts.list_ledger_categories(active_only, include_usage)
|
||||
|
||||
@mcp.tool()
|
||||
def list_cross_transfer_review_queue(limit: int = 300) -> dict:
|
||||
"""List KB↔토스 계좌 이체 확인필요 큐 항목."""
|
||||
return service.list_cross_transfer_review_queue(limit)
|
||||
return contracts.list_cross_transfer_review_queue(limit)
|
||||
|
||||
@mcp.tool()
|
||||
def preview_cross_account_transfers(limit: int = 500) -> dict:
|
||||
"""Preview KB↔토스은행 이체 매칭 후보를 반환합니다."""
|
||||
return service.preview_cross_account_transfers(limit)
|
||||
return contracts.preview_cross_account_transfers(limit)
|
||||
|
||||
@mcp.tool()
|
||||
def run_cross_account_transfer_match(limit: int = 500) -> dict:
|
||||
"""Run KB↔토스은행 자동 이체 매칭 및 태그/분류 반영을 수행합니다."""
|
||||
return service.run_cross_account_transfer_match(limit)
|
||||
return contracts.run_cross_account_transfer_match(limit)
|
||||
|
||||
@mcp.tool()
|
||||
def delete_ledger_category(category: str, subcategory: str = "", hard_delete: bool = False, force: bool = False) -> dict:
|
||||
"""Deactivate or delete a ledger category/subcategory."""
|
||||
return service.delete_ledger_category(category, subcategory, hard_delete, force)
|
||||
return contracts.delete_ledger_category(category, subcategory, hard_delete, force)
|
||||
|
||||
def main() -> None:
|
||||
mcp.run()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import (
|
||||
apply_categorization_rules,
|
||||
close_asset_snapshot,
|
||||
create_category_rule,
|
||||
create_ledger_account,
|
||||
create_ledger_backup,
|
||||
create_ledger_budget,
|
||||
create_ledger_category,
|
||||
create_ledger_transaction,
|
||||
create_manual_asset,
|
||||
deactivate_manual_asset,
|
||||
delete_category_rule,
|
||||
delete_ledger_category,
|
||||
delete_ledger_transaction,
|
||||
get_asset_liquidity_report,
|
||||
get_asset_snapshot_report,
|
||||
get_balance_reconciliation_report,
|
||||
get_budget_vs_actual_report,
|
||||
get_category_spending_report,
|
||||
get_fixed_cost_report,
|
||||
get_investable_cashflow_report,
|
||||
get_ledger_backup_status,
|
||||
get_ledger_db_status,
|
||||
get_monthly_cashflow_report,
|
||||
get_net_worth_timeseries,
|
||||
health_check,
|
||||
import_kb_bank_pdf,
|
||||
import_seoulpay_xlsx,
|
||||
import_toss_bank_pdf,
|
||||
import_toss_securities_pdf,
|
||||
list_category_rules,
|
||||
list_cross_transfer_review_queue,
|
||||
list_ledger_accounts,
|
||||
list_ledger_backups,
|
||||
list_ledger_budgets,
|
||||
list_ledger_categories,
|
||||
list_ledger_import_batches,
|
||||
list_ledger_transactions,
|
||||
list_manual_assets,
|
||||
list_uncategorized_transactions,
|
||||
preview_asset_snapshot_close,
|
||||
preview_categorization,
|
||||
preview_cross_account_transfers,
|
||||
preview_kb_bank_pdf_import,
|
||||
preview_ledger_csv_import,
|
||||
preview_seoulpay_xlsx_import,
|
||||
preview_toss_bank_pdf_import,
|
||||
preview_toss_securities_pdf_import,
|
||||
rollback_ledger_import_batch,
|
||||
run_cross_account_transfer_match,
|
||||
seed_default_category_rules,
|
||||
seed_default_ledger_categories,
|
||||
update_category_rule,
|
||||
update_ledger_account,
|
||||
update_ledger_transaction,
|
||||
update_manual_asset,
|
||||
get_uncategorized_merchant_summary,
|
||||
)
|
||||
@@ -1 +1,11 @@
|
||||
from . import service_gateway
|
||||
from . import (
|
||||
accounts_gateway,
|
||||
assets_gateway,
|
||||
categorization_gateway,
|
||||
imports_gateway,
|
||||
overview_gateway,
|
||||
reconciliation_gateway,
|
||||
service_gateway,
|
||||
transactions_gateway,
|
||||
transfers_gateway,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import (
|
||||
list_ledger_accounts,
|
||||
update_ledger_account,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import (
|
||||
close_asset_snapshot,
|
||||
create_manual_asset,
|
||||
deactivate_manual_asset,
|
||||
list_manual_assets,
|
||||
preview_asset_snapshot_close,
|
||||
update_manual_asset,
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import (
|
||||
apply_categorization_rules,
|
||||
apply_category_rule,
|
||||
apply_uncategorized_group_recommendation,
|
||||
apply_uncategorized_recommendation,
|
||||
create_category_rule,
|
||||
create_ledger_category,
|
||||
delete_ledger_category,
|
||||
get_uncategorized_merchant_summary,
|
||||
get_uncategorized_workbench,
|
||||
list_category_rules,
|
||||
list_ledger_categories,
|
||||
list_uncategorized_transactions,
|
||||
preview_categorization,
|
||||
preview_category_rule_matches,
|
||||
seed_default_category_rules,
|
||||
seed_default_ledger_categories,
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import (
|
||||
import_toss_bank_pdf,
|
||||
import_toss_securities_pdf,
|
||||
list_ledger_import_batches,
|
||||
preview_toss_bank_pdf_import,
|
||||
preview_toss_securities_pdf_import,
|
||||
rollback_ledger_import_batch,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import (
|
||||
get_asset_liquidity_report,
|
||||
get_balance_reconciliation_report,
|
||||
get_budget_vs_actual_report,
|
||||
get_monthly_cashflow_report,
|
||||
health_check,
|
||||
list_cross_transfer_review_queue,
|
||||
list_ledger_backups,
|
||||
list_ledger_import_batches,
|
||||
list_uncategorized_transactions,
|
||||
)
|
||||
@@ -0,0 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import get_balance_reconciliation_report
|
||||
@@ -1,44 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import (
|
||||
apply_categorization_rules,
|
||||
apply_uncategorized_group_recommendation,
|
||||
apply_uncategorized_recommendation,
|
||||
close_asset_snapshot,
|
||||
create_category_rule,
|
||||
create_ledger_category,
|
||||
create_manual_asset,
|
||||
deactivate_manual_asset,
|
||||
delete_ledger_category,
|
||||
find_cancel_original_candidates,
|
||||
find_self_transfer_candidates,
|
||||
get_asset_liquidity_report,
|
||||
get_balance_reconciliation_report,
|
||||
get_budget_vs_actual_report,
|
||||
get_monthly_cashflow_report,
|
||||
health_check,
|
||||
import_toss_bank_pdf,
|
||||
import_toss_securities_pdf,
|
||||
link_cancel_to_original,
|
||||
link_self_transfer_pair,
|
||||
list_category_rules,
|
||||
list_cross_transfer_review_queue,
|
||||
list_ledger_accounts,
|
||||
list_ledger_backups,
|
||||
list_ledger_categories,
|
||||
list_ledger_import_batches,
|
||||
list_manual_assets,
|
||||
list_uncategorized_transactions,
|
||||
preview_asset_snapshot_close,
|
||||
preview_categorization,
|
||||
preview_cross_account_transfers,
|
||||
preview_toss_bank_pdf_import,
|
||||
preview_toss_securities_pdf_import,
|
||||
rollback_ledger_import_batch,
|
||||
run_cross_account_transfer_match,
|
||||
seed_default_category_rules,
|
||||
seed_default_ledger_categories,
|
||||
update_ledger_account,
|
||||
update_ledger_transaction,
|
||||
update_manual_asset,
|
||||
)
|
||||
from .accounts_gateway import *
|
||||
from .assets_gateway import *
|
||||
from .categorization_gateway import *
|
||||
from .imports_gateway import *
|
||||
from .overview_gateway import *
|
||||
from .reconciliation_gateway import *
|
||||
from .transactions_gateway import *
|
||||
from .transfers_gateway import *
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import update_ledger_transaction
|
||||
@@ -0,0 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import (
|
||||
find_cancel_original_candidates,
|
||||
find_self_transfer_candidates,
|
||||
link_cancel_to_original,
|
||||
link_self_transfer_pair,
|
||||
list_cross_transfer_review_queue,
|
||||
preview_cross_account_transfers,
|
||||
run_cross_account_transfer_match,
|
||||
)
|
||||
@@ -1,10 +1,15 @@
|
||||
from . import shared
|
||||
from .accounts_write_services import *
|
||||
from .asset_account_services import *
|
||||
from .assets_service import *
|
||||
from .budgets_write_services import *
|
||||
from .category_rules_write_services import *
|
||||
from .categorization_services import *
|
||||
from .cross_transfer_service import *
|
||||
from .imports_service import *
|
||||
from .import_transfer_services import *
|
||||
from .reconciliation_service import *
|
||||
from .system_services import *
|
||||
from .transactions_write_services import *
|
||||
from .transaction_report_services import *
|
||||
from .uncategorized_workbench_service import *
|
||||
|
||||
@@ -1,493 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
|
||||
def _valuation_policy() -> dict[str, Any]:
|
||||
return {
|
||||
"authority_sources": ["accounts.last_balance", "manual_assets"],
|
||||
"snapshot_role": "historical_record_layer",
|
||||
"formula": "asset_buckets - loan",
|
||||
"asset_buckets": ASSET_BUCKETS,
|
||||
"liability_buckets": LIABILITY_BUCKETS,
|
||||
"missing_valuation_policy": "exclude_without_transaction_inference",
|
||||
}
|
||||
|
||||
|
||||
def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
|
||||
included: list[dict[str, Any]] = []
|
||||
excluded: list[dict[str, Any]] = []
|
||||
invalid: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
account = row_to_dict(row)
|
||||
raw_bucket = str(account.get("liquidity_bucket") or "other")
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(raw_bucket)
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
|
||||
base = {
|
||||
"id": account.get("id", ""),
|
||||
"source_id": account.get("id", ""),
|
||||
"source_type": "account",
|
||||
"authority_source": "accounts.last_balance",
|
||||
"name": account.get("name", ""),
|
||||
"type": account.get("type", ""),
|
||||
"liquidity_bucket": bucket,
|
||||
"valuation_date": account.get("last_synced_at", ""),
|
||||
"last_synced_at": account.get("last_synced_at", ""),
|
||||
"source_note": account.get("memo", ""),
|
||||
"memo": account.get("memo", ""),
|
||||
}
|
||||
balance = account.get("last_balance")
|
||||
if balance is None:
|
||||
excluded.append({**base, "reason": "missing_last_balance"})
|
||||
continue
|
||||
amount = int(balance)
|
||||
included.append({**base, "amount": amount, "last_balance": amount})
|
||||
return included, excluded, invalid
|
||||
|
||||
|
||||
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
where = "WHERE active=1" if active_only else ""
|
||||
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
||||
included: list[dict[str, Any]] = []
|
||||
excluded: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
asset = row_to_dict(row)
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket"))
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
base = {
|
||||
"id": asset.get("id", ""),
|
||||
"source_id": asset.get("id", ""),
|
||||
"source_type": "manual_asset",
|
||||
"authority_source": "manual_assets",
|
||||
"name": asset.get("name", ""),
|
||||
"liquidity_bucket": bucket,
|
||||
"valuation_date": asset.get("valuation_date", ""),
|
||||
"source_note": asset.get("source_note", ""),
|
||||
"memo": asset.get("memo", ""),
|
||||
"active": asset.get("active", True),
|
||||
}
|
||||
value = asset.get("valuation_amount")
|
||||
if value is None:
|
||||
excluded.append({**base, "reason": "missing_valuation_amount"})
|
||||
continue
|
||||
included.append({**base, "amount": int(value), "valuation_amount": int(value)})
|
||||
return included, excluded
|
||||
|
||||
|
||||
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
|
||||
rows = conn.execute(
|
||||
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC",
|
||||
(basis_date,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return [], {}
|
||||
run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]]
|
||||
if not run_rows:
|
||||
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)}
|
||||
latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1]
|
||||
latest_run_id = latest["snapshot_run_id"]
|
||||
selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id]
|
||||
metadata = {
|
||||
"snapshot_run_id": latest_run_id,
|
||||
"is_correction": bool(latest["is_correction"]),
|
||||
"correction_of_run_id": latest["correction_of_run_id"] or "",
|
||||
"correction_reason": latest["correction_reason"] or "",
|
||||
"closed_at": latest["closed_at"] or latest["created_at"] or "",
|
||||
"total_snapshot_rows_for_basis_date": len(rows),
|
||||
}
|
||||
return selected, metadata
|
||||
|
||||
|
||||
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
|
||||
rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date)
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
snap = row_to_dict(row)
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket"))
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
items.append(
|
||||
{
|
||||
"id": snap.get("id", ""),
|
||||
"source_id": snap.get("source_id", ""),
|
||||
"source_type": snap.get("source_type", ""),
|
||||
"authority_source": "asset_snapshots",
|
||||
"valuation_source": snap.get("valuation_source", ""),
|
||||
"name": snap.get("name", ""),
|
||||
"liquidity_bucket": bucket,
|
||||
"amount": int(snap.get("amount") or 0),
|
||||
"valuation_date": snap.get("basis_date", ""),
|
||||
"source_note": snap.get("source_note", ""),
|
||||
"memo": snap.get("memo", ""),
|
||||
"snapshot_run_id": snap.get("snapshot_run_id", ""),
|
||||
"is_correction": bool(snap.get("is_correction", False)),
|
||||
"correction_of_run_id": snap.get("correction_of_run_id", ""),
|
||||
"correction_reason": snap.get("correction_reason", ""),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
||||
bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
||||
for item in included_items:
|
||||
bucket = normalize_liquidity_bucket(item.get("liquidity_bucket"))
|
||||
amount = int(item.get("amount") or 0)
|
||||
bucket_counts[bucket] += 1
|
||||
bucket_totals[bucket] += amount
|
||||
total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS)
|
||||
total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS)
|
||||
net_worth = total_assets - total_liabilities
|
||||
buckets = []
|
||||
for bucket in ASSET_BUCKET_ORDER:
|
||||
amount = bucket_totals[bucket]
|
||||
denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets
|
||||
buckets.append(
|
||||
{
|
||||
"liquidity_bucket": bucket,
|
||||
"amount": amount,
|
||||
"account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket),
|
||||
"manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket),
|
||||
"item_count": bucket_counts[bucket],
|
||||
"asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None,
|
||||
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None,
|
||||
"share": round(abs(amount) / denominator, 4) if denominator else None,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"buckets": buckets,
|
||||
"bucket_totals": bucket_totals,
|
||||
"total_assets": total_assets,
|
||||
"total_liabilities": total_liabilities,
|
||||
"net_worth": net_worth,
|
||||
"included_items": included_items,
|
||||
"excluded_items": excluded_items or [],
|
||||
"valuation_policy": _valuation_policy(),
|
||||
}
|
||||
|
||||
|
||||
def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
if not name.strip():
|
||||
return fail("validation_error", "manual asset name is required")
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
||||
amount = int(valuation_amount) if valuation_amount is not None else None
|
||||
vdate = normalize_date(valuation_date, default_today=True)
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
ts = now_iso()
|
||||
asset_id = new_id("asset")
|
||||
with db(db_path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||
|
||||
|
||||
def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
where = "WHERE active=1" if active_only else ""
|
||||
with db(db_path) as conn:
|
||||
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
||||
assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows]
|
||||
return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy())
|
||||
|
||||
|
||||
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]:
|
||||
allowed_updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
try:
|
||||
if name is not None:
|
||||
if not name.strip():
|
||||
return fail("validation_error", "manual asset name is required")
|
||||
allowed_updates.append("name=?")
|
||||
params.append(name.strip())
|
||||
if valuation_amount is not None:
|
||||
allowed_updates.append("valuation_amount=?")
|
||||
params.append(int(valuation_amount))
|
||||
if valuation_date is not None:
|
||||
allowed_updates.append("valuation_date=?")
|
||||
params.append(normalize_date(valuation_date, default_today=True))
|
||||
if liquidity_bucket is not None:
|
||||
allowed_updates.append("liquidity_bucket=?")
|
||||
params.append(normalize_liquidity_bucket(liquidity_bucket))
|
||||
if source_note is not None:
|
||||
allowed_updates.append("source_note=?")
|
||||
params.append(source_note)
|
||||
if memo is not None:
|
||||
allowed_updates.append("memo=?")
|
||||
params.append(memo)
|
||||
if currency is not None:
|
||||
allowed_updates.append("currency=?")
|
||||
params.append(currency or "KRW")
|
||||
if active is not None:
|
||||
allowed_updates.append("active=?")
|
||||
params.append(int(bool(active)))
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
if not allowed_updates:
|
||||
return fail("validation_error", "no fields to update")
|
||||
allowed_updates.append("updated_at=?")
|
||||
params.extend([now_iso(), asset_id])
|
||||
with db(db_path) as conn:
|
||||
cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params)
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "manual asset not found")
|
||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||
|
||||
|
||||
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
if hard_delete:
|
||||
cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,))
|
||||
else:
|
||||
cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id))
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "manual asset not found")
|
||||
row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete}
|
||||
if row is not None:
|
||||
payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"}
|
||||
return ok(**payload)
|
||||
|
||||
|
||||
def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]:
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||
included = account_items + manual_items
|
||||
excluded = account_excluded + manual_excluded
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
|
||||
warnings: list[dict[str, Any]] = []
|
||||
for item in excluded:
|
||||
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", "")})
|
||||
for item in included:
|
||||
item_date = str(item.get("valuation_date") or "")[:10]
|
||||
if item_date and item_date < basis:
|
||||
warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis})
|
||||
for item in invalid_bucket_accounts:
|
||||
warnings.append({"code": "invalid_liquidity_bucket", **item})
|
||||
if existing_rows:
|
||||
warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"})
|
||||
return {
|
||||
"basis_date": basis,
|
||||
"source": "live_authority",
|
||||
"is_closed_snapshot": False,
|
||||
"snapshot_count": 0,
|
||||
"existing_snapshot_row_count": int(existing_rows),
|
||||
"would_be_correction": bool(existing_rows),
|
||||
"warnings": warnings,
|
||||
**summary,
|
||||
}
|
||||
|
||||
|
||||
def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
preview = _snapshot_preview_from_conn(conn, basis)
|
||||
return ok(**preview)
|
||||
|
||||
|
||||
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
ts = now_iso()
|
||||
run_id = new_id("snaprun")
|
||||
with db(db_path) as conn:
|
||||
preview = _snapshot_preview_from_conn(conn, basis)
|
||||
included = preview["included_items"]
|
||||
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
|
||||
is_correction = bool(preview["existing_snapshot_row_count"])
|
||||
if is_correction and not correction_reason.strip():
|
||||
return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"])
|
||||
correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else ""
|
||||
snapshot_ids: list[str] = []
|
||||
for item in included:
|
||||
snapshot_id = new_id("snap")
|
||||
row_note = source_note or item.get("source_note", "") or "month-end close"
|
||||
row_memo = memo or item.get("memo", "")
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
snapshot_id,
|
||||
basis,
|
||||
item.get("source_type", ""),
|
||||
item.get("source_id", item.get("id", "")),
|
||||
item.get("name", ""),
|
||||
int(item.get("amount") or 0),
|
||||
item.get("currency", "KRW"),
|
||||
normalize_liquidity_bucket(item.get("liquidity_bucket")),
|
||||
item.get("authority_source", item.get("source_type", "")),
|
||||
row_note,
|
||||
row_memo,
|
||||
run_id,
|
||||
correction_of_run_id,
|
||||
correction_reason.strip(),
|
||||
int(is_correction),
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
snapshot_ids.append(snapshot_id)
|
||||
report_items = _snapshot_items(conn, basis)
|
||||
summary = _summarize_valuation_items(report_items, [])
|
||||
return ok(
|
||||
basis_date=basis,
|
||||
source="snapshot",
|
||||
is_closed_snapshot=True,
|
||||
snapshot_run_id=run_id,
|
||||
correction_of_run_id=correction_of_run_id,
|
||||
correction_reason=correction_reason.strip(),
|
||||
is_correction=is_correction,
|
||||
closed_at=ts,
|
||||
inserted_count=len(snapshot_ids),
|
||||
inserted_snapshot_ids=snapshot_ids,
|
||||
warnings=preview["warnings"],
|
||||
**summary,
|
||||
)
|
||||
|
||||
|
||||
def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]:
|
||||
if source_type not in {"account", "manual_asset"}:
|
||||
return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
|
||||
if not name.strip():
|
||||
return fail("validation_error", "snapshot item name is required")
|
||||
try:
|
||||
basis = normalize_date(basis_date, default_today=True)
|
||||
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
||||
amount_int = int(amount) if amount is not None else None
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
ts = now_iso()
|
||||
snapshot_id = new_id("snap")
|
||||
with db(db_path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
|
||||
return ok(asset_snapshot=row_to_dict(row))
|
||||
|
||||
|
||||
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
with db(db_path) as conn:
|
||||
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||
snapshots = _snapshot_items(conn, basis) if rows else []
|
||||
if snapshots:
|
||||
summary = _summarize_valuation_items(snapshots, [])
|
||||
return ok(
|
||||
basis_date=basis,
|
||||
source="asset_snapshots",
|
||||
is_closed_snapshot=True,
|
||||
snapshot_count=len(snapshots),
|
||||
close_metadata=close_metadata,
|
||||
**summary,
|
||||
)
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||
included = account_items + manual_items
|
||||
excluded = account_excluded + manual_excluded
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
return ok(
|
||||
basis_date=basis,
|
||||
source="live_authority",
|
||||
is_closed_snapshot=False,
|
||||
snapshot_count=0,
|
||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||
**summary,
|
||||
)
|
||||
|
||||
|
||||
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
months_int = max(1, min(int(months), 120))
|
||||
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
|
||||
if start_date:
|
||||
start_norm = normalize_date(start_date)
|
||||
else:
|
||||
end_dt = date.fromisoformat(end_norm)
|
||||
start_month = end_dt.month - months_int + 1
|
||||
year = end_dt.year + (start_month - 1) // 12
|
||||
month = (start_month - 1) % 12 + 1
|
||||
start_norm = date(year, month, 1).isoformat()
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
basis_rows = conn.execute(
|
||||
"""SELECT DISTINCT basis_date FROM asset_snapshots
|
||||
WHERE basis_date>=? AND basis_date<=?
|
||||
ORDER BY basis_date ASC""",
|
||||
(start_norm, end_norm),
|
||||
).fetchall()
|
||||
points = []
|
||||
for row in basis_rows:
|
||||
basis = row["basis_date"]
|
||||
items = _snapshot_items(conn, basis)
|
||||
summary = _summarize_valuation_items(items, [])
|
||||
points.append(
|
||||
{
|
||||
"basis_date": basis,
|
||||
"total_assets": summary["total_assets"],
|
||||
"total_liabilities": summary["total_liabilities"],
|
||||
"net_worth": summary["net_worth"],
|
||||
"source": "asset_snapshots",
|
||||
"item_count": len(items),
|
||||
}
|
||||
)
|
||||
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
|
||||
|
||||
|
||||
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Summarize assets/liabilities from shared valuation authority sources.
|
||||
|
||||
Existing REQ-005 keys are preserved. When manual_assets is empty, totals and
|
||||
source remain equivalent to the original accounts.last_balance behavior.
|
||||
"""
|
||||
with db(db_path) as conn:
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||
included = account_items + manual_items
|
||||
excluded = account_excluded + manual_excluded
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
source_accounts = [item for item in included if item.get("source_type") == "account"]
|
||||
source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
|
||||
return ok(
|
||||
buckets=summary["buckets"],
|
||||
accounts=source_accounts,
|
||||
manual_assets=manual_items,
|
||||
total_assets=summary["total_assets"],
|
||||
total_liabilities=summary["total_liabilities"],
|
||||
net_worth=summary["net_worth"],
|
||||
missing_balance_accounts=[
|
||||
{"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")}
|
||||
for item in account_excluded
|
||||
],
|
||||
missing_valuation_items=excluded,
|
||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||
manual_asset_count=len(manual_items),
|
||||
included_items=included,
|
||||
excluded_items=excluded,
|
||||
valuation_policy=summary["valuation_policy"],
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
from .assets_service import (
|
||||
close_asset_snapshot,
|
||||
create_asset_snapshot,
|
||||
create_manual_asset,
|
||||
deactivate_manual_asset,
|
||||
get_asset_liquidity_report,
|
||||
get_asset_snapshot_report,
|
||||
get_net_worth_timeseries,
|
||||
list_manual_assets,
|
||||
preview_asset_snapshot_close,
|
||||
update_manual_asset,
|
||||
)
|
||||
from .accounts_write_services import update_ledger_account
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
|
||||
def _valuation_policy() -> dict[str, Any]:
|
||||
return {
|
||||
"authority_sources": ["accounts.last_balance", "manual_assets"],
|
||||
"snapshot_role": "historical_record_layer",
|
||||
"formula": "asset_buckets - loan",
|
||||
"asset_buckets": ASSET_BUCKETS,
|
||||
"liability_buckets": LIABILITY_BUCKETS,
|
||||
"missing_valuation_policy": "exclude_without_transaction_inference",
|
||||
}
|
||||
|
||||
|
||||
def _account_valuation_items(conn: sqlite3.Connection) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall()
|
||||
included: list[dict[str, Any]] = []
|
||||
excluded: list[dict[str, Any]] = []
|
||||
invalid: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
account = row_to_dict(row)
|
||||
raw_bucket = str(account.get("liquidity_bucket") or "other")
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(raw_bucket)
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
invalid.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket})
|
||||
base = {
|
||||
"id": account.get("id", ""),
|
||||
"source_id": account.get("id", ""),
|
||||
"source_type": "account",
|
||||
"authority_source": "accounts.last_balance",
|
||||
"name": account.get("name", ""),
|
||||
"type": account.get("type", ""),
|
||||
"liquidity_bucket": bucket,
|
||||
"valuation_date": account.get("last_synced_at", ""),
|
||||
"last_synced_at": account.get("last_synced_at", ""),
|
||||
"source_note": account.get("memo", ""),
|
||||
"memo": account.get("memo", ""),
|
||||
}
|
||||
balance = account.get("last_balance")
|
||||
if balance is None:
|
||||
excluded.append({**base, "reason": "missing_last_balance"})
|
||||
continue
|
||||
amount = int(balance)
|
||||
included.append({**base, "amount": amount, "last_balance": amount})
|
||||
return included, excluded, invalid
|
||||
|
||||
|
||||
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
where = "WHERE active=1" if active_only else ""
|
||||
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
||||
included: list[dict[str, Any]] = []
|
||||
excluded: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
asset = row_to_dict(row)
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket"))
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
base = {
|
||||
"id": asset.get("id", ""),
|
||||
"source_id": asset.get("id", ""),
|
||||
"source_type": "manual_asset",
|
||||
"authority_source": "manual_assets",
|
||||
"name": asset.get("name", ""),
|
||||
"liquidity_bucket": bucket,
|
||||
"valuation_date": asset.get("valuation_date", ""),
|
||||
"source_note": asset.get("source_note", ""),
|
||||
"memo": asset.get("memo", ""),
|
||||
"active": asset.get("active", True),
|
||||
}
|
||||
value = asset.get("valuation_amount")
|
||||
if value is None:
|
||||
excluded.append({**base, "reason": "missing_valuation_amount"})
|
||||
continue
|
||||
included.append({**base, "amount": int(value), "valuation_amount": int(value)})
|
||||
return included, excluded
|
||||
|
||||
|
||||
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
|
||||
rows = conn.execute(
|
||||
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC",
|
||||
(basis_date,),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return [], {}
|
||||
run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]]
|
||||
if not run_rows:
|
||||
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)}
|
||||
latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1]
|
||||
latest_run_id = latest["snapshot_run_id"]
|
||||
selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id]
|
||||
metadata = {
|
||||
"snapshot_run_id": latest_run_id,
|
||||
"is_correction": bool(latest["is_correction"]),
|
||||
"correction_of_run_id": latest["correction_of_run_id"] or "",
|
||||
"correction_reason": latest["correction_reason"] or "",
|
||||
"closed_at": latest["closed_at"] or latest["created_at"] or "",
|
||||
"total_snapshot_rows_for_basis_date": len(rows),
|
||||
}
|
||||
return selected, metadata
|
||||
|
||||
|
||||
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
|
||||
rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date)
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
snap = row_to_dict(row)
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket"))
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
items.append(
|
||||
{
|
||||
"id": snap.get("id", ""),
|
||||
"source_id": snap.get("source_id", ""),
|
||||
"source_type": snap.get("source_type", ""),
|
||||
"authority_source": "asset_snapshots",
|
||||
"valuation_source": snap.get("valuation_source", ""),
|
||||
"name": snap.get("name", ""),
|
||||
"liquidity_bucket": bucket,
|
||||
"amount": int(snap.get("amount") or 0),
|
||||
"valuation_date": snap.get("basis_date", ""),
|
||||
"source_note": snap.get("source_note", ""),
|
||||
"memo": snap.get("memo", ""),
|
||||
"snapshot_run_id": snap.get("snapshot_run_id", ""),
|
||||
"is_correction": bool(snap.get("is_correction", False)),
|
||||
"correction_of_run_id": snap.get("correction_of_run_id", ""),
|
||||
"correction_reason": snap.get("correction_reason", ""),
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
||||
bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
||||
for item in included_items:
|
||||
bucket = normalize_liquidity_bucket(item.get("liquidity_bucket"))
|
||||
amount = int(item.get("amount") or 0)
|
||||
bucket_counts[bucket] += 1
|
||||
bucket_totals[bucket] += amount
|
||||
total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS)
|
||||
total_liabilities = sum(abs(bucket_totals[bucket]) for bucket in LIABILITY_BUCKETS)
|
||||
net_worth = total_assets - total_liabilities
|
||||
buckets = []
|
||||
for bucket in ASSET_BUCKET_ORDER:
|
||||
amount = bucket_totals[bucket]
|
||||
denominator = total_liabilities if bucket in LIABILITY_BUCKETS else total_assets
|
||||
buckets.append(
|
||||
{
|
||||
"liquidity_bucket": bucket,
|
||||
"amount": amount,
|
||||
"account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket),
|
||||
"manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket),
|
||||
"item_count": bucket_counts[bucket],
|
||||
"asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None,
|
||||
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None,
|
||||
"share": round(abs(amount) / denominator, 4) if denominator else None,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"buckets": buckets,
|
||||
"bucket_totals": bucket_totals,
|
||||
"total_assets": total_assets,
|
||||
"total_liabilities": total_liabilities,
|
||||
"net_worth": net_worth,
|
||||
"included_items": included_items,
|
||||
"excluded_items": excluded_items or [],
|
||||
"valuation_policy": _valuation_policy(),
|
||||
}
|
||||
|
||||
|
||||
def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
if not name.strip():
|
||||
return fail("validation_error", "manual asset name is required")
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
||||
amount = int(valuation_amount) if valuation_amount is not None else None
|
||||
vdate = normalize_date(valuation_date, default_today=True)
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
ts = now_iso()
|
||||
asset_id = new_id("asset")
|
||||
with db(db_path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||
|
||||
|
||||
def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
where = "WHERE active=1" if active_only else ""
|
||||
with db(db_path) as conn:
|
||||
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
||||
assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows]
|
||||
return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy())
|
||||
|
||||
|
||||
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]:
|
||||
allowed_updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
try:
|
||||
if name is not None:
|
||||
if not name.strip():
|
||||
return fail("validation_error", "manual asset name is required")
|
||||
allowed_updates.append("name=?")
|
||||
params.append(name.strip())
|
||||
if valuation_amount is not None:
|
||||
allowed_updates.append("valuation_amount=?")
|
||||
params.append(int(valuation_amount))
|
||||
if valuation_date is not None:
|
||||
allowed_updates.append("valuation_date=?")
|
||||
params.append(normalize_date(valuation_date, default_today=True))
|
||||
if liquidity_bucket is not None:
|
||||
allowed_updates.append("liquidity_bucket=?")
|
||||
params.append(normalize_liquidity_bucket(liquidity_bucket))
|
||||
if source_note is not None:
|
||||
allowed_updates.append("source_note=?")
|
||||
params.append(source_note)
|
||||
if memo is not None:
|
||||
allowed_updates.append("memo=?")
|
||||
params.append(memo)
|
||||
if currency is not None:
|
||||
allowed_updates.append("currency=?")
|
||||
params.append(currency or "KRW")
|
||||
if active is not None:
|
||||
allowed_updates.append("active=?")
|
||||
params.append(int(bool(active)))
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
if not allowed_updates:
|
||||
return fail("validation_error", "no fields to update")
|
||||
allowed_updates.append("updated_at=?")
|
||||
params.extend([now_iso(), asset_id])
|
||||
with db(db_path) as conn:
|
||||
cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params)
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "manual asset not found")
|
||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||
|
||||
|
||||
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
if hard_delete:
|
||||
cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,))
|
||||
else:
|
||||
cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id))
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "manual asset not found")
|
||||
row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete}
|
||||
if row is not None:
|
||||
payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"}
|
||||
return ok(**payload)
|
||||
|
||||
|
||||
def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]:
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||
included = account_items + manual_items
|
||||
excluded = account_excluded + manual_excluded
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
|
||||
warnings: list[dict[str, Any]] = []
|
||||
for item in excluded:
|
||||
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", "")})
|
||||
for item in included:
|
||||
item_date = str(item.get("valuation_date") or "")[:10]
|
||||
if item_date and item_date < basis:
|
||||
warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis})
|
||||
for item in invalid_bucket_accounts:
|
||||
warnings.append({"code": "invalid_liquidity_bucket", **item})
|
||||
if existing_rows:
|
||||
warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"})
|
||||
return {
|
||||
"basis_date": basis,
|
||||
"source": "live_authority",
|
||||
"is_closed_snapshot": False,
|
||||
"snapshot_count": 0,
|
||||
"existing_snapshot_row_count": int(existing_rows),
|
||||
"would_be_correction": bool(existing_rows),
|
||||
"warnings": warnings,
|
||||
**summary,
|
||||
}
|
||||
|
||||
|
||||
def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
preview = _snapshot_preview_from_conn(conn, basis)
|
||||
return ok(**preview)
|
||||
|
||||
|
||||
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
ts = now_iso()
|
||||
run_id = new_id("snaprun")
|
||||
with db(db_path) as conn:
|
||||
preview = _snapshot_preview_from_conn(conn, basis)
|
||||
included = preview["included_items"]
|
||||
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
|
||||
is_correction = bool(preview["existing_snapshot_row_count"])
|
||||
if is_correction and not correction_reason.strip():
|
||||
return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"])
|
||||
correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else ""
|
||||
snapshot_ids: list[str] = []
|
||||
for item in included:
|
||||
snapshot_id = new_id("snap")
|
||||
row_note = source_note or item.get("source_note", "") or "month-end close"
|
||||
row_memo = memo or item.get("memo", "")
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
snapshot_id,
|
||||
basis,
|
||||
item.get("source_type", ""),
|
||||
item.get("source_id", item.get("id", "")),
|
||||
item.get("name", ""),
|
||||
int(item.get("amount") or 0),
|
||||
item.get("currency", "KRW"),
|
||||
normalize_liquidity_bucket(item.get("liquidity_bucket")),
|
||||
item.get("authority_source", item.get("source_type", "")),
|
||||
row_note,
|
||||
row_memo,
|
||||
run_id,
|
||||
correction_of_run_id,
|
||||
correction_reason.strip(),
|
||||
int(is_correction),
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
snapshot_ids.append(snapshot_id)
|
||||
report_items = _snapshot_items(conn, basis)
|
||||
summary = _summarize_valuation_items(report_items, [])
|
||||
return ok(
|
||||
basis_date=basis,
|
||||
source="snapshot",
|
||||
is_closed_snapshot=True,
|
||||
snapshot_run_id=run_id,
|
||||
correction_of_run_id=correction_of_run_id,
|
||||
correction_reason=correction_reason.strip(),
|
||||
is_correction=is_correction,
|
||||
closed_at=ts,
|
||||
inserted_count=len(snapshot_ids),
|
||||
inserted_snapshot_ids=snapshot_ids,
|
||||
warnings=preview["warnings"],
|
||||
**summary,
|
||||
)
|
||||
|
||||
|
||||
def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]:
|
||||
if source_type not in {"account", "manual_asset"}:
|
||||
return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
|
||||
if not name.strip():
|
||||
return fail("validation_error", "snapshot item name is required")
|
||||
try:
|
||||
basis = normalize_date(basis_date, default_today=True)
|
||||
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
||||
amount_int = int(amount) if amount is not None else None
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
ts = now_iso()
|
||||
snapshot_id = new_id("snap")
|
||||
with db(db_path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
|
||||
return ok(asset_snapshot=row_to_dict(row))
|
||||
|
||||
|
||||
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
with db(db_path) as conn:
|
||||
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||
snapshots = _snapshot_items(conn, basis) if rows else []
|
||||
if snapshots:
|
||||
summary = _summarize_valuation_items(snapshots, [])
|
||||
return ok(
|
||||
basis_date=basis,
|
||||
source="asset_snapshots",
|
||||
is_closed_snapshot=True,
|
||||
snapshot_count=len(snapshots),
|
||||
close_metadata=close_metadata,
|
||||
**summary,
|
||||
)
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||
included = account_items + manual_items
|
||||
excluded = account_excluded + manual_excluded
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
return ok(
|
||||
basis_date=basis,
|
||||
source="live_authority",
|
||||
is_closed_snapshot=False,
|
||||
snapshot_count=0,
|
||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||
**summary,
|
||||
)
|
||||
|
||||
|
||||
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
months_int = max(1, min(int(months), 120))
|
||||
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
|
||||
if start_date:
|
||||
start_norm = normalize_date(start_date)
|
||||
else:
|
||||
end_dt = date.fromisoformat(end_norm)
|
||||
start_month = end_dt.month - months_int + 1
|
||||
year = end_dt.year + (start_month - 1) // 12
|
||||
month = (start_month - 1) % 12 + 1
|
||||
start_norm = date(year, month, 1).isoformat()
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
basis_rows = conn.execute(
|
||||
"""SELECT DISTINCT basis_date FROM asset_snapshots
|
||||
WHERE basis_date>=? AND basis_date<=?
|
||||
ORDER BY basis_date ASC""",
|
||||
(start_norm, end_norm),
|
||||
).fetchall()
|
||||
points = []
|
||||
for row in basis_rows:
|
||||
basis = row["basis_date"]
|
||||
items = _snapshot_items(conn, basis)
|
||||
summary = _summarize_valuation_items(items, [])
|
||||
points.append(
|
||||
{
|
||||
"basis_date": basis,
|
||||
"total_assets": summary["total_assets"],
|
||||
"total_liabilities": summary["total_liabilities"],
|
||||
"net_worth": summary["net_worth"],
|
||||
"source": "asset_snapshots",
|
||||
"item_count": len(items),
|
||||
}
|
||||
)
|
||||
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
|
||||
|
||||
|
||||
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Summarize assets/liabilities from shared valuation authority sources.
|
||||
|
||||
Existing REQ-005 keys are preserved. When manual_assets is empty, totals and
|
||||
source remain equivalent to the original accounts.last_balance behavior.
|
||||
"""
|
||||
with db(db_path) as conn:
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||
included = account_items + manual_items
|
||||
excluded = account_excluded + manual_excluded
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
source_accounts = [item for item in included if item.get("source_type") == "account"]
|
||||
source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
|
||||
return ok(
|
||||
buckets=summary["buckets"],
|
||||
accounts=source_accounts,
|
||||
manual_assets=manual_items,
|
||||
total_assets=summary["total_assets"],
|
||||
total_liabilities=summary["total_liabilities"],
|
||||
net_worth=summary["net_worth"],
|
||||
missing_balance_accounts=[
|
||||
{"id": item.get("id", ""), "name": item.get("name", ""), "liquidity_bucket": item.get("liquidity_bucket", "")}
|
||||
for item in account_excluded
|
||||
],
|
||||
missing_valuation_items=excluded,
|
||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||
manual_asset_count=len(manual_items),
|
||||
included_items=included,
|
||||
excluded_items=excluded,
|
||||
valuation_policy=summary["valuation_policy"],
|
||||
source=source,
|
||||
)
|
||||
@@ -274,220 +274,13 @@ def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = Tru
|
||||
applied += 1
|
||||
return ok(applied_count=applied, preview=preview)
|
||||
|
||||
|
||||
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 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 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)
|
||||
|
||||
from .uncategorized_workbench_service import (
|
||||
apply_uncategorized_group_recommendation,
|
||||
apply_uncategorized_recommendation,
|
||||
get_uncategorized_merchant_summary,
|
||||
get_uncategorized_workbench,
|
||||
list_uncategorized_transactions,
|
||||
)
|
||||
|
||||
DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [
|
||||
{"category": "수입", "subcategory": "급여", "sort_order": 10},
|
||||
@@ -599,6 +392,3 @@ def list_ledger_categories(active_only: bool = True, include_usage: bool = True,
|
||||
if item["subcategory"] and item["subcategory"] not in grouped[item["category"]]:
|
||||
grouped[item["category"]].append(item["subcategory"])
|
||||
return ok(categories=items, grouped=grouped, count=len(items))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,593 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from .shared import _append_memo_tag, _memo_has_tag, _normalize_counterparty_for_match, _party_match_score
|
||||
|
||||
def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]:
|
||||
index: dict[tuple[str, int, str], list[dict[str, Any]]] = {}
|
||||
for tx in transactions:
|
||||
if tx.get("status") != "active":
|
||||
continue
|
||||
key = (tx["transaction_date"], int(tx["amount"]), tx["type"])
|
||||
index.setdefault(key, []).append(tx)
|
||||
return index
|
||||
|
||||
|
||||
def _fetch_transfer_candidates(db_conn: Any, source: str) -> list[dict[str, Any]]:
|
||||
rows = db_conn.execute(
|
||||
"""
|
||||
SELECT t.id, t.transaction_datetime, t.transaction_date, t.type, t.amount, t.category, t.subcategory,
|
||||
t.counterparty, t.memo, t.person, t.account_id, t.payment_method, t.status,
|
||||
a.holder_name, t.merchant
|
||||
FROM transactions t
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
WHERE t.source=?
|
||||
AND t.type IN ('income','expense')
|
||||
AND (
|
||||
t.category='이체'
|
||||
OR t.subcategory IN ('확인필요', '본인계좌이동', '투자이동')
|
||||
OR t.payment_method IN ('내계좌간자동이전', '내계좌간자동이체', '오픈뱅킹', '펌뱅킹출금', '펌뱅킹입금', '입금', '출금', 'ATM출금', '적금개설')
|
||||
OR t.merchant LIKE '%이체%'
|
||||
)
|
||||
""",
|
||||
(source,),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _parse_tx_datetime(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
text = value.strip()
|
||||
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
try:
|
||||
return datetime.fromisoformat(text)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _self_transfer_direction_score(source_type: str, candidate_type: str) -> int:
|
||||
if source_type == "expense":
|
||||
if candidate_type == "income":
|
||||
return 3
|
||||
if candidate_type == "transfer":
|
||||
return 2
|
||||
elif source_type == "income":
|
||||
if candidate_type == "expense":
|
||||
return 3
|
||||
if candidate_type == "transfer":
|
||||
return 2
|
||||
elif source_type == "transfer":
|
||||
if candidate_type in {"income", "expense"}:
|
||||
return 2
|
||||
if candidate_type == "transfer":
|
||||
return 1
|
||||
return 0
|
||||
|
||||
|
||||
def _normalize_cancel_lookup_text(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
text = value.lower().strip()
|
||||
replacements = [
|
||||
"kb체크취소", "승인취소", "취소", "환불", "환급", "refund", "reversal",
|
||||
"카드입금", "카드환급", "매출취소", "입금", "출금", "체크카드", "신용카드",
|
||||
]
|
||||
for token in replacements:
|
||||
text = text.replace(token, " ")
|
||||
text = re.sub(r"\s+", " ", text)
|
||||
return _normalize_counterparty_for_match(text)
|
||||
|
||||
|
||||
def _cancel_channel(value: str) -> str:
|
||||
text = (value or "").lower()
|
||||
if any(token in text for token in ("카드", "check", "credit")):
|
||||
return "card"
|
||||
if any(token in text for token in ("이체", "오픈뱅킹", "출금", "입금", "bank")):
|
||||
return "bank"
|
||||
return "other"
|
||||
|
||||
|
||||
def _is_cancel_like_transaction(tx: dict[str, Any]) -> bool:
|
||||
if str(tx.get("type") or "") == "refund":
|
||||
return True
|
||||
combined = " ".join(
|
||||
str(tx.get(key) or "") for key in ("merchant", "raw_summary", "payment_method", "memo", "description")
|
||||
).lower()
|
||||
keywords = ("취소", "환불", "환급", "refund", "reversal", "카드입금", "매출취소")
|
||||
return str(tx.get("type") or "") == "income" and any(token in combined for token in keywords)
|
||||
|
||||
|
||||
def find_cancel_original_candidates(transaction_id: str, limit: int = 20, lookback_days: int = 365, db_path: str | None = None) -> dict[str, Any]:
|
||||
limit = max(1, min(int(limit), 50))
|
||||
lookback_days = max(1, min(int(lookback_days), 730))
|
||||
with db(db_path) as conn:
|
||||
source_row = conn.execute(
|
||||
"""
|
||||
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
||||
FROM transactions t
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
WHERE t.id=?
|
||||
""",
|
||||
(transaction_id,),
|
||||
).fetchone()
|
||||
if not source_row:
|
||||
return fail("not_found", f"transaction not found: {transaction_id}")
|
||||
source = row_to_dict(source_row)
|
||||
explicit_cancel_like = _is_cancel_like_transaction(source)
|
||||
source_type = str(source.get("type") or "")
|
||||
if source_type not in {"income", "refund"} and not explicit_cancel_like:
|
||||
return fail("not_cancel_like", "cancel/original lookup is intended for 취소·환급성 거래")
|
||||
|
||||
source_date = normalize_date(source.get("transaction_date"), default_today=False)
|
||||
source_dt = _parse_tx_datetime(source.get("transaction_datetime"))
|
||||
source_day = date.fromisoformat(source_date)
|
||||
start_date = date.fromordinal(source_day.toordinal() - lookback_days).isoformat()
|
||||
source_text = " ".join(str(source.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
|
||||
source_norm_text = _normalize_cancel_lookup_text(source_text)
|
||||
source_channel = _cancel_channel(str(source.get("payment_method") or "") + " " + str(source.get("raw_summary") or "") + " " + str(source.get("merchant") or ""))
|
||||
candidate_rows = conn.execute(
|
||||
"""
|
||||
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
||||
FROM transactions t
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
WHERE t.status='active'
|
||||
AND t.id<>?
|
||||
AND t.amount=?
|
||||
AND t.transaction_date BETWEEN ? AND ?
|
||||
AND t.type IN ('expense', 'adjustment')
|
||||
ORDER BY t.transaction_date DESC, t.transaction_datetime DESC, t.created_at DESC
|
||||
""",
|
||||
(transaction_id, int(source.get("amount") or 0), start_date, source_date),
|
||||
).fetchall()
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for row in candidate_rows:
|
||||
candidate = row_to_dict(row)
|
||||
candidate_date = normalize_date(candidate.get("transaction_date"), default_today=False)
|
||||
candidate_day = date.fromisoformat(candidate_date)
|
||||
day_delta = (source_day - candidate_day).days
|
||||
if day_delta < 0:
|
||||
continue
|
||||
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
|
||||
time_delta_seconds = abs(int((source_dt - candidate_dt).total_seconds())) if source_dt and candidate_dt else None
|
||||
same_account = bool(source.get("account_id") and source.get("account_id") == candidate.get("account_id"))
|
||||
source_same = bool(source.get("source") and source.get("source") == candidate.get("source"))
|
||||
candidate_text = " ".join(str(candidate.get(key) or "") for key in ("merchant", "counterparty", "raw_summary", "payment_method"))
|
||||
candidate_norm_text = _normalize_cancel_lookup_text(candidate_text)
|
||||
merchant_score = 0
|
||||
if source_norm_text and candidate_norm_text:
|
||||
if source_norm_text == candidate_norm_text:
|
||||
merchant_score = 4
|
||||
elif source_norm_text in candidate_norm_text or candidate_norm_text in source_norm_text:
|
||||
merchant_score = 2
|
||||
channel_score = 0
|
||||
candidate_channel = _cancel_channel(str(candidate.get("payment_method") or "") + " " + str(candidate.get("raw_summary") or "") + " " + str(candidate.get("merchant") or ""))
|
||||
if source_channel != "other" and source_channel == candidate_channel:
|
||||
channel_score = 2
|
||||
if not explicit_cancel_like and not (same_account and merchant_score >= 2 and channel_score >= 1):
|
||||
continue
|
||||
recency_score = 0
|
||||
if day_delta == 0:
|
||||
recency_score = 5
|
||||
elif day_delta <= 7:
|
||||
recency_score = 4
|
||||
elif day_delta <= 30:
|
||||
recency_score = 3
|
||||
elif day_delta <= 90:
|
||||
recency_score = 2
|
||||
else:
|
||||
recency_score = 1
|
||||
time_score = 0
|
||||
if time_delta_seconds is not None:
|
||||
if time_delta_seconds <= 3600:
|
||||
time_score = 2
|
||||
elif time_delta_seconds <= 86400:
|
||||
time_score = 1
|
||||
score = merchant_score + channel_score + recency_score + time_score + (3 if same_account else 0) + (1 if source_same else 0)
|
||||
reason_bits = [
|
||||
f"same_account={int(same_account)}",
|
||||
f"merchant={merchant_score}",
|
||||
f"channel={channel_score}",
|
||||
f"days={day_delta}",
|
||||
]
|
||||
if time_delta_seconds is not None:
|
||||
reason_bits.append(f"time_delta={time_delta_seconds}s")
|
||||
candidates.append({
|
||||
"id": candidate.get("id"),
|
||||
"transaction_date": candidate.get("transaction_date"),
|
||||
"transaction_datetime": candidate.get("transaction_datetime"),
|
||||
"source": candidate.get("source"),
|
||||
"type": candidate.get("type"),
|
||||
"amount": candidate.get("amount"),
|
||||
"category": candidate.get("category"),
|
||||
"subcategory": candidate.get("subcategory"),
|
||||
"merchant": candidate.get("merchant"),
|
||||
"raw_summary": candidate.get("raw_summary"),
|
||||
"counterparty": candidate.get("counterparty"),
|
||||
"account_id": candidate.get("account_id"),
|
||||
"account_name": candidate.get("account_name"),
|
||||
"institution": candidate.get("institution"),
|
||||
"payment_method": candidate.get("payment_method"),
|
||||
"memo": candidate.get("memo"),
|
||||
"score": score,
|
||||
"day_delta": day_delta,
|
||||
"time_delta_seconds": time_delta_seconds,
|
||||
"reason": ", ".join(reason_bits),
|
||||
})
|
||||
|
||||
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 9999)), int(item.get("time_delta_seconds") or 99999999), str(item.get("transaction_datetime") or "")))
|
||||
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="cancel_original_candidate_search")
|
||||
|
||||
|
||||
def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||
limit = max(1, min(int(limit), 50))
|
||||
with db(db_path) as conn:
|
||||
source_row = conn.execute(
|
||||
"""
|
||||
SELECT t.*, a.name AS account_name, a.institution, a.holder_name
|
||||
FROM transactions t
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
WHERE t.id=?
|
||||
""",
|
||||
(transaction_id,),
|
||||
).fetchone()
|
||||
if not source_row:
|
||||
return fail("not_found", f"transaction not found: {transaction_id}")
|
||||
|
||||
source = row_to_dict(source_row)
|
||||
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", ""))
|
||||
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
|
||||
FROM transactions t
|
||||
LEFT JOIN accounts a ON a.id = t.account_id
|
||||
WHERE t.status='active'
|
||||
AND t.id<>?
|
||||
AND COALESCE(t.account_id, '')<>COALESCE(?, '')
|
||||
AND t.amount=?
|
||||
AND t.transaction_date BETWEEN ? AND ?
|
||||
ORDER BY t.transaction_date, t.transaction_datetime, t.created_at
|
||||
""",
|
||||
(
|
||||
transaction_id,
|
||||
source.get("account_id", ""),
|
||||
int(source.get("amount") or 0),
|
||||
date.fromordinal(window_start).isoformat(),
|
||||
date.fromordinal(window_end).isoformat(),
|
||||
),
|
||||
).fetchall()
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for row in candidate_rows:
|
||||
candidate = row_to_dict(row)
|
||||
direction_score = _self_transfer_direction_score(str(source.get("type", "")), str(candidate.get("type", "")))
|
||||
if direction_score == 0:
|
||||
continue
|
||||
|
||||
candidate_dt = _parse_tx_datetime(candidate.get("transaction_datetime"))
|
||||
day_delta = abs((date.fromisoformat(candidate.get("transaction_date", source_date)) - date.fromisoformat(source_date)).days)
|
||||
time_delta_seconds = abs(int((candidate_dt - source_dt).total_seconds())) if candidate_dt and source_dt else None
|
||||
same_owner = bool(source_holder and source_holder == _normalize_counterparty_for_match(candidate.get("holder_name", "")))
|
||||
party_score = max(
|
||||
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("counterparty", ""))),
|
||||
_party_match_score(str(source.get("counterparty", "")), str(candidate.get("holder_name", ""))),
|
||||
_party_match_score(str(source.get("merchant", "")), str(candidate.get("counterparty", ""))),
|
||||
_party_match_score(str(source.get("merchant", "")), str(candidate.get("holder_name", ""))),
|
||||
)
|
||||
source_gap_score = 1 if source.get("source") != candidate.get("source") else 0
|
||||
day_score = 3 if day_delta == 0 else 1
|
||||
time_score = 0
|
||||
if time_delta_seconds is not None:
|
||||
if time_delta_seconds <= 120:
|
||||
time_score = 3
|
||||
elif time_delta_seconds <= 3600:
|
||||
time_score = 2
|
||||
elif time_delta_seconds <= 86400:
|
||||
time_score = 1
|
||||
score = direction_score + party_score + source_gap_score + day_score + time_score + (3 if same_owner else 0)
|
||||
reason_bits = [
|
||||
f"direction={direction_score}",
|
||||
f"party={party_score}",
|
||||
f"same_owner={int(same_owner)}",
|
||||
f"same_source={int(source.get('source') == candidate.get('source'))}",
|
||||
f"day_delta={day_delta}",
|
||||
]
|
||||
if time_delta_seconds is not None:
|
||||
reason_bits.append(f"time_delta={time_delta_seconds}s")
|
||||
candidates.append(
|
||||
{
|
||||
"id": candidate.get("id"),
|
||||
"transaction_date": candidate.get("transaction_date"),
|
||||
"transaction_datetime": candidate.get("transaction_datetime"),
|
||||
"source": candidate.get("source"),
|
||||
"type": candidate.get("type"),
|
||||
"amount": candidate.get("amount"),
|
||||
"category": candidate.get("category"),
|
||||
"subcategory": candidate.get("subcategory"),
|
||||
"merchant": candidate.get("merchant"),
|
||||
"counterparty": candidate.get("counterparty"),
|
||||
"account_id": candidate.get("account_id"),
|
||||
"account_name": candidate.get("account_name"),
|
||||
"institution": candidate.get("institution"),
|
||||
"holder_name": candidate.get("holder_name"),
|
||||
"memo": candidate.get("memo"),
|
||||
"score": score,
|
||||
"same_owner": same_owner,
|
||||
"day_delta": day_delta,
|
||||
"time_delta_seconds": time_delta_seconds,
|
||||
"reason": ", ".join(reason_bits),
|
||||
}
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda item: (-int(item.get("score", 0)), int(item.get("day_delta", 99)), int(item.get("time_delta_seconds") or 999999), str(item.get("transaction_datetime") or "")))
|
||||
return ok(source_tx=source, count=len(candidates[:limit]), candidates=candidates[:limit], source="self_transfer_candidate_search")
|
||||
|
||||
|
||||
def link_self_transfer_pair(source_transaction_id: str, candidate_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
if not source_transaction_id or not candidate_transaction_id:
|
||||
return fail("validation_error", "both transaction ids are required")
|
||||
if source_transaction_id == candidate_transaction_id:
|
||||
return fail("validation_error", "source and candidate transaction must differ")
|
||||
|
||||
preview = find_self_transfer_candidates(source_transaction_id, limit=50, db_path=db_path)
|
||||
if not preview.get("success"):
|
||||
return preview
|
||||
candidates = preview.get("candidates") or []
|
||||
candidate = next((item for item in candidates if item.get("id") == candidate_transaction_id), None)
|
||||
if candidate is None:
|
||||
return fail("candidate_not_found", f"candidate not found for source transaction: {candidate_transaction_id}")
|
||||
|
||||
link_tag = f"manual_self_transfer_link:{source_transaction_id}|{candidate_transaction_id}"
|
||||
ts = now_iso()
|
||||
with db(db_path) as conn:
|
||||
source_row = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
|
||||
candidate_row = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
|
||||
if not source_row or not candidate_row:
|
||||
return fail("not_found", "source or candidate transaction not found")
|
||||
source_memo = _append_memo_tag(source_row["memo"], link_tag)
|
||||
candidate_memo = _append_memo_tag(candidate_row["memo"], link_tag)
|
||||
conn.execute(
|
||||
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
|
||||
(source_memo, ts, source_transaction_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE transactions SET category='이체', subcategory='본인계좌이동', memo=?, updated_at=? WHERE id=?",
|
||||
(candidate_memo, ts, candidate_transaction_id),
|
||||
)
|
||||
updated_source = conn.execute("SELECT * FROM transactions WHERE id=?", (source_transaction_id,)).fetchone()
|
||||
updated_candidate = conn.execute("SELECT * FROM transactions WHERE id=?", (candidate_transaction_id,)).fetchone()
|
||||
|
||||
return ok(
|
||||
source_transaction=row_to_dict(updated_source),
|
||||
candidate_transaction=row_to_dict(updated_candidate),
|
||||
link_tag=link_tag,
|
||||
score=candidate.get("score"),
|
||||
reason=candidate.get("reason"),
|
||||
source="manual_self_transfer_link",
|
||||
)
|
||||
|
||||
|
||||
def link_cancel_to_original(cancel_transaction_id: str, original_transaction_id: str, db_path: str | None = None) -> dict[str, Any]:
|
||||
if not cancel_transaction_id or not original_transaction_id:
|
||||
return fail("validation_error", "both transaction ids are required")
|
||||
if cancel_transaction_id == original_transaction_id:
|
||||
return fail("validation_error", "cancel and original transaction must differ")
|
||||
|
||||
preview = find_cancel_original_candidates(cancel_transaction_id, limit=50, db_path=db_path)
|
||||
if not preview.get("success"):
|
||||
return preview
|
||||
candidates = preview.get("candidates") or []
|
||||
original = next((item for item in candidates if item.get("id") == original_transaction_id), None)
|
||||
if original is None:
|
||||
return fail("candidate_not_found", f"original transaction candidate not found: {original_transaction_id}")
|
||||
|
||||
link_tag = f"manual_cancel_link:cancel={cancel_transaction_id}|original={original_transaction_id}"
|
||||
ts = now_iso()
|
||||
with db(db_path) as conn:
|
||||
cancel_row = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
|
||||
original_row = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
|
||||
if not cancel_row or not original_row:
|
||||
return fail("not_found", "cancel or original transaction not found")
|
||||
|
||||
cancel_memo = _append_memo_tag(cancel_row["memo"], link_tag)
|
||||
original_memo = _append_memo_tag(original_row["memo"], link_tag)
|
||||
original_category = original_row["category"] or "수입"
|
||||
original_subcategory = original_row["subcategory"] or "환급"
|
||||
original_person = original_row["person"] or ""
|
||||
conn.execute(
|
||||
"UPDATE transactions SET type='refund', category=?, subcategory=?, person=?, memo=?, updated_at=? WHERE id=?",
|
||||
(original_category, original_subcategory, original_person, cancel_memo, ts, cancel_transaction_id),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE transactions SET memo=?, updated_at=? WHERE id=?",
|
||||
(original_memo, ts, original_transaction_id),
|
||||
)
|
||||
updated_cancel = conn.execute("SELECT * FROM transactions WHERE id=?", (cancel_transaction_id,)).fetchone()
|
||||
updated_original = conn.execute("SELECT * FROM transactions WHERE id=?", (original_transaction_id,)).fetchone()
|
||||
|
||||
return ok(
|
||||
cancel_transaction=row_to_dict(updated_cancel),
|
||||
original_transaction=row_to_dict(updated_original),
|
||||
link_tag=link_tag,
|
||||
score=original.get("score"),
|
||||
reason=original.get("reason"),
|
||||
source="manual_cancel_link",
|
||||
)
|
||||
|
||||
|
||||
def _tag_memos(conn: Any, tx_id: str, tag: str) -> None:
|
||||
existing = conn.execute("SELECT memo FROM transactions WHERE id=?", (tx_id,)).fetchone()
|
||||
if not existing:
|
||||
return
|
||||
memo = _append_memo_tag(existing["memo"], tag)
|
||||
conn.execute("UPDATE transactions SET memo=?, updated_at=? WHERE id=?", (memo, now_iso(), tx_id))
|
||||
|
||||
|
||||
def _run_cross_account_transfer_match(limit: int = 500, dry_run: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
limit = max(1, min(int(limit), 1000))
|
||||
matched_pairs: list[dict[str, Any]] = []
|
||||
review_items: list[dict[str, Any]] = []
|
||||
matched_tx_ids: set[str] = set()
|
||||
|
||||
with db(db_path) as conn:
|
||||
kb_rows = _fetch_transfer_candidates(conn, "kb_bank_pdf")
|
||||
toss_rows = _fetch_transfer_candidates(conn, "toss_bank_pdf")
|
||||
toss_index = _build_cross_transfer_index(toss_rows)
|
||||
|
||||
for kb in kb_rows:
|
||||
if kb["status"] != "active":
|
||||
continue
|
||||
if _memo_has_tag(kb.get("memo", ""), CROSS_TRANSFER_MATCH_TAG):
|
||||
continue
|
||||
required_type = "income" if kb["type"] == "expense" else "expense"
|
||||
candidates = [
|
||||
t
|
||||
for t in toss_index.get((kb["transaction_date"], int(kb["amount"]), required_type), [])
|
||||
if t.get("status") == "active" and not _memo_has_tag(t.get("memo", ""), CROSS_TRANSFER_MATCH_TAG)
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
review_items.append(
|
||||
{
|
||||
"source_tx_id": kb["id"],
|
||||
"source": "kb_bank_pdf",
|
||||
"target_tx_id": "",
|
||||
"reason": "no_candidate_found",
|
||||
"type": kb["type"],
|
||||
"amount": kb["amount"],
|
||||
"transaction_date": kb["transaction_date"],
|
||||
"counterparty": kb["counterparty"],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
scored = []
|
||||
for candidate in candidates:
|
||||
score = _party_match_score(kb.get("counterparty", ""), candidate.get("counterparty", ""))
|
||||
scored.append((score, candidate["transaction_datetime"], candidate))
|
||||
scored.sort(key=lambda item: (item[0], item[1]), reverse=True)
|
||||
best_score = scored[0][0]
|
||||
top = [c for c in scored if c[0] == best_score]
|
||||
|
||||
if best_score == 0 or len(top) != 1:
|
||||
review_items.append(
|
||||
{
|
||||
"source_tx_id": kb["id"],
|
||||
"source": "kb_bank_pdf",
|
||||
"target_tx_id": "",
|
||||
"reason": "ambiguous_or_name_mismatch",
|
||||
"candidate_count": len(candidates),
|
||||
"best_name_score": best_score,
|
||||
"type": kb["type"],
|
||||
"amount": kb["amount"],
|
||||
"transaction_date": kb["transaction_date"],
|
||||
"counterparty": kb["counterparty"],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
toss = top[0][2]
|
||||
if toss["id"] in matched_tx_ids:
|
||||
review_items.append(
|
||||
{
|
||||
"source_tx_id": kb["id"],
|
||||
"source": "kb_bank_pdf",
|
||||
"target_tx_id": toss["id"],
|
||||
"reason": "target_already_matched",
|
||||
"type": kb["type"],
|
||||
"amount": kb["amount"],
|
||||
"transaction_date": kb["transaction_date"],
|
||||
"counterparty": kb["counterparty"],
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
kb_party = _normalize_counterparty_for_match(kb.get("counterparty", ""))
|
||||
toss_party = _normalize_counterparty_for_match(toss.get("counterparty", ""))
|
||||
owner_candidates = {
|
||||
_normalize_counterparty_for_match(kb.get("holder_name", "")),
|
||||
_normalize_counterparty_for_match(toss.get("holder_name", "")),
|
||||
}
|
||||
if (kb_party and kb_party in owner_candidates) or (toss_party and toss_party in owner_candidates):
|
||||
subcategory = "투자이동"
|
||||
else:
|
||||
subcategory = "확인필요"
|
||||
|
||||
matched_pairs.append(
|
||||
{
|
||||
"kb_tx_id": kb["id"],
|
||||
"toss_tx_id": toss["id"],
|
||||
"transaction_date": kb["transaction_date"],
|
||||
"amount": kb["amount"],
|
||||
"counterparty_kb": kb["counterparty"],
|
||||
"counterparty_toss": toss["counterparty"],
|
||||
"subcategory": subcategory,
|
||||
"name_score": best_score,
|
||||
"reason": "paired_by_date_amount_party",
|
||||
}
|
||||
)
|
||||
if not dry_run:
|
||||
review_tag = f"{CROSS_TRANSFER_MATCH_TAG}kb={kb['id']}|toss={toss['id']}"
|
||||
conn.execute(
|
||||
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
|
||||
(subcategory, _append_memo_tag(kb.get("memo", ""), review_tag), now_iso(), kb["id"]),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE transactions SET category='이체', subcategory=?, memo=?, updated_at=? WHERE id=?",
|
||||
(subcategory, _append_memo_tag(toss.get("memo", ""), review_tag), now_iso(), toss["id"]),
|
||||
)
|
||||
|
||||
if not dry_run:
|
||||
_tag_memos(conn, kb["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
|
||||
_tag_memos(conn, toss["id"], f"{CROSS_TRANSFER_REVIEW_TAG}paired")
|
||||
matched_tx_ids.add(kb["id"])
|
||||
matched_tx_ids.add(toss["id"])
|
||||
|
||||
if len(matched_pairs) >= limit:
|
||||
break
|
||||
|
||||
for review in review_items:
|
||||
if review["source_tx_id"] in matched_tx_ids:
|
||||
continue
|
||||
if not dry_run:
|
||||
_tag_memos(conn, review["source_tx_id"], f"{CROSS_TRANSFER_REVIEW_TAG}{review['reason']}")
|
||||
|
||||
|
||||
return ok(
|
||||
dry_run=dry_run,
|
||||
matched_count=len(matched_pairs),
|
||||
review_count=len(review_items),
|
||||
matched_pairs=matched_pairs[:limit],
|
||||
review_items=review_items[:limit],
|
||||
source="kb_toss_cross_transfer_match",
|
||||
)
|
||||
|
||||
|
||||
def preview_cross_account_transfers(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
|
||||
return _run_cross_account_transfer_match(limit=limit, dry_run=True, db_path=db_path)
|
||||
|
||||
|
||||
def run_cross_account_transfer_match(limit: int = 500, db_path: str | None = None) -> dict[str, Any]:
|
||||
return _run_cross_account_transfer_match(limit=limit, dry_run=False, db_path=db_path)
|
||||
|
||||
|
||||
def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses = ["t.status='active'", "t.source IN ('kb_bank_pdf','toss_bank_pdf')"]
|
||||
clauses.append("t.memo LIKE '%cross_transfer_review:%'")
|
||||
where = " WHERE " + " AND ".join(clauses)
|
||||
with db(db_path) as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
|
||||
(max(1, min(int(limit), 1000)),),
|
||||
).fetchall()
|
||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows), source="cross_transfer_review_queue")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,922 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
from .shared import _append_memo_tag, _memo_has_tag, _normalize_counterparty_for_match, _party_match_score
|
||||
|
||||
def preview_ledger_csv_import(file_path: str, source: str = "generic", db_path: str | None = None) -> dict[str, Any]:
|
||||
path=Path(file_path).expanduser()
|
||||
if not path.exists():
|
||||
return fail("not_found", f"CSV file not found: {path}")
|
||||
with path.open("r", encoding="utf-8-sig", newline="") as f:
|
||||
rows=list(csv.DictReader(f))
|
||||
preview=[]
|
||||
for r in rows[:20]:
|
||||
preview.append(r)
|
||||
return ok(source=source, filename=str(path), row_count=len(rows), preview=preview, required_columns=["transaction_date/date", "amount", "type optional", "category optional", "merchant/description optional"])
|
||||
|
||||
|
||||
def preview_kb_bank_pdf_import(file_path: str, password: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_kb_bank_pdf(file_path, password)
|
||||
except FileNotFoundError as e:
|
||||
return fail("not_found", str(e))
|
||||
except ValueError as e:
|
||||
return fail("pdf_password_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
transactions = parsed["transactions"]
|
||||
validation = parsed["validation"]
|
||||
source_hash = parsed["source_hash"]
|
||||
duplicate_count = 0
|
||||
with db(db_path) as conn:
|
||||
for tx in transactions:
|
||||
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, "", tx.external_id(source_hash))
|
||||
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
|
||||
duplicate_count += 1 if found else 0
|
||||
preview = [transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
|
||||
return ok(
|
||||
source="kb_bank_pdf",
|
||||
file_path=parsed["file_path"],
|
||||
source_hash=source_hash,
|
||||
page_count=parsed["page_count"],
|
||||
metadata=parsed["metadata"],
|
||||
row_count=len(transactions),
|
||||
duplicate_count=duplicate_count,
|
||||
validation=validation,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
|
||||
def _find_or_create_kb_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
|
||||
if account_id:
|
||||
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if row:
|
||||
return account_id
|
||||
raise ValueError(f"account_id not found: {account_id}")
|
||||
account_number_masked = metadata.get("account_number_masked") or ""
|
||||
row = None
|
||||
if account_number_masked:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||
("KB국민은행", account_number_masked),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
ts = now_iso()
|
||||
new_account_id = new_id("acct")
|
||||
account_name = metadata.get("account_name") or "KB국민은행 계좌"
|
||||
display_name = f"KB국민은행 {account_name} {account_number_masked}".strip()
|
||||
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(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
new_account_id,
|
||||
display_name,
|
||||
"bank",
|
||||
"KB국민은행",
|
||||
"KRW",
|
||||
1,
|
||||
"국민은행 거래내역 PDF import로 자동 생성",
|
||||
account_number_masked,
|
||||
metadata.get("holder_name") or "",
|
||||
metadata.get("final_balance"),
|
||||
ts,
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
return new_account_id
|
||||
|
||||
|
||||
def import_kb_bank_pdf(file_path: str, password: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None, skip_validation: bool = False) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_kb_bank_pdf(file_path, password)
|
||||
except FileNotFoundError as e:
|
||||
return fail("not_found", str(e))
|
||||
except ValueError as e:
|
||||
return fail("pdf_password_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
transactions = parsed["transactions"]
|
||||
validation = parsed["validation"]
|
||||
if not validation.get("ok") and not skip_validation:
|
||||
return fail("balance_validation_failed", "KB Bank PDF balance flow validation failed", validation=validation)
|
||||
source_hash = parsed["source_hash"]
|
||||
metadata = parsed["metadata"]
|
||||
ts = now_iso()
|
||||
batch_id = new_id("imp")
|
||||
inserted = 0
|
||||
skipped_duplicates = 0
|
||||
inserted_ids: list[str] = []
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
linked_account_id = _find_or_create_kb_account(conn, metadata, account_id)
|
||||
conn.execute(
|
||||
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
|
||||
VALUES(?,?,?,?,?,?,?)""",
|
||||
(
|
||||
batch_id,
|
||||
"kb_bank_pdf",
|
||||
parsed["file_path"],
|
||||
ts,
|
||||
0,
|
||||
"active",
|
||||
json.dumps(
|
||||
{
|
||||
"source_hash": source_hash,
|
||||
"page_count": parsed["page_count"],
|
||||
"metadata": metadata,
|
||||
"validation": validation,
|
||||
"parsed_row_count": len(transactions),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
for tx in transactions:
|
||||
tx_id = new_id("tx")
|
||||
external_id = tx.external_id(source_hash)
|
||||
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, tx.counterparty, linked_account_id, external_id)
|
||||
payload = tx.raw_payload()
|
||||
description = f"국민은행 PDF import; 적요={tx.raw_summary}; 거래점={tx.branch}; 잔액={tx.balance_after}"
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
tx_id,
|
||||
tx.transaction_date,
|
||||
"",
|
||||
tx.type,
|
||||
tx.amount,
|
||||
"KRW",
|
||||
"",
|
||||
"",
|
||||
tx.counterparty,
|
||||
description,
|
||||
linked_account_id,
|
||||
tx.raw_summary,
|
||||
"",
|
||||
"",
|
||||
normalize_tags(["kb_bank_pdf", tx.raw_summary] if tx.raw_summary else ["kb_bank_pdf"]),
|
||||
0,
|
||||
"kb_bank_pdf",
|
||||
batch_id,
|
||||
external_id,
|
||||
dedupe_key,
|
||||
tx.remittance_memo,
|
||||
tx.transaction_datetime,
|
||||
tx.counterparty,
|
||||
tx.balance_after,
|
||||
tx.branch,
|
||||
tx.raw_summary,
|
||||
payload,
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
inserted_ids.append(tx_id)
|
||||
except Exception as e:
|
||||
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
|
||||
skipped_duplicates += 1
|
||||
continue
|
||||
raise
|
||||
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||
if transactions:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?",
|
||||
(transactions[-1].balance_after, ts, ts, linked_account_id),
|
||||
)
|
||||
except ValueError as e:
|
||||
return fail("validation_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("db_error", str(e))
|
||||
return ok(
|
||||
import_batch_id=batch_id,
|
||||
account_id=linked_account_id,
|
||||
source="kb_bank_pdf",
|
||||
parsed_row_count=len(transactions),
|
||||
inserted_count=inserted,
|
||||
skipped_duplicate_count=skipped_duplicates,
|
||||
inserted_transaction_ids=inserted_ids[:20],
|
||||
validation=validation,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _find_or_create_seoulpay_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
|
||||
if account_id:
|
||||
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if row:
|
||||
return account_id
|
||||
raise ValueError(f"account_id not found: {account_id}")
|
||||
holder_name = metadata.get("holder_name") or ""
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||
("서울페이", f"서울페이 이용내역 {holder_name}".strip()),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
ts = now_iso()
|
||||
new_account_id = new_id("acct")
|
||||
display_name = f"서울페이 이용내역 {holder_name}".strip()
|
||||
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(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
new_account_id,
|
||||
display_name,
|
||||
"cash",
|
||||
"서울페이",
|
||||
"KRW",
|
||||
1,
|
||||
"서울페이 이용내역 XLSX import로 자동 생성",
|
||||
"",
|
||||
holder_name,
|
||||
None,
|
||||
ts,
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
return new_account_id
|
||||
|
||||
|
||||
def _classify_seoulpay_transaction(tx: Any) -> tuple[str, str, str, bool]:
|
||||
merchant = (tx.merchant or "").strip()
|
||||
text = f"{merchant} {tx.transaction_kind} {tx.payment_kind}"
|
||||
if tx.type == "refund":
|
||||
return "수입", "환급", merchant, False
|
||||
if "땡겨요" in text or "배달" in text:
|
||||
return "식비", "배달", merchant, False
|
||||
if any(keyword in text for keyword in ("교습소", "학원", "바이올린", "피아노")):
|
||||
return "교육", "학원", merchant, False
|
||||
if any(keyword in text for keyword in ("김영모", "베이커리", "제과", "카페", "커피")):
|
||||
return "식비", "카페", merchant, False
|
||||
if "온라인" in text:
|
||||
return "쇼핑", "온라인", merchant, False
|
||||
return "쇼핑", "생활용품", merchant, False
|
||||
|
||||
|
||||
def _find_or_create_toss_bank_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
|
||||
if account_id:
|
||||
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if row:
|
||||
return account_id
|
||||
raise ValueError(f"account_id not found: {account_id}")
|
||||
account_number_masked = metadata.get("account_number_masked") or ""
|
||||
row = None
|
||||
if account_number_masked:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||
("토스뱅크", account_number_masked),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
ts = now_iso()
|
||||
new_account_id = new_id("acct")
|
||||
account_name = metadata.get("account_name") or "토스뱅크 계좌"
|
||||
display_name = f"토스뱅크 {account_name} {account_number_masked}".strip()
|
||||
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(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
new_account_id,
|
||||
display_name,
|
||||
"bank",
|
||||
"토스뱅크",
|
||||
"KRW",
|
||||
1,
|
||||
"토스뱅크 거래내역 PDF import로 자동 생성",
|
||||
account_number_masked,
|
||||
metadata.get("holder_name") or "",
|
||||
metadata.get("final_balance"),
|
||||
ts,
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
return new_account_id
|
||||
|
||||
|
||||
def _classify_toss_bank_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]:
|
||||
action = (tx.action or "").strip()
|
||||
counterparty = (tx.counterparty or "").strip()
|
||||
owner_name = (owner_name or "").strip()
|
||||
if "이자" in action:
|
||||
return "수입", "이자", counterparty, False
|
||||
if "캐시백" in action:
|
||||
return "수입", "캐시백", counterparty, False
|
||||
if "체크카드결제" in action:
|
||||
return "식비", "카드결제", counterparty, False
|
||||
transfer_like = {
|
||||
"이체",
|
||||
"내계좌간자동이체",
|
||||
"오픈뱅킹",
|
||||
"펌뱅킹출금",
|
||||
"펌뱅킹입금",
|
||||
"적금 개설",
|
||||
"출금",
|
||||
"입금",
|
||||
"ATM출금",
|
||||
}
|
||||
if any(keyword in action for keyword in transfer_like):
|
||||
if counterparty and owner_name and counterparty != owner_name:
|
||||
return "이체", "확인필요", counterparty, False
|
||||
return "이체", "본인계좌이동", counterparty, False
|
||||
if "캐시백" in action:
|
||||
return "수입", "캐시백", counterparty, False
|
||||
return "이체", "확인필요", counterparty, False
|
||||
|
||||
def preview_seoulpay_xlsx_import(file_path: str, password: str = "", preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_seoulpay_xlsx(file_path, password=password)
|
||||
except FileNotFoundError as e:
|
||||
return fail("not_found", str(e))
|
||||
except ValueError as e:
|
||||
return fail("xlsx_parse_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("xlsx_parse_error", str(e))
|
||||
|
||||
transactions = parsed["transactions"]
|
||||
source_hash = parsed["source_hash"]
|
||||
duplicate_count = 0
|
||||
by_category: dict[str, dict[str, int]] = {}
|
||||
with db(db_path) as conn:
|
||||
linked_account_id = ""
|
||||
metadata = parsed["metadata"]
|
||||
holder_name = metadata.get("holder_name") or ""
|
||||
if holder_name:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE institution=? AND name=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||
("서울페이", f"서울페이 이용내역 {holder_name}".strip()),
|
||||
).fetchone()
|
||||
linked_account_id = row["id"] if row else ""
|
||||
for tx in transactions:
|
||||
category, subcategory, _, _ = _classify_seoulpay_transaction(tx)
|
||||
key = f"{category}/{subcategory}"
|
||||
item = by_category.setdefault(key, {"count": 0, "amount": 0})
|
||||
item["count"] += 1
|
||||
item["amount"] += int(tx.amount)
|
||||
merchant = f"서울페이 {tx.merchant}".strip()
|
||||
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash))
|
||||
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
|
||||
duplicate_count += 1 if found else 0
|
||||
|
||||
preview = [seoulpay_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
|
||||
return ok(
|
||||
source="seoulpay_xlsx",
|
||||
file_path=parsed["file_path"],
|
||||
source_hash=source_hash,
|
||||
sheet_name=parsed["sheet_name"],
|
||||
metadata=parsed["metadata"],
|
||||
row_count=len(transactions),
|
||||
duplicate_count=duplicate_count,
|
||||
validation=parsed["validation"],
|
||||
by_category=by_category,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
|
||||
def import_seoulpay_xlsx(file_path: str, password: str = "", account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_seoulpay_xlsx(file_path, password=password)
|
||||
except FileNotFoundError as e:
|
||||
return fail("not_found", str(e))
|
||||
except ValueError as e:
|
||||
return fail("xlsx_parse_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("xlsx_parse_error", str(e))
|
||||
|
||||
transactions = parsed["transactions"]
|
||||
validation = parsed["validation"]
|
||||
if not validation.get("ok"):
|
||||
return fail("xlsx_parse_error", "SeoulPay XLSX parsing produced invalid transactions", validation=validation)
|
||||
|
||||
source_hash = parsed["source_hash"]
|
||||
metadata = parsed["metadata"]
|
||||
ts = now_iso()
|
||||
batch_id = new_id("imp")
|
||||
inserted = 0
|
||||
skipped_duplicates = 0
|
||||
inserted_ids: list[str] = []
|
||||
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
linked_account_id = _find_or_create_seoulpay_account(conn, metadata, account_id)
|
||||
conn.execute(
|
||||
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
|
||||
VALUES(?,?,?,?,?,?,?)""",
|
||||
(
|
||||
batch_id,
|
||||
"seoulpay_xlsx",
|
||||
parsed["file_path"],
|
||||
ts,
|
||||
0,
|
||||
"active",
|
||||
json.dumps(
|
||||
{
|
||||
"source_hash": source_hash,
|
||||
"sheet_name": parsed["sheet_name"],
|
||||
"metadata": metadata,
|
||||
"validation": validation,
|
||||
"parsed_row_count": len(transactions),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
for tx in transactions:
|
||||
tx_id = new_id("tx")
|
||||
external_id = tx.external_id(source_hash)
|
||||
merchant = f"서울페이 {tx.merchant}".strip()
|
||||
category, subcategory, person, is_fixed = _classify_seoulpay_transaction(tx)
|
||||
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id)
|
||||
description = f"서울페이 XLSX import; 거래유형={tx.transaction_kind}; 결제구분={tx.payment_kind}; 거래번호={tx.transaction_no}; 가맹점ID={tx.merchant_id}"
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
tx_id,
|
||||
tx.transaction_date,
|
||||
"",
|
||||
tx.type,
|
||||
tx.amount,
|
||||
"KRW",
|
||||
category,
|
||||
subcategory,
|
||||
merchant,
|
||||
description,
|
||||
linked_account_id,
|
||||
tx.payment_kind,
|
||||
person,
|
||||
"",
|
||||
normalize_tags(["seoulpay_xlsx", tx.channel, tx.payment_kind, tx.transaction_kind]),
|
||||
int(is_fixed),
|
||||
"seoulpay_xlsx",
|
||||
batch_id,
|
||||
external_id,
|
||||
dedupe_key,
|
||||
tx.payment_kind,
|
||||
tx.transaction_datetime,
|
||||
tx.merchant,
|
||||
None,
|
||||
tx.channel,
|
||||
tx.transaction_kind,
|
||||
tx.raw_payload(),
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
inserted_ids.append(tx_id)
|
||||
except Exception as e:
|
||||
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
|
||||
skipped_duplicates += 1
|
||||
continue
|
||||
raise
|
||||
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||
except ValueError as e:
|
||||
return fail("validation_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("db_error", str(e))
|
||||
|
||||
return ok(
|
||||
import_batch_id=batch_id,
|
||||
account_id=linked_account_id,
|
||||
source="seoulpay_xlsx",
|
||||
parsed_row_count=len(transactions),
|
||||
inserted_count=inserted,
|
||||
skipped_duplicate_count=skipped_duplicates,
|
||||
inserted_transaction_ids=inserted_ids[:20],
|
||||
validation=validation,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def preview_toss_bank_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_toss_bank_pdf(file_path)
|
||||
except FileNotFoundError as e:
|
||||
return fail("not_found", str(e))
|
||||
except ValueError as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
|
||||
transactions = parsed["transactions"]
|
||||
source_hash = parsed["source_hash"]
|
||||
duplicate_count = 0
|
||||
with db(db_path) as conn:
|
||||
metadata = parsed["metadata"]
|
||||
account_number_masked = metadata.get("account_number_masked") or ""
|
||||
linked_account_id = ""
|
||||
if account_number_masked:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||
("토스뱅크", account_number_masked),
|
||||
).fetchone()
|
||||
linked_account_id = row["id"] if row else ""
|
||||
for tx in transactions:
|
||||
merchant = f"토스뱅크 {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "")
|
||||
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash))
|
||||
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
|
||||
duplicate_count += 1 if found else 0
|
||||
|
||||
preview = [toss_bank_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
|
||||
by_action: dict[str, dict[str, int]] = {}
|
||||
for tx in transactions:
|
||||
item = by_action.setdefault(tx.action, {"count": 0, "amount": 0})
|
||||
item["count"] += 1
|
||||
item["amount"] += int(tx.amount)
|
||||
return ok(
|
||||
source="toss_bank_pdf",
|
||||
file_path=parsed["file_path"],
|
||||
source_hash=source_hash,
|
||||
page_count=parsed["page_count"],
|
||||
metadata=parsed["metadata"],
|
||||
row_count=len(transactions),
|
||||
duplicate_count=duplicate_count,
|
||||
validation=parsed["validation"],
|
||||
by_action=by_action,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
|
||||
def import_toss_bank_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_toss_bank_pdf(file_path)
|
||||
except FileNotFoundError as e:
|
||||
return fail("not_found", str(e))
|
||||
except ValueError as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
|
||||
transactions = parsed["transactions"]
|
||||
validation = parsed["validation"]
|
||||
if not validation.get("ok"):
|
||||
return fail("pdf_parse_error", "Toss Bank PDF parsing produced no valid transactions", validation=validation)
|
||||
|
||||
source_hash = parsed["source_hash"]
|
||||
metadata = parsed["metadata"]
|
||||
ts = now_iso()
|
||||
batch_id = new_id("imp")
|
||||
inserted = 0
|
||||
skipped_duplicates = 0
|
||||
inserted_ids: list[str] = []
|
||||
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
linked_account_id = _find_or_create_toss_bank_account(conn, metadata, account_id)
|
||||
conn.execute(
|
||||
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
|
||||
VALUES(?,?,?,?,?,?,?)""",
|
||||
(
|
||||
batch_id,
|
||||
"toss_bank_pdf",
|
||||
parsed["file_path"],
|
||||
ts,
|
||||
0,
|
||||
"active",
|
||||
json.dumps(
|
||||
{
|
||||
"source_hash": source_hash,
|
||||
"page_count": parsed["page_count"],
|
||||
"metadata": metadata,
|
||||
"validation": validation,
|
||||
"parsed_row_count": len(transactions),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
for tx in transactions:
|
||||
tx_id = new_id("tx")
|
||||
external_id = tx.external_id(source_hash)
|
||||
merchant = f"토스뱅크 {tx.action}" + (f" {tx.counterparty}" if tx.counterparty else "")
|
||||
category, subcategory, person, is_fixed = _classify_toss_bank_transaction(tx, metadata.get("holder_name") or "")
|
||||
if category == "이체":
|
||||
tx_type = "transfer"
|
||||
else:
|
||||
tx_type = tx.type
|
||||
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id)
|
||||
description = f"토스뱅크 PDF import; 구분={tx.action}; 상대={tx.counterparty}; 잔액={tx.balance_after}"
|
||||
payload = tx.raw_payload()
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
tx_id,
|
||||
tx.transaction_date,
|
||||
"",
|
||||
tx_type,
|
||||
tx.amount,
|
||||
"KRW",
|
||||
category,
|
||||
subcategory,
|
||||
merchant,
|
||||
description,
|
||||
linked_account_id,
|
||||
tx.action,
|
||||
person,
|
||||
"",
|
||||
normalize_tags(["toss_bank_pdf", tx.action] + ([tx.counterparty] if tx.counterparty else [])),
|
||||
int(is_fixed),
|
||||
"toss_bank_pdf",
|
||||
batch_id,
|
||||
external_id,
|
||||
dedupe_key,
|
||||
tx.action,
|
||||
tx.transaction_datetime,
|
||||
tx.counterparty,
|
||||
tx.balance_after,
|
||||
"토스뱅크",
|
||||
tx.raw_summary,
|
||||
payload,
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
inserted_ids.append(tx_id)
|
||||
except Exception as e:
|
||||
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
|
||||
skipped_duplicates += 1
|
||||
continue
|
||||
raise
|
||||
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||
if transactions:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?",
|
||||
(transactions[-1].balance_after, ts, ts, linked_account_id),
|
||||
)
|
||||
except ValueError as e:
|
||||
return fail("validation_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("db_error", str(e))
|
||||
|
||||
return ok(
|
||||
import_batch_id=batch_id,
|
||||
account_id=linked_account_id,
|
||||
source="toss_bank_pdf",
|
||||
parsed_row_count=len(transactions),
|
||||
inserted_count=inserted,
|
||||
skipped_duplicate_count=skipped_duplicates,
|
||||
inserted_transaction_ids=inserted_ids[:20],
|
||||
validation=validation,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def _find_or_create_toss_securities_account(conn: Any, metadata: dict[str, Any], account_id: str = "") -> str:
|
||||
if account_id:
|
||||
row = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||
if row:
|
||||
return account_id
|
||||
raise ValueError(f"account_id not found: {account_id}")
|
||||
account_number_masked = metadata.get("account_number_masked") or ""
|
||||
row = None
|
||||
if account_number_masked:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||
("토스증권", account_number_masked),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
ts = now_iso()
|
||||
new_account_id = new_id("acct")
|
||||
display_name = f"토스증권 {metadata.get('account_name') or '계좌'} {account_number_masked}".strip()
|
||||
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(?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
new_account_id,
|
||||
display_name,
|
||||
"investment",
|
||||
"토스증권",
|
||||
"KRW",
|
||||
1,
|
||||
"토스증권 거래내역 PDF import로 자동 생성",
|
||||
account_number_masked,
|
||||
metadata.get("holder_name") or "",
|
||||
None,
|
||||
ts,
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
return new_account_id
|
||||
|
||||
|
||||
def _classify_toss_transaction(tx: Any, owner_name: str = "") -> tuple[str, str, str, bool]:
|
||||
action = tx.action
|
||||
counterparty = (tx.name or "").strip()
|
||||
owner_name = (owner_name or "").strip()
|
||||
if "배당" in action:
|
||||
return "수입", "배당", counterparty, False
|
||||
if "이자" in action:
|
||||
return "수입", "이자", counterparty, False
|
||||
if "환급" in action:
|
||||
return "수입", "환급", counterparty, False
|
||||
if "이체" in action:
|
||||
# 토스증권 입출금에서 상대가 계좌 소유자와 다르면 본인 투자 이동으로 확정하지 않는다.
|
||||
# 예: 이체출금(국민은행) 조상문 -> 외부/타인 송금 가능성이 있어 확인필요.
|
||||
if counterparty and owner_name and counterparty != owner_name:
|
||||
return "이체", "확인필요", counterparty, False
|
||||
return "이체", "투자이동", counterparty, False
|
||||
return "이체", "투자이동", counterparty, False
|
||||
|
||||
|
||||
def preview_toss_securities_pdf_import(file_path: str, preview_limit: int = 20, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_toss_securities_pdf(file_path)
|
||||
except FileNotFoundError as e:
|
||||
return fail("not_found", str(e))
|
||||
except ValueError as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
transactions = parsed["transactions"]
|
||||
source_hash = parsed["source_hash"]
|
||||
duplicate_count = 0
|
||||
with db(db_path) as conn:
|
||||
metadata = parsed["metadata"]
|
||||
account_number_masked = metadata.get("account_number_masked") or ""
|
||||
linked_account_id = ""
|
||||
if account_number_masked:
|
||||
row = conn.execute(
|
||||
"SELECT id FROM accounts WHERE institution=? AND account_number_masked=? AND active=1 ORDER BY created_at LIMIT 1",
|
||||
("토스증권", account_number_masked),
|
||||
).fetchone()
|
||||
linked_account_id = row["id"] if row else ""
|
||||
for tx in transactions:
|
||||
merchant = f"토스증권 {tx.action}" + (f" {tx.name}" if tx.name else "")
|
||||
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, tx.external_id(source_hash))
|
||||
found = conn.execute("SELECT 1 FROM transactions WHERE dedupe_key=?", (dedupe_key,)).fetchone()
|
||||
duplicate_count += 1 if found else 0
|
||||
preview = [toss_transaction_preview(tx, source_hash) for tx in transactions[: max(1, min(int(preview_limit), 100))]]
|
||||
by_action: dict[str, dict[str, int]] = {}
|
||||
for tx in transactions:
|
||||
item = by_action.setdefault(tx.action, {"count": 0, "amount": 0})
|
||||
item["count"] += 1
|
||||
item["amount"] += int(tx.amount)
|
||||
return ok(
|
||||
source="toss_securities_pdf",
|
||||
file_path=parsed["file_path"],
|
||||
source_hash=source_hash,
|
||||
page_count=parsed["page_count"],
|
||||
metadata=parsed["metadata"],
|
||||
row_count=len(transactions),
|
||||
duplicate_count=duplicate_count,
|
||||
validation=parsed["validation"],
|
||||
by_action=by_action,
|
||||
preview=preview,
|
||||
)
|
||||
|
||||
|
||||
def import_toss_securities_pdf(file_path: str, account_id: str = "", skip_duplicates: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
parsed = parse_toss_securities_pdf(file_path)
|
||||
except FileNotFoundError as e:
|
||||
return fail("not_found", str(e))
|
||||
except ValueError as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("pdf_parse_error", str(e))
|
||||
transactions = parsed["transactions"]
|
||||
validation = parsed["validation"]
|
||||
if not validation.get("ok"):
|
||||
return fail("pdf_parse_error", "Toss Securities PDF parsing produced no valid transactions", validation=validation)
|
||||
source_hash = parsed["source_hash"]
|
||||
metadata = parsed["metadata"]
|
||||
ts = now_iso()
|
||||
batch_id = new_id("imp")
|
||||
inserted = 0
|
||||
skipped_duplicates = 0
|
||||
inserted_ids: list[str] = []
|
||||
try:
|
||||
with db(db_path) as conn:
|
||||
linked_account_id = _find_or_create_toss_securities_account(conn, metadata, account_id)
|
||||
conn.execute(
|
||||
"""INSERT INTO import_batches(id,source,filename,imported_at,row_count,status,memo)
|
||||
VALUES(?,?,?,?,?,?,?)""",
|
||||
(
|
||||
batch_id,
|
||||
"toss_securities_pdf",
|
||||
parsed["file_path"],
|
||||
ts,
|
||||
0,
|
||||
"active",
|
||||
json.dumps(
|
||||
{
|
||||
"source_hash": source_hash,
|
||||
"page_count": parsed["page_count"],
|
||||
"metadata": metadata,
|
||||
"validation": validation,
|
||||
"parsed_row_count": len(transactions),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
),
|
||||
)
|
||||
for tx in transactions:
|
||||
tx_id = new_id("tx")
|
||||
external_id = tx.external_id(source_hash)
|
||||
merchant = f"토스증권 {tx.action}" + (f" {tx.name}" if tx.name else "")
|
||||
category, subcategory, person, is_fixed = _classify_toss_transaction(tx, metadata.get("holder_name") or "")
|
||||
dedupe_key = make_dedupe_key(tx.transaction_date, tx.amount, merchant, linked_account_id, external_id)
|
||||
description = f"토스증권 PDF import; 구분={tx.action}; 종목/상대={tx.name}; 수량={tx.quantity}; 단가={tx.unit_price}; 수수료={tx.fee}; 세금={tx.tax}; 잔액={tx.balance_after}"
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO transactions(id,transaction_date,posted_date,type,amount,currency,category,subcategory,merchant,description,account_id,payment_method,person,project,tags,is_fixed,source,import_batch_id,external_id,dedupe_key,memo,transaction_datetime,counterparty,balance_after,branch,raw_summary,raw_payload,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
tx_id,
|
||||
tx.transaction_date,
|
||||
"",
|
||||
tx.type,
|
||||
tx.amount,
|
||||
"KRW",
|
||||
category,
|
||||
subcategory,
|
||||
merchant,
|
||||
description,
|
||||
linked_account_id,
|
||||
tx.action,
|
||||
person,
|
||||
"",
|
||||
normalize_tags(["toss_securities_pdf", tx.section, tx.action, tx.code] if tx.code else ["toss_securities_pdf", tx.section, tx.action]),
|
||||
int(is_fixed),
|
||||
"toss_securities_pdf",
|
||||
batch_id,
|
||||
external_id,
|
||||
dedupe_key,
|
||||
tx.code,
|
||||
tx.transaction_date,
|
||||
tx.name,
|
||||
tx.balance_after,
|
||||
"토스증권",
|
||||
tx.action,
|
||||
tx.raw_payload(),
|
||||
ts,
|
||||
ts,
|
||||
),
|
||||
)
|
||||
inserted += 1
|
||||
inserted_ids.append(tx_id)
|
||||
except Exception as e:
|
||||
if "UNIQUE constraint failed" in str(e) and skip_duplicates:
|
||||
skipped_duplicates += 1
|
||||
continue
|
||||
raise
|
||||
conn.execute("UPDATE import_batches SET row_count=? WHERE id=?", (inserted, batch_id))
|
||||
if transactions:
|
||||
conn.execute(
|
||||
"UPDATE accounts SET last_balance=?, last_synced_at=?, updated_at=? WHERE id=?",
|
||||
(transactions[-1].balance_after, ts, ts, linked_account_id),
|
||||
)
|
||||
except ValueError as e:
|
||||
return fail("validation_error", str(e))
|
||||
except Exception as e:
|
||||
return fail("db_error", str(e))
|
||||
return ok(
|
||||
import_batch_id=batch_id,
|
||||
account_id=linked_account_id,
|
||||
source="toss_securities_pdf",
|
||||
parsed_row_count=len(transactions),
|
||||
inserted_count=inserted,
|
||||
skipped_duplicate_count=skipped_duplicates,
|
||||
inserted_transaction_ids=inserted_ids[:20],
|
||||
validation=validation,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
|
||||
def list_ledger_import_batches(source: str = "", include_rolled_back: bool = True, limit: int = 50, db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses=[]; params=[]
|
||||
if source:
|
||||
clauses.append("source=?"); params.append(source)
|
||||
if not include_rolled_back:
|
||||
clauses.append("status='active'")
|
||||
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||
params.append(max(1, min(int(limit), 500)))
|
||||
with db(db_path) as conn:
|
||||
rows=conn.execute(f"SELECT * FROM import_batches{where} ORDER BY imported_at DESC LIMIT ?", params).fetchall()
|
||||
return ok(import_batches=[row_to_dict(r) for r in rows], count=len(rows))
|
||||
|
||||
|
||||
def rollback_ledger_import_batch(import_batch_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
batch = conn.execute("SELECT * FROM import_batches WHERE id=?", (import_batch_id,)).fetchone()
|
||||
if not batch:
|
||||
return fail("not_found", "import batch not found")
|
||||
if hard_delete:
|
||||
cur = conn.execute("DELETE FROM transactions WHERE import_batch_id=?", (import_batch_id,))
|
||||
else:
|
||||
cur = conn.execute("UPDATE transactions SET status='deleted', updated_at=? WHERE import_batch_id=? AND status='active'", (now_iso(), import_batch_id))
|
||||
conn.execute("UPDATE import_batches SET status='rolled_back' WHERE id=?", (import_batch_id,))
|
||||
return ok(import_batch_id=import_batch_id, affected_transactions=cur.rowcount, hard_delete=hard_delete)
|
||||
@@ -0,0 +1,94 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
|
||||
|
||||
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")
|
||||
@@ -11,11 +11,17 @@ from typing import Any
|
||||
from uuid import uuid4
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from ..infrastructure.parsers import (
|
||||
parse_kb_bank_pdf,
|
||||
parse_seoulpay_xlsx,
|
||||
parse_toss_bank_pdf,
|
||||
parse_toss_securities_pdf,
|
||||
seoulpay_transaction_preview,
|
||||
toss_bank_transaction_preview,
|
||||
toss_transaction_preview,
|
||||
transaction_preview,
|
||||
)
|
||||
from ..infrastructure.sqlite import db, init_db, resolve_db_path
|
||||
from ..kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
|
||||
from ..toss_bank_pdf import parse_toss_bank_pdf, transaction_preview as toss_bank_transaction_preview
|
||||
from ..toss_securities_pdf import parse_toss_securities_pdf, transaction_preview as toss_transaction_preview
|
||||
from ..seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_transaction_preview
|
||||
|
||||
VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"}
|
||||
VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"}
|
||||
|
||||
@@ -23,98 +23,7 @@ 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")
|
||||
|
||||
|
||||
from .reconciliation_service import get_balance_reconciliation_report
|
||||
|
||||
def list_ledger_budgets(month: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||
clauses=[]; params=[]
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .shared import *
|
||||
|
||||
def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
|
||||
if rule.get("transaction_type") and rule.get("transaction_type") != tx.get("type", ""):
|
||||
return False
|
||||
amount = int(tx.get("amount") or 0)
|
||||
if rule.get("amount_min") is not None and amount < int(rule["amount_min"]):
|
||||
return False
|
||||
if rule.get("amount_max") is not None and amount > int(rule["amount_max"]):
|
||||
return False
|
||||
if rule.get("day_of_month") is not None:
|
||||
tx_date = tx.get("transaction_datetime") or tx.get("transaction_date") or ""
|
||||
try:
|
||||
if datetime.fromisoformat(str(tx_date)[:10]).day != int(rule["day_of_month"]):
|
||||
return False
|
||||
except ValueError:
|
||||
return False
|
||||
value = str(tx.get(rule["match_field"], "") or "")
|
||||
pattern = str(rule.get("pattern", ""))
|
||||
match_type = rule.get("match_type") or "contains"
|
||||
if match_type == "contains":
|
||||
return pattern.lower() in value.lower()
|
||||
if match_type == "equals":
|
||||
return value == pattern
|
||||
if match_type == "starts_with":
|
||||
return value.startswith(pattern)
|
||||
if match_type == "ends_with":
|
||||
return value.endswith(pattern)
|
||||
if match_type == "regex":
|
||||
return re.search(pattern, value) is not None
|
||||
return False
|
||||
|
||||
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 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 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)
|
||||
@@ -0,0 +1,4 @@
|
||||
from .kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
|
||||
from .toss_bank_pdf import parse_toss_bank_pdf, transaction_preview as toss_bank_transaction_preview
|
||||
from .toss_securities_pdf import parse_toss_securities_pdf, transaction_preview as toss_transaction_preview
|
||||
from .seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_transaction_preview
|
||||
@@ -0,0 +1 @@
|
||||
from ...kb_bank_pdf import parse_kb_bank_pdf, transaction_preview
|
||||
@@ -0,0 +1 @@
|
||||
from ...seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview
|
||||
@@ -0,0 +1 @@
|
||||
from ...toss_bank_pdf import parse_toss_bank_pdf, transaction_preview
|
||||
@@ -0,0 +1 @@
|
||||
from ...toss_securities_pdf import parse_toss_securities_pdf, transaction_preview
|
||||
@@ -5,20 +5,30 @@ from types import ModuleType
|
||||
|
||||
from .application import accounts_write_services as _accounts_write_services
|
||||
from .application import asset_account_services as _asset_account_services
|
||||
from .application import assets_service as _assets_service
|
||||
from .application import budgets_write_services as _budgets_write_services
|
||||
from .application import category_rules_write_services as _category_rules_write_services
|
||||
from .application import categorization_services as _categorization_services
|
||||
from .application import cross_transfer_service as _cross_transfer_service
|
||||
from .application import imports_service as _imports_service
|
||||
from .application import import_transfer_services as _import_transfer_services
|
||||
from .application import reconciliation_service as _reconciliation_service
|
||||
from .application import shared as _shared
|
||||
from .application import system_services as _system_services
|
||||
from .application import transactions_write_services as _transactions_write_services
|
||||
from .application import transaction_report_services as _transaction_report_services
|
||||
from .application import uncategorized_workbench_service as _uncategorized_workbench_service
|
||||
from .application.asset_account_services import *
|
||||
from .application.assets_service import *
|
||||
from .application.categorization_services import *
|
||||
from .application.cross_transfer_service import *
|
||||
from .application.imports_service import *
|
||||
from .application.import_transfer_services import *
|
||||
from .application.reconciliation_service import *
|
||||
from .application.shared import *
|
||||
from .application.system_services import *
|
||||
from .application.transaction_report_services import *
|
||||
from .application.uncategorized_workbench_service import *
|
||||
|
||||
# Keep compatibility for tests/monkeypatches that reach into service internals.
|
||||
_current_kst_datetime = _shared._current_kst_datetime
|
||||
@@ -28,11 +38,16 @@ _PROXY_MODULES = (
|
||||
_accounts_write_services,
|
||||
_system_services,
|
||||
_asset_account_services,
|
||||
_assets_service,
|
||||
_budgets_write_services,
|
||||
_category_rules_write_services,
|
||||
_transaction_report_services,
|
||||
_reconciliation_service,
|
||||
_imports_service,
|
||||
_import_transfer_services,
|
||||
_cross_transfer_service,
|
||||
_categorization_services,
|
||||
_uncategorized_workbench_service,
|
||||
_transactions_write_services,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user