feat: persist review workbench decisions (#1)
Merge dcy-012 / REQ-020 / BAT-016 implementation after green CI.
This commit is contained in:
+65
-6
@@ -649,6 +649,29 @@ def cancel_original_candidates_dialog(tx: dict[str, Any], db_path: str) -> None:
|
|||||||
st.divider()
|
st.divider()
|
||||||
|
|
||||||
|
|
||||||
|
def render_review_decision_actions(gateway: Any, target_type: str, target_id: str, key_prefix: str, db_path: str, target_payload: dict[str, Any] | None = None) -> None:
|
||||||
|
decision_type = st.selectbox(
|
||||||
|
"판정",
|
||||||
|
["unknown", "account_transfer", "real_expense", "real_income", "investment_transfer", "excluded_from_analysis", "duplicate_or_cancel"],
|
||||||
|
key=f"{key_prefix}_decision_type",
|
||||||
|
)
|
||||||
|
reason_code = st.text_input("사유 코드", value="operator_review", key=f"{key_prefix}_reason")
|
||||||
|
memo = st.text_input("결정 메모", value="", key=f"{key_prefix}_memo")
|
||||||
|
cols = st.columns(4)
|
||||||
|
if cols[0].button("결정 저장", key=f"{key_prefix}_decided", use_container_width=True, type="primary"):
|
||||||
|
res = gateway.upsert_review_decision(target_type, target_id, status="decided", decision_type=decision_type, reason_code=reason_code, memo=memo, target_payload=target_payload or {}, db_path=db_path)
|
||||||
|
clear_cache(); st.success("저장했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||||
|
if cols[1].button("보류", key=f"{key_prefix}_held", use_container_width=True):
|
||||||
|
res = gateway.hold_review_decision(target_type, target_id, reason_code=reason_code or "held", memo=memo, db_path=db_path)
|
||||||
|
clear_cache(); st.success("보류했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||||
|
if cols[2].button("재검토", key=f"{key_prefix}_recheck", use_container_width=True):
|
||||||
|
res = gateway.mark_review_recheck(target_type, target_id, reason_code=reason_code or "needs_recheck", memo=memo, db_path=db_path)
|
||||||
|
clear_cache(); st.success("재검토로 표시했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||||
|
if cols[3].button("해제", key=f"{key_prefix}_release", use_container_width=True):
|
||||||
|
res = gateway.release_review_decision(target_type, target_id, memo=memo, db_path=db_path)
|
||||||
|
clear_cache(); st.success("pending으로 해제했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||||
|
|
||||||
|
|
||||||
RULE_MATCH_FIELDS = ["merchant", "raw_summary", "counterparty", "payment_method", "branch", "description", "memo"]
|
RULE_MATCH_FIELDS = ["merchant", "raw_summary", "counterparty", "payment_method", "branch", "description", "memo"]
|
||||||
RULE_MATCH_TYPES = ["contains", "equals", "starts_with", "ends_with", "regex"]
|
RULE_MATCH_TYPES = ["contains", "equals", "starts_with", "ends_with", "regex"]
|
||||||
RULE_TX_TYPES = ["", "income", "expense", "transfer", "refund", "adjustment"]
|
RULE_TX_TYPES = ["", "income", "expense", "transfer", "refund", "adjustment"]
|
||||||
@@ -888,6 +911,8 @@ def render_uncategorized_work_items(rows: list[dict[str, Any]], db_path: str, re
|
|||||||
linked_transfer_candidates_dialog(row, db_path)
|
linked_transfer_candidates_dialog(row, db_path)
|
||||||
if action_cols[4].button("원거래찾기", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True):
|
if action_cols[4].button("원거래찾기", key=f"uncat_cancel_{row['id']}_{idx}", use_container_width=True):
|
||||||
cancel_original_candidates_dialog(row, db_path)
|
cancel_original_candidates_dialog(row, db_path)
|
||||||
|
with st.expander(f"Review decision · 현재 {row.get('review_status', 'pending')}", expanded=False):
|
||||||
|
render_review_decision_actions(categorization_gateway, "uncategorized_transaction", row["id"], f"uncat_review_decision_{row['id']}_{idx}", db_path, target_payload=row)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -944,6 +969,8 @@ def page_overview(db_path: str) -> None:
|
|||||||
uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path)
|
uncategorized_res = overview_gateway.list_uncategorized_transactions(limit=1000, db_path=db_path)
|
||||||
transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
|
transfer_queue_res = overview_gateway.list_cross_transfer_review_queue(limit=300, db_path=db_path)
|
||||||
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
|
reconciliation_res = reconciliation_gateway.get_balance_reconciliation_report(db_path=db_path)
|
||||||
|
review_indicators_res = reconciliation_gateway.get_review_operating_indicators(db_path=db_path)
|
||||||
|
review_indicators = review_indicators_res.get("indicators", {}) if review_indicators_res.get("success") else {}
|
||||||
backups_res = overview_gateway.list_ledger_backups(limit=1)
|
backups_res = overview_gateway.list_ledger_backups(limit=1)
|
||||||
imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
|
imports_res = overview_gateway.list_ledger_import_batches(limit=1, db_path=db_path)
|
||||||
|
|
||||||
@@ -1008,11 +1035,12 @@ def page_overview(db_path: str) -> None:
|
|||||||
reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
|
reconciliation_items = reconciliation_res.get("items", []) if reconciliation_res.get("success") else []
|
||||||
reconciliation_review = [item for item in reconciliation_items if item.get("status") == "REVIEW_NEEDED"]
|
reconciliation_review = [item for item in reconciliation_items if item.get("status") == "REVIEW_NEEDED"]
|
||||||
missing_balance = asset_report.get("missing_balance_accounts", [])
|
missing_balance = asset_report.get("missing_balance_accounts", [])
|
||||||
w1, w2, w3, w4 = st.columns(4)
|
w1, w2, w3, w4, w5 = st.columns(5)
|
||||||
w1.metric("미분류 거래", f"{uncategorized_res.get('count', 0):,}건")
|
w1.metric("미처리 review", f"{review_indicators.get('pending_review_count', len(review_items)):,}건")
|
||||||
w2.metric("확인필요 큐", f"{len(review_items):,}건")
|
w2.metric("보류/재검토", f"{review_indicators.get('held_recheck_count', 0):,}건")
|
||||||
w3.metric("잔액대조 필요", f"{len(reconciliation_review):,}건")
|
w3.metric("미대조 계좌", f"{review_indicators.get('unreconciled_account_count', len(reconciliation_review)):,}개")
|
||||||
w4.metric("잔액 미입력", f"{len(missing_balance):,}건")
|
w4.metric("최근 대조 마감", review_indicators.get("last_reconciliation_closed_at") or "-")
|
||||||
|
w5.metric("미분류 추천 대기", f"{review_indicators.get('uncategorized_recommendation_backlog', uncategorized_res.get('count', 0)):,}건")
|
||||||
latest_backup = (backups_res.get("backups") or [{}])[0] if backups_res.get("success") else {}
|
latest_backup = (backups_res.get("backups") or [{}])[0] if backups_res.get("success") else {}
|
||||||
latest_import = (imports_res.get("import_batches") or [{}])[0] if imports_res.get("success") else {}
|
latest_import = (imports_res.get("import_batches") or [{}])[0] if imports_res.get("success") else {}
|
||||||
st.caption(
|
st.caption(
|
||||||
@@ -1231,7 +1259,11 @@ def page_cross_transfer_review(db_path: str) -> None:
|
|||||||
for r in rows
|
for r in rows
|
||||||
}
|
}
|
||||||
selected = st.selectbox("수동 수정할 거래", list(options.keys()))
|
selected = st.selectbox("수동 수정할 거래", list(options.keys()))
|
||||||
update_transaction_form(options[selected], "transfer_review", db_path)
|
selected_row = options[selected]
|
||||||
|
st.caption(f"현재 review_status: {selected_row.get('review_status', 'pending')}")
|
||||||
|
with st.expander("Review decision 저장/보류/재검토/해제", expanded=False):
|
||||||
|
render_review_decision_actions(transfers_gateway, "cross_transfer", selected_row["id"], f"transfer_review_decision_{selected_row['id']}", db_path, target_payload=selected_row)
|
||||||
|
update_transaction_form(selected_row, "transfer_review", db_path)
|
||||||
|
|
||||||
|
|
||||||
def page_uncategorized(db_path: str) -> None:
|
def page_uncategorized(db_path: str) -> None:
|
||||||
@@ -1679,6 +1711,33 @@ def page_accounts(db_path: str) -> None:
|
|||||||
st.dataframe(items[show_cols], width="stretch")
|
st.dataframe(items[show_cols], width="stretch")
|
||||||
review_count = int((items["status"] == "REVIEW_NEEDED").sum()) if "status" in items else 0
|
review_count = int((items["status"] == "REVIEW_NEEDED").sum()) if "status" in items else 0
|
||||||
st.metric("확인필요", f"{review_count}건")
|
st.metric("확인필요", f"{review_count}건")
|
||||||
|
selectable = report.get("items", [])
|
||||||
|
if selectable:
|
||||||
|
labels = {f"{item.get('account_name')} · {item.get('status')} · {format_krw(item.get('difference'))}": item for item in selectable}
|
||||||
|
chosen_label = st.selectbox("대조 세션/판정 대상", list(labels.keys()))
|
||||||
|
chosen = labels[chosen_label]
|
||||||
|
with st.expander("Reconciliation session 기록/마감", expanded=False):
|
||||||
|
status = st.selectbox("Session status", ["open", "closed", "needs_recheck", "ignored"], key="recon_session_status")
|
||||||
|
memo = st.text_input("Session memo", value="", key="recon_session_memo")
|
||||||
|
if st.button("Session 저장/갱신", key="recon_session_save", type="primary"):
|
||||||
|
res = reconciliation_gateway.upsert_reconciliation_session(
|
||||||
|
chosen["account_id"],
|
||||||
|
str(chosen.get("baseline_at", ""))[:10],
|
||||||
|
expected_balance=chosen.get("baseline_balance"),
|
||||||
|
actual_balance=chosen.get("calculated_balance"),
|
||||||
|
difference=chosen.get("difference"),
|
||||||
|
status=status,
|
||||||
|
source_note=source_note,
|
||||||
|
memo=memo,
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
clear_cache(); st.success("저장했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||||
|
existing_session = chosen.get("reconciliation_session") or {}
|
||||||
|
if existing_session.get("id") and st.button("기존 Session 마감", key="recon_session_close"):
|
||||||
|
res = reconciliation_gateway.close_reconciliation_session(session_id=existing_session["id"], memo=memo, db_path=db_path)
|
||||||
|
clear_cache(); st.success("마감했습니다." if res.get("success") else res.get("error", res)); st.rerun()
|
||||||
|
with st.expander(f"Review decision · 현재 {chosen.get('review_status', 'pending')}", expanded=False):
|
||||||
|
render_review_decision_actions(reconciliation_gateway, "reconciliation_account", chosen["account_id"], f"recon_review_{chosen['account_id']}", db_path, target_payload=chosen)
|
||||||
else:
|
else:
|
||||||
st.info("잔액대조 대상 계좌가 없습니다.")
|
st.info("잔액대조 대상 계좌가 없습니다.")
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1,31 +1,47 @@
|
|||||||
# BAT-016 Design: Review decision persistence and reconciliation workbench
|
# BAT-016 Design: Review decision persistence and reconciliation workbench
|
||||||
|
|
||||||
## Design Status
|
## Design Status
|
||||||
- Status: draft
|
- Status: completed
|
||||||
- Owner: delivery-runner to complete with dev-impl input before implementation.
|
- Owner: 가웨인 direct recovery path; Kanban prohibited by user.
|
||||||
|
|
||||||
## Required Design Decisions
|
## Architecture Summary
|
||||||
1. Persistence schema
|
BAT-016 adds a small review-state layer beside the existing ledger facts. Transactions, accounts, imports, categories, and reports remain the financial source of truth; `review_decisions` and `reconciliation_sessions` store operator workflow state only. MCP and Streamlit adapters use the existing `service.py` facade and page/domain gateways, so the new behavior is additive to current consumers.
|
||||||
- Define exact columns, indexes, uniqueness/idempotency keys, and schema migration path for `review_decisions` and `reconciliation_sessions`.
|
|
||||||
- Define whether target payload is JSON text and how sensitive content is minimized.
|
|
||||||
2. State model
|
|
||||||
- Map UI labels to internal `status` and `decision_type` values from REQ-020.
|
|
||||||
- Define allowed transitions for pending/decided/held/needs_recheck/ignored.
|
|
||||||
3. Application contracts
|
|
||||||
- Define additive fields returned by review queue, reconciliation report, and uncategorized workbench services.
|
|
||||||
- Define compatibility expectations for existing MCP and Streamlit consumers.
|
|
||||||
4. MCP tool contracts
|
|
||||||
- Define names, arguments, responses, error handling, and non-goals for review decision/session tools.
|
|
||||||
- Explicitly exclude destructive delete/reset operations.
|
|
||||||
5. Streamlit interaction model
|
|
||||||
- Define minimum UI actions, confirmation patterns, and feedback messages.
|
|
||||||
6. Verification design
|
|
||||||
- Define fixtures and representative scenarios for persistence round-trip, contract compatibility, dashboard behavior, and DB safety.
|
|
||||||
|
|
||||||
## Initial Recommendation
|
## Changed Areas
|
||||||
Use a common decision envelope with fields aligned to REQ-020 and target-specific payload fields stored as minimal JSON. Use reconciliation sessions as account/basis-date close records and allow review decisions to target either a session or a specific item.
|
- SQLite schema/migrations: schema version `11`, new `review_decisions` and `reconciliation_sessions` tables plus indexes.
|
||||||
|
- Application: new `review_decision_service.py` owns decision/session persistence, payload sanitization, operating indicators, and attachment helpers.
|
||||||
|
- Existing services: cross-transfer queue, reconciliation report, and uncategorized workbench attach persisted review/session state with additive fields.
|
||||||
|
- MCP: safe review decision/session tools registered through `tool_contracts.py` and `server.py`.
|
||||||
|
- Streamlit: dashboard actions for review decisions and reconciliation sessions through existing gateways.
|
||||||
|
- Docs/tests: data model/project structure/runtime flow baselines and pytest coverage.
|
||||||
|
|
||||||
## Design Completion Criteria
|
## Key Decisions
|
||||||
- Schema and state transitions are explicit enough for dev-impl to implement without product reinterpretation.
|
1. **Schema**
|
||||||
- MCP and Streamlit contracts are additive and testable.
|
- `review_decisions`: common decision envelope with `target_type`, `target_id`, `status`, `decision_type`, `reason_code`, `memo`, `operator`, `decided_at`, `review_after`, `target_payload`, timestamps.
|
||||||
- Verification scenarios map directly to REQ-020 AC 1-10.
|
- `reconciliation_sessions`: account/basis-date session records with expected/actual/difference, `status`, `closed_at`, source/memo/operator, timestamps.
|
||||||
|
- Migrations are additive and idempotent using `CREATE TABLE IF NOT EXISTS` and `CREATE INDEX IF NOT EXISTS`.
|
||||||
|
2. **State model**
|
||||||
|
- Review statuses: `pending`, `decided`, `held`, `needs_recheck`, `ignored`.
|
||||||
|
- Decision types: `account_transfer`, `real_expense`, `real_income`, `investment_transfer`, `excluded_from_analysis`, `duplicate_or_cancel`, `unknown`.
|
||||||
|
- Reconciliation statuses: `open`, `closed`, `needs_recheck`, `ignored`.
|
||||||
|
3. **Payload safety**
|
||||||
|
- `target_payload` is minimal JSON text; known sensitive keys such as `raw_payload`, password/token/secret/file_path are dropped before persistence.
|
||||||
|
4. **Contracts**
|
||||||
|
- Existing payloads remain compatible; new fields such as `review_status`, `review_decision`, and `reconciliation_session` are additive.
|
||||||
|
- MCP adds non-destructive tools only; no delete/reset tool is exposed.
|
||||||
|
5. **Operating indicators**
|
||||||
|
- The service computes pending review, held/recheck, unreconciled account count, last reconciliation close time, and uncategorized recommendation backlog from persisted state plus current ledger state.
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
- Unknown/invalid status or decision type returns validation errors rather than writing inconsistent state.
|
||||||
|
- Release/hold/recheck operations target the latest decision for a given target and preserve audit timestamps.
|
||||||
|
- Reconciliation session `difference` can be supplied or derived from expected/actual values.
|
||||||
|
- Empty or unparsable `target_payload` is reduced to `{}` or a short summary, never raw financial evidence expansion.
|
||||||
|
|
||||||
|
## Architecture Impact
|
||||||
|
- `both`: project structure gains `application/review_decision_service.py`; runtime flows gain review-decision/reconciliation workbench flow.
|
||||||
|
|
||||||
|
## Reference Doc Update Plan
|
||||||
|
- `docs/data-model.md`: add review/session entities, state values, and payload-minimization rules.
|
||||||
|
- `docs/project-structure.md`: list `review_decision_service.py` ownership.
|
||||||
|
- `docs/runtime-flows.md`: add review decision and reconciliation workbench runtime flow.
|
||||||
|
|||||||
+45
-10
@@ -1,17 +1,52 @@
|
|||||||
# BAT-016 Implementation Log
|
# BAT-016 Implementation Log
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
- Not started.
|
- Implemented locally; release-candidate after verification.
|
||||||
|
- Completed At (KST): 2026-07-08 07:11 KST
|
||||||
|
- Branch: `feat/bat-016-review-decision-workbench`
|
||||||
|
|
||||||
## Implementation Owner
|
## Implementation Owner
|
||||||
- dev-builder / dev-impl under delivery-runner orchestration.
|
- 가웨인 direct recovery path. User explicitly prohibited Kanban after prior worker failures.
|
||||||
|
|
||||||
## Expected Evidence
|
## Changed Files
|
||||||
- Code diff summary by slice.
|
- `jarca_ledger_mcp/infrastructure/sqlite/schema.py`
|
||||||
- Migration and validation DB evidence.
|
- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py`
|
||||||
- MCP tool discovery and representative call evidence.
|
- `jarca_ledger_mcp/application/review_decision_service.py`
|
||||||
- Streamlit AppTest/HTTP smoke evidence.
|
- `jarca_ledger_mcp/application/cross_transfer_service.py`
|
||||||
- Full lint/test evidence.
|
- `jarca_ledger_mcp/application/reconciliation_service.py`
|
||||||
|
- `jarca_ledger_mcp/application/uncategorized_workbench_service.py`
|
||||||
|
- `jarca_ledger_mcp/application/system_services.py`
|
||||||
|
- `jarca_ledger_mcp/service.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/mcp/tool_contracts.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/mcp/server.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/streamlit/transfers_gateway.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/streamlit/reconciliation_gateway.py`
|
||||||
|
- `jarca_ledger_mcp/adapters/streamlit/categorization_gateway.py`
|
||||||
|
- `dashboard/app.py`
|
||||||
|
- `tests/test_review_decision_workbench.py`
|
||||||
|
- `tests/test_service.py`
|
||||||
|
- `tests/test_ledger_categories.py`
|
||||||
|
- `docs/data-model.md`
|
||||||
|
- `docs/project-structure.md`
|
||||||
|
- `docs/runtime-flows.md`
|
||||||
|
|
||||||
## Log
|
## Implementation Summary
|
||||||
- 2026-07-08 00:00 KST: Batch scaffolded by lead. No implementation performed yet.
|
- Raised SQLite schema version to `11` and added additive/idempotent `review_decisions` and `reconciliation_sessions` schema objects.
|
||||||
|
- Added `review_decision_service.py` for safe non-destructive review decision and reconciliation session operations.
|
||||||
|
- Added payload minimization to prevent raw payload/password/token/secret/file-path persistence.
|
||||||
|
- Added review/session state enrichment to cross-transfer queue, balance reconciliation report, and uncategorized workbench outputs.
|
||||||
|
- Added MCP tools for list/upsert/hold/recheck/release review decisions, list/upsert/close reconciliation sessions, and review operating indicators.
|
||||||
|
- Added Streamlit actions and gateway exports for review decision/session workflow state.
|
||||||
|
- Added regression tests for schema idempotency, sensitive payload filtering, reconciliation session round trip, report integration, and MCP tool surface.
|
||||||
|
|
||||||
|
## Commands and Results
|
||||||
|
- `python -m ruff check .` → passed.
|
||||||
|
- `python -m pytest tests/test_review_decision_workbench.py -q` → `.... [100%]`.
|
||||||
|
- `python -m pytest -q` → passed, with existing skipped tests preserved.
|
||||||
|
- MCP surface smoke: required 9 review/session tools present; destructive delete/reset absent; tool count `65`.
|
||||||
|
- DB safety smoke: schema version `11`, `PRAGMA integrity_check` `ok`, new tables present and empty on synthetic DB.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
- No production DB destructive operation was performed.
|
||||||
|
- No raw financial evidence or secrets were added to repository fixtures.
|
||||||
|
- Final PR/CI evidence remains the next gate after commit/push.
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
# BAT-016: Review decision persistence and reconciliation workbench
|
# BAT-016: Review decision persistence and reconciliation workbench
|
||||||
|
|
||||||
- Profile: standard
|
- Profile: standard
|
||||||
- Status: draft
|
- Status: release-candidate
|
||||||
- Included REQ: REQ-020
|
- Included REQ: REQ-020
|
||||||
- Source Discovery: dcy-012
|
- Source Discovery: dcy-012
|
||||||
- Created At (KST): 2026-07-08 00:00 KST
|
- Created At (KST): 2026-07-08 00:00 KST
|
||||||
|
- Verification Completed At (KST): 2026-07-08 07:11 KST
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Deliver the approved REQ-020 scope for persisted review decisions, reconciliation sessions, Streamlit workbench actions, network MCP additive tools, operating hub indicators, and baseline documentation.
|
Deliver the approved REQ-020 scope for persisted review decisions, reconciliation sessions, Streamlit workbench actions, network MCP additive tools, operating hub indicators, and baseline documentation.
|
||||||
|
|
||||||
## Included Scope
|
## Included Scope
|
||||||
- Additive/idempotent persistence for `review_decisions` and `reconciliation_sessions` or an equivalent structure.
|
- Additive/idempotent persistence for `review_decisions` and `reconciliation_sessions`.
|
||||||
- Application/service integration for cross-transfer review queue, reconciliation report, and uncategorized workbench.
|
- Application/service integration for cross-transfer review queue, reconciliation report, and uncategorized workbench.
|
||||||
- Streamlit workbench actions for decision create/update/hold/recheck/release.
|
- Streamlit workbench actions for decision create/update/hold/recheck/release.
|
||||||
- Network MCP tools for safe review decision/session read and mutation operations.
|
- Network MCP tools for safe review decision/session read and mutation operations.
|
||||||
@@ -31,6 +32,6 @@ Deliver the approved REQ-020 scope for persisted review decisions, reconciliatio
|
|||||||
- [implementation.md](implementation.md)
|
- [implementation.md](implementation.md)
|
||||||
- [verification.md](verification.md)
|
- [verification.md](verification.md)
|
||||||
|
|
||||||
## Harness Notes
|
## Delivery Notes
|
||||||
- This batch is `standard` because REQ-020 changes persistence, interface surface, runtime flow, Streamlit UI, MCP tools, and baseline docs.
|
- User explicitly prohibited Kanban for this recovery path; delivery was completed directly on feature branch `feat/bat-016-review-decision-workbench`.
|
||||||
- After this batch scaffold is committed, lead should issue a kanban-backed `lead -> delivery-runner` kickoff on the shared development execution board.
|
- Local verification evidence is recorded in `verification.md`; final merge remains subject to PR/CI review.
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
# BAT-016 Planning: Review decision persistence and reconciliation workbench
|
# BAT-016 Planning: Review decision persistence and reconciliation workbench
|
||||||
|
|
||||||
## Planning Status
|
## Planning Status
|
||||||
- Status: draft
|
- Status: completed
|
||||||
- Profile: standard
|
- Profile: standard
|
||||||
- Design Gate: yes
|
- Design Gate: yes
|
||||||
- Design Gate Rationale: REQ-020 changes persistence schema, application service contracts, MCP tools, Streamlit UI flow, and operating hub aggregation. A design document is required before implementation.
|
- Design Gate Rationale: REQ-020 changes persistence schema, application service contracts, MCP tools, Streamlit UI flow, and operating hub aggregation. A design document is required before implementation.
|
||||||
|
|
||||||
## Objective
|
## Objective
|
||||||
Turn REQ-020 into a delivery plan that can be executed by delivery-runner with separated implementation and QC roles.
|
Turn REQ-020 into a delivery plan for persisted review decisions and reconciliation sessions without changing existing public contracts destructively.
|
||||||
|
|
||||||
## Work Slices
|
## Work Slices
|
||||||
1. Persistence foundation
|
1. Persistence foundation
|
||||||
- Add review decision and reconciliation session schema via additive/idempotent migration.
|
- Add review decision and reconciliation session schema via additive/idempotent migration.
|
||||||
- Add repository/service helpers with validation DB coverage.
|
- Add service helpers with validation DB coverage.
|
||||||
2. Application integration
|
2. Application integration
|
||||||
- Connect cross-transfer review queue, reconciliation report, and uncategorized workbench to persisted decision/session state.
|
- Connect cross-transfer review queue, reconciliation report, and uncategorized workbench to persisted decision/session state.
|
||||||
- Preserve existing response fields with additive extensions only.
|
- Preserve existing response fields with additive extensions only.
|
||||||
@@ -24,11 +24,22 @@ Turn REQ-020 into a delivery plan that can be executed by delivery-runner with s
|
|||||||
- Exclude destructive delete/reset tools.
|
- Exclude destructive delete/reset tools.
|
||||||
5. Baseline and verification
|
5. Baseline and verification
|
||||||
- Update data model, project structure, runtime flows.
|
- Update data model, project structure, runtime flows.
|
||||||
- Add pytest, contract/smoke, Streamlit AppTest/HTTP smoke, and DB safety evidence.
|
- Add pytest, MCP surface smoke, Streamlit/dashboard and DB safety evidence.
|
||||||
|
|
||||||
## Acceptance Source
|
## Acceptance Source
|
||||||
- REQ-020 Acceptance Criteria 1-10.
|
- REQ-020 Acceptance Criteria 1-10.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
- dcy-013/BAT-015 network MCP endpoint remains the deployment substrate; BAT-016 does not redesign transport.
|
||||||
|
- Existing SQLite migration path must remain additive/idempotent.
|
||||||
|
- Existing dashboard and MCP consumers must keep their current response fields.
|
||||||
|
|
||||||
|
## Milestones
|
||||||
|
1. Schema + service implementation.
|
||||||
|
2. Adapter/UI integration.
|
||||||
|
3. Targeted tests and full local regression.
|
||||||
|
4. StagePilot artifact update and PR/CI validation.
|
||||||
|
|
||||||
## Risks
|
## Risks
|
||||||
- Source-of-truth ambiguity between current transaction/account state and persisted decision state.
|
- Source-of-truth ambiguity between current transaction/account state and persisted decision state.
|
||||||
- Session granularity mismatch across account-level reconciliation and item-level review decisions.
|
- Session granularity mismatch across account-level reconciliation and item-level review decisions.
|
||||||
@@ -36,6 +47,7 @@ Turn REQ-020 into a delivery plan that can be executed by delivery-runner with s
|
|||||||
- MCP/Streamlit contract drift.
|
- MCP/Streamlit contract drift.
|
||||||
|
|
||||||
## Handoff Readiness
|
## Handoff Readiness
|
||||||
- Ready for design drafting: yes.
|
- Ready for design drafting: completed.
|
||||||
- Ready for implementation: no; design must define schema, state transitions, and interface contracts first.
|
- Ready for implementation: completed after design decisions below.
|
||||||
- Lead authority needed before delivery: no additional user decision is currently open; scope is approved in dcy-012 and REQ-020.
|
- Lead authority needed before delivery: no additional user decision is currently open; scope is approved in dcy-012 and REQ-020.
|
||||||
|
- Kanban status: deliberately not used for this recovery path per explicit user instruction.
|
||||||
|
|||||||
+66
-14
@@ -1,22 +1,74 @@
|
|||||||
# BAT-016 Verification
|
# BAT-016 Verification
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
- Not started.
|
- local-pass
|
||||||
|
- Verified At (KST): 2026-07-08 07:11 KST
|
||||||
|
|
||||||
## QC Owner
|
## QC Owner
|
||||||
- dev-reviewer / dev-qc under delivery-runner orchestration.
|
- 가웨인 direct recovery path; independent QC still recommended before merge because Kanban-based dev-reviewer was explicitly disabled by user instruction.
|
||||||
|
|
||||||
## Required Verification
|
## Required Verification Mapping
|
||||||
- REQ-020 AC 1-10 must be verified with direct evidence.
|
| REQ-020 AC | Evidence |
|
||||||
- `ruff check .` must pass.
|
|---|---|
|
||||||
- Full pytest must pass.
|
| AC1 additive/idempotent persistence | `tests/test_review_decision_workbench.py::test_review_decision_and_reconciliation_schema_are_additive_idempotent`; DB safety smoke showed schema `11`, integrity `ok`. |
|
||||||
- MCP contract/regression and network MCP representative smoke must pass.
|
| AC2 review decision round-trip | `test_review_decision_round_trip_payload_sanitizes_sensitive_fields`. |
|
||||||
- Streamlit AppTest and HTTP smoke must pass.
|
| AC3 reconciliation session round-trip/hub basis | `test_reconciliation_session_round_trip_and_report_integration`. |
|
||||||
- DB safety/migration regression must pass.
|
| AC4 additive existing service payloads | Full pytest passed; report/workbench tests preserved; new fields are additive. |
|
||||||
- Baseline document updates must be inspected.
|
| AC5 network MCP safe tools | MCP tool surface smoke found required review/session tools and no destructive delete/reset tools. |
|
||||||
|
| AC6 Streamlit workbench regression | Full pytest includes `tests/test_dashboard_app.py`; local pass. |
|
||||||
|
| AC7 operating indicators | `get_review_operating_indicators` included in MCP smoke and session test checks last reconciliation close time. |
|
||||||
|
| AC8 financial data safety | Payload sanitization test drops `raw_payload` and `file_path`; no real DB or raw evidence used. |
|
||||||
|
| AC9 regression gates | `ruff check .` and full pytest passed locally. CI/PR evidence pending. |
|
||||||
|
| AC10 baseline docs | `docs/data-model.md`, `docs/project-structure.md`, `docs/runtime-flows.md` updated. |
|
||||||
|
|
||||||
|
## Local Evidence
|
||||||
|
```text
|
||||||
|
python -m ruff check .
|
||||||
|
All checks passed!
|
||||||
|
|
||||||
|
python -m pytest tests/test_review_decision_workbench.py -q
|
||||||
|
.... [100%]
|
||||||
|
|
||||||
|
python -m pytest -q
|
||||||
|
........................ss......................ss... [100%]
|
||||||
|
|
||||||
|
python -m pytest tests/test_contract_regression.py -q
|
||||||
|
... [100%]
|
||||||
|
|
||||||
|
python -m pytest --ignore=tests/test_dashboard_app.py -q
|
||||||
|
....................ss......................ss... [100%]
|
||||||
|
|
||||||
|
python -m pytest tests/test_dashboard_app.py -q
|
||||||
|
.... [100%]
|
||||||
|
|
||||||
|
python scripts/ci_dashboard_http_smoke.py --host 127.0.0.1 --port 18501 --timeout 45
|
||||||
|
success=true, http_status=200, fixture=synthetic
|
||||||
|
|
||||||
|
python scripts/ci_db_safety_smoke.py --timeout 45
|
||||||
|
success=true, fixture_integrity=ok, restored_integrity=ok, operating_db_used=false
|
||||||
|
|
||||||
|
MCP surface smoke
|
||||||
|
close_reconciliation_session OK
|
||||||
|
get_review_operating_indicators OK
|
||||||
|
hold_review_decision OK
|
||||||
|
list_reconciliation_sessions OK
|
||||||
|
list_review_decisions OK
|
||||||
|
mark_review_recheck OK
|
||||||
|
release_review_decision OK
|
||||||
|
upsert_reconciliation_session OK
|
||||||
|
upsert_review_decision OK
|
||||||
|
tool_count 65
|
||||||
|
|
||||||
|
DB safety smoke
|
||||||
|
schema_version 11
|
||||||
|
integrity_check ok
|
||||||
|
review_decisions 0
|
||||||
|
reconciliation_sessions 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Residual Risk
|
||||||
|
- Independent dev-reviewer QC was not run because user prohibited Kanban. Treat PR review/CI as the next independent gate.
|
||||||
|
- Local verification passed, but GitHub Actions CI must still be checked after push.
|
||||||
|
|
||||||
## Verdict
|
## Verdict
|
||||||
- Pending.
|
- Local release-candidate.
|
||||||
|
|
||||||
## Evidence
|
|
||||||
- None yet.
|
|
||||||
|
|||||||
@@ -23,4 +23,6 @@
|
|||||||
| BAT-013 | standard | release-candidate | REQ-017 | dcy-011 | [bat-013_20260703_advanced-workflow-service-split](bat-013_20260703_advanced-workflow-service-split/) |
|
| BAT-013 | standard | release-candidate | REQ-017 | dcy-011 | [bat-013_20260703_advanced-workflow-service-split](bat-013_20260703_advanced-workflow-service-split/) |
|
||||||
| BAT-014 | standard | release-candidate | REQ-018 | dcy-011 | [bat-014_20260703_adapter-surface-narrowing-baseline-refresh](bat-014_20260703_adapter-surface-narrowing-baseline-refresh/) |
|
| BAT-014 | standard | release-candidate | REQ-018 | dcy-011 | [bat-014_20260703_adapter-surface-narrowing-baseline-refresh](bat-014_20260703_adapter-surface-narrowing-baseline-refresh/) |
|
||||||
| BAT-015 | standard | release-candidate | REQ-019 | dcy-013 | [bat-015_20260707_docker-network-mcp-deployment](bat-015_20260707_docker-network-mcp-deployment/) |
|
| BAT-015 | standard | release-candidate | REQ-019 | dcy-013 | [bat-015_20260707_docker-network-mcp-deployment](bat-015_20260707_docker-network-mcp-deployment/) |
|
||||||
| BAT-016 | standard | draft | REQ-020 | dcy-012 | [bat-016_20260708_review-decision-reconciliation-workbench](bat-016_20260708_review-decision-reconciliation-workbench/) |
|
| BAT-016 | standard | release-candidate | REQ-020 | dcy-012 | [bat-016_20260708_review-decision-reconciliation-workbench](bat-016_20260708_review-decision-reconciliation-workbench/) |
|
||||||
|
|
||||||
|
- 2026-07-08 07:11 KST: BAT-016을 local verification 통과 근거로 release-candidate 상태로 전환했다. Kanban은 사용자 명시 지시로 사용하지 않았다.
|
||||||
|
|||||||
+19
-1
@@ -193,6 +193,20 @@
|
|||||||
- Backup artifacts and validation DB copies must not be committed to git.
|
- Backup artifacts and validation DB copies must not be committed to git.
|
||||||
- Restore execution is not a persisted MCP operation and requires script/runbook approval controls.
|
- Restore execution is not a persisted MCP operation and requires script/runbook approval controls.
|
||||||
|
|
||||||
|
## Entity: ReviewDecision
|
||||||
|
- Source of Truth: `review_decisions`
|
||||||
|
- Purpose: 공통 review/workbench envelope for cross-transfer review rows, reconciliation targets, and uncategorized recommendation items.
|
||||||
|
- Lifecycle: pending -> decided/held/needs_recheck/ignored -> pending release when operator clears hold/recheck.
|
||||||
|
- Key Fields: `id`, `target_type`, `target_id`, `status`, `decision_type`, `reason_code`, `memo`, `operator`, `decided_at`, `review_after`, sanitized JSON `target_payload`, `created_at`, `updated_at`.
|
||||||
|
- State Rules: decisions are non-destructive and do not rewrite accounting fields by themselves. Existing read contracts are preserved by attaching additive `review_status` / `review_decision` metadata. `target_payload` omits raw payloads, passwords, tokens, secrets, and source file paths.
|
||||||
|
|
||||||
|
## Entity: ReconciliationSession
|
||||||
|
- Source of Truth: `reconciliation_sessions`
|
||||||
|
- Purpose: 계좌별/기준일별 잔액대조 session close/open state and difference evidence.
|
||||||
|
- Lifecycle: open -> closed, with `needs_recheck`/`ignored` available for operator triage.
|
||||||
|
- Key Fields: `id`, `account_id`, `basis_date`, `expected_balance`, `actual_balance`, `difference`, `status`, `closed_at`, `source_note`, `memo`, `operator`, `created_at`, `updated_at`.
|
||||||
|
- State Rules: account/basis-date rows are upserted idempotently; close records `closed_at`; destructive delete/reset workflow is not exposed.
|
||||||
|
|
||||||
## Consistency Rules
|
## Consistency Rules
|
||||||
- Actual cashflow reports include only categories whose `cashflow_kind` represents actual income or expense according to report semantics.
|
- Actual cashflow reports include only categories whose `cashflow_kind` represents actual income or expense according to report semantics.
|
||||||
- Account movement summaries should be separate from actual household income/expense summaries.
|
- Account movement summaries should be separate from actual household income/expense summaries.
|
||||||
@@ -202,6 +216,8 @@
|
|||||||
- DB corrections should be narrow and memo-tagged.
|
- DB corrections should be narrow and memo-tagged.
|
||||||
- Dashboard/MCP asset and net-worth reports use a shared valuation helper: account-backed items from `accounts.last_balance` plus non-transaction items from `manual_assets`.
|
- Dashboard/MCP asset and net-worth reports use a shared valuation helper: account-backed items from `accounts.last_balance` plus non-transaction items from `manual_assets`.
|
||||||
- Net worth is asset buckets (`cash`, `investment`, `deposit`, `real_estate`, `other`) minus absolute `loan` liabilities; missing valuations are excluded and reported, never inferred from transactions.
|
- Net worth is asset buckets (`cash`, `investment`, `deposit`, `real_estate`, `other`) minus absolute `loan` liabilities; missing valuations are excluded and reported, never inferred from transactions.
|
||||||
|
- Review workbench state is sourced from `review_decisions`; pending/held/recheck counts must not be inferred by overwriting raw transaction evidence.
|
||||||
|
- Reconciliation operating state is sourced from `reconciliation_sessions`; last close time is the max closed session `closed_at`.
|
||||||
|
|
||||||
## Persistence Notes
|
## Persistence Notes
|
||||||
- Schema changes should be additive/idempotent unless a destructive migration is explicitly approved and backed up.
|
- Schema changes should be additive/idempotent unless a destructive migration is explicitly approved and backed up.
|
||||||
@@ -209,11 +225,12 @@
|
|||||||
- Validation DB copies should live outside the repository and should be selected explicitly via `JARCA_LEDGER_DB_PATH` when used.
|
- Validation DB copies should live outside the repository and should be selected explicitly via `JARCA_LEDGER_DB_PATH` when used.
|
||||||
- Passwords for encrypted imports are runtime-only inputs and are not part of the persisted model.
|
- Passwords for encrypted imports are runtime-only inputs and are not part of the persisted model.
|
||||||
- Raw payloads may contain sensitive financial evidence and should not be committed to git.
|
- Raw payloads may contain sensitive financial evidence and should not be committed to git.
|
||||||
|
- ReviewDecision `target_payload` is sanitized to omit `raw_payload`, passwords, tokens, secrets, and file paths before persistence.
|
||||||
|
|
||||||
## Current Gaps / Planned Changes
|
## Current Gaps / Planned Changes
|
||||||
- Formal schema migration documentation should be added before larger DB changes.
|
- Formal schema migration documentation should be added before larger DB changes.
|
||||||
- Some UI workflows need final E2E verification against real rows.
|
- 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.
|
- Statement-unavailable account handling is tracked by REQ-003/BAT-003; REQ-020/BAT-016 adds dedicated reconciliation sessions and persistent review-decision queue metadata.
|
||||||
- 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.
|
- 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
|
## Change Log
|
||||||
@@ -223,3 +240,4 @@
|
|||||||
- 2026-06-29 KST: REQ-005/BAT-005 기준으로 `accounts.liquidity_bucket`, `last_balance` 기반 자산 유동성/부채/순자산 규칙 추가.
|
- 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-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 추가.
|
- 2026-07-01 KST: REQ-011/REQ-012/BAT-009 기준으로 manual asset CRUD, snapshot preview/close, append-only correction metadata, latest-run read policy 추가.
|
||||||
|
- 2026-07-08 KST: REQ-020/BAT-016 기준으로 `review_decisions`, `reconciliation_sessions`, review 상태값, payload 최소보관/마스킹 규칙, 운영 허브 집계 source-of-truth를 추가.
|
||||||
|
|||||||
@@ -4,8 +4,8 @@
|
|||||||
- Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml`
|
- Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml`
|
||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: Jarca / project maintainer
|
- Owner: Jarca / project maintainer
|
||||||
- Last Updated (KST): 2026-07-07
|
- Last Updated (KST): 2026-07-08
|
||||||
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-010, BAT-012, BAT-013, BAT-014, dcy-013/BAT-015
|
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-010, BAT-012, BAT-013, BAT-014, dcy-013/BAT-015, dcy-012/REQ-020/BAT-016
|
||||||
|
|
||||||
## Project Summary
|
## Project Summary
|
||||||
- Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 자산/스냅샷, 현금흐름 분석을 SQLite에 저장하고 운영한다.
|
- Jarca 가계부 전용 MCP 서버와 Streamlit 대시보드로 계좌/카드 거래, 예산, 분류 규칙, import, 자산/스냅샷, 현금흐름 분석을 SQLite에 저장하고 운영한다.
|
||||||
@@ -71,6 +71,7 @@ jarca-ledger-mcp/
|
|||||||
│ │ ├── imports_service.py
|
│ │ ├── imports_service.py
|
||||||
│ │ ├── cross_transfer_service.py
|
│ │ ├── cross_transfer_service.py
|
||||||
│ │ ├── reconciliation_service.py
|
│ │ ├── reconciliation_service.py
|
||||||
|
│ │ ├── review_decision_service.py
|
||||||
│ │ ├── categorization_services.py
|
│ │ ├── categorization_services.py
|
||||||
│ │ └── uncategorized_workbench_service.py
|
│ │ └── uncategorized_workbench_service.py
|
||||||
│ ├── infrastructure/
|
│ ├── infrastructure/
|
||||||
@@ -165,6 +166,8 @@ jarca-ledger-mcp/
|
|||||||
- Cross-account/self-transfer/cancel-original matching workflow ownership.
|
- Cross-account/self-transfer/cancel-original matching workflow ownership.
|
||||||
- `jarca_ledger_mcp/application/reconciliation_service.py`
|
- `jarca_ledger_mcp/application/reconciliation_service.py`
|
||||||
- Manual/partial-import balance reconciliation preview ownership.
|
- Manual/partial-import balance reconciliation preview ownership.
|
||||||
|
- `jarca_ledger_mcp/application/review_decision_service.py`
|
||||||
|
- Persistent review-decision and reconciliation-session workflow ownership. Provides safe non-destructive create/update/hold/recheck/release/close helpers shared by Streamlit and MCP adapters.
|
||||||
- `jarca_ledger_mcp/application/categorization_services.py`
|
- `jarca_ledger_mcp/application/categorization_services.py`
|
||||||
- Category rules, categorization preview/apply, ledger categories, and compatibility delegation to `uncategorized_workbench_service.py`.
|
- Category rules, categorization preview/apply, ledger categories, and compatibility delegation to `uncategorized_workbench_service.py`.
|
||||||
- `jarca_ledger_mcp/application/uncategorized_workbench_service.py`
|
- `jarca_ledger_mcp/application/uncategorized_workbench_service.py`
|
||||||
@@ -242,3 +245,4 @@ jarca-ledger-mcp/
|
|||||||
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 application / infrastructure / adapter layering, compatibility facade/shim, Streamlit gateway 구조를 반영했다.
|
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 application / infrastructure / adapter layering, compatibility facade/shim, Streamlit gateway 구조를 반영했다.
|
||||||
- 2026-07-03 KST: REQ-016/REQ-017/REQ-018 및 BAT-012/BAT-013/BAT-014 기준으로 import/advanced owning services, parser seam, MCP contract helper, page/domain Streamlit gateways를 반영했다.
|
- 2026-07-03 KST: REQ-016/REQ-017/REQ-018 및 BAT-012/BAT-013/BAT-014 기준으로 import/advanced owning services, parser seam, MCP contract helper, page/domain Streamlit gateways를 반영했다.
|
||||||
- 2026-07-07 KST: REQ-019/BAT-015 기준으로 Dockerfile/compose, `jarca-ledger` independent container, streamable-http MCP endpoint, Synology volume `/volume1/docker/jarca-ledger/data`, and Docker deployment runbook을 반영했다.
|
- 2026-07-07 KST: REQ-019/BAT-015 기준으로 Dockerfile/compose, `jarca-ledger` independent container, streamable-http MCP endpoint, Synology volume `/volume1/docker/jarca-ledger/data`, and Docker deployment runbook을 반영했다.
|
||||||
|
- 2026-07-08 KST: REQ-020/BAT-016 기준으로 review decision service, schema version 11, MCP/Streamlit review workflow integration을 반영했다.
|
||||||
|
|||||||
+12
-2
@@ -4,8 +4,8 @@
|
|||||||
- Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml`
|
- Baseline Seed: `.stage-pilot/bootstrap/baseline.yaml`
|
||||||
- Status: draft
|
- Status: draft
|
||||||
- Owner: Jarca / project maintainer
|
- Owner: Jarca / project maintainer
|
||||||
- Last Updated (KST): 2026-07-07
|
- Last Updated (KST): 2026-07-08
|
||||||
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008, BAT-010, BAT-012, BAT-013, BAT-014, dcy-013/BAT-015
|
- Source Discovery / Batch: bootstrap-baseline, BAT-006, BAT-007, BAT-008, BAT-010, BAT-012, BAT-013, BAT-014, dcy-013/BAT-015, dcy-012/REQ-020/BAT-016
|
||||||
- Primary Runtime: mixed
|
- Primary Runtime: mixed
|
||||||
|
|
||||||
## Project Summary
|
## Project Summary
|
||||||
@@ -100,6 +100,15 @@
|
|||||||
3. Matched self-transfers become `이체 / 본인계좌이동`; investment movements become `이체 / 투자이동`; ambiguous rows remain `확인필요`.
|
3. Matched self-transfers become `이체 / 본인계좌이동`; investment movements become `이체 / 투자이동`; ambiguous rows remain `확인필요`.
|
||||||
4. Cancel/refund rows can be linked to original expense rows; memo tags preserve manual link evidence.
|
4. Cancel/refund rows can be linked to original expense rows; memo tags preserve manual link evidence.
|
||||||
|
|
||||||
|
## Flow: `Review decision and reconciliation workbench persistence`
|
||||||
|
1. Operator reviews cross-account transfer candidates, reconciliation differences, or uncategorized recommendations in Streamlit or via MCP.
|
||||||
|
2. UI/MCP calls safe review decision tools such as `upsert_review_decision`, `hold_review_decision`, `mark_review_recheck`, and `release_review_decision`.
|
||||||
|
3. `review_decision_service.py` validates status/decision type, minimizes `target_payload`, drops sensitive keys, and writes `review_decisions`.
|
||||||
|
4. Reconciliation workflows call `upsert_reconciliation_session` or `close_reconciliation_session` for account/basis-date sessions stored in `reconciliation_sessions`.
|
||||||
|
5. Cross-transfer queue, balance reconciliation report, and uncategorized workbench re-read persisted state and return additive fields such as `review_status`, `review_decision`, and `reconciliation_session`.
|
||||||
|
6. Operating hub indicators aggregate pending review, held/recheck, unreconciled account count, last reconciliation close timestamp, and uncategorized recommendation backlog.
|
||||||
|
7. MCP tool surface remains non-destructive; delete/reset of review/session state is intentionally absent.
|
||||||
|
|
||||||
## Flow: `Operational backup, validation DB, and restore smoke`
|
## Flow: `Operational backup, validation DB, and restore smoke`
|
||||||
1. Operator captures non-sensitive DB status evidence with `get_ledger_db_status`: path, size, mtime, sha256, integrity check, major table row counts, and aggregate totals.
|
1. Operator captures non-sensitive DB status evidence with `get_ledger_db_status`: path, size, mtime, sha256, integrity check, major table row counts, and aggregate totals.
|
||||||
2. Operator creates a timestamped backup in `/root/.local/share/jarca/backups/ledger/` with `create_ledger_backup`; retention keeps the most recent 30 days or newest 20 files.
|
2. Operator creates a timestamped backup in `/root/.local/share/jarca/backups/ledger/` with `create_ledger_backup`; retention keeps the most recent 30 days or newest 20 files.
|
||||||
@@ -156,3 +165,4 @@
|
|||||||
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 service facade → application, db shim → infrastructure/sqlite, MCP/Streamlit adapter seam flow를 반영했다.
|
- 2026-07-03 KST: REQ-013/REQ-014/BAT-010 기준으로 service facade → application, db shim → infrastructure/sqlite, MCP/Streamlit adapter seam flow를 반영했다.
|
||||||
- 2026-07-03 KST: REQ-016/REQ-017/REQ-018 및 BAT-012/BAT-013/BAT-014 기준으로 imports/advanced owning services, parser seam, MCP tool_contracts, Streamlit page/domain gateway flow를 반영했다.
|
- 2026-07-03 KST: REQ-016/REQ-017/REQ-018 및 BAT-012/BAT-013/BAT-014 기준으로 imports/advanced owning services, parser seam, MCP tool_contracts, Streamlit page/domain gateway flow를 반영했다.
|
||||||
- 2026-07-07 KST: REQ-019/BAT-015 기준으로 Docker network MCP service flow와 legacy `jarca` DB → `jarca-ledger` volume migration flow를 추가했다.
|
- 2026-07-07 KST: REQ-019/BAT-015 기준으로 Docker network MCP service flow와 legacy `jarca` DB → `jarca-ledger` volume migration flow를 추가했다.
|
||||||
|
- 2026-07-08 KST: REQ-020/BAT-016 기준으로 review decision/reconciliation workbench persistence flow와 운영 허브 지표 집계를 추가했다.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# req-020: Review decision persistence and reconciliation workbench
|
# req-020: Review decision persistence and reconciliation workbench
|
||||||
|
|
||||||
- Status: Approved
|
- Status: Implemented
|
||||||
- Type: Interface
|
- Type: Interface
|
||||||
- Priority: High
|
- Priority: High
|
||||||
- Owner: 렛저장인
|
- Owner: 렛저장인
|
||||||
@@ -161,3 +161,52 @@
|
|||||||
|
|
||||||
#### Validation Plan
|
#### Validation Plan
|
||||||
- Validate through StagePilot doctor, index consistency checks, and CI after commit. Delivery validation remains owned by the later batch verification gate.
|
- Validate through StagePilot doctor, index consistency checks, and CI after commit. Delivery validation remains owned by the later batch verification gate.
|
||||||
|
|
||||||
|
|
||||||
|
### CHG-20260708-02
|
||||||
|
- Date: 2026-07-08
|
||||||
|
- Author: 가웨인
|
||||||
|
|
||||||
|
#### Change Summary
|
||||||
|
- BAT-016 local implementation and verification evidence completed; REQ-020 Status moved to Implemented.
|
||||||
|
|
||||||
|
#### Intent
|
||||||
|
- dcy-012 confirmed scope now has additive persistence, Streamlit/MCP integration, operating indicators, tests, and baseline document updates on feature branch `feat/bat-016-review-decision-workbench`.
|
||||||
|
|
||||||
|
#### Acceptance Criteria Delta
|
||||||
|
- Added:
|
||||||
|
- none
|
||||||
|
- Updated:
|
||||||
|
- none
|
||||||
|
- Removed:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Impacted Area Delta
|
||||||
|
- Docs:
|
||||||
|
- `docs/batches/bat-016_20260708_review-decision-reconciliation-workbench/*`
|
||||||
|
- `docs/data-model.md`
|
||||||
|
- `docs/project-structure.md`
|
||||||
|
- `docs/runtime-flows.md`
|
||||||
|
- Modules:
|
||||||
|
- `jarca_ledger_mcp/application/review_decision_service.py`
|
||||||
|
- `jarca_ledger_mcp/infrastructure/sqlite/schema.py`
|
||||||
|
- `jarca_ledger_mcp/infrastructure/sqlite/migrations.py`
|
||||||
|
- MCP, Streamlit, reconciliation, transfer, and uncategorized workbench integration surfaces
|
||||||
|
- Tests:
|
||||||
|
- `tests/test_review_decision_workbench.py`
|
||||||
|
- schema-version expectations in existing DB health tests
|
||||||
|
|
||||||
|
#### Delivery Trace Delta
|
||||||
|
- Batches:
|
||||||
|
- BAT-016 release-candidate
|
||||||
|
- Releases:
|
||||||
|
- none
|
||||||
|
|
||||||
|
#### Revalidation Impact
|
||||||
|
- Reverification Needed: yes before merge/release
|
||||||
|
- Existing Implementation Invalidated: no
|
||||||
|
- Status Recommendation: implemented-after-local-verification
|
||||||
|
- Follow-up Action: push branch, open PR, and verify GitHub Actions CI.
|
||||||
|
|
||||||
|
#### Validation Plan
|
||||||
|
- Local evidence: `ruff check .`, targeted pytest, full pytest, MCP tool surface smoke, and DB safety smoke passed at 2026-07-08 07:11 KST. GitHub Actions CI remains the next external gate.
|
||||||
|
|||||||
+3
-1
@@ -59,7 +59,7 @@
|
|||||||
| REQ-017 | Advanced finance workflow service split and compatibility-preserving domain isolation | Non-Functional | Implemented | High | 렛저장인 | [req-017_advanced-finance-workflow-service-split.md](Non-Functional/req-017_advanced-finance-workflow-service-split.md) |
|
| REQ-017 | Advanced finance workflow service split and compatibility-preserving domain isolation | Non-Functional | Implemented | High | 렛저장인 | [req-017_advanced-finance-workflow-service-split.md](Non-Functional/req-017_advanced-finance-workflow-service-split.md) |
|
||||||
| REQ-018 | MCP/Streamlit adapter surface narrowing and baseline refresh for dcy-011 completion | Interface | Implemented | High | 렛저장인 | [req-018_adapter-surface-narrowing-baseline-refresh.md](Interface/req-018_adapter-surface-narrowing-baseline-refresh.md) |
|
| REQ-018 | MCP/Streamlit adapter surface narrowing and baseline refresh for dcy-011 completion | Interface | Implemented | High | 렛저장인 | [req-018_adapter-surface-narrowing-baseline-refresh.md](Interface/req-018_adapter-surface-narrowing-baseline-refresh.md) |
|
||||||
| REQ-019 | Docker network MCP deployment and operating DB migration | Deployment | Implemented | High | 렛저장인 | [req-019_docker-network-mcp-deployment.md](Deployment/req-019_docker-network-mcp-deployment.md) |
|
| REQ-019 | Docker network MCP deployment and operating DB migration | Deployment | Implemented | High | 렛저장인 | [req-019_docker-network-mcp-deployment.md](Deployment/req-019_docker-network-mcp-deployment.md) |
|
||||||
| REQ-020 | Review decision persistence and reconciliation workbench | Interface | Approved | High | 렛저장인 | [req-020_review-decision-persistence-reconciliation-workbench.md](Interface/req-020_review-decision-persistence-reconciliation-workbench.md) |
|
| REQ-020 | Review decision persistence and reconciliation workbench | Interface | Implemented | High | 렛저장인 | [req-020_review-decision-persistence-reconciliation-workbench.md](Interface/req-020_review-decision-persistence-reconciliation-workbench.md) |
|
||||||
|
|
||||||
## Recent Change Log Summary
|
## Recent Change Log Summary
|
||||||
|
|
||||||
@@ -97,3 +97,5 @@
|
|||||||
- 2026-07-07 22:23 KST: dcy-013 Docker network MCP 배포 범위로 REQ-019를 생성하고, BAT-015 구현·검증 evidence 및 운영 DB 이전 완료를 근거로 Implemented 상태로 등록했다.
|
- 2026-07-07 22:23 KST: dcy-013 Docker network MCP 배포 범위로 REQ-019를 생성하고, BAT-015 구현·검증 evidence 및 운영 DB 이전 완료를 근거로 Implemented 상태로 등록했다.
|
||||||
- 2026-07-07 23:53 KST: dcy-012 confirmed 범위를 `REQ-020` Proposed 상태로 생성하고 review decision persistence/reconciliation workbench 요구사항으로 등록했다.
|
- 2026-07-07 23:53 KST: dcy-012 confirmed 범위를 `REQ-020` Proposed 상태로 생성하고 review decision persistence/reconciliation workbench 요구사항으로 등록했다.
|
||||||
- 2026-07-08 00:00 KST: REQ-020을 승인 게이트 검토 후 Approved 상태로 전환했다.
|
- 2026-07-08 00:00 KST: REQ-020을 승인 게이트 검토 후 Approved 상태로 전환했다.
|
||||||
|
|
||||||
|
- 2026-07-08 07:11 KST: REQ-020을 BAT-016 local verification 근거로 Implemented 상태로 전환했다. CI/PR review는 후속 외부 gate로 남아 있다.
|
||||||
|
|||||||
@@ -166,6 +166,51 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
|
|||||||
"""Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB."""
|
"""Preview balance reconciliation for manual_only/partial_import accounts without mutating the DB."""
|
||||||
return contracts.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance)
|
return contracts.get_balance_reconciliation_report(account_id, baseline_balance, baseline_at, source_note, memo, tolerance)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_review_decisions(target_type: str = "", target_id: str = "", status: str = "", limit: int = 100) -> dict:
|
||||||
|
"""List persisted review decisions for workbench targets."""
|
||||||
|
return contracts.list_review_decisions(target_type=target_type, target_id=target_id, status=status, limit=limit)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def upsert_review_decision(target_type: str, target_id: str, status: str = "decided", decision_type: str = "unknown", reason_code: str = "", memo: str = "", operator: str = "operator", review_after: str = "", target_payload: dict | None = None, decision_id: str = "") -> dict:
|
||||||
|
"""Create or update a non-destructive persisted review decision."""
|
||||||
|
return contracts.upsert_review_decision(target_type, target_id, status, decision_type, reason_code, memo, operator, review_after, target_payload, decision_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def hold_review_decision(target_type: str, target_id: str, reason_code: str = "held", memo: str = "", operator: str = "operator", review_after: str = "") -> dict:
|
||||||
|
"""Mark a review target as held without deleting evidence."""
|
||||||
|
return contracts.hold_review_decision(target_type, target_id, reason_code, memo, operator, review_after)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def mark_review_recheck(target_type: str, target_id: str, reason_code: str = "needs_recheck", memo: str = "", operator: str = "operator", review_after: str = "") -> dict:
|
||||||
|
"""Mark a review target as needing recheck."""
|
||||||
|
return contracts.mark_review_recheck(target_type, target_id, reason_code, memo, operator, review_after)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def release_review_decision(target_type: str, target_id: str, memo: str = "", operator: str = "operator") -> dict:
|
||||||
|
"""Release a held/recheck review target back to pending."""
|
||||||
|
return contracts.release_review_decision(target_type, target_id, memo, operator)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_date: str = "", limit: int = 100) -> dict:
|
||||||
|
"""List persisted reconciliation sessions."""
|
||||||
|
return contracts.list_reconciliation_sessions(account_id=account_id, status=status, basis_date=basis_date, limit=limit)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def upsert_reconciliation_session(account_id: str, basis_date: str, expected_balance: int | None = None, actual_balance: int | None = None, difference: int | None = None, status: str = "open", source_note: str = "", memo: str = "", operator: str = "operator", session_id: str = "") -> dict:
|
||||||
|
"""Create or update an account/basis-date reconciliation session."""
|
||||||
|
return contracts.upsert_reconciliation_session(account_id, basis_date, expected_balance, actual_balance, difference, status, source_note, memo, operator, session_id)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def close_reconciliation_session(session_id: str = "", account_id: str = "", basis_date: str = "", memo: str = "", operator: str = "operator") -> dict:
|
||||||
|
"""Close an existing reconciliation session without destructive reset/delete."""
|
||||||
|
return contracts.close_reconciliation_session(session_id=session_id, account_id=account_id, basis_date=basis_date, memo=memo, operator=operator)
|
||||||
|
|
||||||
|
@mcp.tool()
|
||||||
|
def get_review_operating_indicators() -> dict:
|
||||||
|
"""Return workbench indicators: pending, held/recheck, unreconciled, last close, uncategorized backlog."""
|
||||||
|
return contracts.get_review_operating_indicators()
|
||||||
|
|
||||||
@mcp.tool()
|
@mcp.tool()
|
||||||
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict:
|
def create_ledger_transaction(transaction_date: str = "", type: str = "expense", amount: int = 0, category: str = "", subcategory: str = "", merchant: str = "", description: str = "", account_id: str = "", payment_method: str = "", person: str = "", project: str = "", tags: list[str] | str | None = None, is_fixed: bool = False, currency: str = "KRW", posted_date: str = "", source: str = "manual", external_id: str = "", memo: str = "", dedupe: bool = True) -> dict:
|
||||||
"""Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction."""
|
"""Create an income, expense, transfer, refund, or adjustment transaction. Amount is positive KRW minor unit; type determines direction."""
|
||||||
|
|||||||
@@ -26,7 +26,16 @@ from ...service import (
|
|||||||
get_ledger_db_status as get_ledger_db_status,
|
get_ledger_db_status as get_ledger_db_status,
|
||||||
get_monthly_cashflow_report as get_monthly_cashflow_report,
|
get_monthly_cashflow_report as get_monthly_cashflow_report,
|
||||||
get_net_worth_timeseries as get_net_worth_timeseries,
|
get_net_worth_timeseries as get_net_worth_timeseries,
|
||||||
|
get_review_operating_indicators as get_review_operating_indicators,
|
||||||
get_uncategorized_merchant_summary as get_uncategorized_merchant_summary,
|
get_uncategorized_merchant_summary as get_uncategorized_merchant_summary,
|
||||||
|
hold_review_decision as hold_review_decision,
|
||||||
|
list_reconciliation_sessions as list_reconciliation_sessions,
|
||||||
|
list_review_decisions as list_review_decisions,
|
||||||
|
mark_review_recheck as mark_review_recheck,
|
||||||
|
release_review_decision as release_review_decision,
|
||||||
|
upsert_reconciliation_session as upsert_reconciliation_session,
|
||||||
|
upsert_review_decision as upsert_review_decision,
|
||||||
|
close_reconciliation_session as close_reconciliation_session,
|
||||||
health_check as health_check,
|
health_check as health_check,
|
||||||
import_kb_bank_pdf as import_kb_bank_pdf,
|
import_kb_bank_pdf as import_kb_bank_pdf,
|
||||||
import_seoulpay_xlsx as import_seoulpay_xlsx,
|
import_seoulpay_xlsx as import_seoulpay_xlsx,
|
||||||
@@ -86,7 +95,16 @@ __all__ = [
|
|||||||
"get_ledger_db_status",
|
"get_ledger_db_status",
|
||||||
"get_monthly_cashflow_report",
|
"get_monthly_cashflow_report",
|
||||||
"get_net_worth_timeseries",
|
"get_net_worth_timeseries",
|
||||||
|
"get_review_operating_indicators",
|
||||||
"get_uncategorized_merchant_summary",
|
"get_uncategorized_merchant_summary",
|
||||||
|
"hold_review_decision",
|
||||||
|
"list_reconciliation_sessions",
|
||||||
|
"list_review_decisions",
|
||||||
|
"mark_review_recheck",
|
||||||
|
"release_review_decision",
|
||||||
|
"upsert_reconciliation_session",
|
||||||
|
"upsert_review_decision",
|
||||||
|
"close_reconciliation_session",
|
||||||
"health_check",
|
"health_check",
|
||||||
"import_kb_bank_pdf",
|
"import_kb_bank_pdf",
|
||||||
"import_seoulpay_xlsx",
|
"import_seoulpay_xlsx",
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ from ...service import (
|
|||||||
delete_ledger_category as delete_ledger_category,
|
delete_ledger_category as delete_ledger_category,
|
||||||
get_uncategorized_merchant_summary as get_uncategorized_merchant_summary,
|
get_uncategorized_merchant_summary as get_uncategorized_merchant_summary,
|
||||||
get_uncategorized_workbench as get_uncategorized_workbench,
|
get_uncategorized_workbench as get_uncategorized_workbench,
|
||||||
|
hold_review_decision as hold_review_decision,
|
||||||
list_category_rules as list_category_rules,
|
list_category_rules as list_category_rules,
|
||||||
|
mark_review_recheck as mark_review_recheck,
|
||||||
|
release_review_decision as release_review_decision,
|
||||||
|
upsert_review_decision as upsert_review_decision,
|
||||||
list_ledger_categories as list_ledger_categories,
|
list_ledger_categories as list_ledger_categories,
|
||||||
list_uncategorized_transactions as list_uncategorized_transactions,
|
list_uncategorized_transactions as list_uncategorized_transactions,
|
||||||
preview_categorization as preview_categorization,
|
preview_categorization as preview_categorization,
|
||||||
@@ -29,7 +33,11 @@ __all__ = [
|
|||||||
"delete_ledger_category",
|
"delete_ledger_category",
|
||||||
"get_uncategorized_merchant_summary",
|
"get_uncategorized_merchant_summary",
|
||||||
"get_uncategorized_workbench",
|
"get_uncategorized_workbench",
|
||||||
|
"hold_review_decision",
|
||||||
"list_category_rules",
|
"list_category_rules",
|
||||||
|
"mark_review_recheck",
|
||||||
|
"release_review_decision",
|
||||||
|
"upsert_review_decision",
|
||||||
"list_ledger_categories",
|
"list_ledger_categories",
|
||||||
"list_uncategorized_transactions",
|
"list_uncategorized_transactions",
|
||||||
"preview_categorization",
|
"preview_categorization",
|
||||||
|
|||||||
@@ -1,7 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from ...service import get_balance_reconciliation_report as get_balance_reconciliation_report
|
from ...service import (
|
||||||
|
close_reconciliation_session as close_reconciliation_session,
|
||||||
|
get_balance_reconciliation_report as get_balance_reconciliation_report,
|
||||||
|
get_review_operating_indicators as get_review_operating_indicators,
|
||||||
|
hold_review_decision as hold_review_decision,
|
||||||
|
list_reconciliation_sessions as list_reconciliation_sessions,
|
||||||
|
mark_review_recheck as mark_review_recheck,
|
||||||
|
release_review_decision as release_review_decision,
|
||||||
|
upsert_reconciliation_session as upsert_reconciliation_session,
|
||||||
|
upsert_review_decision as upsert_review_decision,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"close_reconciliation_session",
|
||||||
"get_balance_reconciliation_report",
|
"get_balance_reconciliation_report",
|
||||||
|
"get_review_operating_indicators",
|
||||||
|
"hold_review_decision",
|
||||||
|
"list_reconciliation_sessions",
|
||||||
|
"mark_review_recheck",
|
||||||
|
"release_review_decision",
|
||||||
|
"upsert_reconciliation_session",
|
||||||
|
"upsert_review_decision",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ from __future__ import annotations
|
|||||||
from ...service import (
|
from ...service import (
|
||||||
find_cancel_original_candidates as find_cancel_original_candidates,
|
find_cancel_original_candidates as find_cancel_original_candidates,
|
||||||
find_self_transfer_candidates as find_self_transfer_candidates,
|
find_self_transfer_candidates as find_self_transfer_candidates,
|
||||||
|
hold_review_decision as hold_review_decision,
|
||||||
link_cancel_to_original as link_cancel_to_original,
|
link_cancel_to_original as link_cancel_to_original,
|
||||||
link_self_transfer_pair as link_self_transfer_pair,
|
link_self_transfer_pair as link_self_transfer_pair,
|
||||||
list_cross_transfer_review_queue as list_cross_transfer_review_queue,
|
list_cross_transfer_review_queue as list_cross_transfer_review_queue,
|
||||||
|
mark_review_recheck as mark_review_recheck,
|
||||||
|
release_review_decision as release_review_decision,
|
||||||
|
upsert_review_decision as upsert_review_decision,
|
||||||
preview_cross_account_transfers as preview_cross_account_transfers,
|
preview_cross_account_transfers as preview_cross_account_transfers,
|
||||||
run_cross_account_transfer_match as run_cross_account_transfer_match,
|
run_cross_account_transfer_match as run_cross_account_transfer_match,
|
||||||
)
|
)
|
||||||
@@ -13,9 +17,13 @@ from ...service import (
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"find_cancel_original_candidates",
|
"find_cancel_original_candidates",
|
||||||
"find_self_transfer_candidates",
|
"find_self_transfer_candidates",
|
||||||
|
"hold_review_decision",
|
||||||
"link_cancel_to_original",
|
"link_cancel_to_original",
|
||||||
"link_self_transfer_pair",
|
"link_self_transfer_pair",
|
||||||
"list_cross_transfer_review_queue",
|
"list_cross_transfer_review_queue",
|
||||||
|
"mark_review_recheck",
|
||||||
|
"release_review_decision",
|
||||||
|
"upsert_review_decision",
|
||||||
"preview_cross_account_transfers",
|
"preview_cross_account_transfers",
|
||||||
"run_cross_account_transfer_match",
|
"run_cross_account_transfer_match",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from .shared import (
|
|||||||
re,
|
re,
|
||||||
row_to_dict,
|
row_to_dict,
|
||||||
)
|
)
|
||||||
|
from .review_decision_service import attach_review_decisions
|
||||||
|
|
||||||
def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]:
|
def _build_cross_transfer_index(transactions: list[dict[str, Any]]) -> dict[tuple[str, int, str], list[dict[str, Any]]]:
|
||||||
index: dict[tuple[str, int, str], list[dict[str, Any]]] = {}
|
index: dict[tuple[str, int, str], list[dict[str, Any]]] = {}
|
||||||
@@ -607,4 +608,5 @@ def list_cross_transfer_review_queue(limit: int = 300, db_path: str | None = Non
|
|||||||
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
|
f"SELECT * FROM transactions t{where} ORDER BY t.transaction_date DESC, t.created_at DESC LIMIT ?",
|
||||||
(max(1, min(int(limit), 1000)),),
|
(max(1, min(int(limit), 1000)),),
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return ok(transactions=[row_to_dict(r) for r in rows], count=len(rows), source="cross_transfer_review_queue")
|
transactions = attach_review_decisions(conn, [row_to_dict(r) for r in rows], "cross_transfer", "id")
|
||||||
|
return ok(transactions=transactions, count=len(transactions), source="cross_transfer_review_queue")
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ from .shared import (
|
|||||||
normalize_snapshot_at,
|
normalize_snapshot_at,
|
||||||
now_iso,
|
now_iso,
|
||||||
ok,
|
ok,
|
||||||
|
row_to_dict,
|
||||||
sqlite3,
|
sqlite3,
|
||||||
)
|
)
|
||||||
|
from .review_decision_service import _fetch_latest_decisions
|
||||||
|
|
||||||
|
|
||||||
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
|
def _transaction_signed_amount(tx_type: str, amount: int) -> int:
|
||||||
@@ -72,6 +74,13 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
|
|||||||
clauses.append(f"statement_status IN ({placeholders})")
|
clauses.append(f"statement_status IN ({placeholders})")
|
||||||
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
|
params.extend(sorted(RECONCILIATION_TARGET_STATUSES))
|
||||||
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
|
accounts = conn.execute(f"SELECT * FROM accounts WHERE {' AND '.join(clauses)} ORDER BY name", params).fetchall()
|
||||||
|
account_targets = [("reconciliation_account", account["id"]) for account in accounts]
|
||||||
|
latest_decisions = _fetch_latest_decisions(conn, account_targets)
|
||||||
|
session_rows = conn.execute("SELECT * FROM reconciliation_sessions ORDER BY basis_date DESC, updated_at DESC").fetchall()
|
||||||
|
latest_sessions: dict[str, dict[str, Any]] = {}
|
||||||
|
for row in session_rows:
|
||||||
|
session = row_to_dict(row)
|
||||||
|
latest_sessions.setdefault(session["account_id"], session)
|
||||||
items: list[dict[str, Any]] = []
|
items: list[dict[str, Any]] = []
|
||||||
for account in accounts:
|
for account in accounts:
|
||||||
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
|
effective_baseline = baseline_balance_int if baseline_balance_int is not None else account["last_balance"]
|
||||||
@@ -99,6 +108,9 @@ def get_balance_reconciliation_report(account_id: str = "", baseline_balance: in
|
|||||||
"difference_ratio": ratio,
|
"difference_ratio": ratio,
|
||||||
"status": status,
|
"status": status,
|
||||||
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
|
"recommended_decisions": ["계좌이동", "실지출", "실수입", "투자이동", "분석제외", "확인필요"] if status == "REVIEW_NEEDED" else [],
|
||||||
|
"reconciliation_session": latest_sessions.get(account["id"]),
|
||||||
|
"review_status": latest_decisions.get(("reconciliation_account", account["id"]), {}).get("status", "pending"),
|
||||||
|
"review_decision": latest_decisions.get(("reconciliation_account", account["id"])),
|
||||||
"source_note": source_note,
|
"source_note": source_note,
|
||||||
"memo": memo,
|
"memo": memo,
|
||||||
**calc,
|
**calc,
|
||||||
|
|||||||
@@ -0,0 +1,359 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from .shared import (
|
||||||
|
Any,
|
||||||
|
db,
|
||||||
|
fail,
|
||||||
|
json,
|
||||||
|
new_id,
|
||||||
|
normalize_date,
|
||||||
|
now_iso,
|
||||||
|
ok,
|
||||||
|
row_to_dict,
|
||||||
|
)
|
||||||
|
|
||||||
|
VALID_REVIEW_STATUSES = {"pending", "decided", "held", "needs_recheck", "ignored"}
|
||||||
|
VALID_DECISION_TYPES = {
|
||||||
|
"account_transfer",
|
||||||
|
"real_expense",
|
||||||
|
"real_income",
|
||||||
|
"investment_transfer",
|
||||||
|
"excluded_from_analysis",
|
||||||
|
"duplicate_or_cancel",
|
||||||
|
"unknown",
|
||||||
|
}
|
||||||
|
VALID_RECONCILIATION_STATUSES = {"open", "closed", "needs_recheck", "ignored"}
|
||||||
|
SENSITIVE_PAYLOAD_KEYS = {"raw_payload", "password", "account_password", "secret", "token", "file_path"}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_review_status(value: str | None) -> str:
|
||||||
|
status = (value or "pending").strip() or "pending"
|
||||||
|
if status not in VALID_REVIEW_STATUSES:
|
||||||
|
raise ValueError(f"status must be one of {sorted(VALID_REVIEW_STATUSES)}")
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_decision_type(value: str | None) -> str:
|
||||||
|
decision_type = (value or "unknown").strip() or "unknown"
|
||||||
|
if decision_type not in VALID_DECISION_TYPES:
|
||||||
|
raise ValueError(f"decision_type must be one of {sorted(VALID_DECISION_TYPES)}")
|
||||||
|
return decision_type
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_reconciliation_status(value: str | None) -> str:
|
||||||
|
status = (value or "open").strip() or "open"
|
||||||
|
if status not in VALID_RECONCILIATION_STATUSES:
|
||||||
|
raise ValueError(f"status must be one of {sorted(VALID_RECONCILIATION_STATUSES)}")
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_payload(target_payload: Any) -> str:
|
||||||
|
if target_payload in (None, ""):
|
||||||
|
return "{}"
|
||||||
|
if isinstance(target_payload, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(target_payload)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return json.dumps({"summary": target_payload[:500]}, ensure_ascii=False)
|
||||||
|
elif isinstance(target_payload, dict):
|
||||||
|
parsed = target_payload
|
||||||
|
else:
|
||||||
|
parsed = {"value": str(target_payload)[:500]}
|
||||||
|
safe: dict[str, Any] = {}
|
||||||
|
for key, value in parsed.items():
|
||||||
|
key_text = str(key)
|
||||||
|
if key_text.lower() in SENSITIVE_PAYLOAD_KEYS:
|
||||||
|
continue
|
||||||
|
if isinstance(value, (str, int, float, bool)) or value is None:
|
||||||
|
safe[key_text] = str(value)[:500] if isinstance(value, str) else value
|
||||||
|
elif isinstance(value, (list, tuple)):
|
||||||
|
safe[key_text] = [str(item)[:200] for item in value[:20]]
|
||||||
|
else:
|
||||||
|
safe[key_text] = str(value)[:500]
|
||||||
|
return json.dumps(safe, ensure_ascii=False, sort_keys=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_payload(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
raw = row.get("target_payload") or "{}"
|
||||||
|
try:
|
||||||
|
row["target_payload"] = json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
row["target_payload"] = {}
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_latest_decisions(conn: Any, targets: list[tuple[str, str]]) -> dict[tuple[str, str], dict[str, Any]]:
|
||||||
|
if not targets:
|
||||||
|
return {}
|
||||||
|
by_target: dict[tuple[str, str], dict[str, Any]] = {}
|
||||||
|
for target_type, target_id in targets:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT * FROM review_decisions
|
||||||
|
WHERE target_type=? AND target_id=?
|
||||||
|
ORDER BY decided_at DESC, updated_at DESC, created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
""",
|
||||||
|
(target_type, target_id),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
by_target[(target_type, target_id)] = _decode_payload(row_to_dict(row))
|
||||||
|
return by_target
|
||||||
|
|
||||||
|
|
||||||
|
def attach_review_decisions(db_conn: Any, items: list[dict[str, Any]], target_type: str, id_field: str = "id") -> list[dict[str, Any]]:
|
||||||
|
targets = [(target_type, str(item.get(id_field) or "")) for item in items if item.get(id_field)]
|
||||||
|
decisions = _fetch_latest_decisions(db_conn, targets)
|
||||||
|
enriched: list[dict[str, Any]] = []
|
||||||
|
for item in items:
|
||||||
|
target_id = str(item.get(id_field) or "")
|
||||||
|
decision = decisions.get((target_type, target_id))
|
||||||
|
state = decision.get("status") if decision else "pending"
|
||||||
|
enriched.append({**item, "review_status": state, "review_decision": decision})
|
||||||
|
return enriched
|
||||||
|
|
||||||
|
|
||||||
|
def list_review_decisions(target_type: str = "", target_id: str = "", status: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if target_type:
|
||||||
|
clauses.append("target_type=?")
|
||||||
|
params.append(target_type)
|
||||||
|
if target_id:
|
||||||
|
clauses.append("target_id=?")
|
||||||
|
params.append(target_id)
|
||||||
|
if status:
|
||||||
|
try:
|
||||||
|
status = _normalize_review_status(status)
|
||||||
|
except ValueError as exc:
|
||||||
|
return fail("validation_error", str(exc))
|
||||||
|
clauses.append("status=?")
|
||||||
|
params.append(status)
|
||||||
|
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
|
max_limit = max(1, min(int(limit), 1000))
|
||||||
|
with db(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM review_decisions{where} ORDER BY decided_at DESC, updated_at DESC LIMIT ?",
|
||||||
|
(*params, max_limit),
|
||||||
|
).fetchall()
|
||||||
|
decisions = [_decode_payload(row_to_dict(row)) for row in rows]
|
||||||
|
return ok(review_decisions=decisions, decisions=decisions, count=len(decisions))
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_review_decision(
|
||||||
|
target_type: str,
|
||||||
|
target_id: str,
|
||||||
|
status: str = "decided",
|
||||||
|
decision_type: str = "unknown",
|
||||||
|
reason_code: str = "",
|
||||||
|
memo: str = "",
|
||||||
|
operator: str = "operator",
|
||||||
|
review_after: str = "",
|
||||||
|
target_payload: Any = None,
|
||||||
|
decision_id: str = "",
|
||||||
|
db_path: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
target_type = (target_type or "").strip()
|
||||||
|
target_id = (target_id or "").strip()
|
||||||
|
if not target_type or not target_id:
|
||||||
|
return fail("validation_error", "target_type and target_id are required")
|
||||||
|
try:
|
||||||
|
normalized_status = _normalize_review_status(status)
|
||||||
|
normalized_decision_type = _normalize_decision_type(decision_type)
|
||||||
|
normalized_review_after = normalize_date(review_after, default_today=False) if review_after else ""
|
||||||
|
except Exception as exc:
|
||||||
|
return fail("validation_error", str(exc))
|
||||||
|
ts = now_iso()
|
||||||
|
payload_text = _safe_payload(target_payload)
|
||||||
|
with db(db_path) as conn:
|
||||||
|
existing = None
|
||||||
|
if decision_id:
|
||||||
|
existing = conn.execute("SELECT * FROM review_decisions WHERE id=?", (decision_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
return fail("not_found", f"review decision not found: {decision_id}")
|
||||||
|
else:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT * FROM review_decisions WHERE target_type=? AND target_id=? ORDER BY decided_at DESC, updated_at DESC LIMIT 1",
|
||||||
|
(target_type, target_id),
|
||||||
|
).fetchone()
|
||||||
|
row_id = decision_id or (existing["id"] if existing else new_id("rd"))
|
||||||
|
created_at = existing["created_at"] if existing else ts
|
||||||
|
decided_at = ts if normalized_status in {"decided", "held", "needs_recheck", "ignored"} else ""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO review_decisions(id, target_type, target_id, status, decision_type, reason_code, memo, operator, decided_at, review_after, target_payload, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
target_type=excluded.target_type,
|
||||||
|
target_id=excluded.target_id,
|
||||||
|
status=excluded.status,
|
||||||
|
decision_type=excluded.decision_type,
|
||||||
|
reason_code=excluded.reason_code,
|
||||||
|
memo=excluded.memo,
|
||||||
|
operator=excluded.operator,
|
||||||
|
decided_at=excluded.decided_at,
|
||||||
|
review_after=excluded.review_after,
|
||||||
|
target_payload=excluded.target_payload,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(row_id, target_type, target_id, normalized_status, normalized_decision_type, reason_code or "", memo or "", operator or "operator", decided_at, normalized_review_after, payload_text, created_at, ts),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM review_decisions WHERE id=?", (row_id,)).fetchone()
|
||||||
|
return ok(review_decision=_decode_payload(row_to_dict(row)), decision_id=row_id)
|
||||||
|
|
||||||
|
|
||||||
|
def hold_review_decision(target_type: str, target_id: str, reason_code: str = "held", memo: str = "", operator: str = "operator", review_after: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
return upsert_review_decision(target_type, target_id, status="held", decision_type="unknown", reason_code=reason_code, memo=memo, operator=operator, review_after=review_after, db_path=db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_review_recheck(target_type: str, target_id: str, reason_code: str = "needs_recheck", memo: str = "", operator: str = "operator", review_after: str = "", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
return upsert_review_decision(target_type, target_id, status="needs_recheck", decision_type="unknown", reason_code=reason_code, memo=memo, operator=operator, review_after=review_after, db_path=db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def release_review_decision(target_type: str, target_id: str, memo: str = "", operator: str = "operator", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
return upsert_review_decision(target_type, target_id, status="pending", decision_type="unknown", reason_code="released", memo=memo, operator=operator, review_after="", db_path=db_path)
|
||||||
|
|
||||||
|
|
||||||
|
def list_reconciliation_sessions(account_id: str = "", status: str = "", basis_date: str = "", limit: int = 100, db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if account_id:
|
||||||
|
clauses.append("account_id=?")
|
||||||
|
params.append(account_id)
|
||||||
|
if status:
|
||||||
|
try:
|
||||||
|
status = _normalize_reconciliation_status(status)
|
||||||
|
except ValueError as exc:
|
||||||
|
return fail("validation_error", str(exc))
|
||||||
|
clauses.append("status=?")
|
||||||
|
params.append(status)
|
||||||
|
if basis_date:
|
||||||
|
try:
|
||||||
|
basis_date = normalize_date(basis_date)
|
||||||
|
except Exception as exc:
|
||||||
|
return fail("validation_error", str(exc))
|
||||||
|
clauses.append("basis_date=?")
|
||||||
|
params.append(basis_date)
|
||||||
|
where = " WHERE " + " AND ".join(clauses) if clauses else ""
|
||||||
|
with db(db_path) as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM reconciliation_sessions{where} ORDER BY basis_date DESC, updated_at DESC LIMIT ?",
|
||||||
|
(*params, max(1, min(int(limit), 1000))),
|
||||||
|
).fetchall()
|
||||||
|
sessions = [row_to_dict(row) for row in rows]
|
||||||
|
return ok(reconciliation_sessions=sessions, sessions=sessions, count=len(sessions))
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_reconciliation_session(
|
||||||
|
account_id: str,
|
||||||
|
basis_date: str,
|
||||||
|
expected_balance: int | None = None,
|
||||||
|
actual_balance: int | None = None,
|
||||||
|
difference: int | None = None,
|
||||||
|
status: str = "open",
|
||||||
|
source_note: str = "",
|
||||||
|
memo: str = "",
|
||||||
|
operator: str = "operator",
|
||||||
|
session_id: str = "",
|
||||||
|
db_path: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
account_id = (account_id or "").strip()
|
||||||
|
if not account_id:
|
||||||
|
return fail("validation_error", "account_id is required")
|
||||||
|
try:
|
||||||
|
basis = normalize_date(basis_date, default_today=False)
|
||||||
|
normalized_status = _normalize_reconciliation_status(status)
|
||||||
|
expected = int(expected_balance) if expected_balance is not None else None
|
||||||
|
actual = int(actual_balance) if actual_balance is not None else None
|
||||||
|
diff = int(difference) if difference is not None else ((expected - actual) if expected is not None and actual is not None else None)
|
||||||
|
except Exception as exc:
|
||||||
|
return fail("validation_error", str(exc))
|
||||||
|
ts = now_iso()
|
||||||
|
closed_at = ts if normalized_status == "closed" else ""
|
||||||
|
with db(db_path) as conn:
|
||||||
|
account = conn.execute("SELECT id FROM accounts WHERE id=?", (account_id,)).fetchone()
|
||||||
|
if not account:
|
||||||
|
return fail("not_found", f"account not found: {account_id}")
|
||||||
|
existing = None
|
||||||
|
if session_id:
|
||||||
|
existing = conn.execute("SELECT * FROM reconciliation_sessions WHERE id=?", (session_id,)).fetchone()
|
||||||
|
if not existing:
|
||||||
|
return fail("not_found", f"reconciliation session not found: {session_id}")
|
||||||
|
else:
|
||||||
|
existing = conn.execute("SELECT * FROM reconciliation_sessions WHERE account_id=? AND basis_date=?", (account_id, basis)).fetchone()
|
||||||
|
row_id = session_id or (existing["id"] if existing else new_id("rs"))
|
||||||
|
created_at = existing["created_at"] if existing else ts
|
||||||
|
if normalized_status != "closed" and existing:
|
||||||
|
closed_at = existing["closed_at"] if existing["status"] == "closed" and normalized_status == "closed" else ""
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO reconciliation_sessions(id, account_id, basis_date, expected_balance, actual_balance, difference, status, closed_at, source_note, memo, operator, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
account_id=excluded.account_id,
|
||||||
|
basis_date=excluded.basis_date,
|
||||||
|
expected_balance=excluded.expected_balance,
|
||||||
|
actual_balance=excluded.actual_balance,
|
||||||
|
difference=excluded.difference,
|
||||||
|
status=excluded.status,
|
||||||
|
closed_at=excluded.closed_at,
|
||||||
|
source_note=excluded.source_note,
|
||||||
|
memo=excluded.memo,
|
||||||
|
operator=excluded.operator,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(row_id, account_id, basis, expected, actual, diff, normalized_status, closed_at, source_note or "", memo or "", operator or "operator", created_at, ts),
|
||||||
|
)
|
||||||
|
row = conn.execute("SELECT * FROM reconciliation_sessions WHERE id=?", (row_id,)).fetchone()
|
||||||
|
return ok(reconciliation_session=row_to_dict(row), session_id=row_id)
|
||||||
|
|
||||||
|
|
||||||
|
def close_reconciliation_session(session_id: str = "", account_id: str = "", basis_date: str = "", memo: str = "", operator: str = "operator", db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
if session_id:
|
||||||
|
row = conn.execute("SELECT * FROM reconciliation_sessions WHERE id=?", (session_id,)).fetchone()
|
||||||
|
elif account_id and basis_date:
|
||||||
|
try:
|
||||||
|
basis = normalize_date(basis_date, default_today=False)
|
||||||
|
except Exception as exc:
|
||||||
|
return fail("validation_error", str(exc))
|
||||||
|
row = conn.execute("SELECT * FROM reconciliation_sessions WHERE account_id=? AND basis_date=?", (account_id, basis)).fetchone()
|
||||||
|
else:
|
||||||
|
return fail("validation_error", "session_id or account_id+basis_date is required")
|
||||||
|
if not row:
|
||||||
|
return fail("not_found", "reconciliation session not found")
|
||||||
|
existing = row_to_dict(row)
|
||||||
|
return upsert_reconciliation_session(
|
||||||
|
account_id=existing["account_id"],
|
||||||
|
basis_date=existing["basis_date"],
|
||||||
|
expected_balance=existing.get("expected_balance"),
|
||||||
|
actual_balance=existing.get("actual_balance"),
|
||||||
|
difference=existing.get("difference"),
|
||||||
|
status="closed",
|
||||||
|
source_note=existing.get("source_note", ""),
|
||||||
|
memo=memo or existing.get("memo", ""),
|
||||||
|
operator=operator,
|
||||||
|
session_id=existing["id"],
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_review_operating_indicators(db_path: str | None = None) -> dict[str, Any]:
|
||||||
|
with db(db_path) as conn:
|
||||||
|
review_rows = conn.execute("SELECT status, COUNT(*) AS count FROM review_decisions GROUP BY status").fetchall()
|
||||||
|
pending_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status='pending'").fetchone()[0])
|
||||||
|
held_recheck_count = int(conn.execute("SELECT COUNT(*) FROM review_decisions WHERE status IN ('held','needs_recheck')").fetchone()[0])
|
||||||
|
unreconciled_accounts = int(conn.execute("SELECT COUNT(DISTINCT account_id) FROM reconciliation_sessions WHERE status<>'closed'").fetchone()[0])
|
||||||
|
last_closed = conn.execute("SELECT MAX(closed_at) AS closed_at FROM reconciliation_sessions WHERE status='closed' AND closed_at<>''").fetchone()["closed_at"]
|
||||||
|
uncategorized_pending = int(conn.execute("SELECT COUNT(*) FROM transactions WHERE status='active' AND COALESCE(category,'')='' ").fetchone()[0])
|
||||||
|
by_status = {row["status"]: int(row["count"] or 0) for row in review_rows}
|
||||||
|
return ok(
|
||||||
|
indicators={
|
||||||
|
"pending_review_count": pending_count,
|
||||||
|
"held_recheck_count": held_recheck_count,
|
||||||
|
"unreconciled_account_count": unreconciled_accounts,
|
||||||
|
"last_reconciliation_closed_at": last_closed or "",
|
||||||
|
"uncategorized_recommendation_backlog": uncategorized_pending,
|
||||||
|
"review_decisions_by_status": by_status,
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -57,7 +57,7 @@ def _readonly_sqlite_connection(path: Path) -> sqlite3.Connection:
|
|||||||
|
|
||||||
def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
def _safe_table_counts(conn: sqlite3.Connection) -> dict[str, int]:
|
||||||
counts: dict[str, int] = {}
|
counts: dict[str, int] = {}
|
||||||
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots"):
|
for table in ("accounts", "transactions", "budgets", "import_batches", "category_rules", "ledger_categories", "manual_assets", "asset_snapshots", "review_decisions", "reconciliation_sessions"):
|
||||||
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
|
exists = conn.execute("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
|
||||||
if exists:
|
if exists:
|
||||||
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
counts[table] = int(conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0])
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from .shared import (
|
|||||||
re,
|
re,
|
||||||
row_to_dict,
|
row_to_dict,
|
||||||
)
|
)
|
||||||
|
from .review_decision_service import attach_review_decisions
|
||||||
|
|
||||||
def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
|
def _rule_matches(rule: dict[str, Any], tx: dict[str, Any]) -> bool:
|
||||||
if rule.get("transaction_type") and rule.get("transaction_type") != tx.get("type", ""):
|
if rule.get("transaction_type") and rule.get("transaction_type") != tx.get("type", ""):
|
||||||
@@ -171,6 +172,8 @@ def get_uncategorized_workbench(
|
|||||||
group["recommendation"] = rec
|
group["recommendation"] = rec
|
||||||
|
|
||||||
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
|
group_items = sorted(groups.values(), key=lambda g: (int(g["count"]), int(g["total_amount"])), reverse=True)
|
||||||
|
with db(db_path) as conn:
|
||||||
|
txs = attach_review_decisions(conn, txs, "uncategorized_transaction", "id")
|
||||||
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
|
recommendable_count = sum(1 for tx in txs if tx.get("recommended_category"))
|
||||||
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
|
repeated_group_count = sum(1 for g in group_items if int(g.get("count") or 0) >= 2)
|
||||||
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
|
total = row_to_dict(total_row) if total_row else {"count": 0, "total_amount": 0, "merchant_group_count": 0}
|
||||||
|
|||||||
@@ -102,6 +102,40 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
|||||||
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_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_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("CREATE INDEX IF NOT EXISTS idx_asset_snapshots_run ON asset_snapshots(basis_date, snapshot_run_id, created_at)")
|
||||||
|
conn.execute("""CREATE TABLE IF NOT EXISTS review_decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
decision_type TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
reason_code TEXT NOT NULL DEFAULT '',
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
operator TEXT NOT NULL DEFAULT 'operator',
|
||||||
|
decided_at TEXT NOT NULL DEFAULT '',
|
||||||
|
review_after TEXT NOT NULL DEFAULT '',
|
||||||
|
target_payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)""")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at)")
|
||||||
|
conn.execute("""CREATE TABLE IF NOT EXISTS reconciliation_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
basis_date TEXT NOT NULL,
|
||||||
|
expected_balance INTEGER,
|
||||||
|
actual_balance INTEGER,
|
||||||
|
difference INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
closed_at TEXT NOT NULL DEFAULT '',
|
||||||
|
source_note TEXT NOT NULL DEFAULT '',
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
operator TEXT NOT NULL DEFAULT 'operator',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)""")
|
||||||
|
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date)")
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at)")
|
||||||
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
|
conn.execute("INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', ?)", (SCHEMA_VERSION,))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
SCHEMA_VERSION = "10"
|
SCHEMA_VERSION = "11"
|
||||||
|
|
||||||
SCHEMA_SQL = """
|
SCHEMA_SQL = """
|
||||||
PRAGMA journal_mode=WAL;
|
PRAGMA journal_mode=WAL;
|
||||||
@@ -168,7 +168,43 @@ CREATE TABLE IF NOT EXISTS asset_snapshots (
|
|||||||
CREATE INDEX IF NOT EXISTS idx_asset_snapshots_basis_date ON asset_snapshots(basis_date);
|
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);
|
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', '10');
|
CREATE TABLE IF NOT EXISTS review_decisions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_type TEXT NOT NULL,
|
||||||
|
target_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
decision_type TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
reason_code TEXT NOT NULL DEFAULT '',
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
operator TEXT NOT NULL DEFAULT 'operator',
|
||||||
|
decided_at TEXT NOT NULL DEFAULT '',
|
||||||
|
review_after TEXT NOT NULL DEFAULT '',
|
||||||
|
target_payload TEXT NOT NULL DEFAULT '{}',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_review_decisions_target ON review_decisions(target_type, target_id, updated_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_review_decisions_status ON review_decisions(status, review_after, updated_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reconciliation_sessions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
account_id TEXT NOT NULL,
|
||||||
|
basis_date TEXT NOT NULL,
|
||||||
|
expected_balance INTEGER,
|
||||||
|
actual_balance INTEGER,
|
||||||
|
difference INTEGER,
|
||||||
|
status TEXT NOT NULL DEFAULT 'open',
|
||||||
|
closed_at TEXT NOT NULL DEFAULT '',
|
||||||
|
source_note TEXT NOT NULL DEFAULT '',
|
||||||
|
memo TEXT NOT NULL DEFAULT '',
|
||||||
|
operator TEXT NOT NULL DEFAULT 'operator',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_reconciliation_sessions_account_basis ON reconciliation_sessions(account_id, basis_date);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reconciliation_sessions_status ON reconciliation_sessions(status, closed_at);
|
||||||
|
|
||||||
|
INSERT OR REPLACE INTO schema_meta(key, value) VALUES ('schema_version', '11');
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
|
ACCOUNT_ADDITIVE_COLUMNS: dict[str, str] = {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from .application import cross_transfer_service as _cross_transfer_service
|
|||||||
from .application import imports_service as _imports_service
|
from .application import imports_service as _imports_service
|
||||||
from .application import import_transfer_services as _import_transfer_services
|
from .application import import_transfer_services as _import_transfer_services
|
||||||
from .application import reconciliation_service as _reconciliation_service
|
from .application import reconciliation_service as _reconciliation_service
|
||||||
|
from .application import review_decision_service as _review_decision_service
|
||||||
from .application import shared as _shared
|
from .application import shared as _shared
|
||||||
from .application import system_services as _system_services
|
from .application import system_services as _system_services
|
||||||
from .application import transactions_write_services as _transactions_write_services
|
from .application import transactions_write_services as _transactions_write_services
|
||||||
@@ -32,6 +33,7 @@ _PROXY_MODULES = (
|
|||||||
_category_rules_write_services,
|
_category_rules_write_services,
|
||||||
_transaction_report_services,
|
_transaction_report_services,
|
||||||
_reconciliation_service,
|
_reconciliation_service,
|
||||||
|
_review_decision_service,
|
||||||
_imports_service,
|
_imports_service,
|
||||||
_import_transfer_services,
|
_import_transfer_services,
|
||||||
_cross_transfer_service,
|
_cross_transfer_service,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
|
|||||||
def test_ledger_category_create_list_delete_reactivate(tmp_path):
|
def test_ledger_category_create_list_delete_reactivate(tmp_path):
|
||||||
db_path = str(tmp_path / "ledger.sqlite3")
|
db_path = str(tmp_path / "ledger.sqlite3")
|
||||||
health = service.health_check(db_path)
|
health = service.health_check(db_path)
|
||||||
assert health["schema_version"] == "10"
|
assert health["schema_version"] == "11"
|
||||||
|
|
||||||
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
|
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
|
||||||
assert created["success"] is True
|
assert created["success"] is True
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from jarca_ledger_mcp import service
|
||||||
|
from jarca_ledger_mcp.db import init_db
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_path: Path) -> None:
|
||||||
|
db_path = str(tmp_path / "validation.sqlite3")
|
||||||
|
init_db(db_path)
|
||||||
|
init_db(db_path)
|
||||||
|
|
||||||
|
status = service.get_ledger_db_status(db_path, include_hash=False, include_integrity=True)
|
||||||
|
assert status["success"] is True
|
||||||
|
assert status["integrity_check"] == "ok"
|
||||||
|
assert status["table_counts"]["review_decisions"] == 0
|
||||||
|
assert status["table_counts"]["reconciliation_sessions"] == 0
|
||||||
|
assert service.health_check(db_path)["schema_version"] == "11"
|
||||||
|
|
||||||
|
|
||||||
|
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
|
||||||
|
db_path = str(tmp_path / "validation.sqlite3")
|
||||||
|
decision = service.upsert_review_decision(
|
||||||
|
"cross_transfer",
|
||||||
|
"tx_synthetic_1",
|
||||||
|
status="decided",
|
||||||
|
decision_type="account_transfer",
|
||||||
|
reason_code="matched_pair",
|
||||||
|
memo="synthetic memo",
|
||||||
|
operator="qc",
|
||||||
|
review_after="2026-07-31",
|
||||||
|
target_payload={"amount": 10000, "raw_payload": "SHOULD_NOT_PERSIST", "file_path": "/secret.pdf"},
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
assert decision["success"] is True
|
||||||
|
saved = decision["review_decision"]
|
||||||
|
assert saved["status"] == "decided"
|
||||||
|
assert saved["decision_type"] == "account_transfer"
|
||||||
|
assert saved["target_payload"] == {"amount": 10000}
|
||||||
|
|
||||||
|
held = service.hold_review_decision("cross_transfer", "tx_synthetic_1", memo="hold", db_path=db_path)
|
||||||
|
assert held["success"] is True
|
||||||
|
assert held["review_decision"]["status"] == "held"
|
||||||
|
|
||||||
|
released = service.release_review_decision("cross_transfer", "tx_synthetic_1", memo="release", db_path=db_path)
|
||||||
|
assert released["success"] is True
|
||||||
|
assert released["review_decision"]["status"] == "pending"
|
||||||
|
|
||||||
|
listed = service.list_review_decisions("cross_transfer", "tx_synthetic_1", db_path=db_path)
|
||||||
|
assert listed["success"] is True
|
||||||
|
assert listed["count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reconciliation_session_round_trip_and_report_integration(tmp_path: Path) -> None:
|
||||||
|
db_path = str(tmp_path / "validation.sqlite3")
|
||||||
|
account = service.create_ledger_account(
|
||||||
|
"Synthetic Manual Account",
|
||||||
|
type="bank",
|
||||||
|
statement_status="manual_only",
|
||||||
|
last_balance=1_000_000,
|
||||||
|
last_synced_at="2026-06-30T23:59:00+09:00",
|
||||||
|
db_path=db_path,
|
||||||
|
)["account"]
|
||||||
|
service.create_ledger_transaction("2026-06-01", "income", 700_000, account_id=account["id"], db_path=db_path)
|
||||||
|
|
||||||
|
session = service.upsert_reconciliation_session(
|
||||||
|
account["id"],
|
||||||
|
"2026-06-30",
|
||||||
|
expected_balance=1_000_000,
|
||||||
|
actual_balance=700_000,
|
||||||
|
status="open",
|
||||||
|
source_note="synthetic validation",
|
||||||
|
db_path=db_path,
|
||||||
|
)
|
||||||
|
assert session["success"] is True
|
||||||
|
assert session["reconciliation_session"]["difference"] == 300_000
|
||||||
|
|
||||||
|
report = service.get_balance_reconciliation_report(db_path=db_path)
|
||||||
|
assert report["success"] is True
|
||||||
|
item = report["items"][0]
|
||||||
|
assert item["reconciliation_session"]["id"] == session["session_id"]
|
||||||
|
assert item["status"] == "REVIEW_NEEDED"
|
||||||
|
|
||||||
|
closed = service.close_reconciliation_session(session_id=session["session_id"], db_path=db_path)
|
||||||
|
assert closed["success"] is True
|
||||||
|
assert closed["reconciliation_session"]["status"] == "closed"
|
||||||
|
indicators = service.get_review_operating_indicators(db_path=db_path)["indicators"]
|
||||||
|
assert indicators["last_reconciliation_closed_at"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_additive_mcp_contract_surface_exposes_safe_review_tools() -> None:
|
||||||
|
from jarca_ledger_mcp.adapters.mcp import server
|
||||||
|
|
||||||
|
tool_names = set(server.mcp._tool_manager._tools.keys())
|
||||||
|
assert "list_review_decisions" in tool_names
|
||||||
|
assert "upsert_review_decision" in tool_names
|
||||||
|
assert "hold_review_decision" in tool_names
|
||||||
|
assert "release_review_decision" in tool_names
|
||||||
|
assert "list_reconciliation_sessions" in tool_names
|
||||||
|
assert "upsert_reconciliation_session" in tool_names
|
||||||
|
assert "close_reconciliation_session" in tool_names
|
||||||
|
assert not {name for name in tool_names if "delete_review" in name or "reset_review" in name}
|
||||||
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
|
|||||||
db_path = tmp_path / "ledger.sqlite3"
|
db_path = tmp_path / "ledger.sqlite3"
|
||||||
result = service.health_check(str(db_path))
|
result = service.health_check(str(db_path))
|
||||||
assert result["success"] is True
|
assert result["success"] is True
|
||||||
assert result["schema_version"] == "10"
|
assert result["schema_version"] == "11"
|
||||||
assert db_path.exists()
|
assert db_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user