diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..596ffcb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,80 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + workflow_dispatch: + +permissions: + contents: read + +jobs: + pytest-regression: + name: pytest-regression + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install project dependencies + run: uv sync --extra dev --extra dashboard + - name: Run pytest regression suite + run: uv run pytest --ignore=tests/test_dashboard_app.py + + dashboard-app-test: + name: dashboard-app-test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install project dependencies + run: uv sync --extra dev --extra dashboard + - name: Run Streamlit AppTest with synthetic fixture DB + run: uv run pytest tests/test_dashboard_app.py + + dashboard-http-smoke: + name: dashboard-http-smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install project dependencies + run: uv sync --extra dashboard + - name: Run dashboard HTTP smoke with synthetic fixture DB + run: uv run python scripts/ci_dashboard_http_smoke.py --host 127.0.0.1 --port 18501 --timeout 45 + + db-safety-smoke: + name: db-safety-smoke + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + - name: Install project dependencies + run: uv sync + - name: Run DB safety smoke with synthetic temp DBs + run: uv run python scripts/ci_db_safety_smoke.py --timeout 45 diff --git a/dashboard/app.py b/dashboard/app.py index be9e9b8..0a2785d 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -1,13 +1,18 @@ from __future__ import annotations import sqlite3 +from datetime import datetime from html import escape from pathlib import Path from typing import Any from urllib.parse import quote +import importlib +import inspect +from zoneinfo import ZoneInfo import pandas as pd import plotly.express as px +import plotly.io as pio import streamlit as st from jarca_ledger_mcp import service @@ -15,6 +20,38 @@ from jarca_ledger_mcp.db import resolve_db_path st.set_page_config(page_title="Jarca Ledger", page_icon="๐Ÿ’ฐ", layout="wide") +JARCA_COLORS = { + "navy": "#0f172a", + "slate": "#475569", + "muted": "#64748b", + "border": "#e2e8f0", + "surface": "#ffffff", + "surface_soft": "#f8fafc", + "blue": "#2563eb", + "cyan": "#06b6d4", + "emerald": "#10b981", + "amber": "#f59e0b", + "rose": "#f43f5e", +} + +JARCA_PLOTLY_TEMPLATE = "jarca_clean" +pio.templates[JARCA_PLOTLY_TEMPLATE] = pio.templates["plotly_white"].update( + { + "layout": { + "font": {"family": "Inter, Pretendard, Noto Sans KR, Arial, sans-serif", "color": JARCA_COLORS["navy"]}, + "paper_bgcolor": "rgba(0,0,0,0)", + "plot_bgcolor": "rgba(255,255,255,0)", + "colorway": [JARCA_COLORS["blue"], JARCA_COLORS["rose"], JARCA_COLORS["emerald"], JARCA_COLORS["amber"], JARCA_COLORS["cyan"]], + "title": {"font": {"size": 16, "color": JARCA_COLORS["navy"]}, "x": 0.02, "xanchor": "left"}, + "legend": {"orientation": "h", "yanchor": "bottom", "y": 1.02, "xanchor": "right", "x": 1}, + "margin": {"l": 12, "r": 12, "t": 56, "b": 28}, + "xaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"}, + "yaxis": {"gridcolor": "rgba(148, 163, 184, 0.20)", "zerolinecolor": "rgba(148, 163, 184, 0.35)"}, + } + } +) +px.defaults.template = JARCA_PLOTLY_TEMPLATE + DEFAULT_CATEGORIES = [ "", "์ˆ˜์ž…", "์ด์ฒด", "์‹๋น„", "์ฃผ๊ฑฐ", "ํ†ต์‹ ", "๊ตํ†ต", "๊ต์œก", "์˜๋ฃŒ", "์ƒํ™œ", "๋ฌธํ™”/์—ฌ๊ฐ€", "๊ธˆ์œต", "๊ฐ€์กฑ/์šฉ๋ˆ", "๋ฏธ๋ถ„๋ฅ˜" ] @@ -43,6 +80,22 @@ CASHFLOW_KIND_LABELS = { "exclude": "๋ถ„์„ ์ œ์™ธ", "unknown": "ํ™•์ธํ•„์š”", } +STATEMENT_STATUS_OPTIONS = ["unknown", "import_supported", "manual_only", "partial_import"] +STATEMENT_STATUS_LABELS = { + "unknown": "๋ฏธ์ •", + "import_supported": "๋ช…์„ธ์„œ import ์ง€์›", + "manual_only": "์ˆ˜๋™ ์ฆ๋น™ ์ „์šฉ", + "partial_import": "๋ถ€๋ถ„ import + ์ˆ˜๋™ ๋ณด์™„", +} +LIQUIDITY_BUCKET_OPTIONS = ["cash", "investment", "deposit", "real_estate", "loan", "other"] +LIQUIDITY_BUCKET_LABELS = { + "cash": "ํ˜„๊ธˆ์„ฑ", + "investment": "ํˆฌ์ž์ž์‚ฐ", + "deposit": "์˜ˆ์ ๊ธˆ", + "real_estate": "๋ถ€๋™์‚ฐ", + "loan": "๋ถ€์ฑ„", + "other": "๊ธฐํƒ€", +} def format_krw(value: Any) -> str: @@ -54,6 +107,31 @@ def format_krw(value: Any) -> str: return str(value) +def current_kst_month() -> str: + return datetime.now(ZoneInfo("Asia/Seoul")).strftime("%Y-%m") + + +def format_percent(value: Any) -> str: + if value is None: + return "-" + try: + return f"{float(value):.1%}" + except Exception: + return str(value) + + +def get_transfer_review_items(payload: dict[str, Any] | None) -> list[dict[str, Any]]: + if not payload or not payload.get("success"): + return [] + items = payload.get("review_items") + if isinstance(items, list): + return items + items = payload.get("transactions") + if isinstance(items, list): + return items + return [] + + @st.cache_data(ttl=5) def read_sql(db_path: str, sql: str, params: tuple[Any, ...] = ()) -> pd.DataFrame: with sqlite3.connect(db_path) as conn: @@ -68,29 +146,92 @@ def get_db_path() -> str: return str(resolve_db_path()) -def inject_sidebar_nav_css() -> None: +def get_service_callable(name: str, required_params: list[str] | None = None): + """Return a service function, reloading once for Streamlit hot-reload edge cases. + + Streamlit can keep an already-imported service module in memory while the + dashboard script itself reloads. When a UI change starts passing a new + keyword argument, a stale service function may still exist but have the old + signature. In that case reload the service module before returning it. + """ + required_params = required_params or [] + fn = getattr(service, name, None) + if callable(fn): + try: + params = inspect.signature(fn).parameters + if all(param in params for param in required_params): + return fn + except (TypeError, ValueError): + if not required_params: + return fn + importlib.reload(service) + return getattr(service, name, None) + + +def inject_app_css() -> None: st.markdown( """ @@ -131,6 +392,25 @@ def inject_sidebar_nav_css() -> None: ) +def render_page_header(title: str, subtitle: str, kicker: str = "Jarca Ledger", pills: list[str] | None = None) -> None: + pill_html = "".join(f'{escape(pill)}' for pill in (pills or [])) + st.markdown( + f""" +
+
{escape(kicker)}
+

{escape(title)}

+

{escape(subtitle)}

+ {f'
{pill_html}
' if pill_html else ''} +
+ """, + unsafe_allow_html=True, + ) + + +def render_section_label(icon: str, label: str) -> None: + st.markdown(f'
{escape(icon)}{escape(label)}
', unsafe_allow_html=True) + + def render_sidebar_nav(pages: list[str], current_page: str) -> None: items = [] for name in pages: @@ -246,7 +526,7 @@ def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str | if submitted: result = service.update_ledger_transaction( tx["id"], category=category, subcategory=subcategory, person=person, - is_fixed=is_fixed, memo=memo, + is_fixed=is_fixed, memo=memo, db_path=db_path, ) if not result.get("success"): st.error(result.get("error", result)) @@ -262,6 +542,7 @@ def update_transaction_form(tx: dict[str, Any], key_prefix: str, db_path: str | is_fixed=is_fixed, priority=70, memo="dashboard: user-confirmed rule", + db_path=db_path, ) if not rule.get("success"): st.warning(f"๊ฑฐ๋ž˜๋Š” ์ €์žฅ๋์ง€๋งŒ ๊ทœ์น™ ์ €์žฅ ์‹คํŒจ: {rule.get('error')}") @@ -493,84 +774,249 @@ def render_uncategorized_action_table(rows: list[dict[str, Any]], db_path: str) cancel_original_candidates_dialog(row, db_path) -def page_overview(db_path: str) -> None: - st.title("๐Ÿ“Š ์š”์•ฝ") - health = service.health_check(db_path) - st.caption(f"DB: `{health.get('db_path')}` ยท schema v{health.get('schema_version')}") - df = read_sql(db_path, "SELECT * FROM transactions WHERE status='active'") - accounts = read_sql(db_path, "SELECT * FROM accounts WHERE active=1") - categories = read_sql(db_path, "SELECT category, subcategory, cashflow_kind FROM ledger_categories WHERE active=1") - if df.empty: - st.info("๊ฑฐ๋ž˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") +def recommendation_text(rec: dict[str, Any] | None) -> str: + rec = rec or {} + if not rec.get("category"): + return "์ถ”์ฒœ ์—†์Œ" + pct = int(round(float(rec.get("confidence") or 0) * 100)) + return f"{rec.get('category')} / {rec.get('subcategory') or '-'} ยท {pct}%" + + +def save_merchant_rule_from_recommendation(merchant: str, tx_type: str, rec: dict[str, Any], db_path: str) -> dict[str, Any]: + if not merchant: + return {"success": False, "error": "merchant๊ฐ€ ๋น„์–ด ์žˆ์–ด ๊ทœ์น™์„ ๋งŒ๋“ค ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."} + if not rec.get("category"): + return {"success": False, "error": "์ถ”์ฒœ ๋ถ„๋ฅ˜๊ฐ€ ์—†์–ด ๊ทœ์น™์„ ๋งŒ๋“ค ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."} + return service.create_category_rule( + match_field="merchant", + match_type="contains", + pattern=merchant, + transaction_type=tx_type or "", + category=rec.get("category", ""), + subcategory=rec.get("subcategory", ""), + priority=70, + confidence=float(rec.get("confidence") or 0.8), + memo="dashboard: uncategorized workbench rule", + db_path=db_path, + ) + + +def render_uncategorized_groups(groups: list[dict[str, Any]], db_path: str, repeated_only: bool = False) -> None: + visible = [g for g in groups if not repeated_only or int(g.get("count") or 0) >= 2] + visible = visible[:10] + if not visible: + st.info("๋ฐ˜๋ณต ํ›„๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") return - df["month"] = pd.to_datetime(df["transaction_date"]).dt.strftime("%Y-%m") - if not categories.empty: - df = df.merge(categories, how="left", on=["category", "subcategory"]) + for idx, group in enumerate(visible): + rec = group.get("recommendation") or {} + cols = st.columns([2.0, 0.7, 1.0, 1.55, 1.0, 1.0], vertical_alignment="center") + merchant = group.get("merchant") or group.get("raw_summary") or "-" + cols[0].markdown(f"**{merchant}** \n{group.get('raw_summary') or '-'} ยท {group.get('type') or '-'}", unsafe_allow_html=True) + cols[1].write(f"{int(group.get('count') or 0):,}๊ฑด") + cols[2].write(format_krw(group.get("total_amount"))) + cols[3].write(recommendation_text(rec)) + apply_disabled = not bool(rec.get("category")) + if cols[4].button("๊ทธ๋ฃน ์ ์šฉ", key=f"uncat_group_apply_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True): + res = service.apply_uncategorized_group_recommendation( + group.get("merchant", ""), group.get("raw_summary", ""), group.get("type", ""), db_path=db_path + ) + clear_cache() + if res.get("success"): + st.success(f"๊ทธ๋ฃน {res.get('applied_count', 0):,}๊ฑด์„ {recommendation_text(res.get('recommendation'))}๋กœ ๋ถ„๋ฅ˜ํ–ˆ์Šต๋‹ˆ๋‹ค.") + st.rerun() + else: + st.error(res.get("error", res)) + if cols[5].button("๊ทœ์น™ ๋งŒ๋“ค๊ธฐ", key=f"uncat_group_rule_{idx}_{merchant}", disabled=apply_disabled, use_container_width=True): + res = save_merchant_rule_from_recommendation(group.get("merchant", ""), group.get("type", ""), rec, db_path) + clear_cache() + if res.get("success"): + st.success("์‚ฌ์šฉ์ฒ˜ ๊ทœ์น™์„ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.") + st.rerun() + else: + st.warning(res.get("error", res)) + st.caption(f"์ตœ๊ทผ: {group.get('latest_date') or '-'} ยท ์ตœ๋Œ€๊ธˆ์•ก: {format_krw(group.get('max_amount'))} ยท ๊ทผ๊ฑฐ: {rec.get('reason') or '์ถ”์ฒœ ์—†์Œ'}") + if idx < len(visible) - 1: + st.divider() + + +def render_uncategorized_work_items(rows: list[dict[str, Any]], db_path: str, recommended_only: bool = False) -> None: + visible = [r for r in rows if not recommended_only or r.get("recommended_category")] + if not visible: + st.info("์กฐ๊ฑด์— ๋งž๋Š” ๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") + return + for idx, row in enumerate(visible): + rec = row.get("recommendation") or {} + date_text = row.get("transaction_datetime") or row.get("transaction_date") or "" + title = f"{date_text} ยท {format_krw(row.get('amount'))} ยท {row.get('merchant') or row.get('counterparty') or '-'} ยท ์ถ”์ฒœ: {recommendation_text(rec)}" + with st.expander(title, expanded=False): + c1, c2, c3, c4 = st.columns(4) + c1.metric("๊ฑฐ๋ž˜์œ ํ˜•", row.get("type") or "-") + c2.metric("๊ธˆ์•ก", format_krw(row.get("amount"))) + c3.metric("์ถ”์ฒœ", recommendation_text(rec)) + c4.metric("์ถ”์ฒœ ๊ทผ๊ฑฐ", rec.get("source") or "none") + st.caption(f"์‚ฌ์šฉ์ฒ˜: {row.get('merchant') or '-'} ยท ์š”์•ฝ: {row.get('raw_summary') or '-'} ยท ์ƒ๋Œ€: {row.get('counterparty') or '-'} ยท source: {row.get('source') or '-'} ยท ID: `{row.get('id')}`") + if rec.get("reason"): + st.info(rec.get("reason")) + action_cols = st.columns([1.0, 1.0, 1.0, 1.1, 1.1, 2.5], vertical_alignment="center") + apply_disabled = not bool(rec.get("category")) + if action_cols[0].button("์ถ”์ฒœ ์ ์šฉ", key=f"uncat_apply_rec_{row['id']}_{idx}", disabled=apply_disabled, use_container_width=True, type="primary"): + res = service.apply_uncategorized_recommendation(row["id"], db_path=db_path) + clear_cache() + if res.get("success"): + st.success(f"{recommendation_text(res.get('recommendation'))}๋กœ ๋ถ„๋ฅ˜ํ–ˆ์Šต๋‹ˆ๋‹ค.") + st.rerun() + else: + st.error(res.get("error", res)) + if action_cols[1].button("์ง์ ‘ ๋ถ„๋ฅ˜", key=f"uncat_direct_{row['id']}_{idx}", use_container_width=True): + transaction_edit_dialog(row, f"uncat_direct_{row['id']}", db_path) + if action_cols[2].button("๊ทœ์น™ ๋งŒ๋“ค๊ธฐ", key=f"uncat_rule_{row['id']}_{idx}", disabled=apply_disabled, use_container_width=True): + res = save_merchant_rule_from_recommendation(row.get("merchant", ""), row.get("type", ""), rec, db_path) + clear_cache() + if res.get("success"): + st.success("์‚ฌ์šฉ์ฒ˜ ๊ทœ์น™์„ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.") + st.rerun() + else: + st.warning(res.get("error", res)) + if action_cols[3].button("์—ฐ๊ฒฐ๊ฑฐ๋ž˜์ฐพ๊ธฐ", key=f"uncat_link_{row['id']}_{idx}", use_container_width=True): + linked_transfer_candidates_dialog(row, db_path) + if action_cols[4].button("์›๊ฑฐ๋ž˜์ฐพ๊ธฐ", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True): + cancel_original_candidates_dialog(row, db_path) + + + +@st.dialog("๊ทœ์น™ ์ ์šฉ ๋ฏธ๋ฆฌ๋ณด๊ธฐ", width="large") +def category_rule_preview_dialog(rule: dict[str, Any], db_path: str) -> None: + rule_id = rule.get("id", "") + pattern = rule.get("pattern") or "-" + result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}" + st.caption(f"๊ทœ์น™ ID: `{rule_id}`") + st.write(f"**{pattern}** โ†’ **{result_text}**") + + preview_fn = get_service_callable("preview_category_rule_matches") + if not callable(preview_fn): + st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์—์„œ ๋‹จ์ผ ๊ทœ์น™ ๋ฏธ๋ฆฌ๋ณด๊ธฐ ํ•จ์ˆ˜๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. Streamlit ์„œ๋ฒ„๋ฅผ ์™„์ „ํžˆ ์ข…๋ฃŒํ•œ ๋’ค ๋‹ค์‹œ ์‹œ์ž‘ํ•ด ์ฃผ์„ธ์š”.") + return + preview = preview_fn(rule_id, limit=10000, sample_limit=3, db_path=db_path) + if not preview.get("success"): + st.error(preview.get("error", preview)) + return + + m1, m2, m3 = st.columns(3) + m1.metric("์Šค์บ”ํ•œ ํ™œ์„ฑ ๊ฑฐ๋ž˜", f"{preview.get('scanned', 0):,}๊ฑด") + m2.metric("์ด ๊ทœ์น™๊ณผ ๋งค์นญ", f"{preview.get('matched_count', 0):,}๊ฑด") + m3.metric("๋ฏธ๋ถ„๋ฅ˜ ์ค‘ ์ ์šฉ ๋Œ€์ƒ", f"{preview.get('uncategorized_matched_count', 0):,}๊ฑด") + + sample = preview.get("sample", []) + if sample: + df = pd.DataFrame(sample) + if "amount" in df: + df["๊ธˆ์•ก"] = df["amount"].map(format_krw) + display_cols = [ + c for c in [ + "transaction_date", "transaction_datetime", "type", "๊ธˆ์•ก", "merchant", "raw_summary", + "counterparty", "current_category", "current_subcategory", "new_category", "new_subcategory", + ] if c in df.columns + ] + st.dataframe(df[display_cols], width="stretch", height=160) else: - df["cashflow_kind"] = None - df["cashflow_kind"] = df["cashflow_kind"].fillna("unknown") + st.info("์ด ๊ทœ์น™์— ๋ถ€ํ•ฉํ•˜๋Š” ๊ฑฐ๋ž˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") - account_in = int(df.loc[df["type"] == "income", "amount"].sum()) - account_out = int(df.loc[df["type"] == "expense", "amount"].sum()) - account_net = account_in - account_out - actual_income = int(df.loc[df["cashflow_kind"] == "income", "amount"].sum()) - actual_expense = int(df.loc[df["cashflow_kind"] == "expense", "amount"].sum()) - actual_net = actual_income - actual_expense - savings_rate = actual_net / actual_income if actual_income else None - transfer_like = int(df.loc[df["cashflow_kind"].isin(["transfer", "investment", "exclude"]), "amount"].sum()) - unknown_amount = int(df.loc[df["cashflow_kind"] == "unknown", "amount"].sum()) - uncategorized = int((df["category"].fillna("") == "").sum()) +def page_overview(db_path: str) -> None: + health = service.health_check(db_path) + render_page_header( + "๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ", + "์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.", + kicker="๐Ÿ“Š ์š”์•ฝ", + pills=[f"schema v{health.get('schema_version')}", "KST ์ด๋ฒˆ ๋‹ฌ", "last_balance ๊ธฐ์ค€ ์ž์‚ฐ"], + ) + st.caption(f"DB: `{health.get('db_path')}`") + month = current_kst_month() + cashflow = service.get_monthly_cashflow_report(month, db_path=db_path) + asset_report = service.get_asset_liquidity_report(db_path=db_path) + budget_report = service.get_budget_vs_actual_report(month, db_path=db_path) + uncategorized_res = service.list_uncategorized_transactions(limit=1000, db_path=db_path) + transfer_queue_res = service.list_cross_transfer_review_queue(limit=300, db_path=db_path) + reconciliation_res = service.get_balance_reconciliation_report(db_path=db_path) + backups_res = service.list_ledger_backups(limit=1) + imports_res = service.list_ledger_import_batches(limit=1, db_path=db_path) - st.subheader("์‹ค์ œ ๊ฐ€๊ณ„๋ถ€ ๊ธฐ์ค€") - c1, c2, c3, c4, c5 = st.columns(5) - c1.metric("์‹ค์ œ ์ˆ˜์ž…", format_krw(actual_income)) - c2.metric("์‹ค์ œ ์ง€์ถœ", format_krw(actual_expense)) - c3.metric("์‹ค์ œ ์ˆœํ˜„๊ธˆํ๋ฆ„", format_krw(actual_net)) - c4.metric("์ €์ถ•๋ฅ ", f"{savings_rate:.1%}" if savings_rate is not None else "-") - c5.metric("ํ™•์ธํ•„์š”", format_krw(unknown_amount)) + render_section_label("๐Ÿ’Ž", "์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ") + st.caption(f"๊ธฐ์ค€: KST ํ˜„์žฌ ์›” {month}, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„") + c1, c2, c3, c4 = st.columns(4) + c1.metric("์‹ค์ œ ์ˆ˜์ž…", format_krw(cashflow.get("income", 0))) + c2.metric("์‹ค์ œ ์ง€์ถœ", format_krw(cashflow.get("expenses", 0))) + c3.metric("์ˆœํ˜„๊ธˆํ๋ฆ„", format_krw(cashflow.get("net_cashflow", 0))) + c4.metric("์ €์ถ•๋ฅ ", format_percent(cashflow.get("savings_rate"))) - st.subheader("๊ณ„์ขŒ ์ž…์ถœ๊ธˆ ํ๋ฆ„") - b1, b2, b3, b4, b5 = st.columns(5) - b1.metric("๊ณ„์ขŒ", len(accounts)) - b2.metric("๊ฑฐ๋ž˜", len(df)) - b3.metric("์ด ์ž…๊ธˆ", format_krw(account_in)) - b4.metric("์ด ์ถœ๊ธˆ", format_krw(account_out)) - b5.metric("๊ณ„์ขŒ ์ˆœ์ฆ๊ฐ", format_krw(account_net)) - st.caption(f"์ด์ฒด/ํˆฌ์ž/๋ถ„์„์ œ์™ธ์„ฑ ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„: {format_krw(transfer_like)} ยท ๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜: {uncategorized:,}๊ฑด") + render_section_label("๐Ÿฆ", "์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ") + st.caption("๊ธฐ์ค€: accounts.liquidity_bucket + accounts.last_balance. ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„๋กœ ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ์„ ์ถ”์ •ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + a1, a2, a3 = st.columns(3) + a1.metric("์ž์‚ฐ ํ•ฉ๊ณ„(๋ถ€์ฑ„ ์ œ์™ธ)", format_krw(asset_report.get("total_assets", 0))) + a2.metric("์ด๋ถ€์ฑ„", format_krw(asset_report.get("total_liabilities", 0))) + a3.metric("์ˆœ์ž์‚ฐ", format_krw(asset_report.get("net_worth", 0))) + buckets_df = pd.DataFrame(asset_report.get("buckets", [])) + if not buckets_df.empty: + buckets_df["label"] = buckets_df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(buckets_df["liquidity_bucket"]) + buckets_df["๊ธˆ์•ก"] = buckets_df["amount"].map(format_krw) + buckets_df["๊ตฌ์„ฑ๋น„"] = buckets_df["asset_share"].map(format_percent) + st.dataframe(buckets_df[["label", "liquidity_bucket", "๊ธˆ์•ก", "account_count", "๊ตฌ์„ฑ๋น„"]], width="stretch", height=250) + chart_df = buckets_df[(buckets_df["liquidity_bucket"] != "loan") & (buckets_df["amount"] != 0)] + if not chart_df.empty: + st.plotly_chart(px.pie(chart_df, names="label", values="amount", title="์ž์‚ฐ ๊ตฌ์„ฑ(loan ์ œ์™ธ)"), width="stretch") - monthly = df.pivot_table(index="month", columns="cashflow_kind", values="amount", aggfunc="sum", fill_value=0).reset_index() - for col in ["income", "expense"]: - if col not in monthly.columns: - monthly[col] = 0 - monthly["actual_net_cashflow"] = monthly["income"] - monthly["expense"] - st.plotly_chart(px.bar(monthly, x="month", y=["income", "expense", "actual_net_cashflow"], barmode="group", title="์›”๋ณ„ ์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ"), width="stretch") + render_section_label("๐Ÿงญ", "์ž์‚ฐ ๊ตฌ์„ฑ") + source_accounts = pd.DataFrame(asset_report.get("accounts", [])) + if not source_accounts.empty: + source_accounts["bucket_label"] = source_accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(source_accounts["liquidity_bucket"]) + source_accounts["last_balance_display"] = source_accounts["last_balance"].map(format_krw) + show_cols = ["name", "type", "bucket_label", "last_balance_display", "last_synced_at", "memo"] + st.dataframe(source_accounts[show_cols], width="stretch", height=260) + else: + st.info("last_balance๊ฐ€ ์ž…๋ ฅ๋œ ํ™œ์„ฑ ๊ณ„์ขŒ/์ž์‚ฐ ํ•ญ๋ชฉ์ด ์—†์Šต๋‹ˆ๋‹ค.") - account_monthly = df.pivot_table(index="month", columns="type", values="amount", aggfunc="sum", fill_value=0).reset_index() - for col in ["income", "expense"]: - if col not in account_monthly.columns: - account_monthly[col] = 0 - account_monthly["account_net"] = account_monthly["income"] - account_monthly["expense"] - st.plotly_chart(px.line(account_monthly, x="month", y=["income", "expense", "account_net"], title="์›”๋ณ„ ๊ณ„์ขŒ ์ž…์ถœ๊ธˆ ํ๋ฆ„"), width="stretch") + render_section_label("๐Ÿ“‰", "์ง€์ถœ ๊ตฌ์กฐ/์˜ˆ์‚ฐ") + budgets = pd.DataFrame(budget_report.get("items", [])) + if not budgets.empty: + over_count = int((budgets["status"] == "over").sum()) if "status" in budgets else 0 + b1, b2, b3 = st.columns(3) + b1.metric("์›” ์˜ˆ์‚ฐ", format_krw(budget_report.get("total_budget", 0))) + b2.metric("์˜ˆ์‚ฐ ๋Œ€์ƒ ์ง€์ถœ", format_krw(budget_report.get("total_actual", 0))) + b3.metric("์ดˆ๊ณผ ํ•ญ๋ชฉ", f"{over_count}๊ฑด") + budgets["budget_display"] = budgets["budget"].map(format_krw) + budgets["actual_display"] = budgets["actual"].map(format_krw) + budgets["remaining_display"] = budgets["remaining"].map(format_krw) + budgets["usage_rate_display"] = budgets["usage_rate"].map(format_percent) + st.dataframe(budgets[["category", "subcategory", "person", "budget_display", "actual_display", "remaining_display", "usage_rate_display", "status"]], width="stretch", height=240) + else: + st.info(f"{month} ์˜ˆ์‚ฐ์ด ์—†์Šต๋‹ˆ๋‹ค. ์˜ˆ์‚ฐ ๋ฉ”๋‰ด์—์„œ ์›”๋ณ„ ์˜ˆ์‚ฐ์„ ๋“ฑ๋กํ•˜๋ฉด ์ด ์˜์—ญ์— ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค.") - person_summary = df[df["cashflow_kind"].isin(["income", "expense"])].pivot_table(index="person", columns="cashflow_kind", values="amount", aggfunc="sum", fill_value=0).reset_index() - if not person_summary.empty: - person_summary["person"] = person_summary["person"].replace("", "๋ฏธ์ง€์ •") - for col in ["income", "expense"]: - if col not in person_summary.columns: - person_summary[col] = 0 - person_summary["net_cashflow"] = person_summary["income"] - person_summary["expense"] - st.subheader("์ธ์›๋ณ„ ์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ") - st.dataframe(person_summary, width="stretch", height=220) - st.plotly_chart(px.bar(person_summary, x="person", y=["income", "expense", "net_cashflow"], barmode="group", title="์ธ์›๋ณ„ ์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ"), width="stretch") - - cat = df[df["cashflow_kind"] == "expense"].groupby(["category", "subcategory"], dropna=False)["amount"].sum().reset_index() - cat["label"] = cat.apply(lambda r: f"{r['category'] or '๋ฏธ๋ถ„๋ฅ˜'} / {r['subcategory'] or '-'}", axis=1) - cat = cat.sort_values("amount", ascending=False).head(20) - st.plotly_chart(px.bar(cat, x="amount", y="label", orientation="h", title="์‹ค์ œ ์ง€์ถœ ์นดํ…Œ๊ณ ๋ฆฌ Top 20"), width="stretch") + render_section_label("โš ๏ธ", "์šด์˜ ๊ฒฝ๊ณ ") + review_items = get_transfer_review_items(transfer_queue_res) + reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else [] + reconciliation_review = [item for item in reconciliation_items if item.get("status") == "REVIEW_NEEDED"] + missing_balance = asset_report.get("missing_balance_accounts", []) + w1, w2, w3, w4 = st.columns(4) + w1.metric("๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜", f"{uncategorized_res.get('count', 0):,}๊ฑด") + w2.metric("ํ™•์ธํ•„์š” ํ", f"{len(review_items):,}๊ฑด") + w3.metric("์ž”์•ก๋Œ€์กฐ ํ•„์š”", f"{len(reconciliation_review):,}๊ฑด") + w4.metric("์ž”์•ก ๋ฏธ์ž…๋ ฅ", f"{len(missing_balance):,}๊ฑด") + latest_backup = (backups_res.get("backups") or [{}])[0] if backups_res.get("success") else {} + latest_import = (imports_res.get("import_batches") or [{}])[0] if imports_res.get("success") else {} + st.caption( + "์ƒ์„ธ ์ด๋™: ๋ฏธ๋ถ„๋ฅ˜ โ†’ ๋ฏธ๋ถ„๋ฅ˜, ํ™•์ธํ•„์š” ํ โ†’ ํ™•์ธํ•„์š” ํ, ์ž”์•ก๋Œ€์กฐ ํ•„์š”/์ž”์•ก ๋ฏธ์ž…๋ ฅ โ†’ ๊ณ„์ขŒ, " + f"์ตœ๊ทผ ๋ฐฑ์—…: {latest_backup.get('name', '์—†์Œ')}, ์ตœ๊ทผ import: {latest_import.get('source', '์—†์Œ')} {latest_import.get('imported_at', '')}" + ) + if missing_balance: + st.warning("last_balance๊ฐ€ ์—†๋Š” ๊ณ„์ขŒ/์ž์‚ฐ์€ ์ž์‚ฐ ์œ ๋™์„ฑ ๊ณ„์‚ฐ์—์„œ ์ œ์™ธํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ณ„์ขŒ ํ™”๋ฉด์—์„œ ๊ธฐ์ค€ ์ž”์•ก๊ณผ ๊ธฐ์ค€ ์‹œ๊ฐ์„ ์ž…๋ ฅํ•˜์„ธ์š”.") def page_transactions(db_path: str) -> None: - st.title("๐Ÿ“’ ๊ฑฐ๋ž˜๋‚ด์—ญ") + render_page_header( + "๊ฑฐ๋ž˜๋‚ด์—ญ", + "๋‚ ์งœ, ์นดํ…Œ๊ณ ๋ฆฌ, ๊ฐ€์กฑ ๊ตฌ์„ฑ์›, ์‚ฌ์šฉ์ฒ˜๋ฅผ ๊ธฐ์ค€์œผ๋กœ ๊ฑฐ๋ž˜๋ฅผ ๋น ๋ฅด๊ฒŒ ์ฐพ์•„ ์ˆ˜์ •ํ•ฉ๋‹ˆ๋‹ค.", + kicker="๐Ÿ“’ Ledger Explorer", + pills=["ํ•„ํ„ฐ๋ง", "๊ฑฐ๋ž˜ ์ˆ˜์ •", "์›์žฅ ๊ฒ€ํ† "], + ) category_options, _ = get_category_options(db_path) people_df = read_sql(db_path, "SELECT DISTINCT person FROM transactions WHERE status='active' AND person<>'' ORDER BY person") people = people_df["person"].tolist() if not people_df.empty else [] @@ -596,7 +1042,7 @@ def page_transactions(db_path: str) -> None: st.dataframe(df.drop(columns=["id", "amount", "balance_after", "is_fixed"], errors="ignore"), width="stretch", height=500) if not df.empty: - st.subheader("์„ ํƒ ๊ฑฐ๋ž˜ ์ˆ˜์ •") + render_section_label("โœ๏ธ", "์„ ํƒ ๊ฑฐ๋ž˜ ์ˆ˜์ •") options = {f"{r.transaction_datetime or r.transaction_date} ยท {r.merchant} ยท {format_krw(r.amount)} ยท {r.category or '๋ฏธ๋ถ„๋ฅ˜'}": r.id for r in df.itertuples()} label = st.selectbox("์ˆ˜์ •ํ•  ๊ฑฐ๋ž˜", list(options.keys())) tx_id = options[label] @@ -605,8 +1051,12 @@ def page_transactions(db_path: str) -> None: def page_cross_transfer_review(db_path: str) -> None: - st.header("๐Ÿ” KBโ†”ํ† ์Šค๋ฑ…ํฌ ํ™•์ธํ•„์š” ํ") - st.caption("KB์™€ ํ† ์Šค๋ฑ…ํฌ ๊ฑฐ๋ž˜๋ฅผ ๋‚ ์งœยท๊ธˆ์•กยท์ƒ๋Œ€ ๊ธฐ์ค€์œผ๋กœ ์ž๋™ ๋Œ€์กฐํ•ฉ๋‹ˆ๋‹ค. ๋จผ์ € ๋ฏธ๋ฆฌ๋ณด๊ธฐ(๋“œ๋ผ์ด๋Ÿฐ)๋กœ ํ›„๋ณด๋ฅผ ํ™•์ธํ•œ ๋’ค ์‹คํ–‰ํ•˜์„ธ์š”.") + render_page_header( + "KBโ†”ํ† ์Šค๋ฑ…ํฌ ํ™•์ธํ•„์š” ํ", + "๋‚ ์งœยท๊ธˆ์•กยท์ƒ๋Œ€ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์ขŒ ๊ฐ„ ์ด๋™ ํ›„๋ณด๋ฅผ ๋Œ€์กฐํ•˜๊ณ , ๋“œ๋ผ์ด๋Ÿฐ ํ›„ ์•ˆ์ „ํ•˜๊ฒŒ ๋งค์นญํ•ฉ๋‹ˆ๋‹ค.", + kicker="๐Ÿ” Transfer Review", + pills=["๋“œ๋ผ์ด๋Ÿฐ ์šฐ์„ ", "๋ณธ์ธ๊ณ„์ขŒ์ด๋™", "์ˆ˜๋™ ๊ฒ€ํ† "], + ) c1, c2 = st.columns(2) with c1: if st.button("๋ฏธ๋ฆฌ๋ณด๊ธฐ(๋“œ๋ผ์ด๋Ÿฐ)"): @@ -621,7 +1071,7 @@ def page_cross_transfer_review(db_path: str) -> None: if "cross_transfer_preview" in st.session_state: prev = st.session_state["cross_transfer_preview"] - st.subheader("๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ฒฐ๊ณผ") + render_section_label("๐Ÿงช", "๋ฏธ๋ฆฌ๋ณด๊ธฐ ๊ฒฐ๊ณผ") st.info(f"์˜ˆ์ƒ ๋งค์นญ {prev.get('matched_count', 0)}๊ฑด / ํ™•์ธํ•„์š” {prev.get('review_count', 0)}๊ฑด") if prev.get("matched_pairs"): st.dataframe(pd.DataFrame(prev.get("matched_pairs")), width="stretch", height=250) @@ -629,7 +1079,7 @@ def page_cross_transfer_review(db_path: str) -> None: st.dataframe(pd.DataFrame(prev.get("review_items")), width="stretch", height=250) st.divider() - st.subheader("ํ™•์ธํ•„์š” ํ") + render_section_label("๐Ÿ“Œ", "ํ™•์ธํ•„์š” ํ") list_queue_fn = getattr(service, "list_cross_transfer_review_queue", None) if callable(list_queue_fn): queue = list_queue_fn(db_path=db_path) @@ -668,23 +1118,50 @@ def page_cross_transfer_review(db_path: str) -> None: def page_uncategorized(db_path: str) -> None: - st.title("๐Ÿงฉ ๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜") - col1, col2 = st.columns([1, 3]) - min_amount = col1.number_input("์ตœ์†Œ ๊ธˆ์•ก", min_value=0, value=0, step=10000) - limit = col2.slider("ํ‘œ์‹œ ๊ฑด์ˆ˜", 10, 500, 100) - summary = service.get_uncategorized_merchant_summary(limit=50) - st.header("๋ฏธ๋ถ„๋ฅ˜ ์‚ฌ์šฉ์ฒ˜ ์š”์•ฝ") - st.dataframe(pd.DataFrame(summary.get("items", [])), width="stretch", height=280) + render_page_header( + "๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜", + "์ถ”์ฒœ ๋ถ„๋ฅ˜๋ฅผ ํ™•์ธํ•˜๊ณ , ๋ฐ˜๋ณต ์‚ฌ์šฉ์ฒ˜๋Š” ๊ทœ์น™์œผ๋กœ ์ „ํ™˜ํ•ด ๋‹ค์Œ import๋ฅผ ์ž๋™ํ™”ํ•ฉ๋‹ˆ๋‹ค.", + kicker="๐Ÿงฉ Categorization Inbox", + pills=["์ถ”์ฒœ", "๋ฐ˜๋ณต ํ›„๋ณด", "๊ทœ์น™ํ™”"], + ) + filter_cols = st.columns([1.0, 1.2, 1.15, 1.15, 1.25]) + min_amount = filter_cols[0].number_input("์ตœ์†Œ ๊ธˆ์•ก", min_value=0, value=0, step=10000) + limit = filter_cols[1].slider("ํ‘œ์‹œ ๊ฑด์ˆ˜", 10, 500, 100) + recommended_only = filter_cols[2].checkbox("์ถ”์ฒœ ์žˆ๋Š” ํ•ญ๋ชฉ๋งŒ", value=False) + repeated_only = filter_cols[3].checkbox("๋ฐ˜๋ณต ํ›„๋ณด๋งŒ", value=False) + sort_label = filter_cols[4].selectbox("์ž‘์—…๋ชฉ๋ก ์ •๋ ฌ", ["๊ธˆ์•ก์ˆœ", "๋‚ ์งœ์ˆœ(์ตœ์‹ ์ˆœ)"], index=0) + sort_by = "date_desc" if sort_label == "๋‚ ์งœ์ˆœ(์ตœ์‹ ์ˆœ)" else "amount_desc" + + workbench_fn = get_service_callable("get_uncategorized_workbench", required_params=["sort_by"]) + if not callable(workbench_fn): + st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์—์„œ ๋ฏธ๋ถ„๋ฅ˜ ์ž‘์—…๋Œ€ ํ•จ์ˆ˜๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. Streamlit ์„œ๋ฒ„๋ฅผ ์™„์ „ํžˆ ์žฌ์‹œ์ž‘ํ•ด ์ฃผ์„ธ์š”.") + return + result = workbench_fn(limit=limit, min_amount=int(min_amount), sort_by=sort_by, db_path=db_path) + if not result.get("success"): + st.error(result.get("error", result)) + return + + summary = result.get("summary", {}) + metric_cols = st.columns(5) + metric_cols[0].metric("๋ฏธ๋ถ„๋ฅ˜", f"{summary.get('uncategorized_count', 0):,}๊ฑด") + metric_cols[1].metric("๋ฏธ๋ถ„๋ฅ˜ ์ด์•ก", format_krw(summary.get("uncategorized_total_amount"))) + metric_cols[2].metric("์‚ฌ์šฉ์ฒ˜ ๊ทธ๋ฃน", f"{summary.get('merchant_group_count', 0):,}๊ฐœ") + metric_cols[3].metric("์ถ”์ฒœ ๊ฐ€๋Šฅ", f"{summary.get('recommendable_count', 0):,}๊ฑด") + metric_cols[4].metric("๋ฐ˜๋ณต ํ›„๋ณด", f"{summary.get('repeated_group_count', 0):,}๊ฐœ") - result = service.list_uncategorized_transactions(limit=limit, min_amount=int(min_amount)) rows = result.get("transactions", []) + groups = result.get("groups", []) if not rows: st.success("๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") return - df = pd.DataFrame(rows) - df["๊ธˆ์•ก"] = df["amount"].map(format_krw) - st.subheader("๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜ ์ˆ˜์ •") - render_uncategorized_action_table(rows, db_path) + + render_section_label("๐Ÿ”", "๋ฐ˜๋ณต ์‚ฌ์šฉ์ฒ˜ ํ›„๋ณด") + st.caption("๊ฐ™์€ ์‚ฌ์šฉ์ฒ˜/์š”์•ฝ/๊ฑฐ๋ž˜์œ ํ˜•์œผ๋กœ ๋ฌถ์ธ ๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜์ž…๋‹ˆ๋‹ค. ์ถ”์ฒœ์ด ์žˆ๋Š” ๊ทธ๋ฃน์€ ํ•œ ๋ฒˆ์— ์ ์šฉํ•˜๊ฑฐ๋‚˜ ๊ทœ์น™์œผ๋กœ ์ €์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.") + render_uncategorized_groups(groups, db_path, repeated_only=repeated_only) + + render_section_label("๐Ÿ› ๏ธ", "๋ฏธ๋ถ„๋ฅ˜ ์ž‘์—… ๋ชฉ๋ก") + st.caption("๊ฑฐ๋ž˜๋ฅผ ํŽผ์น˜๋ฉด ์ถ”์ฒœ ๊ทผ๊ฑฐ์™€ ์ž‘์—… ๋ฒ„ํŠผ์ด ๋ณด์ž…๋‹ˆ๋‹ค. ์ถ”์ฒœ ์ ์šฉ์€ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ๋น„์–ด ์žˆ๋Š” ํ˜„์žฌ ๊ฑฐ๋ž˜์—๋งŒ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค.") + render_uncategorized_work_items(rows, db_path, recommended_only=recommended_only) def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None: @@ -695,130 +1172,217 @@ def render_rule_list_items(rules: list[dict[str, Any]], db_path: str) -> None: st.markdown( """ """, unsafe_allow_html=True, ) - wrapper = st.container(border=True) - with wrapper: - for idx, rule in enumerate(rules): - rule_id = rule.get("id", "") - active = bool(rule.get("active", True)) - status_label = "ํ™œ์„ฑ" if active else "๋น„ํ™œ์„ฑ" - tx_type = rule.get("transaction_type") or "์ „์ฒด" - person = rule.get("person") or "-" - fixed = rule.get("is_fixed") - fixed_label = "์˜ˆ" if fixed is True else "์•„๋‹ˆ์˜ค" if fixed is False else "๋ณ€๊ฒฝ์•ˆํ•จ" - amount_bits = [] - if rule.get("amount_min") is not None: - amount_bits.append(f"์ตœ์†Œ {format_krw(rule.get('amount_min'))}") - if rule.get("amount_max") is not None: - amount_bits.append(f"์ตœ๋Œ€ {format_krw(rule.get('amount_max'))}") - if rule.get("day_of_month") is not None: - amount_bits.append(f"๋งค์›” {rule.get('day_of_month')}์ผ") - condition_text = " ยท ".join(amount_bits) if amount_bits else "์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ" - target_text = f"{rule.get('match_field') or '-'} / {rule.get('match_type') or '-'}" - pattern_text = rule.get("pattern") or "-" - result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}" - memo = rule.get("memo") or "" - - title_cols = st.columns([16, 1], gap="small", vertical_alignment="center") - with title_cols[0]: - st.markdown( - f'
{escape(pattern_text)}p{escape(str(rule.get("priority", "-")))}
', - unsafe_allow_html=True, - ) - with title_cols[1]: - with st.popover("โ‹ฎ"): - if st.button("๋ณ€๊ฒฝ", key=f"rule_menu_edit_{rule_id}", use_container_width=True): - category_rule_edit_dialog(rule, db_path) - if st.button("์‚ญ์ œ", key=f"rule_menu_delete_{rule_id}", use_container_width=True): - delete_rule = getattr(service, "delete_category_rule", None) - if delete_rule is None: - st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์— delete_category_rule ํ•จ์ˆ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์•ฑ์„ ์žฌ์‹œ์ž‘ํ•ด ์ตœ์‹  ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค์„ธ์š”.") - else: - res = delete_rule(rule_id, db_path=db_path) - clear_cache() - if res.get("success"): - if st.session_state.get("rules_edit_rule_id") == rule_id: - st.session_state.pop("rules_edit_rule_id", None) - st.success("๊ทœ์น™์„ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค.") - st.rerun() - else: - st.error(res.get("error", res)) + for idx, rule in enumerate(rules): + rule_id = rule.get("id", "") + active = bool(rule.get("active", True)) + status_label = "ํ™œ์„ฑ" if active else "๋น„ํ™œ์„ฑ" + tx_type = rule.get("transaction_type") or "์ „์ฒด" + person = rule.get("person") or "-" + fixed = rule.get("is_fixed") + fixed_label = "์˜ˆ" if fixed is True else "์•„๋‹ˆ์˜ค" if fixed is False else "๋ณ€๊ฒฝ์•ˆํ•จ" + amount_bits = [] + if rule.get("amount_min") is not None: + amount_bits.append(f"์ตœ์†Œ {format_krw(rule.get('amount_min'))}") + if rule.get("amount_max") is not None: + amount_bits.append(f"์ตœ๋Œ€ {format_krw(rule.get('amount_max'))}") + if rule.get("day_of_month") is not None: + amount_bits.append(f"๋งค์›” {rule.get('day_of_month')}์ผ") + condition_text = " ยท ".join(amount_bits) if amount_bits else "์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ" + match_field = rule.get("match_field") or "-" + match_type = rule.get("match_type") or "-" + pattern_text = rule.get("pattern") or "-" + result_text = f"{rule.get('category') or '-'} / {rule.get('subcategory') or '-'}" + memo = rule.get("memo") or "" + priority = rule.get("priority", "-") + summary = f"{'๐ŸŸข' if active else 'โšช'} {pattern_text} ยท {match_field}/{match_type} โ†’ {result_text} ยท p{priority} ยท {tx_type}" + with st.expander(summary, expanded=False): st.markdown( f""" -
-
๋Œ€์ƒ: {escape(target_text)}
-
์ถ”๊ฐ€ ์กฐ๊ฑด: {escape(condition_text)}
-
๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ: {escape(result_text)}
-
-
๊ฑฐ๋ž˜์œ ํ˜•: {escape(tx_type)}
-
person: {escape(person)}
-
๊ณ ์ •๋น„: {escape(fixed_label)}
-
์ƒํƒœ: {escape(status_label)}
-
ID: {escape(rule_id)}
- {f'
๋ฉ”๋ชจ: {escape(memo)}
' if memo else '
'} +
+
+
IF
+
{escape(match_field)} {escape(match_type)} โ€œ{escape(pattern_text)}โ€
+
+
โ†’
+
+
THEN
+
{escape(result_text)}
+
+ ์ƒํƒœ: {escape(status_label)} + ์šฐ์„ ์ˆœ์œ„: p{escape(str(priority))} + ๊ฑฐ๋ž˜์œ ํ˜•: {escape(tx_type)} + ์กฐ๊ฑด: {escape(condition_text)} + person: {escape(person)} + ๊ณ ์ •๋น„: {escape(fixed_label)} + ID: {escape(rule_id)} +
+ {f'
๋ฉ”๋ชจ: {escape(memo)}
' if memo else ''} """, unsafe_allow_html=True, ) - if idx < len(rules) - 1: - st.divider() + action_cols = st.columns([1.35, 0.9, 0.8, 1.4, 3.2], vertical_alignment="center") + if action_cols[0].button("๐Ÿ”Ž ์ ์šฉ(๋ฏธ๋ฆฌ๋ณด๊ธฐ)", key=f"rule_expander_preview_{rule_id}_{idx}", use_container_width=True): + category_rule_preview_dialog(rule, db_path) + if action_cols[1].button("โœ… ์ ์šฉ", key=f"rule_expander_apply_{rule_id}_{idx}", use_container_width=True): + apply_fn = get_service_callable("apply_category_rule") + if not callable(apply_fn): + st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์—์„œ ๋‹จ์ผ ๊ทœ์น™ ์ ์šฉ ํ•จ์ˆ˜๋ฅผ ์ฐพ์ง€ ๋ชปํ–ˆ์Šต๋‹ˆ๋‹ค. Streamlit ์„œ๋ฒ„๋ฅผ ์™„์ „ํžˆ ์ข…๋ฃŒํ•œ ๋’ค ๋‹ค์‹œ ์‹œ์ž‘ํ•ด ์ฃผ์„ธ์š”.") + else: + res = apply_fn(rule_id, limit=10000, db_path=db_path) + clear_cache() + if res.get("success"): + st.success(f"๋ฏธ๋ถ„๋ฅ˜ ๋งค์นญ {res.get('applied_count', 0):,}๊ฑด์— ๊ทœ์น™์„ ์ ์šฉํ–ˆ์Šต๋‹ˆ๋‹ค.") + st.rerun() + else: + st.error(res.get("error", res)) + if action_cols[2].button("โœ๏ธ ์ˆ˜์ •", key=f"rule_expander_edit_{rule_id}_{idx}", use_container_width=True): + category_rule_edit_dialog(rule, db_path) + with action_cols[3]: + st.markdown('
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
', unsafe_allow_html=True) + if action_cols[4].button("๐Ÿ—‘๏ธ ์‚ญ์ œ", key=f"rule_expander_delete_{rule_id}_{idx}"): + delete_rule = getattr(service, "delete_category_rule", None) + if delete_rule is None: + st.error("ํ˜„์žฌ ์‹คํ–‰ ์ค‘์ธ ์„œ๋น„์Šค์— delete_category_rule ํ•จ์ˆ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ์•ฑ์„ ์žฌ์‹œ์ž‘ํ•ด ์ตœ์‹  ์ฝ”๋“œ๋ฅผ ๋‹ค์‹œ ๋ถˆ๋Ÿฌ์˜ค์„ธ์š”.") + else: + res = delete_rule(rule_id, db_path=db_path) + clear_cache() + if res.get("success"): + if st.session_state.get("rules_edit_rule_id") == rule_id: + st.session_state.pop("rules_edit_rule_id", None) + st.success("๊ทœ์น™์„ ์‚ญ์ œํ–ˆ์Šต๋‹ˆ๋‹ค.") + st.rerun() + else: + st.error(res.get("error", res)) def page_rules(db_path: str) -> None: - title_cols = st.columns([8, 1], vertical_alignment="center") - with title_cols[0]: - st.title("โš™๏ธ ๋ถ„๋ฅ˜ ๊ทœ์น™") - with title_cols[1]: + render_page_header( + "๋ถ„๋ฅ˜ ๊ทœ์น™", + "๋ฐ˜๋ณต๋˜๋Š” ์‚ฌ์šฉ์ฒ˜์™€ ๊ฑฐ๋ž˜ ํŒจํ„ด์„ ๊ทœ์น™์œผ๋กœ ๊ด€๋ฆฌํ•ด ๋ถ„๋ฅ˜ ์ž๋™ํ™” ํ’ˆ์งˆ์„ ๋†’์ž…๋‹ˆ๋‹ค.", + kicker="โš™๏ธ Rule Engine", + pills=["์ž๋™ ๋ถ„๋ฅ˜", "Preview", "Seed/Update"], + ) + action_cols = st.columns([5, 1], vertical_alignment="center") + with action_cols[1]: if st.button("์ถ”๊ฐ€", key="rule_add_dialog_open", use_container_width=True, type="primary"): category_rule_create_dialog(db_path) rules = service.list_category_rules(active_only=False, db_path=db_path).get("rules", []) - st.subheader("ํ˜„์žฌ ๋ถ„๋ฅ˜ ๊ทœ์น™") + render_section_label("๐Ÿ“š", "ํ˜„์žฌ ๋ถ„๋ฅ˜ ๊ทœ์น™") render_rule_list_items(rules, db_path) st.caption("๊ทœ์น™ ์ถ”๊ฐ€๋Š” ์ œ๋ชฉ ์˜ค๋ฅธ์ชฝ `์ถ”๊ฐ€` ๋ฒ„ํŠผ, ๊ธฐ์กด ๊ทœ์น™ ์ˆ˜์ •์€ ๊ฐ ํ•ญ๋ชฉ์˜ `โ‹ฎ โ†’ ๋ณ€๊ฒฝ` ํŒ์—…์„ ์‚ฌ์šฉํ•˜์„ธ์š”.") @@ -844,7 +1408,12 @@ def page_rules(db_path: str) -> None: def page_categories(db_path: str) -> None: - st.header("๐Ÿท๏ธ ์นดํ…Œ๊ณ ๋ฆฌ ๊ด€๋ฆฌ") + render_page_header( + "์นดํ…Œ๊ณ ๋ฆฌ ๊ด€๋ฆฌ", + "ํ˜„๊ธˆํ๋ฆ„ ์„ฑ๊ฒฉ๊นŒ์ง€ ํ•จ๊ป˜ ๊ด€๋ฆฌํ•ด ์š”์•ฝ ๋ฆฌํฌํŠธ์™€ ํˆฌ์ž ๊ฐ€๋Šฅ ํ˜„๊ธˆํ๋ฆ„ ๊ณ„์‚ฐ์˜ ๊ธฐ์ค€์„ ์ •๋ˆํ•ฉ๋‹ˆ๋‹ค.", + kicker="๐Ÿท๏ธ Category Taxonomy", + pills=["์ˆ˜์ž…/์ง€์ถœ/์ด์ฒด", "๋น„ํ™œ์„ฑํ™” ์•ˆ์ „์žฅ์น˜", "์‚ฌ์šฉ๋Ÿ‰ ํ™•์ธ"], + ) st.caption("์นดํ…Œ๊ณ ๋ฆฌ ์‚ญ์ œ๋Š” ๊ธฐ๋ณธ์ ์œผ๋กœ ๋น„ํ™œ์„ฑํ™” ์ฒ˜๋ฆฌํ•ฉ๋‹ˆ๋‹ค. ๊ธฐ์กด ๊ฑฐ๋ž˜์˜ ๊ณผ๊ฑฐ category ๊ฐ’์€ ๋ณด์กด๋ฉ๋‹ˆ๋‹ค.") c1, c2 = st.columns(2) @@ -855,7 +1424,7 @@ def page_categories(db_path: str) -> None: st.rerun() show_inactive = c2.checkbox("๋น„ํ™œ์„ฑ ์นดํ…Œ๊ณ ๋ฆฌ๋„ ๋ณด๊ธฐ", value=False) - st.subheader("์นดํ…Œ๊ณ ๋ฆฌ ์ถ”๊ฐ€") + render_section_label("โž•", "์นดํ…Œ๊ณ ๋ฆฌ ์ถ”๊ฐ€") with st.form("create_ledger_category"): cols = st.columns([2, 2, 2, 1, 3]) category = cols[0].text_input("๋Œ€๋ถ„๋ฅ˜", placeholder="์˜ˆ: ๋ฐ˜๋ ค๋™๋ฌผ") @@ -876,7 +1445,7 @@ def page_categories(db_path: str) -> None: result = service.list_ledger_categories(active_only=not show_inactive, include_usage=True, db_path=db_path) rows = result.get("categories", []) - st.subheader("์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก") + render_section_label("๐Ÿ—‚๏ธ", "์นดํ…Œ๊ณ ๋ฆฌ ๋ชฉ๋ก") if rows: df = pd.DataFrame(rows) if "cashflow_kind" in df: @@ -888,7 +1457,7 @@ def page_categories(db_path: str) -> None: st.info("๋“ฑ๋ก๋œ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๊ธฐ๋ณธ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ seedํ•˜๊ฑฐ๋‚˜ ์ƒˆ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ ์ถ”๊ฐ€ํ•˜์„ธ์š”.") return - st.subheader("์นดํ…Œ๊ณ ๋ฆฌ ์‚ญ์ œ/๋น„ํ™œ์„ฑํ™”") + render_section_label("๐Ÿ›ก๏ธ", "์นดํ…Œ๊ณ ๋ฆฌ ์‚ญ์ œ/๋น„ํ™œ์„ฑํ™”") options = { f"{r['category']} / {r['subcategory'] or '-'} ยท ์‚ฌ์šฉ {r.get('transaction_count', 0)}๊ฑด ยท {'ํ™œ์„ฑ' if r.get('active') else '๋น„ํ™œ์„ฑ'}": r for r in rows @@ -908,17 +1477,106 @@ def page_categories(db_path: str) -> None: def page_accounts(db_path: str) -> None: - st.header("๐Ÿฆ ๊ณ„์ขŒ") - accounts = read_sql(db_path, "SELECT * FROM accounts ORDER BY created_at DESC") + render_page_header( + "๊ณ„์ขŒ", + "์ˆ˜์ง‘ยทimport์— ์—ฐ๊ฒฐ๋œ ๊ณ„์ขŒ์™€ ์นด๋“œ, ๋งˆ์ง€๋ง‰ ์ž”์•ก ์ •๋ณด๋ฅผ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.", + kicker="๐Ÿฆ Accounts", + pills=["์ž”์•ก", "๊ธฐ๊ด€", "statement_status", "์ˆ˜๋™ ์ž”์•ก๋Œ€์กฐ"], + ) + accounts_res = service.list_ledger_accounts(active_only=False, db_path=db_path) + accounts_data = accounts_res.get("accounts", []) if accounts_res.get("success") else [] + accounts = pd.DataFrame(accounts_data) if not accounts.empty: + accounts["statement_status_label"] = accounts["statement_status"].map(STATEMENT_STATUS_LABELS).fillna(accounts["statement_status"]) + accounts["liquidity_bucket_label"] = accounts["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(accounts["liquidity_bucket"]) accounts["last_balance_display"] = accounts["last_balance"].map(format_krw) - st.dataframe(accounts, width="stretch") + display_cols = [c for c in ["id", "name", "type", "institution", "liquidity_bucket", "liquidity_bucket_label", "statement_status", "statement_status_label", "last_balance_display", "last_synced_at", "active", "memo"] if c in accounts.columns] + st.dataframe(accounts[display_cols], width="stretch") + else: + st.info("๋“ฑ๋ก๋œ ๊ณ„์ขŒ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") + + st.divider() + render_section_label("๐Ÿ› ๏ธ", "๊ณ„์ขŒ ์šด์˜ ์†์„ฑ ์ˆ˜์ •") + if accounts_data: + account_options = {f"{a['name']} ({a['id']})": a for a in accounts_data} + with st.form("account_statement_status_form"): + selected_label = st.selectbox("๊ณ„์ขŒ", list(account_options.keys())) + selected = account_options[selected_label] + current_status = selected.get("statement_status", "unknown") + status = st.selectbox( + "statement_status", + STATEMENT_STATUS_OPTIONS, + index=STATEMENT_STATUS_OPTIONS.index(current_status) if current_status in STATEMENT_STATUS_OPTIONS else 0, + format_func=lambda v: f"{v} โ€” {STATEMENT_STATUS_LABELS.get(v, v)}", + ) + current_bucket = selected.get("liquidity_bucket", "other") or "other" + liquidity_bucket = st.selectbox( + "liquidity_bucket", + LIQUIDITY_BUCKET_OPTIONS, + index=LIQUIDITY_BUCKET_OPTIONS.index(current_bucket) if current_bucket in LIQUIDITY_BUCKET_OPTIONS else LIQUIDITY_BUCKET_OPTIONS.index("other"), + format_func=lambda v: f"{v} โ€” {LIQUIDITY_BUCKET_LABELS.get(v, v)}", + ) + last_balance_raw = st.text_input("๊ธฐ์ค€ ์ž”์•ก(last_balance, ์„ ํƒ)", value="" if selected.get("last_balance") is None else str(selected.get("last_balance"))) + last_synced_at = st.text_input("๊ธฐ์ค€ ์‹œ๊ฐ(last_synced_at, ์„ ํƒ)", value=selected.get("last_synced_at") or "", help="์˜ˆ: 2026-06 ๋˜๋Š” 2026-06-30T23:59:00+09:00") + memo = st.text_input("๋ฉ”๋ชจ", value=selected.get("memo") or "") + submitted = st.form_submit_button("๊ณ„์ขŒ ์ƒํƒœ ์ €์žฅ", type="primary") + if submitted: + update_fields: dict[str, Any] = {"statement_status": status, "liquidity_bucket": liquidity_bucket, "last_synced_at": last_synced_at, "memo": memo} + if last_balance_raw.strip(): + try: + update_fields["last_balance"] = int(last_balance_raw.replace(",", "")) + except ValueError: + st.error("๊ธฐ์ค€ ์ž”์•ก์€ ์ •์ˆ˜๋กœ ์ž…๋ ฅํ•˜์„ธ์š”.") + return + res = service.update_ledger_account(selected["id"], db_path=db_path, **update_fields) + clear_cache() + if res.get("success"): + st.success("๊ณ„์ขŒ ์ƒํƒœ๋ฅผ ์ €์žฅํ–ˆ์Šต๋‹ˆ๋‹ค.") + st.rerun() + else: + st.error(res.get("error", res)) + + st.divider() + render_section_label("โš–๏ธ", "์ตœ์†Œ ์ž”์•ก๋Œ€์กฐ ๊ฒฐ๊ณผ") + st.caption("๋Œ€์ƒ: statement_status๊ฐ€ manual_only ๋˜๋Š” partial_import์ธ ๊ณ„์ขŒ. ๊ฒฐ๊ณผ๋Š” preview/report์ด๋ฉฐ ์ž๋™ ํ™•์ •ยท์•Œ๋ฆผยทrestore๋ฅผ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.") + c1, c2, c3 = st.columns([1, 1, 1]) + baseline_at = c1.text_input("์Šค๋ƒ…์ƒท ๊ธฐ์ค€ ์‹œ๊ฐ", value="", placeholder="2026-06 ๋˜๋Š” 2026-06-30T23:59:00+09:00") + tolerance = c2.number_input("ํ—ˆ์šฉ ์˜ค์ฐจ(์›)", min_value=0, value=1000, step=100) + source_note = c3.text_input("์ถœ์ฒ˜ ๋ฉ”๋ชจ", value="", placeholder="์˜ˆ: ์›”๋ง ์•ฑ ์ž”์•ก") + if st.button("์ž”์•ก๋Œ€์กฐ ์‹คํ–‰", type="secondary"): + st.session_state["balance_reconciliation_report"] = service.get_balance_reconciliation_report( + baseline_at=baseline_at, + source_note=source_note, + tolerance=int(tolerance), + db_path=db_path, + ) + report = st.session_state.get("balance_reconciliation_report") + if report: + if report.get("success"): + items = pd.DataFrame(report.get("items", [])) + if not items.empty: + for col in ["baseline_balance", "calculated_balance", "difference"]: + if col in items.columns: + items[f"{col}_display"] = items[col].map(format_krw) + show_cols = [c for c in ["account_name", "statement_status", "status", "baseline_at", "baseline_balance_display", "calculated_balance_display", "difference_display", "difference_ratio", "calculation_basis", "transaction_count"] if c in items.columns] + st.dataframe(items[show_cols], width="stretch") + review_count = int((items["status"] == "REVIEW_NEEDED").sum()) if "status" in items else 0 + st.metric("ํ™•์ธํ•„์š”", f"{review_count}๊ฑด") + else: + st.info("์ž”์•ก๋Œ€์กฐ ๋Œ€์ƒ ๊ณ„์ขŒ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.") + else: + st.error(report.get("error", report)) def page_imports(db_path: str) -> None: - st.header("๐Ÿ“ฅ Import") + render_page_header( + "Import", + "PDF/XLSX ๊ฑฐ๋ž˜๋‚ด์—ญ์„ ๋จผ์ € Preview๋กœ ๊ฒ€์ฆํ•œ ๋’ค ์›์žฅ์— ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค.", + kicker="๐Ÿ“ฅ Statement Import", + pills=["Preview ์šฐ์„ ", "์ค‘๋ณต skip", "Import ์ด๋ ฅ"], + ) - st.subheader("ํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF ๊ฐ€์ ธ์˜ค๊ธฐ") + render_section_label("๐Ÿ“ˆ", "ํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF ๊ฐ€์ ธ์˜ค๊ธฐ") st.caption("ํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ์„œ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ํˆฌ์ž๊ณ„์ขŒ ๊ฑฐ๋ž˜๋กœ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ๋งค์ˆ˜/๋งค๋„/ํ™˜์ „/์ž…์ถœ๊ธˆ์€ `์ด์ฒด/ํˆฌ์ž์ด๋™`, ๋ฐฐ๋‹น/์ด์ž๋Š” ์‹ค์ œ ์ˆ˜์ž…์œผ๋กœ ๋ถ„๋ฅ˜ํ•ฉ๋‹ˆ๋‹ค.") uploaded = st.file_uploader("ํ† ์Šค์ฆ๊ถŒ PDF", type=["pdf"], key="toss_pdf") if uploaded is not None: @@ -952,7 +1610,7 @@ def page_imports(db_path: str) -> None: st.error(res) st.divider() - st.subheader("ํ† ์Šค๋ฑ…ํฌ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF ๊ฐ€์ ธ์˜ค๊ธฐ") + render_section_label("๐Ÿฆ", "ํ† ์Šค๋ฑ…ํฌ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF ๊ฐ€์ ธ์˜ค๊ธฐ") st.caption("ํ† ์Šค๋ฑ…ํฌ ๊ณ„์ขŒ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ์ž…์ถœ๊ธˆ/์ด์ฒด/์นด๋“œ๊ฒฐ์ œ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ํƒ€์ธ ์ด์ฒด/์ถœ๊ธˆ์€ `์ด์ฒด/ํ™•์ธํ•„์š”`๋กœ ๋ถ„๋ฅ˜๋ฉ๋‹ˆ๋‹ค.") toss_bank_uploaded = st.file_uploader("ํ† ์Šค๋ฑ…ํฌ PDF", type=["pdf"], key="tossbank_pdf") if toss_bank_uploaded is not None: @@ -986,8 +1644,8 @@ def page_imports(db_path: str) -> None: st.error(res) st.divider() - st.subheader("Import ์ด๋ ฅ") - batches = service.list_ledger_import_batches(limit=100).get("import_batches", []) + render_section_label("๐Ÿงพ", "Import ์ด๋ ฅ") + batches = service.list_ledger_import_batches(limit=100, db_path=db_path).get("import_batches", []) st.dataframe(pd.DataFrame(batches), width="stretch", height=350) if batches: with st.form("rollback"): @@ -995,7 +1653,7 @@ def page_imports(db_path: str) -> None: hard = st.checkbox("Hard delete", value=False) submitted = st.form_submit_button("Rollback ์‹คํ–‰", type="secondary") if submitted: - res = service.rollback_ledger_import_batch(batch_id, hard_delete=hard) + res = service.rollback_ledger_import_batch(batch_id, hard_delete=hard, db_path=db_path) clear_cache() st.warning(res) st.rerun() @@ -1003,7 +1661,7 @@ def page_imports(db_path: str) -> None: def main() -> None: db_path = get_db_path() - inject_sidebar_nav_css() + inject_app_css() pages = ["์š”์•ฝ", "๊ฑฐ๋ž˜๋‚ด์—ญ", "ํ™•์ธํ•„์š” ํ", "๋ฏธ๋ถ„๋ฅ˜", "๋ถ„๋ฅ˜ ๊ทœ์น™", "์นดํ…Œ๊ณ ๋ฆฌ", "๊ณ„์ขŒ", "Import"] query_page = st.query_params.get("page", "์š”์•ฝ") @@ -1011,7 +1669,8 @@ def main() -> None: st.session_state["page"] = page with st.sidebar: - st.markdown("## Jarca Ledger") + st.markdown("## ๐Ÿ’ฐ Jarca Ledger") + st.caption("๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”") st.markdown('
๋ฉ”๋‰ด
', unsafe_allow_html=True) render_sidebar_nav(pages, page) if st.button("์ƒˆ๋กœ๊ณ ์นจ", key="refresh_button", use_container_width=True): diff --git a/docs/batches/bat-003_20260629_statement-unavailable-reconciliation/implementation.md b/docs/batches/bat-003_20260629_statement-unavailable-reconciliation/implementation.md new file mode 100644 index 0000000..4b30150 --- /dev/null +++ b/docs/batches/bat-003_20260629_statement-unavailable-reconciliation/implementation.md @@ -0,0 +1,28 @@ +# BAT-003 Implementation + +## ์ฝ”๋“œ ๋ณ€๊ฒฝ + +- `jarca_ledger_mcp/db.py` + - schema version 7 + - `accounts.statement_status TEXT NOT NULL DEFAULT 'unknown'` + - ๊ธฐ์กด DB์šฉ additive column migration +- `jarca_ledger_mcp/service.py` + - `VALID_STATEMENT_STATUSES` ๋ฐ reconciliation ๋Œ€์ƒ ์ƒ์ˆ˜ + - `create_ledger_account`์— `statement_status` ์ž…๋ ฅ ์ถ”๊ฐ€ + - `update_ledger_account` ์‹ ๊ทœ ์ถ”๊ฐ€ + - `get_balance_reconciliation_report` ์‹ ๊ทœ ์ถ”๊ฐ€ +- `jarca_ledger_mcp/server.py` + - `create_ledger_account` signature ํ™•์žฅ + - `update_ledger_account`, `get_balance_reconciliation_report` MCP tool ์ถ”๊ฐ€ +- `dashboard/app.py` + - ๊ณ„์ขŒ ํ™”๋ฉด์— statement ์ƒํƒœ ํ‘œ์‹œ/์ˆ˜์ • UI ์ถ”๊ฐ€ + - ์ตœ์†Œ ์ž”์•ก๋Œ€์กฐ ๊ฒฐ๊ณผ ํ‘œ ์ถ”๊ฐ€ +- `tests/test_service.py` + - schema version 7 ๋ฐ˜์˜ + - statement status validation ๋ฐ balance reconciliation ํ…Œ์ŠคํŠธ ์ถ”๊ฐ€ + +## ์šด์˜ ์•ˆ์ „ + +- ์šด์˜ DB ๋ณ€๊ฒฝ ์ „ dcy-002 ๋ฐฑ์—… ์ ˆ์ฐจ๋ฅผ ๋จผ์ € ์ˆ˜ํ–‰ํ•œ๋‹ค. +- ๊ฒ€์ฆ DB์—์„œ preview/reconcile ํ›„ ์šด์˜ DB์— ๋ฐ˜์˜ํ•œ๋‹ค. +- restore๋Š” ์‚ฌ์šฉ์ž์˜ ๋ช…์‹œ ์Šน์ธ ์—†์ด ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. diff --git a/docs/batches/bat-003_20260629_statement-unavailable-reconciliation/index.md b/docs/batches/bat-003_20260629_statement-unavailable-reconciliation/index.md new file mode 100644 index 0000000..b0541ca --- /dev/null +++ b/docs/batches/bat-003_20260629_statement-unavailable-reconciliation/index.md @@ -0,0 +1,25 @@ +# BAT-003 Statement-unavailable ์ˆ˜๋™ ์ฆ๋น™ ๋ฐ ์ž”์•ก๋Œ€์กฐ + +- Profile: batch-lite +- Status: release-candidate +- Included REQ: REQ-003 +- Discovery: dcy-003 +- Owner: ๋ ›์ €์žฅ์ธ +- Date (KST): 2026-06-29 + +## ๋ชฉํ‘œ + +statement ํŒŒ์ผ์ด ์—†๊ฑฐ๋‚˜ ์ผ๋ถ€๋งŒ import ๊ฐ€๋Šฅํ•œ ๊ณ„์ขŒ๋ฅผ `statement_status`๋กœ ๊ตฌ๋ถ„ํ•˜๊ณ , ์ˆ˜๋™ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์ž”์•ก๋Œ€์กฐ report ๋ฐ ๊ณ„์ขŒ ํ™”๋ฉด ์ตœ์†Œ UI๋ฅผ ์ œ๊ณตํ•œ๋‹ค. + +## ์‚ฐ์ถœ๋ฌผ + +- `accounts.statement_status` additive migration +- service ๊ณ„์ขŒ update ๋ฐ ์ž”์•ก๋Œ€์กฐ report +- MCP tool surface ์ถ”๊ฐ€ +- dashboard ๊ณ„์ขŒ ํ™”๋ฉด ํ‘œ์‹œ/์ˆ˜์ •/๋Œ€์กฐ ๊ฒฐ๊ณผ +- REQ-003 ๋ฐ ์šด์˜ ๋ฌธ์„œ ๊ฐฑ์‹  +- pytest ๊ธฐ๋ฐ˜ regression ๊ฒ€์ฆ + +## ๋ฒ”์œ„ ์ œํ•œ + +์ž๋™ ์Šค์ผ€์ค„๋ง/์•Œ๋ฆผ, ์ „์šฉ ์›Œํฌ๋ฒค์น˜ ์ „์ฒด ์‹ ์„ค, ์ž๋™ ํ™•์ • mutation, ์‚ฌ์šฉ์ž ์Šน์ธ ์—†๋Š” restore๋Š” ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค. diff --git a/docs/batches/bat-003_20260629_statement-unavailable-reconciliation/verification.md b/docs/batches/bat-003_20260629_statement-unavailable-reconciliation/verification.md new file mode 100644 index 0000000..b209330 --- /dev/null +++ b/docs/batches/bat-003_20260629_statement-unavailable-reconciliation/verification.md @@ -0,0 +1,35 @@ +# BAT-003 Verification + +## ๊ฒ€์ฆ ๊ณ„ํš + +1. Python ๋ฌธ๋ฒ• ๊ฒ€์ฆ + - `python3 -m py_compile jarca_ledger_mcp/db.py jarca_ledger_mcp/service.py jarca_ledger_mcp/server.py dashboard/app.py tests/test_service.py` +2. ๋Œ€์ƒ ํ…Œ์ŠคํŠธ + - `python3 -m pytest tests/test_service.py -q` +3. ์ „์ฒด ํ…Œ์ŠคํŠธ + - ๊ฐ€๋Šฅํ•˜๋ฉด `python3 -m pytest -q` + +## Acceptance Mapping + +- AC-1/2: `test_health_check_initializes_db`, `test_invalid_statement_status_rejected` +- AC-3/4: `test_statement_status_account_update_and_balance_reconciliation` +- AC-5: dashboard py_compile ๋ฐ ๊ณ„์ขŒ page ์ฝ”๋“œ ๊ฒ€์ฆ +- AC-6: server py_compile ๋ฐ tool ํ•จ์ˆ˜ ์ถ”๊ฐ€ ํ™•์ธ +- AC-7: REQ/batch/data-model/interface/runtime/runbook ๋ฌธ์„œ ๊ฐฑ์‹  + +## ๊ฒฐ๊ณผ + +์ตœ์ข… ๊ฒ€์ฆ ๊ฒฐ๊ณผ(2026-06-29 KST): + +- `python -m py_compile dashboard/app.py jarca_ledger_mcp/service.py jarca_ledger_mcp/server.py jarca_ledger_mcp/db.py` โ€” PASS +- `pytest tests/test_service.py tests/test_categorization.py tests/test_uncategorized_workbench.py -q` โ€” PASS (`12 passed`) +- `pytest -q` โ€” PASS (`28 passed, 4 skipped`) +- Streamlit `AppTest.from_file('dashboard/app.py')` with synthetic temp DB and `?page=๊ณ„์ขŒ` โ€” PASS; account page rendered without exceptions and exposed `์ž”์•ก๋Œ€์กฐ ์‹คํ–‰`. + +๊ฒ€์ฆ ์ค‘ ๋ฐœ๊ฒฌ/์ˆ˜์ •: + +- `tests/test_ledger_categories.py`์˜ schema version ๊ธฐ๋Œ€๊ฐ’์ด ์ด์ „ ๋ฒ„์ „ `6`์œผ๋กœ ๋‚จ์•„ ์žˆ์–ด `7`๋กœ ๊ฐฑ์‹ ํ–ˆ๋‹ค. + +๋‚จ์€ ๋ฒ”์œ„ ์™ธ ํ•ญ๋ชฉ: + +- dcy-004/dcy-005 ๋ฌธ์„œ๋Š” ํ˜„์žฌ ์ž‘์—… ๋ฒ”์œ„๊ฐ€ ์•„๋‹ˆ๋ฏ€๋กœ ๊ตฌํ˜„์„ ์‹œ์ž‘ํ•˜์ง€ ์•Š์•˜๋‹ค. diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/import.dom.html b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/import.dom.html new file mode 100644 index 0000000..3c2bb0f --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/import.dom.html @@ -0,0 +1,241 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

๋ฉ”๋‰ด
+
๐Ÿ“ฅ Statement Import
+

Import

+

PDF/XLSX ๊ฑฐ๋ž˜๋‚ด์—ญ์„ ๋จผ์ € Preview๋กœ ๊ฒ€์ฆํ•œ ๋’ค ์›์žฅ์— ๋ฐ˜์˜ํ•ฉ๋‹ˆ๋‹ค.

+
Preview ์šฐ์„ ์ค‘๋ณต skipImport ์ด๋ ฅ
+

ํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ์„œ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ํˆฌ์ž๊ณ„์ขŒ ๊ฑฐ๋ž˜๋กœ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ๋งค์ˆ˜/๋งค๋„/ํ™˜์ „/์ž…์ถœ๊ธˆ์€ ์ด์ฒด/ํˆฌ์ž์ด๋™, ๋ฐฐ๋‹น/์ด์ž๋Š” ์‹ค์ œ ์ˆ˜์ž…์œผ๋กœ ๋ถ„๋ฅ˜ํ•ฉ๋‹ˆ๋‹ค.


ํ† ์Šค๋ฑ…ํฌ ๊ณ„์ขŒ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ์ž…์ถœ๊ธˆ/์ด์ฒด/์นด๋“œ๊ฒฐ์ œ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ํƒ€์ธ ์ด์ฒด/์ถœ๊ธˆ์€ ์ด์ฒด/ํ™•์ธํ•„์š”๋กœ ๋ถ„๋ฅ˜๋ฉ๋‹ˆ๋‹ค.


+ + + \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/import.png b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/import.png new file mode 100644 index 0000000..fa6bde5 Binary files /dev/null and b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/import.png differ diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/manifest.json b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/manifest.json new file mode 100644 index 0000000..04427e9 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/manifest.json @@ -0,0 +1,62 @@ +{ + "success": true, + "base_url": "http://127.0.0.1:18501", + "output_dir": "/root/projects/jarca-ledger-mcp/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835", + "fixture": { + "account_id": "acct_a4f4de4cdd0f", + "transaction_ids": [ + "tx_cba36b3d2c33", + "tx_24fa1050eb2b", + "tx_8cd3af3d60b6" + ], + "db_path": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/fixture.sqlite3" + }, + "chrome": "/usr/bin/google-chrome-stable", + "streamlit": ".venv/bin/streamlit", + "pages": [ + { + "page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "url": "http://127.0.0.1:18501/?page=%EB%B6%84%EB%A5%98%20%EA%B7%9C%EC%B9%99", + "screenshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/rules.png", + "screenshot_bytes": 364876, + "snapshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/rules.dom.html", + "snapshot_bytes": 174470, + "devtools_port": 19222, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\n๋ฉ”๋‰ด\n์š”์•ฝ\n๊ฑฐ๋ž˜๋‚ด์—ญ\nํ™•์ธํ•„์š” ํ\n๋ฏธ๋ถ„๋ฅ˜\n๋ถ„๋ฅ˜ ๊ทœ์น™\n์นดํ…Œ๊ณ ๋ฆฌ\n๊ณ„์ขŒ\nImport\nStop\nDeploy\nโš™๏ธ Rule Engine\n๋ถ„๋ฅ˜ ๊ทœ์น™\n\n๋ฐ˜๋ณต๋˜๋Š” ์‚ฌ์šฉ์ฒ˜์™€ ๊ฑฐ๋ž˜ ํŒจํ„ด์„ ๊ทœ์น™์œผ๋กœ ๊ด€๋ฆฌํ•ด ๋ถ„๋ฅ˜ ์ž๋™ํ™” ํ’ˆ์งˆ์„ ๋†’์ž…๋‹ˆ๋‹ค.\n\n์ž๋™ ๋ถ„๋ฅ˜\nPreview\nSeed/Update\nkeyboard_arrow_right\n\n๐ŸŸข ์ด์ž ยท merchant/contains โ†’ ์ˆ˜์ž… / ์ด์ž ยท p10 ยท income\n\nkeyboard_arrow_right\n\n๐ŸŸข ํ™˜๊ธ‰ ยท merchant/contains โ†’ ์ˆ˜์ž… / ํ™˜๊ธ‰ ยท p10 ยท income\n\nkeyboard_arrow_right\n\n๐ŸŸข ๊ธ‰์—ฌ ยท merchant/contains โ†’ ์ˆ˜์ž… / ๊ธ‰์—ฌ ยท p10 ยท income\n\nkeyboard_arrow_right\n\n๐ŸŸข KB์นด๋“œ์ถœ๊ธˆ ยท merchant/contains โ†’ ๊ธˆ์œต / ์นด๋“œ๋Œ€๊ธˆ ยท p20 ยท expense\n\nkeyboard_arro" + }, + { + "page": "ํ™•์ธํ•„์š” ํ", + "url": "http://127.0.0.1:18501/?page=%ED%99%95%EC%9D%B8%ED%95%84%EC%9A%94%20%ED%81%90", + "screenshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/transfer-review.png", + "screenshot_bytes": 275792, + "snapshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/transfer-review.dom.html", + "snapshot_bytes": 25551, + "devtools_port": 19223, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\n๋ฉ”๋‰ด\n์š”์•ฝ\n๊ฑฐ๋ž˜๋‚ด์—ญ\nํ™•์ธํ•„์š” ํ\n๋ฏธ๋ถ„๋ฅ˜\n๋ถ„๋ฅ˜ ๊ทœ์น™\n์นดํ…Œ๊ณ ๋ฆฌ\n๊ณ„์ขŒ\nImport\nStop\nDeploy\n๐Ÿ” Transfer Review\nKBโ†”ํ† ์Šค๋ฑ…ํฌ ํ™•์ธํ•„์š” ํ\n\n๋‚ ์งœยท๊ธˆ์•กยท์ƒ๋Œ€ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์ขŒ ๊ฐ„ ์ด๋™ ํ›„๋ณด๋ฅผ ๋Œ€์กฐํ•˜๊ณ , ๋“œ๋ผ์ด๋Ÿฐ ํ›„ ์•ˆ์ „ํ•˜๊ฒŒ ๋งค์นญํ•ฉ๋‹ˆ๋‹ค.\n\n๋“œ๋ผ์ด๋Ÿฐ ์šฐ์„ \n๋ณธ์ธ๊ณ„์ขŒ์ด๋™\n์ˆ˜๋™ ๊ฒ€ํ† \n๐Ÿ“Œ\nํ™•์ธํ•„์š” ํ\n\n๊ฑฐ๋ž˜ ID: tx_8cd3af3d60b6\n\n2026-06-29 ยท SYNTHETIC_TRANSFER ยท 120,000์›" + }, + { + "page": "Import", + "url": "http://127.0.0.1:18501/?page=Import", + "screenshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/import.png", + "screenshot_bytes": 297424, + "snapshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/import.dom.html", + "snapshot_bytes": 23649, + "devtools_port": 19224, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\nStop\nDeploy\n\nํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ์„œ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ํˆฌ์ž๊ณ„์ขŒ ๊ฑฐ๋ž˜๋กœ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ๋งค์ˆ˜/๋งค๋„/ํ™˜์ „/์ž…์ถœ๊ธˆ์€ ์ด์ฒด/ํˆฌ์ž์ด๋™, ๋ฐฐ๋‹น/์ด์ž๋Š” ์‹ค์ œ ์ˆ˜์ž…์œผ๋กœ ๋ถ„๋ฅ˜ํ•ฉ๋‹ˆ๋‹ค.\n\nํ† ์Šค๋ฑ…ํฌ ๊ณ„์ขŒ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ์ž…์ถœ๊ธˆ/์ด์ฒด/์นด๋“œ๊ฒฐ์ œ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ํƒ€์ธ ์ด์ฒด/์ถœ๊ธˆ์€ ์ด์ฒด/ํ™•์ธํ•„์š”๋กœ ๋ถ„๋ฅ˜๋ฉ๋‹ˆ๋‹ค." + } + ], + "server_command": [ + ".venv/bin/streamlit", + "run", + "dashboard/app.py", + "--server.address", + "127.0.0.1", + "--server.port", + "18501", + "--server.headless", + "true", + "--browser.gatherUsageStats", + "false" + ], + "server_log_tail": "" +} \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/rules.dom.html b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/rules.dom.html new file mode 100644 index 0000000..02ba3f9 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/rules.dom.html @@ -0,0 +1,889 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

๋ฉ”๋‰ด
+
โš™๏ธ Rule Engine
+

๋ถ„๋ฅ˜ ๊ทœ์น™

+

๋ฐ˜๋ณต๋˜๋Š” ์‚ฌ์šฉ์ฒ˜์™€ ๊ฑฐ๋ž˜ ํŒจํ„ด์„ ๊ทœ์น™์œผ๋กœ ๊ด€๋ฆฌํ•ด ๋ถ„๋ฅ˜ ์ž๋™ํ™” ํ’ˆ์งˆ์„ ๋†’์ž…๋‹ˆ๋‹ค.

+
์ž๋™ ๋ถ„๋ฅ˜PreviewSeed/Update
+
keyboard_arrow_right

๐ŸŸข ์ด์ž ยท merchant/contains โ†’ ์ˆ˜์ž… / ์ด์ž ยท p10 ยท income

+
+
IF
+
merchant contains โ€œ์ด์žโ€
+
+
โ†’
+
+
THEN
+
์ˆ˜์ž… / ์ด์ž
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p10 + ๊ฑฐ๋ž˜์œ ํ˜•: income + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_1f77940cb31c +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ํ™˜๊ธ‰ ยท merchant/contains โ†’ ์ˆ˜์ž… / ํ™˜๊ธ‰ ยท p10 ยท income

+
+
IF
+
merchant contains โ€œํ™˜๊ธ‰โ€
+
+
โ†’
+
+
THEN
+
์ˆ˜์ž… / ํ™˜๊ธ‰
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p10 + ๊ฑฐ๋ž˜์œ ํ˜•: income + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_6e38f120e5c6 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๊ธ‰์—ฌ ยท merchant/contains โ†’ ์ˆ˜์ž… / ๊ธ‰์—ฌ ยท p10 ยท income

+
+
IF
+
merchant contains โ€œ๊ธ‰์—ฌโ€
+
+
โ†’
+
+
THEN
+
์ˆ˜์ž… / ๊ธ‰์—ฌ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p10 + ๊ฑฐ๋ž˜์œ ํ˜•: income + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_8ceb1ae3a132 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข KB์นด๋“œ์ถœ๊ธˆ ยท merchant/contains โ†’ ๊ธˆ์œต / ์นด๋“œ๋Œ€๊ธˆ ยท p20 ยท expense

+
+
IF
+
merchant contains โ€œKB์นด๋“œ์ถœ๊ธˆโ€
+
+
โ†’
+
+
THEN
+
๊ธˆ์œต / ์นด๋“œ๋Œ€๊ธˆ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p20 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์˜ˆ + ID: rule_235b49d33fbc +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๊ตญ๋ฏผ์นด๋“œ ยท raw_summary/equals โ†’ ๊ธˆ์œต / ์นด๋“œ๋Œ€๊ธˆ ยท p20 ยท expense

+
+
IF
+
raw_summary equals โ€œ๊ตญ๋ฏผ์นด๋“œโ€
+
+
โ†’
+
+
THEN
+
๊ธˆ์œต / ์นด๋“œ๋Œ€๊ธˆ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p20 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์˜ˆ + ID: rule_4d9d7b455bd0 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข KTํ†ต์‹  ยท merchant/contains โ†’ ํ†ต์‹  / ํ†ต์‹ ์š”๊ธˆ ยท p30 ยท expense

+
+
IF
+
merchant contains โ€œKTํ†ต์‹ โ€
+
+
โ†’
+
+
THEN
+
ํ†ต์‹  / ํ†ต์‹ ์š”๊ธˆ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p30 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์˜ˆ + ID: rule_060c204c6d91 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข SKํ…”๋ ˆ์ฝค ยท merchant/contains โ†’ ํ†ต์‹  / ํœด๋Œ€ํฐ ยท p30 ยท expense

+
+
IF
+
merchant contains โ€œSKํ…”๋ ˆ์ฝคโ€
+
+
โ†’
+
+
THEN
+
ํ†ต์‹  / ํœด๋Œ€ํฐ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p30 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์˜ˆ + ID: rule_be4f08863838 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์•ฝ๊ตญ ยท merchant/contains โ†’ ์˜๋ฃŒ / ์•ฝ๊ตญ ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ์•ฝ๊ตญโ€
+
+
โ†’
+
+
THEN
+
์˜๋ฃŒ / ์•ฝ๊ตญ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_50793f60f7ce +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์˜์› ยท merchant/contains โ†’ ์˜๋ฃŒ / ๋ณ‘์› ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ์˜์›โ€
+
+
โ†’
+
+
THEN
+
์˜๋ฃŒ / ๋ณ‘์›
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_688e87cd4148 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ํ•™์› ยท merchant/contains โ†’ ๊ต์œก / ํ•™์› ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œํ•™์›โ€
+
+
โ†’
+
+
THEN
+
๊ต์œก / ํ•™์›
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_8610f7c05413 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๊ต์Šต์†Œ ยท merchant/contains โ†’ ๊ต์œก / ์˜ˆ์ฒด๋Šฅ ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ๊ต์Šต์†Œโ€
+
+
โ†’
+
+
THEN
+
๊ต์œก / ์˜ˆ์ฒด๋Šฅ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_98527962f446 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๋ณ‘์› ยท merchant/contains โ†’ ์˜๋ฃŒ / ๋ณ‘์› ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ๋ณ‘์›โ€
+
+
โ†’
+
+
THEN
+
์˜๋ฃŒ / ๋ณ‘์›
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_d6883e55b483 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์น˜๊ณผ ยท merchant/contains โ†’ ์˜๋ฃŒ / ์น˜๊ณผ ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ์น˜๊ณผโ€
+
+
โ†’
+
+
THEN
+
์˜๋ฃŒ / ์น˜๊ณผ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_e5572dc22f15 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์—ฐ์„ธ๋Œ€ํ•™๊ต ยท merchant/contains โ†’ ์˜๋ฃŒ / ๋ณ‘์› ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ์—ฐ์„ธ๋Œ€ํ•™๊ตโ€
+
+
โ†’
+
+
THEN
+
์˜๋ฃŒ / ๋ณ‘์›
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_ea1c1fe38158 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์ด๋งˆํŠธ ยท merchant/contains โ†’ ์‹๋น„ / ๋งˆํŠธ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์ด๋งˆํŠธโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ๋งˆํŠธ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_2b13c1f27cbd +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์ปคํ”ผ ยท merchant/contains โ†’ ์‹๋น„ / ์นดํŽ˜ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์ปคํ”ผโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์นดํŽ˜
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_58d4275ec10b +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์˜ค์•„์‹œ์Šค๋งˆ์ผ“ ยท merchant/contains โ†’ ์‹๋น„ / ๋งˆํŠธ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์˜ค์•„์‹œ์Šค๋งˆ์ผ“โ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ๋งˆํŠธ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_5ce9ce6dba0c +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์ƒํ˜‘ ยท merchant/contains โ†’ ์‹๋น„ / ๋งˆํŠธ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์ƒํ˜‘โ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ๋งˆํŠธ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_63f37fa65134 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๋งค๋จธ๋“œ ยท merchant/contains โ†’ ์‹๋น„ / ์นดํŽ˜ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ๋งค๋จธ๋“œโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์นดํŽ˜
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_8c72d1931af7 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๋ฉ”๊ฐ€MGC ยท merchant/contains โ†’ ์‹๋น„ / ์นดํŽ˜ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ๋ฉ”๊ฐ€MGCโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์นดํŽ˜
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_c477743d98c3 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์Šคํƒ€๋ฒ…์Šค ยท merchant/contains โ†’ ์‹๋น„ / ์นดํŽ˜ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์Šคํƒ€๋ฒ…์Šคโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์นดํŽ˜
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_db17797fb76a +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์Œ€๊ตญ์ˆ˜ ยท merchant/contains โ†’ ์‹๋น„ / ์™ธ์‹ ยท p60 ยท expense

+
+
IF
+
merchant contains โ€œ์Œ€๊ตญ์ˆ˜โ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์™ธ์‹
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p60 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_102cd412f647 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์งœ์žฅ ยท merchant/contains โ†’ ์‹๋น„ / ์™ธ์‹ ยท p60 ยท expense

+
+
IF
+
merchant contains โ€œ์งœ์žฅโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์™ธ์‹
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p60 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_335996f7e00e +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข KFC ยท merchant/contains โ†’ ์‹๋น„ / ์™ธ์‹ ยท p60 ยท expense

+
+
IF
+
merchant contains โ€œKFCโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์™ธ์‹
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p60 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_9d2d65fc6e87 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์Šค์‹œ ยท merchant/contains โ†’ ์‹๋น„ / ์™ธ์‹ ยท p60 ยท expense

+
+
IF
+
merchant contains โ€œ์Šค์‹œโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์™ธ์‹
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p60 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_beaa5f36255c +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์Šค๋งˆํŠธ์ถœ๊ธˆ ยท raw_summary/equals โ†’ ์ด์ฒด / ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™ ยท p80 ยท ์ „์ฒด

+
+
IF
+
raw_summary equals โ€œ์Šค๋งˆํŠธ์ถœ๊ธˆโ€
+
+
โ†’
+
+
THEN
+
์ด์ฒด / ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p80 + ๊ฑฐ๋ž˜์œ ํ˜•: ์ „์ฒด + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_6d74cb69a357 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ATM์ž…๊ธˆ ยท raw_summary/equals โ†’ ์ด์ฒด / ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™ ยท p80 ยท ์ „์ฒด

+
+
IF
+
raw_summary equals โ€œATM์ž…๊ธˆโ€
+
+
โ†’
+
+
THEN
+
์ด์ฒด / ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p80 + ๊ฑฐ๋ž˜์œ ํ˜•: ์ „์ฒด + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_afa3a6aaf448 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

๊ทœ์น™ ์ถ”๊ฐ€๋Š” ์ œ๋ชฉ ์˜ค๋ฅธ์ชฝ ์ถ”๊ฐ€ ๋ฒ„ํŠผ, ๊ธฐ์กด ๊ทœ์น™ ์ˆ˜์ •์€ ๊ฐ ํ•ญ๋ชฉ์˜ โ‹ฎ โ†’ ๋ณ€๊ฒฝ ํŒ์—…์„ ์‚ฌ์šฉํ•˜์„ธ์š”.

+ + + \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/rules.png b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/rules.png new file mode 100644 index 0000000..8a54d3d Binary files /dev/null and b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/rules.png differ diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/transfer-review.dom.html b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/transfer-review.dom.html new file mode 100644 index 0000000..6687e91 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/transfer-review.dom.html @@ -0,0 +1,241 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

๋ฉ”๋‰ด
+
๐Ÿ” Transfer Review
+

KBโ†”ํ† ์Šค๋ฑ…ํฌ ํ™•์ธํ•„์š” ํ

+

๋‚ ์งœยท๊ธˆ์•กยท์ƒ๋Œ€ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์ขŒ ๊ฐ„ ์ด๋™ ํ›„๋ณด๋ฅผ ๋Œ€์กฐํ•˜๊ณ , ๋“œ๋ผ์ด๋Ÿฐ ํ›„ ์•ˆ์ „ํ•˜๊ฒŒ ๋งค์นญํ•ฉ๋‹ˆ๋‹ค.

+
๋“œ๋ผ์ด๋Ÿฐ ์šฐ์„ ๋ณธ์ธ๊ณ„์ขŒ์ด๋™์ˆ˜๋™ ๊ฒ€ํ† 
+

๊ฑฐ๋ž˜ ID: tx_8cd3af3d60b6

2026-06-29 ยท SYNTHETIC_TRANSFER ยท 120,000์›

+ + + \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/transfer-review.png b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/transfer-review.png new file mode 100644 index 0000000..c6f7d39 Binary files /dev/null and b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/transfer-review.png differ diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/import.dom.html b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/import.dom.html new file mode 100644 index 0000000..8f93317 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/import.dom.html @@ -0,0 +1,21 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

ํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ์„œ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ํˆฌ์ž๊ณ„์ขŒ ๊ฑฐ๋ž˜๋กœ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ๋งค์ˆ˜/๋งค๋„/ํ™˜์ „/์ž…์ถœ๊ธˆ์€ ์ด์ฒด/ํˆฌ์ž์ด๋™, ๋ฐฐ๋‹น/์ด์ž๋Š” ์‹ค์ œ ์ˆ˜์ž…์œผ๋กœ ๋ถ„๋ฅ˜ํ•ฉ๋‹ˆ๋‹ค.


ํ† ์Šค๋ฑ…ํฌ ๊ณ„์ขŒ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ์ž…์ถœ๊ธˆ/์ด์ฒด/์นด๋“œ๊ฒฐ์ œ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ํƒ€์ธ ์ด์ฒด/์ถœ๊ธˆ์€ ์ด์ฒด/ํ™•์ธํ•„์š”๋กœ ๋ถ„๋ฅ˜๋ฉ๋‹ˆ๋‹ค.


+ + + \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/import.png b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/import.png new file mode 100644 index 0000000..a7389af Binary files /dev/null and b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/import.png differ diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/manifest.json b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/manifest.json new file mode 100644 index 0000000..3fc23f8 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/manifest.json @@ -0,0 +1,62 @@ +{ + "success": true, + "base_url": "http://127.0.0.1:18501", + "output_dir": "/root/projects/jarca-ledger-mcp/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632", + "fixture": { + "account_id": "acct_7579e5091359", + "transaction_ids": [ + "tx_22a85feba578", + "tx_04e500d0745c", + "tx_3ee6a6e14179" + ], + "db_path": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/fixture.sqlite3" + }, + "chrome": "/usr/bin/google-chrome-stable", + "streamlit": ".venv/bin/streamlit", + "pages": [ + { + "page": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "url": "http://127.0.0.1:18501/?page=%EB%B6%84%EB%A5%98%20%EA%B7%9C%EC%B9%99", + "screenshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/rules.png", + "screenshot_bytes": 364622, + "snapshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/rules.dom.html", + "snapshot_bytes": 190599, + "devtools_port": 19222, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\n๋ฉ”๋‰ด\n์š”์•ฝ\n๊ฑฐ๋ž˜๋‚ด์—ญ\nํ™•์ธํ•„์š” ํ\n๋ฏธ๋ถ„๋ฅ˜\n๋ถ„๋ฅ˜ ๊ทœ์น™\n์นดํ…Œ๊ณ ๋ฆฌ\n๊ณ„์ขŒ\nImport\nStop\nDeploy\nโš™๏ธ Rule Engine\n๋ถ„๋ฅ˜ ๊ทœ์น™\n\n๋ฐ˜๋ณต๋˜๋Š” ์‚ฌ์šฉ์ฒ˜์™€ ๊ฑฐ๋ž˜ ํŒจํ„ด์„ ๊ทœ์น™์œผ๋กœ ๊ด€๋ฆฌํ•ด ๋ถ„๋ฅ˜ ์ž๋™ํ™” ํ’ˆ์งˆ์„ ๋†’์ž…๋‹ˆ๋‹ค.\n\n์ž๋™ ๋ถ„๋ฅ˜\nPreview\nSeed/Update\n๐Ÿ“š\nํ˜„์žฌ ๋ถ„๋ฅ˜ ๊ทœ์น™\nkeyboard_arrow_right\n\n๐ŸŸข ๊ธ‰์—ฌ ยท merchant/contains โ†’ ์ˆ˜์ž… / ๊ธ‰์—ฌ ยท p10 ยท income\n\nkeyboard_arrow_right\n\n๐ŸŸข ์ด์ž ยท merchant/contains โ†’ ์ˆ˜์ž… / ์ด์ž ยท p10 ยท income\n\nkeyboard_arrow_right\n\n๐ŸŸข ํ™˜๊ธ‰ ยท merchant/contains โ†’ ์ˆ˜์ž… / ํ™˜๊ธ‰ ยท p10 ยท income\n\nkeyboard_arrow_right\n\n๐ŸŸข KB์นด๋“œ์ถœ๊ธˆ ยท merchant/contains โ†’ ๊ธˆ์œต / ์นด๋“œ๋Œ€๊ธˆ ยท p20 ยท expense\n\nke" + }, + { + "page": "ํ™•์ธํ•„์š” ํ", + "url": "http://127.0.0.1:18501/?page=%ED%99%95%EC%9D%B8%ED%95%84%EC%9A%94%20%ED%81%90", + "screenshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/transfer-review.png", + "screenshot_bytes": 282069, + "snapshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/transfer-review.dom.html", + "snapshot_bytes": 27701, + "devtools_port": 19223, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\n๋ฉ”๋‰ด\n์š”์•ฝ\n๊ฑฐ๋ž˜๋‚ด์—ญ\nํ™•์ธํ•„์š” ํ\n๋ฏธ๋ถ„๋ฅ˜\n๋ถ„๋ฅ˜ ๊ทœ์น™\n์นดํ…Œ๊ณ ๋ฆฌ\n๊ณ„์ขŒ\nImport\n\n์ƒˆ๋กœ๊ณ ์นจ\n\nStop\nDeploy\n๐Ÿ” Transfer Review\nKBโ†”ํ† ์Šค๋ฑ…ํฌ ํ™•์ธํ•„์š” ํ\n\n๋‚ ์งœยท๊ธˆ์•กยท์ƒ๋Œ€ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์ขŒ ๊ฐ„ ์ด๋™ ํ›„๋ณด๋ฅผ ๋Œ€์กฐํ•˜๊ณ , ๋“œ๋ผ์ด๋Ÿฐ ํ›„ ์•ˆ์ „ํ•˜๊ฒŒ ๋งค์นญํ•ฉ๋‹ˆ๋‹ค.\n\n๋“œ๋ผ์ด๋Ÿฐ ์šฐ์„ \n๋ณธ์ธ๊ณ„์ขŒ์ด๋™\n์ˆ˜๋™ ๊ฒ€ํ† \n\n๋ฏธ๋ฆฌ๋ณด๊ธฐ(๋“œ๋ผ์ด๋Ÿฐ)\n\n์ž๋™ ๋งค์นญ ์‹คํ–‰\n\n๐Ÿ“Œ\nํ™•์ธํ•„์š” ํ\n\n๊ฑฐ๋ž˜ ID: tx_3ee6a6e14179\n\n2026-06-29 ยท SYNTHETIC_TRANSFER ยท 120,000์›" + }, + { + "page": "Import", + "url": "http://127.0.0.1:18501/?page=Import", + "screenshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/import.png", + "screenshot_bytes": 298123, + "snapshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/import.dom.html", + "snapshot_bytes": 15721, + "devtools_port": 19224, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\nStop\nDeploy\n\nํ† ์Šค์ฆ๊ถŒ ๊ฑฐ๋ž˜๋‚ด์—ญ์„œ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ํˆฌ์ž๊ณ„์ขŒ ๊ฑฐ๋ž˜๋กœ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ๋งค์ˆ˜/๋งค๋„/ํ™˜์ „/์ž…์ถœ๊ธˆ์€ ์ด์ฒด/ํˆฌ์ž์ด๋™, ๋ฐฐ๋‹น/์ด์ž๋Š” ์‹ค์ œ ์ˆ˜์ž…์œผ๋กœ ๋ถ„๋ฅ˜ํ•ฉ๋‹ˆ๋‹ค.\n\nํ† ์Šค๋ฑ…ํฌ ๊ณ„์ขŒ ๊ฑฐ๋ž˜๋‚ด์—ญ PDF๋ฅผ ์—…๋กœ๋“œํ•˜๋ฉด ์ž…์ถœ๊ธˆ/์ด์ฒด/์นด๋“œ๊ฒฐ์ œ๋ฅผ ๊ฐ€์ ธ์˜ต๋‹ˆ๋‹ค. ํƒ€์ธ ์ด์ฒด/์ถœ๊ธˆ์€ ์ด์ฒด/ํ™•์ธํ•„์š”๋กœ ๋ถ„๋ฅ˜๋ฉ๋‹ˆ๋‹ค." + } + ], + "server_command": [ + ".venv/bin/streamlit", + "run", + "dashboard/app.py", + "--server.address", + "127.0.0.1", + "--server.port", + "18501", + "--server.headless", + "true", + "--browser.gatherUsageStats", + "false" + ], + "server_log_tail": "" +} \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/rules.dom.html b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/rules.dom.html new file mode 100644 index 0000000..44e357d --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/rules.dom.html @@ -0,0 +1,601 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

๋ฉ”๋‰ด
+
โš™๏ธ Rule Engine
+

๋ถ„๋ฅ˜ ๊ทœ์น™

+

๋ฐ˜๋ณต๋˜๋Š” ์‚ฌ์šฉ์ฒ˜์™€ ๊ฑฐ๋ž˜ ํŒจํ„ด์„ ๊ทœ์น™์œผ๋กœ ๊ด€๋ฆฌํ•ด ๋ถ„๋ฅ˜ ์ž๋™ํ™” ํ’ˆ์งˆ์„ ๋†’์ž…๋‹ˆ๋‹ค.

+
์ž๋™ ๋ถ„๋ฅ˜PreviewSeed/Update
+
keyboard_arrow_right

๐ŸŸข ๊ธ‰์—ฌ ยท merchant/contains โ†’ ์ˆ˜์ž… / ๊ธ‰์—ฌ ยท p10 ยท income

keyboard_arrow_right

๐ŸŸข ์ด์ž ยท merchant/contains โ†’ ์ˆ˜์ž… / ์ด์ž ยท p10 ยท income

keyboard_arrow_right

๐ŸŸข ํ™˜๊ธ‰ ยท merchant/contains โ†’ ์ˆ˜์ž… / ํ™˜๊ธ‰ ยท p10 ยท income

keyboard_arrow_right

๐ŸŸข KB์นด๋“œ์ถœ๊ธˆ ยท merchant/contains โ†’ ๊ธˆ์œต / ์นด๋“œ๋Œ€๊ธˆ ยท p20 ยท expense

keyboard_arrow_right

๐ŸŸข ๊ตญ๋ฏผ์นด๋“œ ยท raw_summary/equals โ†’ ๊ธˆ์œต / ์นด๋“œ๋Œ€๊ธˆ ยท p20 ยท expense

keyboard_arrow_right

๐ŸŸข KTํ†ต์‹  ยท merchant/contains โ†’ ํ†ต์‹  / ํ†ต์‹ ์š”๊ธˆ ยท p30 ยท expense

keyboard_arrow_right

๐ŸŸข SKํ…”๋ ˆ์ฝค ยท merchant/contains โ†’ ํ†ต์‹  / ํœด๋Œ€ํฐ ยท p30 ยท expense

keyboard_arrow_right

๐ŸŸข ์น˜๊ณผ ยท merchant/contains โ†’ ์˜๋ฃŒ / ์น˜๊ณผ ยท p40 ยท expense

keyboard_arrow_right

๐ŸŸข ์•ฝ๊ตญ ยท merchant/contains โ†’ ์˜๋ฃŒ / ์•ฝ๊ตญ ยท p40 ยท expense

์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๋ณ‘์› ยท merchant/contains โ†’ ์˜๋ฃŒ / ๋ณ‘์› ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ๋ณ‘์›โ€
+
+
โ†’
+
+
THEN
+
์˜๋ฃŒ / ๋ณ‘์›
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_57235cbaebee +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์—ฐ์„ธ๋Œ€ํ•™๊ต ยท merchant/contains โ†’ ์˜๋ฃŒ / ๋ณ‘์› ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ์—ฐ์„ธ๋Œ€ํ•™๊ตโ€
+
+
โ†’
+
+
THEN
+
์˜๋ฃŒ / ๋ณ‘์›
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_92542a0cfe34 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ํ•™์› ยท merchant/contains โ†’ ๊ต์œก / ํ•™์› ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œํ•™์›โ€
+
+
โ†’
+
+
THEN
+
๊ต์œก / ํ•™์›
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_a0b6f3b76fb8 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์˜์› ยท merchant/contains โ†’ ์˜๋ฃŒ / ๋ณ‘์› ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ์˜์›โ€
+
+
โ†’
+
+
THEN
+
์˜๋ฃŒ / ๋ณ‘์›
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_e211ce51f193 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๊ต์Šต์†Œ ยท merchant/contains โ†’ ๊ต์œก / ์˜ˆ์ฒด๋Šฅ ยท p40 ยท expense

+
+
IF
+
merchant contains โ€œ๊ต์Šต์†Œโ€
+
+
โ†’
+
+
THEN
+
๊ต์œก / ์˜ˆ์ฒด๋Šฅ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p40 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_f5ac747ee372 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์ด๋งˆํŠธ ยท merchant/contains โ†’ ์‹๋น„ / ๋งˆํŠธ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์ด๋งˆํŠธโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ๋งˆํŠธ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_0d931666810c +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๋งค๋จธ๋“œ ยท merchant/contains โ†’ ์‹๋น„ / ์นดํŽ˜ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ๋งค๋จธ๋“œโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์นดํŽ˜
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_28f573731c8f +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์ปคํ”ผ ยท merchant/contains โ†’ ์‹๋น„ / ์นดํŽ˜ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์ปคํ”ผโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์นดํŽ˜
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_48e3a7933a41 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์˜ค์•„์‹œ์Šค๋งˆ์ผ“ ยท merchant/contains โ†’ ์‹๋น„ / ๋งˆํŠธ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์˜ค์•„์‹œ์Šค๋งˆ์ผ“โ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ๋งˆํŠธ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_6a91fe915bf9 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ๋ฉ”๊ฐ€MGC ยท merchant/contains โ†’ ์‹๋น„ / ์นดํŽ˜ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ๋ฉ”๊ฐ€MGCโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์นดํŽ˜
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_7190bc920d0b +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์Šคํƒ€๋ฒ…์Šค ยท merchant/contains โ†’ ์‹๋น„ / ์นดํŽ˜ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์Šคํƒ€๋ฒ…์Šคโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์นดํŽ˜
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_a63374ad1371 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์ƒํ˜‘ ยท merchant/contains โ†’ ์‹๋น„ / ๋งˆํŠธ ยท p50 ยท expense

+
+
IF
+
merchant contains โ€œ์ƒํ˜‘โ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ๋งˆํŠธ
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p50 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_fd23a8b83ac2 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข KFC ยท merchant/contains โ†’ ์‹๋น„ / ์™ธ์‹ ยท p60 ยท expense

+
+
IF
+
merchant contains โ€œKFCโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์™ธ์‹
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p60 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_104037affb55 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์Šค์‹œ ยท merchant/contains โ†’ ์‹๋น„ / ์™ธ์‹ ยท p60 ยท expense

+
+
IF
+
merchant contains โ€œ์Šค์‹œโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์™ธ์‹
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p60 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_a1c526b50bd3 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์งœ์žฅ ยท merchant/contains โ†’ ์‹๋น„ / ์™ธ์‹ ยท p60 ยท expense

+
+
IF
+
merchant contains โ€œ์งœ์žฅโ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์™ธ์‹
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p60 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_b9066cf22874 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์Œ€๊ตญ์ˆ˜ ยท merchant/contains โ†’ ์‹๋น„ / ์™ธ์‹ ยท p60 ยท expense

+
+
IF
+
merchant contains โ€œ์Œ€๊ตญ์ˆ˜โ€
+
+
โ†’
+
+
THEN
+
์‹๋น„ / ์™ธ์‹
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p60 + ๊ฑฐ๋ž˜์œ ํ˜•: expense + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_fcf12c6d4fff +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ATM์ž…๊ธˆ ยท raw_summary/equals โ†’ ์ด์ฒด / ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™ ยท p80 ยท ์ „์ฒด

+
+
IF
+
raw_summary equals โ€œATM์ž…๊ธˆโ€
+
+
โ†’
+
+
THEN
+
์ด์ฒด / ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p80 + ๊ฑฐ๋ž˜์œ ํ˜•: ์ „์ฒด + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_0df2c802bf63 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
keyboard_arrow_right

๐ŸŸข ์Šค๋งˆํŠธ์ถœ๊ธˆ ยท raw_summary/equals โ†’ ์ด์ฒด / ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™ ยท p80 ยท ์ „์ฒด

+
+
IF
+
raw_summary equals โ€œ์Šค๋งˆํŠธ์ถœ๊ธˆโ€
+
+
โ†’
+
+
THEN
+
์ด์ฒด / ํ˜„๊ธˆ/๊ณ„์ขŒ์ด๋™
+
+
+
+ ์ƒํƒœ: ํ™œ์„ฑ + ์šฐ์„ ์ˆœ์œ„: p80 + ๊ฑฐ๋ž˜์œ ํ˜•: ์ „์ฒด + ์กฐ๊ฑด: ์ถ”๊ฐ€ ์กฐ๊ฑด ์—†์Œ + person: - + ๊ณ ์ •๋น„: ์•„๋‹ˆ์˜ค + ID: rule_9644817a4d34 +
+
๋ฉ”๋ชจ: default: initial Jarca ledger categorization
์‚ญ์ œ๋ณด๋‹ค ๋น„ํ™œ์„ฑํ™”๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

๊ทœ์น™ ์ถ”๊ฐ€๋Š” ์ œ๋ชฉ ์˜ค๋ฅธ์ชฝ ์ถ”๊ฐ€ ๋ฒ„ํŠผ, ๊ธฐ์กด ๊ทœ์น™ ์ˆ˜์ •์€ ๊ฐ ํ•ญ๋ชฉ์˜ โ‹ฎ โ†’ ๋ณ€๊ฒฝ ํŒ์—…์„ ์‚ฌ์šฉํ•˜์„ธ์š”.

+ + + \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/rules.png b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/rules.png new file mode 100644 index 0000000..3307371 Binary files /dev/null and b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/rules.png differ diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/transfer-review.dom.html b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/transfer-review.dom.html new file mode 100644 index 0000000..e9e6b98 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/transfer-review.dom.html @@ -0,0 +1,241 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

๋ฉ”๋‰ด
+
๐Ÿ” Transfer Review
+

KBโ†”ํ† ์Šค๋ฑ…ํฌ ํ™•์ธํ•„์š” ํ

+

๋‚ ์งœยท๊ธˆ์•กยท์ƒ๋Œ€ ๊ธฐ์ค€์œผ๋กœ ๊ณ„์ขŒ ๊ฐ„ ์ด๋™ ํ›„๋ณด๋ฅผ ๋Œ€์กฐํ•˜๊ณ , ๋“œ๋ผ์ด๋Ÿฐ ํ›„ ์•ˆ์ „ํ•˜๊ฒŒ ๋งค์นญํ•ฉ๋‹ˆ๋‹ค.

+
๋“œ๋ผ์ด๋Ÿฐ ์šฐ์„ ๋ณธ์ธ๊ณ„์ขŒ์ด๋™์ˆ˜๋™ ๊ฒ€ํ† 
+

๊ฑฐ๋ž˜ ID: tx_3ee6a6e14179

2026-06-29 ยท SYNTHETIC_TRANSFER ยท 120,000์›

+ + + \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/transfer-review.png b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/transfer-review.png new file mode 100644 index 0000000..e3e8b73 Binary files /dev/null and b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-195632/transfer-review.png differ diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/manifest.json b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/manifest.json new file mode 100644 index 0000000..02a4ed0 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/manifest.json @@ -0,0 +1,44 @@ +{ + "success": true, + "base_url": "http://127.0.0.1:18501", + "output_dir": "/root/projects/jarca-ledger-mcp/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603", + "fixture": { + "account_id": "acct_75c4cbc5a305", + "transaction_ids": [ + "tx_2123d9b9c1f5", + "tx_a13db72f4885", + "tx_3edaa32c38ae", + "tx_fe176567300b", + "tx_d674d4ddbf1e" + ], + "db_path": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/fixture.sqlite3" + }, + "chrome": "/usr/bin/google-chrome-stable", + "streamlit": ".venv/bin/streamlit", + "pages": [ + { + "page": "์š”์•ฝ", + "url": "http://127.0.0.1:18501/?page=%EC%9A%94%EC%95%BD", + "screenshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/overview.png", + "screenshot_bytes": 300562, + "snapshot": "docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/overview.dom.html", + "snapshot_bytes": 33871, + "devtools_port": 19222, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\n๋ฉ”๋‰ด\n์š”์•ฝ\n๊ฑฐ๋ž˜๋‚ด์—ญ\nํ™•์ธํ•„์š” ํ\n๋ฏธ๋ถ„๋ฅ˜\n๋ถ„๋ฅ˜ ๊ทœ์น™\n์นดํ…Œ๊ณ ๋ฆฌ\n๊ณ„์ขŒ\nImport\n\n์ƒˆ๋กœ๊ณ ์นจ\n\nStop\nDeploy\n๐Ÿ“Š ์š”์•ฝ\n๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ\n\n์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.\n\nschema v8\nKST ์ด๋ฒˆ ๋‹ฌ\nlast_balance ๊ธฐ์ค€ ์ž์‚ฐ\n\nDB: /root/projects/jarca-ledger-mcp/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/fixture.sqlite3\n\n๐Ÿ’Ž\n์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ\n\n๊ธฐ์ค€: KST ํ˜„์žฌ ์›” 2026-06, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„\n\n๐Ÿฆ\n์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ\n\n๊ธฐ์ค€: accounts.liquidity_bucket" + } + ], + "server_command": [ + ".venv/bin/streamlit", + "run", + "dashboard/app.py", + "--server.address", + "127.0.0.1", + "--server.port", + "18501", + "--server.headless", + "true", + "--browser.gatherUsageStats", + "false" + ], + "server_log_tail": "" +} \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/overview.dom.html b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/overview.dom.html new file mode 100644 index 0000000..94c7933 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/overview.dom.html @@ -0,0 +1,241 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

๋ฉ”๋‰ด
+
๐Ÿ“Š ์š”์•ฝ
+

๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ

+

์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

+
schema v8KST ์ด๋ฒˆ ๋‹ฌlast_balance ๊ธฐ์ค€ ์ž์‚ฐ
+

DB: /root/projects/jarca-ledger-mcp/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/fixture.sqlite3

๊ธฐ์ค€: KST ํ˜„์žฌ ์›” 2026-06, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„

๊ธฐ์ค€: accounts.liquidity_bucket + accounts.last_balance. ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„๋กœ ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ์„ ์ถ”์ •ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

+ + + \ No newline at end of file diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/overview.png b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/overview.png new file mode 100644 index 0000000..a130358 Binary files /dev/null and b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260630-071603/overview.png differ diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/implementation.md b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/implementation.md new file mode 100644 index 0000000..26d71ae --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/implementation.md @@ -0,0 +1,33 @@ +# BAT-004 Implementation + +## ์ฝ”๋“œ ๋ณ€๊ฒฝ + +- `scripts/streamlit_browser_evidence.py` + - synthetic fixture DB ์ƒ์„ฑ. + - ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ ์‚ฌ์šฉ ๊ฑฐ๋ถ€ ์•ˆ์ „์žฅ์น˜. + - Streamlit local server ๊ธฐ๋™ ๋ฐ HTTP 200 readiness ํ™•์ธ. + - `/usr/bin/google-chrome-stable` headless Chrome DevTools Protocol ์—ฐ๊ฒฐ. + - page๋ณ„ DOM ํ…์ŠคํŠธ ๋กœ๋“œ ํ™•์ธ ํ›„ PNG screenshot ๋ฐ DOM snapshot ์ €์žฅ. + - manifest JSON์— evidence path/byte size/fixture DB/command ์ •๋ณด๋ฅผ ๊ธฐ๋ก. +- `dashboard/app.py` + - Import page์˜ import batch ์กฐํšŒ์™€ rollback ์‹คํ–‰์ด ํ˜„์žฌ dashboard `db_path`๋ฅผ ์‚ฌ์šฉํ•˜๋„๋ก ์ˆ˜์ •. + - ์ด ์ˆ˜์ •์€ browser evidence fixture DB ์›์น™์„ ์ง€ํ‚ค๊ธฐ ์œ„ํ•œ ์•ˆ์ „ ๋ณด์™„์ด๋ฉฐ ์šด์˜ DB ๋ˆ„์ˆ˜๋ฅผ ๋ง‰๋Š”๋‹ค. + +## ๋ฌธ์„œ ๋ณ€๊ฒฝ + +- `docs/srs/Testing/req-004_browser-streamlit-e2e-evidence-recovery.md` +- `docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/` +- `docs/runbooks/streamlit-browser-evidence.md` +- `docs/runtime-flows.md` +- `docs/interface-contract.md` +- `docs/project-structure.md` +- `docs/srs/index.md` +- `docs/batches/index.md` +- source Discovery์˜ ์ƒ์„ฑ๋œ REQ ์ฐธ์กฐ + +## ์šด์˜ ์•ˆ์ „ + +- evidence script๋Š” `~/.local/share/jarca/ledger.sqlite3`๋ฅผ fixture DB๋กœ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ฑฐ๋ถ€ํ•œ๋‹ค. +- fixture์—๋Š” `SYNTHETIC_*` ๊ฐ€์งœ ๊ณ„์ขŒ/๊ฑฐ๋ž˜/์ƒ๋Œ€๋ช…๋งŒ ์‚ฌ์šฉํ•œ๋‹ค. +- ์›๋ณธ ๊ธˆ์œต ํŒŒ์ผ, ์‹ค์ œ ๊ณ„์ขŒ๋ฒˆํ˜ธ, ๋น„๋ฐ€๋ฒˆํ˜ธ, raw_payload๋Š” screenshot/log/docs์— ๋‚จ๊ธฐ์ง€ ์•Š๋Š”๋‹ค. +- CI ํ†ตํ•ฉ์€ ์ด๋ฒˆ batch ๋ฒ”์œ„๊ฐ€ ์•„๋‹ˆ๋ฉฐ, ๋กœ์ปฌ ์žฌํ˜„ ์ ˆ์ฐจ๋งŒ ์ œ๊ณตํ•œ๋‹ค. diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/index.md b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/index.md new file mode 100644 index 0000000..584ff92 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/index.md @@ -0,0 +1,24 @@ +# BAT-004 Browser/Streamlit E2E evidence ํ™˜๊ฒฝ ๋ณต๊ตฌ + +- Profile: batch-lite +- Status: release-candidate +- Included REQ: REQ-004 +- Discovery: dcy-004 +- Owner: ๋ ›์ €์žฅ์ธ +- Date (KST): 2026-06-29 + +## ๋ชฉํ‘œ + +Streamlit UI batch ๊ฒ€์ฆ์—์„œ AppTest/HTTP 200 ๋Œ€์ฒด ์ฆ๊ฑฐ์— ๋จธ๋ฌผ์ง€ ์•Š๋„๋ก, synthetic DB์™€ `/usr/bin/google-chrome-stable` ๊ธฐ๋ฐ˜ ๋กœ์ปฌ browser screenshot/snapshot evidence ์ ˆ์ฐจ๋ฅผ ๋ณต๊ตฌํ•œ๋‹ค. + +## ์‚ฐ์ถœ๋ฌผ + +- `scripts/streamlit_browser_evidence.py` ๋กœ์ปฌ evidence ์ˆ˜์ง‘ ์Šคํฌ๋ฆฝํŠธ +- `docs/runbooks/streamlit-browser-evidence.md` ์šด์˜ runbook +- REQ-004 ๋ฐ BAT-004 planning/implementation/verification ๋ฌธ์„œ +- runtime/interface/project-structure ๋ฌธ์„œ์˜ evidence gate ๊ธฐ์ค€ ๊ฐฑ์‹  +- synthetic DB ๊ธฐ๋ฐ˜ browser screenshot/snapshot evidence + +## ๋ฒ”์œ„ ์ œํ•œ + +CI ํ†ตํ•ฉ, Playwright/Selenium ๋„์ž…, pixel-diff visual regression, Streamlit ์‹ ๊ทœ ํ™”๋ฉด ๊ธฐ๋Šฅ ๊ฐœ๋ฐœ์€ ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค. diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/planning.md b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/planning.md new file mode 100644 index 0000000..a6cadbc --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/planning.md @@ -0,0 +1,31 @@ +# BAT-004 Planning + +## ๊ตฌํ˜„ ์ „๋žต + +1. `REQ-004`๋ฅผ Testing ์š”๊ตฌ์‚ฌํ•ญ์œผ๋กœ ์ •๋ฆฌํ•œ๋‹ค. +2. ์šด์˜ DB์™€ ์‹ค์ œ ๊ธˆ์œต ํŒŒ์ผ์„ ๋ฐฐ์ œํ•œ synthetic fixture DB๋ฅผ evidence script๊ฐ€ ์ง์ ‘ ์ƒ์„ฑํ•œ๋‹ค. +3. Streamlit์€ `JARCA_LEDGER_DB_PATH=`๋กœ ๊ธฐ๋™ํ•œ๋‹ค. +4. `/usr/bin/google-chrome-stable` headless + Chrome DevTools Protocol๋กœ ๋Œ€์ƒ ํŽ˜์ด์ง€๋ฅผ ์—ด๊ณ , ํ™”๋ฉด ํ…์ŠคํŠธ ๋กœ๋“œ ์™„๋ฃŒ๋ฅผ ํ™•์ธํ•œ ๋’ค screenshot๊ณผ DOM snapshot์„ ์ €์žฅํ•œ๋‹ค. +5. UI/dashboard ์˜ํ–ฅ batch์˜ QC gate ๊ธฐ์ค€์„ ๋ฌธ์„œํ™”ํ•œ๋‹ค. + +## Design Gate + +- Profile: `batch-lite` +- ํŒ๋‹จ ๊ทผ๊ฑฐ: ์‹ ๊ทœ ๋„๋ฉ”์ธ ๊ธฐ๋Šฅ์ด๋‚˜ DB schema ๋ณ€๊ฒฝ ์—†์ด ๊ฒ€์ฆ ์Šคํฌ๋ฆฝํŠธ์™€ ๋ฌธ์„œ ๊ธฐ์ค€์„ ์ถ”๊ฐ€ํ•œ๋‹ค. +- ์„ค๊ณ„ ๋ฌธ์„œ ๋ณ„๋„ ์ž‘์„ฑ ์—ฌ๋ถ€: ๋ณ„๋„ `design.md`๋Š” ๋งŒ๋“ค์ง€ ์•Š๋Š”๋‹ค. ๊ตฌํ˜„ ๊ฒฐ์ •์€ ์ด planning ๋ฐ REQ-004์— ์ถฉ๋ถ„ํžˆ ๊ตญ์†Œ์ ์œผ๋กœ ๊ธฐ๋กํ•œ๋‹ค. + +## Evidence Gate ๊ธฐ์ค€ + +| Batch ์œ ํ˜• | ์ตœ์†Œ evidence | Gate ํŒ์ • | +| --- | --- | --- | +| Streamlit UI ๋˜๋Š” `dashboard/app.py` ์˜ํ–ฅ | Browser screenshot + DOM snapshot + manifest | ๋ˆ„๋ฝ ์‹œ BLOCK ๋˜๋Š” ์žฌ๊ฒ€์ฆ | +| ๋น„-UI service/MCP/parser ๋ณ€๊ฒฝ | pytest/py_compile ์ค‘์‹ฌ, browser evidence ์„ ํƒ | browser ๋ˆ„๋ฝ์€ ๋น„์ฐจ๋‹จ | +| docs-only ๋ณ€๊ฒฝ | ๋ฌธ์„œ diff/review | browser evidence ๋ถˆํ•„์š” | +| Chrome ํ™˜๊ฒฝ ์žฅ์• ๊ฐ€ ์ž…์ฆ๋œ UI ๋ณ€๊ฒฝ | AppTest + HTTP 200 + ์‹คํŒจ ์›์ธ ๋กœ๊ทธ | `PASS_WITH_NOTES`๋งŒ ํ—ˆ์šฉ | + +## ๋ฆฌ์Šคํฌ์™€ ์™„ํ™” + +- Chrome/DevTools ๊ธฐ๋™ ์‹คํŒจ: ์‹คํ–‰ ์ „ Chrome ๊ฒฝ๋กœ๋ฅผ ๊ฒ€์ฆํ•˜๊ณ  ๋ช…ํ™•ํ•œ ์˜ค๋ฅ˜๋ฅผ ์ถœ๋ ฅํ•œ๋‹ค. +- Streamlit ๋ Œ๋”๋ง ์ง€์—ฐ: HTTP 200 ๋Œ€๊ธฐ ํ›„ CDP์—์„œ page ์ œ๋ชฉ ํ…์ŠคํŠธ๊ฐ€ ์‹ค์ œ DOM์— ๋‚˜ํƒ€๋‚  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•œ๋‹ค. +- ๋ฏผ๊ฐ์ •๋ณด ๋…ธ์ถœ: script๊ฐ€ ๊ธฐ๋ณธ ์šด์˜ DB ๊ฒฝ๋กœ๋ฅผ ๊ฑฐ๋ถ€ํ•˜๊ณ  synthetic fixture๋งŒ ์ƒ์„ฑํ•œ๋‹ค. +- dependency ์ฆ๊ฐ€: Playwright/Selenium์„ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š๊ณ  ํ˜„์žฌ dashboard extra์— ์ด๋ฏธ ์กด์žฌํ•˜๋Š” `websockets`๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. diff --git a/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/verification.md b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/verification.md new file mode 100644 index 0000000..6c53948 --- /dev/null +++ b/docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/verification.md @@ -0,0 +1,43 @@ +# BAT-004 Verification + +## ๊ฒ€์ฆ ๊ณ„ํš + +1. Python ๋ฌธ๋ฒ• ๊ฒ€์ฆ + - `.venv/bin/python -m py_compile scripts/streamlit_browser_evidence.py dashboard/app.py` +2. ๋Œ€์ƒ browser smoke + - `.venv/bin/python scripts/streamlit_browser_evidence.py` + - ํ™•์ธ ๋Œ€์ƒ: `๋ถ„๋ฅ˜ ๊ทœ์น™`, `ํ™•์ธํ•„์š” ํ`, `Import` +3. ์ „์ฒด ํ…Œ์ŠคํŠธ + - `.venv/bin/python -m pytest -q` +4. evidence ๋ฏผ๊ฐ์ •๋ณด ๊ฒ€ํ†  + - manifest์™€ DOM snapshot์—๋Š” synthetic fixture ์‹๋ณ„์ž๋งŒ ํฌํ•จ๋˜์–ด์•ผ ํ•œ๋‹ค. + - ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ, ์›๋ณธ ๊ธˆ์œต ํŒŒ์ผ๋ช…, ๋น„๋ฐ€๋ฒˆํ˜ธ, ์‹ค์ œ raw_payload๋Š” ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## Acceptance Mapping + +- AC-1: `scripts/streamlit_browser_evidence.py`์˜ `assert_synthetic_db_path` ๋ฐ fixture ์ƒ์„ฑ ๋กœ์ง. +- AC-2: py_compile ๊ฒฐ๊ณผ. +- AC-3: full pytest ๊ฒฐ๊ณผ. +- AC-4/5: browser evidence script manifest ๋ฐ screenshot/snapshot ํŒŒ์ผ. +- AC-6: `dashboard/app.py` Import page์˜ `db_path` ์ „๋‹ฌ ์ˆ˜์ •. +- AC-7: REQ/BAT/runtime/interface/project-structure/runbook ๋ฌธ์„œ ๊ฐฑ์‹ . + +## ๊ฒฐ๊ณผ + +์ตœ์ข… ๊ฒ€์ฆ ๊ฒฐ๊ณผ(2026-06-29 KST): + +- `.venv/bin/python -m py_compile scripts/streamlit_browser_evidence.py dashboard/app.py jarca_ledger_mcp/db.py jarca_ledger_mcp/service.py jarca_ledger_mcp/server.py tests/test_service.py` โ€” PASS. +- `.venv/bin/python -m pytest -ra` โ€” PASS (`27 passed, 4 skipped in 27.37s`). +- `.venv/bin/python scripts/streamlit_browser_evidence.py` โ€” PASS. + - Evidence directory: `docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-20260629-192835/` + - Manifest: `manifest.json` (`success: true`). + - Captured pages: + - `๋ถ„๋ฅ˜ ๊ทœ์น™`: `rules.png` 364,876 bytes, `rules.dom.html` 174,470 bytes. + - `ํ™•์ธํ•„์š” ํ`: `transfer-review.png` 275,792 bytes, `transfer-review.dom.html` 25,551 bytes. + - `Import`: `import.png` 297,424 bytes, `import.dom.html` 23,649 bytes. + +## Sensitive Evidence Review + +- Evidence DB path is the generated fixture path under BAT-004 evidence, not the operating DB default path. +- Fixture rows use `SYNTHETIC_*` labels and do not include real account numbers, real financial source filenames, passwords, or raw payloads. +- DOM/screenshot evidence was generated from synthetic fixture data only. diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/manifest.json b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/manifest.json new file mode 100644 index 0000000..c7f06b1 --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/manifest.json @@ -0,0 +1,44 @@ +{ + "success": true, + "base_url": "http://127.0.0.1:18501", + "output_dir": "/root/projects/jarca-ledger-mcp/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111", + "fixture": { + "account_id": "acct_6cf9a9db8669", + "transaction_ids": [ + "tx_f60380340545", + "tx_8c16f1387fb1", + "tx_445884ce8638", + "tx_ead36edf0c7e", + "tx_dc27408a7e81" + ], + "db_path": "docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/fixture.sqlite3" + }, + "chrome": "/usr/bin/google-chrome-stable", + "streamlit": ".venv/bin/streamlit", + "pages": [ + { + "page": "์š”์•ฝ", + "url": "http://127.0.0.1:18501/?page=%EC%9A%94%EC%95%BD", + "screenshot": "docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/overview.png", + "screenshot_bytes": 303341, + "snapshot": "docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/overview.dom.html", + "snapshot_bytes": 30757, + "devtools_port": 19222, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\n๋ฉ”๋‰ด\n์š”์•ฝ\n๊ฑฐ๋ž˜๋‚ด์—ญ\nํ™•์ธํ•„์š” ํ\n๋ฏธ๋ถ„๋ฅ˜\n๋ถ„๋ฅ˜ ๊ทœ์น™\n์นดํ…Œ๊ณ ๋ฆฌ\n๊ณ„์ขŒ\nImport\nStop\nDeploy\n๐Ÿ“Š ์š”์•ฝ\n๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ\n\n์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.\n\nschema v8\nKST ์ด๋ฒˆ ๋‹ฌ\nlast_balance ๊ธฐ์ค€ ์ž์‚ฐ\n\nDB: /root/projects/jarca-ledger-mcp/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/fixture.sqlite3\n\n๐Ÿ’Ž\n์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ\n\n๊ธฐ์ค€: KST ํ˜„์žฌ ์›” 2026-06, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„\n\n๐Ÿฆ\n์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ\n\n๊ธฐ์ค€: accounts.liqu" + } + ], + "server_command": [ + ".venv/bin/streamlit", + "run", + "dashboard/app.py", + "--server.address", + "127.0.0.1", + "--server.port", + "18501", + "--server.headless", + "true", + "--browser.gatherUsageStats", + "false" + ], + "server_log_tail": "" +} \ No newline at end of file diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/overview.dom.html b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/overview.dom.html new file mode 100644 index 0000000..dab7e83 --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/overview.dom.html @@ -0,0 +1,241 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

๋ฉ”๋‰ด
+
๐Ÿ“Š ์š”์•ฝ
+

๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ

+

์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

+
schema v8KST ์ด๋ฒˆ ๋‹ฌlast_balance ๊ธฐ์ค€ ์ž์‚ฐ
+

DB: /root/projects/jarca-ledger-mcp/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/fixture.sqlite3

๊ธฐ์ค€: KST ํ˜„์žฌ ์›” 2026-06, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„

๊ธฐ์ค€: accounts.liquidity_bucket + accounts.last_balance. ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„๋กœ ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ์„ ์ถ”์ •ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

+ + + \ No newline at end of file diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/overview.png b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/overview.png new file mode 100644 index 0000000..ba5b7de Binary files /dev/null and b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/overview.png differ diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/manifest.json b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/manifest.json new file mode 100644 index 0000000..c6d3cf3 --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/manifest.json @@ -0,0 +1,44 @@ +{ + "success": true, + "base_url": "http://127.0.0.1:18503", + "output_dir": "/root/projects/jarca-ledger-mcp/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738", + "fixture": { + "account_id": "acct_d3b8cccfccb4", + "transaction_ids": [ + "tx_4b8447f7aa07", + "tx_b2ca8a05ac82", + "tx_8c989e7c3b15", + "tx_f48e63972a9e", + "tx_4e20d08f8df3" + ], + "db_path": "docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/fixture.sqlite3" + }, + "chrome": "/usr/bin/google-chrome-stable", + "streamlit": ".venv/bin/streamlit", + "pages": [ + { + "page": "์š”์•ฝ", + "url": "http://127.0.0.1:18503/?page=%EC%9A%94%EC%95%BD", + "screenshot": "docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/overview.png", + "screenshot_bytes": 301342, + "snapshot": "docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/overview.dom.html", + "snapshot_bytes": 29881, + "devtools_port": 19222, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\n๋ฉ”๋‰ด\n์š”์•ฝ\n๊ฑฐ๋ž˜๋‚ด์—ญ\nํ™•์ธํ•„์š” ํ\n๋ฏธ๋ถ„๋ฅ˜\n๋ถ„๋ฅ˜ ๊ทœ์น™\n์นดํ…Œ๊ณ ๋ฆฌ\n๊ณ„์ขŒ\nImport\nStop\nDeploy\n๐Ÿ“Š ์š”์•ฝ\n๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ\n\n์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.\n\nschema v8\nKST ์ด๋ฒˆ ๋‹ฌ\nlast_balance ๊ธฐ์ค€ ์ž์‚ฐ\n\nDB: /root/projects/jarca-ledger-mcp/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/fixture.sqlite3\n\n๐Ÿ’Ž\n์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ\n\n๊ธฐ์ค€: KST ํ˜„์žฌ ์›” 2026-06, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„\n\n๐Ÿฆ\n์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ\n\n๊ธฐ์ค€: accou" + } + ], + "server_command": [ + ".venv/bin/streamlit", + "run", + "dashboard/app.py", + "--server.address", + "127.0.0.1", + "--server.port", + "18503", + "--server.headless", + "true", + "--browser.gatherUsageStats", + "false" + ], + "server_log_tail": "" +} \ No newline at end of file diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/overview.dom.html b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/overview.dom.html new file mode 100644 index 0000000..bc100c1 --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/overview.dom.html @@ -0,0 +1,241 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

๋ฉ”๋‰ด
+
๐Ÿ“Š ์š”์•ฝ
+

๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ

+

์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

+
schema v8KST ์ด๋ฒˆ ๋‹ฌlast_balance ๊ธฐ์ค€ ์ž์‚ฐ
+

DB: /root/projects/jarca-ledger-mcp/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/fixture.sqlite3

๊ธฐ์ค€: KST ํ˜„์žฌ ์›” 2026-06, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„

๊ธฐ์ค€: accounts.liquidity_bucket + accounts.last_balance. ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„๋กœ ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ์„ ์ถ”์ •ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

+ + + \ No newline at end of file diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/overview.png b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/overview.png new file mode 100644 index 0000000..e8e1627 Binary files /dev/null and b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-check-20260630-073738/overview.png differ diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/manifest.json b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/manifest.json new file mode 100644 index 0000000..d35e275 --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/manifest.json @@ -0,0 +1,44 @@ +{ + "success": true, + "base_url": "http://127.0.0.1:18501", + "output_dir": "/root/projects/jarca-ledger-mcp/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958", + "fixture": { + "account_id": "acct_86182dd8b283", + "transaction_ids": [ + "tx_6e7d47621557", + "tx_546ed7a78004", + "tx_f40ec3aa2df0", + "tx_3207e5cdde0d", + "tx_fa79d639eda4" + ], + "db_path": "docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/fixture.sqlite3" + }, + "chrome": "/usr/bin/google-chrome-stable", + "streamlit": ".venv/bin/streamlit", + "pages": [ + { + "page": "์š”์•ฝ", + "url": "http://127.0.0.1:18501/?page=%EC%9A%94%EC%95%BD", + "screenshot": "docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/overview.png", + "screenshot_bytes": 303039, + "snapshot": "docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/overview.dom.html", + "snapshot_bytes": 32797, + "devtools_port": 19222, + "inner_text_head": "๐Ÿ’ฐ Jarca Ledger\n\n๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”\n\n๋ฉ”๋‰ด\n์š”์•ฝ\n๊ฑฐ๋ž˜๋‚ด์—ญ\nํ™•์ธํ•„์š” ํ\n๋ฏธ๋ถ„๋ฅ˜\n๋ถ„๋ฅ˜ ๊ทœ์น™\n์นดํ…Œ๊ณ ๋ฆฌ\n๊ณ„์ขŒ\nImport\n\n์ƒˆ๋กœ๊ณ ์นจ\n\nStop\nDeploy\n๐Ÿ“Š ์š”์•ฝ\n๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ\n\n์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.\n\nschema v8\nKST ์ด๋ฒˆ ๋‹ฌ\nlast_balance ๊ธฐ์ค€ ์ž์‚ฐ\n\nDB: /root/projects/jarca-ledger-mcp/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/fixture.sqlite3\n\n๐Ÿ’Ž\n์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ\n\n๊ธฐ์ค€: KST ํ˜„์žฌ ์›” 2026-06, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„\n\n๐Ÿฆ\n์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ\n\n๊ธฐ" + } + ], + "server_command": [ + ".venv/bin/streamlit", + "run", + "dashboard/app.py", + "--server.address", + "127.0.0.1", + "--server.port", + "18501", + "--server.headless", + "true", + "--browser.gatherUsageStats", + "false" + ], + "server_log_tail": "" +} \ No newline at end of file diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/overview.dom.html b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/overview.dom.html new file mode 100644 index 0000000..272ae9c --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/overview.dom.html @@ -0,0 +1,241 @@ + + + + + + + Jarca Ledger + + + + + + + + +

๐Ÿ’ฐ Jarca Ledger

๊ฐ€๊ณ„๋ถ€ ยท ๋ถ„๋ฅ˜ ยท Import ์šด์˜ ์ฝ˜์†”

๋ฉ”๋‰ด
+
๐Ÿ“Š ์š”์•ฝ
+

๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ

+

์ด๋ฒˆ ๋‹ฌ ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„, ์ž์‚ฐ ์œ ๋™์„ฑ, ์šด์˜ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„๋Œ€๋กœ ํ™•์ธํ•ฉ๋‹ˆ๋‹ค.

+
schema v8KST ์ด๋ฒˆ ๋‹ฌlast_balance ๊ธฐ์ค€ ์ž์‚ฐ
+

DB: /root/projects/jarca-ledger-mcp/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/fixture.sqlite3

๊ธฐ์ค€: KST ํ˜„์žฌ ์›” 2026-06, transactions.transaction_date, ledger_categories.cashflow_kind ์‹ค์ œ ํ˜„๊ธˆํ๋ฆ„

๊ธฐ์ค€: accounts.liquidity_bucket + accounts.last_balance. ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„๋กœ ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ์„ ์ถ”์ •ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

+ + + \ No newline at end of file diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/overview.png b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/overview.png new file mode 100644 index 0000000..f5dfa06 Binary files /dev/null and b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-verify-20260629-215958/overview.png differ diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/implementation.md b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/implementation.md new file mode 100644 index 0000000..cc1ac26 --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/implementation.md @@ -0,0 +1,37 @@ +# BAT-005 Implementation + +## ์ฝ”๋“œ ๋ณ€๊ฒฝ + +- `jarca_ledger_mcp/db.py` + - schema version์„ `8`๋กœ ์˜ฌ๋ฆฌ๊ณ  `accounts.liquidity_bucket TEXT NOT NULL DEFAULT 'other'`๋ฅผ schema ๋ฐ additive migration ๋ชฉ๋ก์— ์ถ”๊ฐ€ํ•œ๋‹ค. +- `jarca_ledger_mcp/service.py` + - `VALID_LIQUIDITY_BUCKETS`์™€ `normalize_liquidity_bucket` ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ•œ๋‹ค. + - `create_ledger_account`/`update_ledger_account`์—์„œ `liquidity_bucket`์„ ์ €์žฅยท์ˆ˜์ •ํ•œ๋‹ค. + - `get_asset_liquidity_report`๋ฅผ ์ถ”๊ฐ€ํ•ด `accounts.last_balance` ๊ธฐ๋ฐ˜ ๋ฒ„ํ‚ท ํ•ฉ๊ณ„, ์ด์ž์‚ฐ, ์ด๋ถ€์ฑ„, ์ˆœ์ž์‚ฐ, ๋ฏธ์ž…๋ ฅ ๊ณ„์ขŒ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- `jarca_ledger_mcp/server.py` + - MCP account create/update tool signature์— `liquidity_bucket`์„ ์ถ”๊ฐ€ํ•˜๊ณ  `get_asset_liquidity_report` tool์„ ๋…ธ์ถœํ•œ๋‹ค. +- `dashboard/app.py` + - ์š”์•ฝ ํ™”๋ฉด์„ REQ-005 ์šด์˜ ํ—ˆ๋ธŒ ์ˆœ์„œ๋กœ ์žฌ๊ตฌ์„ฑํ•œ๋‹ค. + - KST ํ˜„์žฌ ์›” ๊ธฐ์ค€ ์›”๊ฐ„ ์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ/์ˆœํ˜„๊ธˆํ๋ฆ„/์ €์ถ•๋ฅ ์„ ์ตœ์ƒ๋‹จ์— ํ‘œ์‹œํ•œ๋‹ค. + - ์ž์‚ฐ ์œ ๋™์„ฑ, ์ž์‚ฐ ๊ตฌ์„ฑ, ์˜ˆ์‚ฐ/์ง€์ถœ, ์šด์˜ ๊ฒฝ๊ณ  ์„น์…˜์„ ์ถ”๊ฐ€ํ•œ๋‹ค. + - ๊ณ„์ขŒ ํ™”๋ฉด์— `liquidity_bucket` ํ‘œ์‹œ/์ˆ˜์ • control์„ ์ถ”๊ฐ€ํ•œ๋‹ค. +- `scripts/streamlit_browser_evidence.py` + - synthetic fixture์— `cash`, `investment`, `loan`, `real_estate` bucket ๊ณ„์ขŒ์™€ ์›”๊ฐ„ ์ˆ˜์ž…/์ง€์ถœ/์˜ˆ์‚ฐ ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. +- `tests/test_service.py` + - schema version, liquidity bucket ๊ฒ€์ฆ, account flow, asset liquidity report regression์„ ์ถ”๊ฐ€ํ•œ๋‹ค. + +## ๋ฌธ์„œ ๋ณ€๊ฒฝ + +- `docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/` +- `docs/batches/index.md` +- `docs/srs/Interface/req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md` +- `docs/data-model.md` +- `docs/project-structure.md` +- `docs/runtime-flows.md` +- `docs/interface-contract.md` + +## ์šด์˜ ์•ˆ์ „ + +- ์šด์˜ DB์™€ ์‹ค์ œ ๊ธˆ์œต ์›๋ณธ ํŒŒ์ผ์€ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค. +- browser evidence๋Š” synthetic fixture DB๋กœ๋งŒ ์ƒ์„ฑํ•œ๋‹ค. +- migration์€ additive/idempotent์ด๋ฉฐ ๊ธฐ์กด ๊ณ„์ขŒ์˜ bucket์€ `other` ๊ธฐ๋ณธ๊ฐ’์œผ๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ์ฑ„์›Œ์ง„๋‹ค. diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/index.md b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/index.md new file mode 100644 index 0000000..f301cbe --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/index.md @@ -0,0 +1,25 @@ +# BAT-005 Dashboard operations hub/liquidity/monthly cashflow + +- Profile: standard +- Status: release-candidate +- Included REQ: REQ-005 +- Discovery: dcy-005 +- Owner: ๋ ›์ €์žฅ์ธ +- Date (KST): 2026-06-29 + +## ๋ชฉํ‘œ + +REQ-005์— ๋”ฐ๋ผ Streamlit ์š”์•ฝ ํ™”๋ฉด์„ ์›”์ค‘ ์šด์˜ ํ—ˆ๋ธŒ๋กœ ์žฌ๊ตฌ์„ฑํ•˜๊ณ , ๊ณ„์ขŒ ๋‹จ์œ„ `liquidity_bucket`๊ณผ `last_balance`๋ฅผ ๊ถŒ์œ„ ์†Œ์Šค๋กœ ํ•˜๋Š” ์ž์‚ฐ ์œ ๋™์„ฑ/๊ตฌ์„ฑ/๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ๋ทฐ๋ฅผ ์ œ๊ณตํ•œ๋‹ค. + +## ์‚ฐ์ถœ๋ฌผ + +- `accounts.liquidity_bucket` additive/idempotent schema migration +- ๊ณ„์ขŒ create/list/update ๋ฐ MCP tool signature์˜ `liquidity_bucket` ์ง€์› +- `service.get_asset_liquidity_report` ์ž์‚ฐ ์œ ๋™์„ฑ/์ˆœ์ž์‚ฐ report +- `dashboard/app.py` ์š”์•ฝ ์šด์˜ ํ—ˆ๋ธŒ ๋ฐ ๊ณ„์ขŒ liquidity bucket ํŽธ์ง‘ UI +- BAT-005 planning/implementation/verification ๋ฌธ์„œ์™€ baseline ๋ฌธ์„œ ๊ฐฑ์‹  +- synthetic/validation DB ๊ธฐ๋ฐ˜ Streamlit browser evidence + +## ๋ฒ”์œ„ ์ œํ•œ + +์‹ค์‹œ๊ฐ„ ๊ธˆ์œต ์—ฐ๋™, ์ž๋™ ์‹œ์„ธ ํ‰๊ฐ€, ๋ณ„๋„ `asset_snapshots`/`manual_assets` ํ…Œ์ด๋ธ”, ๊ฑฐ๋ž˜/์นดํ…Œ๊ณ ๋ฆฌ ๋‹จ์œ„ liquidity override, dcy-006 ๋ฒ”์œ„๋Š” ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค. diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/planning.md b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/planning.md new file mode 100644 index 0000000..d8b3f63 --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/planning.md @@ -0,0 +1,30 @@ +# BAT-005 Planning + +## ๊ตฌํ˜„ ์ „๋žต + +1. REQ-005์˜ ์Šน์ธ ์ƒํƒœ๋ฅผ ์ž…๋ ฅ gate๋กœ ํ™•์ธํ•˜๊ณ  BAT-005 batch ๋ฌธ์„œ๋ฅผ ์ƒ์„ฑํ•œ๋‹ค. +2. `accounts.liquidity_bucket`์„ additive/idempotent column์œผ๋กœ ์ถ”๊ฐ€ํ•œ๋‹ค. ํ—ˆ์šฉ๊ฐ’์€ `cash`, `investment`, `deposit`, `real_estate`, `loan`, `other`์ด๋ฉฐ ๊ธฐ์กด/๋ฏธ์ง€์ • ๊ณ„์ขŒ๋Š” `other`๋กœ ํด๋ฐฑํ•œ๋‹ค. +3. ์„œ๋น„์Šค ๊ณ„์ธต์—์„œ create/list/update ๊ณ„์ขŒ ํ๋ฆ„์— `liquidity_bucket`์„ ๋…ธ์ถœํ•˜๊ณ  ํ—ˆ์šฉ๊ฐ’ ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ•œ๋‹ค. +4. ์ž์‚ฐ ์œ ๋™์„ฑ report๋Š” `accounts.last_balance`๋งŒ ์‚ฌ์šฉํ•œ๋‹ค. `last_balance`๊ฐ€ ์—†๋Š” ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ/๋ถ€์ฑ„๋Š” ์ž„์˜ ์ถ”์ •ํ•˜์ง€ ์•Š๊ณ  missing warning์œผ๋กœ ๋‚จ๊ธด๋‹ค. +5. `page_overview`๋Š” ๋‹ค์Œ ์ˆœ์„œ๋กœ ์žฌ๊ตฌ์„ฑํ•œ๋‹ค: ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ โ†’ ์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ โ†’ ์ž์‚ฐ ๊ตฌ์„ฑ โ†’ ์ง€์ถœ ๊ตฌ์กฐ/์˜ˆ์‚ฐ โ†’ ์šด์˜ ๊ฒฝ๊ณ . +6. `scripts/streamlit_browser_evidence.py` synthetic fixture์— overview ๊ฒ€์ฆ์šฉ ์ž์‚ฐ/๋ถ€์ฑ„/์˜ˆ์‚ฐ/์›”๊ฐ„ ํ˜„๊ธˆํ๋ฆ„ ๋ฐ์ดํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , BAT-005 evidence output directory๋กœ ์‹คํ–‰ํ•œ๋‹ค. + +## Design Gate + +- Profile: `standard` +- ํŒ๋‹จ ๊ทผ๊ฑฐ: DB schema, service/MCP contract, Streamlit UI, tests, baseline docs๊ฐ€ ํ•จ๊ป˜ ๋ณ€ํ•˜๋Š” ๊ธฐ๋Šฅ batch๋‹ค. +- ๋ณ„๋„ `design.md`: planning์— ์„ค๊ณ„ ๊ฒฐ์ •์„ ํฌํ•จํ•˜๊ณ , ๊ตฌํ˜„ ์ƒ์„ธ๋Š” `implementation.md`์—์„œ ์ถ”์ ํ•œ๋‹ค. + +## ํ•ต์‹ฌ ์„ค๊ณ„ ๊ฒฐ์ • + +- ์œ ๋™์„ฑ ๊ถŒ์œ„ ์†Œ์Šค๋Š” ๊ณ„์ขŒ ๋‹จ์œ„ `accounts.liquidity_bucket`์œผ๋กœ ๊ณ ์ •ํ•œ๋‹ค. +- ์ž์‚ฐ ํ‰๊ฐ€๋Š” `accounts.last_balance`๋ฅผ ์‚ฌ์šฉํ•˜๋ฉฐ, ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„๋กœ ๋ถ€๋™์‚ฐ/์˜ˆ์ ๊ธˆ/๋Œ€์ถœ ํ˜„์žฌ๊ฐ€๋ฅผ ์ถ”์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- `loan` ๋ฒ„ํ‚ท์€ ์ž์‚ฐ ๊ตฌ์„ฑ ๋น„์œจ ๋ถ„๋ชจ์—์„œ ์ œ์™ธํ•˜๊ณ  ์ด๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ์—๋งŒ ๋ฐ˜์˜ํ•œ๋‹ค. +- `last_balance`๊ฐ€ ์—†๋Š” ๊ณ„์ขŒ๋Š” ๊ณ„์‚ฐ์—์„œ ์ œ์™ธํ•˜๋˜ ์šด์˜ ๊ฒฝ๊ณ ์— ํ‘œ์‹œํ•œ๋‹ค. + +## ๊ฒ€์ฆ ๊ณ„ํš + +- `py_compile` for changed Python files +- `pytest tests/test_service.py -q` +- full `pytest -q` +- `scripts/streamlit_browser_evidence.py --page ์š”์•ฝ --output-dir docs/batches/bat-005_.../evidence/run-...` with synthetic DB diff --git a/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/verification.md b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/verification.md new file mode 100644 index 0000000..8f813d7 --- /dev/null +++ b/docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/verification.md @@ -0,0 +1,44 @@ +# BAT-005 Verification + +## ๊ฒ€์ฆ ๊ณ„ํš + +1. Python ๋ฌธ๋ฒ• ๊ฒ€์ฆ + - `.venv/bin/python -m py_compile jarca_ledger_mcp/db.py jarca_ledger_mcp/service.py jarca_ledger_mcp/server.py dashboard/app.py scripts/streamlit_browser_evidence.py tests/test_service.py` +2. ๋Œ€์ƒ service regression + - `.venv/bin/python -m pytest tests/test_service.py -q` +3. ์ „์ฒด ํ…Œ์ŠคํŠธ + - `.venv/bin/python -m pytest -q` +4. Streamlit browser evidence + - `.venv/bin/python scripts/streamlit_browser_evidence.py --page ์š”์•ฝ --output-dir docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-` +5. ๋ฏผ๊ฐ์ •๋ณด ๊ฒ€ํ†  + - evidence fixture๋Š” synthetic ๋ฐ์ดํ„ฐ๋งŒ ํฌํ•จํ•œ๋‹ค. + - ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ, ์‹ค์ œ ๊ธˆ์œต ์›๋ณธ ํŒŒ์ผ๋ช…, ๋น„๋ฐ€๋ฒˆํ˜ธ, raw_payload๋Š” ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## Acceptance Mapping + +- AC-1: `db.py` schema/migration ๋ฐ schema version 8. +- AC-2: `normalize_liquidity_bucket`, create/update validation tests. +- AC-3: account create/list/update regression tests ๋ฐ MCP signature. +- AC-4: `dashboard/app.py` top section ์›”๊ฐ„ cashflow metrics. +- AC-5/6/7: `get_asset_liquidity_report` regression ๋ฐ overview asset/liability/missing balance sections. +- AC-8: overview operational warning metrics/caption. +- AC-9: pytest ๋ฐ browser evidence result. +- AC-10: baseline docs updated. + +## ๊ฒฐ๊ณผ + +์ตœ์ข… ๊ฒ€์ฆ ๊ฒฐ๊ณผ(2026-06-29 KST): + +- `source .venv/bin/activate && python -m py_compile jarca_ledger_mcp/db.py jarca_ledger_mcp/service.py jarca_ledger_mcp/server.py dashboard/app.py scripts/streamlit_browser_evidence.py tests/test_service.py tests/test_ledger_categories.py` โ€” PASS. +- `source .venv/bin/activate && pytest tests/test_service.py -q` โ€” PASS (`9 passed`). +- `source .venv/bin/activate && pytest -q` โ€” PASS (`31 passed, 4 skipped`). +- `source .venv/bin/activate && python scripts/streamlit_browser_evidence.py --page ์š”์•ฝ --output-dir docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111` โ€” PASS. + - Evidence directory: `docs/batches/bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/evidence/run-20260629-2111/` + - Manifest: `manifest.json` (`success: true`). + - Captured page: `์š”์•ฝ` / `overview.png` 303,341 bytes / `overview.dom.html` 30,757 bytes. + - DOM evidence includes `schema v8`, `KST ์ด๋ฒˆ ๋‹ฌ`, `์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ`, and `์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ` from a synthetic fixture DB. + +## Sensitive Evidence Review + +- Evidence DB path is the generated fixture path under BAT-005 evidence, not the operating DB default path. +- Fixture rows use `Synthetic`/`SYNTHETIC_*` labels and do not include real account numbers, real financial source filenames, passwords, or raw payloads. diff --git a/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/design.md b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/design.md new file mode 100644 index 0000000..06a1f2b --- /dev/null +++ b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/design.md @@ -0,0 +1,64 @@ +# BAT-006 Design + +## Architecture Summary + +BAT-006 introduces a CI safety layer around existing test and smoke assets. GitHub Actions becomes the external gate for PR/push validation, while CI wrapper scripts provide deterministic entrypoints for environment setup, synthetic fixture DB creation, Streamlit readiness checks, DB safety smoke invocation, timeout handling, cleanup, and sensitive-output control. + +The required CI gates are pytest, Streamlit AppTest, dashboard HTTP smoke, and DB safety smoke. Browser screenshot E2E remains a local evidence workflow from dcy-004 and is intentionally excluded from the required CI blocker set. + +## Changed Areas + +- `.github/workflows/` + - New workflow for `pull_request`, `push` to `main`, and `workflow_dispatch`. + - Required jobs should have stable names so branch protection can reference them later. +- `scripts/` + - New or adapted CI wrapper entrypoint for dashboard HTTP smoke. + - New or adapted CI wrapper entrypoint for DB safety smoke using existing `ledger_restore_smoke.py` logic where appropriate. + - Wrapper logs must avoid dumping raw transactions, account numbers, `raw_payload`, source file names, passwords, or live DB paths. +- `tests/` + - Existing pytest and Streamlit AppTest assets are reused. Add fixture helpers only if needed to make CI deterministic. +- `pyproject.toml` / dependency setup + - Confirm CI installs the extras required for pytest and dashboard/AppTest execution. +- Docs + - `docs/project-structure.md`: CI workflow and wrapper responsibilities. + - `docs/runtime-flows.md`: PR/push -> CI jobs -> gate result flow and synthetic fixture DB boundary. + - `docs/runbooks/`: CI failure reproduction runbook if the workflow/wrapper commands need operator guidance. + +## Key Decisions + +1. CI platform is GitHub Actions, with `pull_request`, `push` to `main`, and optional `workflow_dispatch`. +2. Required blocker gates are pytest, Streamlit AppTest, dashboard HTTP smoke, and DB safety smoke. +3. HTTP smoke and DB safety smoke use CI-specific thin wrappers rather than ad-hoc shell snippets, so exit codes, logs, readiness, timeout, and cleanup are deterministic. +4. Every CI run creates or uses synthetic fixture/temporary validation DBs. The operating DB default path is not used. +5. Browser screenshot E2E is not a required CI gate. It remains a local/manual evidence path for UI-heavy changes. +6. Branch protection enforcement is external GitHub repository configuration; the batch stabilizes workflow/check names and documents the required-check expectation. + +## CI Job Boundary + +- `pytest` gate: + - Install project with test dependencies. + - Run the repository pytest suite using the standard project command. + - Fail the workflow on any test failure. +- `dashboard-app-test` gate: + - Prepare synthetic fixture DB. + - Run Streamlit AppTest coverage, especially `tests/test_dashboard_app.py`. + - Fail with test-level logs only. +- `dashboard-http-smoke` gate: + - Prepare synthetic fixture DB in runner temp space. + - Start Streamlit dashboard with that DB and deterministic port policy. + - Poll HTTP readiness until 200 or timeout. + - Terminate the server and fail if cleanup fails materially. +- `db-safety-smoke` gate: + - Prepare source/backup/target paths under runner temp space. + - Invoke DB restore/safety smoke without live DB paths. + - Fail if overwrite approval policy, integrity check, or fixture health check fails. + +## Sensitive Evidence Boundary + +CI output may include command names, job names, synthetic DB temp paths, test identifiers, exit status, row counts for synthetic fixtures, and high-level error messages. CI output must not include live account numbers, real balances, `raw_payload`, original financial file names, passwords, or full transaction row dumps. + +## Reference Doc Update Plan + +- Update `docs/project-structure.md` to replace the current "CI workflow ... not yet defined" baseline with the BAT-006 workflow and wrapper structure. +- Update `docs/runtime-flows.md` with the CI validation flow and the local browser evidence boundary. +- Add a `docs/runbooks/ci-smoke.md` or equivalent only if local reproduction commands are not already obvious from the workflow/wrappers. diff --git a/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/implementation.md b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/implementation.md new file mode 100644 index 0000000..8619d98 --- /dev/null +++ b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/implementation.md @@ -0,0 +1,106 @@ +# BAT-006 Implementation + +## Implementation Status +- Status: completed-for-qc +- Completed At (KST): 2026-06-30 15:23:00 KST +- Scope: REQ-006 CI automation, synthetic fixture entrypoints, local CI smoke reproduction docs, and StagePilot evidence/state updates. +- Boundary: REQ-006 is not marked Implemented yet; QC by `jarcaledgerqc` is the next gate. + +## Changed Files + +### CI workflow +- `.github/workflows/ci.yml` + - Added GitHub Actions workflow triggers for `pull_request`, `push` to `main`, and `workflow_dispatch`. + - Added stable gate jobs/check names: + - `pytest-regression` + - `dashboard-app-test` + - `dashboard-http-smoke` + - `db-safety-smoke` + - Each job installs with `uv` and runs its narrow CI gate command. + +### CI fixture and smoke wrappers +- `scripts/ci_fixture_db.py` + - Added deterministic synthetic fixture DB builder for CI/AppTest/smoke checks. + - Refuses the operating ledger DB default path and only allows CI fixture filenames. + - Seeds synthetic accounts, budget, and transactions, then verifies SQLite integrity. +- `scripts/ci_dashboard_http_smoke.py` + - Added dashboard HTTP smoke wrapper. + - Creates a temp synthetic fixture DB, starts Streamlit on loopback, polls for HTTP 200 readiness, and terminates/kills the process during cleanup. + - Emits status-only JSON with fixture/readiness/cleanup result. +- `scripts/ci_db_safety_smoke.py` + - Added DB safety wrapper. + - Creates temp synthetic source/backup/target DB paths, invokes `scripts/ledger_restore_smoke.py`, and refuses the operating DB path. + - Emits status-only JSON with restore/integrity/health result. + +### Tests +- `tests/test_dashboard_app.py` + - Added Streamlit AppTest coverage that creates a synthetic fixture DB through `scripts/ci_fixture_db.py`, sets `JARCA_LEDGER_DB_PATH`, and checks core pages render without Streamlit exceptions. + - Existing transfer review helper tests remain covered in the dashboard AppTest command. + +### Documentation +- `docs/project-structure.md` + - Documented `.github/workflows/ci.yml`, CI fixture builder, HTTP smoke wrapper, DB safety wrapper, stable gate names, and synthetic fixture policy. +- `docs/runtime-flows.md` + - Added `CI validation gates` flow and clarified that browser screenshot/DOM evidence remains local evidence, not a required CI gate. +- `docs/runbooks/ci-smoke.md` + - Added local reproduction commands, wrapper responsibilities, stable GitHub Actions job names, browser evidence boundary, and failure triage guidance. +- `docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/implementation.md` + - Replaced planned placeholder with actual implementation/evidence log. +- `docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/verification.md` + - Added AC-by-AC verification evidence and command results. +- `docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/index.md` + - Moved BAT-006 batch status from `planned` to `release-candidate` for QC. +- `docs/batches/index.md` + - Moved BAT-006 register status from `planned` to `release-candidate`. + +## Execution Log + +All commands below were run from `/root/projects/jarca-ledger-mcp`. + +1. Pytest regression gate + - Command: `uv run pytest --ignore=tests/test_dashboard_app.py -q` + - Result: PASS, exit code 0. + - Output: `............ss...............ss... [100%]` + +2. Streamlit AppTest gate + - Command: `uv run pytest tests/test_dashboard_app.py -q` + - Result: PASS, exit code 0. + - Output: `.... [100%]` + +3. Dashboard HTTP smoke gate + - Command: `uv run python scripts/ci_dashboard_http_smoke.py --host 127.0.0.1 --port 18501 --timeout 45` + - Result: PASS, exit code 0. + - Output summary: + - `success: true` + - `gate: dashboard-http-smoke` + - `fixture: synthetic` + - `fixture_integrity: ok` + - `http_status: 200` + - `cleanup: terminated` + +4. DB safety smoke gate + - Command: `uv run python scripts/ci_db_safety_smoke.py --timeout 45` + - Result: PASS, exit code 0. + - Output summary: + - `success: true` + - `gate: db-safety-smoke` + - `fixture: synthetic` + - `fixture_integrity: ok` + - `restore_smoke_exit_code: 0` + - `restored_exists: true` + - `restored_integrity: ok` + - `health_success: true` + - `operating_db_used: false` + +## Validation Summary + +- GitHub Actions workflow exists with required BAT-006 trigger shape and stable job names. +- CI/AppTest/HTTP/DB safety commands pass locally against synthetic/temp DBs. +- Wrapper output is status-only and does not expose real account identifiers, operating DB data, raw payloads, source financial filenames, or passwords. +- Browser screenshot/DOM evidence is intentionally not a required CI gate and remains covered by the local browser evidence runbook/flow. + +## Remaining Risks / QC Notes + +- Remote GitHub Actions has not been executed in this local handoff; QC should confirm the workflow on GitHub if remote runner access is available. +- Branch protection required-check enforcement is repository configuration outside this codebase. +- REQ-006 must remain not Implemented until `jarcaledgerqc` completes QC and the requirement implementation state is explicitly confirmed. diff --git a/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/index.md b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/index.md new file mode 100644 index 0000000..ed8a452 --- /dev/null +++ b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/index.md @@ -0,0 +1,32 @@ +# BAT-006 pytest/AppTest/Smoke CI ์ž๋™ํ™” + +- Profile: standard +- Status: release-candidate +- Included REQ: REQ-006 +- Discovery: dcy-006 +- Owner: ๋ ›์ €์žฅ์ธ +- Date (KST): 2026-06-30 + +## ๋ชฉํ‘œ + +REQ-006์— ๋”ฐ๋ผ GitHub Actions ๊ธฐ๋ฐ˜ CI ๊ณ„์•ฝ์„ ์‹ ์„คํ•˜๊ณ , push/PR์—์„œ pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ, Streamlit AppTest, ๋Œ€์‹œ๋ณด๋“œ HTTP smoke, DB safety smoke๋ฅผ synthetic fixture/์ž„์‹œ ๊ฒ€์ฆ DB๋กœ ์ž๋™ ์‹คํ–‰ํ•˜๋Š” ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋ฅผ ๊ตฌ์„ฑํ•œ๋‹ค. + +## ์‚ฐ์ถœ๋ฌผ + +- `.github/workflows/` ์•„๋ž˜ GitHub Actions workflow +- CI ์ „์šฉ ์–‡์€ smoke wrapper entrypoint: HTTP smoke์™€ DB safety smoke์˜ ๊ฒฐ์ •์  ์‹คํ–‰/์ข…๋ฃŒ์ฝ”๋“œ/๋กœ๊ทธ/์ •๋ฆฌ ์ฑ…์ž„ +- synthetic fixture DB ์ค€๋น„ ํ๋ฆ„๊ณผ ์šด์˜ DB ์ ‘๊ทผ ์ฐจ๋‹จ ์ •์ฑ… +- pytest/AppTest/HTTP/DB safety smoke CI job ๊ตฌ์„ฑ +- `docs/project-structure.md`, `docs/runtime-flows.md`, ํ•„์š” ์‹œ `docs/runbooks/`์˜ CI ๊ฒŒ์ดํŠธ/๋กœ์ปฌ browser evidence ๊ฒฝ๊ณ„ ๋ฌธ์„œํ™” +- BAT-006 planning/design/implementation/verification ๋ฌธ์„œ์™€ CI evidence + +## ๋ฒ”์œ„ ์ œํ•œ + +browser-screenshot/visual regression์˜ CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธํ™”, CD/์„œ๋น„์Šค ๋ฐฐํฌ ์ž๋™ํ™”, ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€ ํผ์„ผํŠธ ๊ฐ•์ œ, ์„ฑ๋Šฅ ๋ฒค์น˜๋งˆํฌ ๊ฒŒ์ดํŠธ, ์šด์˜ DB ๋˜๋Š” ์‹ค๊ฑฐ๋ž˜/์‹ค๊ณ„์ขŒ ๋ฐ์ดํ„ฐ์˜ CI ๋ฐ˜์ž…์€ ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## ๋ฌธ์„œ + +- `planning.md`: delivery plan, dependencies, design gate, risks +- `design.md`: CI architecture, job boundaries, wrapper policy, evidence boundaries +- `implementation.md`: ์‹ค์ œ ๋ณ€๊ฒฝ ํŒŒ์ผ๊ณผ ๋กœ์ปฌ ์‹คํ–‰ ๋กœ๊ทธ ๊ธฐ๋ก +- `verification.md`: REQ-006 AC๋ณ„ ๊ฒ€์ฆ evidence ๋ฐ QC handoff ๊ธฐ๋ก diff --git a/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/planning.md b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/planning.md new file mode 100644 index 0000000..1bb5e0b --- /dev/null +++ b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/planning.md @@ -0,0 +1,41 @@ +# BAT-006 Planning + +## Included REQ +- REQ-006: pytest/AppTest/Smoke CI ์ž๋™ํ™” + +## Delivery Plan +1. REQ-006 ์Šน์ธ ์ƒํƒœ์™€ dcy-006 freeze ๊ฒฐ์ •์„ ์ž…๋ ฅ gate๋กœ ํ™•์ธํ•œ๋‹ค. +2. GitHub Actions workflow๋ฅผ `.github/workflows/` ์•„๋ž˜์— ์‹ ์„คํ•˜๊ณ  `pull_request`, `main` ๋Œ€์ƒ `push`, `workflow_dispatch` ํŠธ๋ฆฌ๊ฑฐ๋ฅผ ์ •์˜ํ•œ๋‹ค. +3. CI ์‹คํ–‰ ํ™˜๊ฒฝ์„ ํ‘œ์ค€ํ™”ํ•œ๋‹ค: `uv`/Python ์„ค์น˜, project extras ์„ค์น˜, runner temp ๊ฒฝ๋กœ ์‚ฌ์šฉ, ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ ์ ‘๊ทผ ๊ธˆ์ง€. +4. pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ job์„ ๊ตฌ์„ฑํ•ด `tests/` ์ „์ฒด ๋˜๋Š” ๋™์ผ ์˜๋ฏธ์˜ ํ‘œ์ค€ ๋ช…๋ น์„ ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋กœ ์‹คํ–‰ํ•œ๋‹ค. +5. Streamlit AppTest job์„ synthetic fixture DB ๊ธฐ์ค€์œผ๋กœ ์‹คํ–‰ํ•œ๋‹ค. +6. HTTP smoke์™€ DB safety smoke์šฉ CI wrapper๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. wrapper๋Š” fixture ์ค€๋น„, ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ •, readiness polling, timeout, cleanup, ๋ฏผ๊ฐ์ •๋ณด ๋น„์ถœ๋ ฅ ์ •์ฑ…์„ ๋‹ด๋‹นํ•œ๋‹ค. +7. DB safety smoke๋Š” ์ž„์‹œ fixture/๊ฒ€์ฆ DB์—์„œ๋งŒ ์‹คํ–‰ํ•˜๊ณ  ์šด์˜ DB ๋ฐ ์›๋ณธ ๊ธˆ์œต ํŒŒ์ผ ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”์ง€ ๊ฒ€์ฆํ•œ๋‹ค. +8. `docs/project-structure.md`์™€ `docs/runtime-flows.md`์— CI workflow, smoke wrapper, gate result flow, ๋กœ์ปฌ browser evidence(dcy-004)์™€์˜ ๊ฒฝ๊ณ„๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. ํ•„์š”ํ•˜๋ฉด `docs/runbooks/ci-smoke.md`๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. +9. ๋กœ์ปฌ์—์„œ ๊ฐ€๋Šฅํ•œ ๋ฒ”์œ„์˜ CI ๋“ฑ๊ฐ€ ๋ช…๋ น์„ ์‹คํ–‰ํ•˜๊ณ , GitHub Actions ์‹ค์ œ ๋กœ๊ทธ ๋˜๋Š” ๋กœ์ปฌ dry-run ํ•œ๊ณ„๋ฅผ verification์— ๋ช…ํ™•ํžˆ ๊ธฐ๋กํ•œ๋‹ค. + +## Dependencies +- ๊ธฐ์กด pytest ์„ค์ •: `pyproject.toml`์˜ `[tool.pytest.ini_options]`์™€ dev extra์˜ `pytest`. +- ๊ธฐ์กด Streamlit AppTest ์ž์‚ฐ: `tests/test_dashboard_app.py`. +- ๊ธฐ์กด smoke ์ž์‚ฐ: `scripts/ledger_restore_smoke.py`, `scripts/streamlit_browser_evidence.py` ๋‚ด๋ถ€ ๋กœ์ง ์ค‘ CI์—์„œ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ถ€๋ถ„. +- dcy-002 ์šด์˜ ์•ˆ์ „ ์›์น™: ๊ฒ€์ฆ DB/fixture ์šฐ์„ , ์šด์˜ DB restore/mutation ๊ธˆ์ง€. +- dcy-004 evidence ๊ฒฝ๊ณ„: browser screenshot evidence๋Š” ๋กœ์ปฌ ๋ณด๊ฐ• ๊ฒฝ๋กœ์ด๋ฉฐ CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๊ฐ€ ์•„๋‹ˆ๋‹ค. + +## Design Gate +Design required: yes. + +Reason: ์ด batch๋Š” CI workflow, wrapper entrypoint, fixture DB safety boundary, baseline runtime docs๋ฅผ ํ•จ๊ป˜ ์ •์˜ํ•œ๋‹ค. runner ํ™˜๊ฒฝยท๋ฏผ๊ฐ์ •๋ณดยท์šด์˜ DB ์ ‘๊ทผ ์ฐจ๋‹จ ๊ทœ์น™์ด ์š”๊ตฌ์‚ฌํ•ญ์˜ ํ•ต์‹ฌ์ด๋ฏ€๋กœ ๊ตฌํ˜„ ์ „ job ๊ฒฝ๊ณ„์™€ wrapper ์ฑ…์ž„์„ ์„ค๊ณ„ ๋ฌธ์„œ์— ๊ณ ์ •ํ•œ๋‹ค. + +## Milestones +- M1: REQ-006 Approved ๋ฐ BAT-006 standard batch ์ƒ์„ฑ. +- M2: CI workflow/job ๊ตฌ์กฐ์™€ wrapper contract ์„ค๊ณ„ ์™„๋ฃŒ. +- M3: Workflow์™€ wrapper ๊ตฌํ˜„, synthetic fixture ์‹คํ–‰ ๊ฒฝ๋กœ ์—ฐ๊ฒฐ. +- M4: pytest/AppTest/HTTP/DB safety smoke ๋กœ์ปฌ ๋“ฑ๊ฐ€ ๊ฒ€์ฆ ํ†ต๊ณผ. +- M5: baseline docs/runbook ๊ฐฑ์‹  ๋ฐ verification evidence ์ž‘์„ฑ. +- M6: QC gate PASS/PASS_WITH_NOTES ํ›„ REQ-006 Implemented ์ „ํ™˜. + +## Risks +- Streamlit HTTP smoke๊ฐ€ readiness ๋Œ€๊ธฐ/ํฌํŠธ ์ถฉ๋Œ๋กœ flakyํ•ด์งˆ ์ˆ˜ ์žˆ๋‹ค. ๊ณ ์ • ๋˜๋Š” ๊ฒฐ์ •์  ํฌํŠธ ํ• ๋‹น, polling timeout, ํ”„๋กœ์„ธ์Šค cleanup์„ wrapper์—์„œ ํ‘œ์ค€ํ™”ํ•œ๋‹ค. +- CI ๋กœ๊ทธ์— ์‹ค๊ณ„์ขŒ๋ฒˆํ˜ธ, ์‹ค์ž”์•ก, `raw_payload`, ์›๋ณธ ํŒŒ์ผ๋ช…, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ๋…ธ์ถœ๋  ์ˆ˜ ์žˆ๋‹ค. synthetic fixture๋งŒ ์‚ฌ์šฉํ•˜๊ณ  wrapper ์ถœ๋ ฅ์€ ์š”์•ฝ/์ƒํƒœ ์ค‘์‹ฌ์œผ๋กœ ์ œํ•œํ•œ๋‹ค. +- GitHub Actions runner์—๋Š” ๋กœ์ปฌ browser evidence ํ™˜๊ฒฝ์ด ์—†์„ ์ˆ˜ ์žˆ๋‹ค. browser-screenshot E2E๋Š” CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ์—์„œ ์ œ์™ธํ•˜๊ณ  dcy-004 ๋กœ์ปฌ evidence ๊ฒฝ๋กœ๋กœ ์œ ์ง€ํ•œ๋‹ค. +- ๋ธŒ๋žœ์น˜ ๋ณดํ˜ธ required check ์„ค์ •์€ ์ €์žฅ์†Œ ์™ธ๋ถ€ GitHub ์„ค์ •์ด๋ฏ€๋กœ, ์ด batch๋Š” workflow/check ์ด๋ฆ„์„ ์•ˆ์ •ํ™”ํ•˜๊ณ  ๋ฌธ์„œ์— ํ›„์† ์šด์˜ ์„ค์ •์œผ๋กœ ๊ธฐ๋กํ•œ๋‹ค. diff --git a/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/verification.md b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/verification.md new file mode 100644 index 0000000..c824480 --- /dev/null +++ b/docs/batches/bat-006_20260630_pytest-apptest-smoke-ci-automation/verification.md @@ -0,0 +1,154 @@ +# BAT-006 Verification + +## Verification Status +- Status: PASS_WITH_NOTES +- Ready for Release: true +- Ready for QC: false +- Verified At (KST): 2026-06-30 15:29 KST +- Verified By: jarcaledgerqc +- QC Verdict: PASS_WITH_NOTES +- QC Boundary: satisfied + +## Acceptance Mapping + +### REQ-006 AC-1 +`.github/workflows/` ์•„๋ž˜์— GitHub Actions workflow๊ฐ€ ์ƒ์„ฑ๋˜๊ณ , `pull_request`, `main` ๋Œ€์ƒ `push`, `workflow_dispatch` ํŠธ๋ฆฌ๊ฑฐ๊ฐ€ ์ •์˜๋œ๋‹ค. + +Evidence: +- Workflow file: `.github/workflows/ci.yml`. +- Trigger excerpt verified in file: + - `pull_request:` + - `push:` with `branches: [main]` + - `workflow_dispatch:` +- Stable job/check names in workflow: + - `pytest-regression` + - `dashboard-app-test` + - `dashboard-http-smoke` + - `db-safety-smoke` + +Result: PASS. + +### REQ-006 AC-2 +CI์—์„œ pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ๊ฐ€ ์ž๋™ ์‹คํ–‰๋˜๊ณ , ๋‹จ์ผ ํ…Œ์ŠคํŠธ ์‹คํŒจ ์‹œ workflow๊ฐ€ ์‹คํŒจ ์ƒํƒœ๋กœ ์ข…๋ฃŒ๋œ๋‹ค. + +Evidence: +- Workflow job: `pytest-regression`. +- Workflow command: `uv run pytest --ignore=tests/test_dashboard_app.py`. +- Local equivalent command: + - `uv run pytest --ignore=tests/test_dashboard_app.py -q` + - Result: PASS, exit code 0. + - Output: `............ss...............ss... [100%]` +- GitHub Actions `run:` step uses normal shell exit-code behavior, so a pytest failure fails the job/workflow. + +Result: PASS. + +### REQ-006 AC-3 +CI์—์„œ Streamlit `AppTest`๊ฐ€ synthetic fixture DB ๊ธฐ์ค€์œผ๋กœ ์ž๋™ ์‹คํ–‰๋˜๊ณ  ํ†ต๊ณผ/์‹คํŒจ ๊ฒฐ๊ณผ๊ฐ€ ๋กœ๊ทธ์— ๋‚จ๋Š”๋‹ค. + +Evidence: +- Workflow job: `dashboard-app-test`. +- Workflow command: `uv run pytest tests/test_dashboard_app.py`. +- Test evidence: `tests/test_dashboard_app.py` calls `create_ci_fixture_db(tmp_path / "fixture.sqlite3")`, sets `JARCA_LEDGER_DB_PATH`, and runs Streamlit AppTest across core pages. +- Local equivalent command: + - `uv run pytest tests/test_dashboard_app.py -q` + - Result: PASS, exit code 0. + - Output: `.... [100%]` +- Fixture policy: `scripts/ci_fixture_db.py` refuses `DEFAULT_DB_PATH` and seeds synthetic data only. + +Result: PASS. + +### REQ-006 AC-4 +CI์—์„œ ๋Œ€์‹œ๋ณด๋“œ HTTP smoke๊ฐ€ fixture DB ๊ธฐ์ค€์œผ๋กœ Streamlit์„ ๊ธฐ๋™ํ•œ ๋’ค HTTP 200 ์‘๋‹ต์„ ํ™•์ธํ•˜๊ณ , ์ข…๋ฃŒ ํ›„ ํ”„๋กœ์„ธ์Šค๋ฅผ ์ •๋ฆฌํ•œ๋‹ค. + +Evidence: +- Workflow job: `dashboard-http-smoke`. +- Workflow command: `uv run python scripts/ci_dashboard_http_smoke.py --host 127.0.0.1 --port 18501 --timeout 45`. +- Wrapper: `scripts/ci_dashboard_http_smoke.py` creates a temp synthetic fixture DB, starts Streamlit with `JARCA_LEDGER_DB_PATH`, polls a loopback URL for HTTP 200, and performs process cleanup. +- Local command: + - `uv run python scripts/ci_dashboard_http_smoke.py --host 127.0.0.1 --port 18501 --timeout 45` + - Result: PASS, exit code 0. + - Output summary: `success: true`, `fixture: synthetic`, `fixture_integrity: ok`, `http_status: 200`, `attempts: 3`, `cleanup: terminated`. + +Result: PASS. + +### REQ-006 AC-5 +CI์—์„œ DB safety smoke๊ฐ€ ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ์ž„์‹œ fixture/๊ฒ€์ฆ DB์—์„œ ํ†ต๊ณผํ•œ๋‹ค. + +Evidence: +- Workflow job: `db-safety-smoke`. +- Workflow command: `uv run python scripts/ci_db_safety_smoke.py --timeout 45`. +- Wrapper: `scripts/ci_db_safety_smoke.py` creates temp `source`, `backup`, and `target` DB files and refuses `DEFAULT_DB_PATH` before invoking `scripts/ledger_restore_smoke.py`. +- Local command: + - `uv run python scripts/ci_db_safety_smoke.py --timeout 45` + - Result: PASS, exit code 0. + - Output summary: `success: true`, `fixture: synthetic`, `fixture_integrity: ok`, `restore_smoke_exit_code: 0`, `restored_exists: true`, `restored_integrity: ok`, `health_success: true`, `operating_db_used: false`. + +Result: PASS. + +### REQ-006 AC-6 +HTTP smoke/DB safety smoke๋Š” CI ์ „์šฉ wrapper entrypoint๋ฅผ ํ†ตํ•ด ์‹คํ–‰๋˜๋ฉฐ, wrapper๊ฐ€ ์ข…๋ฃŒ์ฝ”๋“œยทtimeoutยทreadinessยทcleanup์„ ์ผ๊ด€๋˜๊ฒŒ ์ฒ˜๋ฆฌํ•œ๋‹ค. + +Evidence: +- HTTP wrapper path: `scripts/ci_dashboard_http_smoke.py`. + - Readiness: polls URL until HTTP 200 or timeout. + - Timeout/failure: returns non-zero status with status-only JSON. + - Cleanup: terminates/kills Streamlit process and reports cleanup state. +- DB safety wrapper path: `scripts/ci_db_safety_smoke.py`. + - Timeout: catches `subprocess.TimeoutExpired` and returns non-zero status with timeout JSON. + - Exit code: reports restore smoke exit code and returns non-zero on failed restore/integrity/health conditions. + - Safety: refuses operating DB path and reports `operating_db_used: false` on pass. +- Local command results for both wrappers exited 0 and reported success JSON. + +Result: PASS. + +### REQ-006 AC-7 +CI ๋กœ๊ทธยท์•„ํ‹ฐํŒฉํŠธ์—๋Š” ์‹ค๊ณ„์ขŒ๋ฒˆํ˜ธ, ์‹ค์ž”์•ก, `raw_payload`, ์›๋ณธ ๊ธˆ์œต ํŒŒ์ผ๋ช…, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‰๋ฌธ์œผ๋กœ ํฌํ•จ๋˜์ง€ ์•Š๋Š”๋‹ค. + +Evidence: +- Fixture builder uses synthetic labels such as `CI Synthetic Checking`, `CI_SYNTHETIC_*`, and `ci synthetic ...`; it does not load real statement files or passwords. +- Wrapper outputs are status-only JSON. Observed local outputs include gate name, fixture type, integrity, HTTP status, cleanup, restore smoke status, and `operating_db_used: false`; they do not include account numbers, real balances, raw payloads, source financial filenames, or passwords. +- `scripts/ci_fixture_db.py` refuses the operating ledger DB default path. + +Result: PASS. + +### REQ-006 AC-8 +`docs/project-structure.md`์™€ `docs/runtime-flows.md`๊ฐ€ CI ๊ฒŒ์ดํŠธ์™€ ๋กœ์ปฌ browser evidence ๊ฒฝ๊ณ„๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. + +Evidence: +- `docs/project-structure.md` documents `.github/workflows/ci.yml`, stable gate names, `scripts/ci_fixture_db.py`, `scripts/ci_dashboard_http_smoke.py`, `scripts/ci_db_safety_smoke.py`, and synthetic fixture policy. +- `docs/runtime-flows.md` includes `Flow: CI validation gates` and the constraints that CI gates use synthetic/temp DBs only. +- `docs/runbooks/ci-smoke.md` provides reproduction commands and failure triage. + +Result: PASS. + +### REQ-006 AC-9 +browser-screenshot E2E๋Š” CI ํ•„์ˆ˜ ์ฐจ๋‹จ ๊ฒŒ์ดํŠธ์—์„œ ์ œ์™ธ๋˜๊ณ , dcy-004 ๋กœ์ปฌ evidence ์ ˆ์ฐจ์™€์˜ ์—ญํ•  ๋ถ„๋‹ด์ด ๋ฌธ์„œ์— ๋ช…์‹œ๋œ๋‹ค. + +Evidence: +- `.github/workflows/ci.yml` contains the four required CI jobs and no browser screenshot/visual regression required job. +- `docs/runtime-flows.md` states browser screenshot/DOM evidence is not a required GitHub Actions gate in BAT-006 and remains local evidence for visual/layout-sensitive dashboard changes. +- `docs/runbooks/ci-smoke.md` states `scripts/streamlit_browser_evidence.py` remains local evidence and is not a required GitHub Actions gate. + +Result: PASS. + +## Command Evidence Summary + +All commands were run from `/root/projects/jarca-ledger-mcp`. + +| Command | Result | Evidence | +| --- | --- | --- | +| `uv run pytest --ignore=tests/test_dashboard_app.py -q` | PASS, exit code 0 | `............ss...............ss... [100%]` | +| `uv run pytest tests/test_dashboard_app.py -q` | PASS, exit code 0 | `.... [100%]` | +| `uv run python scripts/ci_dashboard_http_smoke.py --host 127.0.0.1 --port 18501 --timeout 45` | PASS, exit code 0 | `success: true`, `http_status: 200`, `cleanup: terminated` | +| `uv run python scripts/ci_db_safety_smoke.py --timeout 45` | PASS, exit code 0 | `success: true`, `restored_integrity: ok`, `health_success: true`, `operating_db_used: false` | + +## Sensitive Evidence Review + +- No command output observed in this verification exposed real account numbers, operating DB balances, source financial filenames, passwords, or `raw_payload`. +- CI fixture/smoke paths are synthetic/temp paths and refuse the default operating ledger DB path. +- Remote GitHub Actions logs were not available in this local handoff; `jarcaledgerqc` recorded this as a note rather than a blocker because local equivalent commands passed and the workflow wiring is present. + +## Blocking Issues + +- None for local QC readiness. +- QC note: Remote GitHub Actions execution and branch protection required-check setup remain repository/remote configuration checks outside this local evidence update. diff --git a/docs/batches/index.md b/docs/batches/index.md index eefd844..a590832 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -10,3 +10,7 @@ | --- | --- | --- | --- | --- | --- | | BAT-001 | batch-lite | release-candidate | REQ-001 | dcy-001 | [bat-001_20260626_category-rule-ops](bat-001_20260626_category-rule-ops/) | | BAT-002 | standard | release-candidate | REQ-002 | dcy-002 | [bat-002_20260627_operational-safety-backup-restore-validation-db](bat-002_20260627_operational-safety-backup-restore-validation-db/) | +| BAT-003 | batch-lite | release-candidate | REQ-003 | dcy-003 | [bat-003_20260629_statement-unavailable-reconciliation](bat-003_20260629_statement-unavailable-reconciliation/) | +| BAT-004 | batch-lite | release-candidate | REQ-004 | dcy-004 | [bat-004_20260629_browser-streamlit-e2e-evidence](bat-004_20260629_browser-streamlit-e2e-evidence/) | +| BAT-005 | standard | release-candidate | REQ-005 | dcy-005 | [bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow](bat-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow/) | +| BAT-006 | standard | release-candidate | REQ-006 | dcy-006 | [bat-006_20260630_pytest-apptest-smoke-ci-automation](bat-006_20260630_pytest-apptest-smoke-ci-automation/) | diff --git a/docs/data-model.md b/docs/data-model.md index bea43f8..af66858 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,7 +4,7 @@ - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Status: draft - Owner: Jarca / project maintainer -- Last Updated (KST): 2026-06-27 10:17 +- Last Updated (KST): 2026-06-29 - Source Discovery / Batch: bootstrap-baseline ## Project Summary @@ -31,10 +31,17 @@ - `account_number_masked` :: text :: masked account identifier - `holder_name` :: text :: owner context for transfer matching - `last_balance` :: integer :: latest known balance when available + - `last_synced_at` :: text :: ๊ธฐ์ค€ ์ž”์•ก ์Šค๋ƒ…์ƒท ์‹œ๊ฐ. ์ •๊ธฐ ๋Œ€์กฐ๋Š” ์›”๋ง 23:59 KST, ์ˆ˜์‹œ ๋Œ€์กฐ๋Š” ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์‹œ๊ฐ + - `statement_status` :: text :: `unknown`, `import_supported`, `manual_only`, `partial_import` + - `liquidity_bucket` :: text :: `cash`, `investment`, `deposit`, `real_estate`, `loan`, `other`; ์ž์‚ฐ ์œ ๋™์„ฑ/๊ตฌ์„ฑ/์ˆœ์ž์‚ฐ ์ง‘๊ณ„์˜ ๊ณ„์ขŒ ๋‹จ์œ„ ๊ถŒ์œ„ ์†Œ์Šค - Relationships: - `transactions.account_id` links rows to accounts. - State Rules: - `active=false` hides accounts from default lists without deleting history. + - `manual_only` is the statement-unavailable class; `partial_import` remains eligible for manual supplementation and balance reconciliation. + - Balance reconciliation is report/preview first: compare `baseline_balance`/`baseline_at` against the latest `balance_after` or net account transaction delta, with 1,000 KRW absolute tolerance. + - ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ/๋ถ€์ฑ„๋Š” ๋ณ„๋„ ํ‰๊ฐ€ ํ…Œ์ด๋ธ” ์—†์ด `last_balance`๋ฅผ ํ˜„์žฌ ํ‰๊ฐ€์•ก/์ž”์•ก, `last_synced_at`์„ ๊ธฐ์ค€์ผ, `memo`๋ฅผ ์ถœ์ฒ˜ ๋ฉ”๋ชจ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + - `liquidity_bucket='loan'`์€ ์ž์‚ฐ ๊ตฌ์„ฑ ๋น„์œจ ๋ถ„๋ชจ์—์„œ ์ œ์™ธํ•˜๊ณ  ์ด๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ์‚ฐ์‹์—๋งŒ ๋ฐ˜์˜ํ•œ๋‹ค. `last_balance`๊ฐ€ ์—†๋Š” ๊ณ„์ขŒ/์ž์‚ฐ์€ ์ž„์˜ ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„๋กœ ์ถ”์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. ## Entity: Transaction - Source of Truth: `transactions` @@ -141,6 +148,7 @@ - External counterparty securities transfers are review items unless matched or explicitly confirmed. - Import evidence fields are retained for audit and future correction. - DB corrections should be narrow and memo-tagged. +- Dashboard asset liquidity/net-worth reports use `accounts.liquidity_bucket` and `accounts.last_balance` only; transactions are not used to infer non-transaction asset values. ## Persistence Notes - Schema changes should be additive/idempotent unless a destructive migration is explicitly approved and backed up. @@ -152,8 +160,11 @@ ## Current Gaps / Planned Changes - Formal schema migration documentation should be added before larger DB changes. - Some UI workflows need final E2E verification against real rows. -- Statement-unavailable account handling needs a documented manual evidence model. +- Statement-unavailable account handling is tracked by REQ-003/BAT-003; future work may add a dedicated reconciliation workbench and persistent review-decision queue. +- REQ-005/BAT-005 adds dashboard operations hub semantics and account-level liquidity buckets; future work may add separate asset snapshots only after a new REQ. ## Change Log - 2026-06-26 22:24 KST: Created by `bootstrap-baseline` from repository observation. - 2026-06-27 10:17 KST: BAT-002/REQ-002 ๊ธฐ์ค€์œผ๋กœ LedgerBackupArtifact์™€ metadata-only evidence ์ •์ฑ… ์ถ”๊ฐ€. +- 2026-06-29 KST: REQ-003/BAT-003 ๊ธฐ์ค€์œผ๋กœ `accounts.statement_status`์™€ ์ˆ˜๋™ ์ž”์•ก๋Œ€์กฐ preview ๋ชจ๋ธ ์ถ”๊ฐ€. +- 2026-06-29 KST: REQ-005/BAT-005 ๊ธฐ์ค€์œผ๋กœ `accounts.liquidity_bucket`, `last_balance` ๊ธฐ๋ฐ˜ ์ž์‚ฐ ์œ ๋™์„ฑ/๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ๊ทœ์น™ ์ถ”๊ฐ€. diff --git a/docs/discovery/dcy-003_20260629_statement-unavailable-manual-evidence-balance-reconciliation.md b/docs/discovery/dcy-003_20260629_statement-unavailable-manual-evidence-balance-reconciliation.md new file mode 100644 index 0000000..73b14ff --- /dev/null +++ b/docs/discovery/dcy-003_20260629_statement-unavailable-manual-evidence-balance-reconciliation.md @@ -0,0 +1,210 @@ +# 0. ๋ฌธ์„œ ์ƒํƒœ +์ด ๋ฌธ์„œ์˜ ํ˜„์žฌ ์ƒํƒœ์™€ ์‹๋ณ„ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ๋ฌธ์„œ ์ถ”์ ๊ณผ ์Šน์ธ ํ๋ฆ„์— ํ•„์š”ํ•œ ์ตœ์†Œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ ๋Š”๋‹ค. + +- ์ƒํƒœ: confirmed +- ๋ฌธ์„œ ID: dcy-003_20260629_statement-unavailable-manual-evidence-balance-reconciliation +- ์ด์Šˆ๋ช…: Statement-unavailable ๊ณ„์ขŒ ์ˆ˜๋™ ์ฆ๋น™ ๋ฐ ์ž”์•ก๋Œ€์กฐ ์›Œํฌํ”Œ๋กœ์šฐ ์ •๋ฆฝ +- ์ž‘์„ฑ ์‹œ๊ฐ(KST): 2026-06-29 12:33 +- ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹  ์‹œ๊ฐ(KST): 2026-06-29 13:06 +- ๋Œ€์ฒด๋จ: ์—†์Œ +- ํ›„์† Discovery ์ฐธ์กฐ: ์—†์Œ +- ์ƒ์„ฑ๋œ REQ ์ฐธ์กฐ: REQ-003 (`docs/srs/Interface/req-003_statement-unavailable-manual-evidence-balance-reconciliation.md`) + +# 1. ๊ณ„ํš ์ƒํƒœ ์š”์•ฝ +ํ˜„์žฌ Discovery๊ฐ€ ์–ด๋–ค ๋ฐฐ๊ฒฝ์—์„œ ์ž‘์„ฑ๋˜๊ณ  ์žˆ๋Š”์ง€, ์—ฐ๊ณ„ ๋ฌธ์„œ๊ฐ€ ์žˆ๋Š”์ง€, ์ง€๊ธˆ ์‹œ์ ์˜ ํ•ด์„์ด ๋ฌด์—‡์ธ์ง€ ์งง๊ฒŒ ์š”์•ฝํ•œ๋‹ค. + +- ์—ฐ๊ณ„ ๋ฌธ์„œ ํ˜„ํ™ฉ: + - ๊ด€๋ จ GitHub Project ํ•ญ๋ชฉ: https://github.com/users/genichin/projects/4 DraftIssue + - ๊ด€๋ จ TODO/๋ฉ”๋ชจ: Statement-unavailable ๊ณ„์ขŒ ์ˆ˜๋™ ์ฆ๋น™ ๋ฐ ์ž”์•ก๋Œ€์กฐ ์›Œํฌํ”Œ๋กœ์šฐ ์ •๋ฆฝ +- ํ•ด์„: + - dcy-001(๋ถ„๋ฅ˜ยทํ™˜๊ธ‰ UX)๊ณผ dcy-002(๋ฐฑ์—…ยท๋ณต๊ตฌ ์•ˆ์ „์žฅ์น˜)๋Š” ๋ชจ๋‘ statement(๊ฑฐ๋ž˜๋‚ด์—ญ ํŒŒ์ผ)๊ฐ€ ์กด์žฌํ•˜๋Š” ๊ณ„์ขŒ์˜ importยท๋ถ„๋ฅ˜ยท๊ฒ€์ฆ ํ๋ฆ„์„ ์ „์ œ๋กœ ํ•˜๋ฉฐ, statement๊ฐ€ ์—†๊ฑฐ๋‚˜ ์ž๋™ import๊ฐ€ ์–ด๋ ค์šด ๊ณ„์ขŒ๋ฅผ ๋‹ค๋ฃจ๋Š” ํ‘œ์ค€ ์ ˆ์ฐจ๋Š” ์•„์ง ์ •์˜๋˜์ง€ ์•Š์•˜๋‹ค. + - ์ด๋ฒˆ Discovery๋Š” ๊ฑฐ๋ž˜๋‚ด์—ญ ํŒŒ์ผ์ด ์—†๋Š” ๊ณ„์ขŒ์— ๋Œ€ํ•œ ์ˆ˜๋™ ์ฆ๋น™ ์ž…๋ ฅ, ์ž”์•ก๋Œ€์กฐ(reconciliation), ํ™•์ธํ•„์š” ํ ์ฒ˜๋ฆฌ, ๊ณ„์ขŒ์ด๋™/์‹ค์ง€์ถœ ํŒ์ • ๊ธฐ์ค€์„ ๋…๋ฆฝ ์ฃผ๊ธฐ๋กœ ์ •์˜ํ•œ๋‹ค. + +# 2. ์š”๊ตฌ์‚ฌํ•ญ ํŒ์ • ๊ฒฐ๊ณผ +์ด๋ฒˆ ์š”์ฒญ์ด ๊ธฐ์กด ์š”๊ตฌ์‚ฌํ•ญ๊ณผ ๋น„๊ตํ•ด ์‹ ๊ทœ์ธ์ง€, ์œ ์‚ฌํ•œ์ง€, ์ค‘๋ณต์ธ์ง€ ํŒ๋‹จํ•˜๊ณ  ๊ทธ ๊ทผ๊ฑฐ๋ฅผ ๋‚จ๊ธด๋‹ค. + +- ํŒ์ •: ์‹ ๊ทœ +- ๊ทผ๊ฑฐ + - ๊ธฐ์กด Discovery dcy-001/dcy-002์™€ REQ-001/REQ-002๋Š” statement ๊ธฐ๋ฐ˜ importยท๋ถ„๋ฅ˜ยท๋ฐฑ์—… ๋ฒ”์œ„๋ฅผ ๋‹ค๋ฃจ๋ฉฐ, statement-unavailable ๊ณ„์ขŒ์˜ ์ˆ˜๋™ ์ฆ๋น™ยท์ž”์•ก๋Œ€์กฐ๋ฅผ ๋…๋ฆฝ ์„ฑ๊ณต ๊ธฐ์ค€์œผ๋กœ ๋‹ค๋ฃจ์ง€ ์•Š๋Š”๋‹ค. + - ์ฝ”๋“œ์—๋Š” `source='manual'` ๊ฑฐ๋ž˜, `ledger_accounts.last_balance`, `transactions.balance_after`, transfer ๋งค์นญ/ํ™•์ธํ•„์š” ํ๊ฐ€ ๋ถ€๋ถ„์ ์œผ๋กœ ์กด์žฌํ•˜๋‚˜, ์ด๋ฅผ ์ž”์•ก๋Œ€์กฐ ์›Œํฌํ”Œ๋กœ์šฐ๋กœ ๋ฌถ๋Š” ๋ช…์‹œ์  ์ ˆ์ฐจยทacceptance criteria๊ฐ€ ์—†์–ด ๋ณ„๋„ ์ฃผ๊ธฐ๋กœ ์ถ”์ ํ•  ํ•„์š”๊ฐ€ ์žˆ๋‹ค. + +# 3. ๋ฌธ์ œ์ ์˜ ์š”์•ฝ +๋ฌธ์ œ์˜ ํ˜„์žฌ ์ƒํƒœ, ๊ธฐ๋Œ€ ์ƒํƒœ, ๊ด€๋ จ ์ดํ•ด๊ด€๊ณ„์ž, ์ œ์•ฝ์‚ฌํ•ญ์„ ์ •๋ฆฌํ•œ๋‹ค. Discovery์˜ ํ•ต์‹ฌ ๋งฅ๋ฝ์„ ๊ฐ€์žฅ ๋จผ์ € ์ดํ•ดํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. + +- ํ˜„์žฌ ์ƒํƒœ + - import ๊ฒฝ๋กœ๋Š” statement ํŒŒ์ผ์„ ์ „์ œ๋กœ ํ•œ๋‹ค: `service.py`์˜ `preview_ledger_csv_import`, `preview_kb_bank_pdf_import`, `preview_seoulpay_xlsx_import`, `preview_toss_bank_pdf_import`, `preview_toss_securities_pdf_import` ๋“ฑ์€ ๋ชจ๋‘ ์ž…๋ ฅ ํŒŒ์ผ์ด ์žˆ์–ด์•ผ ๋™์ž‘ํ•œ๋‹ค. + - ์Šคํ‚ค๋งˆ์—๋Š” ์ˆ˜๋™ ์ฆ๋น™ยท์ž”์•ก๋Œ€์กฐ์˜ ํ† ๋Œ€๊ฐ€ ์ผ๋ถ€ ์กด์žฌํ•œ๋‹ค: `db.py`์˜ `ledger_accounts.last_balance`/`last_synced_at`, `transactions.balance_after`, `transactions.source`(๊ธฐ๋ณธ๊ฐ’ `manual`). + - ๊ณ„์ขŒ์ด๋™/์‹ค์ง€์ถœ ํŒ์ • ์š”์†Œ๊ฐ€ ๋ถ„์‚ฐ๋˜์–ด ์žˆ๋‹ค: `find_self_transfer_candidates`, `link_self_transfer_pair`, `run_cross_account_transfer_match`/`list_cross_transfer_review_queue`(ํ™•์ธํ•„์š” ์„ฑ๊ฒฉ์˜ review ํ), `infer_cashflow_kind`(categoryโ†’cashflow_kind ์ถ”๋ก ). ๊ทธ๋Ÿฌ๋‚˜ statement๊ฐ€ ์—†๋Š” ๊ณ„์ขŒ์— ๋Œ€ํ•ด ์ˆ˜๋™ ์ž…๋ ฅ ๊ฑฐ๋ž˜๋ฅผ ์–ด๋–ค ๊ธฐ์ค€์œผ๋กœ ๊ณ„์ขŒ์ด๋™ ๋˜๋Š” ์‹ค์ง€์ถœ๋กœ ํŒ์ •ํ• ์ง€์— ๋Œ€ํ•œ ๋‹จ์ผ ๊ทœ์น™ยท์ฆ๋น™ ์š”๊ฑด์€ ๋ฌธ์„œํ™”๋˜์–ด ์žˆ์ง€ ์•Š๋‹ค. + - `docs/runbooks/`์—๋Š” `ledger-backup-restore.md`๋งŒ ์žˆ๊ณ , ์ˆ˜๋™ ์ฆ๋น™/์ž”์•ก๋Œ€์กฐ ์šด์˜ ์ ˆ์ฐจ runbook์ด ์—†๋‹ค. `docs/runtime-flows.md`๋„ statement-unavailable ๊ณ„์ขŒ์˜ ์ž…๋ ฅยท๋Œ€์กฐ ํ๋ฆ„์„ ๋‹ค๋ฃจ์ง€ ์•Š๋Š”๋‹ค. +- ๊ธฐ๋Œ€ ์ƒํƒœ + - ์šด์˜์ž๋Š” ๊ฑฐ๋ž˜๋‚ด์—ญ ํŒŒ์ผ์ด ์—†๊ฑฐ๋‚˜ ์ž๋™ import๊ฐ€ ์–ด๋ ค์šด ๊ณ„์ขŒ์— ๋Œ€ํ•ด, ํ‘œ์ค€ํ™”๋œ ์ˆ˜๋™ ์ฆ๋น™(๊ฑฐ๋ž˜ยท์ฆ๋น™ ์ถœ์ฒ˜ยท๊ธˆ์•กยท์ผ์ž) ์ž…๋ ฅ ์ ˆ์ฐจ๋กœ ๋ˆ„๋ฝ ์—†์ด ๊ฑฐ๋ž˜๋ฅผ ๊ธฐ๋กํ•  ์ˆ˜ ์žˆ๋‹ค. + - ๊ฐ ๊ณ„์ขŒ์˜ ๊ธฐ์ค€ ์ž”์•ก(`last_balance`)๊ณผ ๋ˆ„์  ๊ฑฐ๋ž˜๋กœ ์‚ฐ์ถœํ•œ ๊ณ„์‚ฐ ์ž”์•ก์„ ๋Œ€์กฐํ•˜์—ฌ ์ฐจ์ด๋ฅผ ์ •๋Ÿ‰์ ์œผ๋กœ ํ™•์ธํ•˜๊ณ , ํ—ˆ์šฉ ์˜ค์ฐจ๋ฅผ ๋ฒ—์–ด๋‚˜๋Š” ์ฐจ์ด๋Š” ํ™•์ธํ•„์š” ํ๋กœ ๋ผ์šฐํŒ…ํ•œ๋‹ค. + - ์ˆ˜๋™ ์ž…๋ ฅยท๋Œ€์กฐ ๊ณผ์ •์—์„œ ๋ฐœ์ƒํ•œ ๋ชจํ˜ธ ๊ฑฐ๋ž˜๋ฅผ ๊ณ„์ขŒ์ด๋™/์‹ค์ง€์ถœ ํŒ์ • ๊ธฐ์ค€์— ๋”ฐ๋ผ ์ผ๊ด€๋˜๊ฒŒ ๋ถ„๋ฅ˜ํ•˜์—ฌ ์›”๊ฐ„ ํ˜„๊ธˆํ๋ฆ„ยท์นดํ…Œ๊ณ ๋ฆฌ ์ง€์ถœ ๋ณด๊ณ ์„œ์˜ ์™œ๊ณก์„ ์ค„์ธ๋‹ค. +- ์ดํ•ด๊ด€๊ณ„์ž + - ๋ ›์ €์žฅ์ธ(์šด์˜์ž ๊ฒธ ์ž‘์„ฑ์ž) โ€” ์ˆ˜๋™ ์ฆ๋น™ ์ž…๋ ฅ๊ณผ ์ž”์•ก๋Œ€์กฐ ์‹คํ–‰ ์ฃผ์ฒด + - ๊ฒ€์ˆ˜๊ด€/๋ฆฌ๋ทฐ์–ด โ€” ๋Œ€์กฐ ๊ฒฐ๊ณผ์™€ ํŒ์ • ๊ทผ๊ฑฐ์˜ ๊ฒ€์ฆ ์ฃผ์ฒด + - StagePilot ํ›„์† SDLC(REQ/Batch) ์ˆ˜ํ–‰์ž โ€” ๋ณธ Discovery๋ฅผ ํ›„์† ์š”๊ตฌ์‚ฌํ•ญ์œผ๋กœ ์ •์ œ +- ์ œ์•ฝ์‚ฌํ•ญ + - ๊ฐœ์ธ ๊ธˆ์œต์ •๋ณด๋ฅผ ๋‹ค๋ฃจ๋ฏ€๋กœ ์ฆ๋น™ ์›๋ฌธยท๊ณ„์ขŒ๋ฒˆํ˜ธ๋Š” ๋งˆ์Šคํ‚น(`account_number_masked`)ยท์ตœ์†Œ ๋ณด๊ด€ ์›์น™์„ ๋”ฐ๋ฅธ๋‹ค. + - ์›๋ณธ ์šด์˜ DB ํ›ผ์†์„ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด dcy-002์—์„œ ์ •์˜ํ•œ ๋ฐฑ์—…ยท๊ฒ€์ฆ DB ์ ˆ์ฐจ๋ฅผ ์ „์ œ๋กœ ํ•œ๋‹ค. + - ์ˆ˜๋™ ์ž…๋ ฅ์€ ํœด๋จผ ์—๋Ÿฌ ๊ฐ€๋Šฅ์„ฑ์ด ๋†’์•„ ์ž…๋ ฅ ๊ฒ€์ฆ๊ณผ ์žฌ๋Œ€์กฐ ๊ฐ€๋Šฅ์„ฑ์ด ํ•„์š”ํ•˜๋‹ค. + +## ์ฃผ์š” ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค +์‚ฌ์šฉ์ž ๋˜๋Š” ์šด์˜์ž๊ฐ€ ์‹ค์ œ๋กœ ์–ด๋–ค ์ƒํ™ฉ์—์„œ ์ด ๋ณ€๊ฒฝ์„ ํ•„์š”๋กœ ํ•˜๋Š”์ง€ ๋Œ€ํ‘œ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ ๋Š”๋‹ค. + +- ์šด์˜์ž๊ฐ€ ๊ฑฐ๋ž˜๋‚ด์—ญ ํŒŒ์ผ์„ ์ œ๊ณตํ•˜์ง€ ์•Š๋Š” ๊ณ„์ขŒ(์˜ˆ: ์ผ๋ถ€ ํ˜„๊ธˆ ๊ณ„์ขŒ, ๋ชจ์ž„ ํ†ต์žฅ, ๋ช…์„ธ์„œ ๋น„๊ณต๊ฐœ ๊ณ„์ขŒ)์˜ ์›”๋ง ์ž”์•ก๋งŒ ์•Œ๊ณ  ์žˆ์„ ๋•Œ, ๋ˆ„๋ฝ ๊ฑฐ๋ž˜๋ฅผ ์ˆ˜๋™ ์ฆ๋น™์œผ๋กœ ์ž…๋ ฅํ•˜๊ณ  ๊ณ„์‚ฐ ์ž”์•ก์„ ๊ธฐ์ค€ ์ž”์•ก๊ณผ ๋งž์ถ˜๋‹ค. +- import๋Š” ๋˜์ง€๋งŒ ์ผ๋ถ€ ๊ธฐ๊ฐ„ ๋ฐ์ดํ„ฐ๊ฐ€ ๋น„์–ด ์žˆ๋Š” ๊ณ„์ขŒ์—์„œ, ๋น„์–ด ์žˆ๋Š” ๊ตฌ๊ฐ„์˜ ๊ฑฐ๋ž˜๋ฅผ ์ˆ˜๋™ ์ฆ๋น™์œผ๋กœ ๋ณด์™„ํ•œ ๋’ค ์ž”์•ก๋Œ€์กฐ๋กœ ์ •ํ•ฉ์„ฑ์„ ํ™•์ธํ•œ๋‹ค. +- ์ž”์•ก๋Œ€์กฐ ๊ฒฐ๊ณผ ์ฐจ์ด๊ฐ€ ํ—ˆ์šฉ ์˜ค์ฐจ๋ฅผ ์ดˆ๊ณผํ•˜์—ฌ ํ™•์ธํ•„์š” ํ์— ์˜ฌ๋ผ์˜จ ํ•ญ๋ชฉ์„, ์šด์˜์ž๊ฐ€ ๊ณ„์ขŒ์ด๋™(๋‹ค๋ฅธ ๋‚ด ๊ณ„์ขŒ๋กœ์˜ ์ด๋™)์ธ์ง€ ์‹ค์ง€์ถœ์ธ์ง€ ํŒ์ •ํ•˜์—ฌ ์ฒ˜๋ฆฌํ•œ๋‹ค. + +# 4. ์ด๋ฒˆ์— ์ •์˜ํ•  ๋ณ€๊ฒฝ +์ด๋ฒˆ Discovery์—์„œ ๋‹ค๋ฃจ๋Š” ํ•ต์‹ฌ ๋ณ€๊ฒฝ ํ•ญ๋ชฉ์„ ๋ชฉ๋ก์œผ๋กœ ์ ๋Š”๋‹ค. ๊ธฐ๋Šฅ, ๊ตฌ์กฐ, ๊ทœ์น™ ๋ณ€๊ฒฝ์„ ํ•จ๊ป˜ ํฌํ•จํ•ด๋„ ๋œ๋‹ค. + +- `accounts.statement_status`๋ฅผ ์ถ”๊ฐ€ํ•ด `manual_only`๋ฅผ statement-unavailable ๊ณ„์ขŒ๋กœ ์ •์˜ํ•˜๊ณ , `partial_import`๋„ ์ˆ˜๋™ ๋ณด์™„/์ž”์•ก๋Œ€์กฐ ๋Œ€์ƒ์œผ๋กœ ํฌํ•จํ•œ๋‹ค. +- statement-unavailable ๊ณ„์ขŒ์— ๋Œ€ํ•œ ์ˆ˜๋™ ์ฆ๋น™ ์ž…๋ ฅ ํ‘œ์ค€(ํ•„์ˆ˜ ํ•„๋“œ: ์ผ์ž, ๊ธˆ์•ก, ์ถœ์ฒ˜/์ฆ๋น™ ์œ ํ˜•, ๋ฉ”๋ชจ, source=manual)๊ณผ ์ž…๋ ฅ ๊ฒ€์ฆ ๊ทœ์น™์„ ์ •์˜ํ•œ๋‹ค. +- ๊ณ„์ขŒ๋ณ„ ๊ธฐ์ค€ ์ž”์•ก(`last_balance`) ๋Œ€๋น„ ๋ˆ„์  ๊ฑฐ๋ž˜ ๊ณ„์‚ฐ ์ž”์•ก์„ ๋น„๊ตํ•˜๋Š” ์ž”์•ก๋Œ€์กฐ ์ ˆ์ฐจ๋ฅผ ์ •์˜ํ•œ๋‹ค. ์ •๊ธฐ ๊ธฐ์ค€ ์‹œ์ ์€ ๋งค์›” ๋ง์ผ 23:59 KST, ์ˆ˜์‹œ ๊ธฐ์ค€ ์‹œ์ ์€ ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์Šค๋ƒ…์ƒท ์‹œ๊ฐ์ด๋ฉฐ, ์ดˆ๊ธฐ ํ—ˆ์šฉ ์˜ค์ฐจ๋Š” 1,000์›์œผ๋กœ ํ•œ๋‹ค. +- ํ™•์ธํ•„์š” ํ ํ•ญ๋ชฉ์— ๋Œ€ํ•œ ๊ณ„์ขŒ์ด๋™/์‹ค์ง€์ถœ/์‹ค์ˆ˜์ž…/ํˆฌ์ž์ด๋™/๋ถ„์„์ œ์™ธ/ํ™•์ธํ•„์š” ํŒ์ • ๊ธฐ์ค€์„ ๋ฌธ์„œํ™”ํ•œ๋‹ค. ์ž๋™ ํ™•์ •์€ ์ œํ•œํ•˜๊ณ  ์ถ”์ฒœ/ํ›„๋ณด ์ œ์‹œ ํ›„ ์‚ฌ์šฉ์ž ์ˆ˜๋™ ํ™•์ •์„ ๊ธฐ๋ณธ์œผ๋กœ ํ•œ๋‹ค. +- ์ˆ˜๋™ ์ฆ๋น™ยท์ž”์•ก๋Œ€์กฐ ์šด์˜ ์ ˆ์ฐจ๋ฅผ `docs/runtime-flows.md`(์‹ ๊ทœ ํ๋ฆ„)์™€ ์‹ ๊ทœ runbook ํ›„๋ณด(`docs/runbooks/`)์— ๋ฐ˜์˜ํ•  ํ•„์š”๋ฅผ ๋ช…์‹œํ•œ๋‹ค. + +## ์˜ํ–ฅ ๋ฒ”์œ„ +๋ณ€๊ฒฝ์ด ๋ฏธ์น  ์ฝ”๋“œ/๋ฌธ์„œ/์šด์˜ ๋ฒ”์œ„๋ฅผ ๋ช…์‹œํ•ด REQ ์ •์ œ์™€ batch planning์—์„œ ์ถ”์ • ๋ˆ„๋ฝ์„ ์ค„์ธ๋‹ค. + +- ์ฝ”๋“œ ์˜ํ–ฅ: + - `jarca_ledger_mcp/service.py` โ€” ์ˆ˜๋™ ์ฆ๋น™ ์ž…๋ ฅ/์ž”์•ก๋Œ€์กฐ/ํ™•์ธํ•„์š” ๋ผ์šฐํŒ… ์„œ๋น„์Šค ๋กœ์ง ์‹ ์„ค ๋˜๋Š” ๊ธฐ์กด transferยทworkbench ๋กœ์ง ํ™•์žฅ + - `jarca_ledger_mcp/server.py` โ€” ๋Œ€์กฐยท์ฆ๋น™ ๊ด€๋ จ MCP tool ๋…ธ์ถœ (ํ•„์š” ์‹œ) + - `dashboard/app.py` โ€” ๊ณ„์ขŒ ํ™”๋ฉด์˜ `statement_status` ํ‘œ์‹œ/์ˆ˜์ •, ์ตœ์†Œ ์ž”์•ก๋Œ€์กฐ ๊ฒฐ๊ณผ ํ‘œ์‹œ. ์ „์šฉ ์ž”์•ก๋Œ€์กฐ ์›Œํฌ๋ฒค์น˜ ์ „์ฒด ์‹ ์„ค์€ ํ›„์† ๋ฒ”์œ„๋กœ ๋ถ„๋ฆฌ + - `jarca_ledger_mcp/db.py` โ€” `accounts.statement_status` ์ถ”๊ฐ€ ๋ฐ ๋Œ€์กฐ ๋ฉ”ํƒ€(๊ธฐ์ค€ ์ž”์•ก ์Šค๋ƒ…์ƒท, ๋Œ€์กฐ ์‹œ๊ฐ ๋“ฑ) ๋ณด๊ฐ• ํ•„์š” ์—ฌ๋ถ€ ๊ฒ€ํ†  +- ๋ฌธ์„œ ์˜ํ–ฅ: + - `docs/runtime-flows.md` โ€” statement-unavailable ์ˆ˜๋™ ์ฆ๋น™ยท์ž”์•ก๋Œ€์กฐ ํ๋ฆ„ ์ถ”๊ฐ€ + - `docs/runbooks/` โ€” ์ˆ˜๋™ ์ฆ๋น™ยท์ž”์•ก๋Œ€์กฐ ์šด์˜ runbook ์‹ ๊ทœ ํ›„๋ณด + - `docs/data-model.md` โ€” ๋Œ€์กฐ ๊ด€๋ จ ํ•„๋“œ ์˜๋ฏธ ๋ณด๊ฐ• ํ•„์š” ์—ฌ๋ถ€ ๊ฒ€ํ†  +- ์šด์˜ ์˜ํ–ฅ: + - ์›”๋ง/์ •๊ธฐ ์ž”์•ก๋Œ€์กฐ ์šด์˜ ๋ฃจํ‹ด์ด ์‹ ์„ค๋˜๋ฉฐ, ๋Œ€์กฐ ์ „ dcy-002 ๋ฐฑ์—… ์ ˆ์ฐจ ์„ ํ–‰์ด ์š”๊ตฌ๋œ๋‹ค. + +# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก +์‹ค์ œ๋กœ ๊ตฌํ˜„๊ณผ ๊ฒ€์ฆ์œผ๋กœ ์ด์–ด์งˆ ์š”๊ตฌ์‚ฌํ•ญ์„ ๊ธฐ๋Šฅ/๋น„๊ธฐ๋Šฅ์œผ๋กœ ๋‚˜๋ˆ„์–ด ์ •๋ฆฌํ•˜๊ณ , ๋ฒ”์œ„ ๊ฒฝ๊ณ„๋ฅผ ๋ช…ํ™•ํžˆ ์ ๋Š”๋‹ค. + +## ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +- FR-1: `accounts.statement_status` ํ•„๋“œ๋ฅผ `unknown`, `import_supported`, `manual_only`, `partial_import` ๊ฐ’์œผ๋กœ ๊ด€๋ฆฌํ•˜๊ณ , `manual_only` ๊ณ„์ขŒ๋ฅผ statement-unavailable ๊ณ„์ขŒ๋กœ ์ •์˜ํ•ด์•ผ ํ•œ๋‹ค. `partial_import` ๊ณ„์ขŒ๋Š” ์ˆ˜๋™ ๋ณด์™„ ๋ฐ ์ž”์•ก๋Œ€์กฐ ๋Œ€์ƒ์œผ๋กœ ํฌํ•จํ•œ๋‹ค. +- FR-1a: statement-unavailable ๊ณ„์ขŒ์— ๋Œ€ํ•ด ์ผ์žยท๊ธˆ์•กยท์ฆ๋น™์œ ํ˜•ยท๋ฉ”๋ชจ๋ฅผ ๊ฐ–์ถ˜ ์ˆ˜๋™ ์ฆ๋น™ ๊ฑฐ๋ž˜๋ฅผ `source='manual'`๋กœ ์ž…๋ ฅยท์ˆ˜์ •ํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•˜๋ฉฐ, ํ•„์ˆ˜ ํ•„๋“œ ๋ˆ„๋ฝ ์‹œ ์ž…๋ ฅ์„ ๊ฑฐ๋ถ€ํ•ด์•ผ ํ•œ๋‹ค. +- FR-2: ๊ณ„์ขŒ๋ณ„ ๊ธฐ์ค€ ์ž”์•ก(`last_balance`)๊ณผ ๋ˆ„์  ๊ฑฐ๋ž˜ ๊ธฐ๋ฐ˜ ๊ณ„์‚ฐ ์ž”์•ก์„ ๋น„๊ตํ•˜์—ฌ ์ฐจ์•ก๊ณผ ๋Œ€์กฐ ์‹œ์ ์„ ์‚ฐ์ถœํ•˜๋Š” ์ž”์•ก๋Œ€์กฐ ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ด์•ผ ํ•œ๋‹ค. ์ •๊ธฐ ๋Œ€์กฐ ๊ธฐ์ค€ ์‹œ์ ์€ ๋งค์›” ๋ง์ผ 23:59 KST์ด๊ณ , ์ˆ˜์‹œ ๋Œ€์กฐ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ์Šค๋ƒ…์ƒท ์‹œ๊ฐ์„ ๊ธฐ์ค€์œผ๋กœ ํ•œ๋‹ค. +- FR-3: ์ž”์•ก๋Œ€์กฐ ์ฐจ์•ก์ด ์ดˆ๊ธฐ ํ—ˆ์šฉ ์˜ค์ฐจ 1,000์›์„ ์ดˆ๊ณผํ•˜๋ฉด ํ•ด๋‹น ํ•ญ๋ชฉ์„ ํ™•์ธํ•„์š” ํ๋กœ ๋ผ์šฐํŒ…ํ•˜๊ณ , ์šด์˜์ž๊ฐ€ ๊ณ„์ขŒ์ด๋™/์‹ค์ง€์ถœ/๋ถ„์„์ œ์™ธ๋กœ ํŒ์ •ํ•ด ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. ๊ธฐ์ค€ ์ž”์•ก ๋Œ€๋น„ 0.1% ๋น„์œจ์€ ์ฐธ๊ณ ๊ฐ’์œผ๋กœ๋งŒ ํ‘œ์‹œํ•˜๊ณ  1์ฐจ ํŒ์ • ๊ธฐ์ค€์—๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค. +- FR-4: ํ™•์ธํ•„์š” ํ•ญ๋ชฉ์€ ๊ณ„์ขŒ์ด๋™, ์‹ค์ง€์ถœ, ์‹ค์ˆ˜์ž…, ํˆฌ์ž์ด๋™, ๋ถ„์„์ œ์™ธ, ํ™•์ธํ•„์š” 6๊ฐœ ํŒ์ •๊ฐ’์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. ์ž๋™ ํ™•์ •๋ณด๋‹ค๋Š” ํ›„๋ณด ์ถ”์ฒœ๊ณผ ์‚ฌ์šฉ์ž ์ˆ˜๋™ ํ™•์ •์„ ๊ธฐ๋ณธ์œผ๋กœ ํ•˜๋ฉฐ, ํ™•์ • ๊ฒฐ๊ณผ๋Š” `cashflow_kind`์— ๋ฐ˜์˜๋˜์–ด ์›”๊ฐ„ ํ˜„๊ธˆํ๋ฆ„ยท์นดํ…Œ๊ณ ๋ฆฌ ์ง€์ถœ ๋ณด๊ณ ์„œ์— ์ผ๊ด€๋˜๊ฒŒ ์ ์šฉ๋˜์–ด์•ผ ํ•œ๋‹ค. +- FR-5: ์ˆ˜๋™ ์ฆ๋น™ยท์ž”์•ก๋Œ€์กฐ ์šด์˜ ํ๋ฆ„์„ `docs/runtime-flows.md`์— ๋ฐ˜์˜ํ•˜๊ณ , ์šด์˜ ์ ˆ์ฐจ runbook์„ `docs/runbooks/` ํ›„๋ณด๋กœ ์ถ”๊ฐ€ํ•ด์•ผ ํ•œ๋‹ค. + +## ๋น„๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +์„ฑ๋Šฅ, ์šด์˜์„ฑ, ์ผ๊ด€์„ฑ, ์ž๋™ํ™” ์•ˆ์ •์„ฑ ๋“ฑ ๊ธฐ๋Šฅ ์™ธ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ ๋Š”๋‹ค. + +- NFR-1: ๋ชจ๋“  ๋Œ€์กฐยท์ฆ๋น™ ์ž‘์—…์€ dcy-002์˜ ๋ฐฑ์—…ยท๊ฒ€์ฆ DB ์ ˆ์ฐจ๋ฅผ ์ „์ œ๋กœ ์ˆ˜ํ–‰๋˜์–ด ์›๋ณธ ์šด์˜ DB ํ›ผ์† ์œ„ํ—˜์„ ๋‚ฎ์ถฐ์•ผ ํ•œ๋‹ค. +- NFR-2: ์ฆ๋น™ ์›๋ฌธยท๊ณ„์ขŒ ์‹๋ณ„์ •๋ณด๋Š” ๋งˆ์Šคํ‚นยท์ตœ์†Œ ๋ณด๊ด€ ์›์น™์„ ๋”ฐ๋ฅด๊ณ , ๋ฌธ์„œยท๋กœ๊ทธ์— ํ‰๋ฌธ ๊ณ„์ขŒ๋ฒˆํ˜ธ/์ž๊ฒฉ์ฆ๋ช…์„ ๋‚จ๊ธฐ์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. +- NFR-3: ์ž”์•ก๋Œ€์กฐ ๊ฒฐ๊ณผ๋Š” ์žฌํ˜„ ๊ฐ€๋Šฅํ•ด์•ผ ํ•˜๋ฉฐ, ๋™์ผ ์ž…๋ ฅ์— ๋Œ€ํ•ด ๋™์ผํ•œ ์ฐจ์•กยทํ™•์ธํ•„์š” ๋ถ„๋ฅ˜ ๊ฒฐ๊ณผ๋ฅผ ์‚ฐ์ถœํ•ด์•ผ ํ•œ๋‹ค. + +## ๋ฒ”์œ„ ๊ฒฝ๊ณ„ +์ด๋ฒˆ ๋ฐ˜๋ณต์—์„œ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•  ๋ฒ”์œ„์™€ ๋ช…์‹œ์ ์œผ๋กœ ์ œ์™ธํ•  ๋ฒ”์œ„๋ฅผ ๊ตฌ๋ถ„ํ•œ๋‹ค. + +- In Scope + - `accounts.statement_status` ๊ธฐ๋ฐ˜ statement-unavailable ๊ณ„์ขŒ ๋ถ„๋ฅ˜์™€ ์ˆ˜๋™ ์ฆ๋น™ ์ž…๋ ฅยท๊ฒ€์ฆ ๊ทœ์น™ ์ •์˜ + - ๊ธฐ์ค€ ์ž”์•ก ๋Œ€๋น„ ๊ณ„์‚ฐ ์ž”์•ก ๋Œ€์กฐ, ์›”๋ง/์ˆ˜์‹œ ๊ธฐ์ค€ ์‹œ์ , 1,000์› ํ—ˆ์šฉ ์˜ค์ฐจ ๋ฐ ํ™•์ธํ•„์š” ๋ผ์šฐํŒ… ๊ทœ์น™ ์ •์˜ + - ๊ณ„์ขŒ์ด๋™/์‹ค์ง€์ถœ ํŒ์ • ๊ธฐ์ค€์˜ ๋ฌธ์„œํ™” +- Out of Scope + - statement ํŒŒ์ผ ๊ธฐ๋ฐ˜ ์ž๋™ import ํŒŒ์„œ ์‹ ๊ทœ ์ถ”๊ฐ€(๊ธฐ์กด KB/Toss/SeoulPay ๊ฒฝ๋กœ ๋ณ€๊ฒฝ ์—†์Œ) + - ์ž”์•ก๋Œ€์กฐ ์ž๋™ ์Šค์ผ€์ค„๋ง/์•Œ๋ฆผ. ์ด๋ฒˆ ๋ฐ˜๋ณต์€ ์ˆ˜๋™ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์ž”์•ก๋Œ€์กฐ์™€ ๊ฒฐ๊ณผ ๋ฆฌํฌํŠธ๊นŒ์ง€๋งŒ ํฌํ•จํ•œ๋‹ค. + - ์ „์šฉ ์ž”์•ก๋Œ€์กฐ ์›Œํฌ๋ฒค์น˜ ์ „์ฒด ์‹ ์„ค. ์ด๋ฒˆ ๋ฐ˜๋ณต์€ ๊ณ„์ขŒ ํ™”๋ฉด์˜ `statement_status` ํ‘œ์‹œ/์ˆ˜์ •๊ณผ ์ตœ์†Œ ์ž”์•ก๋Œ€์กฐ ๊ฒฐ๊ณผ ํ‘œ์‹œ๊นŒ์ง€๋งŒ ํฌํ•จํ•œ๋‹ค. + +# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก +๋ถˆํ™•์‹ค์„ฑ๊ณผ ์ „์ œ ์กฐ๊ฑด์„ ๋ถ„๋ฆฌํ•ด์„œ ์ ๋Š”๋‹ค. ๋ฆฌ์Šคํฌ๋Š” ์˜ํ–ฅ๊ณผ ์™„ํ™” ๋ฐฉ์•ˆ์„, ๊ฐ€์ •์€ ํ˜„์žฌ ํŒ๋‹จ์˜ ์ „์ œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. + +## ๋ฆฌ์Šคํฌ +- R-1 (High): ์ˆ˜๋™ ์ž…๋ ฅ ํœด๋จผ ์—๋Ÿฌ๋กœ ๊ฑฐ๋ž˜๊ฐ€ ์ค‘๋ณตยท๋ˆ„๋ฝ๋˜์–ด ์ž”์•ก๋Œ€์กฐ ์ฐจ์•ก์ด ์‹ค์ œ ์˜ค๋ฅ˜๋ฅผ ๊ฐ€๋ฆด ์ˆ˜ ์žˆ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ํ•„์ˆ˜ ํ•„๋“œ ๊ฒ€์ฆ, ์ž…๋ ฅ ํ›„ ์žฌ๋Œ€์กฐ, ์ฐจ์•ก ๋ณ€ํ™” ๋กœ๊ทธ๋กœ ์ถ”์  ๊ฐ€๋Šฅ์„ฑ ํ™•๋ณด +- R-2 (Medium): ๊ณ„์ขŒ์ด๋™/์‹ค์ง€์ถœ ํŒ์ • ๊ธฐ์ค€์ด ๋ชจํ˜ธํ•˜๋ฉด ๋™์ผ ๊ฑฐ๋ž˜๊ฐ€ ์ž‘์—…์ž๋งˆ๋‹ค ๋‹ค๋ฅด๊ฒŒ ๋ถ„๋ฅ˜๋˜์–ด ๋ณด๊ณ ์„œ ์ผ๊ด€์„ฑ์ด ๊นจ์ง„๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ์ž๊ธฐ๊ณ„์ขŒ ๊ฐ„ ์ด๋™ยท์™ธ๋ถ€ ์ง€์ถœยท๋ถ„์„์ œ์™ธ์— ๋Œ€ํ•œ ๋ช…์‹œ์  ํŒ์ •ํ‘œ ์ •์˜ ๋ฐ ๊ธฐ์กด `infer_cashflow_kind`/transfer ๋งค์นญ๊ณผ ์ •ํ•ฉ +- R-3 (Medium): ํ—ˆ์šฉ ์˜ค์ฐจ ์ž„๊ณ„๊ฐ’์ด ๋ถ€์ ์ ˆํ•˜๋ฉด ํ™•์ธํ•„์š” ํ๊ฐ€ ๊ณผ๋„ํ•˜๊ฒŒ ์ปค์ง€๊ฑฐ๋‚˜ ์‹ค์ œ ์ฐจ์ด๋ฅผ ๋†“์นœ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ์ดˆ๊ธฐ ์ž„๊ณ„๊ฐ’์„ ๋ณด์ˆ˜์ ์œผ๋กœ ์„ค์ •ํ•˜๊ณ  ์šด์˜ ๋ฐ์ดํ„ฐ๋กœ ์กฐ์ •, ์ž„๊ณ„๊ฐ’์„ ์„ค์ •๊ฐ’์œผ๋กœ ์™ธ๋ถ€ํ™” + +## ๊ฐ€์ • +์•„์ง ํ™•์ •๋˜์ง€ ์•Š์•˜์ง€๋งŒ ํ˜„์žฌ ๋ฌธ์„œ ์ž‘์„ฑ์˜ ๊ธฐ๋ฐ˜์ด ๋˜๋Š” ์ „์ œ๋ฅผ ์ ๋Š”๋‹ค. + +- A-1: `ledger_accounts.last_balance`, `transactions.balance_after`/`source`, ์‹ ๊ทœ `accounts.statement_status` ํ•„๋“œ๋ฅผ ์ž”์•ก๋Œ€์กฐ์˜ ๊ธฐ์ค€ ๋ฐ์ดํ„ฐ๋กœ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. +- A-2: ๊ธฐ์กด transfer ๋งค์นญ(`link_self_transfer_pair`, cross-account match)๊ณผ ํ™•์ธํ•„์š” review ํ ๊ตฌ์กฐ๋ฅผ ๊ณ„์ขŒ์ด๋™ ํŒ์ • ํ๋ฆ„์— ์—ฐ๊ณ„ํ•  ์ˆ˜ ์žˆ๋‹ค. +- A-3: ๋Œ€์กฐ ์ž‘์—…์€ dcy-002 ๋ฐฑ์—…ยท๊ฒ€์ฆ DB ์ ˆ์ฐจ๊ฐ€ ์ด๋ฏธ ์šด์˜ ๊ฐ€๋Šฅํ•œ ์ƒํƒœ์ž„์„ ์ „์ œ๋กœ ํ•œ๋‹ค. + +## ์˜คํ”ˆ ์งˆ๋ฌธ +๋‹ต์„ ์•„์ง ๋ชจ๋ฅด๋Š” ํ•ญ๋ชฉ์„ ์ ๋Š”๋‹ค. ์ถ”์ •์œผ๋กœ ๋ฎ์ง€ ๋ง๊ณ  REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ด์ „์— ํ™•์ธํ•  ์งˆ๋ฌธ์œผ๋กœ ๋‚จ๊ธด๋‹ค. + +- OQ-1: ์ž”์•ก๋Œ€์กฐ ํ—ˆ์šฉ ์˜ค์ฐจ(์ ˆ๋Œ€ ๊ธˆ์•ก/๋น„์œจ)์™€ ๊ธฐ์ค€ ์‹œ์ (์›”๋ง/์ž„์˜ ์Šค๋ƒ…์ƒท)์„ ์–ด๋–ป๊ฒŒ ์ •์˜ํ•  ๊ฒƒ์ธ๊ฐ€? + - ์ƒํƒœ: Answered + - ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ: ์ •๊ธฐ ์ž”์•ก๋Œ€์กฐ๋Š” ๋งค์›” ๋ง์ผ 23:59 KST ๊ธฐ์ค€์œผ๋กœ ์ˆ˜ํ–‰ํ•œ๋‹ค. ์ˆ˜์‹œ ๋Œ€์กฐ๋Š” ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ ์Šค๋ƒ…์ƒท ์‹œ๊ฐ์„ ๊ธฐ์ค€์œผ๋กœ ํ—ˆ์šฉํ•œ๋‹ค. ์ดˆ๊ธฐ ํ—ˆ์šฉ ์˜ค์ฐจ๋Š” ์ ˆ๋Œ€ ๊ธˆ์•ก 1,000์›์œผ๋กœ ๋‘๋ฉฐ, ์ฐจ์•ก์ด 1,000์›์„ ์ดˆ๊ณผํ•˜๋ฉด ํ™•์ธํ•„์š” ํ๋กœ ๋ผ์šฐํŒ…ํ•œ๋‹ค. ๊ธฐ์ค€ ์ž”์•ก ๋Œ€๋น„ 0.1% ๋น„์œจ์€ ํ‘œ์‹œ/์ฐธ๊ณ ๊ฐ’์œผ๋กœ๋งŒ ์ œ๊ณตํ•˜๊ณ , 1์ฐจ ํŒ์ • ๊ธฐ์ค€์œผ๋กœ๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค. + - ์ข…๋ฃŒ ์กฐ๊ฑด: REQ์˜ acceptance criteria์— ๊ธฐ์ค€ ์‹œ์ , ํ—ˆ์šฉ ์˜ค์ฐจ 1,000์›, ํ™•์ธํ•„์š” ๋ผ์šฐํŒ… ์กฐ๊ฑด์ด ์ˆ˜์น˜๋กœ ๋ฐ˜์˜๋˜๋ฉด Answered +- OQ-2: ์–ด๋–ค ๊ณ„์ขŒ๋ฅผ "statement-unavailable"๋กœ ๋ถ„๋ฅ˜ยทํ‘œ์‹œํ•  ๊ฒƒ์ธ๊ฐ€(๊ณ„์ขŒ ์†์„ฑ ์‹ ์„ค ์—ฌ๋ถ€)? + - ์ƒํƒœ: Answered + - ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ: `accounts` ํ…Œ์ด๋ธ”์— `statement_status` ํ•„๋“œ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. ํ—ˆ์šฉ๊ฐ’์€ `unknown`, `import_supported`, `manual_only`, `partial_import`๋กœ ์ •์˜ํ•œ๋‹ค. ๊ธฐ๋ณธ๊ฐ’์€ `unknown`์ด๋‹ค. `manual_only` ๊ณ„์ขŒ๋ฅผ dcy-003์˜ statement-unavailable ๊ณ„์ขŒ๋กœ ๊ฐ„์ฃผํ•˜๊ณ , `partial_import` ๊ณ„์ขŒ๋Š” ์ˆ˜๋™ ๋ณด์™„ ๋ฐ ์ž”์•ก๋Œ€์กฐ ๋Œ€์ƒ์œผ๋กœ ํฌํ•จํ•œ๋‹ค. + - ์ข…๋ฃŒ ์กฐ๊ฑด: REQ์˜ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ/acceptance criteria์— `statement_status` ํ•„๋“œ, ํ—ˆ์šฉ๊ฐ’, ๊ธฐ๋ณธ๊ฐ’, ์ž”์•ก๋Œ€์กฐ ๋Œ€์ƒ ์กฐ๊ฑด์ด ๋ฐ˜์˜๋˜๋ฉด Answered + +# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€ +์ด ๋ณ€๊ฒฝ์ด ์„ฑ๊ณตํ–ˆ๋‹ค๊ณ  ํŒ๋‹จํ•  ๊ธฐ์ค€์„ ์ธก์ • ๊ฐ€๋Šฅํ•˜๊ฒŒ ์ •์˜ํ•œ๋‹ค. ๊ฐ€๋Šฅํ•˜๋ฉด ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜๊นŒ์ง€ ์—ฐ๊ฒฐํ•œ๋‹ค. + +- S-1: `statement_status=manual_only` ๋˜๋Š” `partial_import`์ธ ๋Œ€์ƒ ๊ณ„์ขŒ์— ๋Œ€ํ•ด ์ˆ˜๋™ ์ฆ๋น™ ์ž…๋ ฅ ํ›„ ๊ณ„์‚ฐ ์ž”์•ก๊ณผ ๊ธฐ์ค€ ์ž”์•ก์˜ ์ฐจ์•ก์ด 1,000์› ์ด๋‚ด๋กœ ์ˆ˜๋ ดํ•œ๋‹ค. +- S-2: ์ž”์•ก๋Œ€์กฐ ์ฐจ์•ก์ด 1,000์›์„ ์ดˆ๊ณผํ•˜๋Š” ๋ชจ๋“  ํ•ญ๋ชฉ์ด ํ™•์ธํ•„์š” ํ์— ๋ผ์šฐํŒ…๋˜๊ณ , ๊ณ„์ขŒ์ด๋™/์‹ค์ง€์ถœ/๋ถ„์„์ œ์™ธ ์ค‘ ํ•˜๋‚˜๋กœ ํŒ์ •ยท์ฒ˜๋ฆฌ๋œ๋‹ค. +- S-3: ์ˆ˜๋™ ์ฆ๋น™ยท์ž”์•ก๋Œ€์กฐ ์šด์˜ ํ๋ฆ„์ด `docs/runtime-flows.md`์™€ runbook ํ›„๋ณด ๋ฌธ์„œ๋กœ ์ •๋ฆฌ๋˜์–ด ํ›„์† REQ/Batch์—์„œ ์ฐธ์กฐ ๊ฐ€๋Šฅํ•˜๋‹ค. + +## ์ธก์ • ๋ฐฉ์‹ ๋ฐ ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ +๊ฐ ์„ฑ๊ณต ๊ธฐ์ค€์„ ์–ด๋–ป๊ฒŒ ์ธก์ •ํ• ์ง€์™€ ์–ด๋–ค ๋ฐ์ดํ„ฐ๋‚˜ ๋ฌธ์„œ๋ฅผ ๊ทผ๊ฑฐ๋กœ ์“ธ์ง€ ์ ๋Š”๋‹ค. + +- S-1 ์ธก์ • ๋ฐฉ์‹: ๊ฒ€์ฆ DB์—์„œ `statement_status in (manual_only, partial_import)` ๊ณ„์ขŒ๋ณ„ (๊ณ„์‚ฐ ์ž”์•ก โˆ’ ๊ธฐ์ค€ ์ž”์•ก) ์ฐจ์•ก ์‚ฐ์ถœ ๋ฐ 1,000์› ์ด๋‚ด ์—ฌ๋ถ€ ํ™•์ธ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: `accounts.statement_status`, `ledger_accounts.last_balance`, `transactions`(`amount`/`balance_after`) +- S-2 ์ธก์ • ๋ฐฉ์‹: ํ™•์ธํ•„์š” ํ ์ ์žฌ ๊ฑด์ˆ˜์™€ ๋ฏธํŒ์ • ์ž”์—ฌ ๊ฑด์ˆ˜ ๋น„๊ต / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: ํ™•์ธํ•„์š” ํ(์„œ๋น„์Šค ๋กœ์ง ๊ฒฐ๊ณผ), ํŒ์ • ์ฒ˜๋ฆฌ ๋กœ๊ทธ +- S-3 ์ธก์ • ๋ฐฉ์‹: ๋ฌธ์„œ ์กด์žฌ ๋ฐ ํ๋ฆ„ ๋ฐ˜์˜ ์—ฌ๋ถ€ ๋ฆฌ๋ทฐ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: `docs/runtime-flows.md`, `docs/runbooks/` + +# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ๋‹จ๊ณ„๋กœ ๋„˜๊ธฐ๊ธฐ ์ „์— ๋ฌด์—‡์ด ํ™•์ •๋˜์–ด์•ผ ํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค. ๋ฏธ์™„๋ฃŒ ํ•ญ๋ชฉ์€ ํ›„์† ๊ฒฐ์ •์ด ํ•„์š”ํ•จ์„ ๋œปํ•œ๋‹ค. + +- [x] ๋ฒ”์œ„(In Scope/Out of Scope) ํ™•์ • + - ๊ธฐ์ค€: ์ž๋™ ์Šค์ผ€์ค„๋ง/์•Œ๋ฆผ๊ณผ ์ „์šฉ ์ž”์•ก๋Œ€์กฐ ์›Œํฌ๋ฒค์น˜ ์ „์ฒด ์‹ ์„ค์€ ์ œ์™ธํ•˜๊ณ , ์ˆ˜๋™ ์‹คํ–‰ ์ž”์•ก๋Œ€์กฐยท๊ฒฐ๊ณผ ๋ฆฌํฌํŠธยท๊ณ„์ขŒ ํ™”๋ฉด์˜ `statement_status` ํ‘œ์‹œ/์ˆ˜์ •์€ ํฌํ•จํ•œ๋‹ค. +- [x] ์„ฑ๊ณต ์ง€ํ‘œ์˜ ์ธก์ • ๋ฐฉ์‹/๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ ํ™•์ • + - ๊ธฐ์ค€: ํ—ˆ์šฉ ์˜ค์ฐจ 1,000์›, ์›”๋ง 23:59 KST ์ •๊ธฐ ๊ธฐ์ค€ ์‹œ์ , ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์ˆ˜์‹œ ์Šค๋ƒ…์ƒท ๊ธฐ์ค€ ์‹œ์ ์ด ์ธก์ • ๋ฐฉ์‹์— ๋ฐ˜์˜๋จ +- [x] High ์˜ํ–ฅ ๋ฆฌ์Šคํฌ ์šฐ์„ ์ˆœ์œ„ ๋ฐ ํ•ด์†Œ ์ฑ…์ž„์ž ์ง€์ • + - ๊ธฐ์ค€: R-1(์ˆ˜๋™ ์ž…๋ ฅ ํœด๋จผ ์—๋Ÿฌ)์€ ๋ ›์ €์žฅ์ธ์ด ํ•„์ˆ˜ ํ•„๋“œ ๊ฒ€์ฆยท์žฌ๋Œ€์กฐยท๊ฒ€์ฆ DB evidence๋กœ ํ•ด์†Œํ•œ๋‹ค. +- [x] ๋ชจ๋“  ์˜คํ”ˆ ์งˆ๋ฌธ(OQ) `Open` ์ƒํƒœ ์—†์Œ ํ™•์ธ + - ๊ธฐ์ค€: OQ ๋ชฉ๋ก์— `Open` ์ƒํƒœ ํ•ญ๋ชฉ์ด 0๊ฐœ์ด๊ฑฐ๋‚˜, ์ž”์—ฌ ํ•ญ๋ชฉ ์ „๋ถ€ `Deferred` ์ฒ˜๋ฆฌ ์™„๋ฃŒ +- [x] Freeze ํ™•์ • ๋‹ด๋‹น์ž(CONFIRMED_BY) ์ง€์ • + - ๊ธฐ์ค€: ์‚ฌ์šฉ์ž ์ง„ํ™˜ ๊ณ ๊ฐ€ ๋ณธ Discovery ๊ถŒ์žฅ์•ˆ์„ ์Šน์ธํ•˜๊ณ  ๊ตฌํ˜„ ์œ„์ž„์„ ์ง€์‹œํ–ˆ๋‹ค. + +# 9. ํŒŒ์ผ ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ +์ด Discovery ๋ฌธ์„œ ์ƒ์„ฑ/๊ฐฑ์‹  ๊ฒฐ๊ณผ์™€ ์ฐธ์กฐ ๋ฌธ์„œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ์ดํ›„ ์ถ”์ ๊ณผ ๋ฆฌ๋ทฐ ์‹œ ๊ทผ๊ฑฐ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + +- ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ: ์ƒ์„ฑ +- ์ƒ์„ฑ ๊ฒฝ๋กœ: docs/discovery/dcy-003_20260629_statement-unavailable-manual-evidence-balance-reconciliation.md +- ์ฐธ์กฐ ๋ฌธ์„œ: + - docs/discovery/dcy-002_20260627_operational-safety-backup-restore-validation-db.md + - docs/runtime-flows.md + - docs/data-model.md + +# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ +์‚ฌ์šฉ์ž๋‚˜ ์Šน์ธ์ž๊ฐ€ ๊ฒฐ์ •ํ•ด์•ผ ํ•˜๋Š” ํ•ญ๋ชฉ๋งŒ ๋ชจ์•„ ์ •๋ฆฌํ•œ๋‹ค. ๊ฒฐ์ •, ํ™•์ธ, ๋ฐ์ดํ„ฐ ์š”์ฒญ์„ ๊ตฌ๋ถ„ํ•ด์„œ ์ ๋Š”๋‹ค. + +## DECIDE +- ์ž”์•ก๋Œ€์กฐ ์ž๋™ ์Šค์ผ€์ค„๋ง/์•Œ๋ฆผ์€ ์ด๋ฒˆ ๋ฐ˜๋ณต ๋ฒ”์œ„์—์„œ ์ œ์™ธํ•˜๊ณ , ์ˆ˜๋™ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์ž”์•ก๋Œ€์กฐ์™€ ๊ฒฐ๊ณผ ๋ฆฌํฌํŠธ๊นŒ์ง€๋งŒ ํฌํ•จํ•œ๋‹ค. +- ์ „์šฉ ์ˆ˜๋™ ์ฆ๋น™/์ž”์•ก๋Œ€์กฐ ์›Œํฌ๋ฒค์น˜ ์ „์ฒด ์‹ ์„ค์€ ์ œ์™ธํ•˜๊ณ , ๊ณ„์ขŒ ํ™”๋ฉด์˜ `statement_status` ํ‘œ์‹œ/์ˆ˜์ •๊ณผ ์ตœ์†Œ ์ž”์•ก๋Œ€์กฐ ๊ฒฐ๊ณผ ํ‘œ์‹œ๊นŒ์ง€๋งŒ ํฌํ•จํ•œ๋‹ค. +- ๊ณ„์ขŒ์ด๋™, ์‹ค์ง€์ถœ, ์‹ค์ˆ˜์ž…, ํˆฌ์ž์ด๋™, ๋ถ„์„์ œ์™ธ, ํ™•์ธํ•„์š” 6๊ฐœ ํŒ์ •๊ฐ’์„ ์‚ฌ์šฉํ•œ๋‹ค. ์ž๋™ ํ™•์ •๋ณด๋‹ค๋Š” ์ถ”์ฒœ/ํ›„๋ณด ์ œ์‹œ ํ›„ ์‚ฌ์šฉ์ž ์ˆ˜๋™ ํ™•์ •์„ ๊ธฐ๋ณธ์œผ๋กœ ํ•œ๋‹ค. + +## CONFIRM +- ๋Œ€์กฐ ์ž‘์—… ์ „ dcy-002 ๋ฐฑ์—…ยท๊ฒ€์ฆ DB ์ ˆ์ฐจ ์„ ํ–‰์„ ์šด์˜ ํ‘œ์ค€์œผ๋กœ ํ™•์ •ํ•œ๋‹ค. ์šด์˜ DB ๋ณ€๊ฒฝ ์ „ ๋ฐฑ์—…์€ ํ•„์ˆ˜์ด๋ฉฐ, ๊ฒ€์ฆ DB์—์„œ preview/reconcile์„ ๋จผ์ € ์ˆ˜ํ–‰ํ•œ๋‹ค. Restore๋Š” ์‚ฌ์šฉ์ž ๋ช…์‹œ ์Šน์ธ ์—†์ด๋Š” ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## DATA +- ์‹ค์ œ ๊ณ„์ขŒ๋ณ„ ๊ธฐ์ค€ ์ž”์•ก ๊ฐ’์€ ์šด์˜ ์‹œ์ ์— ์ž…๋ ฅํ•œ๋‹ค. REQ์—๋Š” `account_id`, `statement_status`, `baseline_balance`, `baseline_at`, `source_note`, `memo` ์ž…๋ ฅ ํฌ๋งท๊ณผ ๊ฒ€์ฆ ๊ทœ์น™์„ ์ •์˜ํ•œ๋‹ค. + +# 11. Discovery Freeze +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ธ๊ณ„ ์‹œ์ ์˜ ํ™•์ • ํ”Œ๋ž˜๊ทธ์™€ ์„น์…˜ ์ฐธ์กฐ๋งŒ ๊ธฐ๋กํ•œ๋‹ค. ๋‚ด์šฉ ์žฌ์ž‘์„ฑ ์—†์ด ์›๋ณธ ์„น์…˜์„ ๋งํฌ๋กœ ๋Œ€์‹ ํ•œ๋‹ค. + +## ์„น์…˜ ์ฐธ์กฐ +- ๋ฒ”์œ„: [# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก > ๋ฒ”์œ„ ๊ฒฝ๊ณ„](#5-์š”๊ตฌ์‚ฌํ•ญ-๋ชฉ๋ก) +- ๋ฆฌ์Šคํฌ/์˜คํ”ˆ ์งˆ๋ฌธ: [# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก](#6-๋ฆฌ์Šคํฌ๊ฐ€์ •-๋ชฉ๋ก) +- ์„ฑ๊ณต ๊ธฐ์ค€: [# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€](#7-์ดˆ๊ธฐ-์„ฑ๊ณต-๊ธฐ์ค€) +- ์ธ๊ณ„ ์ „ ํ™•์ธ: [# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ](#8-req๋กœ-๋„˜๊ธฐ๊ธฐ-์ „-ํ™•์ธ-์ฒดํฌ) +- ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š”: [# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ](#10-์‚ฌ์šฉ์ž-๊ฒฐ์ •-ํ•„์š”-ํ•ญ๋ชฉ-์š”์•ฝ) + +## Freeze ํ”Œ๋ž˜๊ทธ +- Handoff Decision: ์Šน์ธ +- Handoff Rationale: ์‚ฌ์šฉ์ž ์Šน์ธ์œผ๋กœ ์ž”์•ก๋Œ€์กฐ ํ—ˆ์šฉ ์˜ค์ฐจ, ๊ธฐ์ค€ ์‹œ์ , statement-unavailable ๊ณ„์ขŒ ๋ถ„๋ฅ˜ ๋ฐฉ์‹, ์ž๋™ํ™”/UI ๋ฒ”์œ„, ํŒ์ • ๊ธฐ์ค€, ๋ฐฑ์—… ์„ ํ–‰ ์กฐ๊ฑด, ๊ธฐ์ค€ ์ž”์•ก ๋ฐ์ดํ„ฐ ์ฒ˜๋ฆฌ ๋ฐฉ์‹์ด ํ™•์ •๋˜์—ˆ์œผ๋ฏ€๋กœ REQ ์ดˆ์•ˆ ์ž‘์„ฑ๊ณผ ๊ตฌํ˜„ ์œ„์ž„์œผ๋กœ ๋„˜๊ธด๋‹ค. +- Ready for REQ Drafting: true +- Confirmed By: ์ง„ํ™˜ ๊ณ  +- Confirmed At (KST): 2026-06-29 13:06 diff --git a/docs/discovery/dcy-004_20260629_browser-streamlit-e2e-evidence-recovery.md b/docs/discovery/dcy-004_20260629_browser-streamlit-e2e-evidence-recovery.md new file mode 100644 index 0000000..e07ae56 --- /dev/null +++ b/docs/discovery/dcy-004_20260629_browser-streamlit-e2e-evidence-recovery.md @@ -0,0 +1,210 @@ +# 0. ๋ฌธ์„œ ์ƒํƒœ +์ด ๋ฌธ์„œ์˜ ํ˜„์žฌ ์ƒํƒœ์™€ ์‹๋ณ„ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ๋ฌธ์„œ ์ถ”์ ๊ณผ ์Šน์ธ ํ๋ฆ„์— ํ•„์š”ํ•œ ์ตœ์†Œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ ๋Š”๋‹ค. + +- ์ƒํƒœ: confirmed +- ๋ฌธ์„œ ID: dcy-004_20260629_browser-streamlit-e2e-evidence-recovery +- ์ด์Šˆ๋ช…: Browser/Streamlit E2E evidence ํ™˜๊ฒฝ ๋ณต๊ตฌ ๋ฐ QC evidence ๊ธฐ์ค€ ๊ฐ•ํ™” +- ์ž‘์„ฑ ์‹œ๊ฐ(KST): 2026-06-29 13:29 +- ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹  ์‹œ๊ฐ(KST): 2026-06-30 13:32 +- ๋Œ€์ฒด๋จ: ์—†์Œ +- ํ›„์† Discovery ์ฐธ์กฐ: docs/discovery/dcy-006_20260630_pytest-apptest-smoke-ci-automation.md +- ์ƒ์„ฑ๋œ REQ ์ฐธ์กฐ: REQ-004 (`docs/srs/Testing/req-004_browser-streamlit-e2e-evidence-recovery.md`) + +# 1. ๊ณ„ํš ์ƒํƒœ ์š”์•ฝ +ํ˜„์žฌ Discovery๊ฐ€ ์–ด๋–ค ๋ฐฐ๊ฒฝ์—์„œ ์ž‘์„ฑ๋˜๊ณ  ์žˆ๋Š”์ง€, ์—ฐ๊ณ„ ๋ฌธ์„œ๊ฐ€ ์žˆ๋Š”์ง€, ์ง€๊ธˆ ์‹œ์ ์˜ ํ•ด์„์ด ๋ฌด์—‡์ธ์ง€ ์งง๊ฒŒ ์š”์•ฝํ•œ๋‹ค. + +- ์—ฐ๊ณ„ ๋ฌธ์„œ ํ˜„ํ™ฉ: + - ๊ด€๋ จ GitHub Project ํ•ญ๋ชฉ: https://github.com/users/genichin/projects/4 DraftIssue (Browser/Streamlit E2E evidence ํ™˜๊ฒฝ ๋ณต๊ตฌ) + - ๊ด€๋ จ TODO/๋ฉ”๋ชจ: `docs/batches/bat-001_20260626_category-rule-ops/verification.md`์˜ "Future Streamlit UX/E2E batches should restore real browser/screenshot evidence" ๋ฉ”๋ชจ +- ํ•ด์„: + - dcy-001(Streamlit UX/E2E ์•ˆ์ •ํ™”)๋ถ€ํ„ฐ bat-001 ๊ฒ€์ฆ๊นŒ์ง€, browser-level screenshot ์ฆ๊ฑฐ๋Š” ๋กœ์ปฌ Chromium snap ๋ถ€์žฌ๋กœ ํ•œ ๋ฒˆ๋„ ํ™•๋ณด๋˜์ง€ ๋ชปํ–ˆ๊ณ  AppTest + HTTP 200 smoke๊ฐ€ ๋Œ€์ฒด ์ฆ๊ฑฐ๋กœ ๋ˆ„์  ์‚ฌ์šฉ๋˜์–ด ์™”๋‹ค. + - ์ด๋ฒˆ Discovery๋Š” ํŠน์ • ๊ธฐ๋Šฅ ์ถ”๊ฐ€๊ฐ€ ์•„๋‹ˆ๋ผ ๊ฒ€์ฆ ์ธํ”„๋ผ ๋ณต๊ตฌ๋ฅผ ๋…๋ฆฝ ์ฃผ๊ธฐ๋กœ ๋‹ค๋ฃฌ๋‹ค. ์ฆ‰ Chromium/screenshot ๊ธฐ๋ฐ˜ browser-level Streamlit E2E ์ฆ๊ฑฐ ์ˆ˜์ง‘ ํ™˜๊ฒฝ์„ ๋ณต๊ตฌํ•˜๊ณ , ๊ฒ€์ˆ˜๊ด€ QC gate๊ฐ€ ์š”๊ตฌํ•˜๋Š” evidence ๊ธฐ์ค€์„ ๊ฐ•ํ™”ํ•˜๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•œ๋‹ค. + +# 2. ์š”๊ตฌ์‚ฌํ•ญ ํŒ์ • ๊ฒฐ๊ณผ +์ด๋ฒˆ ์š”์ฒญ์ด ๊ธฐ์กด ์š”๊ตฌ์‚ฌํ•ญ๊ณผ ๋น„๊ตํ•ด ์‹ ๊ทœ์ธ์ง€, ์œ ์‚ฌํ•œ์ง€, ์ค‘๋ณต์ธ์ง€ ํŒ๋‹จํ•˜๊ณ  ๊ทธ ๊ทผ๊ฑฐ๋ฅผ ๋‚จ๊ธด๋‹ค. + +- ํŒ์ •: ์‹ ๊ทœ +- ๊ทผ๊ฑฐ + - dcy-001์€ Streamlit UX/E2E ์›Œํฌํ”Œ๋กœ์šฐ ์ž์ฒด์˜ acceptance criteria ์ •์˜๊ฐ€ ์ค‘์‹ฌ์ด๋ฉฐ, "browser ์ฆ๊ฑฐ๋ฅผ ์–ด๋–ป๊ฒŒ ํ™•๋ณดยทํ‘œ์ค€ํ™”ํ• ์ง€"๋ผ๋Š” ๊ฒ€์ฆ ํ™˜๊ฒฝ/QC ๊ธฐ์ค€ ๋ฌธ์ œ๋ฅผ ๋…๋ฆฝ ์„ฑ๊ณต ๊ธฐ์ค€์œผ๋กœ ๋‹ค๋ฃจ์ง€ ์•Š์•˜๋‹ค. ํ•ด๋‹น Discovery์—์„œ OQ-1(์ˆ˜๋™ ์ฒดํฌ๋ฆฌ์ŠคํŠธ vs Playwright/Selenium ์ž๋™ํ™”)๋„ ๋ฏธํ•ด๊ฒฐ๋กœ ๋‚จ์•„ ์žˆ์—ˆ๋‹ค. + - bat-001 verification๊ณผ req-001 QC note๋Š” browser screenshot ๋ถ€์žฌ๋ฅผ ๋ฐ˜๋ณต์ ์œผ๋กœ `PASS_WITH_NOTES`์˜ ์ž”์—ฌ ํ•ญ๋ชฉ์œผ๋กœ ๊ธฐ๋กํ–ˆ์„ ๋ฟ, ํ™˜๊ฒฝ ๋ณต๊ตฌ๋ฅผ ํ›„์† ์ž‘์—…์œผ๋กœ ์ •์‹ ์ถ”์ ํ•˜์ง€ ์•Š์•˜๋‹ค. ์ด๋ฅผ ๋…๋ฆฝ ์ฃผ๊ธฐ๋กœ ๋Œ์–ด์˜ฌ๋ ค ์ถ”์ ํ•˜๋Š” ๊ฒƒ์ด ์ฑ…์ž„ ๊ฒฝ๊ณ„์ƒ ๋ช…ํ™•ํ•˜๋‹ค. + +# 3. ๋ฌธ์ œ์ ์˜ ์š”์•ฝ +๋ฌธ์ œ์˜ ํ˜„์žฌ ์ƒํƒœ, ๊ธฐ๋Œ€ ์ƒํƒœ, ๊ด€๋ จ ์ดํ•ด๊ด€๊ณ„์ž, ์ œ์•ฝ์‚ฌํ•ญ์„ ์ •๋ฆฌํ•œ๋‹ค. Discovery์˜ ํ•ต์‹ฌ ๋งฅ๋ฝ์„ ๊ฐ€์žฅ ๋จผ์ € ์ดํ•ดํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. + +- ํ˜„์žฌ ์ƒํƒœ + - browser ์ฆ๊ฑฐ ์ˆ˜์ง‘์ด ์ฐจ๋‹จ๋˜์–ด ์žˆ๋‹ค: `bat-001` ๊ตฌํ˜„/๊ฒ€์ฆ ๊ธฐ๋ก์— ๋”ฐ๋ฅด๋ฉด `/usr/bin/chromium-browser`๊ฐ€ ๋ฏธ์„ค์น˜ chromium snap์„ ์š”๊ตฌํ•ด `Chrome exited early ... requires the chromium snap to be installed`๋กœ ์‹คํŒจํ–ˆ๊ณ , browser tool์€ ํŽ˜์ด์ง€ ๋กœ๋“œ ์ „์— ์ข…๋ฃŒ๋˜์—ˆ๋‹ค. + - ๋Œ€์ฒด ์ฆ๊ฑฐ๊ฐ€ ํ‘œ์ค€์ฒ˜๋Ÿผ ๊ตณ์–ด์กŒ๋‹ค: `streamlit.testing.v1.AppTest`์™€ ํฌํŠธ 8502 HTTP 200 smoke(synthetic fixture DB `/tmp/jarca-ledger-bat-001-smoke.sqlite3`)๊ฐ€ browser screenshot ๋Œ€์‹  ๋ฐ˜๋ณต ์‚ฌ์šฉ๋˜์—ˆ๋‹ค(`bat-001/implementation.md`, `bat-001/verification.md`). + - QC gate๊ฐ€ ์ด๋ฅผ ํ—ˆ์šฉํ•ด ์™”๋‹ค: `req-001`์˜ QC note๋Š” `PASS_WITH_NOTES`๋กœ ํ†ต๊ณผํ•˜๋˜ "browser screenshot unavailable due Chromium snap absence; future batches should restore real browser/screenshot evidence"๋ฅผ ์ž”์—ฌ ํ•ญ๋ชฉ์œผ๋กœ ๋‚จ๊ฒผ๋‹ค. ์ฆ‰ evidence ๋“ฑ๊ธ‰์— ๋Œ€ํ•œ ๋ช…์‹œ์  ๊ธฐ์ค€ยท๊ฒŒ์ดํŠธ ๊ทœ์น™์ด ๋ฌธ์„œํ™”๋˜์–ด ์žˆ์ง€ ์•Š๋‹ค. + - baseline ๋ฌธ์„œ๋“ค๋„ ์ด gap์„ ์ธ์ง€ํ•˜๊ณ  ์žˆ๋‹ค: `docs/project-structure.md`("Final UX verification is still needed ... import E2E flows"), `docs/runtime-flows.md`("Need final browser-level UX verification ..."), `docs/interface-contract.md`("Browser-level UI acceptance criteria for Streamlit flows are not yet captured as REQ documents")๊ฐ€ ๋ชจ๋‘ browser-level ๊ฒ€์ฆ ๋ฏธ๋น„๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. +- ๊ธฐ๋Œ€ ์ƒํƒœ + - ์šด์˜์ž/๊ฒ€์ˆ˜๊ด€์ด ๋กœ์ปฌ ๋˜๋Š” CI ํ™˜๊ฒฝ์—์„œ Chromium์„ ์žฌํ˜„ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ธฐ๋™ํ•˜์—ฌ Streamlit ์ฃผ์š” ํ™”๋ฉด(๋ถ„๋ฅ˜ ๊ทœ์น™, ํ™•์ธํ•„์š” ํ, import preview, ์ž”์•ก๋Œ€์กฐ ๊ฒฐ๊ณผ ๋“ฑ)์˜ browser-level screenshot ์ฆ๊ฑฐ๋ฅผ ์•ˆ์ •์ ์œผ๋กœ ์ˆ˜์ง‘ํ•  ์ˆ˜ ์žˆ๋‹ค. + - evidence ๋“ฑ๊ธ‰(์˜ˆ: browser-screenshot E2E > AppTest semi-auto > HTTP 200 smoke)์ด ๋ช…์‹œ์ ์œผ๋กœ ์ •์˜๋˜๊ณ , ์–ด๋–ค ์ข…๋ฅ˜์˜ batch์— ์–ด๋–ค ์ตœ์†Œ ์ฆ๊ฑฐ๊ฐ€ ํ•„์š”ํ•œ์ง€ QC gate ๊ทœ์น™์œผ๋กœ ๋ฌธ์„œํ™”๋œ๋‹ค. + - "Chromium ๋ถ€์žฌ โ†’ ๋Œ€์ฒด ์ฆ๊ฑฐ ํ—ˆ์šฉ"์ด ๋ฌด๊ธฐํ•œ ๊ธฐ๋ณธ๊ฐ’์ด ์•„๋‹ˆ๋ผ, ํ™˜๊ฒฝ ๋ณต๊ตฌ ํ›„์—๋Š” browser ์ฆ๊ฑฐ๊ฐ€ 1์ˆœ์œ„ ๊ธฐ์ค€์ด ๋˜๋„๋ก QC evidence ๊ธฐ์ค€์ด ๊ฐ•ํ™”๋œ๋‹ค. +- ์ดํ•ด๊ด€๊ณ„์ž + - ๋ ›์ €์žฅ์ธ(์šด์˜์ž ๊ฒธ ์ž‘์„ฑ์ž) โ€” Streamlit E2E ๊ฒ€์ฆ ์‹คํ–‰ ๋ฐ ์ฆ๊ฑฐ ์ˆ˜์ง‘ ์ฃผ์ฒด + - ๊ฒ€์ˆ˜๊ด€/๋ฆฌ๋ทฐ์–ด โ€” QC gate์—์„œ evidence ๋“ฑ๊ธ‰๊ณผ ์ถฉ์กฑ ์—ฌ๋ถ€๋ฅผ ํŒ์ •ํ•˜๋Š” ์ฃผ์ฒด + - StagePilot ํ›„์† SDLC(REQ/Batch) ์ˆ˜ํ–‰์ž โ€” ๋ณธ Discovery๋ฅผ ๊ฒ€์ฆ ํ™˜๊ฒฝ ์š”๊ตฌ์‚ฌํ•ญ์œผ๋กœ ์ •์ œ +- ์ œ์•ฝ์‚ฌํ•ญ + - ์‹คํ–‰ ํ™˜๊ฒฝ(snap ์‚ฌ์šฉ ๊ฐ€๋Šฅ ์—ฌ๋ถ€, ์ปจํ…Œ์ด๋„ˆ/CI ๊ถŒํ•œ, ํ—ค๋“œ๋ฆฌ์Šค ๊ฐ€๋Šฅ ์—ฌ๋ถ€)์ด ๊ณ ์ •์ ์ด์ง€ ์•Š์•„ ๋‹จ์ผ ์„ค์น˜ ๋ฐฉ์‹์„ ๊ฐ•์ œํ•˜๊ธฐ ์–ด๋ ต๋‹ค. + - ๊ฐœ์ธ ๊ธˆ์œต์ •๋ณด๋ฅผ ๋‹ค๋ฃจ๋ฏ€๋กœ screenshot ์ฆ๊ฑฐ์—๋Š” ์‹ค๊ณ„์ขŒ๋ฒˆํ˜ธยท์ž”์•ก ๋“ฑ ๋ฏผ๊ฐ์ •๋ณด๊ฐ€ ๋…ธ์ถœ๋˜์ง€ ์•Š๋„๋ก synthetic fixture ๋˜๋Š” ๋งˆ์Šคํ‚น์„ ์ „์ œ๋กœ ํ•œ๋‹ค. + - ์šด์˜ DB ํ›ผ์†์„ ํ”ผํ•˜๊ธฐ ์œ„ํ•ด E2E๋Š” fixture/๋ณต์‚ฌ๋ณธ DB์—์„œ ์ˆ˜ํ–‰ํ•˜๋ฉฐ, dcy-002 ๋ฐฑ์—…ยท๊ฒ€์ฆ DB ์ ˆ์ฐจ๋ฅผ ์ „์ œ๋กœ ํ•œ๋‹ค. + +## ์ฃผ์š” ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค +์‚ฌ์šฉ์ž ๋˜๋Š” ์šด์˜์ž๊ฐ€ ์‹ค์ œ๋กœ ์–ด๋–ค ์ƒํ™ฉ์—์„œ ์ด ๋ณ€๊ฒฝ์„ ํ•„์š”๋กœ ํ•˜๋Š”์ง€ ๋Œ€ํ‘œ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ ๋Š”๋‹ค. + +- ๊ฒ€์ˆ˜๊ด€์ด ์ƒˆ Streamlit UX batch๋ฅผ QC ๊ฒ€์ˆ˜ํ•  ๋•Œ, AppTest ๋Œ€์ฒด ์ฆ๊ฑฐ๊ฐ€ ์•„๋‹ˆ๋ผ ์‹ค์ œ browser screenshot์œผ๋กœ ํ™”๋ฉด ๋ Œ๋”๋งยท๋ฒ„ํŠผ ๋™์ž‘์„ ํ™•์ธํ•˜๊ณ  evidence๋ฅผ ์ฒจ๋ถ€ํ•œ๋‹ค. +- ์šด์˜์ž๊ฐ€ ๋ถ„๋ฅ˜ ๊ทœ์น™/ํ™•์ธํ•„์š” ํ ํ™”๋ฉด ๋ณ€๊ฒฝ์„ ๊ฒ€์ฆํ•  ๋•Œ, fixture DB๋กœ Streamlit์„ ๊ธฐ๋™ํ•˜๊ณ  headless Chromium์œผ๋กœ ์ฃผ์š” ํ”Œ๋กœ์šฐ๋ฅผ ์Šคํฌ๋ฆฐ์ƒท ์บก์ฒ˜ํ•˜์—ฌ ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ ์ฆ๊ฑฐ ๋””๋ ‰ํ„ฐ๋ฆฌ์— ์ €์žฅํ•œ๋‹ค. +- ๊ฒ€์ˆ˜๊ด€์ด browser ์ฆ๊ฑฐ๋ฅผ ํ™•๋ณดํ•  ์ˆ˜ ์—†๋Š” ํ™˜๊ฒฝ์— ํ•œํ•ด, ์ •์˜๋œ evidence ๋“ฑ๊ธ‰ ๊ทœ์น™์— ๋”ฐ๋ผ ๋ช…์‹œ์ ์œผ๋กœ ๋Œ€์ฒด ์ฆ๊ฑฐ(AppTest+HTTP 200)๋ฅผ `PASS_WITH_NOTES`๋กœ๋งŒ ํ—ˆ์šฉํ•˜๊ณ  ์ž”์—ฌ ํ•ญ๋ชฉ์„ ์ถ”์ ํ•œ๋‹ค. + +# 4. ์ด๋ฒˆ์— ์ •์˜ํ•  ๋ณ€๊ฒฝ +์ด๋ฒˆ Discovery์—์„œ ๋‹ค๋ฃจ๋Š” ํ•ต์‹ฌ ๋ณ€๊ฒฝ ํ•ญ๋ชฉ์„ ๋ชฉ๋ก์œผ๋กœ ์ ๋Š”๋‹ค. ๊ธฐ๋Šฅ, ๊ตฌ์กฐ, ๊ทœ์น™ ๋ณ€๊ฒฝ์„ ํ•จ๊ป˜ ํฌํ•จํ•ด๋„ ๋œ๋‹ค. + +- Chromium/screenshot ๊ธฐ๋ฐ˜ browser-level Streamlit E2E ์ฆ๊ฑฐ ์ˆ˜์ง‘ ํ™˜๊ฒฝ์„ ๋ณต๊ตฌํ•œ๋‹ค. ์ฆ‰ ๋ฏธ์„ค์น˜ chromium snap์— ์˜์กดํ•˜์ง€ ์•Š๊ณ  ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ ๋ฐฉ์‹์œผ๋กœ headless Chromium์„ ๊ธฐ๋™ํ•˜๋Š” ํ‘œ์ค€ ์ ˆ์ฐจ๋ฅผ ์ •์˜ํ•œ๋‹ค. +- browser E2E ์ฆ๊ฑฐ ์ˆ˜์ง‘์„ ์ž๋™ํ™” ๊ฐ€๋Šฅํ•œ smoke/E2E ์ ˆ์ฐจ(์˜ˆ: fixture DB๋กœ Streamlit ๊ธฐ๋™ โ†’ ์ฃผ์š” ํ™”๋ฉด ์Šคํฌ๋ฆฐ์ƒท ์บก์ฒ˜ โ†’ ์ฆ๊ฑฐ ๋””๋ ‰ํ„ฐ๋ฆฌ ์ €์žฅ)๋กœ ํ‘œ์ค€ํ™”ํ•œ๋‹ค. +- QC evidence ๋“ฑ๊ธ‰๊ณผ ๊ฒŒ์ดํŠธ ๊ทœ์น™์„ ๊ฐ•ํ™”ํ•œ๋‹ค: browser-screenshot E2E๋ฅผ 1์ˆœ์œ„ ์ฆ๊ฑฐ๋กœ ๋‘๊ณ , AppTest/HTTP 200 ๋Œ€์ฒด ์ฆ๊ฑฐ๋Š” ํ™˜๊ฒฝ ์ œ์•ฝ์ด ์ž…์ฆ๋œ ๊ฒฝ์šฐ์—๋งŒ `PASS_WITH_NOTES`๋กœ ํ—ˆ์šฉํ•˜๋Š” ๊ธฐ์ค€์„ ๋ฌธ์„œํ™”ํ•œ๋‹ค. +- ๊ฒ€์ฆ ํ™˜๊ฒฝยท์ฆ๊ฑฐ ์ ˆ์ฐจ๋ฅผ `docs/runtime-flows.md`(๊ฒ€์ฆ ํ๋ฆ„)์™€ `docs/project-structure.md`(ํ…Œ์ŠคํŠธ/์ฆ๊ฑฐ ๋„๊ตฌ ๊ตฌ์กฐ), ๊ทธ๋ฆฌ๊ณ  ๊ฒ€์ฆ runbook ํ›„๋ณด์— ๋ฐ˜์˜ํ•  ํ•„์š”๋ฅผ ๋ช…์‹œํ•œ๋‹ค. + +## ์˜ํ–ฅ ๋ฒ”์œ„ +๋ณ€๊ฒฝ์ด ๋ฏธ์น  ์ฝ”๋“œ/๋ฌธ์„œ/์šด์˜ ๋ฒ”์œ„๋ฅผ ๋ช…์‹œํ•ด REQ ์ •์ œ์™€ batch planning์—์„œ ์ถ”์ • ๋ˆ„๋ฝ์„ ์ค„์ธ๋‹ค. + +- ์ฝ”๋“œ ์˜ํ–ฅ: + - `tests/` โ€” Streamlit E2E/browser smoke ์Šคํฌ๋ฆฝํŠธ(ํ—ค๋“œ๋ฆฌ์Šค Chromium ๊ธฐ๋™, ์Šคํฌ๋ฆฐ์ƒท ์บก์ฒ˜)์™€ fixture DB ์ค€๋น„ ์œ ํ‹ธ๋ฆฌํ‹ฐ ์‹ ์„ค ๊ฐ€๋Šฅ์„ฑ. ๊ธฐ์กด `streamlit.testing.v1.AppTest` smoke๋Š” ๋ณด์กดํ•˜๋˜ ๋ณด์กฐ ์ฆ๊ฑฐ๋กœ ์žฌ๋ฐฐ์น˜. + - ๊ฒ€์ฆ ๋ณด์กฐ ์Šคํฌ๋ฆฝํŠธ/์‹คํ–‰ ๋ž˜ํผ โ€” Chromium ๊ธฐ๋™ยทscreenshot ์ €์žฅ ๊ฒฝ๋กœยท์ฆ๊ฑฐ ์ˆ˜์ง‘์„ ์žฌํ˜„ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๋ฌถ๋Š” ์ง„์ž…์ (์ฝ”๋“œ ์œ„์น˜๋Š” REQ/Batch์—์„œ ํ™•์ •). + - `dashboard/app.py` โ€” ์›์น™์ ์œผ๋กœ ๊ธฐ๋Šฅ ๋ณ€๊ฒฝ ์—†์Œ. E2E ์•ˆ์ •ํ™”๋ฅผ ์œ„ํ•œ ์ตœ์†Œ ํ…Œ์ŠคํŠธ ํ›…(์˜ˆ: ๊ฒฐ์ •์  ๋ Œ๋”๋ง)์ด ํ•„์š”ํ•  ๊ฒฝ์šฐ์— ํ•œํ•ด ํ›„์† ๋ฒ”์œ„๋กœ ๊ฒ€ํ† . +- ๋ฌธ์„œ ์˜ํ–ฅ: + - `docs/runtime-flows.md` โ€” browser-level Streamlit E2E ๊ฒ€์ฆ ํ๋ฆ„๊ณผ ์ฆ๊ฑฐ ์ˆ˜์ง‘ ๋‹จ๊ณ„ ์ถ”๊ฐ€. + - `docs/project-structure.md` โ€” ํ…Œ์ŠคํŠธ/์ฆ๊ฑฐ ๋„๊ตฌ(ํ—ค๋“œ๋ฆฌ์Šค Chromium, E2E ์Šคํฌ๋ฆฝํŠธ, ์ฆ๊ฑฐ ๋””๋ ‰ํ„ฐ๋ฆฌ) ๊ตฌ์กฐ ๋ฐ˜์˜ ๋ฐ ๊ธฐ์กด "browser-level verification still needed" ํ•ญ๋ชฉ ๊ฐฑ์‹ . + - `docs/interface-contract.md` โ€” browser-level UI acceptance/evidence ๊ธฐ์ค€์˜ REQํ™” ํ•„์š”์„ฑ ๋ฐ˜์˜ ๊ฒ€ํ† . + - `docs/runbooks/` โ€” Streamlit E2E ์ฆ๊ฑฐ ์ˆ˜์ง‘ ์šด์˜ runbook ์‹ ๊ทœ ํ›„๋ณด. +- ์šด์˜ ์˜ํ–ฅ: + - QC gate ์šด์˜ ๊ธฐ์ค€์ด ๊ฐ•ํ™”๋˜์–ด, browser ์ฆ๊ฑฐ ๊ฐ€๋Šฅ ํ™˜๊ฒฝ์—์„œ๋Š” ๋Œ€์ฒด ์ฆ๊ฑฐ๋งŒ์œผ๋กœ PASS๋ฅผ ํ—ˆ์šฉํ•˜์ง€ ์•Š๋Š” ์šด์˜ ๊ทœ์น™์ด ์‹ ์„ค๋œ๋‹ค. + - E2E๋Š” synthetic fixture ๋˜๋Š” dcy-002 ๊ฒ€์ฆ DB ๋ณต์‚ฌ๋ณธ์—์„œ ์ˆ˜ํ–‰ํ•˜๋Š” ์šด์˜ ๋ฃจํ‹ด์ด ์ถ”๊ฐ€๋œ๋‹ค. + +# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก +์‹ค์ œ๋กœ ๊ตฌํ˜„๊ณผ ๊ฒ€์ฆ์œผ๋กœ ์ด์–ด์งˆ ์š”๊ตฌ์‚ฌํ•ญ์„ ๊ธฐ๋Šฅ/๋น„๊ธฐ๋Šฅ์œผ๋กœ ๋‚˜๋ˆ„์–ด ์ •๋ฆฌํ•˜๊ณ , ๋ฒ”์œ„ ๊ฒฝ๊ณ„๋ฅผ ๋ช…ํ™•ํžˆ ์ ๋Š”๋‹ค. + +## ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +- FR-1: ๋ฏธ์„ค์น˜ chromium snap์— ์˜์กดํ•˜์ง€ ์•Š๊ณ  ์žฌํ˜„ ๊ฐ€๋Šฅํ•˜๊ฒŒ headless Chromium์„ ๊ธฐ๋™ํ•  ์ˆ˜ ์žˆ๋Š” ํ‘œ์ค€ ์‹คํ–‰ ์ ˆ์ฐจ๋ฅผ ์ œ๊ณตํ•ด์•ผ ํ•œ๋‹ค. ์‹คํ–‰ ํ™˜๊ฒฝ์ด Chromium์„ ์ œ๊ณตํ•˜์ง€ ๋ชปํ•˜๋Š” ๊ฒฝ์šฐ ๋ช…ํ™•ํ•œ ์ง„๋‹จ ๋ฉ”์‹œ์ง€์™€ ํ•จ๊ป˜ ๋Œ€์ฒด ์ฆ๊ฑฐ ๊ฒฝ๋กœ๋กœ ์•ˆ๋‚ดํ•ด์•ผ ํ•œ๋‹ค. +- FR-2: fixture DB๋กœ Streamlit์„ ๊ธฐ๋™ํ•˜๊ณ  ์ฃผ์š” ํ™”๋ฉด(์ตœ์†Œ: ๋ถ„๋ฅ˜ ๊ทœ์น™, ํ™•์ธํ•„์š” ํ, import preview)์„ browser-level๋กœ ๋ Œ๋”๋งํ•ด ์Šคํฌ๋ฆฐ์ƒท ์ฆ๊ฑฐ๋ฅผ ์ •ํ•ด์ง„ ์ฆ๊ฑฐ ๋””๋ ‰ํ„ฐ๋ฆฌ์— ์ €์žฅํ•˜๋Š” E2E/smoke ์ ˆ์ฐจ๋ฅผ ์ œ๊ณตํ•ด์•ผ ํ•œ๋‹ค. +- FR-3: QC evidence ๋“ฑ๊ธ‰์„ ์ •์˜ํ•˜๊ณ (์˜ˆ: browser-screenshot E2E > AppTest semi-auto > HTTP 200/service smoke), ๊ฐ batch ์œ ํ˜•์— ํ•„์š”ํ•œ ์ตœ์†Œ ์ฆ๊ฑฐ์™€ ๊ฒŒ์ดํŠธ ๊ฒฐ๊ณผ(`PASS`/`PASS_WITH_NOTES`) ๊ทœ์น™์„ ๋ฌธ์„œํ™”ํ•ด์•ผ ํ•œ๋‹ค. +- FR-4: browser ์ฆ๊ฑฐ ์ˆ˜์ง‘์ด ํ™˜๊ฒฝ์ ์œผ๋กœ ๋ถˆ๊ฐ€๋Šฅํ•œ ๊ฒฝ์šฐ์—๋งŒ ๋Œ€์ฒด ์ฆ๊ฑฐ๋ฅผ ํ—ˆ์šฉํ•˜๋ฉฐ, ๊ทธ ์‚ฌ์œ ์™€ ์ž”์—ฌ ํ•ญ๋ชฉ์„ ๊ฒ€์ฆ ๋ฌธ์„œ์— ๋ช…์‹œ์ ์œผ๋กœ ๊ธฐ๋กํ•˜๋„๋ก ๊ฐ•์ œํ•ด์•ผ ํ•œ๋‹ค. +- FR-5: ๊ฒ€์ฆ ํ™˜๊ฒฝยท์ฆ๊ฑฐ ์ ˆ์ฐจ๋ฅผ `docs/runtime-flows.md`์™€ `docs/project-structure.md`์— ๋ฐ˜์˜ํ•˜๊ณ , Streamlit E2E ์ฆ๊ฑฐ ์ˆ˜์ง‘ runbook์„ `docs/runbooks/` ํ›„๋ณด๋กœ ์ถ”๊ฐ€ํ•ด์•ผ ํ•œ๋‹ค. + +## ๋น„๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +์„ฑ๋Šฅ, ์šด์˜์„ฑ, ์ผ๊ด€์„ฑ, ์ž๋™ํ™” ์•ˆ์ •์„ฑ ๋“ฑ ๊ธฐ๋Šฅ ์™ธ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ ๋Š”๋‹ค. + +- NFR-1: E2E ์ฆ๊ฑฐ ์ˆ˜์ง‘์€ ์žฌํ˜„ ๊ฐ€๋Šฅํ•ด์•ผ ํ•˜๋ฉฐ, ๋™์ผ fixtureยท๋™์ผ ์ ˆ์ฐจ์— ๋Œ€ํ•ด ๋™์ผํ•œ ํ™”๋ฉด ์ฆ๊ฑฐ๋ฅผ ์‚ฐ์ถœํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค(๊ฒฐ์ •์  fixture, ๊ณ ์ • ํฌํŠธ/๊ฒฝ๋กœ ๊ทœ์น™). +- NFR-2: ๋ชจ๋“  E2E ๊ฒ€์ฆ์€ synthetic fixture ๋˜๋Š” dcy-002 ๊ฒ€์ฆ DB ๋ณต์‚ฌ๋ณธ์—์„œ ์ˆ˜ํ–‰ํ•˜์—ฌ ์›๋ณธ ์šด์˜ DB๋ฅผ ํ›ผ์†ํ•˜์ง€ ์•Š์•„์•ผ ํ•˜๋ฉฐ, ์Šคํฌ๋ฆฐ์ƒท์— ์‹ค๊ณ„์ขŒ๋ฒˆํ˜ธยท์ž”์•ก ๋“ฑ ๋ฏผ๊ฐ์ •๋ณด๊ฐ€ ํ‰๋ฌธ์œผ๋กœ ๋…ธ์ถœ๋˜์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. +- NFR-3: ํ™˜๊ฒฝ ์˜์กด์„ฑ(Chromium ์„ค์น˜ ๋ฐฉ์‹)์€ ๋‹จ์ผ ๋ฌผ๋ฆฌ ๋ฐฉ์‹์— ๊ณ ์ •ํ•˜์ง€ ์•Š๊ณ  ์‹คํ–‰ ํ™˜๊ฒฝ๋ณ„๋กœ ํ•ด์„ ๊ฐ€๋Šฅํ•œ ์ ˆ์ฐจ๋กœ ๋ฌธ์„œํ™”ํ•˜์—ฌ, snap ๋ถ€์žฌ ๊ฐ™์€ ๋‹จ์ผ ์‹คํŒจ ์ง€์ ์— ๋‹ค์‹œ ๊ฐ‡ํžˆ์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. + +## ๋ฒ”์œ„ ๊ฒฝ๊ณ„ +์ด๋ฒˆ ๋ฐ˜๋ณต์—์„œ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•  ๋ฒ”์œ„์™€ ๋ช…์‹œ์ ์œผ๋กœ ์ œ์™ธํ•  ๋ฒ”์œ„๋ฅผ ๊ตฌ๋ถ„ํ•œ๋‹ค. + +- In Scope + - headless Chromium ๊ธฐ๋™ ํ‘œ์ค€ ์ ˆ์ฐจ ๋ณต๊ตฌ์™€ browser-level Streamlit ์Šคํฌ๋ฆฐ์ƒท ์ฆ๊ฑฐ ์ˆ˜์ง‘ ์ ˆ์ฐจ ์ •์˜ + - QC evidence ๋“ฑ๊ธ‰ยท๊ฒŒ์ดํŠธ ๊ทœ์น™ ์ •์˜ ๋ฐ ๋ฌธ์„œํ™”(๊ฐ•ํ™”๋œ evidence ๊ธฐ์ค€) + - ๊ฒ€์ฆ ํ๋ฆ„/๋„๊ตฌ ๊ตฌ์กฐ์˜ `docs/runtime-flows.md`ยท`docs/project-structure.md` ๋ฐ˜์˜๊ณผ ๊ฒ€์ฆ runbook ํ›„๋ณด ์ถ”๊ฐ€ +- Out of Scope + - Streamlit ๊ธฐ๋Šฅ/ํ™”๋ฉด ์ž์ฒด์˜ ์‹ ๊ทœ ๊ธฐ๋Šฅ ์ถ”๊ฐ€๋‚˜ UX ์žฌ์„ค๊ณ„(๊ธฐ์กด ํ™”๋ฉด ๊ฒ€์ฆ์ด ๋ชฉ์ ์ด๋ฉฐ ๊ธฐ๋Šฅ ๋ณ€๊ฒฝ ์•„๋‹˜) + - full CI/CD ํŒŒ์ดํ”„๋ผ์ธ ์‹ ์„ค ๋ฐ ์™ธ๋ถ€ ํ˜ธ์ŠคํŒ… ๋„์ž…. CI ํ†ตํ•ฉ์€ Project #4์˜ `pytest/AppTest/Smoke CI ์ž๋™ํ™”` ํ•ญ๋ชฉ์œผ๋กœ ๋ถ„๋ฆฌํ•˜๊ณ , ์ด๋ฒˆ ๋ฐ˜๋ณต์€ ๋กœ์ปฌ ์žฌํ˜„ ์ ˆ์ฐจ๊นŒ์ง€๋งŒ ํฌํ•จ + - ์‹œ๊ฐ ํšŒ๊ท€(pixel-diff) ์ž๋™ ๋น„๊ต ๋“ฑ ๊ณ ๋„ํ™”๋œ visual regression. ์ด๋ฒˆ ๋ฐ˜๋ณต์€ ์ฆ๊ฑฐ ์บก์ฒ˜ยท์ €์žฅยท๋“ฑ๊ธ‰ํ™”๊นŒ์ง€๋กœ ํ•œ์ • + +# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก +๋ถˆํ™•์‹ค์„ฑ๊ณผ ์ „์ œ ์กฐ๊ฑด์„ ๋ถ„๋ฆฌํ•ด์„œ ์ ๋Š”๋‹ค. ๋ฆฌ์Šคํฌ๋Š” ์˜ํ–ฅ๊ณผ ์™„ํ™” ๋ฐฉ์•ˆ์„, ๊ฐ€์ •์€ ํ˜„์žฌ ํŒ๋‹จ์˜ ์ „์ œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. + +## ๋ฆฌ์Šคํฌ +- R-1 (High): ์‹คํ–‰ ํ™˜๊ฒฝ์ด ์—ฌ์ „ํžˆ Chromium ์„ค์น˜๋ฅผ ์ œ์•ฝํ•˜๋ฉด(snap ์ „์šฉ, ๊ถŒํ•œ ๋ถ€์กฑ ๋“ฑ) ํ™˜๊ฒฝ ๋ณต๊ตฌ ์ž์ฒด๊ฐ€ ์ฐจ๋‹จ๋˜์–ด ์ด๋ฒˆ ๋ฐ˜๋ณต ๋ชฉํ‘œ๊ฐ€ ๋ฏธ๋‹ฌ์„ฑ๋  ์ˆ˜ ์žˆ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ํ˜„์žฌ ์„ค์น˜๋œ `/usr/bin/google-chrome-stable` 149.0.7827.200์„ 1์ฐจ runtime์œผ๋กœ ์‚ฌ์šฉํ•˜๊ณ , snap ๊ธฐ๋ฐ˜ `chromium-browser`๋Š” ํ‘œ์ค€ ๊ฒฝ๋กœ์—์„œ ์ œ์™ธํ•œ๋‹ค. ์‹คํŒจ ์‹œ ์ง„๋‹จยท๋Œ€์ฒด ์ฆ๊ฑฐ ๊ฒฝ๋กœ๋ฅผ ๋ช…ํ™•ํžˆ ๋‚จ๊ธด๋‹ค. +- R-2 (Medium): Streamlit ํ™”๋ฉด์ด ๋น„๊ฒฐ์ •์ ์œผ๋กœ ๋ Œ๋”๋ง๋˜๋ฉด(๋น„๋™๊ธฐ ๋กœ๋”ฉ, ๋™์  ๋ฐ์ดํ„ฐ) ์Šคํฌ๋ฆฐ์ƒท ์ฆ๊ฑฐ๊ฐ€ ๋ถˆ์•ˆ์ •ํ•ด์ ธ ์žฌํ˜„์„ฑ์ด ๊นจ์ง„๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ๊ฒฐ์ •์  fixture DB์™€ ๋กœ๋“œ ์™„๋ฃŒ ๋Œ€๊ธฐ ์กฐ๊ฑด์„ ํ‘œ์ค€ํ™”ํ•˜๊ณ , ์บก์ฒ˜ ์ „ ์•ˆ์ •ํ™” ๋‹จ๊ณ„๋ฅผ ์ ˆ์ฐจ์— ํฌํ•จํ•œ๋‹ค. +- R-3 (Medium): evidence ๊ธฐ์ค€์„ ๊ณผ๋„ํ•˜๊ฒŒ ๊ฐ•ํ™”ํ•˜๋ฉด Chromium ๋ถˆ๊ฐ€ ํ™˜๊ฒฝ์—์„œ ์ •๋‹นํ•œ batch๊นŒ์ง€ ๊ฒŒ์ดํŠธ์— ๋ง‰ํž ์ˆ˜ ์žˆ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: "ํ™˜๊ฒฝ ์ œ์•ฝ์ด ์ž…์ฆ๋œ ๊ฒฝ์šฐ `PASS_WITH_NOTES` ํ—ˆ์šฉ + ์ž”์—ฌ ํ•ญ๋ชฉ ์ถ”์ "์˜ ์˜ˆ์™ธ ๊ฒฝ๋กœ๋ฅผ ๊ทœ์น™์— ๋ช…์‹œํ•ด ๊ฐ•ํ™”์™€ ์šด์˜ ํ˜„์‹ค์„ฑ์„ ์–‘๋ฆฝ์‹œํ‚จ๋‹ค. + +## ๊ฐ€์ • +์•„์ง ํ™•์ •๋˜์ง€ ์•Š์•˜์ง€๋งŒ ํ˜„์žฌ ๋ฌธ์„œ ์ž‘์„ฑ์˜ ๊ธฐ๋ฐ˜์ด ๋˜๋Š” ์ „์ œ๋ฅผ ์ ๋Š”๋‹ค. + +- A-1: ์‹คํŒจ ์›์ธ์€ Streamlit/๋Œ€์‹œ๋ณด๋“œ ์ฝ”๋“œ ๊ฒฐํ•จ์ด ์•„๋‹ˆ๋ผ Chromium ์‹คํ–‰ ํ™˜๊ฒฝ ๋ถ€์žฌ์ด๋ฉฐ, ํ™˜๊ฒฝ ํ™•๋ณด ์‹œ ๊ธฐ์กด AppTest๋กœ ํ†ต๊ณผํ•˜๋˜ ํ™”๋ฉด์€ browser-level๋กœ๋„ ๊ฒ€์ฆ ๊ฐ€๋Šฅํ•˜๋‹ค. +- A-2: ๊ธฐ์กด `streamlit.testing.v1.AppTest` smoke์™€ ํฌํŠธ 8502 HTTP 200 ํŒจํ„ด, synthetic fixture DB ๋ฐฉ์‹์„ browser E2E์˜ ๋ณด์กฐ ์ฆ๊ฑฐ ๋ฐ ๊ธฐ๋™ ํ† ๋Œ€๋กœ ์žฌ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ๋‹ค. +- A-3: E2E ๊ฒ€์ฆ์€ dcy-002์˜ ๋ฐฑ์—…ยท๊ฒ€์ฆ DB ์ ˆ์ฐจ๊ฐ€ ์šด์˜ ๊ฐ€๋Šฅํ•œ ์ƒํƒœ์ž„์„ ์ „์ œ๋กœ fixture/๋ณต์‚ฌ๋ณธ DB์—์„œ ์ˆ˜ํ–‰ํ•œ๋‹ค. + +## ์˜คํ”ˆ ์งˆ๋ฌธ +๋‹ต์„ ์•„์ง ๋ชจ๋ฅด๋Š” ํ•ญ๋ชฉ์„ ์ ๋Š”๋‹ค. ์ถ”์ •์œผ๋กœ ๋ฎ์ง€ ๋ง๊ณ  REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ด์ „์— ํ™•์ธํ•  ์งˆ๋ฌธ์œผ๋กœ ๋‚จ๊ธด๋‹ค. + +- OQ-1: browser ์ž๋™ํ™” ๋„๊ตฌ๋กœ ๋ฌด์—‡์„ ํ‘œ์ค€ํ™”ํ•  ๊ฒƒ์ธ๊ฐ€(Playwright / Selenium / ๋ฒˆ๋“ค Chromium + CDP ๋“ฑ)? dcy-001 OQ-1์˜ ๋ฏธํ•ด๊ฒฐ ๊ฒฐ์ •์„ ์ด๋ฒˆ ์ฃผ๊ธฐ๋กœ ์ธ๊ณ„ํ•œ๋‹ค. + - ์ƒํƒœ: Answered + - ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ: 1์ฐจ ํ‘œ์ค€์€ Hermes browser tool๊ณผ ํ˜„์žฌ ์„ค์น˜๋œ `/usr/bin/google-chrome-stable`์„ ์‚ฌ์šฉํ•œ ๋กœ์ปฌ browser evidence ์ ˆ์ฐจ๋กœ ํ•œ๋‹ค. Playwright Python์€ ํ›„์† CI ์ž๋™ํ™” ํ›„๋ณด๋กœ ๋‘๊ณ , Streamlit AppTest + HTTP 200 smoke๋Š” fallback evidence๋กœ ์‚ฌ์šฉํ•œ๋‹ค. snap ๊ธฐ๋ฐ˜ `chromium-browser`๋Š” ํ‘œ์ค€ ๊ฒฝ๋กœ์—์„œ ์ œ์™ธํ•œ๋‹ค. + - ์ข…๋ฃŒ ์กฐ๊ฑด: REQ์— Hermes browser/Google Chrome ๊ธฐ๋ฐ˜ 1์ฐจ ์ ˆ์ฐจ, Playwright ํ›„์† ํ›„๋ณด, AppTest/HTTP fallback, snap chromium ์ œ์™ธ ์›์น™์ด ๋ฐ˜์˜๋˜๋ฉด Answered +- OQ-2: ๊ฐ•ํ™”๋œ QC evidence ๊ธฐ์ค€์—์„œ browser-screenshot ์ฆ๊ฑฐ๋ฅผ ์–ด๋–ค batch ์œ ํ˜•์— "ํ•„์ˆ˜(blocker)"๋กœ ๋‘˜์ง€, ์–ด๋””๊นŒ์ง€ `PASS_WITH_NOTES` ์˜ˆ์™ธ๋ฅผ ํ—ˆ์šฉํ• ์ง€? + - ์ƒํƒœ: Answered + - ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ: Streamlit UI ๋˜๋Š” `dashboard/app.py`์— ์˜ํ–ฅ์„ ์ฃผ๋Š” batch๋Š” browser screenshot/snapshot evidence๋ฅผ ํ•„์ˆ˜๋กœ ํ•œ๋‹ค. ๋น„-UI batch๋Š” browser evidence๋ฅผ ์„ ํƒ์œผ๋กœ ๋‘๊ณ , docs-only batch๋Š” ๋ถˆํ•„์š”๋กœ ํ•œ๋‹ค. ํ™˜๊ฒฝ ์žฅ์• ๊ฐ€ ๋ช…ํ™•ํ•˜๋ฉด AppTest + HTTP 200 fallback evidence์™€ ์‹คํŒจ ์‚ฌ์œ ๋ฅผ ๋‚จ๊ธฐ๋Š” ์กฐ๊ฑด์œผ๋กœ `PASS_WITH_NOTES`๋ฅผ ํ—ˆ์šฉํ•œ๋‹ค. + - ์ข…๋ฃŒ ์กฐ๊ฑด: REQ์˜ QC gate ๊ทœ์น™์— UI/non-UI/docs-only ์œ ํ˜•๋ณ„ ์ตœ์†Œ ์ฆ๊ฑฐ์™€ `PASS_WITH_NOTES` ์˜ˆ์™ธ ์กฐ๊ฑด์ด ๋ฐ˜์˜๋˜๋ฉด Answered + +# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€ +์ด ๋ณ€๊ฒฝ์ด ์„ฑ๊ณตํ–ˆ๋‹ค๊ณ  ํŒ๋‹จํ•  ๊ธฐ์ค€์„ ์ธก์ • ๊ฐ€๋Šฅํ•˜๊ฒŒ ์ •์˜ํ•œ๋‹ค. ๊ฐ€๋Šฅํ•˜๋ฉด ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜๊นŒ์ง€ ์—ฐ๊ฒฐํ•œ๋‹ค. + +- S-1: ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ ํ‘œ์ค€ ์ ˆ์ฐจ๋กœ headless Chromium์„ ๊ธฐ๋™ํ•ด Streamlit ์ฃผ์š” ํ™”๋ฉด(์ตœ์†Œ ๋ถ„๋ฅ˜ ๊ทœ์น™, ํ™•์ธํ•„์š” ํ, import preview) ์ค‘ 1๊ฐœ ์ด์ƒ์—์„œ ์‹ค์ œ browser-level screenshot ์ฆ๊ฑฐ๋ฅผ ํ™•๋ณดํ•œ๋‹ค. +- S-2: QC evidence ๋“ฑ๊ธ‰๊ณผ batch ์œ ํ˜•๋ณ„ ์ตœ์†Œ ์ฆ๊ฑฐยท๊ฒŒ์ดํŠธ ๊ทœ์น™์ด ๋ฌธ์„œ๋กœ ์ •์˜๋˜์–ด, ๊ฒ€์ˆ˜๊ด€์ด ๋™์ผ ๊ธฐ์ค€์œผ๋กœ `PASS`/`PASS_WITH_NOTES`๋ฅผ ํŒ์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. +- S-3: ๊ฒ€์ฆ ํ™˜๊ฒฝยท์ฆ๊ฑฐ ์ ˆ์ฐจ๊ฐ€ `docs/runtime-flows.md`์™€ `docs/project-structure.md`์— ๋ฐ˜์˜๋˜๊ณ , Streamlit E2E ์ฆ๊ฑฐ ์ˆ˜์ง‘ runbook ํ›„๋ณด๊ฐ€ ์ถ”๊ฐ€๋˜์–ด ํ›„์† REQ/Batch์—์„œ ์ฐธ์กฐ ๊ฐ€๋Šฅํ•˜๋‹ค. + +## ์ธก์ • ๋ฐฉ์‹ ๋ฐ ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ +๊ฐ ์„ฑ๊ณต ๊ธฐ์ค€์„ ์–ด๋–ป๊ฒŒ ์ธก์ •ํ• ์ง€์™€ ์–ด๋–ค ๋ฐ์ดํ„ฐ๋‚˜ ๋ฌธ์„œ๋ฅผ ๊ทผ๊ฑฐ๋กœ ์“ธ์ง€ ์ ๋Š”๋‹ค. + +- S-1 ์ธก์ • ๋ฐฉ์‹: fixture DB๋กœ ๊ธฐ๋™ํ•œ Streamlit ํ™”๋ฉด์˜ ์Šคํฌ๋ฆฐ์ƒท ํŒŒ์ผ์ด ์ฆ๊ฑฐ ๋””๋ ‰ํ„ฐ๋ฆฌ์— ์ƒ์„ฑ๋˜์—ˆ๋Š”์ง€์™€ ์ ˆ์ฐจ ์žฌ์‹คํ–‰ ์‹œ ๋™์ผ ์ฆ๊ฑฐ๊ฐ€ ์žฌํ˜„๋˜๋Š”์ง€ ํ™•์ธ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: E2E ์Šคํฌ๋ฆฝํŠธ ์‹คํ–‰ ๋กœ๊ทธ, ์ €์žฅ๋œ screenshot ์ฆ๊ฑฐ ํŒŒ์ผ, fixture DB +- S-2 ์ธก์ • ๋ฐฉ์‹: evidence ๋“ฑ๊ธ‰ยท๊ฒŒ์ดํŠธ ๊ทœ์น™ ๋ฌธ์„œ์˜ ์กด์žฌ์™€, ๋™์ผ batch์— ๋Œ€ํ•ด ๊ฒ€์ˆ˜๊ด€ ํŒ์ •์ด ๊ทœ์น™๊ณผ ์ผ์น˜ํ•˜๋Š”์ง€ ๋ฆฌ๋ทฐ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: ์‹ ๊ทœ QC evidence ๊ธฐ์ค€ ๋ฌธ์„œ, batch verification ๋ฌธ์„œ์˜ evidence ๋“ฑ๊ธ‰ ๊ธฐ๋ก +- S-3 ์ธก์ • ๋ฐฉ์‹: ๋ฌธ์„œ ์กด์žฌ ๋ฐ ๊ฒ€์ฆ ํ๋ฆ„ยท๋„๊ตฌ ๊ตฌ์กฐ ๋ฐ˜์˜ ์—ฌ๋ถ€ ๋ฆฌ๋ทฐ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: `docs/runtime-flows.md`, `docs/project-structure.md`, `docs/runbooks/` + +# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ๋‹จ๊ณ„๋กœ ๋„˜๊ธฐ๊ธฐ ์ „์— ๋ฌด์—‡์ด ํ™•์ •๋˜์–ด์•ผ ํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค. ๋ฏธ์™„๋ฃŒ ํ•ญ๋ชฉ์€ ํ›„์† ๊ฒฐ์ •์ด ํ•„์š”ํ•จ์„ ๋œปํ•œ๋‹ค. + +- [x] ๋ฒ”์œ„(In Scope/Out of Scope) ํ™•์ • + - ๊ธฐ์ค€: ์ด๋ฒˆ ๋ฐ˜๋ณต์€ ๋กœ์ปฌ ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ browser evidence ๋ณต๊ตฌ์™€ ์ฆ๊ฑฐ ๋“ฑ๊ธ‰ํ™”๊นŒ์ง€๋กœ ํ•œ์ •ํ•œ๋‹ค. CI ํ†ตํ•ฉ์€ Project #4์˜ `pytest/AppTest/Smoke CI ์ž๋™ํ™”` ํ•ญ๋ชฉ์œผ๋กœ ๋ถ„๋ฆฌํ•˜๊ณ , visual regression์€ ์ œ์™ธํ•œ๋‹ค. +- [x] ์„ฑ๊ณต ์ง€ํ‘œ์˜ ์ธก์ • ๋ฐฉ์‹/๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ ํ™•์ • + - ๊ธฐ์ค€: Hermes browser/Google Chrome ๊ธฐ๋ฐ˜ screenshot/snapshot evidence, console ํ™•์ธ, Streamlit ์„œ๋ฒ„ ๋กœ๊ทธ, AppTest+HTTP fallback์ด REQ acceptance criteria์— ๋ฐ˜์˜๋œ๋‹ค. +- [x] High ์˜ํ–ฅ ๋ฆฌ์Šคํฌ ์šฐ์„ ์ˆœ์œ„ ๋ฐ ํ•ด์†Œ ์ฑ…์ž„์ž ์ง€์ • + - ๊ธฐ์ค€: R-1(Chromium ํ™•๋ณด ๋ถˆ๊ฐ€)์€ ํ˜„์žฌ ์„ค์น˜๋œ `/usr/bin/google-chrome-stable`์„ 1์ฐจ runtime์œผ๋กœ ์‚ฌ์šฉํ•˜๊ณ , ๋ ›์ €์žฅ์ธ์ด ๋กœ์ปฌ evidence ์ ˆ์ฐจ๋ฅผ ๊ตฌํ˜„ยท๊ฒ€์ฆํ•œ๋‹ค. +- [x] ๋ชจ๋“  ์˜คํ”ˆ ์งˆ๋ฌธ(OQ) `Open` ์ƒํƒœ ์—†์Œ ํ™•์ธ + - ๊ธฐ์ค€: OQ-1(๋„๊ตฌ ํ‘œ์ค€ํ™”), OQ-2(๊ฒŒ์ดํŠธ ์ •์ฑ…)๊ฐ€ Answered ์ฒ˜๋ฆฌ๋จ. +- [x] Freeze ํ™•์ • ๋‹ด๋‹น์ž(CONFIRMED_BY) ์ง€์ • + - ๊ธฐ์ค€: ์‚ฌ์šฉ์ž ์ง„ํ™˜ ๊ณ ๊ฐ€ ๊ถŒ์žฅ์•ˆ์„ ์Šน์ธํ•˜๊ณ  ๋‹ค์Œ ๊ณผ์ • ์ง„ํ–‰์„ ์ง€์‹œํ–ˆ๋‹ค. + +# 9. ํŒŒ์ผ ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ +์ด Discovery ๋ฌธ์„œ ์ƒ์„ฑ/๊ฐฑ์‹  ๊ฒฐ๊ณผ์™€ ์ฐธ์กฐ ๋ฌธ์„œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ์ดํ›„ ์ถ”์ ๊ณผ ๋ฆฌ๋ทฐ ์‹œ ๊ทผ๊ฑฐ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + +- ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ: ์ƒ์„ฑ +- ์ƒ์„ฑ ๊ฒฝ๋กœ: docs/discovery/dcy-004_20260629_browser-streamlit-e2e-evidence-recovery.md +- ์ฐธ์กฐ ๋ฌธ์„œ: + - docs/discovery/dcy-001_20260626_streamlit-ux-e2e-categorization-refund-workflows.md + - docs/batches/bat-001_20260626_category-rule-ops/verification.md + - docs/runtime-flows.md + - docs/project-structure.md + +# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ +์‚ฌ์šฉ์ž๋‚˜ ์Šน์ธ์ž๊ฐ€ ๊ฒฐ์ •ํ•ด์•ผ ํ•˜๋Š” ํ•ญ๋ชฉ๋งŒ ๋ชจ์•„ ์ •๋ฆฌํ•œ๋‹ค. ๊ฒฐ์ •, ํ™•์ธ, ๋ฐ์ดํ„ฐ ์š”์ฒญ์„ ๊ตฌ๋ถ„ํ•ด์„œ ์ ๋Š”๋‹ค. + +## DECIDE +- E2E 1์ฐจ ํ‘œ์ค€์€ Hermes browser tool๊ณผ ์„ค์น˜๋œ `/usr/bin/google-chrome-stable` ๊ธฐ๋ฐ˜ ๋กœ์ปฌ browser evidence ์ ˆ์ฐจ๋กœ ํ•œ๋‹ค. Playwright Python์€ ํ›„์† CI ์ž๋™ํ™” ํ›„๋ณด๋กœ ๋‘๊ณ , Streamlit AppTest + HTTP 200 smoke๋Š” fallback evidence๋กœ ์‚ฌ์šฉํ•œ๋‹ค. +- Streamlit UI ๋˜๋Š” `dashboard/app.py` ์˜ํ–ฅ batch๋Š” browser screenshot/snapshot evidence๋ฅผ ํ•„์ˆ˜๋กœ ํ•œ๋‹ค. ๋น„-UI batch๋Š” ์„ ํƒ, docs-only batch๋Š” ๋ถˆํ•„์š”๋กœ ํ•œ๋‹ค. ํ™˜๊ฒฝ ์žฅ์• ๊ฐ€ ๋ช…ํ™•ํ•˜๋ฉด AppTest + HTTP 200 fallback๊ณผ ์‹คํŒจ ์‚ฌ์œ ๋ฅผ ๋‚จ๊ธฐ๋Š” ์กฐ๊ฑด์œผ๋กœ `PASS_WITH_NOTES`๋ฅผ ํ—ˆ์šฉํ•œ๋‹ค. +- ์ด๋ฒˆ dcy-004๋Š” ๋กœ์ปฌ ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ browser evidence ๋ณต๊ตฌ๊นŒ์ง€๋กœ ํ•œ์ •ํ•œ๋‹ค. CI ํŒŒ์ดํ”„๋ผ์ธ ํ†ตํ•ฉ์€ Project #4์˜ `pytest/AppTest/Smoke CI ์ž๋™ํ™”` ํ•ญ๋ชฉ์—์„œ ๋ณ„๋„ ์ฒ˜๋ฆฌํ•œ๋‹ค. + +## CONFIRM +- Chromium/Chrome runtime์€ ํ˜„์žฌ ์„ค์น˜๋œ `/usr/bin/google-chrome-stable` 149.0.7827.200์„ 1์ฐจ runtime์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. snap ๊ธฐ๋ฐ˜ `chromium-browser`๋Š” ํ‘œ์ค€ ๊ฒฝ๋กœ์—์„œ ์ œ์™ธํ•œ๋‹ค. + +## DATA +- browser E2E๋Š” ์šด์˜ DB๊ฐ€ ์•„๋‹ˆ๋ผ synthetic/validation DB๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. ๊ณ„์ขŒ๋ฒˆํ˜ธ, ์‹ค์ œ ๊ฑฐ๋ž˜์ฒ˜, raw_payload, ์›๋ณธ ํŒŒ์ผ๋ช…, ๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” screenshot/log์— ๋…ธ์ถœํ•˜์ง€ ์•Š๊ณ  ๊ฐ€์งœ ๊ฐ’ ๋˜๋Š” ๊ณต๋ž€์œผ๋กœ ๋‘”๋‹ค. + +# 11. Discovery Freeze +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ธ๊ณ„ ์‹œ์ ์˜ ํ™•์ • ํ”Œ๋ž˜๊ทธ์™€ ์„น์…˜ ์ฐธ์กฐ๋งŒ ๊ธฐ๋กํ•œ๋‹ค. ๋‚ด์šฉ ์žฌ์ž‘์„ฑ ์—†์ด ์›๋ณธ ์„น์…˜์„ ๋งํฌ๋กœ ๋Œ€์‹ ํ•œ๋‹ค. + +## ์„น์…˜ ์ฐธ์กฐ +- ๋ฒ”์œ„: [# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก > ๋ฒ”์œ„ ๊ฒฝ๊ณ„](#5-์š”๊ตฌ์‚ฌํ•ญ-๋ชฉ๋ก) +- ๋ฆฌ์Šคํฌ/์˜คํ”ˆ ์งˆ๋ฌธ: [# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก](#6-๋ฆฌ์Šคํฌ๊ฐ€์ •-๋ชฉ๋ก) +- ์„ฑ๊ณต ๊ธฐ์ค€: [# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€](#7-์ดˆ๊ธฐ-์„ฑ๊ณต-๊ธฐ์ค€) +- ์ธ๊ณ„ ์ „ ํ™•์ธ: [# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ](#8-req๋กœ-๋„˜๊ธฐ๊ธฐ-์ „-ํ™•์ธ-์ฒดํฌ) +- ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š”: [# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ](#10-์‚ฌ์šฉ์ž-๊ฒฐ์ •-ํ•„์š”-ํ•ญ๋ชฉ-์š”์•ฝ) + +## Freeze ํ”Œ๋ž˜๊ทธ +- Handoff Decision: ์Šน์ธ +- Handoff Rationale: ์‚ฌ์šฉ์ž ์Šน์ธ์œผ๋กœ E2E ๋„๊ตฌ ํ‘œ์ค€, evidence gate ์ •์ฑ…, CI ๋ฒ”์œ„, Chrome runtime, fixture/๋งˆ์Šคํ‚น ์›์น™์ด ํ™•์ •๋˜์—ˆ์œผ๋ฏ€๋กœ REQ ์ดˆ์•ˆ ์ž‘์„ฑ์œผ๋กœ ๋„˜๊ธด๋‹ค. +- Ready for REQ Drafting: true +- Confirmed By: ์ง„ํ™˜ ๊ณ  +- Confirmed At (KST): 2026-06-29 13:49 diff --git a/docs/discovery/dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md b/docs/discovery/dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md new file mode 100644 index 0000000..c28dd81 --- /dev/null +++ b/docs/discovery/dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md @@ -0,0 +1,209 @@ +# 0. ๋ฌธ์„œ ์ƒํƒœ +์ด ๋ฌธ์„œ์˜ ํ˜„์žฌ ์ƒํƒœ์™€ ์‹๋ณ„ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ๋ฌธ์„œ ์ถ”์ ๊ณผ ์Šน์ธ ํ๋ฆ„์— ํ•„์š”ํ•œ ์ตœ์†Œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ ๋Š”๋‹ค. + +- ์ƒํƒœ: confirmed +- ๋ฌธ์„œ ID: dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow +- ์ด์Šˆ๋ช…: Jarca Ledger ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ์‚ฌ์–‘ ์ •๋ฆฝ (์ด๋ฒˆ ๋‹ฌ ํ˜„๊ธˆํ๋ฆ„ + ์ž์‚ฐ ์œ ๋™์„ฑ + ์šด์˜ ๋ฏธ์ฒ˜๋ฆฌ) +- ์ž‘์„ฑ ์‹œ๊ฐ(KST): 2026-06-29 14:02 KST +- ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹  ์‹œ๊ฐ(KST): 2026-06-29 20:03 KST +- ๋Œ€์ฒด๋จ: ์—†์Œ +- ํ›„์† Discovery ์ฐธ์กฐ: ์—†์Œ +- ์ƒ์„ฑ๋œ REQ ์ฐธ์กฐ: REQ-005 (`docs/srs/Interface/req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md`) + +# 1. ๊ณ„ํš ์ƒํƒœ ์š”์•ฝ +ํ˜„์žฌ Discovery๊ฐ€ ์–ด๋–ค ๋ฐฐ๊ฒฝ์—์„œ ์ž‘์„ฑ๋˜๊ณ  ์žˆ๋Š”์ง€, ์—ฐ๊ณ„ ๋ฌธ์„œ๊ฐ€ ์žˆ๋Š”์ง€, ์ง€๊ธˆ ์‹œ์ ์˜ ํ•ด์„์ด ๋ฌด์—‡์ธ์ง€ ์งง๊ฒŒ ์š”์•ฝํ•œ๋‹ค. + +- ์—ฐ๊ณ„ ๋ฌธ์„œ ํ˜„ํ™ฉ: + - ๊ด€๋ จ ์ ์ˆ˜ ํŒŒ์ผ: ๋ฏธ์ƒ์„ฑ + - ๊ด€๋ จ TODO/๋ฉ”๋ชจ: ์—†์Œ +- ํ•ด์„: + - ๋Œ€์‹œ๋ณด๋“œ ์ฒซ ํ™”๋ฉด(`page_overview`)์ด ํ˜„์žฌ๋Š” ์ „์ฒด ๊ธฐ๊ฐ„ ๋ˆ„์  ์†์ตยท๊ณ„์ขŒ ์ž…์ถœ๊ธˆ ์œ„์ฃผ๋ผ, "์ด๋ฒˆ ๋‹ฌ ์šด์˜ ์ƒํƒœ"์™€ "ํ˜„์žฌ ์ž์‚ฐ ์œ ๋™์„ฑ/๋ถ€์ฑ„ ๊ตฌ์กฐ"๋ฅผ ํ•œ ํ™”๋ฉด์—์„œ ๋ณด๋ ค๋Š” ์šด์˜์ž ์š”๊ตฌ๋ฅผ ์ถฉ์กฑํ•˜์ง€ ๋ชปํ•œ๋‹ค. + - ์ด๋ฒˆ Discovery๋Š” ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ๋‹จ์ผ ์šด์˜ ํ—ˆ๋ธŒ๋กœ ์žฌ์ •์˜ํ•˜๊ณ , ์ด๋ฒˆ ๋‹ฌ ํ˜„๊ธˆํ๋ฆ„ยท์ž์‚ฐ ์œ ๋™์„ฑยท์šด์˜ ๋ฏธ์ฒ˜๋ฆฌ ๊ฒฝ๊ณ ๋ฅผ ์šฐ์„ ์ˆœ์œ„ ๊ตฌ์กฐ๋กœ ๋ฌถ๋Š” ๋ณ€๊ฒฝ ๋ฒ”์œ„์™€ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ ๋ณด๊ฐ•(`liquidity_bucket`) ํ•„์š”์„ฑ์„ ํŒ๋‹จํ•œ๋‹ค. + +# 2. ์š”๊ตฌ์‚ฌํ•ญ ํŒ์ • ๊ฒฐ๊ณผ +์ด๋ฒˆ ์š”์ฒญ์ด ๊ธฐ์กด ์š”๊ตฌ์‚ฌํ•ญ๊ณผ ๋น„๊ตํ•ด ์‹ ๊ทœ์ธ์ง€, ์œ ์‚ฌํ•œ์ง€, ์ค‘๋ณต์ธ์ง€ ํŒ๋‹จํ•˜๊ณ  ๊ทธ ๊ทผ๊ฑฐ๋ฅผ ๋‚จ๊ธด๋‹ค. + +- ํŒ์ •: ์‹ ๊ทœ +- ๊ทผ๊ฑฐ + - dcy-001์€ Streamlit UX/E2Eยท๋ถ„๋ฅ˜ยทํ™˜๊ธ‰ ์›Œํฌํ”Œ๋กœ์šฐ ์•ˆ์ •ํ™”, dcy-002~004๋Š” ๋ฐฑ์—…ยท๋ณต๊ตฌ, statement-unavailable ์ˆ˜๋™ ์ฆ๋น™/์ž”์•ก๋Œ€์กฐ, E2E evidence ํ™˜๊ฒฝ ๋ณต๊ตฌ๋กœ ๋ฌธ์ œ ์ •์˜๊ฐ€ ๋‹ค๋ฅด๋ฉฐ "๋Œ€์‹œ๋ณด๋“œ ํ™”๋ฉด ๊ตฌ์„ฑยท์ž์‚ฐ ์œ ๋™์„ฑ ๋ทฐ"๋ฅผ ๋‹ค๋ฃจ์ง€ ์•Š๋Š”๋‹ค. + - ๋ณธ ์š”์ฒญ์€ ๋Œ€์‹œ๋ณด๋“œ ์ •๋ณด๊ตฌ์กฐ ์žฌ์ •์˜์™€ ์‹ ๊ทœ ์ž์‚ฐ ๋ถ„๋ฅ˜ ์ถ•(`liquidity_bucket`) ๋„์ž…์„ ํ•ต์‹ฌ์œผ๋กœ ํ•˜๋ฏ€๋กœ ๊ธฐ์กด ํ›„๋ณด์˜ ๋ฏธ์„ธ ์กฐ์ •์ด ์•„๋‹ˆ๋ผ ๋…๋ฆฝ ์ฃผ๊ธฐ๋กœ ์ถ”์ ํ•˜๋Š” ๊ฒƒ์ด ๋ช…ํ™•ํ•˜๋‹ค. + +# 3. ๋ฌธ์ œ์ ์˜ ์š”์•ฝ +๋ฌธ์ œ์˜ ํ˜„์žฌ ์ƒํƒœ, ๊ธฐ๋Œ€ ์ƒํƒœ, ๊ด€๋ จ ์ดํ•ด๊ด€๊ณ„์ž, ์ œ์•ฝ์‚ฌํ•ญ์„ ์ •๋ฆฌํ•œ๋‹ค. Discovery์˜ ํ•ต์‹ฌ ๋งฅ๋ฝ์„ ๊ฐ€์žฅ ๋จผ์ € ์ดํ•ดํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. + +- ํ˜„์žฌ ์ƒํƒœ + - `dashboard/app.py`์˜ `page_overview`๋Š” `status='active'` ์ „์ฒด ๊ฑฐ๋ž˜๋ฅผ ๋ˆ„์  ์ง‘๊ณ„ํ•ด "์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ/์ˆœํ˜„๊ธˆํ๋ฆ„/์ €์ถ•๋ฅ /ํ™•์ธํ•„์š”"์™€ "๊ณ„์ขŒ ์ž…์ถœ๊ธˆ ํ๋ฆ„", ์›”๋ณ„/์ธ์›๋ณ„/์นดํ…Œ๊ณ ๋ฆฌ Top20 ์ฐจํŠธ๋ฅผ ๋ณด์—ฌ์ค€๋‹ค. ์ƒ๋‹จ ์ง€ํ‘œ๊ฐ€ ๋‹น์›”์ด ์•„๋‹ˆ๋ผ ์ „์ฒด ๊ธฐ๊ฐ„ ํ•ฉ๊ณ„ ๊ธฐ์ค€์ด๋ผ "์ด๋ฒˆ ๋‹ฌ ํ˜„ํ™ฉ"์„ ์ฆ‰์‹œ ์ฝ๊ธฐ ์–ด๋ ต๋‹ค. + - ์ž์‚ฐ ์ธก๋ฉด์—์„œ `ledger_accounts.type`์€ bank/card/cash/investment/loan ๊ตฌ๋ถ„๋งŒ ์žˆ๊ณ (`docs/data-model.md` Entity: LedgerAccount), ํ˜„๊ธˆยทํˆฌ์žยท์˜ˆ์ ๊ธˆยท๋ถ€๋™์‚ฐยท๋Œ€์ถœ์„ ์œ ๋™์„ฑ ๊ด€์ ์œผ๋กœ ๋ฌถ๋Š” `liquidity_bucket` ์ถ•์€ ์—†๋‹ค. ์ž์‚ฐ ์œ ๋™์„ฑ/๊ตฌ์„ฑ/์ˆœ์ž์‚ฐ ๋ทฐ๊ฐ€ ๋Œ€์‹œ๋ณด๋“œ์— ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค. + - ์šด์˜ ๋ฏธ์ฒ˜๋ฆฌ ์‹ ํ˜ธ(๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜, ํ™•์ธํ•„์š” ํ, ์ž”์•ก๋Œ€์กฐ ํ•„์š”, ๋ฐฑ์—…/import ์ƒํƒœ)๋Š” `page_uncategorized`, `page_cross_transfer_review`, `docs/runbooks/manual-balance-reconciliation.md`, ๋ฐฑ์—… ์›Œํฌํ”Œ๋กœ์šฐ ๋“ฑ ๋ณ„๋„ ํ™”๋ฉด/๋ฌธ์„œ์— ํฉ์–ด์ ธ ์žˆ์–ด ํ•œ ํ™”๋ฉด์—์„œ ์šด์˜ ์ƒํƒœ๋ฅผ ์ ๊ฒ€ํ•˜์ง€ ๋ชปํ•œ๋‹ค. + - `docs/project-structure.md`, `docs/runtime-flows.md` baseline ๋ฌธ์„œ๋Š” ์กด์žฌํ•˜๋‚˜ ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ์ •๋ณด๊ตฌ์กฐ์™€ `liquidity_bucket` ๋ฐ์ดํ„ฐ ํ๋ฆ„์„ ์•„์ง ๋ฐ˜์˜ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- ๊ธฐ๋Œ€ ์ƒํƒœ + - ๋Œ€์‹œ๋ณด๋“œ ์ฒซ ํ™”๋ฉด์ด "์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ โ†’ ์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ โ†’ ์ž์‚ฐ ๊ตฌ์„ฑ โ†’ ์ง€์ถœ ๊ตฌ์กฐ/์˜ˆ์‚ฐ โ†’ ์šด์˜ ๊ฒฝ๊ณ " ์ˆœ์„œ์˜ ์šด์˜ ํ—ˆ๋ธŒ๋กœ ๋™์ž‘ํ•œ๋‹ค. + - ์ž์‚ฐ์„ ์œ ๋™์„ฑ ๋ฒ„ํ‚ท(ํ˜„๊ธˆ/ํˆฌ์ž/์˜ˆ์ ๊ธˆ/๋ถ€๋™์‚ฐ/๋Œ€์ถœ)์œผ๋กœ ๋ฌถ์–ด ๊ธˆ์•กยท๋น„์œจ์„ ํ™•์ธํ•˜๊ณ , ๋Œ€์ถœ์€ ์ž์‚ฐ ๊ตฌ์„ฑ ๋น„์œจ์—์„œ ์ œ์™ธํ•˜๋˜ ๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ์—์„œ ๋ณ„๋„ ์ฐจ๊ฐํ•œ๋‹ค. +- ์ดํ•ด๊ด€๊ณ„์ž + - ๊ฐ€๊ณ„ ์žฌ๋ฌด๋ฅผ ์šด์˜ํ•˜๋Š” ์‚ฌ์šฉ์ž(๋Œ€์‹œ๋ณด๋“œ 1์ฐจ ์‚ฌ์šฉ์ž) + - ๋ฌธ์„œ/์š”๊ตฌ์‚ฌํ•ญ ๋ฆฌ๋ทฐ์–ด ๋ฐ ์Šน์ธ์ž(Hermes/์‚ฌ์šฉ์ž) + - ๋Œ€์‹œ๋ณด๋“œยท์„œ๋น„์ŠคยทDB๋ฅผ ๊ตฌํ˜„/์šด์˜ํ•˜๋Š” ๊ฐœ๋ฐœ ๋‹ด๋‹น์ž +- ์ œ์•ฝ์‚ฌํ•ญ + - DB ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ์€ additive/idempotent ์›์น™์„ ๋”ฐ๋ฅด๋ฉฐ ํŒŒ๊ดด์  ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜์€ ๋ช…์‹œ ์Šน์ธยท๋ฐฑ์—… ํ›„์—๋งŒ ํ•œ๋‹ค(`docs/data-model.md` Persistence Notes). + - ๋ถ€๋™์‚ฐยท๋ฌด๊ธฐํ•œ ์ž์‚ฐ์ฒ˜๋Ÿผ ๊ฑฐ๋ž˜ ๊ธฐ๋ฐ˜์ด ์•„๋‹Œ ์ž์‚ฐ์€ ์ž…๋ ฅ ์†Œ์Šค๊ฐ€ ๋ถˆ๋ช…ํ™•ํ•ด ๋ฐ์ดํ„ฐ ํ™•๋ณด ๋ฐฉ์‹ ๊ฒฐ์ •์ด ์„ ํ–‰๋˜์–ด์•ผ ํ•œ๋‹ค. + - ๋ณธ Discovery ๋‹จ๊ณ„์—์„œ๋Š” ์†Œ์Šค ์ฝ”๋“œ/ํ…Œ์ŠคํŠธ/์Šคํ‚ค๋งˆ๋ฅผ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š๊ณ  ์‚ฌ์–‘๋งŒ ์ •์˜ํ•œ๋‹ค. + +## ์ฃผ์š” ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค +์‚ฌ์šฉ์ž ๋˜๋Š” ์šด์˜์ž๊ฐ€ ์‹ค์ œ๋กœ ์–ด๋–ค ์ƒํ™ฉ์—์„œ ์ด ๋ณ€๊ฒฝ์„ ํ•„์š”๋กœ ํ•˜๋Š”์ง€ ๋Œ€ํ‘œ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ ๋Š”๋‹ค. + +- ์‚ฌ์šฉ์ž๊ฐ€ ์›”์ค‘์— ๋Œ€์‹œ๋ณด๋“œ๋ฅผ ์—ด์–ด "์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…ยท์ง€์ถœยท์ˆœํ˜„๊ธˆํ๋ฆ„ยท์ €์ถ•๋ฅ "์„ ์ตœ์ƒ๋‹จ์—์„œ ์ฆ‰์‹œ ํ™•์ธํ•˜๊ณ  ์ด๋ฒˆ ๋‹ฌ ์ง€์ถœ ์†๋„๋ฅผ ์ ๊ฒ€ํ•œ๋‹ค. +- ์‚ฌ์šฉ์ž๊ฐ€ "์ง€๊ธˆ ๋‹น์žฅ ์“ธ ์ˆ˜ ์žˆ๋Š” ํ˜„๊ธˆ์ด ์–ผ๋งˆ์ด๊ณ , 1~2์ฃผ ๋‚ด ๊ฐ€์šฉ ํˆฌ์ž/์˜ˆ์ ๊ธˆ์ด ์–ผ๋งˆ์ด๋ฉฐ, ๋ถ€๋™์‚ฐยท๋Œ€์ถœ๊นŒ์ง€ ํฌํ•จํ•œ ์ˆœ์ž์‚ฐ์ด ์–ผ๋งˆ์ธ์ง€" ์ž์‚ฐ ์œ ๋™์„ฑยท๊ตฌ์„ฑ ์„น์…˜์—์„œ ํ•œ๋ˆˆ์— ํŒŒ์•…ํ•œ๋‹ค. +- ์‚ฌ์šฉ์ž๊ฐ€ ๊ฐ™์€ ํ™”๋ฉด ํ•˜๋‹จ์˜ ์šด์˜ ๊ฒฝ๊ณ ์—์„œ ๋ฏธ๋ถ„๋ฅ˜ ๊ฑด์ˆ˜, ํ™•์ธํ•„์š” ํ, ์ž”์•ก๋Œ€์กฐ ํ•„์š” ๊ณ„์ขŒ, ์ตœ๊ทผ ๋ฐฑ์—…/import ์ƒํƒœ๋ฅผ ๋ณด๊ณ  ํ›„์† ์ •๋ฆฌ ์ž‘์—…์œผ๋กœ ์ด๋™ํ•œ๋‹ค. + +# 4. ์ด๋ฒˆ์— ์ •์˜ํ•  ๋ณ€๊ฒฝ +์ด๋ฒˆ Discovery์—์„œ ๋‹ค๋ฃจ๋Š” ํ•ต์‹ฌ ๋ณ€๊ฒฝ ํ•ญ๋ชฉ์„ ๋ชฉ๋ก์œผ๋กœ ์ ๋Š”๋‹ค. ๊ธฐ๋Šฅ, ๊ตฌ์กฐ, ๊ทœ์น™ ๋ณ€๊ฒฝ์„ ํ•จ๊ป˜ ํฌํ•จํ•ด๋„ ๋œ๋‹ค. + +- ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ์ •๋ณด๊ตฌ์กฐ ํ™•์ •: โ‘  ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ โ‘ก ์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ โ‘ข ์ž์‚ฐ ๊ตฌ์„ฑ โ‘ฃ ์ง€์ถœ ๊ตฌ์กฐ/์˜ˆ์‚ฐ โ‘ค ์šด์˜ ๊ฒฝ๊ณ ์˜ 5๊ฐœ ์„น์…˜ ์šฐ์„ ์ˆœ์œ„์™€ ๊ฐ ์„น์…˜ ํ‘œ์‹œ ์ง€ํ‘œ๋ฅผ ์‚ฌ์–‘์œผ๋กœ ์ •์˜ํ•œ๋‹ค. +- ์ž์‚ฐ ์œ ๋™์„ฑ ๋ถ„๋ฅ˜ ์ถ• ๋„์ž… ๊ฒ€ํ† : `liquidity_bucket`(cash, investment, deposit, real_estate, loan, other)์„ ๊ถŒ์žฅ ํ•„๋“œ๋กœ ์ •์˜ํ•˜๊ณ , ๋Œ€์ถœ์€ ์ž์‚ฐ ๊ตฌ์„ฑ ๋น„์œจ์—์„œ ์ œ์™ธยท๋ถ€์ฑ„/์ˆœ์ž์‚ฐ์—์„œ ๋ณ„๋„ ์ฐจ๊ฐํ•˜๋Š” ์ง‘๊ณ„ ๊ทœ์น™์„ ์ •ํ•œ๋‹ค. +- baseline ๋ฌธ์„œ ๋ฐ˜์˜: `liquidity_bucket` ๋ฐ์ดํ„ฐ ๋ชจ๋ธ๊ณผ ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ํ™”๋ฉด ํ๋ฆ„์„ `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md`์— ๊ฐฑ์‹  ์š”๊ตฌ๋กœ ๋“ฑ๋กํ•œ๋‹ค. + +## ์˜ํ–ฅ ๋ฒ”์œ„ +๋ณ€๊ฒฝ์ด ๋ฏธ์น  ์ฝ”๋“œ/๋ฌธ์„œ/์šด์˜ ๋ฒ”์œ„๋ฅผ ๋ช…์‹œํ•ด REQ ์ •์ œ์™€ batch planning์—์„œ ์ถ”์ • ๋ˆ„๋ฝ์„ ์ค„์ธ๋‹ค. + +- ์ฝ”๋“œ ์˜ํ–ฅ: + - `dashboard/app.py`์˜ `page_overview` ์žฌ๊ตฌ์„ฑ(์ด๋ฒˆ ๋‹ฌ ์Šค์ฝ”ํ”„ ์ง‘๊ณ„, ์ž์‚ฐ ์œ ๋™์„ฑ/๊ตฌ์„ฑ/์ˆœ์ž์‚ฐ ์„น์…˜, ์šด์˜ ๊ฒฝ๊ณ  ํ†ตํ•ฉ ํŒจ๋„) ๋ฐ ๊ด€๋ จ ์ง‘๊ณ„ ํ—ฌํผ. + - `liquidity_bucket` ๋„์ž… ์‹œ `jarca_ledger_mcp/db.py` ์Šคํ‚ค๋งˆ(additive)์™€ `service.py` ์ž์‚ฐ/์œ ๋™์„ฑ ์ง‘๊ณ„ ํ•จ์ˆ˜, ๋ถ„๋ฅ˜ ๋งคํ•‘ ๋กœ์ง. +- ๋ฌธ์„œ ์˜ํ–ฅ: + - `docs/data-model.md`(LedgerAccount/LedgerCategory์— `liquidity_bucket` ์ถ•, ์ˆœ์ž์‚ฐ/๋ถ€์ฑ„ ์ง‘๊ณ„ ๊ทœ์น™), `docs/project-structure.md`, `docs/runtime-flows.md`(๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ํ™”๋ฉด ํ๋ฆ„) ๊ฐฑ์‹ . +- ์šด์˜ ์˜ํ–ฅ: + - ๋ถ€๋™์‚ฐยท๋Œ€์ถœ ๋“ฑ ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ ์ž…๋ ฅ/๊ฐฑ์‹  ์ ˆ์ฐจ๊ฐ€ ์‹ ์„ค๋  ์ˆ˜ ์žˆ์–ด ์šด์˜ ์ž…๋ ฅ ๋ฃจํ‹ด๊ณผ ์ž”์•ก๋Œ€์กฐ ์šด์˜ ํ๋ฆ„์— ์˜ํ–ฅ์„ ์ค€๋‹ค. + +# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก +์‹ค์ œ๋กœ ๊ตฌํ˜„๊ณผ ๊ฒ€์ฆ์œผ๋กœ ์ด์–ด์งˆ ์š”๊ตฌ์‚ฌํ•ญ์„ ๊ธฐ๋Šฅ/๋น„๊ธฐ๋Šฅ์œผ๋กœ ๋‚˜๋ˆ„์–ด ์ •๋ฆฌํ•˜๊ณ , ๋ฒ”์œ„ ๊ฒฝ๊ณ„๋ฅผ ๋ช…ํ™•ํžˆ ์ ๋Š”๋‹ค. + +## ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +- FR-1: ๋Œ€์‹œ๋ณด๋“œ ์ตœ์ƒ๋‹จ 1์ˆœ์œ„ ์„น์…˜์€ "์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ"์œผ๋กœ, ๋‹น์›” ๊ธฐ์ค€ ์‹ค์ œ ์ˆ˜์ž…ยท์‹ค์ œ ์ง€์ถœยท์ˆœํ˜„๊ธˆํ๋ฆ„ยท์ €์ถ•๋ฅ ์„ ํ‘œ์‹œํ•œ๋‹ค. ๋‹น์›” ๋ฒ”์œ„๋Š” ๊ฑฐ๋ž˜์ผ(`transaction_date`) ๊ธฐ์ค€ ํ˜„์žฌ ์›”์„ ๊ธฐ๋ณธ์œผ๋กœ ํ•œ๋‹ค. +- FR-2: "์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ" ์„น์…˜์€ ํ˜„๊ธˆ(๋‹น์žฅ ์‚ฌ์šฉ ๊ฐ€๋Šฅ), ํˆฌ์ž(1~2์ฃผ ๋‚ด ์‚ฌ์šฉ ๊ฐ€๋Šฅ), ์˜ˆ์ ๊ธˆ(๋‹ฌ~๋…„), ๋ถ€๋™์‚ฐ(๋ฌด๊ธฐํ•œ), ๋Œ€์ถœ(๋ถ€์ฑ„)์„ ๋ฒ„ํ‚ท์œผ๋กœ ๋ฌถ์–ด ๊ฐ ๊ธˆ์•ก๊ณผ ๋น„์œจ์„ ํ‘œ์‹œํ•œ๋‹ค. ๋น„์œจ ๋ถ„๋ชจ๋Š” ๋Œ€์ถœ(๋ถ€์ฑ„)์„ ์ œ์™ธํ•œ ์ž์‚ฐ ํ•ฉ๊ณ„๋กœ ํ•˜๊ณ , ๋Œ€์ถœ์€ ๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ์—์„œ ๋ณ„๋„ ์ฐจ๊ฐํ•œ๋‹ค. +- FR-3: ๋Œ€์‹œ๋ณด๋“œ๋Š” โ‘  ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ โ‘ก ์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ โ‘ข ์ž์‚ฐ ๊ตฌ์„ฑ โ‘ฃ ์ง€์ถœ ๊ตฌ์กฐ/์˜ˆ์‚ฐ โ‘ค ์šด์˜ ๊ฒฝ๊ณ (๋ฏธ๋ถ„๋ฅ˜, ํ™•์ธํ•„์š”, ์ž”์•ก๋Œ€์กฐ ํ•„์š”, ๋ฐฑ์—…/import ์ƒํƒœ)์˜ ์ˆœ์„œ๋กœ ์„น์…˜์„ ๋ฐฐ์น˜ํ•œ๋‹ค. +- FR-4: ์ž์‚ฐ์„ ์œ ๋™์„ฑ ๊ด€์ ์œผ๋กœ ๋ฌถ๊ธฐ ์œ„ํ•ด `accounts.liquidity_bucket`(cash, investment, deposit, real_estate, loan, other) ๋ถ„๋ฅ˜ ์ถ•์„ ๋ฐ์ดํ„ฐ ๋ชจ๋ธ์— ๋„์ž…ํ•œ๋‹ค. `liquidity_bucket`์˜ ๊ถŒ์œ„ ์†Œ์Šค๋Š” ๊ณ„์ขŒ ๋‹จ์œ„์ด๋ฉฐ, ๋ฏธ์ง€์ • ๊ณ„์ขŒ๋Š” `other`๋กœ ํด๋ฐฑํ•œ๋‹ค. ๊ฑฐ๋ž˜/์นดํ…Œ๊ณ ๋ฆฌ ๋‹จ์œ„ override๋Š” ์ด๋ฒˆ ๋ฒ”์œ„์—์„œ ์ œ์™ธํ•œ๋‹ค. +- FR-5: `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md` baseline ๋ฌธ์„œ์— `liquidity_bucket` ๋ชจ๋ธ๊ณผ ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ํ™”๋ฉด ํ๋ฆ„ ๊ฐฑ์‹ ์„ ํฌํ•จํ•œ๋‹ค. + +## ๋น„๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +์„ฑ๋Šฅ, ์šด์˜์„ฑ, ์ผ๊ด€์„ฑ, ์ž๋™ํ™” ์•ˆ์ •์„ฑ ๋“ฑ ๊ธฐ๋Šฅ ์™ธ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ ๋Š”๋‹ค. + +- NFR-1 (์ผ๊ด€์„ฑ): ์ž์‚ฐ ๊ตฌ์„ฑ ๋น„์œจ๊ณผ ์ˆœ์ž์‚ฐ/๋ถ€์ฑ„ ๊ณ„์‚ฐ์€ ๋‹จ์ผ ์ •์˜(๋Œ€์ถœ์€ ๊ตฌ์„ฑ ๋น„์œจ ์ œ์™ธ, ์ˆœ์ž์‚ฐ์—์„œ ์ฐจ๊ฐ)๋ฅผ ๋ชจ๋“  ์„น์…˜ยท์ง‘๊ณ„์—์„œ ๋™์ผํ•˜๊ฒŒ ์ ์šฉํ•œ๋‹ค. +- NFR-2 (์šด์˜์„ฑ): ์šด์˜ ๊ฒฝ๊ณ  ์„น์…˜์˜ ๊ฐ ํ•ญ๋ชฉ(๋ฏธ๋ถ„๋ฅ˜ยทํ™•์ธํ•„์š”ยท์ž”์•ก๋Œ€์กฐยท๋ฐฑ์—…/import)์€ ๊ฑด์ˆ˜/์ƒํƒœ์™€ ํ•จ๊ป˜ ํ•ด๋‹น ์ƒ์„ธ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•  ์ˆ˜ ์žˆ๋Š” ๋‹จ์„œ๋ฅผ ์ œ๊ณตํ•œ๋‹ค. +- NFR-3 (์•ˆ์ •์„ฑ): `accounts.liquidity_bucket` ์Šคํ‚ค๋งˆ ๋ณ€๊ฒฝ์€ additive/idempotent๋กœ ์ ์šฉํ•˜๊ณ , ๋ฏธ์ง€์ • ๊ณ„์ขŒ๋Š” `other`๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ํด๋ฐฑํ•ด ๊ธฐ์กด ๋Œ€์‹œ๋ณด๋“œ ๋™์ž‘์„ ๊นจ์ง€ ์•Š๋Š”๋‹ค. + +## ๋ฒ”์œ„ ๊ฒฝ๊ณ„ +์ด๋ฒˆ ๋ฐ˜๋ณต์—์„œ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•  ๋ฒ”์œ„์™€ ๋ช…์‹œ์ ์œผ๋กœ ์ œ์™ธํ•  ๋ฒ”์œ„๋ฅผ ๊ตฌ๋ถ„ํ•œ๋‹ค. + +- In Scope + - ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ 5๊ฐœ ์„น์…˜ ์ •๋ณด๊ตฌ์กฐ์™€ ๊ฐ ์„น์…˜ ํ•ต์‹ฌ ์ง€ํ‘œ ์‚ฌ์–‘ ์ •์˜. + - `accounts.liquidity_bucket` ์ถ•๊ณผ ์ž์‚ฐ ์œ ๋™์„ฑ/๊ตฌ์„ฑ/์ˆœ์ž์‚ฐยท๋ถ€์ฑ„ ์ง‘๊ณ„ ๊ทœ์น™ ์ •์˜ ๋ฐ baseline ๋ฌธ์„œ ๊ฐฑ์‹  ์š”๊ตฌ. +- Out of Scope + - ๋‹ค๊ณ„์ขŒ ์‹ค์‹œ๊ฐ„ ์ž”์•ก ์—ฐ๋™, ์™ธ๋ถ€ ์‹œ์„ธ/ํˆฌ์ž ํ‰๊ฐ€์•ก ์ž๋™ ์—ฐ๋™. + - ์นด๋“œ ๋ฏธ๊ฒฐ์ œ๊ธˆ, ๋ณดํ—˜ ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ, ์ž๋™ ์‹œ์„ธ ์—ฐ๋™ ์ž์‚ฐ ๋ฐ˜์˜. + - ๋ณ„๋„ ์ž์‚ฐ ํ‰๊ฐ€ ํ…Œ์ด๋ธ”(`asset_snapshots`, `manual_assets` ๋“ฑ) ์‹ ์„ค. ์ดˆ๊ธฐ ๊ตฌํ˜„์€ `accounts` ํ•ญ๋ชฉ์˜ ์ˆ˜๋™ ์ž…๋ ฅ์œผ๋กœ ์ฒ˜๋ฆฌํ•œ๋‹ค. + +# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก +๋ถˆํ™•์‹ค์„ฑ๊ณผ ์ „์ œ ์กฐ๊ฑด์„ ๋ถ„๋ฆฌํ•ด์„œ ์ ๋Š”๋‹ค. ๋ฆฌ์Šคํฌ๋Š” ์˜ํ–ฅ๊ณผ ์™„ํ™” ๋ฐฉ์•ˆ์„, ๊ฐ€์ •์€ ํ˜„์žฌ ํŒ๋‹จ์˜ ์ „์ œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. + +## ๋ฆฌ์Šคํฌ +- R-1 (Medium): `liquidity_bucket` ๊ถŒ์œ„ ์†Œ์Šค๊ฐ€ ๊ตฌํ˜„ ์ค‘ ๊ณ„์ขŒ ์™ธ ์นดํ…Œ๊ณ ๋ฆฌ/๊ฑฐ๋ž˜ override๋กœ ํ™•์žฅ๋˜๋ฉด ์ž์‚ฐ ์ง‘๊ณ„ ์†Œ์Šค๊ฐ€ ์ด์ค‘ํ™”๋ผ ๋น„์œจ์ด ์–ด๊ธ‹๋‚  ์ˆ˜ ์žˆ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ์ด๋ฒˆ ๋ฐ˜๋ณต์˜ ๋‹จ์ผ ๊ถŒ์œ„ ์†Œ์Šค๋Š” `accounts.liquidity_bucket`์œผ๋กœ ๊ณ ์ •ํ•˜๊ณ , ๊ฑฐ๋ž˜/์นดํ…Œ๊ณ ๋ฆฌ override๋Š” ํ›„์† ๋ฒ”์œ„๋กœ ์ œ์™ธํ•œ๋‹ค. ๋ฏธ๋งคํ•‘์€ `other`๋กœ ํด๋ฐฑํ•œ ๋’ค ์šด์˜ ๊ฒฝ๊ณ ๋กœ ๋…ธ์ถœํ•œ๋‹ค. +- R-2 (Medium): ๋ถ€๋™์‚ฐยท๋Œ€์ถœ ๋“ฑ ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ์€ ๊ฑฐ๋ž˜ ๋ฐ์ดํ„ฐ๋กœ ์‚ฐ์ถœ๋˜์ง€ ์•Š์•„ ์ž…๋ ฅ ๋ˆ„๋ฝ ์‹œ ์ž์‚ฐ ์œ ๋™์„ฑ/์ˆœ์ž์‚ฐ ์ˆ˜์น˜๊ฐ€ ์™œ๊ณก๋œ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ์ดˆ๊ธฐ์—๋Š” `accounts` ํ•ญ๋ชฉ์— ์ˆ˜๋™ ์ž…๋ ฅํ•˜๊ณ , `last_balance`๋ฅผ ํ˜„์žฌ ํ‰๊ฐ€์•ก/์ž”์•ก, `last_synced_at`์„ ๊ธฐ์ค€์ผ, `memo`๋ฅผ ์ถœ์ฒ˜ ๋ฉ”๋ชจ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. ๋ฏธ์ž…๋ ฅ ์ž์‚ฐ์€ ์ˆ˜์น˜์—์„œ ์ œ์™ธํ–ˆ์Œ์„ ํ™”๋ฉด์— ํ‘œ๊ธฐํ•œ๋‹ค. +- R-3 (Low): ์ƒ๋‹จ "์ด๋ฒˆ ๋‹ฌ" ์Šค์ฝ”ํ”„์™€ ๊ธฐ์กด ์ „์ฒด ๊ธฐ๊ฐ„ ์ฐจํŠธ๊ฐ€ ํ•œ ํ™”๋ฉด์— ๊ณต์กดํ•˜๋ฉด ์‚ฌ์šฉ์ž๊ฐ€ ๊ธฐ์ค€ ๊ธฐ๊ฐ„์„ ํ˜ผ๋™ํ•  ์ˆ˜ ์žˆ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ๊ฐ ์„น์…˜์— ๊ธฐ์ค€ ๊ธฐ๊ฐ„ ๋ผ๋ฒจ์„ ๋ช…์‹œํ•˜๊ณ , ๋‹น์›”/์ „์ฒด ๊ธฐ์ค€์„ ์‹œ๊ฐ์ ์œผ๋กœ ๊ตฌ๋ถ„ํ•œ๋‹ค. + +## ๊ฐ€์ • +์•„์ง ํ™•์ •๋˜์ง€ ์•Š์•˜์ง€๋งŒ ํ˜„์žฌ ๋ฌธ์„œ ์ž‘์„ฑ์˜ ๊ธฐ๋ฐ˜์ด ๋˜๋Š” ์ „์ œ๋ฅผ ์ ๋Š”๋‹ค. + +- A-1: ์ €์ถ•๋ฅ ์€ ๊ธฐ์กด `page_overview` ์ •์˜(์ˆœํ˜„๊ธˆํ๋ฆ„ รท ์‹ค์ œ ์ˆ˜์ž…)๋ฅผ ๋‹น์›” ๋ฒ”์œ„๋กœ ์ ์šฉํ•œ๋‹ค. +- A-2: ์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ ํŒ์ •์€ ๊ธฐ์กด `ledger_categories.cashflow_kind`(income/expense ๋“ฑ) ๊ธฐ์ค€์„ ๊ทธ๋Œ€๋กœ ์žฌ์‚ฌ์šฉํ•œ๋‹ค. +- A-3: ๋Œ€์ถœ์€ ์ž์‚ฐ์ด ์•„๋‹ˆ๋ผ ๋ถ€์ฑ„๋กœ ์ทจ๊ธ‰ํ•˜๋ฉฐ ์ž์‚ฐ ๊ตฌ์„ฑ ๋น„์œจ์˜ ๋ถ„๋ชจ์—์„œ ์ œ์™ธํ•˜๊ณ , ์ด๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ์—๋งŒ ์‚ฌ์šฉํ•œ๋‹ค. + +## ์˜คํ”ˆ ์งˆ๋ฌธ +๋‹ต์„ ์•„์ง ๋ชจ๋ฅด๋Š” ํ•ญ๋ชฉ์„ ์ ๋Š”๋‹ค. ์ถ”์ •์œผ๋กœ ๋ฎ์ง€ ๋ง๊ณ  REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ด์ „์— ํ™•์ธํ•  ์งˆ๋ฌธ์œผ๋กœ ๋‚จ๊ธด๋‹ค. + +- OQ-1: `liquidity_bucket`์˜ ๊ถŒ์œ„ ์†Œ์Šค๋ฅผ ๊ณ„์ขŒ ๋‹จ์œ„๋กœ ๋‘˜์ง€, ์นดํ…Œ๊ณ ๋ฆฌ/๊ฑฐ๋ž˜ ๋‹จ์œ„๋กœ ๋‘˜์ง€ ๊ฒฐ์ •ํ•œ๋‹ค. + - ์ƒํƒœ: Answered + - ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ: `liquidity_bucket`์˜ ๊ถŒ์œ„ ์†Œ์Šค๋Š” `accounts.liquidity_bucket`์œผ๋กœ ๋‘”๋‹ค. ํ—ˆ์šฉ๊ฐ’์€ `cash`, `investment`, `deposit`, `real_estate`, `loan`, `other`์ด๋ฉฐ, ๋ฏธ์ง€์ • ๊ณ„์ขŒ๋Š” `other`๋กœ ํด๋ฐฑํ•œ๋‹ค. ๊ฑฐ๋ž˜/์นดํ…Œ๊ณ ๋ฆฌ override๋Š” ์ด๋ฒˆ ๋ฒ”์œ„์—์„œ ์ œ์™ธํ•œ๋‹ค. + - ์ข…๋ฃŒ ์กฐ๊ฑด: ์ž์‚ฐ ์ง‘๊ณ„ ๊ถŒ์œ„ ์†Œ์Šค์™€ ํด๋ฐฑ ๊ทœ์น™์ด REQ ์ดˆ์•ˆ์— ๋ช…์‹œ๋˜๋ฉด Answered ์ฒ˜๋ฆฌ. +- OQ-2: ๋ถ€๋™์‚ฐยท๋ฌด๊ธฐํ•œ ์ž์‚ฐ๊ณผ ๋Œ€์ถœ ์ž”์•ก์˜ ์ž…๋ ฅ/๊ฐฑ์‹  ๋ฐฉ์‹(์ˆ˜๋™ ์ž…๋ ฅ vs ์ „์šฉ ๊ณ„์ขŒ vs ์™ธ๋ถ€ ์—ฐ๋™)์„ ์–ด๋–ป๊ฒŒ ๋‘˜์ง€ ๊ฒฐ์ •ํ•œ๋‹ค. + - ์ƒํƒœ: Answered + - ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ: ๋ถ€๋™์‚ฐ, ์˜ˆ์ ๊ธˆ, ๋Œ€์ถœ ์ž”์•ก ๋“ฑ ๊ฑฐ๋ž˜๋‚ด์—ญ๋งŒ์œผ๋กœ ์‚ฐ์ถœ๋˜์ง€ ์•Š๋Š” ์ž์‚ฐ/๋ถ€์ฑ„๋Š” ์ดˆ๊ธฐ์—๋Š” `accounts` ํ•ญ๋ชฉ์œผ๋กœ ์ˆ˜๋™ ์ž…๋ ฅํ•œ๋‹ค. `last_balance`๋Š” ํ˜„์žฌ ํ‰๊ฐ€์•ก/์ž”์•ก, `last_synced_at`์€ ๊ธฐ์ค€์ผ, `memo`๋Š” ์ถœ์ฒ˜ ๋ฉ”๋ชจ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. ๋ณ„๋„ ์ž์‚ฐ ํ‰๊ฐ€ ํ…Œ์ด๋ธ”๊ณผ ์ž๋™ ์‹œ์„ธ ์—ฐ๋™์€ ํ›„์† ๋ฒ”์œ„๋กœ ๋‘”๋‹ค. + - ์ข…๋ฃŒ ์กฐ๊ฑด: ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ ์ž…๋ ฅ/๊ฐฑ์‹  ๋ฐฉ์‹์ด REQ ์ดˆ์•ˆ๊ณผ ์šด์˜ ์ ˆ์ฐจ์— ๋ฐ˜์˜๋˜๋ฉด Answered ์ฒ˜๋ฆฌ. + +### ์˜คํ”ˆ ์งˆ๋ฌธ ์ฒ˜๋ฆฌ ๊ธฐ์ค€ +- `Open`: ์•„์ง ๋‹ต์ด ์—†๊ณ  ๋‹ค์Œ ๋‹จ๊ณ„ ์ง„ํ–‰์— ์˜ํ–ฅ์ด ์žˆ๋Š” ์ƒํƒœ๋‹ค. ๋ฌธ์„œ์— ์œ ์ง€ํ•œ๋‹ค. +- `Answered`: ๋‹ต์ด ํ™•์ •๋œ ์ƒํƒœ๋‹ค. ๊ด€๋ จ ๋ณธ๋ฌธ/์š”๊ตฌ์‚ฌํ•ญ์— ๋ฐ˜์˜ํ•œ ๋’ค ์˜คํ”ˆ ์งˆ๋ฌธ ๋ชฉ๋ก์—์„œ๋Š” ์ œ๊ฑฐํ•˜๊ฑฐ๋‚˜ resolved ๊ธฐ๋ก์œผ๋กœ ์ด๋™ํ•œ๋‹ค. +- `Deferred`: ์ง€๊ธˆ ๋‹จ๊ณ„์—์„œ ๊ฒฐ์ •ํ•˜์ง€ ์•Š๊ณ  ๋‹ค์Œ ๋‹จ๊ณ„ ๋˜๋Š” ํ›„์† ์ด์Šˆ๋กœ ๋„˜๊ธฐ๋Š” ์ƒํƒœ๋‹ค. ๋„˜๊ธธ ๋Œ€์ƒ๊ณผ ์ด์œ ๋ฅผ ๋ฐ˜๋“œ์‹œ ์ ๋Š”๋‹ค. +- `Dropped`: ๋” ์ด์ƒ ์œ ํšจํ•˜์ง€ ์•Š์€ ์งˆ๋ฌธ์ด๋‹ค. ์‚ญ์ œ ์‚ฌ์œ ๋ฅผ ์งง๊ฒŒ ๋‚จ๊ธฐ๊ณ  ๋ชฉ๋ก์—์„œ ์ œ๊ฑฐํ•œ๋‹ค. +- ์ฒ˜๋ฆฌ ์›์น™ 1: ์งˆ๋ฌธ๋งŒ ๋‚จ๊ธฐ์ง€ ๋ง๊ณ  ๋ฐ˜๋“œ์‹œ `์ƒํƒœ`, `์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ`, `์ข…๋ฃŒ ์กฐ๊ฑด`์„ ํ•จ๊ป˜ ์ ๋Š”๋‹ค. +- ์ฒ˜๋ฆฌ ์›์น™ 2: ๋‹ต์ด ๋‚˜์˜จ ์งˆ๋ฌธ์€ ๋ณธ๋ฌธ ๋ฐ˜์˜ ์—†์ด ๋‹จ์ˆœ ์‚ญ์ œํ•˜์ง€ ์•Š๋Š”๋‹ค. ๋ฐ˜์˜ ์œ„์น˜๋ฅผ ๋จผ์ € ๊ฐฑ์‹ ํ•œ๋‹ค. +- ์ฒ˜๋ฆฌ ์›์น™ 3: High ์˜ํ–ฅ ์งˆ๋ฌธ์€ `Deferred` ์ฒ˜๋ฆฌ ํ›„ ์ฒ˜๋ฆฌ ๋ฐฉ์•ˆ์— REQ ์ธ๊ณ„ ์ถ”์  ๋ฐฉ์‹์„ ๋ช…์‹œํ•œ๋‹ค. + +# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€ +์ด ๋ณ€๊ฒฝ์ด ์„ฑ๊ณตํ–ˆ๋‹ค๊ณ  ํŒ๋‹จํ•  ๊ธฐ์ค€์„ ์ธก์ • ๊ฐ€๋Šฅํ•˜๊ฒŒ ์ •์˜ํ•œ๋‹ค. ๊ฐ€๋Šฅํ•˜๋ฉด ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜๊นŒ์ง€ ์—ฐ๊ฒฐํ•œ๋‹ค. + +- S-1: ๋Œ€์‹œ๋ณด๋“œ ์ฒซ ํ™”๋ฉด์—์„œ ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ, ์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ, ์šด์˜ ๊ฒฝ๊ณ ๊ฐ€ ์ •์˜๋œ ์šฐ์„ ์ˆœ์œ„ ์ˆœ์„œ๋กœ ๋ชจ๋‘ ๋…ธ์ถœ๋œ๋‹ค. +- S-2: ์ž์‚ฐ ์œ ๋™์„ฑ/๊ตฌ์„ฑ ์„น์…˜์—์„œ `accounts.liquidity_bucket` ๊ธฐ์ค€ ๋ฒ„ํ‚ท๋ณ„ ๊ธˆ์•กยท๋น„์œจ, ๋Œ€์ถœ ์ œ์™ธ ์ž์‚ฐ ํ•ฉ๊ณ„, ์ด๋ถ€์ฑ„, ์ˆœ์ž์‚ฐ์ด ๋‹จ์ผ ์ •์˜์— ๋”ฐ๋ผ ์ผ๊ด€๋˜๊ฒŒ ๊ณ„์‚ฐ๋œ๋‹ค. +- S-3: `liquidity_bucket` ๋ฐ์ดํ„ฐ ๋ชจ๋ธ๊ณผ ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ํ™”๋ฉด ํ๋ฆ„์ด `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md`์— ๋ฐ˜์˜๋œ๋‹ค. + +## ์ธก์ • ๋ฐฉ์‹ ๋ฐ ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ +๊ฐ ์„ฑ๊ณต ๊ธฐ์ค€์„ ์–ด๋–ป๊ฒŒ ์ธก์ •ํ• ์ง€์™€ ์–ด๋–ค ๋ฐ์ดํ„ฐ๋‚˜ ๋ฌธ์„œ๋ฅผ ๊ทผ๊ฑฐ๋กœ ์“ธ์ง€ ์ ๋Š”๋‹ค. + +- S-1 ์ธก์ • ๋ฐฉ์‹: ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ํ™”๋ฉด ๊ฒ€์ˆ˜(์Šคํฌ๋ฆฐ์ƒท/E2E)๋กœ ์„น์…˜ ์ˆœ์„œยทํ‘œ์‹œ ์ง€ํ‘œ ํ™•์ธ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: `dashboard/app.py` ๋ Œ๋” ๊ฒฐ๊ณผ, REQ ํ™”๋ฉด ์‚ฌ์–‘ +- S-2 ์ธก์ • ๋ฐฉ์‹: ํ‘œ๋ณธ ๋ฐ์ดํ„ฐ๋กœ ๋ฒ„ํ‚ท ํ•ฉ๊ณ„ยท๋น„์œจยท์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ์„ ์†๊ฒ€์ฆ๊ณผ ๋น„๊ต / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: `transactions`, `ledger_accounts`, `accounts.liquidity_bucket` ๋งคํ•‘ ๊ทœ์น™ +- S-3 ์ธก์ • ๋ฐฉ์‹: baseline ๋ฌธ์„œ diff์—์„œ `liquidity_bucket`ยท์šด์˜ ํ—ˆ๋ธŒ ํ™”๋ฉด ํ๋ฆ„ ๋ฐ˜์˜ ์—ฌ๋ถ€ ํ™•์ธ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md` + +# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ๋‹จ๊ณ„๋กœ ๋„˜๊ธฐ๊ธฐ ์ „์— ๋ฌด์—‡์ด ํ™•์ •๋˜์–ด์•ผ ํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค. ๋ฏธ์™„๋ฃŒ ํ•ญ๋ชฉ์€ ํ›„์† ๊ฒฐ์ •์ด ํ•„์š”ํ•จ์„ ๋œปํ•œ๋‹ค. + +- [x] ๋ฒ”์œ„(In Scope/Out of Scope) ํ™•์ • + - ๊ธฐ์ค€: ์ˆœ์ž์‚ฐ ์‚ฐ์‹์€ `accounts.liquidity_bucket` ๊ธฐ์ค€ ๊ธˆ์œต์ž์‚ฐยท์ˆ˜๋™ ์ž…๋ ฅ ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ์—์„œ `loan`์„ ์ฐจ๊ฐํ•˜๋Š” ๋ฐฉ์‹์œผ๋กœ ํ™•์ •ํ•˜๊ณ , ๋ถ€๋™์‚ฐ ํ‰๊ฐ€ ์ž๋™ํ™”ยท์ž๋™ ์‹œ์„ธ ์—ฐ๋™ยท๋ณ„๋„ ์ž์‚ฐ ํ…Œ์ด๋ธ”์€ ์ œ์™ธํ•œ๋‹ค. +- [x] ์„ฑ๊ณต ์ง€ํ‘œ์˜ ์ธก์ • ๋ฐฉ์‹/๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ ํ™•์ • + - ๊ธฐ์ค€: S-1~S-3์˜ ๊ฒ€์ˆ˜ ๋ฐฉ์‹๊ณผ ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜๊ฐ€ REQ์—์„œ ์‹คํ–‰ ๊ฐ€๋Šฅํ•˜๊ฒŒ ๊ตฌ์ฒดํ™”๋จ +- [x] High ์˜ํ–ฅ ๋ฆฌ์Šคํฌ ์šฐ์„ ์ˆœ์œ„ ๋ฐ ํ•ด์†Œ ์ฑ…์ž„์ž ์ง€์ • + - ๊ธฐ์ค€: `liquidity_bucket` ๊ถŒ์œ„ ์†Œ์Šค๋Š” ๊ณ„์ขŒ ๋‹จ์œ„๋กœ ๊ณ ์ •ํ•˜๊ณ , ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ ์ž…๋ ฅ ๋ฆฌ์Šคํฌ๋Š” ์ˆ˜๋™ ์ž…๋ ฅ ๊ณ„์ขŒ/์ž์‚ฐ ํ•ญ๋ชฉ๊ณผ ๊ธฐ์ค€์ผ/์ถœ์ฒ˜ ๋ฉ”๋ชจ๋กœ ํ•ด์†Œํ•œ๋‹ค. +- [x] ๋ชจ๋“  ์˜คํ”ˆ ์งˆ๋ฌธ(OQ) `Open` ์ƒํƒœ ์—†์Œ ํ™•์ธ + - ๊ธฐ์ค€: OQ ๋ชฉ๋ก์— `Open` ์ƒํƒœ ํ•ญ๋ชฉ์ด 0๊ฐœ์ด๊ฑฐ๋‚˜, ์ž”์—ฌ ํ•ญ๋ชฉ ์ „๋ถ€ `Deferred` ์ฒ˜๋ฆฌ ์™„๋ฃŒ +- [x] Freeze ํ™•์ • ๋‹ด๋‹น์ž(CONFIRMED_BY) ์ง€์ • + - ๊ธฐ์ค€: ์‚ฌ์šฉ์ž ์ง„ํ™˜ ๊ณ ๊ฐ€ ๊ถŒ์žฅ์•ˆ์„ ์Šน์ธํ•˜๊ณ  confirm-discovery ์ง„ํ–‰์„ ์ง€์‹œํ–ˆ๋‹ค. + +# 9. ํŒŒ์ผ ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ +์ด Discovery ๋ฌธ์„œ ์ƒ์„ฑ/๊ฐฑ์‹  ๊ฒฐ๊ณผ์™€ ์ฐธ์กฐ ๋ฌธ์„œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ์ดํ›„ ์ถ”์ ๊ณผ ๋ฆฌ๋ทฐ ์‹œ ๊ทผ๊ฑฐ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + +- ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ: ์ƒ์„ฑ +- ์ƒ์„ฑ ๊ฒฝ๋กœ: docs/discovery/dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md +- ์ฐธ์กฐ ๋ฌธ์„œ: + - docs/data-model.md (LedgerAccount/LedgerCategory, `liquidity_bucket` ๋„์ž… ๋Œ€์ƒ) + - docs/runtime-flows.md, docs/project-structure.md (๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ํ™”๋ฉด ํ๋ฆ„ ๊ฐฑ์‹  ๋Œ€์ƒ; stage-pilot/templates/runtime-flows.md, stage-pilot/templates/project-structure.md๋ฅผ ๋…ผ๋ฆฌ ํ…œํ”Œ๋ฆฟ ์ฐธ์กฐ๋กœ ์‚ฌ์šฉ) + - dashboard/app.py `page_overview` (ํ˜„์žฌ ๋Œ€์‹œ๋ณด๋“œ ์ฒซ ํ™”๋ฉด ๊ตฌํ˜„ ๊ทผ๊ฑฐ) + +# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ +์‚ฌ์šฉ์ž๋‚˜ ์Šน์ธ์ž๊ฐ€ ๊ฒฐ์ •ํ•ด์•ผ ํ•˜๋Š” ํ•ญ๋ชฉ๋งŒ ๋ชจ์•„ ์ •๋ฆฌํ•œ๋‹ค. ๊ฒฐ์ •, ํ™•์ธ, ๋ฐ์ดํ„ฐ ์š”์ฒญ์„ ๊ตฌ๋ถ„ํ•ด์„œ ์ ๋Š”๋‹ค. + +## DECIDE +- `liquidity_bucket`์˜ ๊ถŒ์œ„ ์†Œ์Šค๋Š” `accounts.liquidity_bucket`์œผ๋กœ ๋‘”๋‹ค. ํ—ˆ์šฉ๊ฐ’์€ `cash`, `investment`, `deposit`, `real_estate`, `loan`, `other`์ด๋ฉฐ, ๋ฏธ์ง€์ • ๊ณ„์ขŒ๋Š” `other`๋กœ ํด๋ฐฑํ•œ๋‹ค. ๊ฑฐ๋ž˜/์นดํ…Œ๊ณ ๋ฆฌ override๋Š” ์ด๋ฒˆ ๋ฒ”์œ„์—์„œ ์ œ์™ธํ•œ๋‹ค. +- ์ˆœ์ž์‚ฐ์€ `cash`, `investment`, `deposit`, `real_estate`, `other` ์ค‘ ์ž์‚ฐ์„ฑ ํ•ญ๋ชฉ์˜ ํ•ฉ๊ณ„์—์„œ `loan` bucket ํ•ฉ๊ณ„๋ฅผ ์ฐจ๊ฐํ•ด ๊ณ„์‚ฐํ•œ๋‹ค. ๋Œ€์ถœ์€ ์ž์‚ฐ ๊ตฌ์„ฑ ๋น„์œจ์—๋Š” ํฌํ•จํ•˜์ง€ ์•Š๊ณ  ์ด๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ์—๋งŒ ์‚ฌ์šฉํ•œ๋‹ค. ์นด๋“œ ๋ฏธ๊ฒฐ์ œ๊ธˆ, ๋ณดํ—˜ ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ, ์ž๋™ ์‹œ์„ธ ์—ฐ๋™์€ ์ด๋ฒˆ ๋ฒ”์œ„์—์„œ ์ œ์™ธํ•œ๋‹ค. + +## CONFIRM +- "์ด๋ฒˆ ๋‹ฌ"์€ `transaction_date` ๊ธฐ์ค€ ํ˜„์žฌ ์›”(KST)๋กœ ์ •์˜ํ•œ๋‹ค. `posted_date` ๊ธฐ์ค€ ๋˜๋Š” ์‚ฌ์šฉ์ž ์ •์˜ ํšŒ๊ณ„ ์›” ๋งˆ๊ฐ์€ ํ›„์† ์˜ต์…˜์œผ๋กœ ๋‘”๋‹ค. + +## DATA +- ๋ถ€๋™์‚ฐ, ์˜ˆ์ ๊ธˆ, ๋Œ€์ถœ ์ž”์•ก ๋“ฑ ๊ฑฐ๋ž˜๋‚ด์—ญ๋งŒ์œผ๋กœ ์‚ฐ์ถœ๋˜์ง€ ์•Š๋Š” ์ž์‚ฐ/๋ถ€์ฑ„๋Š” ์ดˆ๊ธฐ์—๋Š” `accounts` ํ•ญ๋ชฉ์œผ๋กœ ์ˆ˜๋™ ์ž…๋ ฅํ•œ๋‹ค. `last_balance`๋Š” ํ˜„์žฌ ํ‰๊ฐ€์•ก/์ž”์•ก, `last_synced_at`์€ ๊ธฐ์ค€์ผ, `memo`๋Š” ์ถœ์ฒ˜ ๋ฉ”๋ชจ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. ๋ณ„๋„ ์ž์‚ฐ ํ…Œ์ด๋ธ”๊ณผ ์ž๋™ ์‹œ์„ธ ์—ฐ๋™์€ ํ›„์† ๋ฒ”์œ„๋กœ ๋‘”๋‹ค. + +# 11. Discovery Freeze +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ธ๊ณ„ ์‹œ์ ์˜ ํ™•์ • ํ”Œ๋ž˜๊ทธ์™€ ์„น์…˜ ์ฐธ์กฐ๋งŒ ๊ธฐ๋กํ•œ๋‹ค. ๋‚ด์šฉ ์žฌ์ž‘์„ฑ ์—†์ด ์›๋ณธ ์„น์…˜์„ ๋งํฌ๋กœ ๋Œ€์‹ ํ•œ๋‹ค. + +## ์„น์…˜ ์ฐธ์กฐ +- ๋ฒ”์œ„: [# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก > ๋ฒ”์œ„ ๊ฒฝ๊ณ„](#5-์š”๊ตฌ์‚ฌํ•ญ-๋ชฉ๋ก) +- ๋ฆฌ์Šคํฌ/์˜คํ”ˆ ์งˆ๋ฌธ: [# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก](#6-๋ฆฌ์Šคํฌ๊ฐ€์ •-๋ชฉ๋ก) +- ์„ฑ๊ณต ๊ธฐ์ค€: [# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€](#7-์ดˆ๊ธฐ-์„ฑ๊ณต-๊ธฐ์ค€) +- ์ธ๊ณ„ ์ „ ํ™•์ธ: [# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ](#8-req๋กœ-๋„˜๊ธฐ๊ธฐ-์ „-ํ™•์ธ-์ฒดํฌ) +- ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š”: [# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ](#10-์‚ฌ์šฉ์ž-๊ฒฐ์ •-ํ•„์š”-ํ•ญ๋ชฉ-์š”์•ฝ) + +## Freeze ํ”Œ๋ž˜๊ทธ +- Handoff Decision: ์Šน์ธ +- Handoff Rationale: ์‚ฌ์šฉ์ž ์Šน์ธ์œผ๋กœ ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ์ •๋ณด๊ตฌ์กฐ, `accounts.liquidity_bucket` ๊ถŒ์œ„ ์†Œ์Šค, ์ˆœ์ž์‚ฐ/๋ถ€์ฑ„ ๊ณ„์‚ฐ ๊ฒฝ๊ณ„, ์ด๋ฒˆ ๋‹ฌ ๊ธฐ์ค€, ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ ์ˆ˜๋™ ์ž…๋ ฅ ๋ฐฉ์‹์ด ํ™•์ •๋˜์—ˆ์œผ๋ฏ€๋กœ REQ ์ดˆ์•ˆ ์ž‘์„ฑ์œผ๋กœ ๋„˜๊ธด๋‹ค. +- Ready for REQ Drafting: true +- Confirmed By: ์ง„ํ™˜ ๊ณ  +- Confirmed At (KST): 2026-06-29 14:08 KST diff --git a/docs/discovery/dcy-006_20260630_pytest-apptest-smoke-ci-automation.md b/docs/discovery/dcy-006_20260630_pytest-apptest-smoke-ci-automation.md new file mode 100644 index 0000000..13bfb05 --- /dev/null +++ b/docs/discovery/dcy-006_20260630_pytest-apptest-smoke-ci-automation.md @@ -0,0 +1,216 @@ +# 0. ๋ฌธ์„œ ์ƒํƒœ +์ด ๋ฌธ์„œ์˜ ํ˜„์žฌ ์ƒํƒœ์™€ ์‹๋ณ„ ์ •๋ณด๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ๋ฌธ์„œ ์ถ”์ ๊ณผ ์Šน์ธ ํ๋ฆ„์— ํ•„์š”ํ•œ ์ตœ์†Œ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ๋ฅผ ์ ๋Š”๋‹ค. + +- ์ƒํƒœ: confirmed +- ๋ฌธ์„œ ID: dcy-006_20260630_pytest-apptest-smoke-ci-automation +- ์ด์Šˆ๋ช…: pytest/AppTest/Smoke CI ์ž๋™ํ™” +- ์ž‘์„ฑ ์‹œ๊ฐ(KST): 2026-06-30 13:32 +- ๋งˆ์ง€๋ง‰ ๊ฐฑ์‹  ์‹œ๊ฐ(KST): 2026-06-30 14:42 KST +- ๋Œ€์ฒด๋จ: ์—†์Œ +- ํ›„์† Discovery ์ฐธ์กฐ: ์—†์Œ +- ์ƒ์„ฑ๋œ REQ ์ฐธ์กฐ: REQ-006 (`docs/srs/Testing/req-006_pytest-apptest-smoke-ci-automation.md`) + +# 1. ๊ณ„ํš ์ƒํƒœ ์š”์•ฝ +ํ˜„์žฌ Discovery๊ฐ€ ์–ด๋–ค ๋ฐฐ๊ฒฝ์—์„œ ์ž‘์„ฑ๋˜๊ณ  ์žˆ๋Š”์ง€, ์—ฐ๊ณ„ ๋ฌธ์„œ๊ฐ€ ์žˆ๋Š”์ง€, ์ง€๊ธˆ ์‹œ์ ์˜ ํ•ด์„์ด ๋ฌด์—‡์ธ์ง€ ์งง๊ฒŒ ์š”์•ฝํ•œ๋‹ค. + +- ์—ฐ๊ณ„ ๋ฌธ์„œ ํ˜„ํ™ฉ: + - ๊ด€๋ จ GitHub Project ํ•ญ๋ชฉ: https://github.com/users/genichin/projects/4 ์˜ `pytest/AppTest/Smoke CI ์ž๋™ํ™”` ํ•ญ๋ชฉ (Project #4 ์ž”์—ฌ ์ž‘์—…) + - ์„ ํ–‰ Discovery: `docs/discovery/dcy-004_20260629_browser-streamlit-e2e-evidence-recovery.md` (Out of Scope/์ฒดํฌ๋ฆฌ์ŠคํŠธ์—์„œ CI ํ†ตํ•ฉ์„ ๋ณธ ํ•ญ๋ชฉ์œผ๋กœ ๋ถ„๋ฆฌ) +- ํ•ด์„: + - dcy-004๋Š” ๋กœ์ปฌ ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ browser evidence ๋ณต๊ตฌ๊นŒ์ง€๋กœ ๋ฒ”์œ„๋ฅผ ํ•œ์ •ํ•˜๊ณ , `pytest/AppTest/Smoke CI ์ž๋™ํ™”`๋ฅผ Project #4์˜ ๋ณ„๋„ ํ•ญ๋ชฉ์œผ๋กœ ๋ช…์‹œ์ ์œผ๋กœ ๋ถ„๋ฆฌํ–ˆ๋‹ค. ๋ณธ Discovery๋Š” ๊ทธ ๋ถ„๋ฆฌ๋œ ํ›„์† ์ž‘์—…์„ ๋…๋ฆฝ ์ฃผ๊ธฐ๋กœ ๋Œ์–ด์˜ฌ๋ ค ์ถ”์ ํ•œ๋‹ค. + - ํ˜„์žฌ ์ €์žฅ์†Œ์—๋Š” pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ, Streamlit AppTest, HTTP/DB safety smoke ์ž์‚ฐ์ด ์ด๋ฏธ ์กด์žฌํ•˜์ง€๋งŒ ์ด๋ฅผ ๊ฐ•์ œ ์‹คํ–‰ยท๊ฒŒ์ดํŠธํ™”ํ•˜๋Š” CI ๊ณ„์•ฝ(workflow)์ด ์ „ํ˜€ ์—†๋‹ค. ๋ณธ Discovery๋Š” "์ด๋ฏธ ์žˆ๋Š” ๊ฒ€์ฆ ์ž์‚ฐ์„ CI๋กœ ์ž๋™ ์‹คํ–‰ยท๊ฒŒ์ดํŠธํ™”ํ•˜๋Š” ๊ณ„์•ฝ"์„ ์ •์˜ํ•˜๋Š” ๊ฒƒ์„ ๋ชฉํ‘œ๋กœ ํ•œ๋‹ค. + +# 2. ์š”๊ตฌ์‚ฌํ•ญ ํŒ์ • ๊ฒฐ๊ณผ +์ด๋ฒˆ ์š”์ฒญ์ด ๊ธฐ์กด ์š”๊ตฌ์‚ฌํ•ญ๊ณผ ๋น„๊ตํ•ด ์‹ ๊ทœ์ธ์ง€, ์œ ์‚ฌํ•œ์ง€, ์ค‘๋ณต์ธ์ง€ ํŒ๋‹จํ•˜๊ณ  ๊ทธ ๊ทผ๊ฑฐ๋ฅผ ๋‚จ๊ธด๋‹ค. + +- ํŒ์ •: ์‹ ๊ทœ +- ๊ทผ๊ฑฐ + - dcy-004๋Š” browser-level evidence ๋ณต๊ตฌ์™€ QC evidence ๋“ฑ๊ธ‰ ์ •์˜๊ฐ€ ์ค‘์‹ฌ์ด๋ฉฐ, "๊ฒ€์ฆ ์ž์‚ฐ์„ CI์—์„œ ์ž๋™ ์‹คํ–‰ยท๊ฒŒ์ดํŠธํ™”"ํ•˜๋Š” ์ž๋™ํ™” ๊ณ„์•ฝ์„ ๋…๋ฆฝ ์„ฑ๊ณต ๊ธฐ์ค€์œผ๋กœ ๋‹ค๋ฃจ์ง€ ์•Š์•˜๋‹ค. dcy-004 ๋ณธ๋ฌธ(`# 5 ๋ฒ”์œ„ ๊ฒฝ๊ณ„`, `# 8 ํ™•์ธ ์ฒดํฌ`)์ด CI ํ†ตํ•ฉ์„ ๋ณธ ํ•ญ๋ชฉ์œผ๋กœ ๋ช…์‹œ ๋ถ„๋ฆฌํ–ˆ๋‹ค. + - ์ €์žฅ์†Œ์— `.github/workflows/`๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๊ณ  `docs/project-structure.md`๋„ "CI workflow and deployment/service management are not yet defined."๋กœ ๊ธฐ๋กํ•œ๋‹ค. ๋”ฐ๋ผ์„œ CI ์ž๋™ํ™”๋Š” ๊ธฐ์กด ์š”๊ตฌ์‚ฌํ•ญ์˜ ๋ฏธ์„ธ ์กฐ์ •์ด ์•„๋‹ˆ๋ผ ์‹ ๊ทœ governance unit์ด๋‹ค. + +# 3. ๋ฌธ์ œ์ ์˜ ์š”์•ฝ +๋ฌธ์ œ์˜ ํ˜„์žฌ ์ƒํƒœ, ๊ธฐ๋Œ€ ์ƒํƒœ, ๊ด€๋ จ ์ดํ•ด๊ด€๊ณ„์ž, ์ œ์•ฝ์‚ฌํ•ญ์„ ์ •๋ฆฌํ•œ๋‹ค. Discovery์˜ ํ•ต์‹ฌ ๋งฅ๋ฝ์„ ๊ฐ€์žฅ ๋จผ์ € ์ดํ•ดํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. + +- ํ˜„์žฌ ์ƒํƒœ + - CI ๊ณ„์•ฝ์ด ๋ถ€์žฌํ•˜๋‹ค: ์ €์žฅ์†Œ์— `.github/workflows/` ๋””๋ ‰ํ„ฐ๋ฆฌ ์ž์ฒด๊ฐ€ ์—†์–ด, push/PR ์‹œ ์ž๋™์œผ๋กœ ํ…Œ์ŠคํŠธ๋ฅผ ์‹คํ–‰ยท์ฐจ๋‹จํ•˜๋Š” ๊ฒŒ์ดํŠธ๊ฐ€ ์กด์žฌํ•˜์ง€ ์•Š๋Š”๋‹ค. ๊ฒ€์ฆ์€ ์ „์ ์œผ๋กœ ์ž‘์„ฑ์ž/๊ฒ€์ˆ˜๊ด€์˜ ์ˆ˜๋™ ๋กœ์ปฌ ์‹คํ–‰์— ์˜์กดํ•œ๋‹ค. `docs/project-structure.md`๋„ "CI workflow and deployment/service management are not yet defined."๋กœ ๋ช…์‹œํ•œ๋‹ค. + - ๊ฒ€์ฆ ์ž์‚ฐ์€ ์ด๋ฏธ ํ’๋ถ€ํ•˜๋‹ค: pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ(`tests/test_categorization.py`, `tests/test_service.py`, `tests/test_backup_workflow.py`, `tests/test_dashboard_app.py` ๋“ฑ 11๊ฐœ ํ…Œ์ŠคํŠธ ๋ชจ๋“ˆ), `pyproject.toml`์˜ `[tool.pytest.ini_options]`(testpaths=["tests"], dev extra์˜ `pytest>=8.0.0`)๊ฐ€ ๊ฐ–์ถฐ์ ธ ์žˆ๋‹ค. ๊ทธ๋Ÿฌ๋‚˜ ์ด๋“ค์„ ์ž๋™ ์‹คํ–‰ํ•˜๋Š” ์™ธ๋ถ€ ๊ณ„์•ฝ์ด ์—†๋‹ค. + - AppTest/smoke ์ž์‚ฐ์ด ๋กœ์ปฌ์— ๋ฌถ์—ฌ ์žˆ๋‹ค: Streamlit `AppTest` ๊ธฐ๋ฐ˜ `tests/test_dashboard_app.py`, browser evidence ์Šคํฌ๋ฆฝํŠธ `scripts/streamlit_browser_evidence.py`, DB restore safety smoke `scripts/ledger_restore_smoke.py`๊ฐ€ ์กด์žฌํ•˜์ง€๋งŒ ๋ชจ๋‘ ๋กœ์ปฌ ์ˆ˜๋™ ์‹คํ–‰์ด ์ „์ œ๋‹ค. `docs/project-structure.md`๋Š” "Local browser evidence capture exists ... broader flow coverage and CI automation remain planned."๋กœ ์ด ํ•œ๊ณ„๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. + - browser evidence๊ฐ€ ๋กœ์ปฌ ํ•œ์ •์ด๋‹ค: dcy-004 ๊ฒฐ๊ณผ๋กœ `/usr/bin/google-chrome-stable` ๊ธฐ๋ฐ˜ ๋กœ์ปฌ browser evidence ์ ˆ์ฐจ๋Š” ๋ณต๊ตฌ๋์œผ๋‚˜, ์ด๋Š” ํ—ค๋“œ๋ฆฌ์Šค Chrome/snap ๊ฐ€์šฉ์„ฑ์— ์˜์กดํ•ด CI runner๋กœ ๊ทธ๋Œ€๋กœ ์ด์‹ํ•˜๊ธฐ ์–ด๋ ต๋‹ค. ๋”ฐ๋ผ์„œ CI 1์ฐจ ๊ฒŒ์ดํŠธ๋Š” browser-screenshot์ด ์•„๋‹ˆ๋ผ ๊ฒฐ์ •์ ์œผ๋กœ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ pytest/AppTest/HTTP/DB safety smoke๋กœ ๊ตฌ์„ฑํ•ด์•ผ ํ•œ๋‹ค. +- ๊ธฐ๋Œ€ ์ƒํƒœ + - push/PR ์‹œ pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ, Streamlit AppTest, HTTP smoke, DB safety smoke๊ฐ€ CI์—์„œ ์ž๋™ ์‹คํ–‰๋˜์–ด ํ•ฉ๊ฒฉ/๋ถˆํ•ฉ๊ฒฉ์ด ๊ฒŒ์ดํŠธ๋กœ ๊ฐ•์ œ๋œ๋‹ค. + - CI ๊ณ„์•ฝ์ด ๋ฌธ์„œํ™”๋œ ๋‹จ์ผ ์ง„์ž…์ (workflow ์ •์˜)์œผ๋กœ ์กด์žฌํ•˜๊ณ , ์–ด๋–ค ๊ฒ€์ฆ์ด blocker์ด๊ณ  ์–ด๋–ค ๊ฒƒ์ด best-effort์ธ์ง€ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„๋œ๋‹ค. + - browser-level evidence์ฒ˜๋Ÿผ ํ™˜๊ฒฝ ์˜์กด์ด ํฐ ๊ฒ€์ฆ์€ CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ์—์„œ ๋ถ„๋ฆฌํ•˜๊ณ , ๋กœ์ปฌ/์ˆ˜๋™ evidence ์ ˆ์ฐจ(dcy-004)์™€์˜ ๊ฒฝ๊ณ„๊ฐ€ ๋ช…ํ™•ํžˆ ์ •์˜๋œ๋‹ค. +- ์ดํ•ด๊ด€๊ณ„์ž + - ๋ ›์ €์žฅ์ธ(์šด์˜์ž ๊ฒธ ์ž‘์„ฑ์ž) โ€” CI workflow๋ฅผ ๊ตฌ์„ฑยท์œ ์ง€ํ•˜๊ณ  ๋กœ์ปฌ ๊ฒ€์ฆ๊ณผ์˜ ๊ฒฝ๊ณ„๋ฅผ ์šด์˜ํ•˜๋Š” ์ฃผ์ฒด + - ๊ฒ€์ˆ˜๊ด€/๋ฆฌ๋ทฐ์–ด โ€” CI ๊ฒŒ์ดํŠธ ๊ฒฐ๊ณผ๋ฅผ QC gate ํŒ์ •์˜ 1์ฐจ ๊ทผ๊ฑฐ๋กœ ์‚ฌ์šฉํ•˜๋Š” ์ฃผ์ฒด + - StagePilot ํ›„์† SDLC(REQ/Batch) ์ˆ˜ํ–‰์ž โ€” ๋ณธ Discovery๋ฅผ CI ์ž๋™ํ™” ์š”๊ตฌ์‚ฌํ•ญ์œผ๋กœ ์ •์ œ +- ์ œ์•ฝ์‚ฌํ•ญ + - ๊ฐœ์ธ ๊ธˆ์œต์ •๋ณด๋ฅผ ๋‹ค๋ฃจ๋ฏ€๋กœ CI ์‹คํ–‰ ๋กœ๊ทธยท์•„ํ‹ฐํŒฉํŠธ์— ์‹ค๊ณ„์ขŒ๋ฒˆํ˜ธยท์ž”์•กยทraw_payloadยท์›๋ณธ ํŒŒ์ผ๋ช… ๋“ฑ ๋ฏผ๊ฐ์ •๋ณด๊ฐ€ ๋…ธ์ถœ๋˜์–ด์„œ๋Š” ์•ˆ ๋œ๋‹ค. CI ๊ฒ€์ฆ์€ synthetic fixture๋ฅผ ์šฐ์„  ์‚ฌ์šฉํ•œ๋‹ค. + - CI runner๋Š” ํ—ค๋“œ๋ฆฌ์Šค browser/snap ๊ฐ€์šฉ์„ฑ์ด ๋ณด์žฅ๋˜์ง€ ์•Š์œผ๋ฏ€๋กœ, browser-screenshot E2E๋ฅผ CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋กœ ๊ฐ•์ œํ•˜์ง€ ์•Š๋Š”๋‹ค(dcy-004 evidence ๋“ฑ๊ธ‰์˜ fallback ๊ฒฝ๋กœ๋ฅผ CI ๋งฅ๋ฝ์—์„œ ์žฌํ•ด์„). + - DB safety smoke๋Š” ์šด์˜ DB๊ฐ€ ์•„๋‹ˆ๋ผ ์ž„์‹œ fixture/๊ฒ€์ฆ DB(dcy-002 ์ ˆ์ฐจ)์—์„œ๋งŒ ์ˆ˜ํ–‰ํ•˜์—ฌ ์›๋ณธ ๋ฐ์ดํ„ฐ๋ฅผ ํ›ผ์†ํ•˜์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. + +## ์ฃผ์š” ์‚ฌ์šฉ์ž ์‹œ๋‚˜๋ฆฌ์˜ค +์‚ฌ์šฉ์ž ๋˜๋Š” ์šด์˜์ž๊ฐ€ ์‹ค์ œ๋กœ ์–ด๋–ค ์ƒํ™ฉ์—์„œ ์ด ๋ณ€๊ฒฝ์„ ํ•„์š”๋กœ ํ•˜๋Š”์ง€ ๋Œ€ํ‘œ ์‹œ๋‚˜๋ฆฌ์˜ค๋ฅผ ์ ๋Š”๋‹ค. + +- ์ž‘์„ฑ์ž๊ฐ€ ๋ถ„๋ฅ˜/์„œ๋น„์Šค/๋ฐฑ์—… ๋กœ์ง์„ ๋ณ€๊ฒฝํ•ด PR์„ ์˜ฌ๋ฆฌ๋ฉด, CI๊ฐ€ pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ๋ฅผ ์ž๋™ ์‹คํ–‰ํ•˜๊ณ  ์‹คํŒจ ์‹œ ๋จธ์ง€๋ฅผ ์ฐจ๋‹จํ•ด ํšŒ๊ท€๋ฅผ ์กฐ๊ธฐ์— ์žก๋Š”๋‹ค. +- ์ž‘์„ฑ์ž๊ฐ€ `dashboard/app.py`๋ฅผ ๋ณ€๊ฒฝํ•˜๋ฉด, CI๊ฐ€ Streamlit AppTest์™€ HTTP smoke๋ฅผ fixture DB๋กœ ์‹คํ–‰ํ•ด ๋Œ€์‹œ๋ณด๋“œ๊ฐ€ ๊ธฐ๋™ยท๋ Œ๋”๋ง๋˜๋Š”์ง€ ์ž๋™ ๊ฒ€์ฆํ•œ๋‹ค. +- ๊ฒ€์ˆ˜๊ด€์ด batch verification์„ ์ž‘์„ฑํ•  ๋•Œ, CI ๊ฒŒ์ดํŠธ ํ†ต๊ณผ ๋กœ๊ทธ๋ฅผ 1์ฐจ evidence๋กœ ์ธ์šฉํ•˜๊ณ , browser-screenshot evidence๋Š” ํ™˜๊ฒฝ์ด ํ—ˆ์šฉํ•˜๋Š” ๋กœ์ปฌ ์ ˆ์ฐจ(dcy-004)์—์„œ ๋ณด๊ฐ•ํ•œ๋‹ค. +- ๋ฐฑ์—…/๋ณต๊ตฌ ๋กœ์ง์„ ๋ณ€๊ฒฝํ•˜๋ฉด, CI๊ฐ€ DB safety smoke(`scripts/ledger_restore_smoke.py` ๊ณ„์—ด)๋ฅผ ์ž„์‹œ fixture DB๋กœ ์‹คํ–‰ํ•ด ๋ณต๊ตฌ ์ ˆ์ฐจ๊ฐ€ ์šด์˜ DB๋ฅผ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๊ณ  ํ†ต๊ณผํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค. + +# 4. ์ด๋ฒˆ์— ์ •์˜ํ•  ๋ณ€๊ฒฝ +์ด๋ฒˆ Discovery์—์„œ ๋‹ค๋ฃจ๋Š” ํ•ต์‹ฌ ๋ณ€๊ฒฝ ํ•ญ๋ชฉ์„ ๋ชฉ๋ก์œผ๋กœ ์ ๋Š”๋‹ค. ๊ธฐ๋Šฅ, ๊ตฌ์กฐ, ๊ทœ์น™ ๋ณ€๊ฒฝ์„ ํ•จ๊ป˜ ํฌํ•จํ•ด๋„ ๋œ๋‹ค. + +- pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ๋ฅผ push/PR์—์„œ ์ž๋™ ์‹คํ–‰ํ•˜๋Š” CI ๊ณ„์•ฝ(workflow)์„ ์‹ ์„คํ•œ๋‹ค. ์–ด๋–ค ์ด๋ฒคํŠธ์—์„œ ์–ด๋–ค job์ด ์‹คํ–‰๋˜๊ณ  ๋ฌด์—‡์ด ๋จธ์ง€ blocker์ธ์ง€ ์ •์˜ํ•œ๋‹ค. +- Streamlit AppTest์™€ HTTP smoke, DB safety smoke๋ฅผ CI job์œผ๋กœ ์ž๋™ํ™”ํ•œ๋‹ค. ๋ชจ๋‘ synthetic fixture/์ž„์‹œ ๊ฒ€์ฆ DB์—์„œ ๊ฒฐ์ •์ ์œผ๋กœ ์‹คํ–‰๋˜๋„๋ก ํ‘œ์ค€ํ™”ํ•œ๋‹ค. +- CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ์™€ ํ™˜๊ฒฝ ์˜์กด ๊ฒ€์ฆ(browser-screenshot E2E)์˜ ๊ฒฝ๊ณ„๋ฅผ ๊ทœ์น™์œผ๋กœ ์ •์˜ํ•œ๋‹ค. browser evidence๋Š” CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ์—์„œ ๋ถ„๋ฆฌํ•˜๊ณ  ๋กœ์ปฌ ์ ˆ์ฐจ(dcy-004)์™€์˜ ์—ญํ• ์„ ๋ฌธ์„œํ™”ํ•œ๋‹ค. +- CI ๊ณ„์•ฝ๊ณผ ๊ฒ€์ฆ job ๊ตฌ์กฐ๋ฅผ baseline ๋ฌธ์„œ์— ๋ฐ˜์˜ํ•œ๋‹ค: `docs/project-structure.md`์˜ "CI workflow ... not yet defined" ํ•ญ๋ชฉ์„ ๊ฐฑ์‹ ํ•˜๊ณ , `docs/runtime-flows.md`์— CI ๊ฒ€์ฆ ํ๋ฆ„์„ ์ถ”๊ฐ€ํ•  ํ•„์š”๋ฅผ ๋ช…์‹œํ•œ๋‹ค. + +## ์˜ํ–ฅ ๋ฒ”์œ„ +๋ณ€๊ฒฝ์ด ๋ฏธ์น  ์ฝ”๋“œ/๋ฌธ์„œ/์šด์˜ ๋ฒ”์œ„๋ฅผ ๋ช…์‹œํ•ด REQ ์ •์ œ์™€ batch planning์—์„œ ์ถ”์ • ๋ˆ„๋ฝ์„ ์ค„์ธ๋‹ค. + +- ์ฝ”๋“œ ์˜ํ–ฅ: + - `.github/workflows/` โ€” CI workflow ์ •์˜ ์‹ ์„ค(ํŒŒ์ผ ๊ฒฝ๋กœ/์ด๋ฆ„์€ REQ/Batch์—์„œ ํ™•์ •). pytest, AppTest, HTTP smoke, DB safety smoke job ๊ตฌ์„ฑ. + - `scripts/` โ€” ๊ธฐ์กด `ledger_restore_smoke.py`, `streamlit_browser_evidence.py`๋ฅผ CI์—์„œ ํ˜ธ์ถœ ๊ฐ€๋Šฅํ•œ ๊ฒฐ์ •์  ์ง„์ž…์ ์œผ๋กœ ์žฌ์‚ฌ์šฉ/๋ž˜ํ•‘ํ•  ๊ฐ€๋Šฅ์„ฑ. HTTP smoke ์ „์šฉ ํ—ฌํผ ์‹ ์„ค ์—ฌ๋ถ€๋Š” ํ›„์† ๋ฒ”์œ„. + - `tests/` โ€” ๊ธฐ์กด pytest/AppTest ์ž์‚ฐ์€ ๋ณด์กด. CI์—์„œ fixture DB๋ฅผ ๊ฒฐ์ •์ ์œผ๋กœ ์ค€๋น„ํ•˜๋Š” ๊ณต์šฉ ์œ ํ‹ธ ์ถ”๊ฐ€ ๊ฐ€๋Šฅ์„ฑ. + - `pyproject.toml` โ€” CI ์‹คํ–‰์— ํ•„์š”ํ•œ dev/dashboard extra ์„ค์น˜ ๊ฒฝ๊ณ„ ํ™•์ธ(์ด๋ฏธ `dev`=pytest, `dashboard`=streamlit/pandas/plotly ์กด์žฌ). +- ๋ฌธ์„œ ์˜ํ–ฅ: + - `docs/project-structure.md` โ€” "CI workflow and deployment/service management are not yet defined." ๋ฐ "CI automation remain planned." ํ•ญ๋ชฉ ๊ฐฑ์‹ . + - `docs/runtime-flows.md` โ€” CI ๊ฒ€์ฆ ํ๋ฆ„(์ด๋ฒคํŠธ โ†’ job โ†’ ๊ฒŒ์ดํŠธ ๊ฒฐ๊ณผ) ์ถ”๊ฐ€. + - `docs/interface-contract.md` โ€” CI ๊ฒŒ์ดํŠธ ๊ฒฐ๊ณผ๋ฅผ QC evidence๋กœ ์ธ์šฉํ•˜๋Š” ๊ธฐ์ค€ ๋ฐ˜์˜ ๊ฒ€ํ† . + - `docs/runbooks/` โ€” CI ์‹คํŒจ ๋Œ€์‘/๋กœ์ปฌ ์žฌํ˜„ runbook ํ›„๋ณด. +- ์šด์˜ ์˜ํ–ฅ: + - push/PR๋งˆ๋‹ค ์ž๋™ ๊ฒ€์ฆ์ด ์‹คํ–‰๋˜์–ด ์ˆ˜๋™ ๋กœ์ปฌ ์‹คํ–‰ ์˜์กด๋„๊ฐ€ ๋‚ฎ์•„์ง„๋‹ค. + - QC gate ์šด์˜์—์„œ CI ํ†ต๊ณผ ๋กœ๊ทธ๊ฐ€ 1์ฐจ evidence๋กœ ํ‘œ์ค€ํ™”๋˜๊ณ , browser evidence๋Š” ํ™˜๊ฒฝ ํ—ˆ์šฉ ์‹œ ๋ณด๊ฐ• ์ฆ๊ฑฐ๋กœ ์šด์˜๋œ๋‹ค. + +# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก +์‹ค์ œ๋กœ ๊ตฌํ˜„๊ณผ ๊ฒ€์ฆ์œผ๋กœ ์ด์–ด์งˆ ์š”๊ตฌ์‚ฌํ•ญ์„ ๊ธฐ๋Šฅ/๋น„๊ธฐ๋Šฅ์œผ๋กœ ๋‚˜๋ˆ„์–ด ์ •๋ฆฌํ•˜๊ณ , ๋ฒ”์œ„ ๊ฒฝ๊ณ„๋ฅผ ๋ช…ํ™•ํžˆ ์ ๋Š”๋‹ค. + +## ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +- FR-1: push/PR ์ด๋ฒคํŠธ์—์„œ pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ(`testpaths=["tests"]`)๋ฅผ ์ž๋™ ์‹คํ–‰ํ•˜๊ณ , ์‹คํŒจ ์‹œ ๋จธ์ง€ blocker๋กœ ๋™์ž‘ํ•˜๋Š” CI workflow๋ฅผ ์ œ๊ณตํ•ด์•ผ ํ•œ๋‹ค. +- FR-2: Streamlit `AppTest`(`tests/test_dashboard_app.py` ๊ณ„์—ด)์™€ ๋Œ€์‹œ๋ณด๋“œ HTTP smoke(๊ณ ์ • ํฌํŠธ๋กœ ๊ธฐ๋™ ํ›„ HTTP 200 ํ™•์ธ)๋ฅผ synthetic fixture DB๋กœ CI์—์„œ ์ž๋™ ์‹คํ–‰ํ•ด์•ผ ํ•œ๋‹ค. +- FR-3: DB safety smoke(`scripts/ledger_restore_smoke.py` ๊ณ„์—ด)๋ฅผ ์ž„์‹œ fixture/๊ฒ€์ฆ DB์—์„œ CI job์œผ๋กœ ์‹คํ–‰ํ•˜์—ฌ, ๋ณต๊ตฌ ์ ˆ์ฐจ๊ฐ€ ์›๋ณธ ์šด์˜ DB๋ฅผ ๊ฑด๋“œ๋ฆฌ์ง€ ์•Š๊ณ  ํ†ต๊ณผํ•˜๋Š”์ง€ ์ž๋™ ๊ฒ€์ฆํ•ด์•ผ ํ•œ๋‹ค. +- FR-4: CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ(pytest/AppTest/HTTP/DB safety smoke)์™€ ํ™˜๊ฒฝ ์˜์กด ๊ฒ€์ฆ(browser-screenshot E2E)์„ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ํ•˜๊ณ , browser evidence๋Š” CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ์—์„œ ์ œ์™ธํ•˜๋˜ ๋กœ์ปฌ ์ ˆ์ฐจ(dcy-004)์™€์˜ ์—ญํ• ์„ ๋ฌธ์„œํ™”ํ•ด์•ผ ํ•œ๋‹ค. +- FR-5: CI ๊ณ„์•ฝ๊ณผ ๊ฒ€์ฆ job ๊ตฌ์กฐ๋ฅผ `docs/project-structure.md`์™€ `docs/runtime-flows.md`์— ๋ฐ˜์˜ํ•˜๊ณ ("CI workflow ... not yet defined" ํ•ญ๋ชฉ ๊ฐฑ์‹ ), ํ•„์š” ์‹œ CI ์‹คํŒจ ๋Œ€์‘ runbook ํ›„๋ณด๋ฅผ `docs/runbooks/`์— ์ถ”๊ฐ€ํ•ด์•ผ ํ•œ๋‹ค. + +## ๋น„๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ +์„ฑ๋Šฅ, ์šด์˜์„ฑ, ์ผ๊ด€์„ฑ, ์ž๋™ํ™” ์•ˆ์ •์„ฑ ๋“ฑ ๊ธฐ๋Šฅ ์™ธ ์š”๊ตฌ์‚ฌํ•ญ์„ ์ ๋Š”๋‹ค. + +- NFR-1: CI ๊ฒ€์ฆ์€ ๊ฒฐ์ •์ ์ด์–ด์•ผ ํ•œ๋‹ค. ๋™์ผ ์ปค๋ฐ‹์— ๋Œ€ํ•ด ๋™์ผ ๊ฒฐ๊ณผ๋ฅผ ์‚ฐ์ถœํ•˜๋„๋ก synthetic fixtureยท๊ณ ์ • ํฌํŠธ/๊ฒฝ๋กœยท๋กœ๋“œ ์™„๋ฃŒ ๋Œ€๊ธฐ ์กฐ๊ฑด์„ ํ‘œ์ค€ํ™”ํ•œ๋‹ค(flaky job ์ตœ์†Œํ™”). +- NFR-2: CI ๋กœ๊ทธยท์•„ํ‹ฐํŒฉํŠธ์— ์‹ค๊ณ„์ขŒ๋ฒˆํ˜ธยท์ž”์•กยทraw_payloadยท์›๋ณธ ํŒŒ์ผ๋ช…ยท๋น„๋ฐ€๋ฒˆํ˜ธ ๋“ฑ ๋ฏผ๊ฐ์ •๋ณด๊ฐ€ ํ‰๋ฌธ์œผ๋กœ ๋…ธ์ถœ๋˜์–ด์„œ๋Š” ์•ˆ ๋˜๋ฉฐ, ๋ชจ๋“  CI ๊ฒ€์ฆ์€ synthetic fixture๋ฅผ ์šฐ์„  ์‚ฌ์šฉํ•œ๋‹ค. +- NFR-3: CI๋Š” ์šด์˜ DB๋ฅผ ์ ˆ๋Œ€ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ์ž„์‹œ fixture/๊ฒ€์ฆ DB(dcy-002 ์ ˆ์ฐจ)์—์„œ๋งŒ ๋™์ž‘ํ•˜์—ฌ ์›๋ณธ ๋ฐ์ดํ„ฐ ํ›ผ์† ์œ„ํ—˜์ด ์—†์–ด์•ผ ํ•œ๋‹ค. +- NFR-4: CI runner์˜ browser/snap ๊ฐ€์šฉ์„ฑ์— ์˜์กดํ•˜์ง€ ์•Š๊ณ ๋„ ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๊ฐ€ ํ†ต๊ณผ ๊ฐ€๋Šฅํ•ด์•ผ ํ•œ๋‹ค(headless browser๋Š” best-effort, ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋Š” pytest/AppTest/HTTP/DB safety smoke). + +## ๋ฒ”์œ„ ๊ฒฝ๊ณ„ +์ด๋ฒˆ ๋ฐ˜๋ณต์—์„œ ๋ฐ˜๋“œ์‹œ ํฌํ•จํ•  ๋ฒ”์œ„์™€ ๋ช…์‹œ์ ์œผ๋กœ ์ œ์™ธํ•  ๋ฒ”์œ„๋ฅผ ๊ตฌ๋ถ„ํ•œ๋‹ค. + +- In Scope + - pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ, Streamlit AppTest, ๋Œ€์‹œ๋ณด๋“œ HTTP smoke, DB safety smoke๋ฅผ push/PR์—์„œ ์ž๋™ ์‹คํ–‰ํ•˜๋Š” CI ๊ณ„์•ฝ ์ •์˜ + - CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ์™€ ํ™˜๊ฒฝ ์˜์กด(browser) ๊ฒ€์ฆ์˜ ๊ฒฝ๊ณ„ ๊ทœ์น™ ์ •์˜ ๋ฐ baseline ๋ฌธ์„œ ๋ฐ˜์˜ + - synthetic fixture ์šฐ์„ ยท๋ฏผ๊ฐ์ •๋ณด ๊ธˆ์ง€ยท์šด์˜ DB ๋ฏธ์‚ฌ์šฉ ์›์น™์˜ CI ์ ์šฉ +- Out of Scope + - browser-screenshot/visual regression์˜ CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธํ™”(ํ™˜๊ฒฝ ์˜์กด์ด ์ปค best-effort๋กœ๋งŒ ๊ฒ€ํ† ). ๋กœ์ปฌ browser evidence๋Š” dcy-004 ๋ฒ”์œ„๋กœ ์œ ์ง€. + - ๋ฐฐํฌ/๋ฆด๋ฆฌ์Šค ์ž๋™ํ™”(CD), ์™ธ๋ถ€ ํ˜ธ์ŠคํŒ…/์„œ๋น„์Šค ์šด์˜ ๋„์ž…. ๋ณธ ๋ฐ˜๋ณต์€ ๊ฒ€์ฆ CI ๊ฒŒ์ดํŠธ๊นŒ์ง€๋กœ ํ•œ์ •. + - ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€ ๋ชฉํ‘œ์น˜ ๊ฐ•์ œ, ์„ฑ๋Šฅ ๋ฒค์น˜๋งˆํฌ ๊ฒŒ์ดํŠธ ๊ฐ™์€ ๊ณ ๋„ํ™” ํ’ˆ์งˆ ๊ฒŒ์ดํŠธ๋Š” ํ›„์† ๋ฐ˜๋ณต์œผ๋กœ ๋ถ„๋ฆฌ. + - CI ํ”Œ๋žซํผ์€ GitHub Actions๋กœ ๊ณ ์ •ํ•˜๊ณ , ๊ธฐ๋ณธ ํŠธ๋ฆฌ๊ฑฐ๋Š” `pull_request`์™€ `main` ๋Œ€์ƒ `push`, ์„ ํƒ ๊ฒฝ๋กœ๋Š” `workflow_dispatch`๋กœ ๋‘”๋‹ค. + +# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก +๋ถˆํ™•์‹ค์„ฑ๊ณผ ์ „์ œ ์กฐ๊ฑด์„ ๋ถ„๋ฆฌํ•ด์„œ ์ ๋Š”๋‹ค. ๋ฆฌ์Šคํฌ๋Š” ์˜ํ–ฅ๊ณผ ์™„ํ™” ๋ฐฉ์•ˆ์„, ๊ฐ€์ •์€ ํ˜„์žฌ ํŒ๋‹จ์˜ ์ „์ œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. + +## ๋ฆฌ์Šคํฌ +- R-1 (Medium): Streamlit AppTest/HTTP smoke๊ฐ€ ๋™์  ๋ Œ๋”๋งยท๋น„๋™๊ธฐ ๋กœ๋”ฉ์œผ๋กœ CI์—์„œ ๋น„๊ฒฐ์ •์ ์œผ๋กœ ์‹คํŒจ(flaky)ํ•˜๋ฉด ๊ฒŒ์ดํŠธ ์‹ ๋ขฐ๋„๊ฐ€ ๋–จ์–ด์ง„๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ๊ฒฐ์ •์  synthetic fixture DB, ๊ณ ์ • ํฌํŠธ, ๊ธฐ๋™ ํ›„ readiness ๋Œ€๊ธฐ(HTTP 200 ํด๋ง) ์กฐ๊ฑด์„ ํ‘œ์ค€ํ™”ํ•˜๊ณ , ์žฌ์‹œ๋„/ํƒ€์ž„์•„์›ƒ ์ •์ฑ…์„ ๋ช…์‹œํ•œ๋‹ค. +- R-2 (Medium): CI ์‹คํ–‰ ์ค‘ fixture ์ค€๋น„๋‚˜ ๋กœ๊ทธ ์ถœ๋ ฅ์— ์‹ค๋ฐ์ดํ„ฐ/๋ฏผ๊ฐ์ •๋ณด๊ฐ€ ์„ž์ด๋ฉด ์™ธ๋ถ€ CI ๋กœ๊ทธ์— ํ‰๋ฌธ ๋…ธ์ถœ๋  ์ˆ˜ ์žˆ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: ๋ชจ๋“  CI ๊ฒ€์ฆ์„ synthetic fixture๋กœ ๊ณ ์ •ํ•˜๊ณ  ์šด์˜ DB ๊ฒฝ๋กœ ์ ‘๊ทผ์„ ์ฐจ๋‹จํ•œ๋‹ค. ๋กœ๊ทธ/์•„ํ‹ฐํŒฉํŠธ ๋งˆ์Šคํ‚น ๋ฐ ๋ฏผ๊ฐ ํ•„๋“œ ๊ณต๋ž€ ์›์น™์„ ๊ฒ€์ฆ ๋‹จ๊ณ„์— ํฌํ•จํ•œ๋‹ค. +- R-3 (Low): browser evidence๋ฅผ CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋กœ ๋ฌด๋ฆฌํ•˜๊ฒŒ ๋Œ์–ด๋“ค์ด๋ฉด runner ํ™˜๊ฒฝ ์ œ์•ฝ์œผ๋กœ ๊ฒŒ์ดํŠธ๊ฐ€ ์ƒ์‹œ ์‹คํŒจํ•  ์ˆ˜ ์žˆ๋‹ค. + - ์™„ํ™” ๋ฐฉ์•ˆ: browser-screenshot E2E๋ฅผ ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ์—์„œ ์ œ์™ธํ•˜๊ณ  best-effort/๋กœ์ปฌ ์ ˆ์ฐจ๋กœ ๋ถ„๋ฆฌํ•˜๋Š” ๊ฒฝ๊ณ„ ๊ทœ์น™์„ ๋ช…์‹œํ•œ๋‹ค. + +## ๊ฐ€์ • +์•„์ง ํ™•์ •๋˜์ง€ ์•Š์•˜์ง€๋งŒ ํ˜„์žฌ ๋ฌธ์„œ ์ž‘์„ฑ์˜ ๊ธฐ๋ฐ˜์ด ๋˜๋Š” ์ „์ œ๋ฅผ ์ ๋Š”๋‹ค. + +- A-1: ๊ธฐ์กด pytest ์Šค์œ„ํŠธ, `tests/test_dashboard_app.py`์˜ AppTest, `scripts/ledger_restore_smoke.py`, `scripts/streamlit_browser_evidence.py` ์ž์‚ฐ์„ CI์—์„œ ์žฌ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ํ˜ธ์ถœํ•  ์ˆ˜ ์žˆ๋‹ค. +- A-2: CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋Š” headless browser ์—†์ด๋„ pytest/AppTest/HTTP smoke/DB safety smoke๋งŒ์œผ๋กœ ์˜๋ฏธ ์žˆ๋Š” ํšŒ๊ท€ ๋ณดํ˜ธ๋ฅผ ์ œ๊ณตํ•œ๋‹ค. +- A-3: DB safety smoke์™€ fixture ์ค€๋น„๋Š” dcy-002์˜ ๋ฐฑ์—…ยท๊ฒ€์ฆ DB ์ ˆ์ฐจ๊ฐ€ ์šด์˜ ๊ฐ€๋Šฅํ•œ ์ƒํƒœ์ž„์„ ์ „์ œ๋กœ ์ž„์‹œ/๋ณต์‚ฌ๋ณธ DB์—์„œ ์ˆ˜ํ–‰ํ•œ๋‹ค. +- A-4: synthetic fixture๋งŒ์œผ๋กœ ํ•ต์‹ฌ import/๋ถ„๋ฅ˜/๋Œ€์‹œ๋ณด๋“œ ๊ฒฝ๋กœ์˜ CI ๊ฒ€์ฆ์ด ๊ฐ€๋Šฅํ•˜๋ฉฐ, ์‹ค๋ฐ์ดํ„ฐ ์—†์ด๋„ ํšŒ๊ท€๋ฅผ ์ถฉ๋ถ„ํžˆ ํฌ์ฐฉํ•  ์ˆ˜ ์žˆ๋‹ค. + +## ์˜คํ”ˆ ์งˆ๋ฌธ +๋‹ต์„ ์•„์ง ๋ชจ๋ฅด๋Š” ํ•ญ๋ชฉ์„ ์ ๋Š”๋‹ค. ์ถ”์ •์œผ๋กœ ๋ฎ์ง€ ๋ง๊ณ  REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ด์ „์— ํ™•์ธํ•  ์งˆ๋ฌธ์œผ๋กœ ๋‚จ๊ธด๋‹ค. + +- OQ-1: CI ์‹คํ–‰ ํ”Œ๋žซํผ๊ณผ ํŠธ๋ฆฌ๊ฑฐ ๋ฒ”์œ„๋ฅผ ๋ฌด์—‡์œผ๋กœ ํ™•์ •ํ•  ๊ฒƒ์ธ๊ฐ€(GitHub Actions push/PR vs ์ˆ˜๋™ dispatch vs ๋กœ์ปฌ pre-commit ๋ณ‘ํ–‰)? + - ์ƒํƒœ: Answered + - ๋‹ต๋ณ€: GitHub Actions๋ฅผ ๊ธฐ๋ณธ CI ํ”Œ๋žซํผ์œผ๋กœ ์‚ฌ์šฉํ•˜๊ณ , `pull_request`์™€ `main` ๋Œ€์ƒ `push`๋ฅผ ๊ธฐ๋ณธ ํŠธ๋ฆฌ๊ฑฐ๋กœ ํ•˜๋ฉฐ `workflow_dispatch`๋ฅผ ์„ ํƒ์  ์ˆ˜๋™ ์žฌ์‹คํ–‰ ๊ฒฝ๋กœ๋กœ ํ—ˆ์šฉํ•œ๋‹ค. + - ๋ฐ˜์˜ ๋ฉ”๋ชจ: REQ acceptance criteria์—๋Š” PR ๊ธฐ์ค€ ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ์™€ `main` push ์žฌ์‹คํ–‰ ๊ธฐ์ค€, ์„ ํƒ์  ์ˆ˜๋™ ์‹คํ–‰ ํ—ˆ์šฉ์„ ํฌํ•จํ•œ๋‹ค. +- OQ-2: HTTP smoke์™€ DB safety smoke๋ฅผ ๊ธฐ์กด `scripts/` ์ž์‚ฐ์„ ๊ทธ๋Œ€๋กœ CI์—์„œ ํ˜ธ์ถœํ• ์ง€, CI ์ „์šฉ ๊ฒฐ์ •์  ์ง„์ž…์ ์œผ๋กœ ๋ณ„๋„ ๋ž˜ํ•‘ํ• ์ง€? + - ์ƒํƒœ: Answered + - ๋‹ต๋ณ€: ๊ธฐ์กด `scripts/`์™€ ํ…Œ์ŠคํŠธ ์ž์‚ฐ์˜ ๋‚ด๋ถ€ ๋กœ์ง์€ ์žฌ์‚ฌ์šฉํ•˜๋˜, CI์—์„œ๋Š” ์ข…๋ฃŒ์ฝ”๋“œยท์ธ์žยท๋กœ๊ทธ๊ฐ€ ๊ฒฐ์ •์ ์ธ ์–‡์€ wrapper entrypoint๋ฅผ ๋ณ„๋„๋กœ ๋‘”๋‹ค. + - ๋ฐ˜์˜ ๋ฉ”๋ชจ: REQ/Batch ์„ค๊ณ„ ์‹œ smoke job์€ CI ์ „์šฉ wrapper๋ฅผ ํ‘œ์ค€ ์ง„์ž…์ ์œผ๋กœ ์‚ฌ์šฉํ•˜๊ณ , ๊ธฐ์กด ์ž์‚ฐ์€ ๋‚ด๋ถ€ ๊ตฌํ˜„์œผ๋กœ ์žฌ์‚ฌ์šฉํ•œ๋‹ค. + +# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€ +์ด ๋ณ€๊ฒฝ์ด ์„ฑ๊ณตํ–ˆ๋‹ค๊ณ  ํŒ๋‹จํ•  ๊ธฐ์ค€์„ ์ธก์ • ๊ฐ€๋Šฅํ•˜๊ฒŒ ์ •์˜ํ•œ๋‹ค. ๊ฐ€๋Šฅํ•˜๋ฉด ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜๊นŒ์ง€ ์—ฐ๊ฒฐํ•œ๋‹ค. + +- S-1: push/PR ์ด๋ฒคํŠธ์—์„œ pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ๊ฐ€ CI๋กœ ์ž๋™ ์‹คํ–‰๋˜๊ณ , ์˜๋„์ ์œผ๋กœ ๊นจ์ง„ ๋ณ€๊ฒฝ์— ๋Œ€ํ•ด ๊ฒŒ์ดํŠธ๊ฐ€ ๋จธ์ง€๋ฅผ ์ฐจ๋‹จํ•จ์„ ์žฌํ˜„ ๊ฐ€๋Šฅํ•˜๊ฒŒ ํ™•์ธํ•œ๋‹ค. +- S-2: Streamlit AppTest, HTTP smoke, DB safety smoke๊ฐ€ synthetic fixture/์ž„์‹œ ๊ฒ€์ฆ DB๋กœ CI์—์„œ ํ†ต๊ณผํ•˜๊ณ , ์‹คํ–‰ ๋กœ๊ทธยท์•„ํ‹ฐํŒฉํŠธ์— ๋ฏผ๊ฐ์ •๋ณด๊ฐ€ ๋…ธ์ถœ๋˜์ง€ ์•Š์Œ์„ ํ™•์ธํ•œ๋‹ค. +- S-3: CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ์™€ browser evidence ๊ฒฝ๊ณ„, CI ๊ฒ€์ฆ ํ๋ฆ„์ด `docs/project-structure.md`("CI workflow ... not yet defined" ๊ฐฑ์‹ )์™€ `docs/runtime-flows.md`์— ๋ฐ˜์˜๋˜์–ด ํ›„์† REQ/Batch์—์„œ ์ฐธ์กฐ ๊ฐ€๋Šฅํ•˜๋‹ค. + +## ์ธก์ • ๋ฐฉ์‹ ๋ฐ ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ +๊ฐ ์„ฑ๊ณต ๊ธฐ์ค€์„ ์–ด๋–ป๊ฒŒ ์ธก์ •ํ• ์ง€์™€ ์–ด๋–ค ๋ฐ์ดํ„ฐ๋‚˜ ๋ฌธ์„œ๋ฅผ ๊ทผ๊ฑฐ๋กœ ์“ธ์ง€ ์ ๋Š”๋‹ค. + +- S-1 ์ธก์ • ๋ฐฉ์‹: CI ์‹คํ–‰ ๊ฒฐ๊ณผ(์„ฑ๊ณต/์‹คํŒจ) ๋กœ๊ทธ์™€, ์˜๋„์  ํšŒ๊ท€ ์ฃผ์ž… ์‹œ ๊ฒŒ์ดํŠธ๊ฐ€ failํ•˜๋Š”์ง€ ์žฌํ˜„ ํ™•์ธ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: CI workflow ์‹คํ–‰ ๋กœ๊ทธ, pytest ๊ฒฐ๊ณผ +- S-2 ์ธก์ • ๋ฐฉ์‹: AppTest/HTTP/DB safety smoke job์˜ ํ†ต๊ณผ ๋กœ๊ทธ ํ™•์ธ ๋ฐ ๋กœ๊ทธยท์•„ํ‹ฐํŒฉํŠธ์˜ ๋ฏผ๊ฐ์ •๋ณด ๋ฏธ๋…ธ์ถœ ๊ฒ€์ˆ˜ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: CI job ๋กœ๊ทธยท์•„ํ‹ฐํŒฉํŠธ, synthetic fixture DB +- S-3 ์ธก์ • ๋ฐฉ์‹: baseline ๋ฌธ์„œ ๊ฐฑ์‹  ๋ฐ CI ๊ฒ€์ฆ ํ๋ฆ„ ๋ฐ˜์˜ ์—ฌ๋ถ€ ๋ฆฌ๋ทฐ / ๋ฐ์ดํ„ฐ ์ถœ์ฒ˜: `docs/project-structure.md`, `docs/runtime-flows.md`, `docs/runbooks/` + +# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ๋‹จ๊ณ„๋กœ ๋„˜๊ธฐ๊ธฐ ์ „์— ๋ฌด์—‡์ด ํ™•์ •๋˜์–ด์•ผ ํ•˜๋Š”์ง€ ํ™•์ธํ•œ๋‹ค. ๋ฏธ์™„๋ฃŒ ํ•ญ๋ชฉ์€ ํ›„์† ๊ฒฐ์ •์ด ํ•„์š”ํ•จ์„ ๋œปํ•œ๋‹ค. + +- [x] ๋ฒ”์œ„(In Scope/Out of Scope) ํ™•์ • + - ๊ธฐ์ค€: ๋ณธ ๋ฐ˜๋ณต์€ ๊ฒ€์ฆ CI ๊ฒŒ์ดํŠธ(pytest/AppTest/HTTP/DB safety smoke)๊นŒ์ง€๋กœ ํ•œ์ •ํ•˜๊ณ  CDยทvisual regressionยท์ปค๋ฒ„๋ฆฌ์ง€ ๊ฒŒ์ดํŠธ๋Š” ์ œ์™ธํ•œ๋‹ค๋Š” ์ ์„ ์‚ฌ์šฉ์ž๊ฐ€ ํ™•์ธํ•œ๋‹ค. +- [x] ์„ฑ๊ณต ์ง€ํ‘œ์˜ ์ธก์ • ๋ฐฉ์‹/๋ฐ์ดํ„ฐ ์ถœ์ฒ˜ ํ™•์ • + - ๊ธฐ์ค€: CI ์‹คํ–‰ ๋กœ๊ทธยท์•„ํ‹ฐํŒฉํŠธยทsynthetic fixture๊ฐ€ acceptance criteria์˜ ์ธก์ • ๊ทผ๊ฑฐ๋กœ ํ™•์ •๋œ๋‹ค. +- [ ] High ์˜ํ–ฅ ๋ฆฌ์Šคํฌ ์šฐ์„ ์ˆœ์œ„ ๋ฐ ํ•ด์†Œ ์ฑ…์ž„์ž ์ง€์ • + - ๊ธฐ์ค€: flaky ์œ„ํ—˜(R-1)๊ณผ ๋ฏผ๊ฐ์ •๋ณด ๋…ธ์ถœ(R-2) ์™„ํ™” ์ฑ…์ž„์ž๋ฅผ ์ง€์ •ํ•œ๋‹ค. ๋ณธ Discovery์— ํ˜„์žฌ High ๋ฆฌ์Šคํฌ๋Š” ์—†์œผ๋‚˜ R-1/R-2๋ฅผ ์šฐ์„  ๊ด€๋ฆฌํ•œ๋‹ค. +- [x] ๋ชจ๋“  ์˜คํ”ˆ ์งˆ๋ฌธ(OQ) `Open` ์ƒํƒœ ์—†์Œ ํ™•์ธ + - ๊ธฐ์ค€: OQ-1(CI ํ”Œ๋žซํผ/ํŠธ๋ฆฌ๊ฑฐ), OQ-2(smoke ์ง„์ž…์  ์žฌ์‚ฌ์šฉ vs ๋ž˜ํ•‘)๊ฐ€ ๋ชจ๋‘ Answered๋กœ ์ •๋ฆฌ๋˜์—ˆ๋‹ค. +- [x] Freeze ํ™•์ • ๋‹ด๋‹น์ž(CONFIRMED_BY) ์ง€์ • + - ๊ธฐ์ค€: ์‚ฌ์šฉ์ž(์ง„ํ™˜ ๊ณ )๊ฐ€ ๊ถŒ์žฅ์•ˆ์„ ์Šน์ธํ–ˆ๋‹ค. + +# 9. ํŒŒ์ผ ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ +์ด Discovery ๋ฌธ์„œ ์ƒ์„ฑ/๊ฐฑ์‹  ๊ฒฐ๊ณผ์™€ ์ฐธ์กฐ ๋ฌธ์„œ๋ฅผ ๊ธฐ๋กํ•œ๋‹ค. ์ดํ›„ ์ถ”์ ๊ณผ ๋ฆฌ๋ทฐ ์‹œ ๊ทผ๊ฑฐ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + +- ์ฒ˜๋ฆฌ ๊ฒฐ๊ณผ: ์ƒ์„ฑ +- ์ƒ์„ฑ ๊ฒฝ๋กœ: docs/discovery/dcy-006_20260630_pytest-apptest-smoke-ci-automation.md +- ์ฐธ์กฐ ๋ฌธ์„œ: + - docs/discovery/dcy-004_20260629_browser-streamlit-e2e-evidence-recovery.md + - docs/project-structure.md + - docs/runtime-flows.md + - stage-pilot/templates/project-structure.md + - stage-pilot/templates/runtime-flows.md + +# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ +์‚ฌ์šฉ์ž๋‚˜ ์Šน์ธ์ž๊ฐ€ ๊ฒฐ์ •ํ•ด์•ผ ํ•˜๋Š” ํ•ญ๋ชฉ๋งŒ ๋ชจ์•„ ์ •๋ฆฌํ•œ๋‹ค. ๊ฒฐ์ •, ํ™•์ธ, ๋ฐ์ดํ„ฐ ์š”์ฒญ์„ ๊ตฌ๋ถ„ํ•ด์„œ ์ ๋Š”๋‹ค. + +## DECIDE +- CI ํ”Œ๋žซํผ์€ GitHub Actions๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. ๊ธฐ๋ณธ ํŠธ๋ฆฌ๊ฑฐ๋Š” `pull_request`์™€ `main` ๋Œ€์ƒ `push`์ด๋ฉฐ, `workflow_dispatch`๋Š” ์„ ํƒ์  ์ˆ˜๋™ ์žฌ์‹คํ–‰ ๊ฒฝ๋กœ๋กœ ํ—ˆ์šฉํ•œ๋‹ค. +- HTTP smoke/DB safety smoke๋Š” ๊ธฐ์กด `scripts/`์™€ ํ…Œ์ŠคํŠธ ์ž์‚ฐ์„ ๋‚ด๋ถ€์ ์œผ๋กœ ์žฌ์‚ฌ์šฉํ•˜๋˜, CI์—์„œ๋Š” ์ข…๋ฃŒ์ฝ”๋“œยท์ธ์žยท๋กœ๊ทธ๊ฐ€ ๊ฒฐ์ •์ ์ธ ์–‡์€ wrapper entrypoint๋ฅผ ํ‘œ์ค€์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + +## CONFIRM +- CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋Š” `pytest`, `Streamlit AppTest`, `HTTP smoke`, `DB safety smoke`๋กœ ํ•œ์ •ํ•œ๋‹ค. browser-screenshot E2E๋Š” ํ•„์ˆ˜ ์ฐจ๋‹จ ๊ฒŒ์ดํŠธ์— ๋„ฃ์ง€ ์•Š๊ณ  ๋กœ์ปฌ ๋ณด๊ฐ• evidence ์ ˆ์ฐจ(dcy-004)๋กœ ์œ ์ง€ํ•œ๋‹ค. + +## DATA +- CI๋Š” ๋งค ์‹คํ–‰ ์‹œ ์ƒ์„ฑ๋˜๋Š” synthetic fixture DB๋งŒ ์‚ฌ์šฉํ•œ๋‹ค. ์šด์˜ DB๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š์œผ๋ฉฐ ์‹ค๊ณ„์ขŒ๋ฒˆํ˜ธยท์‹ค์ž”์•กยท`raw_payload`ยท์›๋ณธ ํŒŒ์ผ๋ช…ยท๋น„๋ฐ€๋ฒˆํ˜ธ๋Š” ํฌํ•จํ•˜์ง€ ์•Š๋Š”๋‹ค. +- synthetic fixture DB๋Š” ๊ณ„์ขŒ/๊ฑฐ๋ž˜/ํ™•์ธํ•„์š” ํ/์›”๊ฐ„ ํ˜„๊ธˆํ๋ฆ„/๋ณต๊ตฌ smoke๊ฐ€ ๋ชจ๋‘ ์žฌํ˜„ ๊ฐ€๋Šฅํ•œ ์ตœ์†Œ ๊ฒฐ์ •์  ์ƒ˜ํ”Œ ์„ธํŠธ๋ฅผ ํฌํ•จํ•˜๊ณ , runner temp ๊ฒฝ๋กœ ๋˜๋Š” ๋™๋“ฑํ•œ ์ž„์‹œ ๊ฒฝ๋กœ์— ์ƒ์„ฑํ•œ๋‹ค. + +# 11. Discovery Freeze +REQ ์ดˆ์•ˆ ์ž‘์„ฑ ์ธ๊ณ„ ์‹œ์ ์˜ ํ™•์ • ํ”Œ๋ž˜๊ทธ์™€ ์„น์…˜ ์ฐธ์กฐ๋งŒ ๊ธฐ๋กํ•œ๋‹ค. ๋‚ด์šฉ ์žฌ์ž‘์„ฑ ์—†์ด ์›๋ณธ ์„น์…˜์„ ๋งํฌ๋กœ ๋Œ€์‹ ํ•œ๋‹ค. + +## ์„น์…˜ ์ฐธ์กฐ +- ๋ฒ”์œ„: [# 5. ์š”๊ตฌ์‚ฌํ•ญ ๋ชฉ๋ก > ๋ฒ”์œ„ ๊ฒฝ๊ณ„](#5-์š”๊ตฌ์‚ฌํ•ญ-๋ชฉ๋ก) +- ๋ฆฌ์Šคํฌ/์˜คํ”ˆ ์งˆ๋ฌธ: [# 6. ๋ฆฌ์Šคํฌ/๊ฐ€์ • ๋ชฉ๋ก](#6-๋ฆฌ์Šคํฌ๊ฐ€์ •-๋ชฉ๋ก) +- ์„ฑ๊ณต ๊ธฐ์ค€: [# 7. ์ดˆ๊ธฐ ์„ฑ๊ณต ๊ธฐ์ค€](#7-์ดˆ๊ธฐ-์„ฑ๊ณต-๊ธฐ์ค€) +- ์ธ๊ณ„ ์ „ ํ™•์ธ: [# 8. REQ๋กœ ๋„˜๊ธฐ๊ธฐ ์ „ ํ™•์ธ ์ฒดํฌ](#8-req๋กœ-๋„˜๊ธฐ๊ธฐ-์ „-ํ™•์ธ-์ฒดํฌ) +- ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š”: [# 10. ์‚ฌ์šฉ์ž ๊ฒฐ์ • ํ•„์š” ํ•ญ๋ชฉ ์š”์•ฝ](#10-์‚ฌ์šฉ์ž-๊ฒฐ์ •-ํ•„์š”-ํ•ญ๋ชฉ-์š”์•ฝ) + +## Freeze ํ”Œ๋ž˜๊ทธ +- Handoff Decision: ์Šน์ธ +- Handoff Rationale: ์‚ฌ์šฉ์ž๊ฐ€ ๊ถŒ์žฅ ๊ธฐ๋ณธ์•ˆ(GitHub Actions + CI ์ „์šฉ ์–‡์€ smoke wrapper + pytest/AppTest/HTTP/DB safety smoke ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ + synthetic fixture DB ์ •์ฑ…)์„ ์Šน์ธํ•ด ์‚ฌ๋žŒ ๊ฒฐ์ • ํ•ญ๋ชฉ์ด ํ•ด์†Œ๋˜์—ˆ๋‹ค. ๋‚จ์€ ์ฑ…์ž„์ž ์ง€์ •์€ REQ/Batch planning ๋‹จ๊ณ„์—์„œ ํ• ๋‹น ๊ฐ€๋Šฅํ•œ ์šด์˜ ํ•ญ๋ชฉ์ด๋ฉฐ Discovery confirm์„ ๋ง‰์ง€ ์•Š๋Š”๋‹ค. +- Ready for REQ Drafting: true +- Confirmed By: ์ง„ํ™˜ ๊ณ  +- Confirmed At (KST): 2026-06-30 13:54 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index afc3ee1..0c66911 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -8,3 +8,7 @@ |---|---|---|---|---| | dcy-001_20260626_streamlit-ux-e2e-categorization-refund-workflows | 20260626 | Streamlit ์šด์˜ UX/E2E ๊ฒ€์ฆ ๋ฐ ๋ถ„๋ฅ˜ยทํ™˜๊ธ‰ ์›Œํฌํ”Œ๋กœ์šฐ ์•ˆ์ •ํ™” | [Discovery](./dcy-001_20260626_streamlit-ux-e2e-categorization-refund-workflows.md) | approved | | dcy-002_20260627_operational-safety-backup-restore-validation-db | 20260627 | Jarca Ledger ์šด์˜ ์•ˆ์ „์žฅ์น˜ ๋ฐ ๋ฐฑ์—…ยท๋ณต๊ตฌยท๊ฒ€์ฆ DB ์›Œํฌํ”Œ๋กœ์šฐ ์ •๋ฆฝ | [Discovery](./dcy-002_20260627_operational-safety-backup-restore-validation-db.md) | confirmed | +| dcy-003_20260629_statement-unavailable-manual-evidence-balance-reconciliation | 20260629 | Statement-unavailable ๊ณ„์ขŒ ์ˆ˜๋™ ์ฆ๋น™ ๋ฐ ์ž”์•ก๋Œ€์กฐ ์›Œํฌํ”Œ๋กœ์šฐ ์ •๋ฆฝ | [Discovery](./dcy-003_20260629_statement-unavailable-manual-evidence-balance-reconciliation.md) | confirmed | +| dcy-004_20260629_browser-streamlit-e2e-evidence-recovery | 20260629 | Browser/Streamlit E2E evidence ํ™˜๊ฒฝ ๋ณต๊ตฌ ๋ฐ QC evidence ๊ธฐ์ค€ ๊ฐ•ํ™” | [Discovery](./dcy-004_20260629_browser-streamlit-e2e-evidence-recovery.md) | confirmed | +| dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow | 20260629 | Jarca Ledger ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ์‚ฌ์–‘ ์ •๋ฆฝ (์ด๋ฒˆ ๋‹ฌ ํ˜„๊ธˆํ๋ฆ„ + ์ž์‚ฐ ์œ ๋™์„ฑ + ์šด์˜ ๋ฏธ์ฒ˜๋ฆฌ) | [Discovery](./dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md) | confirmed | +| dcy-006_20260630_pytest-apptest-smoke-ci-automation | 20260630 | pytest/AppTest/Smoke CI ์ž๋™ํ™” | [Discovery](./dcy-006_20260630_pytest-apptest-smoke-ci-automation.md) | confirmed | diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 9809ee6..208856b 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,7 +4,7 @@ - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Status: draft - Owner: Jarca / project maintainer -- Last Updated (KST): 2026-06-27 10:17 +- Last Updated (KST): 2026-06-29 - Source Discovery / Batch: bootstrap-baseline ## Project Summary @@ -23,12 +23,13 @@ ### Representative Tool Groups - Health: `ledger_health_check`, `get_ledger_db_status` - Operational backup: `create_ledger_backup`, `list_ledger_backups`, `get_ledger_backup_status` (restore is intentionally not exposed as MCP) -- Accounts: `create_ledger_account`, `list_ledger_accounts` +- Accounts: `create_ledger_account`, `list_ledger_accounts`, `update_ledger_account`; account payloads include additive `liquidity_bucket` for asset-liquidity reporting. - Transactions: `create_ledger_transaction`, `list_ledger_transactions`, `update_ledger_transaction`, `delete_ledger_transaction` -- Budgets/reports: `create_ledger_budget`, `list_ledger_budgets`, monthly/category/fixed/budget/investable reports +- Budgets/reports: `create_ledger_budget`, `list_ledger_budgets`, monthly/category/fixed/budget/investable reports, `get_asset_liquidity_report` - Imports: KB Bank PDF, Toss Bank PDF, Toss Securities PDF, SeoulPay XLSX, CSV preview/import where implemented - Categories/rules: category CRUD, rule CRUD, categorization preview/apply, uncategorized summaries - Reconciliation: cross-account preview/run/review queue, self-transfer candidates/linking, cancel-original candidates/linking +- Manual balance reconciliation: `get_balance_reconciliation_report` returns preview-only account reconciliation for `manual_only`/`partial_import` accounts. ### Inputs - Structured MCP tool arguments as declared in `server.py` signatures. @@ -75,12 +76,17 @@ ### Outputs - Tables, charts, dialogs, previews, import results, and transaction/category updates. +- Browser evidence runs produce local PNG screenshots, DOM snapshot HTML files, and a manifest JSON for QC review. ### UI Contract Rules - Surface `cashflow_kind` where classification affects actual cashflow interpretation. - Keep actual income/expense separated from account movement summaries. - Rule page should support create/edit/delete and condition fields `amount_min`, `amount_max`, `day_of_month`. - Dialog-based triage actions should avoid losing context where possible. +- Account page should display/edit `statement_status` and show a minimal manual balance reconciliation report without adding an automatic schedule or dedicated workbench. +- Account page should display/edit `liquidity_bucket`; overview uses it with `last_balance` for asset liquidity/composition/debt/net-worth sections. +- Overview top priority is KST current-month actual income/expense/net cashflow/savings rate based on `transaction_date` and `cashflow_kind`. +- UI/dashboard-impacting batches require browser screenshot/snapshot evidence from synthetic/validation DBs. Non-UI batches may omit browser evidence, and docs-only batches do not require it. ## Interface: File Import Contracts - KB Bank PDF: encrypted/password-protected PDF account history; preserve transaction datetime, counterparty, balance, branch, raw summary, and raw payload. @@ -95,13 +101,19 @@ - Import batch metadata should support rollback and audit. - Broad mutation workflows should capture metadata-only DB status and backup evidence before/after risky operations. - Restore execution is limited to script/runbook workflows and explicit approval, not MCP tools. +- Manual balance reconciliation is preview/report-first. `REVIEW_NEEDED` suggests manual decision values (`๊ณ„์ขŒ์ด๋™`, `์‹ค์ง€์ถœ`, `์‹ค์ˆ˜์ž…`, `ํˆฌ์ž์ด๋™`, `๋ถ„์„์ œ์™ธ`, `ํ™•์ธํ•„์š”`) but does not auto-confirm mutations. +- Asset liquidity reporting is account-based: `liquidity_bucket='loan'` is debt/net-worth only, and missing `last_balance` rows must not be inferred from transaction totals. +- Browser evidence screenshots/snapshots must use synthetic or validation DBs only and must not include real account identifiers, source financial filenames, passwords, or raw payloads. - Financial data and passwords are sensitive runtime inputs and should not be committed. ## Current Gaps / Planned Changes - Formal versioning for MCP response contracts is not yet defined. -- Browser-level UI acceptance criteria for Streamlit flows are not yet captured as REQ documents. +- Browser-level UI evidence criteria are captured in REQ-004; broader CI automation is still not present. - CI contract for tests/lint is not yet present. ## Change Log - 2026-06-26 22:24 KST: Created by `bootstrap-baseline` from repository observation. - 2026-06-27 10:17 KST: BAT-002/REQ-002 ๊ธฐ์ค€์œผ๋กœ backup/status/list/hash MCP contract์™€ restore ๋น„๋…ธ์ถœ ์ •์ฑ… ์ถ”๊ฐ€. +- 2026-06-29 KST: REQ-003/BAT-003 ๊ธฐ์ค€์œผ๋กœ account statement status update์™€ balance reconciliation report contract ์ถ”๊ฐ€. +- 2026-06-29 KST: REQ-004/BAT-004 ๊ธฐ์ค€์œผ๋กœ Streamlit browser evidence ์‚ฐ์ถœ๋ฌผ๊ณผ UI evidence gate ๊ทœ์น™ ์ถ”๊ฐ€. +- 2026-06-29 KST: REQ-005/BAT-005 ๊ธฐ์ค€์œผ๋กœ account `liquidity_bucket`, `get_asset_liquidity_report`, dashboard operations hub contract ์ถ”๊ฐ€. diff --git a/docs/project-structure.md b/docs/project-structure.md index 0b17f48..bc94f0c 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -4,8 +4,8 @@ - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Status: draft - Owner: Jarca / project maintainer -- Last Updated (KST): 2026-06-26 22:24 -- Source Discovery / Batch: bootstrap-baseline +- Last Updated (KST): 2026-06-30 +- Source Discovery / Batch: bootstrap-baseline, BAT-006 ## Project Summary - Jarca ๊ฐ€๊ณ„๋ถ€ ์ „์šฉ MCP ์„œ๋ฒ„์™€ Streamlit ๋Œ€์‹œ๋ณด๋“œ๋กœ ๊ณ„์ขŒ/์นด๋“œ ๊ฑฐ๋ž˜, ์˜ˆ์‚ฐ, ๋ถ„๋ฅ˜ ๊ทœ์น™, import, ํ˜„๊ธˆํ๋ฆ„ ๋ถ„์„์„ SQLite์— ์ €์žฅํ•˜๊ณ  ์šด์˜ํ•œ๋‹ค. @@ -45,6 +45,9 @@ jarca-ledger-mcp/ โ”œโ”€โ”€ pyproject.toml โ”œโ”€โ”€ uv.lock โ”œโ”€โ”€ .env.example +โ”œโ”€โ”€ .github/ +โ”‚ โ””โ”€โ”€ workflows/ +โ”‚ โ””โ”€โ”€ ci.yml โ”œโ”€โ”€ dashboard/ โ”‚ โ””โ”€โ”€ app.py โ”œโ”€โ”€ jarca_ledger_mcp/ @@ -65,10 +68,17 @@ jarca-ledger-mcp/ โ”‚ โ”œโ”€โ”€ test_toss_securities_pdf.py โ”‚ โ”œโ”€โ”€ test_seoulpay_xlsx.py โ”‚ โ””โ”€โ”€ test_ledger_categories.py +โ”œโ”€โ”€ scripts/ +โ”‚ โ”œโ”€โ”€ ci_fixture_db.py +โ”‚ โ”œโ”€โ”€ ci_dashboard_http_smoke.py +โ”‚ โ”œโ”€โ”€ ci_db_safety_smoke.py +โ”‚ โ”œโ”€โ”€ ledger_restore_smoke.py +โ”‚ โ””โ”€โ”€ streamlit_browser_evidence.py โ”œโ”€โ”€ docs/ โ”‚ โ”œโ”€โ”€ discovery/ โ”‚ โ”œโ”€โ”€ srs/ โ”‚ โ”œโ”€โ”€ batches/ +โ”‚ โ”œโ”€โ”€ runbooks/ โ”‚ โ”œโ”€โ”€ releases/ โ”‚ โ”œโ”€โ”€ project-structure.md โ”‚ โ”œโ”€โ”€ runtime-flows.md @@ -82,7 +92,7 @@ jarca-ledger-mcp/ - `jarca_ledger_mcp/db.py` - SQLite connection, schema initialization, migrations, row conversion, and database path resolution. - `jarca_ledger_mcp/service.py` - - Domain service layer for accounts, transactions, categories, category rules, imports, reports, transfer matching, and manual link operations. + - Domain service layer for accounts, transactions, categories, category rules, imports, reports, asset liquidity/net-worth aggregation, transfer matching, and manual link operations. - `jarca_ledger_mcp/server.py` - FastMCP tool registration layer that exposes service functions to Hermes/MCP clients. - `jarca_ledger_mcp/kb_bank_pdf.py` @@ -94,9 +104,21 @@ jarca-ledger-mcp/ - `jarca_ledger_mcp/seoulpay_xlsx.py` - SeoulPay encrypted XLSX parsing and preview conversion. - `dashboard/app.py` - - Streamlit operator UI for summaries, transaction filtering, uncategorized triage, rule management, review queues, categories, accounts, and import operations. + - Streamlit operator UI for the operations hub summary, transaction filtering, uncategorized triage, rule management, review queues, categories, accounts/liquidity bucket editing, and import operations. - `tests/` - - Regression tests for service behavior, parser behavior, categorization, category rules, cross-account transfer matching, and ledger category semantics. + - Regression tests for service behavior, parser behavior, categorization, category rules, cross-account transfer matching, ledger category semantics, and Streamlit AppTest dashboard smoke. +- `.github/workflows/ci.yml` + - GitHub Actions workflow for `pull_request`, `push` to `main`, and `workflow_dispatch`; stable required gate jobs are `pytest-regression`, `dashboard-app-test`, `dashboard-http-smoke`, and `db-safety-smoke`. +- `scripts/ci_fixture_db.py` + - Synthetic fixture DB builder for CI/AppTest/smoke checks. Refuses the default operating DB path and seeds only synthetic ledger data. +- `scripts/ci_dashboard_http_smoke.py` + - CI HTTP smoke wrapper that creates a synthetic fixture DB, starts Streamlit on loopback, polls HTTP 200 readiness, and cleans up the process with status-only JSON output. +- `scripts/ci_db_safety_smoke.py` + - CI DB safety wrapper that runs restore smoke through temp synthetic source/backup/target DB paths only and emits status-only JSON. +- `scripts/streamlit_browser_evidence.py` + - Local browser evidence helper that creates a synthetic fixture DB, starts Streamlit with that DB, and captures Chrome screenshot/DOM snapshot artifacts for QC. +- `scripts/ledger_restore_smoke.py` + - Script-only restore smoke helper for validation DBs; restore remains outside the MCP tool surface. ## Dependency Rules - `server.py` should remain a thin tool adapter and delegate business logic to `service.py`. @@ -106,13 +128,17 @@ jarca-ledger-mcp/ ## Shared Boundaries - `cashflow_kind` is the boundary between actual household income/expense and account movement/exclusion semantics. +- `liquidity_bucket` is the account-level boundary for asset liquidity/composition/debt/net-worth views; `dashboard/app.py` displays it and `service.py` computes it from `accounts.last_balance`. - Import evidence fields (`source`, `external_id`, `raw_summary`, `raw_payload`, `counterparty`, `import_batch_id`) are audit boundaries and should be preserved. - Manual reconciliation is represented through memo tags and narrow transaction updates, not broad rewrites. +- Browser evidence artifacts are generated from synthetic/validation DBs and should not embed real financial identifiers or raw source payloads. +- CI gate fixtures are generated at runtime under temp space by `scripts/ci_fixture_db.py`; CI and AppTest must not read the default operating DB path. ## Current Gaps / Planned Changes -- Final UX verification is still needed for ๋ถ„๋ฅ˜ ๊ทœ์น™ ์‚ญ์ œ, ์ทจ์†Œ/ํ™˜๊ธ‰ ์›๊ฑฐ๋ž˜ ์—ฐ๊ฒฐ, and import E2E flows. -- CI workflow and deployment/service management are not yet defined. +- Local browser evidence capture exists for core Streamlit pages; browser screenshot/visual regression remains local evidence rather than a required CI gate. +- CI workflow now covers pytest/AppTest/HTTP smoke/DB safety smoke; branch protection required-check enforcement is a repository setting outside this codebase. - Long-term policy for accounts without downloadable statements remains partially operational/documentation work. +- Separate automatic asset snapshots/manual asset tables are not present; BAT-005 uses account rows as the manual asset/liability source. ## Update Triggers - Update this document when package boundaries change. @@ -121,3 +147,6 @@ jarca-ledger-mcp/ ## Change Log - 2026-06-26 22:24 KST: Created by `bootstrap-baseline` from repository observation. +- 2026-06-29 KST: REQ-004/BAT-004 ๊ธฐ์ค€์œผ๋กœ `scripts/streamlit_browser_evidence.py` ๋ฐ browser evidence artifact ๊ตฌ์กฐ ์ถ”๊ฐ€. +- 2026-06-29 KST: REQ-005/BAT-005 ๊ธฐ์ค€์œผ๋กœ dashboard operations hub์™€ service asset liquidity aggregation ์ฑ…์ž„ ์ถ”๊ฐ€. +- 2026-06-30 KST: REQ-006/BAT-006 ๊ธฐ์ค€์œผ๋กœ GitHub Actions CI workflow, synthetic fixture DB builder, AppTest/HTTP/DB safety smoke wrappers, CI-vs-local-browser evidence ๊ฒฝ๊ณ„ ์ถ”๊ฐ€. diff --git a/docs/runbooks/ci-smoke.md b/docs/runbooks/ci-smoke.md new file mode 100644 index 0000000..6291739 --- /dev/null +++ b/docs/runbooks/ci-smoke.md @@ -0,0 +1,69 @@ +# CI Smoke Gates Runbook + +- Scope: BAT-006 / REQ-006 pytest, Streamlit AppTest, dashboard HTTP smoke, and DB safety smoke gates. +- Safety rule: every command below creates or uses a synthetic fixture/temporary validation DB only. Do not set `JARCA_LEDGER_DB_PATH` to the operating ledger DB for CI reproduction. + +## GitHub Actions gates + +The workflow is `.github/workflows/ci.yml` and runs on: + +- `pull_request` +- `push` to `main` +- `workflow_dispatch` + +Stable job/check names: + +- `pytest-regression` +- `dashboard-app-test` +- `dashboard-http-smoke` +- `db-safety-smoke` + +## Local reproduction + +From repository root: + +```bash +source .venv/bin/activate +pytest --ignore=tests/test_dashboard_app.py +pytest tests/test_dashboard_app.py +python scripts/ci_dashboard_http_smoke.py --host 127.0.0.1 --port 18501 --timeout 45 +python scripts/ci_db_safety_smoke.py --timeout 45 +``` + +With uv, equivalent CI-style commands are: + +```bash +uv sync --extra dev --extra dashboard +uv run pytest --ignore=tests/test_dashboard_app.py +uv run pytest tests/test_dashboard_app.py +uv run python scripts/ci_dashboard_http_smoke.py --host 127.0.0.1 --port 18501 --timeout 45 +uv run python scripts/ci_db_safety_smoke.py --timeout 45 +``` + +## Wrapper responsibilities + +- `scripts/ci_fixture_db.py` + - Creates a deterministic synthetic fixture DB named `fixture.sqlite3`, `ci-ledger-fixture.sqlite3`, or `ledger-fixture.sqlite3`. + - Refuses the default operating DB path. + - Seeds only synthetic accounts, categories, budgets, and transactions. +- `scripts/ci_dashboard_http_smoke.py` + - Creates a temp synthetic fixture DB. + - Starts Streamlit with `JARCA_LEDGER_DB_PATH` pointed at that fixture. + - Polls a loopback URL until HTTP 200 or timeout. + - Terminates/kills the Streamlit process during cleanup. + - Emits status-only JSON. +- `scripts/ci_db_safety_smoke.py` + - Creates a temp synthetic fixture DB and backup copy. + - Runs `scripts/ledger_restore_smoke.py` against temp source/backup/target paths only. + - Emits status-only JSON and never requires restore approval because the target is newly created under temp space. + +## Browser evidence boundary + +Browser screenshot/DOM evidence remains local evidence from `scripts/streamlit_browser_evidence.py`. It is not a required GitHub Actions gate. Use it in addition to the CI gates when a change materially affects visual layout or browser-only behavior. + +## Failure triage + +- `pytest-regression` failure: reproduce with `pytest --ignore=tests/test_dashboard_app.py` and fix the failing unit/service/parser test. +- `dashboard-app-test` failure: reproduce with `pytest tests/test_dashboard_app.py`; inspect Streamlit AppTest exceptions first. +- `dashboard-http-smoke` failure: check whether port 18501 is already in use locally, then rerun with a different port if needed. In CI the job uses a fresh runner. +- `db-safety-smoke` failure: inspect the wrapper JSON fields `restore_smoke_exit_code`, `restored_integrity`, and `health_success`. Do not retry against the operating DB. diff --git a/docs/runbooks/manual-balance-reconciliation.md b/docs/runbooks/manual-balance-reconciliation.md new file mode 100644 index 0000000..1a1a3a1 --- /dev/null +++ b/docs/runbooks/manual-balance-reconciliation.md @@ -0,0 +1,57 @@ +# ์ˆ˜๋™ ์ฆ๋น™ ๋ฐ ์ž”์•ก๋Œ€์กฐ Runbook + +- Source: REQ-003 / BAT-003 / dcy-003 +- Last Updated (KST): 2026-06-29 +- Owner: ๋ ›์ €์žฅ์ธ + +## ๋ชฉ์  + +๊ฑฐ๋ž˜๋‚ด์—ญ ํŒŒ์ผ์ด ์—†๊ฑฐ๋‚˜ ์ผ๋ถ€๋งŒ import ๊ฐ€๋Šฅํ•œ ๊ณ„์ขŒ(`manual_only`, `partial_import`)๋ฅผ ๋Œ€์ƒ์œผ๋กœ ์ˆ˜๋™ ์ฆ๋น™ ๊ฑฐ๋ž˜๋ฅผ ์ž…๋ ฅํ•˜๊ณ  ๊ธฐ์ค€ ์ž”์•ก๊ณผ ๊ณ„์‚ฐ ์ž”์•ก์„ ๋Œ€์กฐํ•œ๋‹ค. + +## ์•ˆ์ „ ์›์น™ + +1. ์šด์˜ DB ๋ณ€๊ฒฝ ์ „ dcy-002 ์ ˆ์ฐจ์— ๋”ฐ๋ผ DB ์ƒํƒœ evidence๋ฅผ ํ™•์ธํ•˜๊ณ  ๋ฐฑ์—…์„ ์ƒ์„ฑํ•œ๋‹ค. +2. ๊ฒ€์ฆ DB์—์„œ ๋จผ์ € preview/reconcile์„ ์ˆ˜ํ–‰ํ•œ๋‹ค. +3. restore๋Š” ์‚ฌ์šฉ์ž ๋ช…์‹œ ์Šน์ธ ์—†์ด๋Š” ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. +4. ์›๋ฌธ ์ฆ๋น™, ํ‰๋ฌธ ๊ณ„์ขŒ๋ฒˆํ˜ธ, ๋น„๋ฐ€๋ฒˆํ˜ธ, ๋ฏผ๊ฐ counterparty ์›๋ฌธ์€ ๋ฌธ์„œ/๋กœ๊ทธ/์ปค๋ฐ‹์— ๋‚จ๊ธฐ์ง€ ์•Š๋Š”๋‹ค. + +## ๊ณ„์ขŒ ์ƒํƒœ ์ •์˜ + +- `unknown`: ์•„์ง statement ์ง€์› ์—ฌ๋ถ€๊ฐ€ ์ •๋ฆฌ๋˜์ง€ ์•Š์Œ. +- `import_supported`: ๊ธฐ์กด statement import ํ๋ฆ„์œผ๋กœ ์ฒ˜๋ฆฌ ๊ฐ€๋Šฅ. +- `manual_only`: statement-unavailable ๊ณ„์ขŒ. ์ˆ˜๋™ ์ฆ๋น™ ๋ฐ ์ž”์•ก๋Œ€์กฐ ๋Œ€์ƒ. +- `partial_import`: ์ผ๋ถ€ statement import ๊ฐ€๋Šฅํ•˜์ง€๋งŒ ๋ˆ„๋ฝ ๊ตฌ๊ฐ„ ์ˆ˜๋™ ๋ณด์™„ ๋ฐ ์ž”์•ก๋Œ€์กฐ ๋Œ€์ƒ. + +## ์ž…๋ ฅ ํฌ๋งท + +์ž”์•ก๋Œ€์กฐ report ์ž…๋ ฅ: + +- `account_id`: ํŠน์ • ๊ณ„์ขŒ๋งŒ ๋Œ€์กฐํ•  ๋•Œ ์‚ฌ์šฉ. ๋น„์šฐ๋ฉด ํ™œ์„ฑ `manual_only`, `partial_import` ๊ณ„์ขŒ ์ „์ฒด. +- `baseline_balance`: ๊ธฐ์ค€ ์ž”์•ก. ์—†์œผ๋ฉด ๊ณ„์ขŒ `last_balance` ์‚ฌ์šฉ. +- `baseline_at`: ๊ธฐ์ค€ ์‹œ๊ฐ. `YYYY-MM`์€ ํ•ด๋‹น ์›” ๋ง์ผ `23:59:00+09:00`, `YYYY-MM-DD`๋Š” ํ•ด๋‹น์ผ `23:59:00+09:00`, ISO datetime์€ ์ˆ˜์‹œ ์Šค๋ƒ…์ƒท ์‹œ๊ฐ. +- `source_note`: ์˜ˆ: ์›”๋ง ์•ฑ ์ž”์•ก, ํ†ต์žฅ ํ™”๋ฉด ๋ฉ”๋ชจ ๋“ฑ ๋น„๋ฏผ๊ฐ ์ถœ์ฒ˜ ์„ค๋ช…. +- `memo`: ์šด์˜์ž ๋ฉ”๋ชจ. + +## ์ ˆ์ฐจ + +1. ๊ณ„์ขŒ ํ™”๋ฉด ๋˜๋Š” MCP/service๋กœ `statement_status`๋ฅผ ์„ค์ •ํ•œ๋‹ค. +2. ์ˆ˜๋™ ์ฆ๋น™ ๊ฑฐ๋ž˜๋Š” `source='manual'`, ์–‘์ˆ˜ ๊ธˆ์•ก, ๊ฑฐ๋ž˜์ผ, ๊ณ„์ขŒ, ๋น„๋ฏผ๊ฐ ๋ฉ”๋ชจ๋ฅผ ๊ฐ–์ถ”์–ด ์ž…๋ ฅํ•œ๋‹ค. +3. `get_balance_reconciliation_report`๋ฅผ ์‹คํ–‰ํ•œ๋‹ค. +4. ๊ฒฐ๊ณผ๋ฅผ ํ™•์ธํ•œ๋‹ค. + - `OK`: ์ฐจ์•ก ์ ˆ๋Œ€๊ฐ’์ด 1,000์› ์ดํ•˜. + - `REVIEW_NEEDED`: ์ฐจ์•ก ์ ˆ๋Œ€๊ฐ’์ด 1,000์› ์ดˆ๊ณผ. ํ›„๋ณด ํŒ์ •๊ฐ’ ์ค‘ ํ•˜๋‚˜๋กœ ์ˆ˜๋™ ๊ฒ€ํ† ํ•œ๋‹ค. + - `BASELINE_REQUIRED`: ๊ธฐ์ค€ ์ž”์•ก์ด ์—†์–ด ํŒ์ • ๋ถˆ๊ฐ€. +5. `REVIEW_NEEDED`๋Š” `๊ณ„์ขŒ์ด๋™`, `์‹ค์ง€์ถœ`, `์‹ค์ˆ˜์ž…`, `ํˆฌ์ž์ด๋™`, `๋ถ„์„์ œ์™ธ`, `ํ™•์ธํ•„์š”` ์ค‘ ํ•˜๋‚˜๋กœ ์ˆ˜๋™ ํ™•์ •ํ•  ๊ทผ๊ฑฐ๋ฅผ ๋ชจ์€๋‹ค. + +## ํŒ์ • ๊ธฐ์ค€ + +- ๊ณ„์ขŒ์ด๋™: ๊ฐ™์€ ์†Œ์œ ์ž์˜ ๋‹ค๋ฅธ ๊ณ„์ขŒ ์ž…์ถœ๊ธˆ๊ณผ ๊ธˆ์•ก/์ผ์ž/์ƒ๋Œ€๋ฐฉ ๊ทผ๊ฑฐ๊ฐ€ ๋งž์Œ. +- ์‹ค์ง€์ถœ: ์™ธ๋ถ€๋กœ ์ž๊ธˆ์ด ๋‚˜๊ฐ”๊ณ  household expense๋กœ ๋ณด์•„์•ผ ํ•จ. +- ์‹ค์ˆ˜์ž…: ์™ธ๋ถ€์—์„œ ๋“ค์–ด์˜จ ์‹ค์ œ ์ˆ˜์ž…. +- ํˆฌ์ž์ด๋™: ์ฆ๊ถŒ/ํˆฌ์ž ๊ณ„์ขŒ ๋งค์ˆ˜ยท๋งค๋„ยท์ž…์ถœ๊ธˆ ๋“ฑ ํˆฌ์ž ํฌ์ง€์…˜ ์ด๋™. +- ๋ถ„์„์ œ์™ธ: ๊ธˆ์œต ๋‚ด๋ถ€ ์กฐ์ •, ์˜ค๋ฅ˜ ์ •์ •, ๋ถ„์„์— ๋„ฃ์œผ๋ฉด ์™œ๊ณก๋˜๋Š” ํ•ญ๋ชฉ. +- ํ™•์ธํ•„์š”: ๊ทผ๊ฑฐ ๋ถ€์กฑ์œผ๋กœ ํ™•์ • ๋ณด๋ฅ˜. + +## ์ œ์™ธ + +์ž๋™ ์Šค์ผ€์ค„๋ง/์•Œ๋ฆผ, ์ „์šฉ ์›Œํฌ๋ฒค์น˜, ์ž๋™ ํ™•์ • mutation์€ ์ด๋ฒˆ runbook ๋ฒ”์œ„๊ฐ€ ์•„๋‹ˆ๋‹ค. diff --git a/docs/runbooks/streamlit-browser-evidence.md b/docs/runbooks/streamlit-browser-evidence.md new file mode 100644 index 0000000..1ee5e7a --- /dev/null +++ b/docs/runbooks/streamlit-browser-evidence.md @@ -0,0 +1,90 @@ +# Streamlit Browser Evidence Runbook + +## ๋ชฉ์  + +Streamlit dashboard ๋ณ€๊ฒฝ์„ QCํ•  ๋•Œ synthetic DB์™€ local Chrome์œผ๋กœ browser-level screenshot/snapshot evidence๋ฅผ ์ˆ˜์ง‘ํ•œ๋‹ค. ์šด์˜ DB, ์‹ค์ œ ๊ธˆ์œต ํŒŒ์ผ, ๋น„๋ฐ€๋ฒˆํ˜ธ, raw_payload๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## ํ‘œ์ค€ ๋ช…๋ น + +```bash +cd /root/projects/jarca-ledger-mcp +.venv/bin/python scripts/streamlit_browser_evidence.py +``` + +๊ธฐ๋ณธ ์‚ฐ์ถœ๋ฌผ ์œ„์น˜: + +```text +docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-YYYYMMDD-HHMMSS/ +โ”œโ”€โ”€ fixture.sqlite3 +โ”œโ”€โ”€ manifest.json +โ”œโ”€โ”€ rules.png +โ”œโ”€โ”€ rules.dom.html +โ”œโ”€โ”€ transfer-review.png +โ”œโ”€โ”€ transfer-review.dom.html +โ”œโ”€โ”€ import.png +โ””โ”€โ”€ import.dom.html +``` + +## ์ „์ œ ์กฐ๊ฑด + +- Chrome: `/usr/bin/google-chrome-stable` +- Streamlit: `.venv/bin/streamlit` +- Dashboard dependencies installed in `.venv` +- ์‹คํ–‰ DB๋Š” script๊ฐ€ ์ƒ์„ฑํ•˜๋Š” `fixture.sqlite3`๋งŒ ์‚ฌ์šฉ + +## ์‹คํ–‰ ํ๋ฆ„ + +1. script๊ฐ€ output directory๋ฅผ ๋งŒ๋“ค๊ณ  synthetic fixture DB๋ฅผ ์ƒ์„ฑํ•œ๋‹ค. +2. `JARCA_LEDGER_DB_PATH=/fixture.sqlite3`๋กœ Streamlit์„ `127.0.0.1:18501`์— ๊ธฐ๋™ํ•œ๋‹ค. +3. Chrome DevTools Protocol๋กœ page๋ฅผ ์—ด๊ณ  page ์ œ๋ชฉ ํ…์ŠคํŠธ๊ฐ€ ๋ Œ๋”๋ง๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐํ•œ๋‹ค. +4. ๊ฐ page์˜ PNG screenshot๊ณผ DOM snapshot์„ ์ €์žฅํ•œ๋‹ค. +5. `manifest.json`์— page/url/path/byte size์™€ fixture DB ์ •๋ณด๋ฅผ ๋‚จ๊ธด๋‹ค. +6. script ์ข…๋ฃŒ ์‹œ Streamlit๊ณผ Chrome process๋ฅผ ์ข…๋ฃŒํ•œ๋‹ค. + +## ๋Œ€์ƒ ํŽ˜์ด์ง€ + +๊ธฐ๋ณธ ๋Œ€์ƒ: + +- `๋ถ„๋ฅ˜ ๊ทœ์น™` +- `ํ™•์ธํ•„์š” ํ` +- `Import` + +์ถ”๊ฐ€ page๋ฅผ ์ง€์ •ํ•˜๋ ค๋ฉด `--page`๋ฅผ ๋ฐ˜๋ณตํ•œ๋‹ค. + +```bash +.venv/bin/python scripts/streamlit_browser_evidence.py --page ๊ณ„์ขŒ --page ๋ฏธ๋ถ„๋ฅ˜ +``` + +## QC evidence gate + +| Batch ์œ ํ˜• | ์ตœ์†Œ evidence | ํŒ์ • ๊ธฐ์ค€ | +| --- | --- | --- | +| Streamlit UI ๋˜๋Š” `dashboard/app.py` ์˜ํ–ฅ | Browser screenshot + DOM snapshot + manifest | ๋ˆ„๋ฝ ์‹œ QC BLOCK ๋˜๋Š” ์žฌ์‹คํ–‰ | +| ๋น„-UI service/MCP/parser ๋ณ€๊ฒฝ | pytest/py_compile ์ค‘์‹ฌ, browser evidence ์„ ํƒ | browser ๋ˆ„๋ฝ ๋น„์ฐจ๋‹จ | +| docs-only ๋ณ€๊ฒฝ | ๋ฌธ์„œ diff/review | browser evidence ๋ถˆํ•„์š” | +| Chrome ํ™˜๊ฒฝ ์žฅ์• ๊ฐ€ ์ž…์ฆ๋œ UI ๋ณ€๊ฒฝ | AppTest + HTTP 200 + ์‹คํŒจ ๋กœ๊ทธ | `PASS_WITH_NOTES`๋งŒ ํ—ˆ์šฉ | + +## ๋ฏผ๊ฐ์ •๋ณด ๊ธˆ์ง€ + +๋‹ค์Œ์€ screenshot, snapshot, manifest, test log, docs์— ๋‚จ๊ธฐ์ง€ ์•Š๋Š”๋‹ค. + +- ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ์˜ ์‹ค์ œ row dump +- ์‹ค์ œ ๊ณ„์ขŒ๋ฒˆํ˜ธ/์นด๋“œ๋ฒˆํ˜ธ/์ž”์•ก ํ™”๋ฉด +- ์‹ค์ œ ๊ฑฐ๋ž˜์ฒ˜/์ƒ๋Œ€๋ฐฉ full dump +- ์›๋ณธ ๊ธˆ์œต ํŒŒ์ผ๋ช… ๋˜๋Š” ํŒŒ์ผ ๋‚ด์šฉ +- ๋น„๋ฐ€๋ฒˆํ˜ธ, PDF/XLSX password +- `raw_payload` ์›๋ฌธ + +## ์‹คํŒจ ์‹œ fallback + +Chrome ๋˜๋Š” browser capture๊ฐ€ ์‹คํŒจํ•œ ๊ฒฝ์šฐ: + +1. ์‹คํŒจ ๋ช…๋ น, Chrome ๊ฒฝ๋กœ, stderr/์˜ค๋ฅ˜ ๋ฉ”์‹œ์ง€๋ฅผ verification ๋ฌธ์„œ์— ๊ธฐ๋กํ•œ๋‹ค. +2. `.venv/bin/python -m py_compile dashboard/app.py`๋ฅผ ์‹คํ–‰ํ•œ๋‹ค. +3. `streamlit.testing.v1.AppTest` ๋˜๋Š” HTTP 200 smoke๋ฅผ ์‹คํ–‰ํ•œ๋‹ค. +4. UI ์˜ํ–ฅ batch๋ผ๋ฉด fallback evidence๋งŒ์œผ๋กœ๋Š” `PASS_WITH_NOTES`๊นŒ์ง€๋งŒ ํ—ˆ์šฉํ•˜๊ณ  browser evidence ๋ณต๊ตฌ ์ž”์—ฌ ํ•ญ๋ชฉ์„ ๋‚จ๊ธด๋‹ค. + +## ์ฃผ์˜ + +- ์ด runbook์€ ๋กœ์ปฌ ์žฌํ˜„ ์ ˆ์ฐจ๋‹ค. CI ํ†ตํ•ฉ์€ ํ›„์† Project #4 ํ•ญ๋ชฉ์—์„œ ๋ณ„๋„ ๋‹ค๋ฃฌ๋‹ค. +- Playwright Python์€ ํ›„์† CI ํ›„๋ณด์ด๋ฉฐ ํ˜„์žฌ ํ‘œ์ค€ ๊ฒฝ๋กœ๊ฐ€ ์•„๋‹ˆ๋‹ค. diff --git a/docs/runtime-flows.md b/docs/runtime-flows.md index 5f792e6..05c72e8 100644 --- a/docs/runtime-flows.md +++ b/docs/runtime-flows.md @@ -4,8 +4,8 @@ - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Status: draft - Owner: Jarca / project maintainer -- Last Updated (KST): 2026-06-27 10:17 -- Source Discovery / Batch: bootstrap-baseline +- Last Updated (KST): 2026-06-30 +- Source Discovery / Batch: bootstrap-baseline, BAT-006 - Primary Runtime: mixed ## Project Summary @@ -23,6 +23,7 @@ - `jarca-ledger-mcp` :: MCP stdio server for Hermes tool calls. - `dashboard/app.py` :: Streamlit local dashboard. - `pytest` :: regression test runner. +- `.github/workflows/ci.yml` :: GitHub Actions CI gate runner for PR/push/manual checks. ## Shared Components - SQLite database at `~/.local/share/jarca/ledger.sqlite3` by default. @@ -44,6 +45,22 @@ 4. UI calls service functions for reads, updates, imports, categorization, and manual linking. 5. UI displays success/error status and refreshes visible tables. +## Flow: `Dashboard operations hub overview` +1. Operator opens the `์š”์•ฝ` page. +2. Dashboard resolves the KST current month and calls `get_monthly_cashflow_report(month)` using `transactions.transaction_date` and `ledger_categories.cashflow_kind` semantics. +3. Dashboard calls `get_asset_liquidity_report`, which groups active accounts by `accounts.liquidity_bucket` and uses only `accounts.last_balance` for asset/liability values. +4. Dashboard renders sections in REQ-005 priority order: this-month actual income/expense/net/savings rate, asset liquidity, asset composition, budget/spend, operational warnings. +5. Missing `last_balance` account rows are excluded from asset totals and surfaced as warnings; transaction sums are not used to estimate non-transaction assets. + +## Flow: `CI validation gates` +1. GitHub Actions starts `.github/workflows/ci.yml` on `pull_request`, `push` to `main`, or `workflow_dispatch`. +2. `pytest-regression` installs the project with dev/dashboard extras and runs `pytest --ignore=tests/test_dashboard_app.py` so service/parser/regression failures fail the workflow. +3. `dashboard-app-test` runs `pytest tests/test_dashboard_app.py`; the AppTest test creates a synthetic fixture DB through `scripts/ci_fixture_db.py`, sets `JARCA_LEDGER_DB_PATH`, and asserts core dashboard pages render without Streamlit exceptions. +4. `dashboard-http-smoke` runs `scripts/ci_dashboard_http_smoke.py`; the wrapper creates a temp fixture DB, starts Streamlit on loopback, polls for HTTP 200, then terminates the process. +5. `db-safety-smoke` runs `scripts/ci_db_safety_smoke.py`; the wrapper creates temp source/backup/target DB files and invokes `scripts/ledger_restore_smoke.py` without touching the operating DB path. +6. Any job failure fails the workflow. Stable job names are intended for branch protection configuration outside the repository. +7. CI logs are status-oriented and synthetic-data-only; they must not contain real account identifiers, raw payloads, statement filenames, balances from the operating DB, or passwords. + ## Flow: `Import preview and import` 1. Operator chooses or supplies a supported source file: KB Bank PDF, Toss Bank PDF, Toss Securities PDF, SeoulPay XLSX, or CSV. 2. Preview function parses the file and reports counts, sample rows, duplicate status, and classification preview. @@ -71,6 +88,25 @@ 5. Restore execution is not an MCP flow. Restore smoke uses `scripts/ledger_restore_smoke.py` or the runbook and requires explicit approval before overwriting an existing target DB. 6. Verification evidence must not include full transaction dumps, full merchant/counterparty dumps, account identifiers, raw_payload, original financial file contents, or passwords. +## Flow: `Statement-unavailable manual balance reconciliation` +1. Operator follows the dcy-002 safety standard first: capture DB status, create or confirm a recent backup, and run preview/reconcile on a validation DB before operating on the production DB. +2. Operator marks accounts with `statement_status`: `manual_only` for statement-unavailable accounts, `partial_import` for accounts that require manual supplementation, or `import_supported` where normal statement import is sufficient. +3. Operator records manual evidence transactions with `source='manual'`, positive amount, date, account, and non-sensitive memo/source context. +4. Operator runs `get_balance_reconciliation_report` from MCP/service or the dashboard account page. If `baseline_at` is `YYYY-MM`, the snapshot time is that month end at 23:59 KST; ad-hoc reconciliation uses the user-supplied snapshot timestamp. +5. The report compares `baseline_balance`/`last_balance` with the latest `balance_after` if available, otherwise with net signed account transactions. Absolute tolerance is 1,000 KRW; 0.1% of baseline is reference-only. +6. `OK` rows need no immediate triage. `REVIEW_NEEDED` rows present manual decision candidates: `๊ณ„์ขŒ์ด๋™`, `์‹ค์ง€์ถœ`, `์‹ค์ˆ˜์ž…`, `ํˆฌ์ž์ด๋™`, `๋ถ„์„์ œ์™ธ`, `ํ™•์ธํ•„์š”`. +7. This flow does not run automatic scheduling, notifications, restore, or auto-confirming mutation. + +## Flow: `Streamlit browser evidence capture` +1. Operator runs `scripts/streamlit_browser_evidence.py` from the repository root. +2. The script creates a synthetic fixture DB under the evidence output directory and refuses to use the default operating DB path. +3. Streamlit starts with `JARCA_LEDGER_DB_PATH=` on a local loopback port. +4. `/usr/bin/google-chrome-stable` runs in headless mode through Chrome DevTools Protocol and opens target dashboard pages such as `๋ถ„๋ฅ˜ ๊ทœ์น™`, `ํ™•์ธํ•„์š” ํ`, and `Import`. +5. After page title text is rendered, the script stores PNG screenshots, DOM snapshots, and `manifest.json` in the evidence directory. +6. UI/dashboard-impacting batches must attach browser screenshot/snapshot evidence. Non-UI batches may treat it as optional, and docs-only batches do not require browser evidence. +7. If Chrome/browser capture is unavailable, AppTest + HTTP 200 fallback evidence is allowed only as `PASS_WITH_NOTES` with the environment failure and remaining browser-evidence item recorded. +8. Browser screenshot/DOM evidence is not a required GitHub Actions gate in BAT-006. Run it locally for visual/layout-sensitive dashboard changes, while non-visual backend/CI changes can rely on pytest/AppTest/HTTP/DB safety gates. + ## Flow Constraints - Preview before broad import or categorization mutation where supported. - Do not include `transfer`, `investment`, `exclude`, or unresolved rows in actual household spending totals unless a report explicitly asks for account movement. @@ -78,12 +114,16 @@ - Avoid destructive migrations without a backup/approval plan. - Before broad DB mutation, capture DB status evidence and create or confirm a recent backup. - Restore execution is script/runbook only and must not be exposed as an MCP tool. +- Manual balance reconciliation must remain preview/report-first unless a future REQ approves persistent review routing and confirmation mutation. +- Streamlit browser evidence must use synthetic/validation DBs only and must not screenshot/log real account numbers, balances, source financial filenames, passwords, full merchant/counterparty dumps, or raw payloads. +- CI gates must use synthetic/temp DBs only. The default operating DB path (`~/.local/share/jarca/ledger.sqlite3`) is forbidden for AppTest, HTTP smoke, and DB safety smoke wrappers. +- Dashboard asset liquidity calculations must use `accounts.liquidity_bucket` and `accounts.last_balance`; `loan` is excluded from the asset composition denominator and reflected as debt/net-worth only. - Sensitive passwords are runtime inputs and must not be committed. ## Current Gaps / Planned Changes -- Need final browser-level UX verification for rule delete and cancel/original linking dialogs. -- Need CI/deployment flow if this project will be maintained beyond local operation. -- Need a documented operational process for statement-unavailable accounts. +- Browser-level evidence is available locally through `scripts/streamlit_browser_evidence.py`; it remains outside required CI gates by design. +- Dedicated reconciliation workbench and persistent review-decision queue are deferred beyond REQ-003. +- Separate asset snapshot/manual asset tables and automatic market/real-estate valuation are deferred beyond REQ-005. ## Update Triggers - Update this document when a command entry point changes. @@ -93,3 +133,7 @@ ## Change Log - 2026-06-26 22:24 KST: Created by `bootstrap-baseline` from repository observation. - 2026-06-27 10:17 KST: BAT-002/REQ-002 ๊ธฐ์ค€์œผ๋กœ ์šด์˜ backup/status/list/hash ๋ฐ restore ์ œํ•œ flow ์ถ”๊ฐ€. +- 2026-06-29 KST: REQ-003/BAT-003 ๊ธฐ์ค€์œผ๋กœ statement-unavailable ์ˆ˜๋™ ์ž”์•ก๋Œ€์กฐ flow ์ถ”๊ฐ€. +- 2026-06-29 KST: REQ-004/BAT-004 ๊ธฐ์ค€์œผ๋กœ Streamlit browser evidence capture flow ๋ฐ UI evidence gate ๊ธฐ์ค€ ์ถ”๊ฐ€. +- 2026-06-29 KST: REQ-005/BAT-005 ๊ธฐ์ค€์œผ๋กœ dashboard operations hub ๋ฐ `liquidity_bucket`/`last_balance` ์ž์‚ฐ ์œ ๋™์„ฑ flow ์ถ”๊ฐ€. +- 2026-06-30 KST: REQ-006/BAT-006 ๊ธฐ์ค€์œผ๋กœ GitHub Actions CI validation gates, synthetic fixture DB boundary, AppTest/HTTP/DB safety smoke flow, local browser evidence boundary ์ถ”๊ฐ€. diff --git a/docs/srs/Interface/req-003_statement-unavailable-manual-evidence-balance-reconciliation.md b/docs/srs/Interface/req-003_statement-unavailable-manual-evidence-balance-reconciliation.md new file mode 100644 index 0000000..8399bc6 --- /dev/null +++ b/docs/srs/Interface/req-003_statement-unavailable-manual-evidence-balance-reconciliation.md @@ -0,0 +1,80 @@ +# REQ-003 Statement-unavailable ๊ณ„์ขŒ ์ˆ˜๋™ ์ฆ๋น™ ๋ฐ ์ž”์•ก๋Œ€์กฐ + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: ๋ ›์ €์žฅ์ธ +- Source Discovery: `docs/discovery/dcy-003_20260629_statement-unavailable-manual-evidence-balance-reconciliation.md` +- Batch: `docs/batches/bat-003_20260629_statement-unavailable-reconciliation/` +- Created At (KST): 2026-06-29 +- Implemented At (KST): 2026-06-29 + +## 1. ๋ฐฐ๊ฒฝ + +๊ฑฐ๋ž˜๋‚ด์—ญ ํŒŒ์ผ์ด ์—†๊ฑฐ๋‚˜ ์ผ๋ถ€ ๊ธฐ๊ฐ„๋งŒ import ๊ฐ€๋Šฅํ•œ ๊ณ„์ขŒ๋Š” ๊ธฐ์กด statement import ํ๋ฆ„๋งŒ์œผ๋กœ ์ •ํ•ฉ์„ฑ์„ ๋ณด์žฅํ•  ์ˆ˜ ์—†๋‹ค. dcy-003 confirmed Discovery์— ๋”ฐ๋ผ ๊ณ„์ขŒ๋ณ„ statement ์ง€์› ์ƒํƒœ๋ฅผ ๋ช…์‹œํ•˜๊ณ , ์ˆ˜๋™ ์‹คํ–‰ ๊ฐ€๋Šฅํ•œ ์ž”์•ก๋Œ€์กฐ report ๋ฐ ๊ณ„์ขŒ ํ™”๋ฉด์˜ ์ตœ์†Œ UI๋ฅผ ์ œ๊ณตํ•œ๋‹ค. + +## 2. ๋ฒ”์œ„ + +### In Scope +- `accounts.statement_status` ํ•„๋“œ ์ถ”๊ฐ€ ๋ฐ ์„œ๋น„์Šค ๊ฒ€์ฆ. +- ํ—ˆ์šฉ๊ฐ’: `unknown`, `import_supported`, `manual_only`, `partial_import`. +- `manual_only`๋Š” statement-unavailable ๊ณ„์ขŒ๋กœ ์ •์˜ํ•œ๋‹ค. +- `partial_import`๋„ ์ˆ˜๋™ ๋ณด์™„ ๋ฐ ์ž”์•ก๋Œ€์กฐ ๋Œ€์ƒ์— ํฌํ•จํ•œ๋‹ค. +- ๊ธฐ์ค€ ์ž”์•ก ์ž…๋ ฅ ํฌ๋งท: `account_id`, `statement_status`, `baseline_balance`, `baseline_at`, `source_note`, `memo`. +- ์ˆ˜๋™ ์‹คํ–‰ ์ž”์•ก๋Œ€์กฐ report: ๊ณ„์‚ฐ ์ž”์•ก, ์ฐจ์•ก, ํ—ˆ์šฉ ์˜ค์ฐจ, 0.1% ์ฐธ๊ณ  ๋น„์œจ, `OK`/`REVIEW_NEEDED`/`BASELINE_REQUIRED` ์ƒํƒœ. +- ๊ณ„์ขŒ ํ™”๋ฉด์˜ `statement_status` ํ‘œ์‹œ/์ˆ˜์ • ๋ฐ ์ตœ์†Œ ์ž”์•ก๋Œ€์กฐ ๊ฒฐ๊ณผ ํ‘œ์‹œ. +- MCP tool surface์— ๊ณ„์ขŒ update ๋ฐ ์ž”์•ก๋Œ€์กฐ report๋ฅผ additive๋กœ ๋…ธ์ถœ. +- dcy-002 ๋ฐฑ์—…/๊ฒ€์ฆ DB ์ ˆ์ฐจ ์„ ํ–‰ ์›์น™ ๋ฌธ์„œํ™”. + +### Out of Scope +- ์ž๋™ ์Šค์ผ€์ค„๋ง/์•Œ๋ฆผ. +- ์ „์šฉ ์ž”์•ก๋Œ€์กฐ ์›Œํฌ๋ฒค์น˜ ์ „์ฒด ์‹ ์„ค. +- ์‚ฌ์šฉ์ž ์Šน์ธ ์—†๋Š” restore ์‹คํ–‰ ๋˜๋Š” restore MCP tool ๋…ธ์ถœ. +- ์ž๋™ ํ™•์ •ํ˜• ํ™•์ธํ•„์š” ๋ผ์šฐํŒ… mutation. ์ด๋ฒˆ ๊ตฌํ˜„์€ preview/report ์ค‘์‹ฌ์ด๋ฉฐ ์ˆ˜๋™ ํ™•์ •์„ ์ „์ œ๋กœ ํ•œ๋‹ค. + +## 3. ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ + +### FR-1. ๊ณ„์ขŒ statement ์ƒํƒœ +- `accounts.statement_status`๋Š” `TEXT NOT NULL DEFAULT 'unknown'`์ด์–ด์•ผ ํ•œ๋‹ค. +- ์„œ๋น„์Šค ๊ณ„์ธต์€ ํ—ˆ์šฉ๊ฐ’ ์™ธ ์ž…๋ ฅ์„ `validation_error`๋กœ ๊ฑฐ๋ถ€ํ•ด์•ผ ํ•œ๋‹ค. +- `create_ledger_account`, `list_ledger_accounts`, `update_ledger_account`์—์„œ ํ•ด๋‹น ํ•„๋“œ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. + +### FR-2. ์ž”์•ก๋Œ€์กฐ ์ž…๋ ฅ ๋ฐ ๊ธฐ์ค€ ์‹œ๊ฐ +- ์ž”์•ก๋Œ€์กฐ ์ž…๋ ฅ์€ ๋‹ค์Œ ํฌ๋งท์„ ์ง€์›ํ•ด์•ผ ํ•œ๋‹ค. + - `account_id`: ์„ ํƒ. ๋น„์–ด ์žˆ์œผ๋ฉด `manual_only`, `partial_import` ํ™œ์„ฑ ๊ณ„์ขŒ ์ „์ฒด๋ฅผ ๋Œ€์ƒ์œผ๋กœ ํ•œ๋‹ค. + - `baseline_balance`: ์„ ํƒ. ์—†์œผ๋ฉด ๊ณ„์ขŒ์˜ `last_balance`๋ฅผ ์‚ฌ์šฉํ•œ๋‹ค. + - `baseline_at`: ์„ ํƒ. `YYYY-MM`์ด๋ฉด ํ•ด๋‹น ์›” ๋ง์ผ `23:59:00+09:00`, `YYYY-MM-DD`์ด๋ฉด ํ•ด๋‹น์ผ `23:59:00+09:00`, ISO datetime์ด๋ฉด ์›๋ฌธ ๊ธฐ์ค€์œผ๋กœ ์ •๊ทœํ™” ๊ฒ€์ฆํ•œ๋‹ค. + - `source_note`, `memo`: ์„ ํƒ ๋ฉ”ํƒ€๋ฐ์ดํ„ฐ. ์›๋ฌธ ์ฆ๋น™์ด๋‚˜ ๋ฏผ๊ฐ ์‹๋ณ„์ž๋Š” ์ €์žฅยท์ถœ๋ ฅํ•˜์ง€ ์•Š๋Š”๋‹ค. +- ์ •๊ธฐ ๋Œ€์กฐ ๊ธฐ์ค€์€ ๋งค์›” ๋ง์ผ 23:59 KST์ด๋ฉฐ, ์ˆ˜์‹œ ๋Œ€์กฐ๋Š” ์‚ฌ์šฉ์ž ์ž…๋ ฅ ์Šค๋ƒ…์ƒท ์‹œ๊ฐ์„ ์‚ฌ์šฉํ•œ๋‹ค. + +### FR-3. ์ž”์•ก๋Œ€์กฐ ํŒ์ • +- ๊ณ„์‚ฐ ์ž”์•ก์€ ์šฐ์„  `transactions.balance_after`์˜ ์ตœ์‹  ๊ฐ’์„ ์‚ฌ์šฉํ•˜๊ณ , ์—†์œผ๋ฉด ๊ธฐ์ค€ ์‹œ์ ๊นŒ์ง€์˜ ๊ณ„์ขŒ ๊ฑฐ๋ž˜ ์ˆœ์ฆ๊ฐ์„ ์‚ฌ์šฉํ•œ๋‹ค. +- ์ฐจ์•ก์€ `baseline_balance - calculated_balance`๋กœ ์‚ฐ์ถœํ•œ๋‹ค. +- ์ดˆ๊ธฐ ํ—ˆ์šฉ ์˜ค์ฐจ๋Š” ์ ˆ๋Œ€ ๊ธˆ์•ก 1,000์›์ด๋‹ค. +- `abs(difference) <= 1000`์ด๋ฉด `OK`, ์ดˆ๊ณผํ•˜๋ฉด `REVIEW_NEEDED`๋กœ ํ‘œ์‹œํ•œ๋‹ค. +- ๊ธฐ์ค€ ์ž”์•ก ๋Œ€๋น„ 0.1%๋Š” `difference_ratio`์™€ `reference_ratio_threshold=0.001`๋กœ ์ฐธ๊ณ  ํ‘œ์‹œ๋งŒ ํ•˜๊ณ  ํŒ์ •์—๋Š” ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š”๋‹ค. + +### FR-4. ํ™•์ธํ•„์š” ํ›„๋ณด ํŒ์ •๊ฐ’ +- `REVIEW_NEEDED` ๊ฒฐ๊ณผ๋Š” ๋‹ค์Œ ์ˆ˜๋™ ํŒ์ • ํ›„๋ณด๋ฅผ ์ œ์‹œํ•ด์•ผ ํ•œ๋‹ค: `๊ณ„์ขŒ์ด๋™`, `์‹ค์ง€์ถœ`, `์‹ค์ˆ˜์ž…`, `ํˆฌ์ž์ด๋™`, `๋ถ„์„์ œ์™ธ`, `ํ™•์ธํ•„์š”`. +- ์ž๋™ ํ™•์ •์ด ์•„๋‹Œ ํ›„๋ณด ์ œ์‹œ/์ˆ˜๋™ ํ™•์ •์„ ๊ธฐ๋ณธ์œผ๋กœ ํ•œ๋‹ค. + +### FR-5. ์šด์˜ ์•ˆ์ „ +- ์šด์˜ DB ๋ณ€๊ฒฝ ์ „ dcy-002 ๋ฐฑ์—…์€ ํ•„์ˆ˜๋‹ค. +- ๊ฒ€์ฆ DB์—์„œ preview/reconcile์„ ๋จผ์ € ์ˆ˜ํ–‰ํ•œ๋‹ค. +- restore๋Š” ์‚ฌ์šฉ์ž ๋ช…์‹œ ์Šน์ธ ์—†์ด ์‹คํ–‰ํ•˜์ง€ ์•Š๋Š”๋‹ค. + +## 4. Acceptance Criteria + +- AC-1: ์ƒˆ DB ๋ฐ ๊ธฐ์กด DB migration ํ›„ `accounts.statement_status`๊ฐ€ ์กด์žฌํ•˜๊ณ  ๊ธฐ๋ณธ๊ฐ’์€ `unknown`์ด๋‹ค. +- AC-2: ์ž˜๋ชป๋œ `statement_status`๋กœ ๊ณ„์ขŒ ์ƒ์„ฑ/์ˆ˜์ • ์‹œ `validation_error`๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- AC-3: `manual_only` ๋˜๋Š” `partial_import` ๊ณ„์ขŒ์— ๋Œ€ํ•ด ์ž”์•ก๋Œ€์กฐ report๊ฐ€ `baseline_balance`, `calculated_balance`, `difference`, `tolerance=1000`, `difference_ratio`, `status`๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค. +- AC-4: ์ฐจ์•ก์ด 1,000์› ์ด๋‚ด๋ฉด `OK`, ์ดˆ๊ณผ๋ฉด `REVIEW_NEEDED`์ด๋ฉฐ ํ™•์ธํ•„์š” ํ›„๋ณด ํŒ์ •๊ฐ’ 6๊ฐœ๊ฐ€ ํ‘œ์‹œ๋œ๋‹ค. +- AC-5: dashboard ๊ณ„์ขŒ ํ™”๋ฉด์—์„œ `statement_status`๋ฅผ ํ™•์ธ/์ˆ˜์ •ํ•˜๊ณ  ์ตœ์†Œ ์ž”์•ก๋Œ€์กฐ report๋ฅผ ์‹คํ–‰ํ•  ์ˆ˜ ์žˆ๋‹ค. +- AC-6: MCP tool๋กœ `update_ledger_account`, `get_balance_reconciliation_report`๊ฐ€ additive ๋…ธ์ถœ๋œ๋‹ค. +- AC-7: data model, interface contract, runtime flow, runbook ๋ฌธ์„œ๊ฐ€ dcy-003 ๋ฒ”์œ„๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. + +## 5. ๊ตฌํ˜„ ๋ฉ”๋ชจ + +- Schema version์€ `7`๋กœ ์˜ฌ๋ฆฐ๋‹ค. +- ์ž”์•ก๋Œ€์กฐ๋Š” ํ˜„์žฌ preview/report๋งŒ ์ˆ˜ํ–‰ํ•˜๋ฉฐ transaction/category mutation์€ ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- ๋Œ€์กฐ ๊ธฐ์ค€ ์ž”์•ก ์‹ค์ œ ๊ฐ’์€ ์šด์˜ ์‹œ์ ์— ์‚ฌ์šฉ์ž๊ฐ€ ์ž…๋ ฅํ•œ๋‹ค. diff --git a/docs/srs/Interface/req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md b/docs/srs/Interface/req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md new file mode 100644 index 0000000..c37e795 --- /dev/null +++ b/docs/srs/Interface/req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md @@ -0,0 +1,93 @@ +# REQ-005 Dashboard operations hub/liquidity/monthly cashflow + +- Status: Approved +- Type: Interface +- Priority: High +- Owner: ๋ ›์ €์žฅ์ธ +- Source Discovery: `docs/discovery/dcy-005_20260629_dashboard-operations-hub-liquidity-monthly-cashflow.md` +- Batch: BAT-005 +- Created At (KST): 2026-06-29 +- Approved At (KST): 2026-06-29 21:08 KST + +## 1. ๋ฐฐ๊ฒฝ + +ํ˜„์žฌ Streamlit ๋Œ€์‹œ๋ณด๋“œ ์ฒซ ํ™”๋ฉด์€ ์ „์ฒด ๊ธฐ๊ฐ„ ๋ˆ„์  ํ˜„๊ธˆํ๋ฆ„๊ณผ ๊ณ„์ขŒ ์ž…์ถœ๊ธˆ ์ค‘์‹ฌ์œผ๋กœ ๊ตฌ์„ฑ๋˜์–ด ์žˆ์–ด, ์šด์˜์ž๊ฐ€ ์›”์ค‘์— ํ•„์š”ํ•œ "์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ", "ํ˜„์žฌ ์ž์‚ฐ ์œ ๋™์„ฑ", "๋ฏธ์ฒ˜๋ฆฌ ์šด์˜ ๊ฒฝ๊ณ "๋ฅผ ํ•œ ํ™”๋ฉด์—์„œ ์ฆ‰์‹œ ํŒ๋‹จํ•˜๊ธฐ ์–ด๋ ต๋‹ค. dcy-005 confirmed Discovery์— ๋”ฐ๋ผ ๋Œ€์‹œ๋ณด๋“œ ์ฒซ ํ™”๋ฉด์„ ์šด์˜ ํ—ˆ๋ธŒ๋กœ ์žฌ์ •์˜ํ•˜๊ณ , ๊ณ„์ขŒ ๋‹จ์œ„ `liquidity_bucket` ์ถ•์„ ๋„์ž…ํ•ด ์ž์‚ฐ ์œ ๋™์„ฑ/๊ตฌ์„ฑ/์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ์„ ์ผ๊ด€๋˜๊ฒŒ ์ œ๊ณตํ•œ๋‹ค. + +## 2. ๋ฒ”์œ„ + +### In Scope +- `page_overview`๋ฅผ ๋‹ค์Œ ์šฐ์„ ์ˆœ์œ„ ์„น์…˜์œผ๋กœ ์žฌ๊ตฌ์„ฑํ•œ๋‹ค: โ‘  ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ โ‘ก ์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ โ‘ข ์ž์‚ฐ ๊ตฌ์„ฑ โ‘ฃ ์ง€์ถœ ๊ตฌ์กฐ/์˜ˆ์‚ฐ โ‘ค ์šด์˜ ๊ฒฝ๊ณ . +- ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ์€ KST ํ˜„์žฌ ์›”์˜ `transaction_date` ๊ธฐ์ค€์œผ๋กœ ์‹ค์ œ ์ˆ˜์ž…, ์‹ค์ œ ์ง€์ถœ, ์ˆœํ˜„๊ธˆํ๋ฆ„, ์ €์ถ•๋ฅ ์„ ํ‘œ์‹œํ•œ๋‹ค. +- ์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ์€ ๊ณ„์ขŒ ๋‹จ์œ„ `accounts.liquidity_bucket` ๊ฐ’์„ ๊ธฐ์ค€์œผ๋กœ `cash`, `investment`, `deposit`, `real_estate`, `loan`, `other` ๋ฒ„ํ‚ท๋ณ„ ๊ธˆ์•ก๊ณผ ๋น„์œจ์„ ํ‘œ์‹œํ•œ๋‹ค. +- `accounts.liquidity_bucket`์€ additive/idempotent schema migration์œผ๋กœ ๋„์ž…ํ•˜๊ณ , ๋ฏธ์ง€์ • ๊ณ„์ขŒ๋Š” `other`๋กœ ํด๋ฐฑํ•œ๋‹ค. +- ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ/๋ถ€์ฑ„๋Š” ์ดˆ๊ธฐ ๊ตฌํ˜„์—์„œ `ledger_accounts` ํ•ญ๋ชฉ์˜ `last_balance`, `last_synced_at`, `memo`๋ฅผ ์ˆ˜๋™ ์ž…๋ ฅ ์†Œ์Šค๋กœ ์‚ฌ์šฉํ•œ๋‹ค. +- ์šด์˜ ๊ฒฝ๊ณ  ์„น์…˜์€ ๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜, ํ™•์ธํ•„์š” ํ, ์ž”์•ก๋Œ€์กฐ ํ•„์š” ๊ณ„์ขŒ, ์ตœ๊ทผ ๋ฐฑ์—…/import ์ƒํƒœ๋ฅผ ํ•œ ํ™”๋ฉด์— ๋…ธ์ถœํ•˜๊ณ  ์ƒ์„ธ ํ™”๋ฉด/์ ˆ์ฐจ๋กœ ์ด๋™ํ•  ๋‹จ์„œ๋ฅผ ์ œ๊ณตํ•œ๋‹ค. +- `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md`, ํ•„์š” ์‹œ `docs/interface-contract.md`์— ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ์™€ `liquidity_bucket` ํ๋ฆ„์„ ๋ฐ˜์˜ํ•œ๋‹ค. + +### Out of Scope +- ๋‹ค๊ณ„์ขŒ ์‹ค์‹œ๊ฐ„ ์ž”์•ก ์—ฐ๋™, ์™ธ๋ถ€ ์‹œ์„ธ/ํˆฌ์ž ํ‰๊ฐ€์•ก ์ž๋™ ์—ฐ๋™. +- ์นด๋“œ ๋ฏธ๊ฒฐ์ œ๊ธˆ, ๋ณดํ—˜ ํ•ด์ง€ํ™˜๊ธ‰๊ธˆ, ์ž๋™ ์‹œ์„ธ ์—ฐ๋™ ์ž์‚ฐ ๋ฐ˜์˜. +- `asset_snapshots`, `manual_assets` ๊ฐ™์€ ๋ณ„๋„ ์ž์‚ฐ ํ‰๊ฐ€ ํ…Œ์ด๋ธ” ์‹ ์„ค. +- ๊ฑฐ๋ž˜/์นดํ…Œ๊ณ ๋ฆฌ ๋‹จ์œ„ `liquidity_bucket` override. +- ์‚ฌ์šฉ์ž ์ •์˜ ํšŒ๊ณ„ ์›” ๋งˆ๊ฐ ๋˜๋Š” `posted_date` ๊ธฐ์ค€ ์›”๊ฐ„ ํ˜„๊ธˆํ๋ฆ„ ์ „ํ™˜. + +## 3. ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ + +### FR-1. ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ +- ๋Œ€์‹œ๋ณด๋“œ ์ตœ์ƒ๋‹จ 1์ˆœ์œ„ ์„น์…˜์€ "์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ"์ด์–ด์•ผ ํ•œ๋‹ค. +- ๊ธฐ๋ณธ ๊ธฐ๊ฐ„์€ KST ํ˜„์žฌ ์›”์˜ ์‹œ์ž‘์ผ๋ถ€ํ„ฐ ์ข…๋ฃŒ์ผ๊นŒ์ง€์ด๋ฉฐ, ์ง‘๊ณ„ ๊ธฐ์ค€์ผ์€ `transactions.transaction_date`๋‹ค. +- ์‹ค์ œ ์ˆ˜์ž…/์ง€์ถœ ํŒ์ •์€ ๊ธฐ์กด `ledger_categories.cashflow_kind` ๊ธฐ์ค€์„ ์žฌ์‚ฌ์šฉํ•œ๋‹ค. +- ํ‘œ์‹œ ์ง€ํ‘œ๋Š” ์‹ค์ œ ์ˆ˜์ž…, ์‹ค์ œ ์ง€์ถœ, ์ˆœํ˜„๊ธˆํ๋ฆ„, ์ €์ถ•๋ฅ ์„ ํฌํ•จํ•ด์•ผ ํ•œ๋‹ค. +- ์ €์ถ•๋ฅ ์€ ๊ธฐ์กด ์ •์˜์™€ ๋™์ผํ•˜๊ฒŒ `์ˆœํ˜„๊ธˆํ๋ฆ„ รท ์‹ค์ œ ์ˆ˜์ž…`์„ ๋‹น์›” ๋ฒ”์œ„์— ์ ์šฉํ•œ๋‹ค. ์‹ค์ œ ์ˆ˜์ž…์ด 0์ธ ๊ฒฝ์šฐ์—๋Š” ๋‚˜๋ˆ„๊ธฐ ์˜ค๋ฅ˜ ์—†์ด ์•ˆ์ „ํ•œ ๋Œ€์ฒด ํ‘œ์‹œ๋ฅผ ์ œ๊ณตํ•œ๋‹ค. + +### FR-2. ์ž์‚ฐ ์œ ๋™์„ฑ ๋ถ„๋ฅ˜ ์ถ• +- `accounts.liquidity_bucket`์€ `TEXT NOT NULL DEFAULT 'other'` ์„ฑ๊ฒฉ์˜ additive ํ•„๋“œ๋กœ ๋„์ž…ํ•ด์•ผ ํ•œ๋‹ค. +- ํ—ˆ์šฉ๊ฐ’์€ `cash`, `investment`, `deposit`, `real_estate`, `loan`, `other`๋‹ค. +- ์„œ๋น„์Šค ๊ณ„์ธต์€ ํ—ˆ์šฉ๊ฐ’ ์™ธ ์ž…๋ ฅ์„ ๊ฒ€์ฆ ์˜ค๋ฅ˜๋กœ ๊ฑฐ๋ถ€ํ•ด์•ผ ํ•œ๋‹ค. +- ๊ณ„์ขŒ ์ƒ์„ฑ, ๊ณ„์ขŒ ๋ชฉ๋ก, ๊ณ„์ขŒ ์ˆ˜์ • ํ๋ฆ„์—์„œ `liquidity_bucket`์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. +- ๊ธฐ์กด ๊ณ„์ขŒ ๋˜๋Š” ๋ฏธ์ง€์ • ๊ณ„์ขŒ๋Š” `other`๋กœ ์•ˆ์ „ํ•˜๊ฒŒ ํด๋ฐฑํ•ด์•ผ ํ•œ๋‹ค. + +### FR-3. ์ž์‚ฐ ์œ ๋™์„ฑ/๊ตฌ์„ฑ/์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ +- ์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ์€ `accounts.liquidity_bucket`์„ ๋‹จ์ผ ๊ถŒ์œ„ ์†Œ์Šค๋กœ ์‚ฌ์šฉํ•œ๋‹ค. +- ๋ฒ„ํ‚ท๋ณ„ ๊ธˆ์•ก์€ ๊ฐ ๊ณ„์ขŒ์˜ ์ตœ์‹  `last_balance`๋ฅผ ์šฐ์„  ์‚ฌ์šฉํ•œ๋‹ค. `last_balance`๊ฐ€ ์—†๋Š” ๊ณ„์ขŒ๋Š” ๊ณ„์‚ฐ์—์„œ ์ œ์™ธํ•˜๊ฑฐ๋‚˜ ๋ณ„๋„ ๋ฏธ์ž…๋ ฅ ๊ฒฝ๊ณ ๋กœ ํ‘œ์‹œํ•˜๋˜, ์ž„์˜์˜ ๊ฑฐ๋ž˜ ํ•ฉ๊ณ„๋กœ ์ž์‚ฐ ํ‰๊ฐ€์•ก์„ ์ถ”์ •ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- ์ž์‚ฐ ๊ตฌ์„ฑ ๋น„์œจ์˜ ๋ถ„๋ชจ๋Š” `cash`, `investment`, `deposit`, `real_estate`, `other` ๋ฒ„ํ‚ท ํ•ฉ๊ณ„์ด๋ฉฐ, `loan`์€ ์ œ์™ธํ•œ๋‹ค. +- ์ด๋ถ€์ฑ„๋Š” `loan` ๋ฒ„ํ‚ท ํ•ฉ๊ณ„๋กœ ํ‘œ์‹œํ•˜๊ณ , ์ˆœ์ž์‚ฐ์€ ๋Œ€์ถœ์„ ์ œ์™ธํ•œ ์ž์‚ฐ ํ•ฉ๊ณ„์—์„œ ์ด๋ถ€์ฑ„๋ฅผ ์ฐจ๊ฐํ•ด ๊ณ„์‚ฐํ•œ๋‹ค. +- ๋ถ€๋™์‚ฐ, ์˜ˆ์ ๊ธˆ, ๋Œ€์ถœ ๋“ฑ ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ/๋ถ€์ฑ„๋Š” `last_balance`๋ฅผ ํ˜„์žฌ ํ‰๊ฐ€์•ก/์ž”์•ก, `last_synced_at`์„ ๊ธฐ์ค€์ผ, `memo`๋ฅผ ์ถœ์ฒ˜ ๋ฉ”๋ชจ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. + +### FR-4. ์šด์˜ ํ—ˆ๋ธŒ ํ™”๋ฉด ๊ตฌ์„ฑ +- `page_overview`๋Š” ๋‹ค์Œ ์ˆœ์„œ๋กœ ์„น์…˜์„ ๋ฐฐ์น˜ํ•ด์•ผ ํ•œ๋‹ค. + 1. ์ด๋ฒˆ ๋‹ฌ ์ˆ˜์ž…/์ง€์ถœ ํ˜„ํ™ฉ + 2. ์ž์‚ฐ ์œ ๋™์„ฑ ํ˜„ํ™ฉ + 3. ์ž์‚ฐ ๊ตฌ์„ฑ + 4. ์ง€์ถœ ๊ตฌ์กฐ/์˜ˆ์‚ฐ + 5. ์šด์˜ ๊ฒฝ๊ณ  +- ๊ฐ ์„น์…˜์€ ๊ธฐ์ค€ ๊ธฐ๊ฐ„ ๋˜๋Š” ๊ธฐ์ค€์ผ์„ ๋ช…ํ™•ํžˆ ํ‘œ์‹œํ•ด์•ผ ํ•œ๋‹ค. +- ์šด์˜ ๊ฒฝ๊ณ  ์„น์…˜์€ ์ตœ์†Œํ•œ ๋ฏธ๋ถ„๋ฅ˜ ๊ฑฐ๋ž˜ ๊ฑด์ˆ˜, ํ™•์ธํ•„์š” ํ ์ƒํƒœ, ์ž”์•ก๋Œ€์กฐ ํ•„์š” ๊ณ„์ขŒ, ์ตœ๊ทผ ๋ฐฑ์—…/import ์ƒํƒœ๋ฅผ ํ‘œ์‹œํ•ด์•ผ ํ•œ๋‹ค. +- ์šด์˜ ๊ฒฝ๊ณ  ํ•ญ๋ชฉ์€ ํ•ด๋‹น ์ƒ์„ธ ํ™”๋ฉด ๋˜๋Š” ๊ด€๋ จ ์‹คํ–‰ ์ ˆ์ฐจ๋กœ ์ด๋™ํ•  ์ˆ˜ ์žˆ๋Š” ๋ผ๋ฒจ, ๋ฉ”๋‰ด ์•ˆ๋‚ด, ๋ฒ„ํŠผ ๋“ฑ ๋ช…ํ™•ํ•œ ๋‹จ์„œ๋ฅผ ์ œ๊ณตํ•ด์•ผ ํ•œ๋‹ค. + +### FR-5. ๋ฌธ์„œ ๋ฐ ๊ธฐ์ค€์„  ์ •๋ ฌ +- `docs/data-model.md`๋Š” `LedgerAccount`์˜ `liquidity_bucket`, ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ ์ˆ˜๋™ ์ž…๋ ฅ ๋ฐฉ์‹, ์ž์‚ฐ/๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ ๊ทœ์น™์„ ์„ค๋ช…ํ•ด์•ผ ํ•œ๋‹ค. +- `docs/project-structure.md`๋Š” ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ์™€ ๊ด€๋ จ ์ง‘๊ณ„ ์ฑ…์ž„์ด `dashboard/app.py` ๋ฐ `jarca_ledger_mcp.service`์— ๊ฑธ์น˜๋Š” ๊ตฌ์กฐ๋ฅผ ๋ฐ˜์˜ํ•ด์•ผ ํ•œ๋‹ค. +- `docs/runtime-flows.md`๋Š” ๋Œ€์‹œ๋ณด๋“œ ์šด์˜ ํ—ˆ๋ธŒ ์กฐํšŒ ํ๋ฆ„๊ณผ KST ํ˜„์žฌ ์›”/`transaction_date` ๊ธฐ์ค€, `last_balance` ๊ธฐ๋ฐ˜ ์ž์‚ฐ ์œ ๋™์„ฑ ์ง‘๊ณ„ ํ๋ฆ„์„ ๋ฐ˜์˜ํ•ด์•ผ ํ•œ๋‹ค. +- MCP/service/dashboard interface๊ฐ€ ๋ณ€๊ฒฝ๋˜๋ฉด `docs/interface-contract.md`๋„ ํ•จ๊ป˜ ๊ฐฑ์‹ ํ•ด์•ผ ํ•œ๋‹ค. + +## 4. Acceptance Criteria + +- AC-1: ์ƒˆ DB ๋ฐ ๊ธฐ์กด DB migration ํ›„ `accounts.liquidity_bucket`์ด ์กด์žฌํ•˜๊ณ  ๊ธฐ๋ณธ๊ฐ’/ํด๋ฐฑ์€ `other`๋‹ค. +- AC-2: ์ž˜๋ชป๋œ `liquidity_bucket` ๊ฐ’์œผ๋กœ ๊ณ„์ขŒ ์ƒ์„ฑ/์ˆ˜์ • ์‹œ ๊ฒ€์ฆ ์˜ค๋ฅ˜๊ฐ€ ๋ฐ˜ํ™˜๋œ๋‹ค. +- AC-3: ๊ณ„์ขŒ ์ƒ์„ฑ, ๊ณ„์ขŒ ๋ชฉ๋ก, ๊ณ„์ขŒ ์ˆ˜์ • ๊ฒฐ๊ณผ์—์„œ `liquidity_bucket`์„ ํ™•์ธยท์„ค์ •ํ•  ์ˆ˜ ์žˆ๋‹ค. +- AC-4: ๋Œ€์‹œ๋ณด๋“œ ์ฒซ ํ™”๋ฉด ์ตœ์ƒ๋‹จ์— KST ํ˜„์žฌ ์›” `transaction_date` ๊ธฐ์ค€ ์‹ค์ œ ์ˆ˜์ž…, ์‹ค์ œ ์ง€์ถœ, ์ˆœํ˜„๊ธˆํ๋ฆ„, ์ €์ถ•๋ฅ ์ด ํ‘œ์‹œ๋œ๋‹ค. +- AC-5: ์ž์‚ฐ ์œ ๋™์„ฑ/๊ตฌ์„ฑ ์„น์…˜์ด `accounts.liquidity_bucket`๊ณผ `last_balance` ๊ธฐ์ค€์œผ๋กœ ๋ฒ„ํ‚ท๋ณ„ ๊ธˆ์•ก, ๊ตฌ์„ฑ ๋น„์œจ, ์ด๋ถ€์ฑ„, ์ˆœ์ž์‚ฐ์„ ํ‘œ์‹œํ•œ๋‹ค. +- AC-6: `loan` ๋ฒ„ํ‚ท์€ ์ž์‚ฐ ๊ตฌ์„ฑ ๋น„์œจ ๋ถ„๋ชจ์—์„œ ์ œ์™ธ๋˜๊ณ  ์ด๋ถ€์ฑ„/์ˆœ์ž์‚ฐ ๊ณ„์‚ฐ์—๋งŒ ๋ฐ˜์˜๋œ๋‹ค. +- AC-7: `last_balance`๊ฐ€ ์—†๋Š” ๋น„๊ฑฐ๋ž˜ ์ž์‚ฐ/๋ถ€์ฑ„๋Š” ์ž„์˜ ์ถ”์ •ํ•˜์ง€ ์•Š๊ณ  ๋ฏธ์ž…๋ ฅ ๋˜๋Š” ์ œ์™ธ ์ƒํƒœ๊ฐ€ ํ™”๋ฉด์— ๋ช…ํ™•ํžˆ ํ‘œ์‹œ๋œ๋‹ค. +- AC-8: ์šด์˜ ๊ฒฝ๊ณ  ์„น์…˜์ด ๋ฏธ๋ถ„๋ฅ˜, ํ™•์ธํ•„์š” ํ, ์ž”์•ก๋Œ€์กฐ ํ•„์š”, ๋ฐฑ์—…/import ์ƒํƒœ๋ฅผ ํ‘œ์‹œํ•˜๊ณ  ์ƒ์„ธ ํ™”๋ฉด/์ ˆ์ฐจ ์ด๋™ ๋‹จ์„œ๋ฅผ ์ œ๊ณตํ•œ๋‹ค. +- AC-9: `pytest -q`๊ฐ€ ํ†ต๊ณผํ•˜๊ณ , `dashboard/app.py` ์˜ํ–ฅ ๋ณ€๊ฒฝ์ด๋ฏ€๋กœ `scripts/streamlit_browser_evidence.py` ๊ธฐ๋ฐ˜ screenshot/DOM evidence๊ฐ€ ์ƒ์„ฑ๋œ๋‹ค. +- AC-10: `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md`, ํ•„์š” ์‹œ `docs/interface-contract.md`๊ฐ€ REQ-005 ๋ฒ”์œ„๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. + +## 5. ๊ตฌํ˜„ ๋ฉ”๋ชจ + +- Schema version์€ ํ›„์† batch์—์„œ ํ˜„์žฌ ๊ฐ’๋ณด๋‹ค 1 ์ฆ๊ฐ€์‹œํ‚ค๊ณ , migration์€ additive/idempotent๋กœ ์ž‘์„ฑํ•œ๋‹ค. +- `liquidity_bucket`์˜ ๊ถŒ์œ„ ์†Œ์Šค๋Š” ๊ณ„์ขŒ ๋‹จ์œ„๋กœ ๊ณ ์ •ํ•˜๋ฉฐ ๊ฑฐ๋ž˜/์นดํ…Œ๊ณ ๋ฆฌ override๋Š” ์ด๋ฒˆ ๋ฒ”์œ„์—์„œ ์ œ์™ธํ•œ๋‹ค. +- ์šด์˜ DB, ์›๋ณธ ๊ธˆ์œต ํŒŒ์ผ, ๋ฏผ๊ฐ์ •๋ณด๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  synthetic/validation DB์—์„œ ๋Œ€์‹œ๋ณด๋“œ evidence๋ฅผ ์ƒ์„ฑํ•œ๋‹ค. +- dcy-004 QC ๊ฒฐ๊ณผ์— ๋”ฐ๋ผ UI ์˜ํ–ฅ batch๋Š” browser screenshot/snapshot evidence๋ฅผ ํ•„์ˆ˜๋กœ ํ•œ๋‹ค. +- ์ด REQ๋Š” ๊ตฌํ˜„ ๊ธฐ์ค€์œผ๋กœ ์Šน์ธ๋˜์—ˆ์œผ๋ฉฐ, ํ›„์† batch์—์„œ ๊ตฌํ˜„ยท๊ฒ€์ฆ์„ ์ง„ํ–‰ํ•œ๋‹ค. diff --git a/docs/srs/Testing/req-004_browser-streamlit-e2e-evidence-recovery.md b/docs/srs/Testing/req-004_browser-streamlit-e2e-evidence-recovery.md new file mode 100644 index 0000000..b798245 --- /dev/null +++ b/docs/srs/Testing/req-004_browser-streamlit-e2e-evidence-recovery.md @@ -0,0 +1,72 @@ +# REQ-004 Browser/Streamlit E2E evidence ํ™˜๊ฒฝ ๋ณต๊ตฌ + +- Status: Implemented +- Type: Testing +- Priority: High +- Owner: ๋ ›์ €์žฅ์ธ +- Source Discovery: `docs/discovery/dcy-004_20260629_browser-streamlit-e2e-evidence-recovery.md` +- Batch: `docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/` +- Created At (KST): 2026-06-29 +- Implemented At (KST): 2026-06-29 + +## 1. ๋ฐฐ๊ฒฝ + +Streamlit UI batch๋Š” ์‹ค์ œ ๋ธŒ๋ผ์šฐ์ € ๋ Œ๋”๋ง ์ฆ๊ฑฐ๊ฐ€ ํ•„์š”ํ•˜์ง€๋งŒ, ๊ธฐ์กด ๊ฒ€์ฆ์—์„œ๋Š” chromium snap ๋ถ€์žฌ ๋•Œ๋ฌธ์— AppTest์™€ HTTP 200 smoke๊ฐ€ ๋ฐ˜๋ณต์ ์œผ๋กœ ๋Œ€์ฒด ์ฆ๊ฑฐ๋กœ ์‚ฌ์šฉ๋˜์—ˆ๋‹ค. dcy-004 confirmed Discovery์— ๋”ฐ๋ผ `/usr/bin/google-chrome-stable` ๊ธฐ๋ฐ˜ ๋กœ์ปฌ headless browser evidence ์ ˆ์ฐจ๋ฅผ 1์ฐจ ํ‘œ์ค€์œผ๋กœ ๋ณต๊ตฌํ•˜๊ณ , fallback evidence์˜ ํ—ˆ์šฉ ์กฐ๊ฑด์„ ๋ช…ํ™•ํžˆ ํ•œ๋‹ค. + +## 2. ๋ฒ”์œ„ + +### In Scope +- synthetic/validation DB๋ฅผ ์ƒ์„ฑํ•ด `dashboard/app.py`๋ฅผ ๋กœ์ปฌ Streamlit ์„œ๋ฒ„๋กœ ๊ธฐ๋™ํ•˜๋Š” ์ ˆ์ฐจ. +- `/usr/bin/google-chrome-stable` headless Chrome DevTools Protocol ๊ธฐ๋ฐ˜ screenshot ๋ฐ DOM snapshot evidence ์ˆ˜์ง‘. +- ์ตœ์†Œ evidence ํŽ˜์ด์ง€: `๋ถ„๋ฅ˜ ๊ทœ์น™`, `ํ™•์ธํ•„์š” ํ`, `Import`. +- evidence ์‚ฐ์ถœ๋ฌผ: PNG screenshot, DOM snapshot HTML, manifest JSON, synthetic fixture DB. +- Streamlit UI ๋˜๋Š” `dashboard/app.py` ์˜ํ–ฅ batch์˜ QC evidence gate ๊ทœ์น™ ๋ฌธ์„œํ™”. +- ์šด์˜ DB, ์›๋ณธ ๊ธˆ์œต ํŒŒ์ผ, ๋น„๋ฐ€๋ฒˆํ˜ธ, full raw_payload๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๋Š” ์•ˆ์ „์žฅ์น˜. + +### Out of Scope +- CI ํ†ตํ•ฉ ๋ฐ GitHub Actions ์ž๋™ํ™”. +- Playwright/Selenium ํ‘œ์ค€ํ™”. Playwright Python์€ ํ›„์† CI ํ›„๋ณด๋กœ๋งŒ ๋‘”๋‹ค. +- pixel-diff ๊ธฐ๋ฐ˜ visual regression. +- Streamlit ํ™”๋ฉด ๊ธฐ๋Šฅ/UX ์‹ ๊ทœ ๊ฐœ๋ฐœ. + +## 3. ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ + +### FR-1. Chrome ๊ธฐ๋ฐ˜ ๋ธŒ๋ผ์šฐ์ € evidence ์‹คํ–‰ +- ํ‘œ์ค€ ์‹คํ–‰ ๊ฒฝ๋กœ๋Š” `/usr/bin/google-chrome-stable`์„ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค. +- snap ๊ธฐ๋ฐ˜ `chromium-browser`๊ฐ€ ์—†์–ด๋„ ์‹คํ–‰ ๊ฐ€๋Šฅํ•ด์•ผ ํ•œ๋‹ค. +- Chrome์ด ์—†๊ฑฐ๋‚˜ Streamlit์ด ๊ธฐ๋™ํ•˜์ง€ ์•Š์œผ๋ฉด ๋ช…ํ™•ํ•œ ์‹คํŒจ ๋ฉ”์‹œ์ง€๋ฅผ ๋ฐ˜ํ™˜ํ•ด์•ผ ํ•œ๋‹ค. + +### FR-2. Synthetic DB ์ „์šฉ Streamlit ๊ธฐ๋™ +- evidence ์ ˆ์ฐจ๋Š” `JARCA_LEDGER_DB_PATH`๋ฅผ synthetic fixture DB๋กœ ์ง€์ •ํ•ด์•ผ ํ•œ๋‹ค. +- fixture DB๋Š” ๊ฐ€์งœ ๊ณ„์ขŒ/๊ฑฐ๋ž˜/๋ถ„๋ฅ˜ ๊ทœ์น™๋งŒ ํฌํ•จํ•ด์•ผ ํ•˜๋ฉฐ ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ ๊ฑฐ๋ถ€ํ•ด์•ผ ํ•œ๋‹ค. +- screenshot/log์—๋Š” ์‹ค์ œ ๊ณ„์ขŒ๋ฒˆํ˜ธ, ์‹ค์ œ ๊ฑฐ๋ž˜์ฒ˜, raw_payload, ์›๋ณธ ๊ธˆ์œต ํŒŒ์ผ๋ช…, ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋‚จ๊ธฐ์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. + +### FR-3. Screenshot ๋ฐ snapshot ์‚ฐ์ถœ๋ฌผ +- ์‹คํ–‰ ๊ฒฐ๊ณผ๋Š” ํŽ˜์ด์ง€๋ณ„ PNG screenshot๊ณผ DOM snapshot HTML์„ ์ €์žฅํ•ด์•ผ ํ•œ๋‹ค. +- manifest JSON์€ base URL, fixture DB ๊ฒฝ๋กœ, Chrome/Streamlit ์‹คํ–‰ ๊ฒฝ๋กœ, ์บก์ฒ˜ํ•œ page/url/path/byte size๋ฅผ ๊ธฐ๋กํ•ด์•ผ ํ•œ๋‹ค. +- ์ตœ์†Œ 1๊ฐœ ์ด์ƒ์ด ์•„๋‹ˆ๋ผ ๊ธฐ๋ณธ 3๊ฐœ ํŽ˜์ด์ง€(`๋ถ„๋ฅ˜ ๊ทœ์น™`, `ํ™•์ธํ•„์š” ํ`, `Import`)๋ฅผ ์บก์ฒ˜ํ•ด์•ผ ํ•œ๋‹ค. + +### FR-4. QC evidence gate ์ •์ฑ… +- Streamlit UI ๋˜๋Š” `dashboard/app.py`์— ์˜ํ–ฅ์„ ์ฃผ๋Š” batch๋Š” browser screenshot/snapshot evidence๋ฅผ ํ•„์ˆ˜๋กœ ํ•œ๋‹ค. +- ๋น„-UI batch๋Š” browser evidence๋ฅผ ์„ ํƒ์œผ๋กœ ๋‘๊ณ , docs-only batch๋Š” ๋ถˆํ•„์š”๋กœ ํ•œ๋‹ค. +- Chrome/๋ธŒ๋ผ์šฐ์ € ํ™˜๊ฒฝ ์žฅ์• ๊ฐ€ ๋ช…ํ™•ํžˆ ์ž…์ฆ๋œ ๊ฒฝ์šฐ์—๋งŒ AppTest + HTTP 200 fallback evidence๋กœ `PASS_WITH_NOTES`๋ฅผ ํ—ˆ์šฉํ•˜๊ณ , ์‹คํŒจ ์‚ฌ์œ ์™€ ์ž”์—ฌ ํ•ญ๋ชฉ์„ verification ๋ฌธ์„œ์— ๊ธฐ๋กํ•ด์•ผ ํ•œ๋‹ค. + +### FR-5. ๋ฌธ์„œํ™” ๋ฐ ๋กœ์ปฌ ์žฌํ˜„์„ฑ +- runbook์€ ๋กœ์ปฌ ์žฌํ˜„ ๋ช…๋ น, ์‚ฐ์ถœ๋ฌผ ์œ„์น˜, ๋ฏผ๊ฐ์ •๋ณด ๊ธˆ์ง€ ๋ชฉ๋ก, fallback ์กฐ๊ฑด์„ ํฌํ•จํ•ด์•ผ ํ•œ๋‹ค. +- `docs/runtime-flows.md`, `docs/project-structure.md`, `docs/interface-contract.md`๋Š” browser evidence ํ๋ฆ„๊ณผ ๊ธฐ์ค€์„ ๋ฐ˜์˜ํ•ด์•ผ ํ•œ๋‹ค. + +## 4. Acceptance Criteria + +- AC-1: `scripts/streamlit_browser_evidence.py`๊ฐ€ synthetic fixture DB๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ ์‚ฌ์šฉ์„ ๊ฑฐ๋ถ€ํ•œ๋‹ค. +- AC-2: `python3 -m py_compile scripts/streamlit_browser_evidence.py dashboard/app.py`๊ฐ€ ํ†ต๊ณผํ•œ๋‹ค. +- AC-3: `pytest -q`๊ฐ€ ํ†ต๊ณผํ•œ๋‹ค. +- AC-4: `.venv/bin/python scripts/streamlit_browser_evidence.py` ์‹คํ–‰์œผ๋กœ `๋ถ„๋ฅ˜ ๊ทœ์น™`, `ํ™•์ธํ•„์š” ํ`, `Import` ํŽ˜์ด์ง€์˜ PNG screenshot๊ณผ DOM snapshot, manifest JSON์ด ์ƒ์„ฑ๋œ๋‹ค. +- AC-5: manifest์˜ ๋ชจ๋“  page entry๋Š” `screenshot_bytes > 0`, `snapshot_bytes > 0`์ด๋ฉฐ DOM snapshot์— ํ•ด๋‹น page ์ œ๋ชฉ์ด ํฌํ•จ๋œ๋‹ค. +- AC-6: dashboard Import page์˜ import history/rollback ์กฐํšŒ๋Š” evidence fixture DB์˜ `db_path`๋ฅผ ์‚ฌ์šฉํ•˜๋ฉฐ ์šด์˜ DB๋กœ ๋ˆ„์ˆ˜๋˜์ง€ ์•Š๋Š”๋‹ค. +- AC-7: runtime/interface/project-structure/runbook ๋ฌธ์„œ๊ฐ€ evidence ๋“ฑ๊ธ‰๊ณผ UI/non-UI/docs-only gate ๊ทœ์น™์„ ์„ค๋ช…ํ•œ๋‹ค. + +## 5. ๊ตฌํ˜„ ๋ฉ”๋ชจ + +- ํ˜„์žฌ 1์ฐจ ๊ตฌํ˜„์€ Chrome DevTools Protocol์„ ์ง์ ‘ ์‚ฌ์šฉํ•œ๋‹ค. Streamlit dashboard extra์— ํฌํ•จ๋œ `websockets` ํŒจํ‚ค์ง€๋ฅผ ์‚ฌ์šฉํ•˜๋ฉฐ ๋ณ„๋„ browser automation dependency๋ฅผ ์ถ”๊ฐ€ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- Playwright Python์€ ํ›„์† CI ํ†ตํ•ฉ ํ›„๋ณด๋กœ ๋‚จ๊ธด๋‹ค. +- evidence ํŒŒ์ผ์€ ๊ธฐ๋ณธ์ ์œผ๋กœ `docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence/run-YYYYMMDD-HHMMSS/`์— ์ƒ์„ฑ๋œ๋‹ค. diff --git a/docs/srs/Testing/req-006_pytest-apptest-smoke-ci-automation.md b/docs/srs/Testing/req-006_pytest-apptest-smoke-ci-automation.md new file mode 100644 index 0000000..b956eb9 --- /dev/null +++ b/docs/srs/Testing/req-006_pytest-apptest-smoke-ci-automation.md @@ -0,0 +1,88 @@ +# REQ-006 pytest/AppTest/Smoke CI ์ž๋™ํ™” + +- Status: Implemented +- Type: Testing +- Priority: High +- Owner: ๋ ›์ €์žฅ์ธ +- Source Discovery: `docs/discovery/dcy-006_20260630_pytest-apptest-smoke-ci-automation.md` +- Batch: BAT-006 +- Created At (KST): 2026-06-30 14:42 KST +- Approved At (KST): 2026-06-30 14:50 KST +- Implemented At (KST): 2026-06-30 15:29 KST + +## 1. ๋ฐฐ๊ฒฝ + +ํ˜„์žฌ ์ €์žฅ์†Œ์—๋Š” `pytest` ํšŒ๊ท€ ์Šค์œ„ํŠธ, Streamlit `AppTest`, ๋Œ€์‹œ๋ณด๋“œ HTTP smoke, DB restore safety smoke ์ž์‚ฐ์ด ์ด๋ฏธ ์กด์žฌํ•˜์ง€๋งŒ, ์ด๋ฅผ `push`/`pull_request`์—์„œ ์ž๋™ ์‹คํ–‰ํ•ด ๋จธ์ง€๋ฅผ ์ฐจ๋‹จํ•˜๋Š” CI ๊ณ„์•ฝ์ด ์—†๋‹ค. ๊ทธ ๊ฒฐ๊ณผ ํšŒ๊ท€ ๊ฒ€์ฆ์€ ์ž‘์„ฑ์ž์™€ ๊ฒ€์ˆ˜๊ด€์˜ ์ˆ˜๋™ ๋กœ์ปฌ ์‹คํ–‰์— ์˜์กดํ•˜๊ณ , browser evidence ๋ณต๊ตฌ(dcy-004)์™€ dashboard ์šด์˜ ํ—ˆ๋ธŒ(dcy-005)์—์„œ ํ™•๋ณดํ•œ ๊ฒ€์ฆ ์ž์‚ฐ๋„ ๋ฐ˜๋ณต ๊ฐ€๋Šฅํ•œ ๊ฒŒ์ดํŠธ๋กœ ๊ณ ์ •๋˜์ง€ ๋ชปํ–ˆ๋‹ค. dcy-006 confirmed Discovery์— ๋”ฐ๋ผ GitHub Actions ๊ธฐ๋ฐ˜ CI๋ฅผ ๋„์ž…ํ•˜๊ณ , ๊ฒฐ์ •์  synthetic fixture DB๋ฅผ ์‚ฌ์šฉํ•ด `pytest`/`AppTest`/`HTTP smoke`/`DB safety smoke`๋ฅผ ์ž๋™ ์‹คํ–‰ํ•˜๋Š” ํ‘œ์ค€์„ ์ •์˜ํ•œ๋‹ค. + +## 2. ๋ฒ”์œ„ + +### In Scope +- GitHub Actions ๊ธฐ๋ฐ˜ CI workflow๋ฅผ ์‹ ์„คํ•˜๊ณ  `pull_request`, `main` ๋Œ€์ƒ `push`, ์„ ํƒ์  `workflow_dispatch` ํŠธ๋ฆฌ๊ฑฐ๋ฅผ ์ •์˜ํ•œ๋‹ค. +- ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋ฅผ `pytest`, Streamlit `AppTest`, ๋Œ€์‹œ๋ณด๋“œ HTTP smoke, DB safety smoke๋กœ ๊ตฌ์„ฑํ•œ๋‹ค. +- CI์—์„œ๋Š” ๊ธฐ์กด ํ…Œ์ŠคํŠธ/์Šคํฌ๋ฆฝํŠธ ์ž์‚ฐ์˜ ๋‚ด๋ถ€ ๋กœ์ง์„ ์žฌ์‚ฌ์šฉํ•˜๋˜, ์ข…๋ฃŒ์ฝ”๋“œยท์ธ์žยท๋กœ๊ทธ๊ฐ€ ๊ฒฐ์ •์ ์ธ ์–‡์€ wrapper entrypoint๋ฅผ ํ‘œ์ค€ ์ง„์ž…์ ์œผ๋กœ ์‚ฌ์šฉํ•œ๋‹ค. +- ๋ชจ๋“  CI ๊ฒ€์ฆ์€ ๋งค ์‹คํ–‰ ์‹œ ์ƒ์„ฑ๋˜๋Š” synthetic fixture DB ๋˜๋Š” ์ž„์‹œ ๊ฒ€์ฆ DB์—์„œ ์ˆ˜ํ–‰ํ•˜๊ณ  ์šด์˜ DB ์ ‘๊ทผ์„ ๊ธˆ์ง€ํ•œ๋‹ค. +- `docs/project-structure.md`, `docs/runtime-flows.md`, ํ•„์š” ์‹œ `docs/runbooks/`๋ฅผ ๊ฐฑ์‹ ํ•ด CI ๊ฒŒ์ดํŠธ์™€ ๋กœ์ปฌ browser evidence(dcy-004) ๊ฒฝ๊ณ„๋ฅผ ๋ฌธ์„œํ™”ํ•œ๋‹ค. + +### Out of Scope +- browser-screenshot/visual regression์„ CI ํ•„์ˆ˜ ์ฐจ๋‹จ ๊ฒŒ์ดํŠธ๋กœ ์Šน๊ฒฉํ•˜๋Š” ์ž‘์—…. +- ๋ฐฐํฌ/๋ฆด๋ฆฌ์Šค ์ž๋™ํ™”(CD), ์„œ๋น„์Šค ํ˜ธ์ŠคํŒ…, ์šด์˜ ํ”„๋กœ์„ธ์Šค ๊ด€๋ฆฌ. +- ํ…Œ์ŠคํŠธ ์ปค๋ฒ„๋ฆฌ์ง€ ํผ์„ผํŠธ ๊ฐ•์ œ, ์„ฑ๋Šฅ ๋ฒค์น˜๋งˆํฌ ๊ฒŒ์ดํŠธ, flaky test quarantine ์‹œ์Šคํ…œ ๊ฐ™์€ ๊ณ ๋„ํ™” ํ’ˆ์งˆ ์ฒด๊ณ„. +- ์šด์˜ DB ๋˜๋Š” ์‹ค๊ฑฐ๋ž˜/์‹ค๊ณ„์ขŒ ๋ฐ์ดํ„ฐ๋ฅผ CI์— ๋ฐ˜์ž…ํ•˜๋Š” ์ž‘์—…. + +## 3. ๊ธฐ๋Šฅ ์š”๊ตฌ์‚ฌํ•ญ + +### FR-1. GitHub Actions CI ์ง„์ž…์  +- ์ €์žฅ์†Œ๋Š” GitHub Actions workflow ํŒŒ์ผ์„ ์ œ๊ณตํ•ด์•ผ ํ•œ๋‹ค. +- ๊ธฐ๋ณธ ํŠธ๋ฆฌ๊ฑฐ๋Š” `pull_request`์™€ `main` ๋Œ€์ƒ `push`์—ฌ์•ผ ํ•˜๋ฉฐ, `workflow_dispatch`๋Š” ์„ ํƒ์  ์ˆ˜๋™ ์žฌ์‹คํ–‰ ๊ฒฝ๋กœ๋กœ ํ—ˆ์šฉํ•ด์•ผ ํ•œ๋‹ค. +- ํ•„์ˆ˜ ๊ฒ€์ฆ job ์‹คํŒจ ์‹œ workflow ์ „์ฒด๊ฐ€ ์‹คํŒจํ•ด์•ผ ํ•˜๋ฉฐ, PR ๋ณ‘ํ•ฉ ์ฐจ๋‹จ ๊ทผ๊ฑฐ๋กœ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์–ด์•ผ ํ•œ๋‹ค. + +### FR-2. pytest ํšŒ๊ท€ ๊ฒŒ์ดํŠธ +- CI๋Š” ์ €์žฅ์†Œ์˜ ํ‘œ์ค€ pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ๋ฅผ ์ž๋™ ์‹คํ–‰ํ•ด์•ผ ํ•œ๋‹ค. +- ์‹คํ–‰ ์ง„์ž…์ ์€ ๋กœ์ปฌ๊ณผ CI์—์„œ ๋™์ผํ•œ ์˜๋ฏธ๋ฅผ ๊ฐ€์ง€๋„๋ก ํ‘œ์ค€ ๋ช…๋ น ๋˜๋Š” wrapper๋กœ ๊ณ ์ •ํ•ด์•ผ ํ•œ๋‹ค. +- ์‹คํŒจ ๊ฒฐ๊ณผ๋Š” ์–ด๋–ค ํ…Œ์ŠคํŠธ๊ฐ€ ์‹คํŒจํ–ˆ๋Š”์ง€ ์‹๋ณ„ ๊ฐ€๋Šฅํ•œ ํ˜•ํƒœ๋กœ ๋กœ๊ทธ์— ๋‚จ๊ฒจ์•ผ ํ•œ๋‹ค. + +### FR-3. Streamlit AppTest + HTTP smoke ๊ฒŒ์ดํŠธ +- CI๋Š” `tests/test_dashboard_app.py` ๊ณ„์—ด Streamlit `AppTest`๋ฅผ synthetic fixture DB๋กœ ์ž๋™ ์‹คํ–‰ํ•ด์•ผ ํ•œ๋‹ค. +- CI๋Š” ๋Œ€์‹œ๋ณด๋“œ HTTP smoke๋ฅผ ๋ณ„๋„ job ๋˜๋Š” ๋™์ผ ๋‹จ๊ณ„์—์„œ ์ˆ˜ํ–‰ํ•ด, Streamlit ์„œ๋ฒ„๊ฐ€ fixture DB ๊ธฐ์ค€์œผ๋กœ ๊ธฐ๋™๋˜๊ณ  HTTP 200 ์‘๋‹ต์„ ๋ฐ˜ํ™˜ํ•˜๋Š”์ง€ ํ™•์ธํ•ด์•ผ ํ•œ๋‹ค. +- HTTP smoke๋Š” ๊ณ ์ • ๋˜๋Š” ๊ฒฐ์ •์ ์œผ๋กœ ํ• ๋‹น๋˜๋Š” ํฌํŠธ, readiness ๋Œ€๊ธฐ, timeout/์ •๋ฆฌ ์ •์ฑ…์„ ํฌํ•จํ•ด์•ผ ํ•œ๋‹ค. + +### FR-4. DB safety smoke ๊ฒŒ์ดํŠธ +- CI๋Š” `scripts/ledger_restore_smoke.py` ๊ณ„์—ด DB safety smoke๋ฅผ ์ž„์‹œ fixture/๊ฒ€์ฆ DB์—์„œ ์ž๋™ ์‹คํ–‰ํ•ด์•ผ ํ•œ๋‹ค. +- smoke๋Š” ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š์•„์•ผ ํ•˜๋ฉฐ, overwrite/restore ์œ„ํ—˜ ๋™์ž‘์€ ๋ช…์‹œ์ ์œผ๋กœ ์ฐจ๋‹จ๋˜์–ด์•ผ ํ•œ๋‹ค. +- smoke ์‹คํŒจ ์‹œ ๋ณต๊ตฌ/๊ฒ€์ฆ ์ ˆ์ฐจ๊ฐ€ ์–ด๋–ค ๋‹จ๊ณ„์—์„œ ์‹คํŒจํ–ˆ๋Š”์ง€ ๋กœ๊ทธ๋กœ ์‹๋ณ„ ๊ฐ€๋Šฅํ•ด์•ผ ํ•œ๋‹ค. + +### FR-5. CI ์ „์šฉ wrapper entrypoint +- HTTP smoke์™€ DB safety smoke๋Š” ๊ธฐ์กด `scripts/`ยทํ…Œ์ŠคํŠธ ์ž์‚ฐ์˜ ๋‚ด๋ถ€ ๋กœ์ง์„ ์žฌ์‚ฌ์šฉํ•˜๋˜, CI์—์„œ๋Š” ์ข…๋ฃŒ์ฝ”๋“œยท์ž…๋ ฅ ์ธ์žยท์ถœ๋ ฅ ๋กœ๊ทธ๊ฐ€ ๊ฒฐ์ •์ ์ธ ์–‡์€ wrapper entrypoint๋ฅผ ํ‘œ์ค€์œผ๋กœ ์‚ฌ์šฉํ•ด์•ผ ํ•œ๋‹ค. +- wrapper๋Š” fixture ์ค€๋น„, ํ™˜๊ฒฝ ๋ณ€์ˆ˜ ์„ค์ •, readiness polling, timeout, ์ข…๋ฃŒ ํ›„ ์ •๋ฆฌ๋ฅผ ์ผ๊ด€๋˜๊ฒŒ ์ˆ˜ํ–‰ํ•ด์•ผ ํ•œ๋‹ค. +- wrapper๋Š” ๋ฏผ๊ฐ์ •๋ณด๊ฐ€ ์„ž์ผ ์ˆ˜ ์žˆ๋Š” ํ™˜๊ฒฝ ๋ณ€์ˆ˜/๊ฒฝ๋กœ/๋ฐ์ดํ„ฐ๋ฅผ ๊ทธ๋Œ€๋กœ ๋กœ๊ทธ์— ์ถœ๋ ฅํ•˜์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. + +### FR-6. CI ๊ฒŒ์ดํŠธ์™€ browser evidence ๊ฒฝ๊ณ„ +- CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋Š” `pytest`, `Streamlit AppTest`, `HTTP smoke`, `DB safety smoke`๋กœ ํ•œ์ •ํ•ด์•ผ ํ•œ๋‹ค. +- browser-screenshot E2E๋Š” dcy-004์˜ ๋กœ์ปฌ ๋ณด๊ฐ• evidence ์ ˆ์ฐจ๋กœ ์œ ์ง€ํ•˜๊ณ , CI ํ•„์ˆ˜ ์ฐจ๋‹จ ๊ฒŒ์ดํŠธ์— ํฌํ•จํ•˜์ง€ ์•Š์•„์•ผ ํ•œ๋‹ค. +- ๋ฌธ์„œ์—๋Š” ์–ด๋–ค ๋ณ€๊ฒฝ์ด CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๋งŒ์œผ๋กœ ์ถฉ๋ถ„ํ•œ์ง€, ์–ด๋–ค ๊ฒฝ์šฐ์— ๋กœ์ปฌ browser evidence๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•˜๋Š”์ง€ ๊ฒฝ๊ณ„๋ฅผ ์„ค๋ช…ํ•ด์•ผ ํ•œ๋‹ค. + +### FR-7. ๊ธฐ์ค€์„  ๋ฌธ์„œ ์ •๋ ฌ +- `docs/project-structure.md`๋Š” CI workflow์™€ ๊ด€๋ จ ์Šคํฌ๋ฆฝํŠธ/wrapper ์ฑ…์ž„์„ ๋ฐ˜์˜ํ•ด์•ผ ํ•œ๋‹ค. +- `docs/runtime-flows.md`๋Š” PR/push โ†’ CI jobs โ†’ gate result ํ๋ฆ„๊ณผ synthetic fixture DB ์›์น™์„ ๋ฐ˜์˜ํ•ด์•ผ ํ•œ๋‹ค. +- ํ•„์š” ์‹œ `docs/runbooks/`์— CI ์‹คํŒจ ์‹œ ๋กœ์ปฌ ์žฌํ˜„ ์ ˆ์ฐจ ๋˜๋Š” smoke ์‹คํ–‰ ์ ˆ์ฐจ๋ฅผ ์ถ”๊ฐ€ํ•ด์•ผ ํ•œ๋‹ค. + +## 4. Acceptance Criteria + +- AC-1: `.github/workflows/` ์•„๋ž˜์— GitHub Actions workflow๊ฐ€ ์ƒ์„ฑ๋˜๊ณ , `pull_request`, `main` ๋Œ€์ƒ `push`, `workflow_dispatch` ํŠธ๋ฆฌ๊ฑฐ๊ฐ€ ์ •์˜๋œ๋‹ค. +- AC-2: CI์—์„œ pytest ํšŒ๊ท€ ์Šค์œ„ํŠธ๊ฐ€ ์ž๋™ ์‹คํ–‰๋˜๊ณ , ๋‹จ์ผ ํ…Œ์ŠคํŠธ ์‹คํŒจ ์‹œ workflow๊ฐ€ ์‹คํŒจ ์ƒํƒœ๋กœ ์ข…๋ฃŒ๋œ๋‹ค. +- AC-3: CI์—์„œ Streamlit `AppTest`๊ฐ€ synthetic fixture DB ๊ธฐ์ค€์œผ๋กœ ์ž๋™ ์‹คํ–‰๋˜๊ณ  ํ†ต๊ณผ/์‹คํŒจ ๊ฒฐ๊ณผ๊ฐ€ ๋กœ๊ทธ์— ๋‚จ๋Š”๋‹ค. +- AC-4: CI์—์„œ ๋Œ€์‹œ๋ณด๋“œ HTTP smoke๊ฐ€ fixture DB ๊ธฐ์ค€์œผ๋กœ Streamlit์„ ๊ธฐ๋™ํ•œ ๋’ค HTTP 200 ์‘๋‹ต์„ ํ™•์ธํ•˜๊ณ , ์ข…๋ฃŒ ํ›„ ํ”„๋กœ์„ธ์Šค๋ฅผ ์ •๋ฆฌํ•œ๋‹ค. +- AC-5: CI์—์„œ DB safety smoke๊ฐ€ ์šด์˜ DB ๊ธฐ๋ณธ ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์šฉํ•˜์ง€ ์•Š๊ณ  ์ž„์‹œ fixture/๊ฒ€์ฆ DB์—์„œ ํ†ต๊ณผํ•œ๋‹ค. +- AC-6: HTTP smoke/DB safety smoke๋Š” CI ์ „์šฉ wrapper entrypoint๋ฅผ ํ†ตํ•ด ์‹คํ–‰๋˜๋ฉฐ, wrapper๊ฐ€ ์ข…๋ฃŒ์ฝ”๋“œยทtimeoutยทreadinessยทcleanup์„ ์ผ๊ด€๋˜๊ฒŒ ์ฒ˜๋ฆฌํ•œ๋‹ค. +- AC-7: CI ๋กœ๊ทธยท์•„ํ‹ฐํŒฉํŠธ์—๋Š” ์‹ค๊ณ„์ขŒ๋ฒˆํ˜ธ, ์‹ค์ž”์•ก, `raw_payload`, ์›๋ณธ ๊ธˆ์œต ํŒŒ์ผ๋ช…, ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ํ‰๋ฌธ์œผ๋กœ ํฌํ•จ๋˜์ง€ ์•Š๋Š”๋‹ค. +- AC-8: `docs/project-structure.md`์™€ `docs/runtime-flows.md`๊ฐ€ CI ๊ฒŒ์ดํŠธ์™€ ๋กœ์ปฌ browser evidence ๊ฒฝ๊ณ„๋ฅผ ๋ฐ˜์˜ํ•œ๋‹ค. +- AC-9: browser-screenshot E2E๋Š” CI ํ•„์ˆ˜ ์ฐจ๋‹จ ๊ฒŒ์ดํŠธ์—์„œ ์ œ์™ธ๋˜๊ณ , dcy-004 ๋กœ์ปฌ evidence ์ ˆ์ฐจ์™€์˜ ์—ญํ•  ๋ถ„๋‹ด์ด ๋ฌธ์„œ์— ๋ช…์‹œ๋œ๋‹ค. + +## 5. ๊ตฌํ˜„ ๋ฉ”๋ชจ + +- ์ด REQ๋Š” ํ…Œ์ŠคํŠธ/๊ฒ€์ฆ ๊ณ„์•ฝ๊ณผ ์šด์˜ ๋ฌธ์„œ๋ฅผ ํ•จ๊ป˜ ๋‹ค๋ฃจ๋Š” `Testing` ํƒ€์ž…์œผ๋กœ ๊ด€๋ฆฌํ•œ๋‹ค. +- CI๋Š” synthetic fixture DB๋ฅผ ๋งค ์‹คํ–‰๋งˆ๋‹ค ์ƒˆ๋กœ ์ƒ์„ฑํ•˜๋Š” ๋ฐฉ์‹์„ ๊ธฐ๋ณธ์œผ๋กœ ํ•˜๋ฉฐ, repo์— ์‹ค๋ฐ์ดํ„ฐ ๊ธฐ๋ฐ˜ fixture๋ฅผ ์ปค๋ฐ‹ํ•˜์ง€ ์•Š๋Š”๋‹ค. +- PR ๋ณ‘ํ•ฉ ์ฐจ๋‹จ์˜ ์‹ค์ œ enforcement(๋ธŒ๋žœ์น˜ ๋ณดํ˜ธ ๊ทœ์น™)๋Š” GitHub ์„ค์ •๊ณผ ์—ฐ๋™๋˜๋ฏ€๋กœ, workflow ์„ค๊ณ„ ์‹œ required check ์ด๋ฆ„์ด ์•ˆ์ •์ ์œผ๋กœ ์œ ์ง€๋˜๋„๋ก ํ•œ๋‹ค. +- `scripts/streamlit_browser_evidence.py`๋Š” CI ํ•„์ˆ˜ ๊ฒŒ์ดํŠธ๊ฐ€ ์•„๋‹ˆ๋ผ ๋กœ์ปฌ ๋ณด๊ฐ• evidence ๊ฒฝ๋กœ๋กœ ์œ ์ง€ํ•œ๋‹ค. +- ๋ฆฌ์Šคํฌ ์ฑ…์ž„์ž ์ง€์ •๊ณผ batch ๋‹จ์œ„ ๊ตฌํ˜„ ๋ถ„ํ•ด๋Š” BAT-006 planning ๋‹จ๊ณ„์—์„œ ํ™•์ •ํ•œ๋‹ค. +- ์ด REQ๋Š” BAT-006 ๊ตฌํ˜„ ๋ฐ `jarcaledgerqc`์˜ PASS_WITH_NOTES ๊ฒ€์ฆ์„ ๊ทผ๊ฑฐ๋กœ Implemented๋กœ ์ „ํ™˜ํ–ˆ๋‹ค. ๋‚จ์€ note๋Š” ์›๊ฒฉ GitHub Actions ์‹คํ–‰/required check ์„ค์ • ํ™•์ธ์œผ๋กœ, ์ €์žฅ์†Œ ์ฝ”๋“œ ๋ฒ”์œ„๋ฅผ ๋„˜๋Š” ์šด์˜ ํ™•์ธ ํ•ญ๋ชฉ์ด๋‹ค. diff --git a/docs/srs/index.md b/docs/srs/index.md index d0ad83e..206b225 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -33,7 +33,7 @@ ## Next Requirement ID -- Current: `REQ-003` +- Current: `REQ-007` - Rule: `Requirement Register`์— ์žˆ๋Š” ๊ฐ€์žฅ ํฐ REQ ๋ฒˆํ˜ธ๋ณด๋‹ค ์ •ํ™•ํžˆ 1 ์ปค์•ผ ํ•œ๋‹ค. ## Requirement Register @@ -42,6 +42,10 @@ | --- | --- | --- | --- | --- | --- | --- | | REQ-001 | ๋ถ„๋ฅ˜ ๊ทœ์น™ ์šด์˜ ์•ก์…˜์˜ DB ๊ฒฝ๋กœ ์ผ๊ด€์„ฑ ๋ฐ ์‚ญ์ œ ๋…ธ์ถœ | Interface | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-001_category-rule-ops.md](Interface/req-001_category-rule-ops.md) | | REQ-002 | ์šด์˜ ์•ˆ์ „ ๋ฐฑ์—…ยท๋ณต๊ตฌยท๊ฒ€์ฆ DB ์›Œํฌํ”Œ๋กœ์šฐ | Non-Functional | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-002_operational-safety-backup-restore-validation-db.md](Non-Functional/req-002_operational-safety-backup-restore-validation-db.md) | +| REQ-003 | Statement-unavailable ๊ณ„์ขŒ ์ˆ˜๋™ ์ฆ๋น™ ๋ฐ ์ž”์•ก๋Œ€์กฐ | Interface | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-003_statement-unavailable-manual-evidence-balance-reconciliation.md](Interface/req-003_statement-unavailable-manual-evidence-balance-reconciliation.md) | +| REQ-004 | Browser/Streamlit E2E evidence ํ™˜๊ฒฝ ๋ณต๊ตฌ | Testing | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-004_browser-streamlit-e2e-evidence-recovery.md](Testing/req-004_browser-streamlit-e2e-evidence-recovery.md) | +| REQ-005 | Dashboard operations hub/liquidity/monthly cashflow | Interface | Approved | High | ๋ ›์ €์žฅ์ธ | [req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md](Interface/req-005_dashboard-operations-hub-liquidity-monthly-cashflow.md) | +| REQ-006 | pytest/AppTest/Smoke CI ์ž๋™ํ™” | Testing | Implemented | High | ๋ ›์ €์žฅ์ธ | [req-006_pytest-apptest-smoke-ci-automation.md](Testing/req-006_pytest-apptest-smoke-ci-automation.md) | ## Recent Change Log Summary @@ -49,3 +53,10 @@ - 2026-06-26 23:41 KST: BAT-001 ๊ฒ€์ฆ ๋ฐ ๊ฒ€์ˆ˜๊ด€ PASS_WITH_NOTES ๊ทผ๊ฑฐ๋กœ REQ-001์„ Implemented ์ƒํƒœ๋กœ ์ „ํ™˜. - 2026-06-27 10:17 KST: dcy-002 ํ›„์† ์šด์˜ ์•ˆ์ „์žฅ์น˜ ๋ฒ”์œ„๋กœ REQ-002๋ฅผ ์ƒ์„ฑํ•˜๊ณ  ์‚ฌ์šฉ์ž ์Šน์ธ ๊ธฐ๋ณธ์•ˆ์— ๋”ฐ๋ผ Approved ์ƒํƒœ๋กœ ๋“ฑ๋ก. - 2026-06-27 10:33 KST: BAT-002 verification ๋ฐ jarcaledgerqc PASS ๊ทผ๊ฑฐ๋กœ REQ-002๋ฅผ Implemented ์ƒํƒœ๋กœ ์ „ํ™˜. +- 2026-06-29 KST: dcy-003 confirmed ๋ฒ”์œ„๋กœ REQ-003์„ ์ƒ์„ฑํ•˜๊ณ  BAT-003 ๊ตฌํ˜„ยท๊ฒ€์ฆ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋ก. +- 2026-06-29 KST: dcy-004 confirmed ๋ฒ”์œ„๋กœ REQ-004๋ฅผ ์ƒ์„ฑํ•˜๊ณ  BAT-004 browser evidence ๊ตฌํ˜„ยท๊ฒ€์ฆ ๋Œ€์ƒ์œผ๋กœ ๋“ฑ๋ก. +- 2026-06-29 20:03 KST: dcy-005 confirmed ๋ฒ”์œ„๋กœ REQ-005๋ฅผ Proposed ์ƒํƒœ๋กœ ์ƒ์„ฑํ•˜๊ณ  dashboard operations hub/liquidity/monthly cashflow ์š”๊ตฌ์‚ฌํ•ญ์œผ๋กœ ๋“ฑ๋ก. +- 2026-06-29 21:08 KST: REQ-005๋ฅผ Approved๋กœ ์ „ํ™˜ํ•˜๊ณ  dcy-005 ๊ตฌํ˜„ ์ž…๋ ฅ์œผ๋กœ ํ™•์ •. +- 2026-06-30 14:42 KST: dcy-006 confirmed ๋ฒ”์œ„๋กœ REQ-006์„ Proposed ์ƒํƒœ๋กœ ์ƒ์„ฑํ•˜๊ณ  GitHub Actions ๊ธฐ๋ฐ˜ pytest/AppTest/HTTP smoke/DB safety smoke CI ์š”๊ตฌ์‚ฌํ•ญ์œผ๋กœ ๋“ฑ๋ก. +- 2026-06-30 14:50 KST: REQ-006์„ Approved๋กœ ์ „ํ™˜ํ•˜๊ณ  BAT-006 standard batch ์ž…๋ ฅ์œผ๋กœ ํ™•์ •. +- 2026-06-30 15:29 KST: BAT-006 ๊ตฌํ˜„๊ณผ jarcaledgerqc PASS_WITH_NOTES ๊ฒ€์ฆ์„ ๊ทผ๊ฑฐ๋กœ REQ-006์„ Implemented๋กœ ์ „ํ™˜. diff --git a/jarca_ledger_mcp/db.py b/jarca_ledger_mcp/db.py index dbc2d43..a145a86 100644 --- a/jarca_ledger_mcp/db.py +++ b/jarca_ledger_mcp/db.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Iterator DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3" -SCHEMA_VERSION = "6" +SCHEMA_VERSION = "8" SCHEMA_SQL = """ PRAGMA journal_mode=WAL; @@ -30,6 +30,8 @@ CREATE TABLE IF NOT EXISTS accounts ( holder_name TEXT NOT NULL DEFAULT '', last_balance INTEGER, last_synced_at TEXT NOT NULL DEFAULT '', + statement_status TEXT NOT NULL DEFAULT 'unknown', + liquidity_bucket TEXT NOT NULL DEFAULT 'other', created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -135,7 +137,7 @@ CREATE TABLE IF NOT EXISTS ledger_categories ( CREATE INDEX IF NOT EXISTS idx_ledger_categories_active_sort ON ledger_categories(active, sort_order, category, subcategory); CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_categories_unique ON ledger_categories(category, subcategory); -INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '6'); +INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '7'); """ ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { @@ -143,6 +145,8 @@ ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { "holder_name": "TEXT NOT NULL DEFAULT ''", "last_balance": "INTEGER", "last_synced_at": "TEXT NOT NULL DEFAULT ''", + "statement_status": "TEXT NOT NULL DEFAULT 'unknown'", + "liquidity_bucket": "TEXT NOT NULL DEFAULT 'other'", } TRANSACTION_ADDITIVE_COLUMNS: dict[str, str] = { diff --git a/jarca_ledger_mcp/server.py b/jarca_ledger_mcp/server.py index 438aeeb..25e9c4e 100644 --- a/jarca_ledger_mcp/server.py +++ b/jarca_ledger_mcp/server.py @@ -35,15 +35,30 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_ return service.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 = "") -> dict: +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) + 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) @mcp.tool() def list_ledger_accounts(active_only: bool = True) -> dict: """List ledger accounts and cards.""" return service.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) + +@mcp.tool() +def get_asset_liquidity_report() -> dict: + """Return asset liquidity, debt, and net worth from account liquidity buckets and last_balance.""" + return service.get_asset_liquidity_report() + +@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) + @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.""" diff --git a/jarca_ledger_mcp/service.py b/jarca_ledger_mcp/service.py index a3c934c..9ec735b 100644 --- a/jarca_ledger_mcp/service.py +++ b/jarca_ledger_mcp/service.py @@ -9,6 +9,7 @@ from datetime import date, datetime, timedelta from pathlib import Path from typing import Any from uuid import uuid4 +from zoneinfo import ZoneInfo from .db import db, init_db, resolve_db_path from .kb_bank_pdf import parse_kb_bank_pdf, transaction_preview @@ -18,6 +19,11 @@ from .seoulpay_xlsx import parse_seoulpay_xlsx, transaction_preview as seoulpay_ VALID_TRANSACTION_TYPES = {"income", "expense", "transfer", "refund", "adjustment"} VALID_ACCOUNT_TYPES = {"bank", "card", "cash", "investment", "loan", "other"} +VALID_STATEMENT_STATUSES = {"unknown", "import_supported", "manual_only", "partial_import"} +VALID_LIQUIDITY_BUCKETS = {"cash", "investment", "deposit", "real_estate", "loan", "other"} +RECONCILIATION_TARGET_STATUSES = {"manual_only", "partial_import"} +BALANCE_RECONCILIATION_TOLERANCE_KRW = 1_000 +BALANCE_RECONCILIATION_REFERENCE_RATIO = 0.001 VALID_CASHFLOW_KINDS = {"income", "expense", "transfer", "investment", "debt_payment", "exclude", "unknown"} CASHFLOW_KIND_SQL = """COALESCE(lc.cashflow_kind, CASE WHEN t.category='์ˆ˜์ž…' THEN 'income' @@ -33,6 +39,7 @@ END)""" CROSS_TRANSFER_MATCH_TAG = "cross_transfer_match:" CROSS_TRANSFER_REVIEW_TAG = "cross_transfer_review:" DEFAULT_LEDGER_BACKUP_DIR = Path.home() / ".local" / "share" / "jarca" / "backups" / "ledger" +KST = ZoneInfo("Asia/Seoul") BACKUP_RESTORE_POLICY = { "mcp_restore_exposed": False, "restore_requires_explicit_user_approval": True, @@ -79,8 +86,20 @@ def _memo_has_tag(raw_memo: str, tag_prefix: str) -> bool: return tag_prefix in memo +def _current_kst_datetime() -> datetime: + return datetime.now(KST).replace(microsecond=0) + + +def current_kst_date() -> date: + return _current_kst_datetime().date() + + +def current_kst_month() -> str: + return current_kst_date().strftime("%Y-%m") + + def now_iso() -> str: - return datetime.now().replace(microsecond=0).isoformat() + return _current_kst_datetime().isoformat() def new_id(prefix: str) -> str: return f"{prefix}_{uuid4().hex[:12]}" @@ -106,17 +125,51 @@ def row_to_dict(row: Any) -> dict[str, Any]: def normalize_date(value: str | None, default_today: bool = False) -> str: if not value: if default_today: - return date.today().isoformat() + return current_kst_date().isoformat() raise ValueError("date is required") return date.fromisoformat(value[:10]).isoformat() def normalize_month(value: str | None) -> str: if not value: - return date.today().strftime("%Y-%m") + return current_kst_month() if re.fullmatch(r"\d{4}-\d{2}", value): return value return date.fromisoformat(value[:10]).strftime("%Y-%m") + +def normalize_statement_status(value: str | None) -> str: + status = (value or "unknown").strip() or "unknown" + if status not in VALID_STATEMENT_STATUSES: + raise ValueError(f"statement_status must be one of {sorted(VALID_STATEMENT_STATUSES)}") + return status + + +def normalize_liquidity_bucket(value: str | None) -> str: + bucket = (value or "other").strip() or "other" + if bucket not in VALID_LIQUIDITY_BUCKETS: + raise ValueError(f"liquidity_bucket must be one of {sorted(VALID_LIQUIDITY_BUCKETS)}") + return bucket + + +def normalize_snapshot_at(value: str | None, default_now: bool = True) -> str: + if not value: + if default_now: + return now_iso() + raise ValueError("baseline_at is required") + raw = value.strip() + if re.fullmatch(r"\d{4}-\d{2}", raw): + month_start = date.fromisoformat(raw + "-01") + if month_start.month == 12: + next_month = date(month_start.year + 1, 1, 1) + else: + next_month = date(month_start.year, month_start.month + 1, 1) + last_day = next_month - timedelta(days=1) + return f"{last_day.isoformat()}T23:59:00+09:00" + if "T" not in raw and len(raw) == 10: + return f"{normalize_date(raw)}T23:59:00+09:00" + datetime.fromisoformat(raw.replace("Z", "+00:00")) + return raw + def normalize_tags(tags: Any) -> str: if tags is None or tags == "": return "[]" @@ -178,7 +231,7 @@ def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]: def _safe_monthly_aggregates(conn: sqlite3.Connection) -> dict[str, Any]: - current_month = date.today().strftime("%Y-%m") + current_month = current_kst_month() start, next_month = _month_range(current_month) rows = conn.execute( f"""SELECT {CASHFLOW_KIND_SQL} AS cashflow_kind, SUM(t.amount) AS total, COUNT(*) AS count @@ -236,10 +289,13 @@ def _backup_file_metadata(path: Path, include_hash: bool = False) -> dict[str, A def _apply_backup_retention(backup_dir: Path, retention_days: int = 30, retention_count: int = 20) -> list[str]: backups = sorted(backup_dir.glob("*.sqlite3"), key=lambda p: p.stat().st_mtime, reverse=True) keep_count = max(1, int(retention_count)) - cutoff = datetime.now() - timedelta(days=max(0, int(retention_days))) + # Retention comparison needs sub-second precision for retention_days=0: + # the timestamp used for backup names is second-truncated, but filesystem mtimes + # can identify files created just before retention runs as older than "now". + cutoff = datetime.now(KST) - timedelta(days=max(0, int(retention_days))) pruned: list[str] = [] for idx, path in enumerate(backups): - mtime = datetime.fromtimestamp(path.stat().st_mtime) + mtime = datetime.fromtimestamp(path.stat().st_mtime, tz=KST) should_keep = idx < keep_count or mtime >= cutoff if should_keep: continue @@ -255,7 +311,7 @@ def create_ledger_backup(db_path: str | None = None, backup_dir: str | None = No return fail("not_found", f"ledger DB not found: {source_path}", db_path=str(source_path), exists=False) backup_root = _resolve_backup_dir(backup_dir) backup_root.mkdir(parents=True, exist_ok=True) - timestamp = datetime.now().strftime("%Y%m%d%H%M%S%f") + timestamp = _current_kst_datetime().strftime("%Y%m%d%H%M%S%f") backup_path = backup_root / f"ledger_{timestamp}.sqlite3" try: # Use SQLite's online backup API so WAL-mode databases are copied consistently. @@ -294,15 +350,20 @@ def get_ledger_backup_status(file_path: str, include_hash: bool = True, include_ return fail("not_found", f"backup file not found: {path}", path=str(path), exists=False) return ok(backup=_ledger_db_status_for_path(path, include_hash=include_hash, include_integrity=include_integrity), restore_policy=BACKUP_RESTORE_POLICY) -def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", db_path: str | None = None) -> dict[str, Any]: +def create_ledger_account(name: str, type: str = "bank", institution: str = "", currency: str = "KRW", active: bool = True, memo: str = "", account_number_masked: str = "", holder_name: str = "", last_balance: int | None = None, last_synced_at: str = "", statement_status: str = "unknown", liquidity_bucket: str = "other", db_path: str | None = None) -> dict[str, Any]: if not name.strip(): return fail("validation_error", "account name is required") if type not in VALID_ACCOUNT_TYPES: return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") + try: + statement_status_norm = normalize_statement_status(statement_status) + liquidity_bucket_norm = normalize_liquidity_bucket(liquidity_bucket) + except Exception as e: + return fail("validation_error", str(e)) ts = now_iso(); account_id = new_id("acct") with db(db_path) as conn: - conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,created_at,updated_at) - VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, ts, ts)) + conn.execute("""INSERT INTO accounts(id,name,type,institution,currency,active,memo,account_number_masked,holder_name,last_balance,last_synced_at,statement_status,liquidity_bucket,created_at,updated_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (account_id, name.strip(), type, institution, currency, int(active), memo, account_number_masked, holder_name, last_balance, last_synced_at, statement_status_norm, liquidity_bucket_norm, ts, ts)) row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() return ok(account=row_to_dict(row)) @@ -312,6 +373,110 @@ def list_ledger_accounts(active_only: bool = True, db_path: str | None = None) - rows = conn.execute(sql).fetchall() return ok(accounts=[row_to_dict(r) for r in rows], count=len(rows)) + +def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]: + """Summarize assets/liabilities from account-level liquidity_bucket and last_balance only.""" + with db(db_path) as conn: + rows = conn.execute("SELECT * FROM accounts WHERE active=1 ORDER BY name").fetchall() + accounts = [row_to_dict(r) for r in rows] + bucket_order = ["cash", "investment", "deposit", "real_estate", "other", "loan"] + bucket_totals = {bucket: 0 for bucket in bucket_order} + bucket_counts = {bucket: 0 for bucket in bucket_order} + missing_balance_accounts: list[dict[str, Any]] = [] + invalid_bucket_accounts: list[dict[str, Any]] = [] + source_accounts: list[dict[str, Any]] = [] + + for account in accounts: + raw_bucket = str(account.get("liquidity_bucket") or "other") + try: + bucket = normalize_liquidity_bucket(raw_bucket) + except ValueError: + bucket = "other" + invalid_bucket_accounts.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": raw_bucket}) + + balance = account.get("last_balance") + if balance is None: + missing_balance_accounts.append({"id": account.get("id", ""), "name": account.get("name", ""), "liquidity_bucket": bucket}) + continue + + amount = int(balance) + bucket_counts[bucket] += 1 + bucket_totals[bucket] += amount + source_accounts.append( + { + "id": account.get("id", ""), + "name": account.get("name", ""), + "type": account.get("type", ""), + "liquidity_bucket": bucket, + "last_balance": amount, + "last_synced_at": account.get("last_synced_at", ""), + "memo": account.get("memo", ""), + } + ) + + asset_buckets = ["cash", "investment", "deposit", "real_estate", "other"] + total_assets = sum(bucket_totals[bucket] for bucket in asset_buckets) + total_liabilities = abs(bucket_totals["loan"]) + net_worth = total_assets - total_liabilities + buckets = [] + for bucket in bucket_order: + amount = bucket_totals[bucket] + denominator = total_liabilities if bucket == "loan" else total_assets + buckets.append( + { + "liquidity_bucket": bucket, + "amount": amount, + "account_count": bucket_counts[bucket], + "asset_share": round(amount / total_assets, 4) if bucket != "loan" and total_assets else None, + "liability_share": round(abs(amount) / total_liabilities, 4) if bucket == "loan" and total_liabilities else None, + "share": round(abs(amount) / denominator, 4) if denominator else None, + } + ) + return ok( + buckets=buckets, + accounts=source_accounts, + total_assets=total_assets, + total_liabilities=total_liabilities, + net_worth=net_worth, + missing_balance_accounts=missing_balance_accounts, + invalid_bucket_accounts=invalid_bucket_accounts, + source="accounts.last_balance", + ) + + +def update_ledger_account(account_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]: + allowed = {"name", "type", "institution", "currency", "active", "memo", "account_number_masked", "holder_name", "last_balance", "last_synced_at", "statement_status", "liquidity_bucket"} + updates: list[str] = [] + params: list[Any] = [] + for k, v in fields.items(): + if k not in allowed or v is None: + continue + try: + if k == "type" and v not in VALID_ACCOUNT_TYPES: + return fail("validation_error", f"type must be one of {sorted(VALID_ACCOUNT_TYPES)}") + if k == "statement_status": + v = normalize_statement_status(str(v)) + if k == "liquidity_bucket": + v = normalize_liquidity_bucket(str(v)) + if k == "active": + v = int(bool(v)) + if k == "last_balance" and v != "": + v = int(v) + except Exception as e: + return fail("validation_error", str(e)) + updates.append(f"{k}=?") + params.append(v) + if not updates: + return fail("validation_error", "no fields to update") + updates.append("updated_at=?") + params.extend([now_iso(), account_id]) + with db(db_path) as conn: + cur = conn.execute(f"UPDATE accounts SET {', '.join(updates)} WHERE id=?", params) + if cur.rowcount == 0: + return fail("not_found", "account not found") + row = conn.execute("SELECT * FROM accounts WHERE id=?", (account_id,)).fetchone() + return ok(account=row_to_dict(row)) + def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int | float = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: Any = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True, transaction_datetime: str = "", counterparty: str = "", balance_after: int | None = None, branch: str = "", raw_summary: str = "", raw_payload: str = "", db_path: str | None = None) -> dict[str, Any]: try: tx_date = normalize_date(transaction_date, default_today=True) @@ -355,6 +520,97 @@ def list_ledger_transactions(start_date: str = "", end_date: str = "", type: str rows = conn.execute(sql, params).fetchall() return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) + +def _transaction_signed_amount(tx_type: str, amount: int) -> int: + if tx_type in {"income", "refund", "adjustment"}: + return int(amount) + if tx_type in {"expense", "transfer"}: + return -int(amount) + return 0 + + +def _account_balance_row(conn: sqlite3.Connection, account_id: str, baseline_at: str) -> dict[str, Any]: + date_cutoff = normalize_date(baseline_at[:10]) + rows = conn.execute( + """SELECT type, amount, balance_after, transaction_date, transaction_datetime, created_at + FROM transactions + WHERE status='active' AND account_id=? AND transaction_date<=? + ORDER BY transaction_date ASC, transaction_datetime ASC, created_at ASC""", + (account_id, date_cutoff), + ).fetchall() + signed_total = sum(_transaction_signed_amount(row["type"], int(row["amount"] or 0)) for row in rows) + latest_balance_row = next((row for row in reversed(rows) if row["balance_after"] is not None), None) + if latest_balance_row is not None: + calculated_balance = int(latest_balance_row["balance_after"]) + basis = "latest_transaction_balance_after" + basis_at = latest_balance_row["transaction_datetime"] or latest_balance_row["transaction_date"] + else: + calculated_balance = int(signed_total) + basis = "net_transaction_delta_without_opening_balance" + basis_at = baseline_at + return { + "calculated_balance": calculated_balance, + "calculation_basis": basis, + "calculation_basis_at": basis_at, + "transaction_count": len(rows), + "signed_transaction_total": int(signed_total), + } + + +def get_balance_reconciliation_report(account_id: str = "", baseline_balance: int | None = None, baseline_at: str = "", source_note: str = "", memo: str = "", tolerance: int = BALANCE_RECONCILIATION_TOLERANCE_KRW, db_path: str | None = None) -> dict[str, Any]: + """Preview manual/partial-import account balance reconciliation without mutating transactions.""" + try: + baseline_at_norm = normalize_snapshot_at(baseline_at.strip(), default_now=False) if baseline_at and baseline_at.strip() else None + tolerance_int = int(tolerance) + if tolerance_int < 0: + raise ValueError("tolerance must be non-negative") + baseline_balance_int = int(baseline_balance) if baseline_balance is not None else None + except Exception as e: + return fail("validation_error", str(e)) + with db(db_path) as conn: + clauses = ["active=1"] + params: list[Any] = [] + if account_id: + clauses.append("id=?") + params.append(account_id) + else: + placeholders = ",".join("?" for _ in RECONCILIATION_TARGET_STATUSES) + clauses.append(f"statement_status IN ({placeholders})") + params.extend(sorted(RECONCILIATION_TARGET_STATUSES)) + accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall() + items: list[dict[str, Any]] = [] + for account in accounts: + effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"] + effective_at = baseline_at_norm or account["last_synced_at"] or now_iso() + calc = _account_balance_row(conn, account["id"], effective_at) + if effective_baseline is None: + difference = None + ratio = None + status = "BASELINE_REQUIRED" + else: + difference = int(effective_baseline) - int(calc["calculated_balance"]) + ratio = abs(difference) / abs(int(effective_baseline)) if int(effective_baseline) else None + status = "OK" if abs(difference) <= tolerance_int else "REVIEW_NEEDED" + items.append({ + "account_id": account["id"], + "account_name": account["name"], + "statement_status": account["statement_status"], + "is_reconciliation_target": account["statement_status"] in RECONCILIATION_TARGET_STATUSES, + "baseline_balance": effective_baseline, + "baseline_at": effective_at, + "calculated_balance": calc["calculated_balance"], + "difference": difference, + "tolerance": tolerance_int, + "reference_ratio_threshold": BALANCE_RECONCILIATION_REFERENCE_RATIO, + "difference_ratio": ratio, + "status": status, + "recommended_decisions": ["๊ณ„์ขŒ์ด๋™", "์‹ค์ง€์ถœ", "์‹ค์ˆ˜์ž…", "ํˆฌ์ž์ด๋™", "๋ถ„์„์ œ์™ธ", "ํ™•์ธํ•„์š”"] if status == "REVIEW_NEEDED" else [], + "source_note": source_note, + "memo": memo, + **calc, + }) + return ok(items=items, count=len(items), tolerance=tolerance_int, baseline_at=baseline_at_norm, routing="preview_only_manual_confirmation_required") + def update_ledger_transaction(transaction_id: str, db_path: str | None = None, **fields: Any) -> dict[str, Any]: allowed = {"transaction_date","posted_date","type","amount","currency","category","subcategory","merchant","description","account_id","payment_method","person","project","tags","is_fixed","source","external_id","memo","status","transaction_datetime","counterparty","balance_after","branch","raw_summary","raw_payload"} updates=[]; params=[] @@ -1057,8 +1313,9 @@ def find_self_transfer_candidates(transaction_id: str, limit: int = 20, db_path: source_date = normalize_date(source.get("transaction_date"), default_today=False) source_dt = _parse_tx_datetime(source.get("transaction_datetime")) source_holder = _normalize_counterparty_for_match(source.get("holder_name", "")) - window_start = (date.fromisoformat(source_date) if source_date else date.today()).toordinal() - 1 - window_end = (date.fromisoformat(source_date) if source_date else date.today()).toordinal() + 1 + anchor_date = date.fromisoformat(source_date) if source_date else current_kst_date() + window_start = anchor_date.toordinal() - 1 + window_end = anchor_date.toordinal() + 1 candidate_rows = conn.execute( """ SELECT t.*, a.name AS account_name, a.institution, a.holder_name @@ -2228,6 +2485,96 @@ def preview_categorization(limit: int = 1000, only_uncategorized: bool = True, d ) + +def _category_rule_match_rows(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]: + if not rule_id: + return fail("validation_error", "rule_id is required") + row_limit = max(1, min(int(limit), 50000)) + with db(db_path) as conn: + rule_row = conn.execute("SELECT * FROM category_rules WHERE id=?", (rule_id,)).fetchone() + if not rule_row: + return fail("not_found", f"category rule not found: {rule_id}") + rule = row_to_dict(rule_row) + rows = [row_to_dict(r) for r in conn.execute( + """SELECT * FROM transactions + WHERE status='active' + ORDER BY transaction_datetime DESC, transaction_date DESC, created_at DESC + LIMIT ?""", + (row_limit,), + ).fetchall()] + + matched = [] + uncategorized = [] + for tx in rows: + if not _rule_matches(rule, tx): + continue + item = { + "transaction_id": tx["id"], + "transaction_date": tx.get("transaction_date", ""), + "transaction_datetime": tx.get("transaction_datetime", ""), + "type": tx.get("type", ""), + "amount": tx.get("amount"), + "merchant": tx.get("merchant", ""), + "raw_summary": tx.get("raw_summary", ""), + "counterparty": tx.get("counterparty", ""), + "current_category": tx.get("category", "") or "", + "current_subcategory": tx.get("subcategory", "") or "", + "new_category": rule.get("category", ""), + "new_subcategory": rule.get("subcategory", ""), + "rule_id": rule_id, + "rule_pattern": rule.get("pattern", ""), + } + matched.append(item) + if not (tx.get("category") or ""): + uncategorized.append(item) + return ok(rule=rule, scanned=len(rows), matched=matched, uncategorized=uncategorized) + + +def preview_category_rule_matches(rule_id: str, limit: int = 10000, sample_limit: int = 3, db_path: str | None = None) -> dict[str, Any]: + data = _category_rule_match_rows(rule_id, limit, db_path) + if not data.get("success"): + return data + sample_n = max(1, min(int(sample_limit), 20)) + return ok( + rule=data["rule"], + scanned=data["scanned"], + matched_count=len(data["matched"]), + uncategorized_matched_count=len(data["uncategorized"]), + sample=data["matched"][:sample_n], + ) + + +def apply_category_rule(rule_id: str, limit: int = 10000, db_path: str | None = None) -> dict[str, Any]: + data = _category_rule_match_rows(rule_id, limit, db_path) + if not data.get("success"): + return data + rule = data["rule"] + ts = now_iso() + applied = 0 + with db(db_path) as conn: + for m in data["uncategorized"]: + conn.execute( + """UPDATE transactions + SET category=?, subcategory=?, + person=CASE WHEN ?<>'' THEN ? ELSE person END, + is_fixed=CASE WHEN ? IS NULL THEN is_fixed ELSE ? END, + updated_at=? + WHERE id=? AND status='active' AND COALESCE(category,'')=''""", + ( + rule.get("category", ""), rule.get("subcategory", ""), + rule.get("person", ""), rule.get("person", ""), + rule.get("is_fixed"), rule.get("is_fixed"), + ts, m["transaction_id"], + ), + ) + applied += 1 + return ok( + applied_count=applied, + matched_count=len(data["matched"]), + uncategorized_matched_count=len(data["uncategorized"]), + rule=rule, + ) + def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = True, db_path: str | None = None) -> dict[str, Any]: data = _categorization_preview_data(limit, only_uncategorized, db_path) preview = ok( @@ -2249,18 +2596,218 @@ def apply_categorization_rules(limit: int = 1000, only_uncategorized: bool = Tru def list_uncategorized_transactions(limit: int = 100, min_amount: int = 0, db_path: str | None = None) -> dict[str, Any]: with db(db_path) as conn: - rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND category='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall() + rows=conn.execute("""SELECT * FROM transactions WHERE status='active' AND COALESCE(category,'')='' AND amount>=? ORDER BY amount DESC, transaction_datetime DESC LIMIT ?""", (int(min_amount), max(1,min(int(limit),1000)))).fetchall() return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows)) def get_uncategorized_merchant_summary(limit: int = 50, db_path: str | None = None) -> dict[str, Any]: with db(db_path) as conn: rows=conn.execute("""SELECT merchant, raw_summary, type, COUNT(*) count, SUM(amount) total_amount, MAX(amount) max_amount - FROM transactions WHERE status='active' AND category='' + FROM transactions WHERE status='active' AND COALESCE(category,'')='' GROUP BY merchant, raw_summary, type ORDER BY total_amount DESC LIMIT ?""", (max(1,min(int(limit),500)),)).fetchall() return ok(items=[row_to_dict(r) for r in rows], count=len(rows)) +def _recommend_category_for_transaction(tx: dict[str, Any], rules: list[dict[str, Any]], history: dict[tuple[str, str], list[dict[str, Any]]]) -> dict[str, Any]: + """Recommend a category for one transaction using deterministic, explainable signals.""" + for rule in rules: + try: + if _rule_matches(rule, tx): + return { + "category": rule.get("category", ""), + "subcategory": rule.get("subcategory", ""), + "confidence": round(float(rule.get("confidence") or 1.0), 2), + "source": "rule", + "reason": f"ํ™œ์„ฑ ๊ทœ์น™ `{rule.get('pattern', '')}` ๋งค์นญ", + "rule_id": rule.get("id", ""), + } + except re.error: + continue + + key = (tx.get("merchant", "") or "", tx.get("type", "") or "") + candidates = history.get(key, []) + if candidates: + best = candidates[0] + count = int(best.get("count") or 0) + confidence = min(0.9, 0.55 + min(count, 5) * 0.07) + return { + "category": best.get("category", ""), + "subcategory": best.get("subcategory", ""), + "confidence": round(confidence, 2), + "source": "history", + "reason": f"๊ฐ™์€ ์‚ฌ์šฉ์ฒ˜์˜ ๊ธฐ์กด ๋ถ„๋ฅ˜ {count}๊ฑด", + "rule_id": "", + } + + return {"category": "", "subcategory": "", "confidence": 0.0, "source": "none", "reason": "์ถ”์ฒœ ๊ทผ๊ฑฐ ์—†์Œ", "rule_id": ""} + + +def get_uncategorized_workbench( + limit: int = 100, + min_amount: int = 0, + sort_by: str = "amount_desc", + db_path: str | None = None, +) -> dict[str, Any]: + row_limit = max(1, min(int(limit), 1000)) + minimum_amount = int(min_amount) + order_by_map = { + "amount_desc": "amount DESC, transaction_datetime DESC, transaction_date DESC, created_at DESC", + "date_desc": "transaction_datetime DESC, transaction_date DESC, created_at DESC, amount DESC", + } + order_by = order_by_map.get(sort_by, order_by_map["amount_desc"]) + with db(db_path) as conn: + tx_rows = [row_to_dict(r) for r in conn.execute( + f"""SELECT id, transaction_date, transaction_datetime, type, amount, currency, merchant, raw_summary, + counterparty, account_id, payment_method, person, is_fixed, source, memo, created_at + FROM transactions + WHERE status='active' AND COALESCE(category,'')='' AND amount>=? + ORDER BY {order_by} + LIMIT ?""", + (minimum_amount, row_limit), + ).fetchall()] + total_row = conn.execute( + """SELECT COUNT(*) AS count, COALESCE(SUM(amount),0) AS total_amount, + COUNT(DISTINCT COALESCE(NULLIF(merchant,''), raw_summary, counterparty, '')) AS merchant_group_count + FROM transactions + WHERE status='active' AND COALESCE(category,'')='' AND amount>=?""", + (minimum_amount,), + ).fetchone() + history_rows = [row_to_dict(r) for r in conn.execute( + """SELECT merchant, type, category, subcategory, COUNT(*) AS count, + SUM(amount) AS total_amount, MAX(transaction_date) AS latest_date + FROM transactions + WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>'' + GROUP BY merchant, type, category, subcategory + ORDER BY merchant, type, count DESC, total_amount DESC""" + ).fetchall()] + rules = [row_to_dict(r) for r in conn.execute( + "SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id" + ).fetchall()] + + history: dict[tuple[str, str], list[dict[str, Any]]] = {} + for row in history_rows: + history.setdefault((row.get("merchant", "") or "", row.get("type", "") or ""), []).append(row) + + txs: list[dict[str, Any]] = [] + groups: dict[tuple[str, str, str], dict[str, Any]] = {} + for tx in tx_rows: + rec = _recommend_category_for_transaction(tx, rules, history) + public_tx = { + **tx, + "recommendation": rec, + "recommended_category": rec.get("category", ""), + "recommended_subcategory": rec.get("subcategory", ""), + "recommendation_confidence": rec.get("confidence", 0.0), + "recommendation_reason": rec.get("reason", ""), + "recommendation_source": rec.get("source", "none"), + } + txs.append(public_tx) + gkey = (tx.get("merchant", "") or "", tx.get("raw_summary", "") or "", tx.get("type", "") or "") + group = groups.setdefault(gkey, { + "merchant": gkey[0], + "raw_summary": gkey[1], + "type": gkey[2], + "count": 0, + "total_amount": 0, + "max_amount": 0, + "latest_date": "", + "sample_transaction_id": tx.get("id", ""), + "recommendation": rec, + }) + group["count"] += 1 + group["total_amount"] += int(tx.get("amount") or 0) + group["max_amount"] = max(int(group["max_amount"] or 0), int(tx.get("amount") or 0)) + date_value = tx.get("transaction_datetime") or tx.get("transaction_date") or "" + if date_value > (group.get("latest_date") or ""): + group["latest_date"] = date_value + if not group["recommendation"].get("category") and rec.get("category"): + group["recommendation"] = rec + + group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True) + recommendable_count = sum(1 for tx in txs if tx.get("recommended_category")) + repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2) + total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0} + return ok( + summary={ + "uncategorized_count": int(total.get("count") or 0), + "uncategorized_total_amount": int(total.get("total_amount") or 0), + "merchant_group_count": int(total.get("merchant_group_count") or 0), + "recommendable_count": recommendable_count, + "repeated_group_count": repeated_group_count, + }, + groups=group_items, + transactions=txs, + count=len(txs), + ) + + +def apply_uncategorized_recommendation(transaction_id: str, db_path: str | None = None) -> dict[str, Any]: + if not transaction_id: + return fail("validation_error", "transaction_id is required") + with db(db_path) as conn: + row = conn.execute("SELECT * FROM transactions WHERE id=? AND status='active'", (transaction_id,)).fetchone() + if not row: + return fail("not_found", "transaction not found") + tx = row_to_dict(row) + if tx.get("category"): + return fail("validation_error", "transaction is already categorized") + rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()] + history_rows = [row_to_dict(r) for r in conn.execute( + """SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount + FROM transactions + WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>'' + GROUP BY merchant, type, category, subcategory + ORDER BY count DESC, total_amount DESC""" + ).fetchall()] + history: dict[tuple[str, str], list[dict[str, Any]]] = {} + for h in history_rows: + history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h) + rec = _recommend_category_for_transaction(tx, rules, history) + if not rec.get("category"): + return fail("no_recommendation", "์ถ”์ฒœ ๋ถ„๋ฅ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + cur = conn.execute( + """UPDATE transactions + SET category=?, subcategory=?, updated_at=? + WHERE id=? AND status='active' AND COALESCE(category,'')=''""", + (rec.get("category", ""), rec.get("subcategory", ""), now_iso(), transaction_id), + ) + return ok(applied_count=cur.rowcount, transaction_id=transaction_id, recommendation=rec) + + +def apply_uncategorized_group_recommendation(merchant: str, raw_summary: str = "", transaction_type: str = "", db_path: str | None = None) -> dict[str, Any]: + with db(db_path) as conn: + sample = conn.execute( + """SELECT * FROM transactions + WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=? + ORDER BY amount DESC, transaction_datetime DESC, transaction_date DESC LIMIT 1""", + (merchant, raw_summary, transaction_type), + ).fetchone() + if not sample: + return fail("not_found", "uncategorized group not found") + tx = row_to_dict(sample) + rules = [row_to_dict(r) for r in conn.execute("SELECT * FROM category_rules WHERE active=1 ORDER BY priority, id").fetchall()] + history_rows = [row_to_dict(r) for r in conn.execute( + """SELECT merchant, type, category, subcategory, COUNT(*) AS count, SUM(amount) AS total_amount + FROM transactions + WHERE status='active' AND COALESCE(category,'')<>'' AND COALESCE(merchant,'')<>'' + GROUP BY merchant, type, category, subcategory + ORDER BY count DESC, total_amount DESC""" + ).fetchall()] + history: dict[tuple[str, str], list[dict[str, Any]]] = {} + for h in history_rows: + history.setdefault((h.get("merchant", "") or "", h.get("type", "") or ""), []).append(h) + rec = _recommend_category_for_transaction(tx, rules, history) + if not rec.get("category"): + return fail("no_recommendation", "์ถ”์ฒœ ๋ถ„๋ฅ˜๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค") + cur = conn.execute( + """UPDATE transactions + SET category=?, subcategory=?, updated_at=? + WHERE status='active' AND COALESCE(category,'')='' AND merchant=? AND raw_summary=? AND type=?""", + (rec.get("category", ""), rec.get("subcategory", ""), now_iso(), merchant, raw_summary, transaction_type), + ) + return ok(applied_count=cur.rowcount, merchant=merchant, raw_summary=raw_summary, type=transaction_type, recommendation=rec) + + DEFAULT_LEDGER_CATEGORIES: list[dict[str, Any]] = [ {"category": "์ˆ˜์ž…", "subcategory": "๊ธ‰์—ฌ", "sort_order": 10}, {"category": "์ˆ˜์ž…", "subcategory": "์ด์ž", "sort_order": 11}, diff --git a/scripts/ci_dashboard_http_smoke.py b/scripts/ci_dashboard_http_smoke.py new file mode 100755 index 0000000..b16b395 --- /dev/null +++ b/scripts/ci_dashboard_http_smoke.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""CI wrapper for the Streamlit dashboard HTTP smoke gate. + +Creates a synthetic fixture DB, starts Streamlit against it, waits for HTTP 200, +and always attempts process cleanup. Output is intentionally status-only. +""" +from __future__ import annotations + +import argparse +import json +import os +import signal +import subprocess +import sys +import tempfile +import time +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from scripts.ci_fixture_db import create_ci_fixture_db + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run Streamlit dashboard HTTP smoke against a synthetic CI fixture DB.") + parser.add_argument("--host", default="127.0.0.1", help="Loopback host to bind") + parser.add_argument("--port", type=int, default=18501, help="Deterministic smoke port") + parser.add_argument("--timeout", type=int, default=45, help="Readiness timeout in seconds") + parser.add_argument("--page", default="์š”์•ฝ", help="Dashboard page query param to request") + return parser.parse_args() + + +def wait_for_http(url: str, timeout_seconds: int) -> dict[str, Any]: + deadline = time.monotonic() + timeout_seconds + attempts = 0 + last_error = "not_started" + while time.monotonic() < deadline: + attempts += 1 + try: + with urllib.request.urlopen(url, timeout=2) as response: + if response.status == 200: + return {"ready": True, "status": response.status, "attempts": attempts} + last_error = f"HTTP {response.status}" + except Exception as exc: # noqa: BLE001 - compact readiness diagnostic + last_error = exc.__class__.__name__ + time.sleep(1) + return {"ready": False, "attempts": attempts, "last_error": last_error} + + +def terminate_process(proc: subprocess.Popen[str], timeout: int = 10) -> dict[str, Any]: + if proc.poll() is not None: + return {"cleanup": "already_exited", "returncode": proc.returncode} + try: + proc.send_signal(signal.SIGTERM) + proc.wait(timeout=timeout) + return {"cleanup": "terminated", "returncode": proc.returncode} + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=timeout) + return {"cleanup": "killed", "returncode": proc.returncode} + + +def main() -> int: + args = parse_args() + smoke_url = f"http://{args.host}:{args.port}/?page={urllib.parse.quote(args.page)}" + proc: subprocess.Popen[str] | None = None + with tempfile.TemporaryDirectory(prefix="jarca-ledger-ci-http-") as tmp: + tmp_path = Path(tmp) + fixture_path = tmp_path / "fixture.sqlite3" + try: + fixture = create_ci_fixture_db(fixture_path) + env = os.environ.copy() + env["JARCA_LEDGER_DB_PATH"] = str(fixture_path) + env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false" + cmd = [ + sys.executable, + "-m", + "streamlit", + "run", + "dashboard/app.py", + "--server.address", + args.host, + "--server.port", + str(args.port), + "--server.headless", + "true", + "--browser.gatherUsageStats", + "false", + ] + proc = subprocess.Popen( + cmd, + cwd=Path(__file__).resolve().parents[1], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + ) + readiness = wait_for_http(smoke_url, args.timeout) + if not readiness["ready"]: + cleanup = terminate_process(proc) + print(json.dumps({"success": False, "gate": "dashboard-http-smoke", "fixture": "synthetic", "readiness": readiness, "cleanup": cleanup}, ensure_ascii=False)) + return 10 + cleanup = terminate_process(proc) + success = cleanup.get("cleanup") in {"terminated", "already_exited", "killed"} + print(json.dumps({ + "success": success, + "gate": "dashboard-http-smoke", + "fixture": "synthetic", + "fixture_integrity": fixture.get("integrity_check"), + "http_status": readiness.get("status"), + "attempts": readiness.get("attempts"), + "cleanup": cleanup.get("cleanup"), + }, ensure_ascii=False, indent=2)) + return 0 if success else 11 + except Exception as exc: # noqa: BLE001 - deterministic wrapper failure + cleanup = terminate_process(proc) if proc is not None else {"cleanup": "not_started"} + print(json.dumps({"success": False, "gate": "dashboard-http-smoke", "error_code": "wrapper_error", "error": exc.__class__.__name__, "cleanup": cleanup.get("cleanup")}, ensure_ascii=False)) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci_db_safety_smoke.py b/scripts/ci_db_safety_smoke.py new file mode 100755 index 0000000..d387173 --- /dev/null +++ b/scripts/ci_db_safety_smoke.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""CI wrapper for Jarca Ledger DB restore/safety smoke. + +The wrapper builds a synthetic fixture DB under a temporary directory, copies it +through the existing restore smoke script, and emits only status-level evidence. +""" +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO_ROOT)) + +from jarca_ledger_mcp.db import DEFAULT_DB_PATH +from scripts.ci_fixture_db import create_ci_fixture_db + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Run DB safety smoke with synthetic temp DBs only.") + parser.add_argument("--timeout", type=int, default=45, help="Subprocess timeout in seconds") + return parser.parse_args() + + +def parse_json_output(output: str) -> dict[str, Any]: + try: + return json.loads(output) + except json.JSONDecodeError: + return {"success": False, "error_code": "invalid_json_output"} + + +def main() -> int: + args = parse_args() + repo = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory(prefix="jarca-ledger-ci-db-") as tmp: + tmp_path = Path(tmp) + source = tmp_path / "fixture.sqlite3" + backup = tmp_path / "backup.sqlite3" + target = tmp_path / "restored.sqlite3" + try: + fixture = create_ci_fixture_db(source) + backup.write_bytes(source.read_bytes()) + if any(path.resolve() == DEFAULT_DB_PATH.resolve() for path in [source, backup, target]): + print(json.dumps({"success": False, "gate": "db-safety-smoke", "error_code": "operating_db_path_refused"}, ensure_ascii=False)) + return 20 + env = os.environ.copy() + env["JARCA_LEDGER_DB_PATH"] = str(source) + cmd = [sys.executable, "scripts/ledger_restore_smoke.py", "--backup", str(backup), "--target", str(target)] + run = subprocess.run(cmd, cwd=repo, env=env, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=args.timeout, check=False) + except subprocess.TimeoutExpired: + print(json.dumps({"success": False, "gate": "db-safety-smoke", "error_code": "timeout", "timeout_seconds": args.timeout}, ensure_ascii=False)) + return 21 + except Exception as exc: # noqa: BLE001 - deterministic wrapper failure + print(json.dumps({"success": False, "gate": "db-safety-smoke", "error_code": "wrapper_error", "error": exc.__class__.__name__}, ensure_ascii=False)) + return 1 + + smoke = parse_json_output(run.stdout) + success = run.returncode == 0 and bool(smoke.get("success")) and target.exists() + result = { + "success": success, + "gate": "db-safety-smoke", + "fixture": "synthetic", + "fixture_integrity": fixture.get("integrity_check"), + "restore_smoke_exit_code": run.returncode, + "restored_exists": target.exists(), + "restored_integrity": smoke.get("target", {}).get("integrity_check"), + "health_success": smoke.get("health_check", {}).get("success"), + "operating_db_used": False, + } + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 if success else 22 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/ci_fixture_db.py b/scripts/ci_fixture_db.py new file mode 100755 index 0000000..6844402 --- /dev/null +++ b/scripts/ci_fixture_db.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Synthetic fixture DB builder for CI/AppTest/smoke checks. + +The fixture intentionally contains only synthetic identifiers and summary data. +It must never point at the operating ledger database. +""" +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Any + +from jarca_ledger_mcp import service +from jarca_ledger_mcp.db import DEFAULT_DB_PATH, init_db + + +def assert_ci_fixture_db_path(path: Path) -> Path: + resolved = path.expanduser().resolve() + if resolved == DEFAULT_DB_PATH.resolve(): + raise ValueError("refusing to use the operating ledger DB as a CI fixture") + if resolved.exists() and resolved.is_dir(): + raise ValueError(f"fixture DB path is a directory: {resolved}") + allowed_names = {"fixture.sqlite3", "ci-ledger-fixture.sqlite3", "ledger-fixture.sqlite3"} + if resolved.name not in allowed_names: + raise ValueError(f"CI fixture DB filename must be one of {sorted(allowed_names)}: {resolved.name}") + return resolved + + +def create_ci_fixture_db(db_path: str | Path) -> dict[str, Any]: + path = assert_ci_fixture_db_path(Path(db_path)) + path.parent.mkdir(parents=True, exist_ok=True) + if path.exists(): + path.unlink() + init_db(str(path)) + service.seed_default_ledger_categories(db_path=str(path)) + service.seed_default_category_rules(db_path=str(path)) + + account = service.create_ledger_account( + "CI Synthetic Checking", + institution="CI Synthetic Bank", + holder_name="CI Synthetic Holder", + last_balance=1_250_000, + last_synced_at="2026-06-30T23:59:00+09:00", + statement_status="partial_import", + liquidity_bucket="cash", + memo="ci synthetic fixture account", + db_path=str(path), + ) + if not account.get("success"): + raise RuntimeError(f"fixture account seed failed: {account.get('error_code')}") + account_id = account["account"]["id"] + + for account_spec in [ + dict(name="CI Synthetic Investment", type="investment", institution="CI Synthetic Securities", last_balance=5_000_000, liquidity_bucket="investment"), + dict(name="CI Synthetic Loan", type="loan", institution="CI Synthetic Bank", last_balance=2_000_000, liquidity_bucket="loan"), + dict(name="CI Synthetic Manual Asset", type="other", institution="Manual", liquidity_bucket="real_estate"), + ]: + result = service.create_ledger_account( + last_synced_at="2026-06-30T23:59:00+09:00", + memo="ci synthetic fixture account", + db_path=str(path), + **account_spec, + ) + if not result.get("success"): + raise RuntimeError(f"fixture account seed failed: {result.get('error_code')}") + + budget = service.create_ledger_budget("2026-06", "์‹๋น„", 50_000, memo="ci synthetic budget", db_path=str(path)) + if not budget.get("success"): + raise RuntimeError(f"fixture budget seed failed: {budget.get('error_code')}") + + tx_specs: list[dict[str, Any]] = [ + dict(transaction_date="2026-06-25", type="income", amount=2_000_000, merchant="CI_SYNTHETIC_SALARY", category="์ˆ˜์ž…", subcategory="๊ธ‰์—ฌ", source="manual", memo="ci synthetic income"), + dict(transaction_date="2026-06-26", type="expense", amount=35_000, merchant="CI_SYNTHETIC_DINING", category="์‹๋น„", subcategory="์™ธ์‹", source="manual", memo="ci synthetic expense"), + dict(transaction_date="2026-06-27", type="expense", amount=18_500, merchant="CI_SYNTHETIC_MART", category="", subcategory="", source="manual", memo="ci synthetic uncategorized", raw_summary="ci synthetic grocery"), + dict(transaction_date="2026-06-28", type="expense", amount=4_800, merchant="CI_SYNTHETIC_CAFE", category="", subcategory="", source="manual", memo="ci synthetic uncategorized", raw_summary="ci synthetic cafe"), + dict(transaction_date="2026-06-29", type="expense", amount=120_000, merchant="CI_SYNTHETIC_TRANSFER", category="์ด์ฒด", subcategory="ํ™•์ธํ•„์š”", source="kb_bank_pdf", counterparty="CI_SYNTHETIC_TOSS", memo="cross_transfer_review: ci synthetic", raw_summary="ci synthetic transfer", balance_after=1_250_000), + ] + transaction_ids: list[str] = [] + for index, spec in enumerate(tx_specs, start=1): + result = service.create_ledger_transaction( + account_id=account_id, + external_id=f"ci-fixture-{index}", + db_path=str(path), + **spec, + ) + if not result.get("success"): + raise RuntimeError(f"fixture transaction seed failed: {result.get('error_code')}") + transaction_ids.append(result["transaction"]["id"]) + + status = service.get_ledger_db_status(str(path), include_hash=False, include_integrity=True) + if not status.get("success") or status.get("integrity_check") != "ok": + raise RuntimeError(f"fixture health check failed: {status.get('error_code')}") + + return { + "success": True, + "fixture_db": str(path), + "account_count": 4, + "transaction_count": len(transaction_ids), + "integrity_check": status.get("integrity_check"), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="Create a synthetic Jarca Ledger CI fixture DB.") + parser.add_argument("--db", required=True, help="Output fixture path, e.g. $RUNNER_TEMP/fixture.sqlite3") + args = parser.parse_args() + try: + result = create_ci_fixture_db(args.db) + except Exception as exc: # noqa: BLE001 - CLI wrapper reports deterministic failure + print(json.dumps({"success": False, "error_code": "fixture_seed_failed", "error": str(exc)}, ensure_ascii=False)) + return 1 + print(json.dumps(result, ensure_ascii=False, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/streamlit_browser_evidence.py b/scripts/streamlit_browser_evidence.py new file mode 100755 index 0000000..293c667 --- /dev/null +++ b/scripts/streamlit_browser_evidence.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Capture local Streamlit browser evidence with Chrome and a synthetic DB. + +This script is intentionally local-only. It creates a synthetic fixture database, +starts Streamlit against that database, and uses an installed Chrome binary to +save page screenshots and DOM snapshots. It must not target the operating ledger +DB or real financial statement files. +""" + +from __future__ import annotations + +import argparse +import asyncio +import base64 +import json +import os +import shutil +import signal +import subprocess +import time +import urllib.parse +import urllib.request +from datetime import datetime +from pathlib import Path +from typing import Any + +import websockets + +from jarca_ledger_mcp import service +from jarca_ledger_mcp.db import init_db + +DEFAULT_PAGES = ["๋ถ„๋ฅ˜ ๊ทœ์น™", "ํ™•์ธํ•„์š” ํ", "Import"] +DEFAULT_CHROME = "/usr/bin/google-chrome-stable" +DEFAULT_STREAMLIT = Path(".venv/bin/streamlit") +DEFAULT_OUTPUT_ROOT = Path("docs/batches/bat-004_20260629_browser-streamlit-e2e-evidence/evidence") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Capture Streamlit browser screenshots and DOM snapshots with a synthetic DB.") + parser.add_argument("--chrome", default=os.getenv("CHROME_BIN", DEFAULT_CHROME), help="Chrome executable path") + parser.add_argument("--streamlit", default=str(DEFAULT_STREAMLIT), help="Streamlit executable path") + parser.add_argument("--port", type=int, default=18501, help="Local Streamlit port") + parser.add_argument("--host", default="127.0.0.1", help="Local Streamlit host") + parser.add_argument("--output-dir", default="", help="Evidence output directory. Defaults under docs/batches/BAT-004/evidence/run-.") + parser.add_argument("--page", action="append", dest="pages", help="Page to capture. Repeatable. Defaults to core E2E evidence pages.") + parser.add_argument("--keep-server", action="store_true", help="Leave Streamlit running after capture for manual inspection.") + return parser.parse_args() + + +def slugify_page(name: str) -> str: + mapping = { + "๋ถ„๋ฅ˜ ๊ทœ์น™": "rules", + "ํ™•์ธํ•„์š” ํ": "transfer-review", + "Import": "import", + "๊ณ„์ขŒ": "accounts", + "์š”์•ฝ": "overview", + "๊ฑฐ๋ž˜๋‚ด์—ญ": "transactions", + "๋ฏธ๋ถ„๋ฅ˜": "uncategorized", + "์นดํ…Œ๊ณ ๋ฆฌ": "categories", + } + return mapping.get(name, urllib.parse.quote(name, safe="").replace("%", "").lower() or "page") + + +def require_executable(path: str, label: str) -> str: + resolved = shutil.which(path) if os.path.sep not in path else path + if not resolved or not Path(resolved).exists(): + raise SystemExit(f"{label} executable not found: {path}") + return resolved + + +def assert_synthetic_db_path(path: Path) -> None: + resolved = path.expanduser().resolve() + operating = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3" + if resolved == operating.resolve(): + raise SystemExit("Refusing to use the operating ledger DB for browser evidence") + if not str(resolved).endswith("fixture.sqlite3"): + raise SystemExit(f"Synthetic evidence DB must end with fixture.sqlite3: {resolved}") + + +def seed_fixture_db(db_path: Path) -> dict[str, Any]: + assert_synthetic_db_path(db_path) + if db_path.exists(): + db_path.unlink() + init_db(str(db_path)) + service.seed_default_ledger_categories(db_path=str(db_path)) + service.seed_default_category_rules(db_path=str(db_path)) + + account = service.create_ledger_account( + "Synthetic Evidence Checking", + institution="Synthetic Bank", + account_number_masked="SYNTH-0000", + holder_name="Synthetic Holder", + last_balance=1_250_000, + last_synced_at="2026-06-30T23:59:00+09:00", + statement_status="partial_import", + liquidity_bucket="cash", + memo="browser evidence synthetic fixture", + db_path=str(db_path), + )["account"] + account_id = account["id"] + service.create_ledger_account( + "Synthetic Investment Account", + type="investment", + institution="Synthetic Securities", + last_balance=5_000_000, + last_synced_at="2026-06-30T23:59:00+09:00", + liquidity_bucket="investment", + memo="browser evidence investment asset", + db_path=str(db_path), + ) + service.create_ledger_account( + "Synthetic Mortgage Balance", + type="loan", + institution="Synthetic Bank", + last_balance=2_000_000, + last_synced_at="2026-06-30T23:59:00+09:00", + liquidity_bucket="loan", + memo="browser evidence liability", + db_path=str(db_path), + ) + service.create_ledger_account( + "Synthetic Appraisal Pending", + type="other", + institution="Manual", + liquidity_bucket="real_estate", + memo="browser evidence missing last_balance warning", + db_path=str(db_path), + ) + service.create_ledger_budget("2026-06", "์‹๋น„", 20_000, memo="browser evidence budget", db_path=str(db_path)) + + tx_specs = [ + dict(transaction_date="2026-06-25", type="income", amount=2_000_000, merchant="SYNTHETIC_SALARY", category="์ˆ˜์ž…", subcategory="๊ธ‰์—ฌ", source="manual", memo="browser evidence income"), + dict(transaction_date="2026-06-26", type="expense", amount=35_000, merchant="SYNTHETIC_DINING", category="์‹๋น„", subcategory="์™ธ์‹", source="manual", memo="browser evidence expense"), + dict(transaction_date="2026-06-27", type="expense", amount=18_500, merchant="SYNTHETIC_MART", category="", subcategory="", source="manual", memo="browser evidence uncategorized", raw_summary="synthetic grocery"), + dict(transaction_date="2026-06-28", type="expense", amount=4_800, merchant="SYNTHETIC_CAFE", category="", subcategory="", source="manual", memo="browser evidence uncategorized", raw_summary="synthetic cafe"), + dict(transaction_date="2026-06-29", type="transfer", amount=120_000, merchant="SYNTHETIC_TRANSFER", category="์ด์ฒด", subcategory="ํ™•์ธํ•„์š”", source="kb_bank_pdf", counterparty="SYNTHETIC_TOSS", memo="cross_transfer_review: synthetic browser evidence", raw_summary="synthetic transfer", balance_after=1_250_000), + ] + transactions = [] + for index, spec in enumerate(tx_specs, start=1): + result = service.create_ledger_transaction(account_id=account_id, external_id=f"browser-evidence-{index}", db_path=str(db_path), **spec) + if not result.get("success"): + raise SystemExit(f"Fixture transaction seed failed: {result}") + transactions.append(result["transaction"]["id"]) + + return {"account_id": account_id, "transaction_ids": transactions, "db_path": str(db_path)} + + +def wait_for_http(url: str, timeout_seconds: int = 45) -> None: + deadline = time.time() + timeout_seconds + last_error: str | None = None + while time.time() < deadline: + try: + with urllib.request.urlopen(url, timeout=2) as response: + if response.status == 200: + return + last_error = f"HTTP {response.status}" + except Exception as exc: # noqa: BLE001 - diagnostics returned in manifest + last_error = str(exc) + time.sleep(1) + raise SystemExit(f"Streamlit did not become ready at {url}: {last_error}") + + +def read_json_url(url: str, timeout: int = 2) -> Any: + with urllib.request.urlopen(url, timeout=timeout) as response: + return json.loads(response.read().decode("utf-8")) + + +def wait_for_devtools(port: int, timeout_seconds: int = 15) -> str: + deadline = time.time() + timeout_seconds + last_error: str | None = None + while time.time() < deadline: + try: + targets = read_json_url(f"http://127.0.0.1:{port}/json/list") + for target in targets: + if target.get("type") == "page" and target.get("webSocketDebuggerUrl"): + return target["webSocketDebuggerUrl"] + last_error = "no page target" + except Exception as exc: # noqa: BLE001 - Chrome startup diagnostics + last_error = str(exc) + time.sleep(0.25) + raise SystemExit(f"Chrome DevTools endpoint did not become ready on port {port}: {last_error}") + + +class CdpClient: + def __init__(self, ws: websockets.ClientConnection) -> None: + self.ws = ws + self.next_id = 1 + + async def call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + command_id = self.next_id + self.next_id += 1 + await self.ws.send(json.dumps({"id": command_id, "method": method, "params": params or {}})) + while True: + raw = await self.ws.recv() + payload = json.loads(raw) + if payload.get("id") == command_id: + if "error" in payload: + raise RuntimeError(f"CDP {method} failed: {payload['error']}") + return payload.get("result", {}) + + +async def cdp_capture(ws_url: str, url: str, expected_text: str, screenshot: Path, snapshot: Path) -> dict[str, Any]: + async with websockets.connect(ws_url, max_size=16 * 1024 * 1024) as ws: + cdp = CdpClient(ws) + await cdp.call("Page.enable") + await cdp.call("Runtime.enable") + await cdp.call( + "Emulation.setDeviceMetricsOverride", + {"width": 1440, "height": 1400, "deviceScaleFactor": 1, "mobile": False}, + ) + await cdp.call("Page.navigate", {"url": url}) + + inner_text = "" + deadline = time.time() + 45 + while time.time() < deadline: + result = await cdp.call( + "Runtime.evaluate", + {"expression": "document.body ? document.body.innerText : ''", "returnByValue": True}, + ) + inner_text = result.get("result", {}).get("value", "") or "" + if expected_text in inner_text: + break + await asyncio.sleep(1) + if expected_text not in inner_text: + raise SystemExit(f"Browser page did not render expected text {expected_text!r}; last text starts with {inner_text[:200]!r}") + + html_result = await cdp.call( + "Runtime.evaluate", + {"expression": "document.documentElement.outerHTML", "returnByValue": True}, + ) + snapshot.write_text(html_result.get("result", {}).get("value", ""), encoding="utf-8") + image_result = await cdp.call("Page.captureScreenshot", {"format": "png", "fromSurface": True, "captureBeyondViewport": True}) + screenshot.write_bytes(base64.b64decode(image_result["data"])) + return {"inner_text_head": inner_text[:500]} + + +def capture_page(chrome: str, base_url: str, page: str, output_dir: Path, devtools_port: int) -> dict[str, Any]: + slug = slugify_page(page) + url = f"{base_url}/?page={urllib.parse.quote(page)}" + screenshot = output_dir / f"{slug}.png" + snapshot = output_dir / f"{slug}.dom.html" + + profile_dir = output_dir / f"chrome-profile-{slug}" + chrome_cmd = [ + chrome, + "--headless=new", + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-gpu", + "--disable-software-rasterizer", + "--hide-scrollbars", + f"--remote-debugging-port={devtools_port}", + f"--user-data-dir={profile_dir}", + "--window-size=1440,1400", + "about:blank", + ] + chrome_proc = subprocess.Popen(chrome_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + try: + ws_url = wait_for_devtools(devtools_port) + cdp_info = asyncio.run(cdp_capture(ws_url, url, page, screenshot, snapshot)) + finally: + if chrome_proc.poll() is None: + chrome_proc.terminate() + try: + chrome_proc.wait(timeout=8) + except subprocess.TimeoutExpired: + chrome_proc.kill() + shutil.rmtree(profile_dir, ignore_errors=True) + + if not screenshot.exists() or screenshot.stat().st_size <= 0: + raise SystemExit(f"Screenshot was not created for {page}: {screenshot}") + if page not in snapshot.read_text(encoding="utf-8", errors="ignore"): + raise SystemExit(f"DOM snapshot does not contain expected page title {page!r}: {snapshot}") + + return { + "page": page, + "url": url, + "screenshot": str(screenshot), + "screenshot_bytes": screenshot.stat().st_size, + "snapshot": str(snapshot), + "snapshot_bytes": snapshot.stat().st_size, + "devtools_port": devtools_port, + "inner_text_head": cdp_info.get("inner_text_head", ""), + } + + +def main() -> int: + args = parse_args() + chrome = require_executable(args.chrome, "Chrome") + streamlit = require_executable(args.streamlit, "Streamlit") + run_id = datetime.now().strftime("run-%Y%m%d-%H%M%S") + output_dir = Path(args.output_dir) if args.output_dir else DEFAULT_OUTPUT_ROOT / run_id + output_dir.mkdir(parents=True, exist_ok=True) + db_path = output_dir / "fixture.sqlite3" + fixture = seed_fixture_db(db_path) + + env = os.environ.copy() + env["JARCA_LEDGER_DB_PATH"] = str(db_path.resolve()) + env["STREAMLIT_BROWSER_GATHER_USAGE_STATS"] = "false" + base_url = f"http://{args.host}:{args.port}" + server_cmd = [ + streamlit, + "run", + "dashboard/app.py", + "--server.address", + args.host, + "--server.port", + str(args.port), + "--server.headless", + "true", + "--browser.gatherUsageStats", + "false", + ] + + server = subprocess.Popen(server_cmd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, env=env, start_new_session=True) + manifest: dict[str, Any] = { + "success": False, + "base_url": base_url, + "output_dir": str(output_dir.resolve()), + "fixture": fixture, + "chrome": chrome, + "streamlit": streamlit, + "pages": [], + "server_command": server_cmd, + } + try: + wait_for_http(base_url) + for index, page in enumerate(args.pages or DEFAULT_PAGES): + manifest["pages"].append(capture_page(chrome, base_url, page, output_dir, devtools_port=19222 + index)) + manifest["success"] = True + print(json.dumps(manifest, ensure_ascii=False, indent=2)) + return 0 + finally: + log_tail = "" + if server.stdout is not None: + try: + time.sleep(0.5) + if server.poll() is not None: + log_tail = server.stdout.read()[-4000:] + except Exception: + log_tail = "" + manifest["server_log_tail"] = log_tail + (output_dir / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8") + if not args.keep_server and server.poll() is None: + os.killpg(server.pid, signal.SIGTERM) + try: + server.wait(timeout=10) + except subprocess.TimeoutExpired: + os.killpg(server.pid, signal.SIGKILL) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_categorization.py b/tests/test_categorization.py index 2ec41f8..4f120f2 100644 --- a/tests/test_categorization.py +++ b/tests/test_categorization.py @@ -49,3 +49,35 @@ def test_apply_categorization_rules_applies_all_matches_not_just_preview_sample( assert applied["applied_count"] == 150 uncategorized = service.list_uncategorized_transactions(limit=200, db_path=db_path) assert uncategorized["count"] == 0 + +def test_single_category_rule_preview_and_apply_only_uncategorized(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + seeded = service.seed_default_category_rules(db_path=db_path) + assert seeded["success"] is True + rules = service.list_category_rules(db_path=db_path)["rules"] + mart_rule = next(r for r in rules if r["pattern"] == "์ด๋งˆํŠธ") + + uncategorized = service.create_ledger_transaction( + "2026-06-01", "expense", 10_000, merchant="์ด๋งˆํŠธ์—๋ธŒ๋ฆฌ๋ฐ์ด", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + )["transaction"] + categorized = service.create_ledger_transaction( + "2026-06-02", "expense", 20_000, category="์ƒํ™œ", subcategory="์ƒํ•„ํ’ˆ", merchant="์ด๋งˆํŠธ24", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + )["transaction"] + service.create_ledger_transaction( + "2026-06-03", "expense", 30_000, merchant="์•Œ์ˆ˜์—†์Œ", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + ) + + preview = service.preview_category_rule_matches(mart_rule["id"], db_path=db_path) + assert preview["success"] is True + assert preview["matched_count"] == 2 + assert preview["uncategorized_matched_count"] == 1 + assert len(preview["sample"]) == 2 + + applied = service.apply_category_rule(mart_rule["id"], db_path=db_path) + assert applied["success"] is True + assert applied["applied_count"] == 1 + + all_tx = service.list_ledger_transactions(limit=10, db_path=db_path)["transactions"] + by_id = {tx["id"]: tx for tx in all_tx} + assert by_id[uncategorized["id"]]["category"] == "์‹๋น„" + assert by_id[categorized["id"]]["category"] == "์ƒํ™œ" diff --git a/tests/test_dashboard_app.py b/tests/test_dashboard_app.py new file mode 100644 index 0000000..6e88540 --- /dev/null +++ b/tests/test_dashboard_app.py @@ -0,0 +1,59 @@ +from pathlib import Path +import sys + +from streamlit.testing.v1 import AppTest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from dashboard.app import get_transfer_review_items +from scripts.ci_fixture_db import create_ci_fixture_db + + +def test_get_transfer_review_items_accepts_transactions_key(): + payload = { + "success": True, + "transactions": [ + {"id": "tx_1", "amount": 10000}, + ], + } + + assert get_transfer_review_items(payload) == payload["transactions"] + + +def test_get_transfer_review_items_prefers_review_items_when_present(): + payload = { + "success": True, + "review_items": [ + {"id": "tx_legacy", "amount": 20000}, + ], + "transactions": [ + {"id": "tx_new", "amount": 30000}, + ], + } + + assert get_transfer_review_items(payload) == payload["review_items"] + + +def test_get_transfer_review_items_returns_empty_for_unsuccessful_payload(): + assert get_transfer_review_items({"success": False, "transactions": [{"id": "tx_1"}]}) == [] + assert get_transfer_review_items(None) == [] + + +def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeypatch): + fixture = tmp_path / "fixture.sqlite3" + create_ci_fixture_db(fixture) + monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(fixture)) + + app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py" + page_expectations = { + "์š”์•ฝ": "๊ฐ€๊ณ„๋ถ€ ์šด์˜ ๋Œ€์‹œ๋ณด๋“œ", + "๋ถ„๋ฅ˜ ๊ทœ์น™": "๋ถ„๋ฅ˜ ๊ทœ์น™", + "ํ™•์ธํ•„์š” ํ": "KBโ†”ํ† ์Šค๋ฑ…ํฌ ํ™•์ธํ•„์š” ํ", + } + for page, expected_text in page_expectations.items(): + at = AppTest.from_file(str(app_path), default_timeout=10) + at.query_params["page"] = page + at.run() + assert not at.exception + body_text = "\n".join(str(item.value) for item in list(at.text) + list(at.markdown) + list(at.caption)) + assert expected_text in body_text diff --git a/tests/test_ledger_categories.py b/tests/test_ledger_categories.py index 013437c..0f11f52 100644 --- a/tests/test_ledger_categories.py +++ b/tests/test_ledger_categories.py @@ -6,7 +6,7 @@ from jarca_ledger_mcp import service def test_ledger_category_create_list_delete_reactivate(tmp_path): db_path = str(tmp_path / "ledger.sqlite3") health = service.health_check(db_path) - assert health["schema_version"] == "6" + assert health["schema_version"] == "8" created = service.create_ledger_category("๋ฐ˜๋ ค๋™๋ฌผ", "์‚ฌ๋ฃŒ", sort_order=250, memo="test", db_path=db_path) assert created["success"] is True diff --git a/tests/test_service.py b/tests/test_service.py index 210ebdc..d0de6cf 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -1,5 +1,8 @@ from __future__ import annotations +from datetime import datetime +from zoneinfo import ZoneInfo + from jarca_ledger_mcp import service @@ -7,10 +10,35 @@ def test_health_check_initializes_db(tmp_path): db_path = tmp_path / "ledger.sqlite3" result = service.health_check(str(db_path)) assert result["success"] is True - assert result["schema_version"] == "6" + assert result["schema_version"] == "8" assert db_path.exists() +def test_service_default_dates_and_monthly_reports_use_kst_boundary(tmp_path, monkeypatch): + db_path = str(tmp_path / "ledger.sqlite3") + boundary_instant = datetime(2026, 7, 1, 0, 30, tzinfo=ZoneInfo("Asia/Seoul")) + monkeypatch.setattr(service, "_current_kst_datetime", lambda: boundary_instant) + + assert service.now_iso() == "2026-07-01T00:30:00+09:00" + assert service.normalize_date(None, default_today=True) == "2026-07-01" + assert service.normalize_month("") == "2026-07" + + service.create_ledger_transaction("2026-06-30", "expense", 10_000, category="์‹๋น„", db_path=db_path) + service.create_ledger_transaction("2026-07-01", "income", 30_000, category="์ˆ˜์ž…", db_path=db_path) + service.create_ledger_transaction("2026-07-02", "expense", 5_000, category="์‹๋น„", db_path=db_path) + + cashflow = service.get_monthly_cashflow_report("", db_path) + assert cashflow["success"] is True + assert cashflow["month"] == "2026-07" + assert cashflow["income"] == 30_000 + assert cashflow["expenses"] == 5_000 + + status = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False) + assert status["aggregate_totals"]["month"] == "2026-07" + assert status["aggregate_totals"]["by_cashflow_kind"]["income"] == {"amount": 30_000, "count": 1} + assert status["aggregate_totals"]["by_cashflow_kind"]["expense"] == {"amount": 5_000, "count": 1} + + def test_account_transaction_cashflow_and_category_reports(tmp_path): db_path = str(tmp_path / "ledger.sqlite3") acct = service.create_ledger_account("์‹ ํ•œ์นด๋“œ", type="card", institution="์‹ ํ•œ", db_path=db_path) @@ -135,3 +163,129 @@ def test_duplicate_detection_and_soft_delete(tmp_path): listed_all = service.list_ledger_transactions(include_deleted=True, db_path=db_path) assert listed_all["count"] == 1 assert listed_all["transactions"][0]["status"] == "deleted" + + +def test_statement_status_account_update_and_balance_reconciliation(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + acct = service.create_ledger_account( + "์ˆ˜๋™ ๊ณ„์ขŒ", + statement_status="manual_only", + last_balance=901_500, + last_synced_at="2026-06-30T23:59:00+09:00", + db_path=db_path, + ) + assert acct["success"] is True + assert acct["account"]["statement_status"] == "manual_only" + account_id = acct["account"]["id"] + + service.create_ledger_transaction("2026-06-01", "income", 1_000_000, account_id=account_id, db_path=db_path) + service.create_ledger_transaction("2026-06-10", "expense", 98_500, account_id=account_id, db_path=db_path) + + report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path) + assert report["success"] is True + assert report["items"][0]["calculated_balance"] == 901_500 + assert report["items"][0]["difference"] == 0 + assert report["items"][0]["status"] == "OK" + + updated = service.update_ledger_account(account_id, statement_status="partial_import", last_balance=905_000, db_path=db_path) + assert updated["success"] is True + assert updated["account"]["statement_status"] == "partial_import" + + review = service.get_balance_reconciliation_report(account_id=account_id, baseline_balance=905_000, baseline_at="2026-06", db_path=db_path) + assert review["success"] is True + assert review["baseline_at"] == "2026-06-30T23:59:00+09:00" + assert review["items"][0]["difference"] == 3_500 + assert review["items"][0]["status"] == "REVIEW_NEEDED" + assert "ํ™•์ธํ•„์š”" in review["items"][0]["recommended_decisions"] + + +def test_balance_reconciliation_uses_account_last_synced_at_when_baseline_at_omitted(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + acct = service.create_ledger_account( + "์ˆ˜๋™ ๊ณ„์ขŒ ๊ธฐ์ค€์ผ", + statement_status="manual_only", + last_balance=1_000, + last_synced_at="2026-01-31T23:59:00+09:00", + db_path=db_path, + ) + assert acct["success"] is True + account_id = acct["account"]["id"] + + service.create_ledger_transaction("2026-01-15", "income", 1_000, account_id=account_id, db_path=db_path) + service.create_ledger_transaction("2026-02-01", "expense", 200, account_id=account_id, db_path=db_path) + + report = service.get_balance_reconciliation_report(account_id=account_id, db_path=db_path) + assert report["success"] is True + assert report["baseline_at"] is None + assert report["items"][0]["baseline_at"] == "2026-01-31T23:59:00+09:00" + assert report["items"][0]["calculated_balance"] == 1_000 + assert report["items"][0]["difference"] == 0 + assert report["items"][0]["status"] == "OK" + + explicit = service.get_balance_reconciliation_report( + account_id=account_id, + baseline_balance=800, + baseline_at="2026-02", + db_path=db_path, + ) + assert explicit["success"] is True + assert explicit["baseline_at"] == "2026-02-28T23:59:00+09:00" + assert explicit["items"][0]["baseline_at"] == "2026-02-28T23:59:00+09:00" + assert explicit["items"][0]["calculated_balance"] == 800 + assert explicit["items"][0]["difference"] == 0 + assert explicit["items"][0]["status"] == "OK" + + +def test_invalid_statement_status_rejected(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + created = service.create_ledger_account("์˜ค๋ฅ˜ ๊ณ„์ขŒ", statement_status="unsupported", db_path=db_path) + assert created["success"] is False + assert created["error_code"] == "validation_error" + + acct = service.create_ledger_account("์ •์ƒ ๊ณ„์ขŒ", db_path=db_path) + updated = service.update_ledger_account(acct["account"]["id"], statement_status="unsupported", db_path=db_path) + assert updated["success"] is False + assert updated["error_code"] == "validation_error" + + +def test_liquidity_bucket_account_flow_and_asset_report(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + cash = service.create_ledger_account("ํ˜„๊ธˆ์„ฑ ๊ณ„์ขŒ", last_balance=1_000_000, liquidity_bucket="cash", db_path=db_path) + investment = service.create_ledger_account("ํˆฌ์ž ๊ณ„์ขŒ", type="investment", last_balance=3_000_000, liquidity_bucket="investment", db_path=db_path) + loan = service.create_ledger_account("๋Œ€์ถœ", type="loan", last_balance=500_000, liquidity_bucket="loan", db_path=db_path) + missing = service.create_ledger_account("ํ‰๊ฐ€ ๋Œ€๊ธฐ ๋ถ€๋™์‚ฐ", liquidity_bucket="real_estate", db_path=db_path) + default_bucket = service.create_ledger_account("๊ธฐ๋ณธ ๊ณ„์ขŒ", db_path=db_path) + + assert cash["success"] is True + assert investment["success"] is True + assert loan["success"] is True + assert missing["success"] is True + assert default_bucket["account"]["liquidity_bucket"] == "other" + + updated = service.update_ledger_account(default_bucket["account"]["id"], liquidity_bucket="deposit", last_balance=2_000_000, db_path=db_path) + assert updated["success"] is True + assert updated["account"]["liquidity_bucket"] == "deposit" + + accounts = service.list_ledger_accounts(db_path=db_path) + assert {account["liquidity_bucket"] for account in accounts["accounts"]} >= {"cash", "investment", "deposit", "real_estate", "loan"} + + report = service.get_asset_liquidity_report(db_path=db_path) + assert report["success"] is True + assert report["total_assets"] == 6_000_000 + assert report["total_liabilities"] == 500_000 + assert report["net_worth"] == 5_500_000 + assert report["missing_balance_accounts"] == [{"id": missing["account"]["id"], "name": "ํ‰๊ฐ€ ๋Œ€๊ธฐ ๋ถ€๋™์‚ฐ", "liquidity_bucket": "real_estate"}] + loan_bucket = next(item for item in report["buckets"] if item["liquidity_bucket"] == "loan") + assert loan_bucket["asset_share"] is None + + +def test_invalid_liquidity_bucket_rejected(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + created = service.create_ledger_account("์˜ค๋ฅ˜ ๊ณ„์ขŒ", liquidity_bucket="liquid", db_path=db_path) + assert created["success"] is False + assert created["error_code"] == "validation_error" + + acct = service.create_ledger_account("์ •์ƒ ๊ณ„์ขŒ", db_path=db_path) + updated = service.update_ledger_account(acct["account"]["id"], liquidity_bucket="liquid", db_path=db_path) + assert updated["success"] is False + assert updated["error_code"] == "validation_error" diff --git a/tests/test_uncategorized_workbench.py b/tests/test_uncategorized_workbench.py new file mode 100644 index 0000000..f3cbded --- /dev/null +++ b/tests/test_uncategorized_workbench.py @@ -0,0 +1,81 @@ +from __future__ import annotations + +from jarca_ledger_mcp import service + + +def test_uncategorized_workbench_recommends_from_rules_and_applies_single(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + service.seed_default_category_rules(db_path=db_path) + tx = service.create_ledger_transaction( + "2026-06-01", "expense", 10_000, merchant="์Šคํƒ€๋ฒ…์Šค๊ฐ•๋‚จ", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + )["transaction"] + + workbench = service.get_uncategorized_workbench(db_path=db_path) + + assert workbench["success"] is True + assert workbench["summary"]["uncategorized_count"] == 1 + assert workbench["summary"]["recommendable_count"] == 1 + row = workbench["transactions"][0] + assert row["id"] == tx["id"] + assert row["recommended_category"] == "์‹๋น„" + assert row["recommended_subcategory"] == "์นดํŽ˜" + assert row["recommendation"]["source"] == "rule" + + applied = service.apply_uncategorized_recommendation(tx["id"], db_path=db_path) + + assert applied["success"] is True + assert applied["applied_count"] == 1 + saved = service.list_ledger_transactions(limit=10, db_path=db_path)["transactions"][0] + assert saved["category"] == "์‹๋น„" + assert saved["subcategory"] == "์นดํŽ˜" + + +def test_uncategorized_workbench_recommends_from_history_and_applies_group(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + service.create_ledger_transaction( + "2026-06-01", "expense", 7_000, category="์‹๋น„", subcategory="์นดํŽ˜", merchant="๋™๋„ค์ปคํ”ผ", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + ) + tx1 = service.create_ledger_transaction( + "2026-06-02", "expense", 8_000, merchant="๋™๋„ค์ปคํ”ผ", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + )["transaction"] + tx2 = service.create_ledger_transaction( + "2026-06-03", "expense", 9_000, merchant="๋™๋„ค์ปคํ”ผ", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + )["transaction"] + + workbench = service.get_uncategorized_workbench(db_path=db_path) + + assert workbench["summary"]["uncategorized_count"] == 2 + assert workbench["summary"]["recommendable_count"] == 2 + assert workbench["summary"]["repeated_group_count"] == 1 + group = workbench["groups"][0] + assert group["count"] == 2 + assert group["recommendation"]["category"] == "์‹๋น„" + assert group["recommendation"]["source"] == "history" + + applied = service.apply_uncategorized_group_recommendation("๋™๋„ค์ปคํ”ผ", "์ฒดํฌ์นด๋“œ", "expense", db_path=db_path) + + assert applied["success"] is True + assert applied["applied_count"] == 2 + transactions = service.list_ledger_transactions(limit=10, db_path=db_path)["transactions"] + by_id = {tx["id"]: tx for tx in transactions} + assert by_id[tx1["id"]]["category"] == "์‹๋น„" + assert by_id[tx2["id"]]["subcategory"] == "์นดํŽ˜" + + +def test_uncategorized_workbench_supports_amount_and_date_sort(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + service.create_ledger_transaction( + "2026-06-01", "expense", 30_000, merchant="์˜ค๋ž˜๋œ๊ณ ์•ก", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + ) + service.create_ledger_transaction( + "2026-06-03", "expense", 5_000, merchant="์ตœ์‹ ์†Œ์•ก", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + ) + service.create_ledger_transaction( + "2026-06-02", "expense", 10_000, merchant="์ค‘๊ฐ„", raw_summary="์ฒดํฌ์นด๋“œ", db_path=db_path + ) + + by_amount = service.get_uncategorized_workbench(sort_by="amount_desc", db_path=db_path)["transactions"] + by_date = service.get_uncategorized_workbench(sort_by="date_desc", db_path=db_path)["transactions"] + + assert [row["merchant"] for row in by_amount] == ["์˜ค๋ž˜๋œ๊ณ ์•ก", "์ค‘๊ฐ„", "์ตœ์‹ ์†Œ์•ก"] + assert [row["merchant"] for row in by_date] == ["์ตœ์‹ ์†Œ์•ก", "์ค‘๊ฐ„", "์˜ค๋ž˜๋œ๊ณ ์•ก"] diff --git a/uv.lock b/uv.lock index bc1f7b6..48d7ae8 100644 --- a/uv.lock +++ b/uv.lock @@ -318,6 +318,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/aa/50/a9caea39ad19c431c1a3f8a31114df65b260cdfe67786b6c7e7c040c4c44/cryptography-49.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be9fcb48a55f023493482827d4f459bd263cc20efde64f204b97c123201850c6", size = 3783731, upload-time = "2026-06-12T20:02:43.319Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -464,6 +473,8 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "mcp" }, + { name = "msoffcrypto-tool" }, + { name = "openpyxl" }, { name = "pymupdf" }, { name = "python-dotenv" }, ] @@ -481,6 +492,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "mcp", specifier = ">=1.0.0" }, + { name = "msoffcrypto-tool", specifier = ">=5.4.0" }, + { name = "openpyxl", specifier = ">=3.1.0" }, { name = "pandas", marker = "extra == 'dashboard'", specifier = ">=2.0.0" }, { name = "plotly", marker = "extra == 'dashboard'", specifier = ">=5.0.0" }, { name = "pymupdf", specifier = ">=1.24.0" }, @@ -628,6 +641,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2e/e1/4c1dc1fbb688641a712d34650c3d58bbbdcb314ddb75bc5817bbf33515a4/mcp-1.28.0-py3-none-any.whl", hash = "sha256:9c1e7cf3a9125557e418ecd4fed8e9adddce81b0dfdae4d6601d700f5beb71a4", size = 221959, upload-time = "2026-06-16T21:37:16.579Z" }, ] +[[package]] +name = "msoffcrypto-tool" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "olefile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/34/6250bdddaeaae24098e45449ea362fb3555a65fba30cad0ad5630ea48d1a/msoffcrypto_tool-6.0.0.tar.gz", hash = "sha256:9a5ebc4c0096b42e5d7ebc2350afdc92dc511061e935ca188468094fdd032bbe", size = 40593, upload-time = "2026-01-12T08:59:56.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/85/9e359fa9279e1d6861faaf9b6f037a3226374deb20a054c3937be6992013/msoffcrypto_tool-6.0.0-py3-none-any.whl", hash = "sha256:46c394ed5d9641e802fc79bf3fb0666a53748b23fa8c4aa634ae9d30d46fe397", size = 48791, upload-time = "2026-01-12T08:59:55.394Z" }, +] + [[package]] name = "narwhals" version = "2.22.1" @@ -716,6 +742,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, ] +[[package]] +name = "olefile" +version = "0.47" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" }, +] + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "packaging" version = "26.2"