chore(jarca-ledger): finalize approved batch docs, dashboard, and CI smoke updates

This commit is contained in:
Jarca
2026-06-30 16:06:47 +09:00
parent 67d8d4871b
commit 4929e52189
75 changed files with 8367 additions and 252 deletions
+80
View File
@@ -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
+842 -183
View File
File diff suppressed because it is too large Load Diff
@@ -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 문서는 현재 작업 범위가 아니므로 구현을 시작하지 않았다.
File diff suppressed because one or more lines are too long
Binary file not shown.

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": ""
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

File diff suppressed because one or more lines are too long
Binary file not shown.

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": ""
}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

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": ""
}
File diff suppressed because one or more lines are too long
Binary file not shown.

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": ""
}
@@ -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": ""
}
@@ -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": ""
}
@@ -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.
+4
View File
@@ -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/) |
+13 -2
View File
@@ -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
+4
View File
@@ -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 |
+16 -4
View File
@@ -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 추가.
+36 -7
View File
@@ -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 경계 추가.
+69
View File
@@ -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 후보이며 현재 표준 경로가 아니다.
+49 -5
View File
@@ -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 설정 확인으로, 저장소 코드 범위를 넘는 운영 확인 항목이다.
+12 -1
View File
@@ -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로 전환.
+6 -2
View File
@@ -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] = {
+17 -2
View File
@@ -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."""
+561 -14
View File
@@ -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},
+126
View File
@@ -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())
+81
View File
@@ -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())
+118
View File
@@ -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())
+353
View File
@@ -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())
+32
View File
@@ -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"] == "생활"
+59
View File
@@ -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
+1 -1
View File
@@ -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
+155 -1
View File
@@ -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"
+81
View File
@@ -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] == ["최신소액", "중간", "오래된고액"]
Generated
+47
View File
@@ -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"