Implement BAT-009 manual asset CRUD and snapshot close

This commit is contained in:
Jarca
2026-07-01 10:55:34 +09:00
parent beddff7661
commit 02380a88ab
24 changed files with 1129 additions and 24 deletions
+107 -1
View File
@@ -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 == "확인필요 큐":
@@ -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.
@@ -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.
@@ -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
@@ -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.
@@ -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.
+1
View File
@@ -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/) |
+15 -4
View File
@@ -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 추가.
@@ -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. 계획 상태 요약
@@ -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
+1
View File
@@ -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 |
+11 -8
View File
@@ -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.
@@ -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로 전환.
@@ -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로 전환.
+6 -1
View File
@@ -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로 전환했다.
+20 -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 = "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,))
+25
View File
@@ -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."""
+194 -4
View File
@@ -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,
@@ -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"
}
}
}
}
+61 -1
View File
@@ -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
+12
View File
@@ -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
+1
View File
@@ -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↔토스뱅크 확인필요 큐",
}
+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"] == "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
+1 -1
View File
@@ -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()