chore(jarca-ledger): finalize approved batch docs, dashboard, and CI smoke updates
@@ -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
|
||||
@@ -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는 사용자의 명시 승인 없이 실행하지 않는다.
|
||||
@@ -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는 포함하지 않는다.
|
||||
@@ -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 문서는 현재 작업 범위가 아니므로 구현을 시작하지 않았다.
|
||||
|
After Width: | Height: | Size: 290 KiB |
@@ -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": ""
|
||||
}
|
||||
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 291 KiB |
@@ -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": ""
|
||||
}
|
||||
|
After Width: | Height: | Size: 356 KiB |
|
After Width: | Height: | Size: 276 KiB |
@@ -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": ""
|
||||
}
|
||||
|
After Width: | Height: | Size: 294 KiB |
@@ -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 범위가 아니며, 로컬 재현 절차만 제공한다.
|
||||
@@ -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 신규 화면 기능 개발은 포함하지 않는다.
|
||||
@@ -0,0 +1,31 @@
|
||||
# BAT-004 Planning
|
||||
|
||||
## 구현 전략
|
||||
|
||||
1. `REQ-004`를 Testing 요구사항으로 정리한다.
|
||||
2. 운영 DB와 실제 금융 파일을 배제한 synthetic fixture DB를 evidence script가 직접 생성한다.
|
||||
3. Streamlit은 `JARCA_LEDGER_DB_PATH=<fixture.sqlite3>`로 기동한다.
|
||||
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`를 사용한다.
|
||||
@@ -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.
|
||||
@@ -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": ""
|
||||
}
|
||||
|
After Width: | Height: | Size: 296 KiB |
@@ -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": ""
|
||||
}
|
||||
|
After Width: | Height: | Size: 294 KiB |
@@ -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": ""
|
||||
}
|
||||
|
After Width: | Height: | Size: 296 KiB |
@@ -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` 기본값으로 안전하게 채워진다.
|
||||
@@ -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 범위는 포함하지 않는다.
|
||||
@@ -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
|
||||
@@ -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-<timestamp>`
|
||||
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 기록
|
||||
@@ -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 이름을 안정화하고 문서에 후속 운영 설정으로 기록한다.
|
||||
@@ -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.
|
||||
@@ -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/) |
|
||||
|
||||
@@ -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` 기반 자산 유동성/부채/순자산 규칙 추가.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 추가.
|
||||
|
||||
@@ -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 경계 추가.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 범위가 아니다.
|
||||
@@ -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=<output>/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 후보이며 현재 표준 경로가 아니다.
|
||||
@@ -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=<fixture.sqlite3>` 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 추가.
|
||||
|
||||
@@ -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은 하지 않는다.
|
||||
- 대조 기준 잔액 실제 값은 운영 시점에 사용자가 입력한다.
|
||||
@@ -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에서 구현·검증을 진행한다.
|
||||
@@ -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/`에 생성된다.
|
||||
@@ -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 설정 확인으로, 저장소 코드 범위를 넘는 운영 확인 항목이다.
|
||||
@@ -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로 전환.
|
||||
|
||||
@@ -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] = {
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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-<timestamp>.")
|
||||
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())
|
||||
@@ -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"] == "생활"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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] == ["최신소액", "중간", "오래된고액"]
|
||||
@@ -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"
|
||||
|
||||