From 02380a88abcb0986ee0dc949adc467f7adfb53f9 Mon Sep 17 00:00:00 2001 From: Jarca Date: Wed, 1 Jul 2026 10:55:34 +0900 Subject: [PATCH] Implement BAT-009 manual asset CRUD and snapshot close --- dashboard/app.py | 108 +++++++- .../design.md | 40 +++ .../implementation.md | 47 ++++ .../index.md | 36 +++ .../planning.md | 37 +++ .../verification.md | 48 ++++ docs/batches/index.md | 1 + docs/data-model.md | 19 +- ..._asset-snapshot-manual-valuation-system.md | 2 +- ...-crud-month-end-snapshot-close-workflow.md | 237 ++++++++++++++++++ docs/discovery/index.md | 1 + docs/interface-contract.md | 19 +- ...set-crud-month-end-snapshot-write-model.md | 99 ++++++++ ...se-mcp-and-operator-dashboard-alignment.md | 96 +++++++ docs/srs/index.md | 7 +- jarca_ledger_mcp/db.py | 22 +- jarca_ledger_mcp/server.py | 25 ++ jarca_ledger_mcp/service.py | 198 ++++++++++++++- .../response_contract_v1_0.json | 32 +++ tests/test_asset_snapshots.py | 62 ++++- tests/test_contract_regression.py | 12 + tests/test_dashboard_app.py | 1 + tests/test_ledger_categories.py | 2 +- tests/test_service.py | 2 +- 24 files changed, 1129 insertions(+), 24 deletions(-) create mode 100644 docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/design.md create mode 100644 docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/implementation.md create mode 100644 docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/index.md create mode 100644 docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/planning.md create mode 100644 docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/verification.md create mode 100644 docs/discovery/dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md create mode 100644 docs/srs/Data/req-011_manual-asset-crud-month-end-snapshot-write-model.md create mode 100644 docs/srs/Interface/req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md diff --git a/dashboard/app.py b/dashboard/app.py index 2880a2b..e971d15 100644 --- a/dashboard/app.py +++ b/dashboard/app.py @@ -1016,6 +1016,110 @@ def page_overview(db_path: str) -> None: st.warning("last_balance가 없는 계좌/자산은 자산 유동성 계산에서 제외했습니다. 계좌 화면에서 기준 잔액과 기준 시각을 입력하세요.") +def page_assets(db_path: str) -> None: + render_page_header( + "자산/마감", + "수기 자산·부채를 관리하고 월말 순자산 스냅샷을 preview 후 마감합니다.", + kicker="🏦 Asset Close", + pills=["수기 자산 CRUD", "Preview 우선", "Append-only 정정"], + ) + st.caption("표시 기준: preview는 live_authority 추정값, close 이후 조회는 closed snapshot 값입니다. 실운영 전 synthetic/validation DB에서 먼저 검증하세요.") + + render_section_label("✍️", "수기 자산/부채 등록") + with st.form("manual_asset_create"): + c1, c2, c3 = st.columns([1.3, 1, 1]) + name = c1.text_input("자산/부채명", placeholder="예: 전세보증금, 주택담보대출") + amount = c2.number_input("평가금액(원)", min_value=0, value=0, step=10000) + valuation_date = c3.date_input("평가 기준일") + c4, c5 = st.columns([1, 2]) + bucket = c4.selectbox("유동성 버킷", LIQUIDITY_BUCKET_OPTIONS, format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x)) + source_note = c5.text_input("출처 메모", placeholder="예: 월말 앱 잔액, 계약서, synthetic") + memo = st.text_input("운영 메모", value="") + submitted = st.form_submit_button("수기 자산 추가", type="primary") + if submitted: + res = service.create_manual_asset(name, int(amount), valuation_date.isoformat(), bucket, source_note=source_note, memo=memo, db_path=db_path) + if res.get("success"): + clear_cache() + st.success("수기 자산을 추가했습니다.") + st.rerun() + else: + st.error(res.get("error", res)) + + assets_res = service.list_manual_assets(active_only=False, db_path=db_path) + assets = assets_res.get("manual_assets", []) if assets_res.get("success") else [] + render_section_label("📋", "수기 자산/부채 목록") + if assets: + df = pd.DataFrame(assets) + df["bucket_label"] = df["liquidity_bucket"].map(LIQUIDITY_BUCKET_LABELS).fillna(df["liquidity_bucket"]) + df["valuation_display"] = df["valuation_amount"].map(format_krw) + st.dataframe(df[["name", "active", "bucket_label", "valuation_display", "valuation_date", "source_note", "memo", "id"]], width="stretch", height=220) + with st.form("manual_asset_update"): + asset_options = {f"{a['name']} ({a['id']})": a for a in assets} + selected_label = st.selectbox("수정/비활성 대상", list(asset_options.keys())) + selected = asset_options[selected_label] + u1, u2, u3 = st.columns([1.3, 1, 1]) + new_name = u1.text_input("이름", value=selected.get("name", "")) + new_amount = u2.number_input("평가금액(원)", min_value=0, value=int(selected.get("valuation_amount") or 0), step=10000, key="asset_update_amount") + new_date = u3.text_input("평가 기준일", value=selected.get("valuation_date", "")) + u4, u5 = st.columns([1, 2]) + current_bucket = selected.get("liquidity_bucket", "other") if selected.get("liquidity_bucket", "other") in LIQUIDITY_BUCKET_OPTIONS else "other" + new_bucket = u4.selectbox("버킷", LIQUIDITY_BUCKET_OPTIONS, index=LIQUIDITY_BUCKET_OPTIONS.index(current_bucket), format_func=lambda x: LIQUIDITY_BUCKET_LABELS.get(x, x), key="asset_update_bucket") + new_source = u5.text_input("출처 메모", value=selected.get("source_note", "")) + new_memo = st.text_input("메모", value=selected.get("memo", ""), key="asset_update_memo") + make_active = st.checkbox("활성", value=bool(selected.get("active", True))) + col_update, col_deactivate = st.columns(2) + do_update = col_update.form_submit_button("선택 항목 수정", type="primary") + do_deactivate = col_deactivate.form_submit_button("선택 항목 비활성", type="secondary") + if do_update: + res = service.update_manual_asset(selected["id"], name=new_name, valuation_amount=int(new_amount), valuation_date=new_date, liquidity_bucket=new_bucket, source_note=new_source, memo=new_memo, active=make_active, db_path=db_path) + if res.get("success"): + clear_cache(); st.success("수정했습니다."); st.rerun() + else: + st.error(res.get("error", res)) + if do_deactivate: + res = service.deactivate_manual_asset(selected["id"], db_path=db_path) + if res.get("success"): + clear_cache(); st.warning("비활성 처리했습니다."); st.rerun() + else: + st.error(res.get("error", res)) + else: + st.info("등록된 수기 자산/부채가 없습니다.") + + st.divider() + render_section_label("🔒", "월말 스냅샷 Preview / Close") + st.caption("Preview는 DB를 변경하지 않습니다. 같은 기준일을 다시 close하면 정정 사유가 필요하며 기존 행을 덮어쓰지 않고 새 correction run을 append합니다.") + c1, c2, c3 = st.columns([1, 1.5, 1.5]) + basis_date = c1.text_input("마감 기준일", value=f"{current_kst_month()}-01", help="YYYY-MM-DD") + close_note = c2.text_input("Close 출처 메모", value="month-end close") + correction_reason = c3.text_input("정정 사유(재마감 시 필수)", value="") + p1, p2 = st.columns(2) + if p1.button("스냅샷 Preview", type="secondary"): + st.session_state["asset_snapshot_preview"] = service.preview_asset_snapshot_close(basis_date, db_path=db_path) + if p2.button("스냅샷 Close 실행", type="primary"): + st.session_state["asset_snapshot_close"] = service.close_asset_snapshot(basis_date, source_note=close_note, correction_reason=correction_reason, db_path=db_path) + clear_cache() + preview = st.session_state.get("asset_snapshot_preview") + close_res = st.session_state.get("asset_snapshot_close") + if preview: + if preview.get("success"): + st.success(f"Preview: source={preview.get('source')}, closed={preview.get('is_closed_snapshot')}, warnings={len(preview.get('warnings', []))}") + m1, m2, m3 = st.columns(3) + m1.metric("자산 합계", format_krw(preview.get("total_assets"))) + m2.metric("총부채", format_krw(preview.get("total_liabilities"))) + m3.metric("순자산", format_krw(preview.get("net_worth"))) + st.dataframe(pd.DataFrame(preview.get("included_items", [])), width="stretch", height=220) + if preview.get("warnings"): + st.warning(preview.get("warnings")) + else: + st.error(preview.get("error", preview)) + if close_res: + if close_res.get("success"): + st.success(f"Close 완료: run={close_res.get('snapshot_run_id')}, correction={close_res.get('is_correction')}, inserted={close_res.get('inserted_count')}") + st.json({k: close_res.get(k) for k in ["basis_date", "source", "is_closed_snapshot", "snapshot_run_id", "correction_of_run_id", "correction_reason", "closed_at", "net_worth"]}) + else: + st.error(close_res.get("error", close_res)) + + def page_transactions(db_path: str) -> None: render_page_header( "거래내역", @@ -1669,7 +1773,7 @@ def main() -> None: db_path = get_db_path() inject_app_css() - pages = ["요약", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"] + pages = ["요약", "자산/마감", "거래내역", "확인필요 큐", "미분류", "분류 규칙", "카테고리", "계좌", "Import"] query_page = st.query_params.get("page", "요약") page = query_page if query_page in pages else "요약" st.session_state["page"] = page @@ -1685,6 +1789,8 @@ def main() -> None: if page == "요약": page_overview(db_path) + elif page == "자산/마감": + page_assets(db_path) elif page == "거래내역": page_transactions(db_path) elif page == "확인필요 큐": diff --git a/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/design.md b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/design.md new file mode 100644 index 0000000..c820581 --- /dev/null +++ b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/design.md @@ -0,0 +1,40 @@ +# BAT-009 Design + +## Schema and persistence design + +- Reuse `manual_assets` / `asset_snapshots` tables from BAT-008 and extend them only additively for write workflow metadata as needed. +- `manual_assets` write lifecycle must support create/update/deactivate without destroying prior snapshot traceability. +- `asset_snapshots` remains append-only; correction runs add new rows/metadata rather than overwriting prior close rows. + +## Write workflow design + +- Service layer should expose: + - manual asset create/update/deactivate helpers + - snapshot preview helper that returns write-free totals/warnings + - snapshot close helper that materializes authority-source rows for one `basis_date` + - correction helper/metadata so same-date re-close is represented as a correction append, not overwrite +- Close path must preserve `source_type`, `source_id`, display name, valuation amount, `liquidity_bucket`, memo/reason metadata. + +## MCP and contract design + +- `server.py` should expose mutation MCP tools for manual asset CRUD and snapshot preview/close. +- Success/error envelopes must match dcy-007 contract conventions. +- Preview responses expose `source='live_authority'`, `is_closed_snapshot=false`; close responses expose `source='snapshot'`, `is_closed_snapshot=true`. +- Correction close responses must identify that a correction run occurred. + +## Dashboard/operator UX design + +- `dashboard/app.py` should provide manual asset create/edit/deactivate flows and a month-end close panel. +- Preview and close actions must be visually distinct. +- Screen text should clarify whether displayed values are live authority estimates or closed snapshot values. +- Correction history should be explainable at least through summary metadata/messages. + +## Verification design + +- Add targeted regression tests for: + - manual asset update/deactivate lifecycle + - snapshot preview side-effect freedom + - append-only correction behavior for same `basis_date` + - mutation MCP contract envelope coverage + - dashboard/shared-helper fallback and source labeling +- Prefer temp DB + synthetic data + local Streamlit smoke only. diff --git a/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/implementation.md b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/implementation.md new file mode 100644 index 0000000..8c8ebfb --- /dev/null +++ b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/implementation.md @@ -0,0 +1,47 @@ +# BAT-009 Implementation + +## Plan summary + +BAT-009 extends the BAT-008 valuation/read foundation into a full write workflow: manual asset CRUD, month-end snapshot preview/close/correction, mutation MCP contracts, operator dashboard UX, and baseline doc alignment while preserving shared valuation rules and safe fallback behavior. + +## Changed files + +- `jarca_ledger_mcp/db.py` +- `jarca_ledger_mcp/service.py` +- `jarca_ledger_mcp/server.py` +- `dashboard/app.py` +- `tests/test_asset_snapshots.py` +- `tests/test_contract_regression.py` +- `tests/test_dashboard_app.py` +- `tests/test_ledger_categories.py` +- `tests/test_service.py` +- `tests/contract_snapshots/response_contract_v1_0.json` +- `docs/data-model.md` +- `docs/interface-contract.md` +- `docs/discovery/dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md` +- `docs/srs/Data/req-011_manual-asset-crud-month-end-snapshot-write-model.md` +- `docs/srs/Interface/req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md` +- `docs/srs/index.md` +- `docs/batches/index.md` +- `docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/index.md` +- `docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/verification.md` + +## Execution log + +- 2026-07-01 09:03 KST: BAT-009 standard batch scaffold created from approved REQ-011/REQ-012. +- 2026-07-01 10:52 KST: `manual_assets` write lifecycle(create/update/deactivate), `asset_snapshots` close/correction metadata, latest-run snapshot read selection, mutation MCP 5종, 대시보드 `자산/마감` 페이지, contract snapshot/test, baseline docs 정렬을 반영했다. +- 2026-07-01 10:52 KST: stale schema-version expectations(`8`/`9`)을 current schema version `10`에 맞게 `tests/test_ledger_categories.py`, `tests/test_service.py`에서 정렬했다. + +## Validation + +- `python3 -m py_compile jarca_ledger_mcp/*.py dashboard/app.py` → PASS +- `python3 -m pytest tests/test_asset_snapshots.py tests/test_contract_regression.py tests/test_dashboard_app.py -q` → PASS (`12 passed`) +- `python3 -m pytest -q` → PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`) +- Browser smoke: Streamlit app on `http://127.0.0.1:18501/?page=자산/마감` rendered the new page with manual asset CRUD section and snapshot preview/close section; browser console JS errors 없음. + +## Remaining risks / follow-up notes + +- Correction metadata shape must remain compatible with existing read helpers and historical snapshots. +- Dashboard UX should not imply production-safe automation; this batch is still synthetic-evidence only. +- Automatic valuation feeds and scheduled month-end close remain explicitly out of scope for BAT-009. diff --git a/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/index.md b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/index.md new file mode 100644 index 0000000..3114209 --- /dev/null +++ b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/index.md @@ -0,0 +1,36 @@ +# BAT-009 Manual asset CRUD / snapshot close + +- Profile: standard +- Status: release-candidate +- Included REQ: REQ-011, REQ-012 +- Discovery: dcy-009 +- Owner: 렛저장인 +- Date (KST): 2026-07-01 + +## 목표 + +REQ-011/REQ-012에 따라 `manual_assets` write lifecycle, month-end snapshot preview/close, append-only correction policy, mutation MCP, operator dashboard UX, source/is_closed 표기 정렬을 구현한다. 동시에 기존 BAT-008 read model/REQ-005 dashboard safety fallback/dcy-007 contract regression을 깨지 않도록 write/read 일관성을 유지한다. + +## 산출물 + +- `manual_assets` create/update/deactivate write support and validation +- snapshot preview/close/correction service APIs and additive persistence metadata +- mutation MCP tools/contracts: manual asset CRUD, snapshot preview, snapshot close +- `dashboard/app.py` operator UX for manual asset management and month-end close preview/execute +- regression tests for write/read consistency, correction policy, contract envelope, dashboard fallback +- `docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flows.md` baseline write-workflow updates +- BAT-009 planning/design/implementation/verification documents and synthetic-only evidence + +## 범위 제한 + +- 외부 시세/부동산 자동 평가 연동 +- 월말 자동 스케줄링/cron close +- 투자 조언/리밸런싱/세금/수익률 계산 +- production ledger DB 실데이터 기반 evidence + +## 문서 + +- `planning.md`: delivery plan, dependency, risks, validation entry criteria +- `design.md`: write-model/correction/MCP/UI design +- `implementation.md`: changed files, execution log, validation commands +- `verification.md`: AC mapping, PASS/FAIL, REQ Implemented decision diff --git a/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/planning.md b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/planning.md new file mode 100644 index 0000000..b000f99 --- /dev/null +++ b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/planning.md @@ -0,0 +1,37 @@ +# BAT-009 Planning + +## Scope + +- Implement REQ-011: manual asset create/update/deactivate write model, snapshot preview/close persistence, append-only correction policy. +- Implement REQ-012: mutation MCP tools/contracts, operator dashboard UX, source/is_closed_snapshot alignment. +- Preserve REQ-005/REQ-010 safe fallback when manual asset write paths are unused or new write metadata is absent. +- Use synthetic/temp data only for tests and verification evidence. + +## Dependencies + +- dcy-009 confirmed Discovery and approved REQ-011/REQ-012. +- Existing BAT-008 read model in `jarca_ledger_mcp/db.py`, `service.py`, `server.py`, `dashboard/app.py`. +- Existing dcy-007 response contract / `tests/test_contract_regression.py` expectations. +- Additive/idempotent SQLite migration discipline and dcy-002 backup/approval safety policy. + +## Work plan + +1. Create BAT-009 standard batch docs and register entry. +2. Extend schema/service write path for manual asset update/deactivate and snapshot preview/close/correction metadata. +3. Add mutation MCP endpoints/contracts for manual asset CRUD and snapshot preview/close. +4. Add dashboard operator flows for manual asset management and month-end close preview/execute. +5. Update baseline docs (`docs/data-model.md`, `docs/interface-contract.md`, `docs/project-structure.md`, `docs/runtime-flows.md`). +6. Run narrow validation first (targeted tests / contract regression / synthetic service probe / dashboard smoke). +7. If evidence satisfies ACs, move BAT-009 to release-candidate and REQ-011/REQ-012 to Implemented. + +## Design Gate + +- Result: standard batch 유지. +- Reason: schema, service, MCP contract, dashboard UX, correction policy, baseline docs를 함께 바꾸므로 design/verification depth가 필요하다. + +## Risks and mitigations + +- Correction semantics drifting from read model: mitigate by explicit correction metadata, latest-valid-close selection tests, and read-after-close probes. +- Mutation contract drift vs dcy-007: mitigate by extending contract regression snapshots/tests for new tools. +- Dashboard operator flow accidentally mutating without preview clarity: mitigate by separate preview/close actions and source/is_closed labeling. +- Hard delete breaking historical traceability: mitigate by soft delete default plus tests asserting deactivated items do not erase prior snapshots. diff --git a/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/verification.md b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/verification.md new file mode 100644 index 0000000..d4c20cd --- /dev/null +++ b/docs/batches/bat-009_20260701_manual-asset-crud-snapshot-close/verification.md @@ -0,0 +1,48 @@ +# BAT-009 Verification + +## Verification summary + +Status: PASS + +Evidence was collected against synthetic/temp data only. No production ledger DB mutation or real financial files were used. + +## Acceptance criteria mapping + +### REQ-011 + +- AC-1: PASS — `manual_assets` create/update/deactivate write path and additive schema migration/correction columns implemented in `db.py`/`service.py`; full pytest PASS confirms existing/new DB initialization succeeds. +- AC-2: PASS — `preview_asset_snapshot_close` exists and targeted tests verify side-effect-free totals/warnings behavior. +- AC-3: PASS — `close_asset_snapshot` writes basis-date authority-source rows with close metadata and is covered by `tests/test_asset_snapshots.py`. +- AC-4: PASS — same-date re-close requires correction reasoning and appends correction rows via `snapshot_run_id` / `correction_of_run_id` / `is_correction`; targeted tests verify append-only behavior. +- AC-5: PASS — latest-run snapshot read selection plus shared valuation helpers keep read model/report consistency; targeted + full pytest PASS. +- AC-6: PASS — `docs/data-model.md` updated for write lifecycle, close/correction metadata, and latest-run read policy. + +### REQ-012 + +- AC-1: PASS — MCP tools `create_manual_asset`, `update_manual_asset`, `deactivate_manual_asset` are exposed in `server.py`; `hermes mcp test jarca-ledger` discovered all 56 tools including BAT-009 additions. +- AC-2: PASS — `preview_asset_snapshot_close` MCP/service returns preview totals, warnings, `source='live_authority'`, `is_closed_snapshot=false`; targeted tests PASS. +- AC-3: PASS — `close_asset_snapshot` MCP/service returns close metadata and correction markers with `source='snapshot'`, `is_closed_snapshot=true`; targeted tests PASS. +- AC-4: PASS — `dashboard/app.py` adds `자산/마감` page with manual asset add/update/deactivate and snapshot preview/close UX; browser smoke rendered the page successfully. +- AC-5: PASS — browser/render + targeted tests confirm `source`/`is_closed_snapshot` aligned across preview/close/report flows and dashboard messaging. +- AC-6: PASS — `tests/test_contract_regression.py` and `tests/contract_snapshots/response_contract_v1_0.json` updated; `docs/interface-contract.md` updated; targeted/full pytest PASS. + +## Requirement state decision + +- REQ-011: Implemented. +- REQ-012: Implemented. + +## Validation evidence + +- `python3 -m py_compile jarca_ledger_mcp/*.py dashboard/app.py` → PASS +- `python3 -m pytest tests/test_asset_snapshots.py tests/test_contract_regression.py tests/test_dashboard_app.py -q` → PASS (`12 passed`) +- `python3 -m pytest -q` → PASS (`42 passed, 4 skipped`) +- `hermes mcp test jarca-ledger` → PASS (`Connected`, `Tools discovered: 56`) +- Browser smoke on Streamlit `자산/마감` page → PASS, JS errors 없음 + +## Remaining issues + +### Hard blockers +- None. + +### Non-blocking follow-up +- Automatic valuation feeds and scheduled month-end close remain explicitly out of scope for BAT-009. diff --git a/docs/batches/index.md b/docs/batches/index.md index 225d76d..70cf76e 100644 --- a/docs/batches/index.md +++ b/docs/batches/index.md @@ -16,3 +16,4 @@ | 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/) | | BAT-007 | standard | release-candidate | REQ-007, REQ-008 | dcy-007 | [bat-007_20260630_mcp-response-contract-ci-versioning](bat-007_20260630_mcp-response-contract-ci-versioning/) | | BAT-008 | standard | release-candidate | REQ-009, REQ-010 | dcy-008 | [bat-008_20260630_asset-snapshots-manual-assets-read-mcp](bat-008_20260630_asset-snapshots-manual-assets-read-mcp/) | +| BAT-009 | standard | release-candidate | REQ-011, REQ-012 | dcy-009 | [bat-009_20260701_manual-asset-crud-snapshot-close](bat-009_20260701_manual-asset-crud-snapshot-close/) | diff --git a/docs/data-model.md b/docs/data-model.md index 74b91ef..0e2200d 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -4,8 +4,8 @@ - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Status: draft - Owner: Jarca / project maintainer -- Last Updated (KST): 2026-06-30 -- Source Discovery / Batch: bootstrap-baseline, BAT-008 +- Last Updated (KST): 2026-07-01 +- Source Discovery / Batch: bootstrap-baseline, BAT-008, BAT-009 ## Project Summary - Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 현금흐름 분석을 SQLite에 저장하고 운영한다. @@ -62,6 +62,8 @@ - Referenced by `asset_snapshots.source_type='manual_asset'` and `source_id` when a month-end valuation is snapshotted. - State Rules: - `manual_assets` is the live authority source for non-transaction assets/liabilities. + - Create/update mutation validates name, date, liquidity bucket, source evidence fields, and stores changes additively in the existing row; inactive rows are hidden from default valuation reports. + - Soft delete/deactivate (`active=false`) is the default deletion semantic so historical `asset_snapshots.source_id` remains reproducible. Hard delete is exceptional cleanup only. - Missing `valuation_amount` rows are excluded; transaction sums are not used as a substitute. - `loan` bucket values are treated as liabilities and subtracted from asset-bucket totals. @@ -78,11 +80,19 @@ - `amount` :: integer nullable :: snapshotted valuation amount - `liquidity_bucket` :: text :: bucket copied from the source at capture time - `valuation_source`, `source_note`, `memo` :: text :: evidence and operator context + - `snapshot_run_id` :: text :: month-end close run identifier for rows created together + - `correction_of_run_id` :: text :: previous close run corrected by this append-only run when applicable + - `correction_reason` :: text :: operator reason required for same-date re-close/correction + - `is_correction` :: integer/bool :: marks correction close rows + - `closed_at` :: text :: close execution timestamp copied across the run - `created_at` :: text :: snapshot row creation timestamp - Relationships: - Represents a historical record of `LedgerAccount.last_balance` or `ManualAsset.valuation_amount` rather than a new primary input source. - State Rules: - - Snapshot rows are append-only for BAT-008. Existing rows are not destructively overwritten. + - Snapshot rows are append-only. Existing rows are not destructively overwritten. + - `preview_asset_snapshot_close` computes the source item list, bucket totals, total assets/liabilities, net worth, and warnings without writing rows. + - `close_asset_snapshot` writes one run of item-level rows for a basis date from live authority sources. Same-date re-close requires `correction_reason`, appends a new correction run, and links to the previous `snapshot_run_id` when available. + - Read reports choose the latest run for a basis date when run metadata exists, preserving original and correction rows for audit while avoiding double counting. - Basis-date snapshot reports prefer stored `asset_snapshots` rows when rows exist for the date; otherwise read reports can fall back to live authority sources. ## Entity: Transaction @@ -204,7 +214,7 @@ - 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 is tracked by REQ-003/BAT-003; future work may add a dedicated reconciliation workbench and persistent review-decision queue. -- BAT-008 adds `manual_assets` and `asset_snapshots` with read-only MCP/report support. Future work may add mutation MCP for snapshot creation/manual asset CRUD and automatic market/real-estate valuation. +- BAT-009 implements manual asset CRUD mutation and month-end snapshot preview/close/correction service/MCP/dashboard workflow. Future work may add automatic market/real-estate valuation or scheduled month-end close. ## Change Log - 2026-06-26 22:24 KST: Created by `bootstrap-baseline` from repository observation. @@ -212,3 +222,4 @@ - 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` 기반 자산 유동성/부채/순자산 규칙 추가. - 2026-06-30 KST: REQ-009/REQ-010/BAT-008 기준으로 `manual_assets`, `asset_snapshots`, 권위 소스(`accounts.last_balance` + `manual_assets`), 순자산 산식/미평가 제외 정책 추가. +- 2026-07-01 KST: REQ-011/REQ-012/BAT-009 기준으로 manual asset CRUD, snapshot preview/close, append-only correction metadata, latest-run read policy 추가. diff --git a/docs/discovery/dcy-008_20260630_asset-snapshot-manual-valuation-system.md b/docs/discovery/dcy-008_20260630_asset-snapshot-manual-valuation-system.md index f3980e7..15b590d 100644 --- a/docs/discovery/dcy-008_20260630_asset-snapshot-manual-valuation-system.md +++ b/docs/discovery/dcy-008_20260630_asset-snapshot-manual-valuation-system.md @@ -7,7 +7,7 @@ - 작성 시각(KST): 2026-06-30 20:38 KST - 마지막 갱신 시각(KST): 2026-06-30 21:17 KST - 대체됨: 없음 -- 후속 Discovery 참조: 없음 +- 후속 Discovery 참조: `docs/discovery/dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md` (후속으로 미룬 manual asset CRUD·snapshot creation/mutation·월말 마감 운영 워크플로우를 독립 주기로 이어받음) - 생성된 REQ 참조: `REQ-009`, `REQ-010` # 1. 계획 상태 요약 diff --git a/docs/discovery/dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md b/docs/discovery/dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md new file mode 100644 index 0000000..2dc25c1 --- /dev/null +++ b/docs/discovery/dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md @@ -0,0 +1,237 @@ +# 0. 문서 상태 +이 문서의 현재 상태와 식별 정보를 기록한다. 문서 추적과 승인 흐름에 필요한 최소 메타데이터를 적는다. + +- 상태: confirmed +- 문서 ID: dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow +- 이슈명: 수기 자산 CRUD + 월말 스냅샷 생성/마감 운영 워크플로우 +- 작성 시각(KST): 2026-07-01 08:52 KST +- 마지막 갱신 시각(KST): 2026-07-01 09:00 KST +- 대체됨: 없음 +- 후속 Discovery 참조: 없음 +- 생성된 REQ 참조: `REQ-011`, `REQ-012` + +# 1. 계획 상태 요약 +현재 Discovery가 어떤 배경에서 작성되고 있는지, 연계 문서가 있는지, 지금 시점의 해석이 무엇인지 짧게 요약한다. + +- 연계 문서 현황: + - 선행 Discovery: dcy-008(`docs/discovery/dcy-008_20260630_asset-snapshot-manual-valuation-system.md`) — `asset_snapshots`/`manual_assets` 모델·평가 체계·조회성 MCP 범위를 confirmed로 확정하고, 스냅샷 생성·수기 자산 CRUD 같은 mutation MCP는 명시적으로 후속 범위로 분리함 + - 관련 TODO/메모: `docs/data-model.md` 207행과 `docs/runtime-flows.md` 68·73·134행이 "manual asset CRUD와 snapshot creation MCP는 future scope / 후속 REQ 필요"라고 명시 보류 +- 해석: + - 현재 BAT-008 구현으로 `manual_assets`/`asset_snapshots` 스키마, 공유 평가 helper, 조회성 read model(`get_asset_snapshot_report`/`get_net_worth_timeseries`/`list_manual_assets`)은 존재하지만, 수기 자산을 등록·수정·삭제하거나 월말 스냅샷을 생성·마감하는 mutation 경로와 운영자 워크플로우가 없어 데이터가 채워지지 않으면 read model이 빈 폴백 상태로만 동작한다. + - 이번 Discovery는 dcy-008이 후속으로 미룬 manual asset CRUD, snapshot creation/mutation path, 월말 마감 운영자 워크플로우(preview/review, overwrite policy, basis-date 중복 처리)와 dashboard live vs snapshot source 명확화를 독립 주기로 정의한다. + +# 2. 요구사항 판정 결과 +이번 요청이 기존 요구사항과 비교해 신규인지, 유사한지, 중복인지 판단하고 그 근거를 남긴다. + +- 판정: 신규 +- 근거 + - 가장 근접한 dcy-008은 `asset_snapshots`/`manual_assets` 모델과 평가 체계, 조회(read) 중심 MCP 범위를 confirmed로 다루며 mutation MCP(스냅샷 생성·수기 자산 CRUD)를 Out of Scope로 명시 제외했다. 이번 요청은 그 제외된 mutation 경로와 월말 마감 운영 워크플로우를 신규로 정의하므로 dcy-008의 문구·측정 보강 수준 미세 조정이 아니다. + - dcy-008은 confirmed 상태로 REQ-009/REQ-010/BAT-008(구현 완료)까지 진행돼 read model 전제가 고정돼 있어, 쓰기 경로·overwrite/중복 정책·운영자 마감 절차 같은 새 책임을 같은 문서에 누적하면 책임 경계가 흐려진다. 후속 반복을 독립 주기로 추적하는 편이 명확하다. + +# 3. 문제점의 요약 +문제의 현재 상태, 기대 상태, 관련 이해관계자, 제약사항을 정리한다. Discovery의 핵심 맥락을 가장 먼저 이해할 수 있어야 한다. + +- 현재 상태 + - `docs/data-model.md`(207행)와 `docs/runtime-flows.md`(73·134행)는 BAT-008이 `manual_assets`/`asset_snapshots`에 read-only MCP/report 지원만 추가했고 "snapshot creation/manual asset CRUD mutation MCP는 future scope"라고 명시한다. 즉 쓰기 경로가 의도적으로 비어 있다. + - `docs/runtime-flows.md`(67~73행) `Asset snapshot and net-worth read model` flow는 월말 수동 마감이 `asset_snapshots`에 항목 단위 행을 기록한다고 기술하지만, "BAT-008 stores the schema and service helper but does not expose snapshot creation as an MCP mutation"이라 실제 스냅샷 생성·마감을 수행하는 운영 경로가 없다. + - `docs/data-model.md`(85~86행)는 스냅샷 행이 BAT-008 기준 append-only이며 파괴적 덮어쓰기를 하지 않는다고만 규정할 뿐, 동일 basis-date 재마감 시 중복 처리·overwrite·정정 정책은 정의돼 있지 않다. + - `docs/runtime-flows.md`(51~52·71행) 기준 dashboard/read report는 live authority(`accounts.last_balance` + `manual_assets`)와 stored snapshot을 상황에 따라 섞어 쓰며, snapshot rows가 있으면 stored, 없으면 `source='live_authority'`로 폴백한다. 그러나 운영자/사용자가 화면에서 보는 값이 live인지 마감된 snapshot인지 구분·표기하는 규칙이 약하다. + - 관련 구현은 `jarca_ledger_mcp/service.py`(평가 helper·snapshot/timeseries read), `jarca_ledger_mcp/db.py`(`manual_assets`/`asset_snapshots` 스키마), `jarca_ledger_mcp/server.py`(조회성 MCP), `dashboard/app.py`(`get_asset_liquidity_report`)에 존재하나, 수기 자산 입력 UI/MCP write, 스냅샷 생성·마감 핸들러는 존재하지 않는다. + - `docs/project-structure.md`, `docs/runtime-flows.md`, `docs/data-model.md`, `docs/interface-contract.md` baseline 문서는 read model까지만 반영하며 mutation 경로·운영자 마감 워크플로우를 아직 담지 않는다. +- 기대 상태 + - 사용자가 수기 자산/부채를 생성·수정·삭제(CRUD)할 수 있고, 각 변경이 기준일·출처 메모·`liquidity_bucket`과 함께 일관되게 검증·기록된다. + - 월말 마감 운영자 워크플로우가 정의돼, 마감 전 preview/review로 어떤 항목이 어떤 평가값으로 스냅샷될지 확인한 뒤 특정 basis-date의 자산 스냅샷을 생성·마감한다. + - 동일 basis-date 재마감, overwrite/정정, 중복 행 처리 정책이 단일 정의로 확정돼 이력 보존성과 정정 가능성이 양립한다. + - 대시보드/리포트가 표시하는 값이 live authority인지 마감된 snapshot인지 명확히 구분·표기되어 사용자가 "확정 마감값"과 "현재 추정값"을 혼동하지 않는다. +- 이해관계자 + - 월말에 자산/부채를 입력·마감하고 순자산 추이를 관리하는 사용자 겸 운영자(대시보드 1차 사용자) + - 문서/요구사항 리뷰어 및 승인자(Hermes/사용자) + - 데이터 모델·서비스·DB·MCP·대시보드 mutation 경로를 구현/운영하는 개발 담당자 +- 제약사항 + - DB 스키마/데이터 변경은 additive/idempotent 원칙을 따르고, 파괴적 마이그레이션·일괄 덮어쓰기는 명시 승인·백업 후에만 한다(`docs/data-model.md` Persistence Notes, dcy-002 운영 안전장치). + - dcy-008/REQ-009·REQ-010 read model의 평가 권위 소스(`accounts.last_balance` + `manual_assets`)·순자산 산식(자산 합계 − `loan` 차감, 미평가 제외)과 충돌하지 않아야 한다. + - 신규 mutation MCP는 dcy-007/BAT-007에서 확정한 MCP response contract(성공/오류 envelope, contract versioning)와 정합해야 한다. + - 본 Discovery 단계에서는 소스 코드/테스트/스키마를 변경하지 않고 사양만 정의한다. + +## 주요 사용자 시나리오 +사용자 또는 운영자가 실제로 어떤 상황에서 이 변경을 필요로 하는지 대표 시나리오를 적는다. + +- 사용자가 새 전세보증금·예적금·대출 항목을 수기 자산으로 등록하고, 이후 평가값이 바뀌면 같은 항목을 수정하거나 더 이상 보유하지 않는 항목을 비활성/삭제한다. +- 운영자가 월말 마감 시 "이번 달 말 기준으로 어떤 계좌·수기 자산이 어떤 평가값으로 스냅샷될지"를 preview로 검토한 뒤 해당 basis-date 스냅샷을 생성·마감한다. +- 운영자가 이미 마감한 basis-date에 입력 오류를 발견해 재마감/정정을 시도할 때, 시스템이 중복 생성 대신 정해진 overwrite·정정 정책에 따라 처리하고 이력을 남긴다. +- 사용자가 대시보드에서 특정 월 순자산을 볼 때, 그 값이 마감된 snapshot인지 아직 마감 전 live 추정값인지 화면 표기로 즉시 구분한다. + +# 4. 이번에 정의할 변경 +이번 Discovery에서 다루는 핵심 변경 항목을 목록으로 적는다. 기능, 구조, 규칙 변경을 함께 포함해도 된다. + +- 수기 자산 CRUD 경로 정의: `manual_assets` 항목의 생성·수정·삭제(또는 비활성)와 입력 검증(필수 필드: 자산명, 평가 금액, 기준일, `liquidity_bucket`, 출처 메모), 대시보드 입력 UI 및 조회성 외 mutation MCP 응답 계약을 사양으로 정의한다. +- 월말 스냅샷 생성/마감 경로 정의: 특정 basis-date에 대해 live authority(`accounts.last_balance` + `manual_assets`)를 항목 단위로 고정해 `asset_snapshots`에 기록하는 생성·마감 핸들러와, 마감 전 preview/review(스냅샷될 항목·평가값·합계 미리보기) 단계를 정의한다. +- overwrite/중복/정정 정책 정의: 동일 basis-date 재마감은 append-only를 유지하되 기존 마감값을 덮어쓰지 않고 정정 행을 추가하는 정책으로 확정한다. basis-date 중복 키는 "기존 마감 존재 + 정정 사유/근거 메모를 가진 추가 정정 실행"으로 처리하고, 정정 전 백업/승인 연계를 필수로 둔다. +- live vs snapshot source 명확화: 대시보드/리포트/ MCP 응답이 표시값의 출처(`live_authority` vs stored snapshot)와 마감 여부를 명시적으로 구분·표기하도록 표기 규칙을 정의한다. +- baseline 문서 반영: 위 mutation 데이터 흐름(CRUD, 스냅샷 생성/마감, preview, overwrite 정책, source 표기)을 `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md`, `docs/interface-contract.md` 갱신 요구로 등록한다. + +## 영향 범위 +변경이 미칠 코드/문서/운영 범위를 명시해 REQ 정제와 batch planning에서 추정 누락을 줄인다. + +- 코드 영향: + - `jarca_ledger_mcp/db.py` 수기 자산 CRUD·스냅샷 생성/마감 write helper(additive, idempotent 키 처리), `jarca_ledger_mcp/service.py` 마감·preview·overwrite 정책 로직, `jarca_ledger_mcp/server.py` mutation MCP 도구(수기 자산 CRUD, 스냅샷 생성/마감) 및 dcy-007 response contract 정합. + - `dashboard/app.py` 수기 자산 입력/편집 UI, 월말 마감 preview/실행 화면, live vs snapshot source 표기. +- 문서 영향: + - `docs/data-model.md`(스냅샷 overwrite/중복/정정 정책, `manual_assets` mutation 규칙), `docs/runtime-flows.md`(CRUD·스냅샷 생성/마감·preview 흐름, source 표기), `docs/project-structure.md`(mutation 경로 모듈), `docs/interface-contract.md`(mutation MCP 응답 계약) 갱신. + - dcy-008/REQ-009·REQ-010 read model 및 dcy-007 response contract와의 정합성 명시. +- 운영 영향: + - 월말 마감 절차·수기 자산 입력 루틴이 운영 표준 워크플로우로 신설되며, 정정/재마감 시 백업·승인 절차(dcy-002)와 연계된다. + +# 5. 요구사항 목록 +실제로 구현과 검증으로 이어질 요구사항을 기능/비기능으로 나누어 정리하고, 범위 경계를 명확히 적는다. + +## 기능 요구사항 +- FR-1: 수기 자산/부채 항목의 생성·수정·삭제(또는 비활성) 경로를 도입한다. 각 mutation은 필수 필드(자산명, 평가 금액, 기준일, `liquidity_bucket` ∈ `cash`/`investment`/`deposit`/`real_estate`/`loan`/`other`, 출처 메모)를 검증하고, 삭제는 이력 보존을 위해 비활성/soft delete를 기본 정책으로 확정한다. hard delete는 운영 복구가 불필요한 예외 정리 작업에 한해 별도 승인 하에서만 허용한다. +- FR-2: 특정 basis-date에 대해 live authority(`accounts.last_balance` + 활성 `manual_assets`)를 항목 단위로 고정해 `asset_snapshots`에 기록하는 스냅샷 생성/마감 경로를 도입한다. 마감은 항목별 source_type(`account`/`manual_asset`)·source_id·평가값·`liquidity_bucket`을 capture 시점 기준으로 보존한다. +- FR-3: 스냅샷 마감 실행 전 preview/review 단계를 제공한다. preview는 해당 basis-date에 스냅샷될 항목 목록·평가값·버킷별 합계·총자산·총부채·순자산을 마감 실행 없이 보여주고, 미평가/제외 항목을 경고로 표기한다. +- FR-4: 동일 basis-date 재마감·중복·정정 정책을 단일 정의로 확정한다. 기본 정책은 append-only + 정정 행 추가이며, basis-date 중복 키는 overwrite가 아니라 정정 실행으로 처리한다. 정정 시에는 dcy-002 백업·승인 절차를 선행하고, 원본 마감 행과 정정 행이 함께 남아 기준일별 재현 가능성을 유지해야 한다. +- FR-5: 대시보드/리포트/MCP 응답이 표시값의 출처를 명확히 구분한다. 각 자산·순자산 값이 stored snapshot 기반인지 `live_authority` 추정인지, 그리고 마감 여부를 응답 필드/화면 표기로 노출한다. +- FR-6: 수기 자산 CRUD·스냅샷 생성/마감 mutation MCP 도구는 dcy-007/BAT-007의 MCP response contract(성공/오류 envelope, contract versioning)와 정합한 응답 계약을 따른다. +- FR-7: `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md`, `docs/interface-contract.md` baseline 문서에 수기 자산 CRUD·스냅샷 생성/마감·preview·overwrite 정책·source 표기 데이터 흐름 갱신을 포함한다. + +## 비기능 요구사항 +성능, 운영성, 일관성, 자동화 안정성 등 기능 외 요구사항을 적는다. + +- NFR-1 (일관성): mutation 경로가 채운 값과 dcy-008 read model이 읽는 값은 동일한 평가 권위 소스·순자산 산식(자산 합계 − `loan` 차감, 미평가 제외)을 따라야 하며, 쓰기/읽기 간 산식 불일치가 없어야 한다. +- NFR-2 (이력 보존성): 수기 자산 삭제와 스냅샷 정정은 과거 마감 기록을 파괴적으로 잃지 않도록 soft delete/이력 행을 기본으로 하고, 기준일별 재현 가능성을 보장한다. +- NFR-3 (안전성): 스냅샷 생성/마감·정정 mutation은 additive/idempotent로 적용하고, dcy-002 백업·복구 안전장치와 연계해 재마감/정정 시 데이터 유실 위험을 통제한다. +- NFR-4 (계약 안정성): 신규 mutation MCP 도구는 dcy-007 response contract 버전·envelope를 깨지 않고, contract-regression CI 범위에서 검증 가능해야 한다. + +## 범위 경계 +이번 반복에서 반드시 포함할 범위와 명시적으로 제외할 범위를 구분한다. + +- In Scope + - 수기 자산 CRUD(생성/수정/삭제 또는 비활성) 사양과 입력 검증 규칙. + - 월말 스냅샷 생성/마감 경로, preview/review 단계, basis-date overwrite/중복/정정 정책. + - live vs snapshot source 표기 규칙 및 dcy-007 response contract 정합 mutation MCP 응답 계약. + - 위 흐름의 `docs/data-model.md`/`docs/project-structure.md`/`docs/runtime-flows.md`/`docs/interface-contract.md` 갱신 요구. +- Out of Scope + - 외부 시세/부동산 평가 자동 연동, 실시간 잔액 연동, 자동 리밸런싱/세금·수익률 계산(dcy-008 Out of Scope 유지). + - 자산 스냅샷 자동 생성(스케줄러/cron) — 본 반복은 운영자 수동 마감 중심으로 한정하고, 월말 자동 스케줄링은 후속 반복으로 둔다. + - 본 단계에서의 실제 스키마/코드/테스트 변경(사양 정의만 수행). + +# 6. 리스크/가정 목록 +불확실성과 전제 조건을 분리해서 적는다. 리스크는 영향과 완화 방안을, 가정은 현재 판단의 전제를 기록한다. + +## 리스크 +- R-1 (High): 동일 basis-date 재마감/정정 정책이 모호하면 스냅샷 중복 행, 순자산 이중 집계, 과거 마감값 훼손이 발생할 수 있다. + - 완화 방안: FR-4에서 basis-date 중복 키 처리·overwrite/정정 규칙을 먼저 확정하고, 정정은 dcy-002 백업·승인 절차와 연계하며 append-only 이력과 양립하도록 REQ 초안에 명시한다. +- R-2 (High): mutation 경로(쓰기)와 dcy-008 read model(읽기)이 서로 다른 권위 소스·산식을 쓰면 마감값과 화면 표시값이 어긋난다. + - 완화 방안: NFR-1로 공유 평가 helper/산식 단일화를 고정하고, 마감 직후 read report로 동일 basis-date 순자산이 일치하는지 검증을 성공 기준에 포함한다. +- R-3 (Medium): 수기 자산 hard delete가 과거 스냅샷이 참조하는 source_id를 끊어 이력 재현을 깨뜨릴 수 있다. + - 완화 방안: FR-1/NFR-2에서 soft delete/비활성을 기본으로 하고, 스냅샷은 capture 시점 값을 자체 보존(`asset_snapshots` 복사 필드)하도록 정책을 명시한다. +- R-4 (Medium): 신규 mutation MCP 도구가 dcy-007 response contract/contract-regression 범위와 어긋나면 CI 계약 게이트가 깨진다. + - 완화 방안: FR-6/NFR-4로 contract envelope 정합을 요구하고, mutation success/오류 envelope를 contract snapshot 범위에 포함하도록 REQ에 명시한다. + +## 가정 +아직 확정되지 않았지만 현재 문서 작성의 기반이 되는 전제를 적는다. + +- A-1: 평가 권위 소스(`accounts.last_balance` + `manual_assets`)와 순자산 산식은 dcy-008/REQ-009·REQ-010에서 확정된 정의를 그대로 계승하며, 이번 반복은 그 위에 쓰기 경로만 추가한다. +- A-2: `asset_snapshots`는 dcy-008 기준 BAT-008에서 append-only로 도입돼 있으므로, 정정/overwrite는 기존 append-only 전제를 의도적으로 확장·재정의하는 변경이다. +- A-3: 스냅샷 생성/마감은 초기 운영 정책상 운영자 수동 실행을 권위 경로로 확정하고, 자동 스케줄링은 후속 범위로 둔다. +- A-4: mutation MCP는 dcy-007/BAT-007 response contract가 이미 mutation success envelope를 정의하고 있다는 전제를 재사용한다. + +## 오픈 질문 +답을 아직 모르는 항목을 적는다. 추정으로 덮지 말고 REQ 초안 작성 이전에 확인할 질문으로 남긴다. + +- OQ-1: 동일 basis-date 재마감 시 정책을 무엇으로 확정할지(① append-only 유지 + 정정 행 추가, ② 기존 행 overwrite, ③ 차단 후 명시적 재마감 명령) 결정한다. + - 상태: Answered + - 답: 기본 정책은 append-only 유지 + 정정 행 추가로 확정한다. overwrite는 기본 경로에서 허용하지 않으며, 동일 basis-date 재마감은 정정 실행과 백업/승인 연계로 처리한다. + - 반영 위치: `# 4. 이번에 정의할 변경`, FR-4, NFR-2, NFR-3 +- OQ-2: 수기 자산 삭제 시맨틱을 soft delete(비활성)로 고정할지, 조건부 hard delete를 허용할지 결정한다. + - 상태: Answered + - 답: 기본 정책은 soft delete(비활성)로 확정한다. hard delete는 운영 복구가 불필요한 예외 정리 작업에 한해 별도 승인 하에서만 허용한다. + - 반영 위치: FR-1, NFR-2, R-3 완화 방안 +- OQ-3: 스냅샷 생성/마감을 운영자 수동에 한정할지, 본 반복에서 월말 자동 스케줄링까지 포함할지 결정한다. + - 상태: Answered + - 답: 본 반복은 운영자 수동 마감에 한정하고, 월말 자동 스케줄링은 후속 반복으로 유지한다. + - 반영 위치: `# 4. 이번에 정의할 변경`, Out of Scope, A-3 + +### 오픈 질문 처리 기준 +- `Open`: 아직 답이 없고 다음 단계 진행에 영향이 있는 상태다. 문서에 유지한다. +- `Answered`: 답이 확정된 상태다. 관련 본문/요구사항에 반영한 뒤 오픈 질문 목록에서는 제거하거나 resolved 기록으로 이동한다. +- `Deferred`: 지금 단계에서 결정하지 않고 다음 단계 또는 후속 이슈로 넘기는 상태다. 넘길 대상과 이유를 반드시 적는다. +- `Dropped`: 더 이상 유효하지 않은 질문이다. 삭제 사유를 짧게 남기고 목록에서 제거한다. +- 처리 원칙 1: 질문만 남기지 말고 반드시 `상태`, `처리 방안`, `종료 조건`을 함께 적는다. +- 처리 원칙 2: 답이 나온 질문은 본문 반영 없이 단순 삭제하지 않는다. 반영 위치를 먼저 갱신한다. +- 처리 원칙 3: High 영향 질문은 `Deferred` 처리 후 처리 방안에 REQ 인계 추적 방식을 명시한다. + +# 7. 초기 성공 기준 +이 변경이 성공했다고 판단할 기준을 측정 가능하게 정의한다. 가능하면 데이터 출처까지 연결한다. + +- S-1: 수기 자산 CRUD와 월말 스냅샷 생성/마감(preview 포함) 경로가 입력 검증·source_type 보존·dcy-007 response contract 정합을 포함해 사양으로 정의된다. +- S-2: 동일 basis-date 재마감/중복/정정 정책과 수기 자산 삭제 시맨틱이 이력 보존성과 양립하는 단일 정의로 확정되고, 마감값과 dcy-008 read model 순자산이 같은 basis-date에서 일치하도록 검증 기준이 정의된다. +- S-3: 대시보드/리포트/MCP가 표시값의 출처(live vs snapshot)와 마감 여부를 구분 표기하는 규칙이 정의된다. +- S-4: 위 mutation 데이터 흐름이 `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md`, `docs/interface-contract.md`에 반영되고, dcy-008 read model·dcy-007 contract와의 정합성이 문서로 확인된다. + +## 측정 방식 및 데이터 출처 +각 성공 기준을 어떻게 측정할지와 어떤 데이터나 문서를 근거로 쓸지 적는다. + +- S-1 측정 방식: CRUD·스냅샷 생성/마감/preview 사양이 필드·검증·응답 envelope 수준까지 REQ로 내릴 수 있는지 검토 / 데이터 출처: dcy-008 `manual_assets`/`asset_snapshots` 모델, dcy-007 response contract, `jarca_ledger_mcp/service.py`·`db.py`·`server.py` +- S-2 측정 방식: 표본 basis-date 재마감·정정·삭제 케이스로 정책 결과와 read model 순자산 일치를 손검증과 비교 / 데이터 출처: `docs/data-model.md` 스냅샷 정책, `get_asset_snapshot_report`/`get_net_worth_timeseries` read model, dcy-005 순자산 정의 +- S-3 측정 방식: read report/대시보드 표기에 `source`(live_authority vs snapshot)·마감 여부 필드가 노출되는지 사양 검토 / 데이터 출처: `docs/runtime-flows.md` 71행 source 폴백 규칙, `docs/interface-contract.md` +- S-4 측정 방식: baseline 문서 diff에서 CRUD·스냅샷 생성/마감·preview·overwrite 정책·source 표기·dcy-008/dcy-007 정합 반영 여부 확인 / 데이터 출처: `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md`, `docs/interface-contract.md` + +# 8. REQ로 넘기기 전 확인 체크 +REQ 초안 작성 단계로 넘기기 전에 무엇이 확정되어야 하는지 확인한다. 미완료 항목은 후속 결정이 필요함을 뜻한다. + +- [x] 범위(In Scope/Out of Scope) 확정 + - 기준: 수기 자산 CRUD·스냅샷 생성/마감/preview 포함 범위와 자동 스케줄링 제외 여부가 사용자 confirm으로 확정됐는가. +- [x] 성공 지표의 측정 방식/데이터 출처 확정 + - 기준: S-1~S-4 검수 방식과 데이터 출처가 REQ 초안으로 내릴 수 있을 정도로 정의됐는가. +- [x] High 영향 리스크 우선순위 및 해소 정책 확정 + - 기준: R-1(재마감/정정 정책)·R-2(쓰기/읽기 산식 정합) 해소 정책이 Discovery 범위에서 확정됐고, 세부 구현 책임자 지정은 REQ/batch planning 단계로 이관한다. +- [x] 모든 오픈 질문(OQ) `Open` 상태 없음 확인 + - 기준: OQ 목록에 `Open` 상태 항목이 0개이거나, 잔여 항목 전부 `Deferred` 처리 완료. +- [x] Freeze 확정 담당자(CONFIRMED_BY) 지정 + - 기준: 사용자가 overwrite 정책·삭제 시맨틱·자동화 범위 결정을 승인하고 confirm을 지시했는가. + +# 9. 파일 처리 결과 +이 Discovery 문서 생성/갱신 결과와 참조 문서를 기록한다. 이후 추적과 리뷰 시 근거로 사용한다. + +- 처리 결과: 생성 +- 생성 경로: docs/discovery/dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md +- 참조 문서: + - docs/discovery/dcy-008_20260630_asset-snapshot-manual-valuation-system.md (선행 Discovery; read model·평가 체계 확정 및 mutation MCP 후속 보류 출처) + - docs/data-model.md (`manual_assets`/`asset_snapshots` 모델, append-only/source_type 보존 규칙, mutation·overwrite 정책 갱신 대상; stage-pilot/templates/project-structure.md를 논리 템플릿 참조로 사용) + - docs/runtime-flows.md, docs/project-structure.md, docs/interface-contract.md (CRUD·스냅샷 생성/마감·preview·source 표기 흐름/응답 계약 갱신 대상; stage-pilot/templates/runtime-flows.md, stage-pilot/templates/project-structure.md를 논리 템플릿 참조로 사용) + - docs/discovery/dcy-007_20260630_mcp-response-contract-ci-contract-versioning.md (mutation MCP 응답 계약 정합 근거) + - docs/discovery/dcy-002_20260627_operational-safety-backup-restore-validation-db.md (정정/재마감 시 백업·승인 안전장치 연계) + - jarca_ledger_mcp/service.py, jarca_ledger_mcp/db.py, jarca_ledger_mcp/server.py, dashboard/app.py (현재 read model·평가 helper 구현 근거) + +# 10. 사용자 결정 필요 항목 요약 +사용자나 승인자가 결정해야 하는 항목만 모아 정리한다. 결정, 확인, 데이터 요청을 구분해서 적는다. + +## DECIDE +- DECIDE_SNAPSHOT_OVERWRITE_POLICY: append-only + 정정 행 추가로 확정. overwrite는 기본 경로에서 허용하지 않고, 동일 basis-date 재마감은 정정 실행 + 백업/승인 연계로 처리한다. +- DECIDE_MANUAL_ASSET_DELETE_SEMANTICS: soft delete(비활성)를 기본으로 확정. hard delete는 운영 복구가 불필요한 예외 정리 작업에 한해 별도 승인 하에서만 허용한다. +- DECIDE_AUTO_SNAPSHOT_SCHEDULING: 본 반복 범위에서는 제외하고, 월말 자동 스케줄링은 후속 반복으로 둔다. + +## CONFIRM +- CONFIRM_SOURCE_LABELING: 대시보드/리포트/MCP는 모든 자산·순자산 응답에 `source`(`live_authority` 또는 `snapshot`)와 `is_closed_snapshot`를 명시하고, 화면에서도 마감값/실시간 추정값을 구분 표기하는 방향으로 확정. +- CONFIRM_MUTATION_MCP_CONTRACT: 수기 자산 CRUD·스냅샷 생성/마감 mutation MCP 응답은 dcy-007 contract envelope를 따르고 contract-regression 범위에 포함하는 방향으로 확정. + +## DATA +- DATA_MONTH_END_CLOSE_ROUTINE: 실제 월말 마감 운영 루틴(마감 시점, 입력 책임자, 정정 빈도)은 REQ/delivery 단계 입력 데이터로 수집한다. Discovery confirm의 차단 항목으로 남기지 않는다. + +# 11. Discovery Freeze +REQ 초안 작성 인계 시점의 확정 플래그와 섹션 참조만 기록한다. 내용 재작성 없이 원본 섹션을 링크로 대신한다. + +## 섹션 참조 +- 범위: [# 5. 요구사항 목록 > 범위 경계](#5-요구사항-목록) +- 리스크/오픈 질문: [# 6. 리스크/가정 목록](#6-리스크가정-목록) +- 성공 기준: [# 7. 초기 성공 기준](#7-초기-성공-기준) +- 인계 전 확인: [# 8. REQ로 넘기기 전 확인 체크](#8-req로-넘기기-전-확인-체크) +- 사용자 결정 필요: [# 10. 사용자 결정 필요 항목 요약](#10-사용자-결정-필요-항목-요약) + +## Freeze 플래그 +- Handoff Decision: 승인 +- Handoff Rationale: 권장안 기준으로 append-only + 정정 행 정책, soft delete 기본, 자동 스케줄링 후속 분리, source 표기, mutation MCP contract 정합 방향을 모두 확정했다. 월말 운영 루틴의 실제 입력 데이터는 REQ/delivery 단계 수집 항목이며 REQ 초안 작성을 막지 않는다. +- Ready for REQ Drafting: true +- Confirmed By: 진환 고 +- Confirmed At (KST): 2026-07-01 08:59 KST diff --git a/docs/discovery/index.md b/docs/discovery/index.md index 6a96ed3..99ce66e 100644 --- a/docs/discovery/index.md +++ b/docs/discovery/index.md @@ -14,3 +14,4 @@ | dcy-006_20260630_pytest-apptest-smoke-ci-automation | 20260630 | pytest/AppTest/Smoke CI 자동화 | [Discovery](./dcy-006_20260630_pytest-apptest-smoke-ci-automation.md) | confirmed | | dcy-007_20260630_mcp-response-contract-ci-contract-versioning | 20260630 | MCP response contract / CI contract versioning 정립 | [Discovery](./dcy-007_20260630_mcp-response-contract-ci-contract-versioning.md) | confirmed | | dcy-008_20260630_asset-snapshot-manual-valuation-system | 20260630 | 자산 스냅샷/수기 자산 테이블/평가 체계 정립 | [Discovery](./dcy-008_20260630_asset-snapshot-manual-valuation-system.md) | confirmed | +| dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow | 20260701 | 수기 자산 CRUD + 월말 스냅샷 생성/마감 운영 워크플로우 | [Discovery](./dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md) | confirmed | diff --git a/docs/interface-contract.md b/docs/interface-contract.md index c30a05f..613a003 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -4,8 +4,8 @@ - Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml` - Status: governed - Owner: Jarca / project maintainer -- Last Updated (KST): 2026-06-30 -- Source Discovery / Batch: bootstrap-baseline, dcy-007, BAT-007, BAT-008 +- Last Updated (KST): 2026-07-01 +- Source Discovery / Batch: bootstrap-baseline, dcy-007, BAT-007, BAT-008, BAT-009 ## Project Summary - Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 현금흐름 분석을 SQLite에 저장하고 운영한다. @@ -26,7 +26,7 @@ - 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, `get_asset_liquidity_report` -- Asset valuation reads: `list_manual_assets`, `get_asset_snapshot_report`, `get_net_worth_timeseries`; these are read-only in BAT-008 and use the same valuation policy as the dashboard. +- Asset valuation reads/writes: `list_manual_assets`, `create_manual_asset`, `update_manual_asset`, `deactivate_manual_asset`, `preview_asset_snapshot_close`, `close_asset_snapshot`, `get_asset_snapshot_report`, `get_net_worth_timeseries`; these use the same valuation policy as the dashboard and preserve append-only snapshot correction semantics. - 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 @@ -73,13 +73,16 @@ | --- | --- | --- | --- | --- | | 1.0 | 2026-06-30 | Initial governed baseline | dcy-007 / REQ-007 / BAT-007 | Formalizes compatibility governance without exposing runtime version fields or changing success payload shapes. | -### Asset Valuation Read Tools +### Asset Valuation Read/Mutation Tools - `list_manual_assets(active_only=true)` returns `success`, `manual_assets`, `count`, and `valuation_policy`. Each item exposes `name`, `valuation_amount`, `valuation_date`, `liquidity_bucket`, `source_note`, `memo`, and `authority_source='manual_assets'`. -- `get_asset_snapshot_report(basis_date='')` returns `success`, `basis_date`, `source`, `snapshot_count`, `buckets`, `total_assets`, `total_liabilities`, `net_worth`, `included_items`, `excluded_items`, and `valuation_policy`. - - If `asset_snapshots` rows exist for `basis_date`, `source='asset_snapshots'` and values come from stored snapshot rows. - - If no snapshot rows exist, `source='live_authority'` and the report reads `accounts.last_balance` plus active `manual_assets`. +- `create_manual_asset`, `update_manual_asset`, and `deactivate_manual_asset` return dcy-007-compatible success/error envelopes. Mutation success includes the affected `manual_asset` payload or deactivation metadata; soft delete (`active=false`) is the default. +- `preview_asset_snapshot_close(basis_date='')` returns `success`, `basis_date`, `source='live_authority'`, `is_closed_snapshot=false`, item lists, bucket totals, total assets/liabilities, net worth, and warnings. It is side-effect free. +- `close_asset_snapshot(basis_date='', source_note='', memo='', correction_reason='')` writes item-level snapshot rows and returns `source='snapshot'`, `is_closed_snapshot=true`, `snapshot_run_id`, correction metadata, inserted count, totals, and warnings. If rows already exist for the same basis date, `correction_reason` is required and the close appends a correction run instead of overwriting. +- `get_asset_snapshot_report(basis_date='')` returns `success`, `basis_date`, `source`, `is_closed_snapshot`, `snapshot_count`, `buckets`, `total_assets`, `total_liabilities`, `net_worth`, `included_items`, `excluded_items`, and `valuation_policy`. + - If `asset_snapshots` rows exist for `basis_date`, `source='asset_snapshots'`, `is_closed_snapshot=true`, and values come from the latest stored close run when run metadata exists. + - If no snapshot rows exist, `source='live_authority'`, `is_closed_snapshot=false`, and the report reads `accounts.last_balance` plus active `manual_assets`. - `get_net_worth_timeseries(start_date='', end_date='', months=12)` returns `success`, requested date bounds, `points`, `count`, and `valuation_policy`; each point contains `basis_date`, `total_assets`, `total_liabilities`, `net_worth`, `source`, and `item_count`. -- Contract impact: additive minor. Existing success payloads are preserved; `get_asset_liquidity_report` gains optional explanatory fields (`manual_assets`, `manual_asset_count`, `included_items`, `excluded_items`, `valuation_policy`) while preserving existing keys. +- Contract impact: additive minor. Existing success payloads are preserved; `get_asset_liquidity_report` gains optional explanatory fields (`manual_assets`, `manual_asset_count`, `included_items`, `excluded_items`, `valuation_policy`) while preserving existing keys, and BAT-009 adds new mutation/preview scopes to contract regression. ## Interface: CI Required-Check Contract - Required-check source of truth in code: `.github/workflows/ci.yml` job names are stable and must be mirrored by repository branch protection settings. diff --git a/docs/srs/Data/req-011_manual-asset-crud-month-end-snapshot-write-model.md b/docs/srs/Data/req-011_manual-asset-crud-month-end-snapshot-write-model.md new file mode 100644 index 0000000..b26794c --- /dev/null +++ b/docs/srs/Data/req-011_manual-asset-crud-month-end-snapshot-write-model.md @@ -0,0 +1,99 @@ +# REQ-011 Manual asset CRUD / month-end snapshot write model + +- Status: Implemented +- Type: Data +- Priority: High +- Owner: 렛저장인 +- Source Discovery: `docs/discovery/dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md` +- Batch: BAT-009 +- Created At (KST): 2026-07-01 09:00 KST +- Approved At (KST): 2026-07-01 09:02 KST +- Implemented At (KST): 2026-07-01 10:52 KST + +## 1. 배경 + +REQ-009/REQ-010은 `manual_assets`, `asset_snapshots`, 조회성 MCP, 대시보드 정렬까지 구현했지만 쓰기 경로는 의도적으로 제외했다. 현재는 read model과 평가 helper는 존재하지만, 수기 자산을 생성·수정·비활성 처리하거나 특정 기준일 자산 상태를 마감해 `asset_snapshots`에 확정하는 1급 운영 경로가 없다. dcy-009 confirmed Discovery에 따라 write model, 재마감 정책, 이력 보존 규칙을 구현 가능한 데이터/서비스 요구사항으로 고정해야 한다. + +## 2. Intent + +수기 자산 CRUD와 월말 마감 스냅샷 생성을 임시 수기 절차가 아니라 명시적 저장 계층/서비스 계층 기능으로 정립한다. 이 REQ의 목적은 `manual_assets` write lifecycle, preview 입력 모델, `asset_snapshots` append-only correction 정책, soft delete 원칙, 백업/승인 연계 안전장치를 구현 가능한 데이터 책임으로 고정하는 것이다. + +## 3. 범위 + +### In Scope +- `manual_assets`의 생성/수정/비활성(soft delete) write model을 정의한다. +- 특정 `basis_date` 기준 live authority(`accounts.last_balance` + 활성 `manual_assets`)를 스냅샷 행으로 고정하는 month-end snapshot close write path를 정의한다. +- snapshot preview를 위한 집계/검증 데이터 모델과 warning shape를 정의한다. +- 동일 `basis_date` 재마감 시 `append-only + 정정 행 추가` 정책과 중복/정정 메타데이터 규칙을 정의한다. +- dcy-002 운영 안전장치와 정합한 백업/승인 연계 규칙을 정의한다. +- `docs/data-model.md`와 필요 baseline 문서에 write model 책임을 반영한다. + +### Out of Scope +- 외부 시세/부동산 자동 평가 연동. +- 월말 자동 스케줄링/cron 생성. +- read-only MCP 응답 shape 자체의 상세 설계(별도 Interface REQ에서 다룸). +- 투자 조언/리밸런싱/세금/수익률 계산. + +## 4. Requirement + +### FR-1. Manual asset CRUD write lifecycle +- `manual_assets`는 create/update/deactivate 흐름을 지원해야 한다. +- 생성/수정 시 최소 필드 `name`, `valuation_amount`, `valuation_date`, `liquidity_bucket`, `source_note`를 검증해야 한다. +- 허용 `liquidity_bucket` 값은 `cash`, `investment`, `deposit`, `real_estate`, `loan`, `other`다. +- 삭제 기본 정책은 hard delete가 아니라 비활성/soft delete여야 한다. +- hard delete는 운영 복구가 불필요한 예외 정리 작업에 한해 별도 승인 하에서만 허용되어야 한다. + +### FR-2. Snapshot preview data model +- snapshot close 실행 전 preview는 해당 `basis_date`에 스냅샷될 계좌/수기 자산 항목 목록, 평가값, `liquidity_bucket`, source type, 합계, 경고를 계산할 수 있어야 한다. +- preview는 write side effect 없이 동작해야 한다. +- preview는 미평가/제외 항목, stale valuation, 중복 위험을 warning 형태로 드러낼 수 있어야 한다. + +### FR-3. Month-end snapshot close write path +- snapshot close는 특정 `basis_date`의 live authority를 항목 단위 `asset_snapshots` 행으로 고정해야 한다. +- close 시 각 행은 `source_type`, `source_id`, `display_name`, `valuation_amount`, `liquidity_bucket`, `basis_date`, close 근거 메모를 보존해야 한다. +- close 결과는 이후 read model이 동일 산식으로 재구성 가능한 형태여야 한다. + +### FR-4. Append-only correction policy +- 동일 `basis_date` 재마감 기본 정책은 overwrite가 아니라 `append-only + 정정 행 추가`여야 한다. +- 정정 실행은 기존 마감 행을 지우지 않고 correction 메타데이터(정정 사유, 정정 실행 시각, 정정 대상 basis_date)를 남겨야 한다. +- 정정 실행 전에는 dcy-002 기준 백업/승인 절차를 선행할 수 있어야 한다. +- read side는 최신 유효 마감 집합을 결정할 수 있도록 정정 계보를 추적할 수 있어야 한다. + +### FR-5. Valuation/rule consistency +- write path는 REQ-009/REQ-010이 확정한 권위 소스(`accounts.last_balance` + `manual_assets`)와 순자산 산식(자산성 버킷 합계 − `loan`, 미평가 제외)을 그대로 따라야 한다. +- write path가 생성한 snapshot과 read model의 보고 결과는 동일 `basis_date`에서 일치 검증 가능해야 한다. + +### FR-6. Baseline documentation alignment +- `docs/data-model.md`는 manual asset write lifecycle, soft delete semantics, correction metadata, snapshot close policy를 반영해야 한다. +- 필요 시 `docs/runtime-flows.md`, `docs/project-structure.md`는 preview → close → corrected-close 흐름과 write/read 책임 경계를 반영해야 한다. + +## 5. Acceptance Criteria + +- AC-1: 신규/기존 DB 모두에서 manual asset create/update/deactivate에 필요한 필드와 상태가 additive/idempotent하게 저장된다. +- AC-2: snapshot preview가 side effect 없이 항목 목록, bucket totals, total assets, total liabilities, net worth, warnings를 반환할 수 있다. +- AC-3: month-end snapshot close가 특정 `basis_date`의 authority source를 `asset_snapshots`에 항목 단위로 고정 저장한다. +- AC-4: 동일 `basis_date` 재마감은 overwrite 없이 correction append 정책으로 처리되고, 원본/정정 이력이 모두 보존된다. +- AC-5: manual asset soft delete와 snapshot correction이 있어도 REQ-009/REQ-010 read model은 동일 산식으로 일관된 결과를 낸다. +- AC-6: `docs/data-model.md`와 필요 baseline 문서가 본 REQ 범위를 반영한다. + +## 6. Impacted Area + +- `jarca_ledger_mcp/db.py` +- `jarca_ledger_mcp/service.py` +- `tests/test_asset_snapshots.py` +- 필요 시 `tests/test_service.py` +- `docs/data-model.md` +- 필요 시 `docs/runtime-flows.md` +- 필요 시 `docs/project-structure.md` + +## 7. Notes + +- 이 REQ는 write model, correction policy, persistence semantics를 다루므로 `Data` 타입으로 관리한다. +- MCP mutation contract와 operator UX는 `REQ-012`에서 다룬다. +- 월말 자동 스케줄링은 Discovery 결정에 따라 후속 범위로 유지한다. + +## 8. Change Log + +- 2026-07-01 09:00 KST: dcy-009 confirmed 범위를 바탕으로 Proposed 초안을 생성. +- 2026-07-01 09:02 KST: dcy-009 confirmed 결정사항(append-only correction, soft delete 기본, auto scheduling 후속)을 반영해 Approved로 전환. +- 2026-07-01 10:52 KST: BAT-009 구현, full pytest PASS, MCP tool discovery, browser smoke evidence를 근거로 Implemented로 전환. diff --git a/docs/srs/Interface/req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md b/docs/srs/Interface/req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md new file mode 100644 index 0000000..ec3e813 --- /dev/null +++ b/docs/srs/Interface/req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md @@ -0,0 +1,96 @@ +# REQ-012 Manual asset CRUD / snapshot close MCP and operator dashboard alignment + +- Status: Implemented +- Type: Interface +- Priority: High +- Owner: 렛저장인 +- Source Discovery: `docs/discovery/dcy-009_20260701_manual-asset-crud-month-end-snapshot-close-workflow.md` +- Batch: BAT-009 +- Created At (KST): 2026-07-01 09:00 KST +- Approved At (KST): 2026-07-01 09:02 KST +- Implemented At (KST): 2026-07-01 10:52 KST + +## 1. 배경 + +BAT-008은 조회성 MCP와 대시보드 read alignment까지 구현했지만, 운영자가 수기 자산을 입력·수정·비활성 처리하거나 월말 snapshot preview/close를 실행할 인터페이스는 남겨 두었다. dcy-009 confirmed Discovery는 mutation MCP와 operator dashboard UX가 동일 source 표기 규칙과 dcy-007 contract envelope를 따르면서 month-end close workflow를 제공해야 한다고 결정했다. + +## 2. Intent + +수기 자산 CRUD와 month-end snapshot close를 운영자가 실제로 사용할 수 있는 MCP mutation 및 대시보드 인터페이스로 정렬한다. 이 REQ의 목적은 preview/close/operator messaging/source labeling/contract regression 범위를 구현 가능한 인터페이스 요구사항으로 고정하는 것이다. + +## 3. 범위 + +### In Scope +- manual asset create/update/deactivate mutation MCP를 정의한다. +- snapshot preview/close mutation MCP를 정의한다. +- 대시보드에 manual asset 관리와 month-end snapshot preview/close operator UX를 정의한다. +- 모든 관련 응답/화면에 `source`와 `is_closed_snapshot` 표기를 정렬한다. +- dcy-007 response contract/contract regression 범위에 신규 mutation MCP를 포함한다. +- `docs/interface-contract.md`와 필요 baseline 문서를 갱신한다. + +### Out of Scope +- 외부 자동 시세 연동 UI/MCP. +- 월말 자동 스케줄링 UI/MCP. +- 투자 조언/리밸런싱 추천 UX. +- production DB 실데이터를 이용한 수동 운영 evidence. + +## 4. Requirement + +### FR-1. Manual asset mutation MCP +- manual asset create/update/deactivate MCP 도구를 제공해야 한다. +- 응답은 dcy-007 contract envelope와 호환되어야 하며 성공/오류 시 일관된 top-level shape를 유지해야 한다. +- create/update 결과는 최소한 식별자, 현재 상태(active/deactivated), 최근 평가 기준일, `liquidity_bucket`, source note를 돌려줄 수 있어야 한다. + +### FR-2. Snapshot preview/close MCP +- 특정 `basis_date`에 대한 snapshot preview MCP를 제공해야 한다. +- preview 응답은 side effect 없이 항목 목록, bucket totals, liabilities, net worth, warnings, `source='live_authority'`, `is_closed_snapshot=false`를 설명할 수 있어야 한다. +- snapshot close MCP는 close 완료 후 close metadata와 `source='snapshot'`, `is_closed_snapshot=true`를 포함한 결과를 반환해야 한다. +- correction close가 발생하면 overwrite가 아니라 correction run임을 응답에서 구분할 수 있어야 한다. + +### FR-3. Operator dashboard UX +- 대시보드는 manual asset 추가/수정/비활성 입력 경로를 제공해야 한다. +- 대시보드는 month-end snapshot preview와 close 실행 경로를 제공해야 한다. +- 화면은 현재 값이 live authority 기반 추정인지, 닫힌 snapshot 기반인지 명시적으로 구분해야 한다. +- correction/재마감이 있었으면 운영자가 그 사실을 인지할 수 있는 상태 문구 또는 메타데이터를 노출해야 한다. + +### FR-4. Dashboard/read alignment +- mutation 실행 후 대시보드와 read MCP는 동일 authority source와 동일 순자산 산식을 반영해야 한다. +- close 이전 preview와 close 이후 read report는 같은 `basis_date` 기준 동일 집계 규칙을 사용해야 한다. +- 신규 write model이 비어 있거나 미사용 상태여도 기존 REQ-005/REQ-010 경로는 안전 폴백을 유지해야 한다. + +### FR-5. Contract regression and documentation alignment +- 신규 mutation MCP는 `tests/test_contract_regression.py` 또는 동등 회귀 검증 범위에서 discovery/shape drift를 검출할 수 있어야 한다. +- `docs/interface-contract.md`는 manual asset CRUD 및 snapshot preview/close의 request/response contract를 반영해야 한다. +- 필요 시 `docs/runtime-flows.md`, `docs/project-structure.md`는 operator workflow와 UI/service/server 책임 경계를 반영해야 한다. + +## 5. Acceptance Criteria + +- AC-1: manual asset create/update/deactivate MCP가 정의되고 dcy-007 envelope와 정합한 응답을 반환한다. +- AC-2: snapshot preview MCP가 side effect 없이 preview totals/warnings/source flags를 반환한다. +- AC-3: snapshot close MCP가 close metadata와 closed snapshot source flags를 반환하고 correction close를 구분한다. +- AC-4: 대시보드가 manual asset CRUD와 month-end snapshot preview/close operator UX를 제공한다. +- AC-5: 대시보드/read MCP/close 결과가 `source` 및 `is_closed_snapshot` 표기를 일관되게 유지한다. +- AC-6: contract regression과 baseline 문서가 본 REQ 범위를 반영한다. + +## 6. Impacted Area + +- `jarca_ledger_mcp/server.py` +- `jarca_ledger_mcp/service.py` +- `dashboard/app.py` +- `tests/test_contract_regression.py` +- 필요 시 `tests/test_asset_snapshots.py` +- `docs/interface-contract.md` +- 필요 시 `docs/runtime-flows.md` +- 필요 시 `docs/project-structure.md` + +## 7. Notes + +- 이 REQ는 MCP mutation contract와 operator-facing dashboard flow를 다루므로 `Interface` 타입으로 관리한다. +- persistence semantics와 correction storage model은 `REQ-011`을 선행 입력으로 사용한다. +- 실제 월말 운영 루틴 세부값(누가 언제 마감하는지)은 구현/운영 evidence 단계에서 수집한다. + +## 8. Change Log + +- 2026-07-01 09:00 KST: dcy-009 confirmed 범위를 바탕으로 Proposed 초안을 생성. +- 2026-07-01 09:02 KST: dcy-009 confirmed 결정사항(source/is_closed_snapshot 표기, mutation MCP contract 정합)을 반영해 Approved로 전환. +- 2026-07-01 10:52 KST: BAT-009 구현, full pytest PASS, MCP tool discovery, browser smoke evidence를 근거로 Implemented로 전환. diff --git a/docs/srs/index.md b/docs/srs/index.md index 98d98a2..c9d2bab 100644 --- a/docs/srs/index.md +++ b/docs/srs/index.md @@ -33,7 +33,7 @@ ## Next Requirement ID -- Current: `REQ-011` +- Current: `REQ-013` - Rule: `Requirement Register`에 있는 가장 큰 REQ 번호보다 정확히 1 커야 한다. ## Requirement Register @@ -50,6 +50,8 @@ | REQ-008 | CI contract regression and lint required checks | Testing | Implemented | High | 렛저장인 | [req-008_ci-contract-regression-and-lint-required-checks.md](Testing/req-008_ci-contract-regression-and-lint-required-checks.md) | | REQ-009 | Asset snapshot/manual assets valuation model | Data | Implemented | High | 렛저장인 | [req-009_asset-snapshot-manual-assets-valuation-model.md](Data/req-009_asset-snapshot-manual-assets-valuation-model.md) | | REQ-010 | Asset snapshot/net worth read MCP and dashboard alignment | Interface | Implemented | High | 렛저장인 | [req-010_asset-snapshot-net-worth-read-mcp-and-dashboard-alignment.md](Interface/req-010_asset-snapshot-net-worth-read-mcp-and-dashboard-alignment.md) | +| REQ-011 | Manual asset CRUD / month-end snapshot write model | Data | Implemented | High | 렛저장인 | [req-011_manual-asset-crud-month-end-snapshot-write-model.md](Data/req-011_manual-asset-crud-month-end-snapshot-write-model.md) | +| REQ-012 | Manual asset CRUD / snapshot close MCP and operator dashboard alignment | Interface | Implemented | High | 렛저장인 | [req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md](Interface/req-012_manual-asset-crud-snapshot-close-mcp-and-operator-dashboard-alignment.md) | ## Recent Change Log Summary @@ -71,3 +73,6 @@ - 2026-06-30 21:17 KST: dcy-008 confirmed 범위를 `REQ-009`(asset snapshot/manual assets valuation model)과 `REQ-010`(asset snapshot/net worth read MCP and dashboard alignment) Proposed 상태로 생성했다. - 2026-06-30 21:24 KST: `REQ-009`, `REQ-010`을 승인 게이트 재검토 후 Approved로 전환했다. - 2026-06-30 22:05 KST: BAT-008 구현·검증 evidence, fresh Streamlit browser smoke, jarcaledgerqc PASS gate를 근거로 `REQ-009`, `REQ-010`을 Implemented로 전환했다. +- 2026-07-01 09:00 KST: dcy-009 confirmed 범위를 `REQ-011`(manual asset CRUD / month-end snapshot write model)과 `REQ-012`(manual asset CRUD / snapshot close MCP and operator dashboard alignment) Proposed 상태로 생성했다. +- 2026-07-01 09:02 KST: `REQ-011`, `REQ-012`를 승인 게이트 재검토 후 Approved로 전환했다. +- 2026-07-01 10:52 KST: BAT-009 구현·검증 evidence, full pytest PASS, MCP tool discovery, browser smoke를 근거로 `REQ-011`, `REQ-012`를 Implemented로 전환했다. diff --git a/jarca_ledger_mcp/db.py b/jarca_ledger_mcp/db.py index 9f5dba2..b9b195f 100644 --- a/jarca_ledger_mcp/db.py +++ b/jarca_ledger_mcp/db.py @@ -7,7 +7,7 @@ from pathlib import Path from typing import Iterator DEFAULT_DB_PATH = Path.home() / ".local" / "share" / "jarca" / "ledger.sqlite3" -SCHEMA_VERSION = "9" +SCHEMA_VERSION = "10" SCHEMA_SQL = """ PRAGMA journal_mode=WAL; @@ -165,12 +165,17 @@ CREATE TABLE IF NOT EXISTS asset_snapshots ( valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', + snapshot_run_id TEXT NOT NULL DEFAULT '', + correction_of_run_id TEXT NOT NULL DEFAULT '', + correction_reason TEXT NOT NULL DEFAULT '', + is_correction INTEGER NOT NULL DEFAULT 0, + closed_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date); CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id); -INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '9'); +INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '10'); """ ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = { @@ -286,10 +291,23 @@ def migrate_db(conn: sqlite3.Connection) -> None: valuation_source TEXT NOT NULL DEFAULT '', source_note TEXT NOT NULL DEFAULT '', memo TEXT NOT NULL DEFAULT '', + snapshot_run_id TEXT NOT NULL DEFAULT '', + correction_of_run_id TEXT NOT NULL DEFAULT '', + correction_reason TEXT NOT NULL DEFAULT '', + is_correction INTEGER NOT NULL DEFAULT 0, + closed_at TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL )""") + _add_missing_columns(conn, "asset_snapshots", { + "snapshot_run_id": "TEXT NOT NULL DEFAULT ''", + "correction_of_run_id": "TEXT NOT NULL DEFAULT ''", + "correction_reason": "TEXT NOT NULL DEFAULT ''", + "is_correction": "INTEGER NOT NULL DEFAULT 0", + "closed_at": "TEXT NOT NULL DEFAULT ''", + }) conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date)") conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_source ON asset_snapshots(source_type, source_id)") + conn.execute("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)") conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,)) diff --git a/jarca_ledger_mcp/server.py b/jarca_ledger_mcp/server.py index 96b343a..67dceae 100644 --- a/jarca_ledger_mcp/server.py +++ b/jarca_ledger_mcp/server.py @@ -59,6 +59,31 @@ def list_manual_assets(active_only: bool = True) -> dict: """List manual non-transaction assets/liabilities used for net-worth valuation.""" return service.list_manual_assets(active_only=active_only) +@mcp.tool() +def create_manual_asset(name: str, valuation_amount: int, valuation_date: str, liquidity_bucket: str, source_note: str, memo: str = "", currency: str = "KRW", active: bool = True) -> dict: + """Create a manual non-transaction asset/liability valuation row.""" + return service.create_manual_asset(name, valuation_amount, valuation_date, liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active) + +@mcp.tool() +def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None) -> dict: + """Update selected fields on a manual asset/liability valuation row.""" + return service.update_manual_asset(asset_id, name=name, valuation_amount=valuation_amount, valuation_date=valuation_date, liquidity_bucket=liquidity_bucket, source_note=source_note, memo=memo, currency=currency, active=active) + +@mcp.tool() +def deactivate_manual_asset(asset_id: str, hard_delete: bool = False) -> dict: + """Deactivate a manual asset/liability by default; hard delete only for exceptional cleanup.""" + return service.deactivate_manual_asset(asset_id, hard_delete=hard_delete) + +@mcp.tool() +def preview_asset_snapshot_close(basis_date: str = "") -> dict: + """Preview a month-end asset snapshot close without mutating the database.""" + return service.preview_asset_snapshot_close(basis_date=basis_date) + +@mcp.tool() +def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "") -> dict: + """Close an asset snapshot for a basis date; same-date re-close appends correction rows.""" + return service.close_asset_snapshot(basis_date=basis_date, source_note=source_note, memo=memo, correction_reason=correction_reason) + @mcp.tool() def get_asset_snapshot_report(basis_date: str = "") -> dict: """Return basis-date asset snapshot/net-worth report with live-authority fallback.""" diff --git a/jarca_ledger_mcp/service.py b/jarca_ledger_mcp/service.py index dc1a39d..b8bd47f 100644 --- a/jarca_ledger_mcp/service.py +++ b/jarca_ledger_mcp/service.py @@ -120,7 +120,7 @@ def row_to_dict(row: Any) -> dict[str, Any]: d["tags"] = json.loads(d["tags"] or "[]") except json.JSONDecodeError: d["tags"] = [] - for k in ("active", "is_fixed"): + for k in ("active", "is_fixed", "is_correction"): if k in d: d[k] = bool(d[k]) return d @@ -454,11 +454,32 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = return included, excluded -def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]: +def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]: rows = conn.execute( - "SELECT * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", + "SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,), ).fetchall() + if not rows: + return [], {} + run_rows = [row for row in rows if "snapshot_run_id" in row.keys() and row["snapshot_run_id"]] + if not run_rows: + return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} + latest = sorted(run_rows, key=lambda row: (int(row["_rowid"]), row["closed_at"] or row["created_at"] or "", row["snapshot_run_id"] or ""))[-1] + latest_run_id = latest["snapshot_run_id"] + selected = [row for row in run_rows if row["snapshot_run_id"] == latest_run_id] + metadata = { + "snapshot_run_id": latest_run_id, + "is_correction": bool(latest["is_correction"]), + "correction_of_run_id": latest["correction_of_run_id"] or "", + "correction_reason": latest["correction_reason"] or "", + "closed_at": latest["closed_at"] or latest["created_at"] or "", + "total_snapshot_rows_for_basis_date": len(rows), + } + return selected, metadata + + +def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]: + rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date) items: list[dict[str, Any]] = [] for row in rows: snap = row_to_dict(row) @@ -479,6 +500,10 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, "valuation_date": snap.get("basis_date", ""), "source_note": snap.get("source_note", ""), "memo": snap.get("memo", ""), + "snapshot_run_id": snap.get("snapshot_run_id", ""), + "is_correction": bool(snap.get("is_correction", False)), + "correction_of_run_id": snap.get("correction_of_run_id", ""), + "correction_reason": snap.get("correction_reason", ""), } ) return items @@ -552,6 +577,167 @@ def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy()) +def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]: + allowed_updates: list[str] = [] + params: list[Any] = [] + try: + if name is not None: + if not name.strip(): + return fail("validation_error", "manual asset name is required") + allowed_updates.append("name=?") + params.append(name.strip()) + if valuation_amount is not None: + allowed_updates.append("valuation_amount=?") + params.append(int(valuation_amount)) + if valuation_date is not None: + allowed_updates.append("valuation_date=?") + params.append(normalize_date(valuation_date, default_today=True)) + if liquidity_bucket is not None: + allowed_updates.append("liquidity_bucket=?") + params.append(normalize_liquidity_bucket(liquidity_bucket)) + if source_note is not None: + allowed_updates.append("source_note=?") + params.append(source_note) + if memo is not None: + allowed_updates.append("memo=?") + params.append(memo) + if currency is not None: + allowed_updates.append("currency=?") + params.append(currency or "KRW") + if active is not None: + allowed_updates.append("active=?") + params.append(int(bool(active))) + except Exception as e: + return fail("validation_error", str(e)) + if not allowed_updates: + return fail("validation_error", "no fields to update") + allowed_updates.append("updated_at=?") + params.extend([now_iso(), asset_id]) + with db(db_path) as conn: + cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params) + if cur.rowcount == 0: + return fail("not_found", "manual asset not found") + row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() + return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"}) + + +def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]: + with db(db_path) as conn: + if hard_delete: + cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,)) + else: + cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id)) + if cur.rowcount == 0: + return fail("not_found", "manual asset not found") + row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone() + payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete} + if row is not None: + payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"} + return ok(**payload) + + +def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str) -> dict[str, Any]: + account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) + manual_items, manual_excluded = _manual_asset_valuation_items(conn) + included = account_items + manual_items + excluded = account_excluded + manual_excluded + summary = _summarize_valuation_items(included, excluded) + existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0] + warnings: list[dict[str, Any]] = [] + for item in excluded: + warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", "")}) + for item in included: + item_date = str(item.get("valuation_date") or "")[:10] + if item_date and item_date < basis: + warnings.append({"code": "stale_valuation", "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "name": item.get("name", ""), "valuation_date": item_date, "basis_date": basis}) + for item in invalid_bucket_accounts: + warnings.append({"code": "invalid_liquidity_bucket", **item}) + if existing_rows: + warnings.append({"code": "existing_snapshot_rows", "basis_date": basis, "snapshot_row_count": int(existing_rows), "policy": "append_only_correction_required"}) + return { + "basis_date": basis, + "source": "live_authority", + "is_closed_snapshot": False, + "snapshot_count": 0, + "existing_snapshot_row_count": int(existing_rows), + "would_be_correction": bool(existing_rows), + "warnings": warnings, + **summary, + } + + +def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]: + try: + basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() + except Exception as e: + return fail("validation_error", str(e)) + with db(db_path) as conn: + preview = _snapshot_preview_from_conn(conn, basis) + return ok(**preview) + + +def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None) -> dict[str, Any]: + try: + basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() + except Exception as e: + return fail("validation_error", str(e)) + ts = now_iso() + run_id = new_id("snaprun") + with db(db_path) as conn: + preview = _snapshot_preview_from_conn(conn, basis) + included = preview["included_items"] + latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis) + is_correction = bool(preview["existing_snapshot_row_count"]) + if is_correction and not correction_reason.strip(): + return fail("validation_error", "correction_reason is required when closing a basis_date that already has snapshot rows", basis_date=basis, existing_snapshot_row_count=preview["existing_snapshot_row_count"]) + correction_of_run_id = latest_meta.get("snapshot_run_id", "") if latest_rows else "" + snapshot_ids: list[str] = [] + for item in included: + snapshot_id = new_id("snap") + row_note = source_note or item.get("source_note", "") or "month-end close" + row_memo = memo or item.get("memo", "") + conn.execute( + """INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + ( + snapshot_id, + basis, + item.get("source_type", ""), + item.get("source_id", item.get("id", "")), + item.get("name", ""), + int(item.get("amount") or 0), + item.get("currency", "KRW"), + normalize_liquidity_bucket(item.get("liquidity_bucket")), + item.get("authority_source", item.get("source_type", "")), + row_note, + row_memo, + run_id, + correction_of_run_id, + correction_reason.strip(), + int(is_correction), + ts, + ts, + ), + ) + snapshot_ids.append(snapshot_id) + report_items = _snapshot_items(conn, basis) + summary = _summarize_valuation_items(report_items, []) + return ok( + basis_date=basis, + source="snapshot", + is_closed_snapshot=True, + snapshot_run_id=run_id, + correction_of_run_id=correction_of_run_id, + correction_reason=correction_reason.strip(), + is_correction=is_correction, + closed_at=ts, + inserted_count=len(snapshot_ids), + inserted_snapshot_ids=snapshot_ids, + warnings=preview["warnings"], + **summary, + ) + + def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]: if source_type not in {"account", "manual_asset"}: return fail("validation_error", "source_type must be one of ['account', 'manual_asset']") @@ -578,13 +764,16 @@ def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, nam def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) -> dict[str, Any]: basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat() with db(db_path) as conn: - snapshots = _snapshot_items(conn, basis) + rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis) + snapshots = _snapshot_items(conn, basis) if rows else [] if snapshots: summary = _summarize_valuation_items(snapshots, []) return ok( basis_date=basis, source="asset_snapshots", + is_closed_snapshot=True, snapshot_count=len(snapshots), + close_metadata=close_metadata, **summary, ) account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn) @@ -595,6 +784,7 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None) return ok( basis_date=basis, source="live_authority", + is_closed_snapshot=False, snapshot_count=0, invalid_bucket_accounts=invalid_bucket_accounts, **summary, diff --git a/tests/contract_snapshots/response_contract_v1_0.json b/tests/contract_snapshots/response_contract_v1_0.json index 7a5b52a..0130e4b 100644 --- a/tests/contract_snapshots/response_contract_v1_0.json +++ b/tests/contract_snapshots/response_contract_v1_0.json @@ -64,6 +64,38 @@ "success": "bool", "account": {"type": "dict", "required": {"id": "str", "name": "str", "type": "str", "active": "bool"}} } + }, + "manual_asset_mutation": { + "required": { + "success": "bool", + "manual_asset": {"type": "dict", "required": {"id": "str", "name": "str", "active": "bool", "valuation_date": "str", "liquidity_bucket": "str", "source_note": "str"}} + } + }, + "asset_snapshot_preview": { + "required": { + "success": "bool", + "basis_date": "str", + "source": "str", + "is_closed_snapshot": "bool", + "total_assets": "int", + "total_liabilities": "int", + "net_worth": "int", + "warnings": "list" + } + }, + "asset_snapshot_close": { + "required": { + "success": "bool", + "basis_date": "str", + "source": "str", + "is_closed_snapshot": "bool", + "snapshot_run_id": "str", + "is_correction": "bool", + "inserted_count": "int", + "total_assets": "int", + "total_liabilities": "int", + "net_worth": "int" + } } } } diff --git a/tests/test_asset_snapshots.py b/tests/test_asset_snapshots.py index 1c99be0..1393413 100644 --- a/tests/test_asset_snapshots.py +++ b/tests/test_asset_snapshots.py @@ -16,7 +16,7 @@ def test_asset_snapshot_schema_is_additive_and_idempotent(tmp_path): snapshot_cols = {row[1] for row in conn.execute("PRAGMA table_info(asset_snapshots)")} assert {"id", "name", "valuation_amount", "valuation_date", "liquidity_bucket", "source_note", "memo", "active"} <= manual_cols - assert {"id", "basis_date", "source_type", "source_id", "name", "amount", "liquidity_bucket", "valuation_source", "source_note", "memo"} <= snapshot_cols + assert {"id", "basis_date", "source_type", "source_id", "name", "amount", "liquidity_bucket", "valuation_source", "source_note", "memo", "snapshot_run_id", "correction_of_run_id", "correction_reason", "is_correction", "closed_at"} <= snapshot_cols def test_manual_assets_snapshot_report_and_net_worth_formula(tmp_path): @@ -83,3 +83,63 @@ def test_asset_liquidity_report_empty_new_tables_preserves_req005_fallback(tmp_p assert report["manual_asset_count"] == 0 assert report["source"] == "accounts.last_balance" assert [item["source_type"] for item in report["accounts"]] == ["account", "account", "account"] + + +def test_manual_asset_update_deactivate_preview_and_close_correction_policy(tmp_path): + db_path = str(tmp_path / "ledger.sqlite3") + account = service.create_ledger_account("월말 현금", last_balance=1_000_000, last_synced_at="2026-06-30", liquidity_bucket="cash", db_path=db_path) + asset = service.create_manual_asset("전세보증금", 300_000_000, "2026-06-30", "real_estate", source_note="synthetic", db_path=db_path) + loan = service.create_manual_asset("대출", 120_000_000, "2026-06-30", "loan", source_note="synthetic", db_path=db_path) + dormant = service.create_manual_asset("해지 예금", 5_000_000, "2026-06-30", "deposit", source_note="synthetic", db_path=db_path) + assert account["success"] and asset["success"] and loan["success"] and dormant["success"] + + updated = service.update_manual_asset(asset["manual_asset"]["id"], valuation_amount=310_000_000, memo="updated synthetic", db_path=db_path) + assert updated["success"] is True + assert updated["manual_asset"]["valuation_amount"] == 310_000_000 + deactivated = service.deactivate_manual_asset(dormant["manual_asset"]["id"], db_path=db_path) + assert deactivated["success"] is True + assert deactivated["manual_asset"]["active"] is False + + before = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("asset_snapshots", 0) + preview = service.preview_asset_snapshot_close("2026-06-30", db_path=db_path) + after = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=False)["table_counts"].get("asset_snapshots", 0) + assert preview["success"] is True + assert preview["source"] == "live_authority" + assert preview["is_closed_snapshot"] is False + assert before == after + assert preview["total_assets"] == 311_000_000 + assert preview["total_liabilities"] == 120_000_000 + assert preview["net_worth"] == 191_000_000 + + closed = service.close_asset_snapshot("2026-06-30", source_note="synthetic close", db_path=db_path) + assert closed["success"] is True + assert closed["source"] == "snapshot" + assert closed["is_closed_snapshot"] is True + assert closed["is_correction"] is False + assert closed["inserted_count"] == 3 + first_run = closed["snapshot_run_id"] + + report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path) + assert report["success"] is True + assert report["is_closed_snapshot"] is True + assert report["net_worth"] == closed["net_worth"] + assert report["close_metadata"]["snapshot_run_id"] == first_run + + blocked = service.close_asset_snapshot("2026-06-30", db_path=db_path) + assert blocked["success"] is False + assert blocked["error_code"] == "validation_error" + + service.update_manual_asset(loan["manual_asset"]["id"], valuation_amount=110_000_000, db_path=db_path) + corrected = service.close_asset_snapshot("2026-06-30", source_note="synthetic correction", correction_reason="synthetic correction test", db_path=db_path) + assert corrected["success"] is True + assert corrected["is_correction"] is True + assert corrected["correction_of_run_id"] == first_run + assert corrected["net_worth"] == 201_000_000 + + corrected_report = service.get_asset_snapshot_report("2026-06-30", db_path=db_path) + assert corrected_report["net_worth"] == 201_000_000 + assert corrected_report["close_metadata"]["snapshot_run_id"] == corrected["snapshot_run_id"] + + with sqlite3.connect(db_path) as conn: + row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date='2026-06-30'").fetchone()[0] + assert row_count == 6 diff --git a/tests/test_contract_regression.py b/tests/test_contract_regression.py index b053f1b..2acf235 100644 --- a/tests/test_contract_regression.py +++ b/tests/test_contract_regression.py @@ -118,6 +118,18 @@ def test_list_report_and_mutation_success_envelope_contract(tmp_path: Path) -> N assert mutation["success"] is True _assert_subset_schema(mutation, contract["mutation_success_envelope"], "mutation_success_envelope") + manual_asset = service.create_manual_asset("Synthetic Deposit", 2_000_000, "2026-06-30", "deposit", source_note="synthetic contract", db_path=db_path) + assert manual_asset["success"] is True + _assert_subset_schema(manual_asset, contract["manual_asset_mutation"], "manual_asset_mutation") + + preview = service.preview_asset_snapshot_close("2026-06-30", db_path=db_path) + assert preview["success"] is True + _assert_subset_schema(preview, contract["asset_snapshot_preview"], "asset_snapshot_preview") + + close = service.close_asset_snapshot("2026-06-30", source_note="synthetic contract close", db_path=db_path) + assert close["success"] is True + _assert_subset_schema(close, contract["asset_snapshot_close"], "asset_snapshot_close") + accounts = service.list_ledger_accounts(db_path=db_path) assert accounts["success"] is True assert accounts["count"] >= 2 diff --git a/tests/test_dashboard_app.py b/tests/test_dashboard_app.py index 6e88540..01f2123 100644 --- a/tests/test_dashboard_app.py +++ b/tests/test_dashboard_app.py @@ -47,6 +47,7 @@ def test_streamlit_apptest_core_pages_use_synthetic_fixture_db(tmp_path, monkeyp app_path = Path(__file__).resolve().parents[1] / "dashboard" / "app.py" page_expectations = { "요약": "가계부 운영 대시보드", + "자산/마감": "월말 스냅샷 Preview / Close", "분류 규칙": "분류 규칙", "확인필요 큐": "KB↔토스뱅크 확인필요 큐", } diff --git a/tests/test_ledger_categories.py b/tests/test_ledger_categories.py index 0f11f52..4785272 100644 --- a/tests/test_ledger_categories.py +++ b/tests/test_ledger_categories.py @@ -6,7 +6,7 @@ from jarca_ledger_mcp import service def test_ledger_category_create_list_delete_reactivate(tmp_path): db_path = str(tmp_path / "ledger.sqlite3") health = service.health_check(db_path) - assert health["schema_version"] == "8" + assert health["schema_version"] == "10" created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path) assert created["success"] is True diff --git a/tests/test_service.py b/tests/test_service.py index 699294d..d9eb3a3 100644 --- a/tests/test_service.py +++ b/tests/test_service.py @@ -10,7 +10,7 @@ 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"] == "9" + assert result["schema_version"] == "10" assert db_path.exists()