Compare commits
3
Commits
68212e773b
...
577bd0b912
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
577bd0b912 | ||
|
|
6fbe81a5d3 | ||
|
|
92f44661c3 |
@@ -8,6 +8,7 @@ import streamlit as st
|
||||
|
||||
from jarca_ledger_mcp.adapters.streamlit import assets_gateway
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
|
||||
from dashboard.page_views.settlement_drafts import render_settlement_drafts
|
||||
from dashboard.common import * # noqa: F403, F405
|
||||
|
||||
|
||||
@@ -153,3 +154,6 @@ def page_assets(db_path: str) -> None:
|
||||
st.json({k: close_res.get(k) for k in ["basis_date", "source", "is_closed_snapshot", "snapshot_run_id", "correction_of_run_id", "correction_reason", "closed_at", "net_worth"]})
|
||||
else:
|
||||
st.error(close_res.get("error", close_res))
|
||||
|
||||
st.divider()
|
||||
render_settlement_drafts(db_path=db_path)
|
||||
|
||||
@@ -9,6 +9,7 @@ import streamlit as st
|
||||
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway
|
||||
from jarca_ledger_mcp.adapters.streamlit import reconciliation_gateway
|
||||
from dashboard.page_views.settlement_drafts import render_settlement_drafts
|
||||
from dashboard.common import * # noqa: F403, F405
|
||||
|
||||
|
||||
@@ -265,5 +266,8 @@ def page_monthly_settlement(db_path: str) -> None:
|
||||
for action in action_candidates:
|
||||
st.markdown(f"- {action}")
|
||||
|
||||
st.divider()
|
||||
render_settlement_drafts(db_path=db_path, month=selected_month)
|
||||
|
||||
st.divider()
|
||||
st.markdown("- [요약 페이지로 이동](?page=요약)")
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import streamlit as st
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway as gateway
|
||||
|
||||
|
||||
def _safe_result(result: dict) -> None:
|
||||
if result.get("success"):
|
||||
st.success("요청을 처리했습니다.")
|
||||
else:
|
||||
# Never expose exception details, arbitrary evidence, or backend secrets.
|
||||
st.error(f"요청을 처리하지 못했습니다 ({result.get('error_code', 'operation_failed')}).")
|
||||
|
||||
|
||||
def _invalidate_and_rerun(draft_id: str, result: dict) -> None:
|
||||
st.session_state[f"settlement_mutation_feedback_{draft_id}"] = {
|
||||
"success": bool(result.get("success")),
|
||||
"error_code": result.get("error_code", "operation_failed"),
|
||||
"memo_saved": bool(result.get("memo_saved")),
|
||||
}
|
||||
st.session_state.pop(f"review_result_{draft_id}", None)
|
||||
st.session_state.pop(f"approved_{draft_id}", None)
|
||||
st.session_state.pop("settlement_bound_state", None)
|
||||
st.rerun()
|
||||
|
||||
|
||||
def render_settlement_drafts(db_path: str | None = None, month: str = "") -> None:
|
||||
st.header("월정산 초안 lifecycle")
|
||||
st.caption("자산 마감과 거래 정산 상태는 별도입니다. 조회/렌더링은 쓰기를 수행하지 않습니다.")
|
||||
month_value = month or st.text_input("초안 월", value="2026-09", key="settlement_month")
|
||||
actor = st.text_input("작업자", value="operator", key="settlement_actor")
|
||||
with st.form("settlement_create"):
|
||||
memo = st.text_input("메모")
|
||||
idem = st.text_input("생성 키", value="dashboard-create")
|
||||
correction_run = st.text_input("정정 대상 run ID (선택)")
|
||||
correction_reason = st.text_input("정정 사유")
|
||||
create = st.form_submit_button("초안 생성 / 정정 초안")
|
||||
if create:
|
||||
_safe_result(gateway.create_settlement_draft(month_value, actor, idem, memo=memo,
|
||||
correction_of_run_id=correction_run, correction_reason=correction_reason, db_path=db_path))
|
||||
|
||||
listed = gateway.list_settlement_drafts(month_value, db_path=db_path)
|
||||
drafts = listed.get("drafts", []) if listed.get("success") else []
|
||||
if not listed.get("success"):
|
||||
st.error(f"초안 목록을 불러오지 못했습니다 ({listed.get('error_code', 'operation_failed')}).")
|
||||
if not isinstance(drafts, list): drafts = []
|
||||
ids = [str(d.get("id", "")) for d in drafts if isinstance(d, dict) and d.get("id")]
|
||||
selected_id = st.selectbox("초안 선택", ids, key="settlement_draft_id") if ids else ""
|
||||
if not selected_id:
|
||||
if listed.get("success"):
|
||||
st.info("초안이 없습니다. 생성은 명시적 버튼으로 수행됩니다.")
|
||||
st.caption("완료 초안은 이력에서 불변으로 조회하고, 정정은 이전 마감 ID와 사유를 지정해 새 초안으로 만듭니다.")
|
||||
st.caption("목록은 활성·완료·취소 이력을 포함합니다. 평가자료 조회·등록·정정은 초안 변경과 별도 작업입니다.")
|
||||
st.button("검토", disabled=True, key="settlement_review_disabled")
|
||||
st.button("승인", disabled=True, key="settlement_approve_disabled")
|
||||
st.button("최종 마감", disabled=True, key="settlement_finalize_disabled")
|
||||
st.button("초안 취소", disabled=True, key="settlement_cancel_disabled")
|
||||
st.button("평가자료 조회 / 등록 / 정정", disabled=True, key="settlement_valuation_disabled")
|
||||
st.caption("제외 사유는 항목별로 명시 입력합니다.")
|
||||
st.caption("거래 정산 상태(transaction readiness)는 자산 마감 준비와 별도입니다.")
|
||||
return
|
||||
detail = gateway.get_settlement_draft(selected_id, db_path=db_path)
|
||||
feedback = st.session_state.pop(f"settlement_mutation_feedback_{selected_id}", None)
|
||||
if isinstance(feedback, dict):
|
||||
if feedback.get("memo_saved"):
|
||||
st.warning("메모는 저장되었지만 항목 변경은 실패했습니다")
|
||||
_safe_result(feedback)
|
||||
draft = detail.get("draft", {}) if detail.get("success") else {}
|
||||
if not isinstance(draft, dict): draft = {}
|
||||
version = draft.get("version")
|
||||
draft_version = version
|
||||
fingerprint = detail.get("review", {}).get("review_fingerprint", "") if isinstance(detail.get("review"), dict) else ""
|
||||
state_key = "settlement_bound_state"
|
||||
bound = (selected_id, version, fingerprint)
|
||||
previous = st.session_state.get(state_key)
|
||||
if previous != bound:
|
||||
st.session_state[state_key] = bound
|
||||
st.session_state.pop(f"review_result_{selected_id}", None)
|
||||
st.session_state.pop(f"approved_{selected_id}", None)
|
||||
st.caption(f"ID {draft.get('id', selected_id)} · 월 {draft.get('month', month_value)} · 기준일 {draft.get('basis_date', '-')} · 상태 {draft.get('status', '-')} · 버전 {version} · draft_version={draft_version}")
|
||||
st.caption(f"review_fingerprint={fingerprint or '미검토'}")
|
||||
st.caption(f"생성자 {draft.get('created_by', '-')} · 생성 {draft.get('created_at', '-')} · 변경 {draft.get('updated_at', '-')} · 메모 {draft.get('memo', '')}")
|
||||
if draft.get("status") == "completed":
|
||||
st.success(f"완료 — run {draft.get('final_run_id', '-')}; 변경 불가, 정정은 새 초안으로 생성합니다.")
|
||||
if draft.get("status") in {"completed", "cancelled"}:
|
||||
st.markdown("### 불변 이력")
|
||||
st.write("final_run_id", draft.get("final_run_id"))
|
||||
st.write("correction_of_run_id", draft.get("correction_of_run_id"))
|
||||
st.json(detail.get("items", []))
|
||||
st.caption("종료된 초안의 증거는 수정하지 않습니다. 정정이 필요하면 이전 마감과 사유를 연결해 새 초안을 생성하세요.")
|
||||
return
|
||||
items = detail.get("items", []) if isinstance(detail.get("items"), list) else []
|
||||
for item in items:
|
||||
if not isinstance(item, dict): continue
|
||||
st.write(f"{item.get('source_type', '')} {item.get('source_id', '')} · {item.get('status', '')} · 평가 {item.get('valuation_date', '-')}")
|
||||
basis, actual = item.get("basis_date", draft.get("basis_date", "")), item.get("valuation_date", "")
|
||||
if basis and actual:
|
||||
try:
|
||||
from datetime import date
|
||||
difference = (date.fromisoformat(actual) - date.fromisoformat(basis)).days
|
||||
st.caption(f"기준일 대비 날짜 차이: {difference:+}일 ({'이후 대체' if difference > 0 else '이전' if difference < 0 else '동일'})")
|
||||
except ValueError:
|
||||
st.caption("날짜 차이를 계산할 수 없습니다.")
|
||||
if item.get("status") == "missing": st.warning("누락 — 값은 0원이 아닙니다.")
|
||||
with st.form(f"settlement_edit_{selected_id}"):
|
||||
memo_edit = st.text_input("초안 메모 저장", value=draft.get("memo", ""))
|
||||
source_type = st.selectbox("대상 유형", ["account", "manual_asset"])
|
||||
source_id = st.text_input("대상 ID")
|
||||
valuation_id = st.text_input("평가자료 ID (비우면 해제)")
|
||||
mode = st.selectbox("선택 방식", ["exact", "carried_before", "manual_substitute"])
|
||||
selection_reason = st.text_input("선택 사유")
|
||||
included = st.checkbox("포함", value=True)
|
||||
exclusion_reason = st.text_input("제외 사유")
|
||||
save = st.form_submit_button("저장 / 지정 / 포함·제외")
|
||||
if save:
|
||||
result = gateway.update_settlement_draft(selected_id, version, actor, memo_edit, db_path=db_path)
|
||||
current_version = (result.get("draft") or {}).get("version", version) if result.get("success") else version
|
||||
memo_updated = bool(result.get("success"))
|
||||
if result.get("success") and source_id:
|
||||
result = gateway.set_settlement_draft_item(selected_id, source_type, source_id, current_version, actor,
|
||||
valuation_id=valuation_id, selection_mode=mode, selection_reason=selection_reason,
|
||||
included=included, exclusion_reason=exclusion_reason, db_path=db_path)
|
||||
if not result.get("success"):
|
||||
result = {**result, "memo_saved": True}
|
||||
if memo_updated or result.get("success"):
|
||||
_invalidate_and_rerun(selected_id, result)
|
||||
_safe_result(result)
|
||||
|
||||
with st.form(f"valuation_{selected_id}"):
|
||||
account_id = st.text_input("평가 등록 계좌 ID")
|
||||
valuation_date = st.text_input("평가일 YYYY-MM-DD")
|
||||
amount = st.number_input("금액", value=0, step=1)
|
||||
currency = st.text_input("통화", value="KRW")
|
||||
valuation_kind = st.selectbox("평가 종류", ["balance", "total_value", "cash_balance", "loan", "loan_balance"])
|
||||
liability_definition = st.selectbox("대표 부채 정의", ["", "principal_only", "principal_interest_fees"])
|
||||
source_ref = st.text_input("증빙 참조 ID")
|
||||
verification = st.selectbox("확인 상태", ["unverified", "verified", "rejected"])
|
||||
valuation_memo = st.text_input("평가 메모")
|
||||
registration_key = st.text_input("등록 idempotency key")
|
||||
register = st.form_submit_button("평가자료 등록")
|
||||
if register:
|
||||
result = gateway.register_account_valuation(account_id, valuation_date, int(amount), currency=currency,
|
||||
valuation_kind=valuation_kind, source_ref=source_ref, verification_status=verification,
|
||||
memo=valuation_memo, idempotency_key=registration_key,
|
||||
representative_liability_definition=liability_definition or None, db_path=db_path)
|
||||
if result.get("success") and any(item.get("source_id") == account_id for item in items if isinstance(item, dict)):
|
||||
_invalidate_and_rerun(selected_id, result)
|
||||
_safe_result(result)
|
||||
if st.button("평가자료 조회", key=f"valuation_list_{selected_id}") and source_id:
|
||||
vals = gateway.list_account_valuations(source_id, db_path=db_path)
|
||||
if vals.get("success"):
|
||||
st.json([{k: value.get(k) for k in ("id", "account_id", "basis_date", "recorded_at", "amount", "currency", "valuation_kind", "verification_status", "source_ref", "supersedes_id", "representative_liability_definition")} for value in vals.get("valuations", [])])
|
||||
else: _safe_result(vals)
|
||||
with st.form(f"valuation_correct_{selected_id}"):
|
||||
valuation_id_correct = st.text_input("정정할 valuation ID")
|
||||
corrected_amount = st.number_input("정정 금액", value=0, step=1)
|
||||
correction_reason = st.text_input("평가 정정 사유")
|
||||
correction_key = st.text_input("평가 정정 키")
|
||||
correction_verification = st.selectbox("정정 확인 상태", ["unverified", "verified", "rejected"])
|
||||
correction_source_ref = st.text_input("정정 증빙 참조 ID")
|
||||
correct = st.form_submit_button("평가자료 정정")
|
||||
if correct:
|
||||
result = gateway.correct_account_valuation(valuation_id_correct, int(corrected_amount), correction_reason,
|
||||
idempotency_key=correction_key, verification_status=correction_verification,
|
||||
source_ref=correction_source_ref, db_path=db_path)
|
||||
corrected_account_id = (result.get("valuation") or {}).get("account_id", "")
|
||||
if result.get("success") and any(item.get("source_id") == corrected_account_id for item in items if isinstance(item, dict)):
|
||||
_invalidate_and_rerun(selected_id, result)
|
||||
_safe_result(result)
|
||||
review_result = None
|
||||
if st.button("검토 / 새로고침 대상", key=f"review_{selected_id}"):
|
||||
review_result = gateway.review_settlement_draft(selected_id, db_path=db_path)
|
||||
st.session_state[f"review_result_{selected_id}"] = review_result
|
||||
if st.button("대상 명시적 갱신", key=f"refresh_{selected_id}"):
|
||||
result = gateway.refresh_settlement_draft_targets(selected_id, version, actor, db_path=db_path)
|
||||
if result.get("success"):
|
||||
_invalidate_and_rerun(selected_id, result)
|
||||
_safe_result(result)
|
||||
review_result = review_result or st.session_state.get(f"review_result_{selected_id}")
|
||||
if isinstance(review_result, dict) and review_result.get("success"):
|
||||
review = review_result.get("review", review_result)
|
||||
st.write("검토 합계 (잠정)", review.get("total_assets"), review.get("total_liabilities"), review.get("net_worth"))
|
||||
st.write("누락", review.get("missing_items", []))
|
||||
if review.get("contains_substitutions"):
|
||||
st.warning("대체 평가자료를 포함한 월정산")
|
||||
fingerprint = review.get("review_fingerprint", "")
|
||||
st.caption(f"현재 승인 대상 fingerprint: {fingerprint}")
|
||||
st.session_state[state_key] = (selected_id, review.get("version", version), fingerprint)
|
||||
with st.form(f"approve_{selected_id}"):
|
||||
approval_reason = st.text_input("승인 사유")
|
||||
approval_reference = st.text_input("승인 근거")
|
||||
allow_missing = st.checkbox("누락 승인", value=False)
|
||||
allow_carried = st.checkbox("이월 승인", value=False)
|
||||
allow_substitute = st.checkbox("대체자료 승인", value=False)
|
||||
approve = st.form_submit_button("검토 결과 승인")
|
||||
if approve:
|
||||
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
|
||||
st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.")
|
||||
return
|
||||
_safe_result(gateway.approve_settlement_draft(selected_id, version, fingerprint, actor,
|
||||
approval_reason, approval_reference, allow_missing, allow_carried, allow_substitute, db_path=db_path))
|
||||
if st.button("최종 마감", key=f"finalize_{selected_id}"):
|
||||
if st.session_state.get(state_key) != (selected_id, review.get("version", version), fingerprint):
|
||||
st.error("선택한 초안/버전/검토 fingerprint가 변경되었습니다. 다시 검토하세요.")
|
||||
return
|
||||
result = gateway.finalize_settlement_draft(selected_id, version, fingerprint,
|
||||
f"dashboard-finalize-{selected_id}-{version}", db_path=db_path)
|
||||
if result.get("success"):
|
||||
_invalidate_and_rerun(selected_id, result)
|
||||
_safe_result(result)
|
||||
with st.form(f"cancel_{selected_id}"):
|
||||
cancel_reason = st.text_input("취소 사유")
|
||||
cancel = st.form_submit_button("초안 취소")
|
||||
if cancel:
|
||||
result = gateway.cancel_settlement_draft(selected_id, version, actor, cancel_reason, db_path=db_path)
|
||||
if result.get("success"):
|
||||
_invalidate_and_rerun(selected_id, result)
|
||||
_safe_result(result)
|
||||
@@ -174,3 +174,22 @@
|
||||
- 오류 데이터: invalid currency/kind, missing loan definition, idempotency/correction conflict
|
||||
- 저장 후 재조회: service list/select/report and contract regression tests
|
||||
- 마이그레이션: `test_*schema*` tests plus `python -m pytest tests/test_account_valuations.py tests/test_asset_snapshots.py`
|
||||
|
||||
## settlement-drafts — 월정산 초안
|
||||
|
||||
Schema 13 adds two additive tables; `init_db()`/`migrate_db()` remain the only schema writers and schema 11→12 history is unchanged. No draft backfill is inferred. Exact migrated columns (from `infrastructure/sqlite/migrations.py`) are listed below; `SCHEMA_SQL` and migration DDL are the schema authority.
|
||||
|
||||
| Table | Columns (SQLite types; `?` means nullable) | Indexes / constraints |
|
||||
|---|---|---|
|
||||
| `settlement_drafts` | `id TEXT PK`, `month TEXT NOT NULL`, `basis_date TEXT NOT NULL`, `status TEXT NOT NULL`, `version INTEGER NOT NULL`, `created_by TEXT NOT NULL`, `created_at TEXT NOT NULL`, `updated_at TEXT NOT NULL`, `memo TEXT DEFAULT ''`, `create_key TEXT UNIQUE NOT NULL`, `correction_of_run_id TEXT DEFAULT ''`, `correction_reason TEXT DEFAULT ''`, `final_run_id TEXT DEFAULT ''`, `final_response TEXT DEFAULT ''`, `cancelled_by TEXT DEFAULT ''`, `cancellation_reason TEXT DEFAULT ''`, `cancelled_at TEXT DEFAULT ''`, `approval_fingerprint TEXT DEFAULT ''`, `approval_version INTEGER DEFAULT 0`, `approved_by TEXT DEFAULT ''`, `approved_at TEXT DEFAULT ''`, `approval_reason TEXT DEFAULT ''`, `approval_reference TEXT DEFAULT ''`, `approval_flags TEXT DEFAULT '{}'`, additive `original_request_hash TEXT DEFAULT ''` | `idx_settlement_drafts_active_month(month) WHERE status='active'` unique; `idx_settlement_drafts_month(month,created_at)` |
|
||||
| `settlement_draft_items` | `id TEXT PK`, `draft_id TEXT NOT NULL FK`, `source_type TEXT NOT NULL`, `source_id TEXT NOT NULL`, `valuation_id TEXT DEFAULT ''`, `selection_mode TEXT DEFAULT 'exact'`, `selection_reason TEXT DEFAULT ''`, `included INTEGER DEFAULT 1`, `exclusion_reason TEXT DEFAULT ''`, `version INTEGER DEFAULT 1`, `selected_by TEXT DEFAULT ''`, `selected_at TEXT DEFAULT ''`, `evidence_json TEXT DEFAULT '{}'` | `UNIQUE(draft_id,source_type,source_id)` |
|
||||
|
||||
`settlement_drafts` therefore owns generated draft ID, YYYY-MM month, fixed month-end `basis_date`, active/completed/cancelled status, positive version, creator/timestamps/memo, create idempotency key+request fingerprint, correction parent/reason, approval actor/time/reason/reference/flags/fingerprint/version, final run ID and persisted response. The partial unique active-month constraint is shared by normal/correction drafts. `settlement_draft_items` owns stable target type/ID, inclusion/reason, selected valuation and selection mode/reason, item version/audit, frozen source evidence/hash and correction-child identity. Monetary authority is derived from eligible frozen evidence, not editable amount columns.
|
||||
|
||||
Modes are `exact`, `carried_before`, and `manual_substitute`. `exact` requires confirmed same-day evidence; `carried_before` requires a verified prior valuation and reason; `manual_substitute` requires explicit valuation ID, reason and review/approval even when valuation date is future. Preserve `date_difference_days = valuation_date - basis_date`; `age_days` retains its historical basis-minus-valuation meaning. Unverified candidates remain visible with `candidate_amount` but authority amount NULL. Missing is distinct from substitution/exclusion. Manual evidence freezes semantic fields and hash; only explicit refresh/reselection changes it.
|
||||
|
||||
Frozen/public evidence retains `valuation_date`, original `recorded_at`, derived `date_difference_direction` (`after`/`before`/`same`/`unknown`) and `age_days`; loan evidence retains `representative_liability_definition`. `recorded_at` may be later than month end while `valuation_date` is exactly month end. These names are preserved through finalized response, reports, timeseries and monthly coverage; date differences and semantic hashes are derived from frozen JSON rather than separate draft-item columns. Actor/version/timestamps of approval are cleared together on draft mutation.
|
||||
|
||||
Review fingerprint binds draft version, target/inclusion state, full valuation/manual semantic evidence, correction child, warnings, amounts/classification/date and prior run. Approved fingerprint/version become invalid on mutation or external drift. Finalize checks idempotency replay before active status, then validates version, approval, fingerprint, current evidence and correction parent, and inserts run+rows+draft completion/link in one `BEGIN IMMEDIATE`. Rollback on any SQLite failure; replay returns persisted response.
|
||||
|
||||
KRW only aggregation; non-KRW is warned/excluded, never converted. NULL is not zero; no targets yields provisional zero plus completeness and unregistered-liability warning, not a complete close. Existing snapshot rows preserve full frozen evidence, approval, date/mode/difference and actors; substitution, carried and approved-missing flags are orthogonal. `monthly_source_snapshot_id` remains a read-model alias, never a persistence field.
|
||||
|
||||
@@ -424,12 +424,43 @@ Writer/bootstrap explicitly calls `init_db()`. `read_db()` uses SQLite `mode=ro`
|
||||
|
||||
## D04 snapshot close output contract
|
||||
|
||||
Every successful snapshot close output has these exact top-level fields (the transport envelope may additionally include `success`): `snapshot_run_id`, `close_status`, `approval`, `correction`, `warnings`, `excluded_items`, `carried_items`, `provenance`, `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id`. `monthly_source_snapshot_id` is an alias of `snapshot_run_id`, not a persistence field.
|
||||
Every successful **legacy direct close** output retains these required top-level fields as required top-level metadata fields (the transport envelope may additionally include `success`): `snapshot_run_id`, `close_status`, `approval`, `correction`, `warnings`, `excluded_items`, `carried_items`, `provenance`, `valuation_date`, `age_days`, `verification_status`, `evidence_kind`, and `monthly_source_snapshot_id`. This is a required retained metadata-field list, not an exhaustive inventory of all keys in a successful legacy response; additional legacy response keys remain part of the legacy output. The list does not define the additive settlement-draft finalize response. `monthly_source_snapshot_id` is an alias of `snapshot_run_id`, not a persistence field.
|
||||
|
||||
The top-level `provenance` object has these exact nested `provenance` fields: `valuation_date`, `age_days`, `carried_forward`, `verification_status`, `evidence_kind`, `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`. `approval` and `correction` are objects or explicit empty values when not applicable; warnings and item collections are arrays. `valuation_date` is the actual selected evidence date, `age_days` is computed from the requested basis date, and verification/evidence status describe selected evidence. Live, closed, timeseries, and monthly read models preserve these fields and alias semantics.
|
||||
The top-level `provenance` object retains these required nested `provenance` fields (metadata, not an exhaustive inventory of all nested response keys): `valuation_date`, `age_days`, `carried_forward`, `verification_status`, `evidence_kind`, `approval`, `correction`, `warnings`, `excluded_items`, and `carried_items`. `approval` and `correction` are objects or explicit empty values when not applicable; warnings and item collections are arrays. `valuation_date` is the actual selected evidence date, `age_days` is computed from the requested basis date, and verification/evidence status describe selected evidence. Live, closed, timeseries, and monthly read models preserve these fields and alias semantics.
|
||||
|
||||
The preview input includes basis_date, selection_policy, included/excluded evidence state hashes, warnings, and candidate_state_hashes. preview output includes preview_fingerprint. Close accepts the exact `preview_fingerprint: str = ""` parameter shown in the signature block and recomputes the same authority fingerprint before opening the write boundary. A mismatch returns `authority_drift` and no partial write.
|
||||
|
||||
Replay uses replay payload equality: an existing idempotency key replays only when the persisted request fingerprint is equal; a changed payload returns `idempotency_conflict`. The replay response is reconstructed from the persisted run and its evidence, with no replay-only reused fields.
|
||||
|
||||
`close_metadata` is a recursive close metadata allowlist: only run, approval, correction, warnings, exclusions, carried evidence, and allowlisted evidence fields (`source_type`, `source_id`, `authority_source`, `valuation_id`, dates/statuses and `state_hash`) cross the boundary. `applied_evidence`, `excluded_evidence`, and `carried_evidence` are redacted recursively; unknown nested keys are dropped, and credentials, tokens, cookies, raw account data, and arbitrary source-note payloads are never returned. This is the recursive close_metadata allowlist and redaction contract.
|
||||
|
||||
## settlement-draft-service — 월정산 초안 API (Issue #15)
|
||||
|
||||
Common application owner: `jarca_ledger_mcp/application/settlement_draft_service.py`; re-export through `jarca_ledger_mcp.service`; service and gateway signatures below include internal `db_path`, which is omitted from MCP tool inputSchema. All other MCP-exposed parameters retain their documented names, defaults, and requiredness. MCP exposes the same operation names, not the same signatures; Dashboard uses existing gateway layering. Draft finalize adds draft-specific response fields while preserving common aliases; it is not a claim that its response has the legacy direct-close shape. Each returns the existing `success`/`error_code`/`error` envelope. Dashboard auth gate precedes DB access; this adds no MCP authentication claim.
|
||||
|
||||
```python
|
||||
create_settlement_draft(month: str, created_by: str, idempotency_key: str, basis_date: str = "", memo: str = "", correction_of_run_id: str = "", correction_reason: str = "", db_path: str | None = None)
|
||||
list_settlement_drafts(month: str = "", db_path: str | None = None)
|
||||
get_settlement_draft(draft_id: str, db_path: str | None = None)
|
||||
update_settlement_draft(draft_id: str, expected_version: int, updated_by: str, memo: str, db_path: str | None = None)
|
||||
refresh_settlement_draft_targets(draft_id: str, expected_version: int, updated_by: str, db_path: str | None = None)
|
||||
set_settlement_draft_item(draft_id: str, source_type: str, source_id: str, expected_version: int, selected_by: str, valuation_id: str = "", selection_mode: str = "exact", selection_reason: str = "", included: bool = True, exclusion_reason: str = "", db_path: str | None = None)
|
||||
review_settlement_draft(draft_id: str, db_path: str | None = None)
|
||||
approve_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, approved_by: str, approval_reason: str, approval_reference: str, allow_missing_exclusions: bool = False, allow_carried_before: bool = False, allow_manual_substitute: bool = False, db_path: str | None = None)
|
||||
finalize_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, idempotency_key: str, db_path: str | None = None)
|
||||
cancel_settlement_draft(draft_id: str, expected_version: int, cancelled_by: str, reason: str, db_path: str | None = None)
|
||||
```
|
||||
|
||||
Create validates strict YYYY-MM and month-end date, nonempty actor/key and payload replay equality; same key+payload returns same draft, changed payload or active duplicate conflicts. Versions are positive strict integers (bool/fraction/None rejected); inclusion flags actual bool. `review` is read-only and returns draft/version/fingerprint, items and included/excluded/missing/warnings, approval_required, provisional totals, completeness, substitution and unregistered-liability flags. `candidate_amount` may be shown for unverified evidence but authority amount NULL. Approval binds fingerprint+version and explicit flags. Finalize requires approval even for exact complete data; same request replays the persisted response, conflicting key/payload errors, and failed transaction leaves no partial state.
|
||||
|
||||
`read_db()` remains SQLite `mode=ro`: missing DB returns `db_not_initialized`, no-create/no mkdir/no migrate/no commit; initialized read failures return `db_error`. Existing auth cookie/logout, MCP transport/scope, menu, query, shell and browser behavior are unchanged. Existing D04 close aliases and recursive metadata allowlist/redaction remain authoritative.
|
||||
|
||||
### Draft evidence public fields
|
||||
|
||||
`unregistered_liabilities` is true when the frozen target set contains no registered liability source. It does not assert unknown real-world debt or prove the operator is debt-free. Exclusion and missing evidence remain separate completeness states; neither changes the registration meaning of this flag.
|
||||
|
||||
`review.included_items`, finalized `items`, asset report `close_metadata.applied_evidence`, timeseries point `applied_evidence`, and monthly `source_coverage.assets.applied_evidence` preserve the same selected-evidence field names: `valuation_date` (actual valuation date), `recorded_at` (original recording timestamp, not the basis date), `date_difference_days = valuation_date - basis_date`, `date_difference_direction` (`after`, `before`, `same`, or `unknown`), and `age_days` (basis-minus-valuation days; negative for a future substitute). A loan additionally preserves `representative_liability_definition`; selection audit includes `selection_mode`, `selection_reason`, `selected_by`, `selected_at`, `item_version`, and draft/fingerprint lineage. These are derived or frozen response fields, not additional database columns.
|
||||
|
||||
Nested draft `approval` retains `allow_missing_exclusions`, `allow_carried_before`, and `allow_manual_substitute` alongside actor/time/reason/reference. The three approval flags and the three result flags (`contains_substitutions`, `contains_carried`, `has_approved_missing`) are independent. Unverified evidence can be visible as `candidate_amount` but cannot become applied monetary authority; only an explicitly approved missing/excluded row may persist with NULL amount. No lineage default keys are injected into legacy persisted evidence arrays. Recursive allowlisting excludes raw source notes, memos, and unknown nested fields.
|
||||
|
||||
Executable field-contract regression: `tests/test_settlement_draft_contract_scenarios.py::test_date_metadata_public_contract_names_survive_all_closed_consumers`. Related clause tests cover representative integer precision, transaction readiness independent of asset close, approved unresolved candidates, and the synthetic September pension/loan dates with stale, rollback, and concurrent idempotent retry.
|
||||
|
||||
@@ -210,3 +210,17 @@ The owner is the canonical decision or write boundary; consumers render or read
|
||||
The Dashboard gateway is `jarca_ledger_mcp/adapters/streamlit/overview_gateway.py` (including `get_asset_snapshot_report` and reconciliation/monthly read delegates). It calls shared application read services after `auth-before-DB`; page rendering is not an alternate policy or persistence boundary. A missing DB has stable unavailable semantics (`db_not_initialized`) and no-create behavior. The missing DB stable unavailable contract is no-create. An initialized DB read failure is `db_error`; initialized read has no write, migration, mkdir, or commit. The initialized DB read failure is db_error and initialized read has no write.
|
||||
|
||||
Reconciliation ownership remains in `reconciliation_service.py`: opening evidence and transaction coverage are required inputs. Missing opening evidence or incomplete coverage produces `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, never an inferred balance or fake transaction.
|
||||
|
||||
## Issue #15 settlement draft ownership
|
||||
|
||||
| Path | Ownership / boundary |
|
||||
|---|---|
|
||||
| `jarca_ledger_mcp/application/settlement_draft_service.py` | Shared business validation, frozen selection/review/approval/finalize; owns no UI or transport behavior. |
|
||||
| `jarca_ledger_mcp/application/monthly_settlement_service.py` | Read-only monthly aggregate; never draft mutation or transaction readiness write. |
|
||||
| `jarca_ledger_mcp/application/assets_service.py` | Existing close/report authority; draft finalization does not call live-select close as shortcut. |
|
||||
| `jarca_ledger_mcp/infrastructure/sqlite/migrations.py` | Additive schema 12→13 migration; schema initializer and migration constraints agree. |
|
||||
| `jarca_ledger_mcp/adapters/mcp/server.py` | Thin same-name/same-signature MCP tools; no duplicate business logic, no new auth claim. |
|
||||
| `jarca_ledger_mcp/adapters/streamlit/` | Existing Dashboard gateway boundary; auth-before-DB, explicit user actions only. |
|
||||
| `tests/test_monthly_settlement_drafts.py` | Synthetic DB lifecycle, failure injection, replay and no-write regressions. |
|
||||
|
||||
Never document invented `jarca_ledger_mcp.mcp` or `monthly.py` modules. `transaction readiness` is separate from asset completeness and monthly reads remain read-only.
|
||||
|
||||
@@ -3,13 +3,13 @@
|
||||
"schema": "gitea-agent.requirement/v1",
|
||||
"id": "REQ-003",
|
||||
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
|
||||
"revision": "1.0",
|
||||
"revision": "1.1",
|
||||
"status": "approved",
|
||||
"depends_on": [],
|
||||
"applies_to": ["ledger", "dashboard", "mcp"],
|
||||
"language": "ko",
|
||||
"issue": "#12",
|
||||
"implementation_pr": "#13"
|
||||
"issue": "#15",
|
||||
"implementation_pr": "미정"
|
||||
}
|
||||
---
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
## 2. 배경
|
||||
|
||||
구현은 SQLite schema 12를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다.
|
||||
구현은 SQLite schema 13를 schema 11에 additive하게 적용한다. 계좌 잔액·import 결과는 실제 증거가 없는 한 `unverified` 후보이고, 기존 거래·인증·메뉴 계약을 바꾸지 않는다.
|
||||
|
||||
## 3. 범위
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
|
||||
### REQ-003.01 — additive 이력과 legacy 보존
|
||||
|
||||
schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 12의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다.
|
||||
schema 11의 테이블·필드·거래를 삭제하거나 추측 이주하지 않고 schema 13의 `account_valuations`, `asset_snapshot_runs`를 추가하며 `asset_snapshots`에 필요한 필드를 additive하게 확장한다. migration은 idempotent다.
|
||||
|
||||
**영향 범위**
|
||||
- 인터페이스: 기존 service/MCP 응답 envelope 유지, 신규 valuation/snapshot fields additive
|
||||
@@ -172,9 +172,21 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
|
||||
- 호환성: 기존 auth/menu/query/shell/browser evidence semantic inventory 0 missing
|
||||
- 배포 전제 조건: secret redaction과 fixture DB
|
||||
|
||||
### REQ-003.11 — 월정산 초안·승인·원자적 마감 (Issue #15)
|
||||
|
||||
월말 기준일의 durable draft는 자료가 없어도 생성되며 생성 자체로 run/rows를 만들지 않는다. 월별 활성 초안은 일반·정정 공통으로 하나다. 대상 구성은 명시적 refresh만 반영하고 검토는 frozen evidence의 version/fingerprint에 묶인다. Finalize는 drift를 거절하고 `BEGIN IMMEDIATE`에서 run, rows, completion/link를 함께 기록하며 동일 요청은 persisted response로 replay한다.
|
||||
|
||||
**영향 범위**
|
||||
- 인터페이스: shared settlement draft service, MCP tools, authenticated Dashboard gateway; monthly summary read-only
|
||||
- 영속성: schema 12→13 additive `settlement_drafts`, `settlement_draft_items`; legacy 11→12 보존
|
||||
- 후속 사용 주체: Dashboard operator, MCP caller, close reports/timeseries/monthly read model
|
||||
- 마이그레이션: additive/idempotent; historical drafts backfill 없음
|
||||
- 호환성: existing valuation/auth/menu/query/shell/MCP contracts 유지
|
||||
- 배포 전제 조건: explicit writer init, SQLite transaction, synthetic tests
|
||||
|
||||
## 6. 제약과 불변조건
|
||||
|
||||
- verified leaf만 authority이며 future/unverified/rejected/unknown은 합산하지 않는다.
|
||||
- Automatic `exact`/`as_of` selection and legacy direct close exclude future, unverified, rejected, and unknown valuations from authority. The sole future-valuation exception is an explicitly selected, verified `manual_substitute` in a settlement draft, with reason, review, approval, and original valuation/basis dates preserved; a future valuation is never chosen automatically. Unverified, rejected, or unknown amounts are never authority on any path.
|
||||
- 동일 idempotency payload는 같은 ID를 반환하고 다른 payload는 conflict다.
|
||||
- correction과 re-close는 append-only이고 parent당 child/run은 하나다.
|
||||
- NULL과 0을 구별하고 모든 금액은 integer precision이다.
|
||||
@@ -186,13 +198,353 @@ MCP와 Dashboard는 shared service/gateway를 사용한다. 기존 success/error
|
||||
|
||||
## 7. 인수 기준
|
||||
|
||||
### REQ-003.11-AC01 — 빈 월 초안 영속 생성
|
||||
|
||||
**판정 기준**
|
||||
|
||||
자료가 없어도 월말 기준일의 초안이 저장되고 생성만으로 snapshot run/rows가 생기지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_empty_draft_persists_without_closing_and_has_consistent_public_shape`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: 정상 empty draft; 경계 no targets; 실패 invalid month
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Create는 close side effect가 없다.
|
||||
|
||||
### REQ-003.11-AC02 — 월말 기준일 및 create replay
|
||||
|
||||
**판정 기준**
|
||||
|
||||
월말만 허용하고 같은 key/payload는 idempotent, 다른 payload는 충돌한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_basis_must_be_calendar_month_end_and_create_key_is_idempotent`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal month-end; boundary leap month; failure non-month-end
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
거절된 create는 초안을 추가하지 않는다.
|
||||
|
||||
### REQ-003.11-AC03 — 월별 단일 active 초안과 correction link
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Normal/correction 초안을 합쳐 월별 active는 하나이며 취소 후 월 재개가 가능하다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_one_active_draft_across_normal_and_correction_and_cancellation_reopens_month`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal competing create; boundary correction lineage; failure second active
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Correction lineage는 명시적이고 유일하다.
|
||||
|
||||
### REQ-003.11-AC04 — 입력·버전·행위자 검증
|
||||
|
||||
**판정 기준**
|
||||
|
||||
잘못된 값과 stale version을 거절하고 include/exclude 사유 및 actor를 기록한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_input_validation_versions_actors_and_include_exclude_reason`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal valid mutation; boundary version; failure invalid type
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
거절된 mutation은 상태와 version을 변경하지 않는다.
|
||||
|
||||
### REQ-003.11-AC05 — 명시적 target refresh와 read-only review
|
||||
|
||||
**판정 기준**
|
||||
|
||||
대상 membership은 refresh 전 고정되고 review는 읽기 전용이다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal refresh; boundary changed sources; failure implicit refresh
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Review는 DB 상태를 쓰지 않는다.
|
||||
|
||||
### REQ-003.11-AC06 — exact, carry, manual substitute 날짜 구분
|
||||
|
||||
**판정 기준**
|
||||
|
||||
선택 모드는 valuation date와 basis date 차이를 보존하며 future substitute는 명시적이다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_explicit_future_substitute_and_late_recorded_exact_are_distinguished`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal exact; boundary future substitute; failure implicit future choice
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
`date_difference_days = valuation_date - basis_date`를 보존한다.
|
||||
|
||||
### REQ-003.11-AC07 — unverified 후보는 authority 아님
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Unverified 후보 금액은 표시 가능하나 authority amount는 NULL이며 verified child는 재선택을 요구한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal verified selection; boundary candidate; failure candidate authority
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
NULL은 0이나 verified 금액으로 대체되지 않는다.
|
||||
|
||||
### REQ-003.11-AC08 — 수동 evidence drift와 future 차단
|
||||
|
||||
**판정 기준**
|
||||
|
||||
수동 source 변경은 명시적 refresh가 필요하고 future evidence가 자동 선택되지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal unchanged source; boundary drift; failure auto-select future
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Frozen source hash는 refresh 전까지 불변이다.
|
||||
|
||||
### REQ-003.11-AC09 — 승인 fingerprint 및 drift gate
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Mutation/source drift는 approval을 무효화하고 finalize를 막는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_approval_fingerprint_mutation_and_external_drift_block_finalize`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal unchanged approval; boundary drift; failure stale finalize
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Finalize는 승인된 version/fingerprint만 받는다.
|
||||
|
||||
### REQ-003.11-AC10 — 원자적 finalize 및 persisted replay
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Finalize는 immutable provenance를 저장하고 동일 요청에 persisted response를 반환한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal finalize; boundary replay; failure conflicting key
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Replay는 저장된 결과를 재구성하며 중복 run을 만들지 않는다.
|
||||
|
||||
### REQ-003.11-AC11 — DB 실패 rollback
|
||||
|
||||
**판정 기준**
|
||||
|
||||
SQLite write 실패 시 run, rows, completed 상태가 모두 rollback된다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_sqlite_trigger_failure_rolls_back_run_rows_and_completion`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal successful commit; boundary trigger failure; failure mid-transaction
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
부분 마감 결과는 남지 않는다.
|
||||
|
||||
### REQ-003.11-AC12 — additive schema 13 migration
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Migration은 legacy rows를 보존하고 반복 실행에 안전하다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_schema13_migration_is_additive_and_idempotent`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal new DB; boundary schema 12; failure repeated migration
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Schema 11→12 의미와 데이터는 보존된다.
|
||||
|
||||
### REQ-003.11-AC13 — 동시 create와 stale finalize
|
||||
|
||||
**판정 기준**
|
||||
|
||||
동시 create는 active uniqueness를 지키고 stale version finalize는 거절한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_monthly_settlement_drafts.py::test_concurrent_create_and_stale_version_finalization_are_safe`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal sequential mutation; boundary concurrent create; failure stale finalize
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
한 달에 active 초안은 최대 하나다.
|
||||
|
||||
### REQ-003.11-AC14 — draft adapter public contract
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Gateway/MCP는 실제 service signature와 결과를 그대로 노출한다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_settlement_draft_adapters.py::test_gateway_exports_exact_contract_signature_and_delegates`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: application
|
||||
- 케이스: normal delegation; boundary signature; failure service error
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Adapter는 authority 판단이나 응답 변형을 하지 않는다.
|
||||
|
||||
### REQ-003.11-AC15 — Dashboard auth 및 render 경계
|
||||
|
||||
**판정 기준**
|
||||
|
||||
인증은 DB 접근 전 수행되고 render는 lifecycle mutation을 하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_settlement_draft_adapters.py::test_dashboard_authentication_entrypoint_precedes_database_resolution`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: dashboard
|
||||
- 케이스: normal authenticated read; boundary unauthenticated; failure DB access before auth
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
인증 없는 요청은 DB에 접근하지 않는다.
|
||||
|
||||
### REQ-003.11-AC16 — missing DB read no-create
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Read-only gateway missing database를 생성하거나 migrate하지 않는다.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_settlement_draft_adapters.py::test_read_only_gateway_calls_do_not_create_missing_database`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: sqlite
|
||||
- 케이스: normal initialized read; boundary missing DB; failure implicit create
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
Read path는 no mkdir, no migrate, no commit이다.
|
||||
|
||||
### REQ-003.11-AC17 — legacy contract regression
|
||||
|
||||
**판정 기준**
|
||||
|
||||
Valuation correction, monthly summary read-only, menu/auth compatibility regressions remain green.
|
||||
|
||||
**검증**
|
||||
|
||||
`tests/test_settlement_draft_adapters.py::test_service_reexports_every_lifecycle_operation`
|
||||
|
||||
**검증 분류**
|
||||
|
||||
- 수준: integration
|
||||
- 백엔드: application
|
||||
- 케이스: normal legacy call; boundary old schema; failure incompatible export
|
||||
|
||||
**상태 불변조건**
|
||||
|
||||
New draft operations are additive and legacy entrypoints remain available.
|
||||
|
||||
### REQ-003.01-AC01 — migration and preservation
|
||||
|
||||
**Cases (정상/경계/실패)**: 정상 schema 12 init twice; 경계 schema 11 legacy DB; 실패 malformed legacy DB.
|
||||
**Cases (정상/경계/실패)**: 정상 schema 13 init twice; 경계 schema 11 legacy DB; 실패 malformed legacy DB.
|
||||
|
||||
**State Invariants**: legacy tables/rows remain; migration repeats without duplicate columns.
|
||||
|
||||
**판정 기준**: `schema_version=12`, required tables/columns/indexes exist and old rows remain.
|
||||
**판정 기준**: `schema_version=13`, required tables/columns/indexes exist and old rows remain.
|
||||
|
||||
**검증**: `tests/test_account_valuations.py::test_account_valuation_schema_is_v12_and_idempotent`, `tests/test_issue_12_regressions.py::test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent`.
|
||||
**검증 분류**: level=integration; backend=sqlite
|
||||
|
||||
@@ -239,3 +239,21 @@ The fingerprint input is exactly basis date, selection policy, included and excl
|
||||
An idempotent close is replayed only for equal request fingerprints. Replay reconstructs the response from the persisted run/evidence (no replay-only reused payload), while a different payload returns `idempotency_conflict`. Recursive close metadata is allowlisted and redacted before it reaches Dashboard/MCP.
|
||||
|
||||
Dashboard missing-DB reads return stable unavailable `db_not_initialized` without create/migrate/commit. The missing DB stable unavailable contract is no-create. Initialized read failures return `db_error`; successful initialized reads are read-only. The initialized DB read failure is db_error and initialized read has no write. Reconciliation requires opening evidence and coverage through the requested basis; otherwise it reports `INCOMPLETE_CALCULATION`/`reconciliation unavailable` with provenance, no inferred balance, and no fake transaction.
|
||||
|
||||
## settlement-draft-create — 월정산 초안 lifecycle (Issue #15)
|
||||
|
||||
### create → edit → review → approve → finalize
|
||||
|
||||
| 단계 ID | 실행 주체 | 처리 / guard | 상태 경계 | 실패 전이 |
|
||||
|---|---|---|---|---|
|
||||
| `settlement-draft-create` | adapter/service | Strict month/date/actor/key validation; explicit correction prior run/reason; acquire `BEGIN IMMEDIATE`; idempotent equal replay; enforce one active draft/month. | Persist draft and initial target items only; no snapshot run/rows/close. | validation/conflict; rollback |
|
||||
| `settlement-draft-edit` | shared service | Expected version; targets change only through explicit refresh; item include/exclude and valuation selection audited; mutation clears approval. | Increment draft/item versions; retain history/reasons. | stale version or invalid scope, no write |
|
||||
| `settlement-draft-review` | shared service | Read frozen source/valuation evidence, warning and correction-child drift; compute review fingerprint/totals/missing; no auto future selection. | Read-only; unverified candidate_amount with authority amount NULL. | drift/missing warnings; no authority mutation |
|
||||
| `settlement-draft-approve` | authorized caller | Require matching current fingerprint/version and explicit actor/reason/reference/flags. | Persist approval binding and flags. | mismatch/unsafe unknown/rejected evidence rejected |
|
||||
| `settlement-draft-finalize` | shared service/SQLite | Idempotency persisted-response lookup first; then active/version/approval/fingerprint/drift/correction-parent checks; insert run, rows and completed/link within one `BEGIN IMMEDIATE`. | Atomic immutable close and draft completion. | conflict or injected DB failure rolls back all writes |
|
||||
|
||||
Exact requires same-date verified evidence; carried_before uses verified prior with reason; manual_substitute requires explicit ID and reason, with signed `date_difference_days = valuation_date - basis_date`. `age_days` keeps its existing direction/meaning. Corrected valuation children are never followed automatically: explicit reselection then fresh review/approval. Missing and substitution remain orthogonal; manual evidence drift requires explicit refresh. Existing row-only legacy close uses established latest row-group ordering and available run identity; never infer a registry row or fall back to live authority.
|
||||
|
||||
### failure/recovery and read-only guards
|
||||
|
||||
Draft create/update/finalize use transaction-scoped writes; any DB failure rolls back run, rows and completion. Same finalize request returns persisted response, not a replay-only `reused` field; different request conflicts. Monthly summary and review do not mutate; transaction readiness remains independent. `read_db()` missing path is `db_not_initialized`, with no-create/no mkdir/no migrate/no commit. Dashboard gate precedes every protected DB access; existing auth cookie lifecycle and exact menu/query/shell/browser semantics remain unchanged.
|
||||
|
||||
@@ -443,3 +443,44 @@ def main() -> None:
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
def create_settlement_draft(month: str, created_by: str, idempotency_key: str, basis_date: str = "", memo: str = "", correction_of_run_id: str = "", correction_reason: str = "") -> dict:
|
||||
return contracts.create_settlement_draft(month, created_by, idempotency_key, basis_date, memo, correction_of_run_id, correction_reason)
|
||||
|
||||
@mcp.tool()
|
||||
def list_settlement_drafts(month: str = "") -> dict:
|
||||
return contracts.list_settlement_drafts(month)
|
||||
|
||||
@mcp.tool()
|
||||
def get_settlement_draft(draft_id: str) -> dict:
|
||||
return contracts.get_settlement_draft(draft_id)
|
||||
|
||||
@mcp.tool()
|
||||
def update_settlement_draft(draft_id: str, expected_version: int, updated_by: str, memo: str) -> dict:
|
||||
return contracts.update_settlement_draft(draft_id, expected_version, updated_by, memo)
|
||||
|
||||
@mcp.tool()
|
||||
def refresh_settlement_draft_targets(draft_id: str, expected_version: int, updated_by: str) -> dict:
|
||||
return contracts.refresh_settlement_draft_targets(draft_id, expected_version, updated_by)
|
||||
|
||||
@mcp.tool()
|
||||
def set_settlement_draft_item(draft_id: str, source_type: str, source_id: str, expected_version: int, selected_by: str, valuation_id: str = "", selection_mode: str = "exact", selection_reason: str = "", included: bool = True, exclusion_reason: str = "") -> dict:
|
||||
return contracts.set_settlement_draft_item(draft_id, source_type, source_id, expected_version, selected_by, valuation_id, selection_mode, selection_reason, included, exclusion_reason)
|
||||
|
||||
@mcp.tool()
|
||||
def review_settlement_draft(draft_id: str) -> dict:
|
||||
return contracts.review_settlement_draft(draft_id)
|
||||
|
||||
@mcp.tool()
|
||||
def approve_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, approved_by: str, approval_reason: str, approval_reference: str, allow_missing_exclusions: bool = False, allow_carried_before: bool = False, allow_manual_substitute: bool = False) -> dict:
|
||||
return contracts.approve_settlement_draft(draft_id, expected_version, review_fingerprint, approved_by, approval_reason, approval_reference, allow_missing_exclusions, allow_carried_before, allow_manual_substitute)
|
||||
|
||||
@mcp.tool()
|
||||
def finalize_settlement_draft(draft_id: str, expected_version: int, review_fingerprint: str, idempotency_key: str) -> dict:
|
||||
return contracts.finalize_settlement_draft(draft_id, expected_version, review_fingerprint, idempotency_key)
|
||||
|
||||
@mcp.tool()
|
||||
def cancel_settlement_draft(draft_id: str, expected_version: int, cancelled_by: str, reason: str) -> dict:
|
||||
return contracts.cancel_settlement_draft(draft_id, expected_version, cancelled_by, reason)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ...service import (
|
||||
approve_settlement_draft as approve_settlement_draft,
|
||||
cancel_settlement_draft as cancel_settlement_draft,
|
||||
create_settlement_draft as create_settlement_draft,
|
||||
finalize_settlement_draft as finalize_settlement_draft,
|
||||
get_settlement_draft as get_settlement_draft,
|
||||
list_settlement_drafts as list_settlement_drafts,
|
||||
refresh_settlement_draft_targets as refresh_settlement_draft_targets,
|
||||
review_settlement_draft as review_settlement_draft,
|
||||
set_settlement_draft_item as set_settlement_draft_item,
|
||||
update_settlement_draft as update_settlement_draft,
|
||||
annotations as annotations,
|
||||
apply_categorization_rules as apply_categorization_rules,
|
||||
close_asset_snapshot as close_asset_snapshot,
|
||||
@@ -74,7 +84,21 @@ from ...service import (
|
||||
update_manual_asset as update_manual_asset,
|
||||
)
|
||||
|
||||
TOOL_CONTRACTS = {
|
||||
name: globals()[name]
|
||||
for name in (
|
||||
"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
|
||||
"update_settlement_draft", "refresh_settlement_draft_targets",
|
||||
"set_settlement_draft_item", "review_settlement_draft",
|
||||
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
|
||||
)
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
"approve_settlement_draft", "cancel_settlement_draft", "create_settlement_draft",
|
||||
"finalize_settlement_draft", "get_settlement_draft", "list_settlement_drafts",
|
||||
"refresh_settlement_draft_targets", "review_settlement_draft",
|
||||
"set_settlement_draft_item", "update_settlement_draft",
|
||||
"annotations",
|
||||
"apply_categorization_rules",
|
||||
"close_asset_snapshot",
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
|
||||
from ...service import (
|
||||
approve_settlement_draft as approve_settlement_draft,
|
||||
cancel_settlement_draft as cancel_settlement_draft,
|
||||
correct_account_valuation as correct_account_valuation,
|
||||
create_settlement_draft as create_settlement_draft,
|
||||
finalize_settlement_draft as finalize_settlement_draft,
|
||||
get_settlement_draft as get_settlement_draft,
|
||||
list_account_valuations as list_account_valuations,
|
||||
list_settlement_drafts as list_settlement_drafts,
|
||||
refresh_settlement_draft_targets as refresh_settlement_draft_targets,
|
||||
register_account_valuation as register_account_valuation,
|
||||
review_settlement_draft as review_settlement_draft,
|
||||
set_settlement_draft_item as set_settlement_draft_item,
|
||||
update_settlement_draft as update_settlement_draft,
|
||||
)
|
||||
from ...application import settlement_draft_service as _draft_service
|
||||
from ... import service as _service
|
||||
|
||||
|
||||
def _resolve_db_path() -> str:
|
||||
from ...db import resolve_db_path
|
||||
return str(resolve_db_path())
|
||||
|
||||
|
||||
def _guard(db_path: str | None) -> str:
|
||||
return db_path or _resolve_db_path()
|
||||
|
||||
|
||||
def _wrap(fn):
|
||||
def call(*args, db_path=None, **kwargs):
|
||||
target = _draft_service if fn.__name__ in {"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft", "update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft"} else _service
|
||||
return getattr(target, fn.__name__)(*args, db_path=_guard(db_path), **kwargs)
|
||||
call.__name__ = fn.__name__
|
||||
call.__doc__ = fn.__doc__
|
||||
call.__signature__ = inspect.signature(fn)
|
||||
return call
|
||||
|
||||
|
||||
# Keep the service signatures unchanged; path resolution/auth is adapter policy.
|
||||
for _name in (
|
||||
"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
|
||||
"update_settlement_draft", "refresh_settlement_draft_targets",
|
||||
"set_settlement_draft_item", "review_settlement_draft",
|
||||
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
|
||||
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
|
||||
):
|
||||
globals()[_name] = _wrap(getattr(_service, _name))
|
||||
|
||||
__all__ = [
|
||||
"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
|
||||
"update_settlement_draft", "refresh_settlement_draft_targets",
|
||||
"set_settlement_draft_item", "review_settlement_draft",
|
||||
"approve_settlement_draft", "finalize_settlement_draft", "cancel_settlement_draft",
|
||||
"register_account_valuation", "list_account_valuations", "correct_account_valuation",
|
||||
]
|
||||
@@ -67,6 +67,18 @@ def _public_evidence(item: dict[str, Any], strict: bool = False) -> dict[str, An
|
||||
"valuation_id", "valuation_kind", "active", "last_synced_at", "last_balance", "valuation_amount", "reason",
|
||||
"unverified_candidates", "candidate", "candidates", "state_hash", "blocking", "snapshot_run_id", "is_correction",
|
||||
"correction_of_run_id", "correction_reason",
|
||||
# Draft-close provenance is intentionally an exact allowlist. Nested
|
||||
# objects are recursively filtered by these same keys.
|
||||
"date_difference_days", "direction", "original_recorded_at", "selection_mode",
|
||||
"date_difference_direction", "recorded_at", "representative_liability_definition",
|
||||
"allow_missing_exclusions", "allow_carried_before", "allow_manual_substitute",
|
||||
"reason", "selection_reason", "selected_by", "selected_at", "item_version", "source_ref",
|
||||
"source_attributes", "approval", "approval_reference", "approved_by",
|
||||
"approved_at", "approval_reason", "draft_id", "draft_version",
|
||||
"review_fingerprint", "contains_substitutions", "contains_carried",
|
||||
"has_approved_missing", "carried_forward", "source_type", "source_id",
|
||||
"amount", "currency", "valuation_id", "valuation_date", "age_days",
|
||||
"verification_status", "evidence_kind", "carried_forward",
|
||||
}
|
||||
if isinstance(item, dict):
|
||||
return {key: _public_evidence(value, strict) for key, value in item.items() if key in allowed or (not strict and isinstance(value, (dict, list)))}
|
||||
@@ -304,6 +316,9 @@ def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[
|
||||
integrity_error = any(value is None for value in evidence.values())
|
||||
metadata = {
|
||||
"snapshot_run_id": run["id"],
|
||||
"draft_id": run["draft_id"] if "draft_id" in run.keys() else "",
|
||||
"draft_version": run["draft_version"] if "draft_version" in run.keys() else 0,
|
||||
"review_fingerprint": run["review_fingerprint"] if "review_fingerprint" in run.keys() else "",
|
||||
"is_correction": bool(run["correction_of_run_id"]),
|
||||
"correction_of_run_id": run["correction_of_run_id"] or "",
|
||||
"correction_reason": run["correction_reason"] or "",
|
||||
@@ -408,10 +423,18 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
|
||||
|
||||
def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Expose immutable run evidence without source notes or memos."""
|
||||
applied = [_public_evidence(item, strict=True) for item in metadata.get("applied_evidence", [])]
|
||||
excluded = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])]
|
||||
carried = [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])]
|
||||
contains_substitutions = any(item.get("selection_mode") == "manual_substitute" for item in applied)
|
||||
contains_carried = bool(carried) or any(item.get("selection_mode") == "carried_before" or item.get("carried_forward") for item in applied)
|
||||
has_approved_missing = any(item.get("reason") == "approved_missing" for item in excluded)
|
||||
# Evidence is immutable: do not inject absent draft keys into legacy
|
||||
# exclusions/carried rows. Draft finalization persists its own lineage.
|
||||
warnings = []
|
||||
exclusions = [_public_evidence(item, strict=True) for item in metadata.get("excluded_evidence", [])]
|
||||
|
||||
candidate_warnings: dict[tuple[str, str], dict[str, Any]] = {}
|
||||
for item in exclusions:
|
||||
for item in excluded:
|
||||
if item.get("candidate"):
|
||||
key = (item.get("source_type", ""), item.get("source_id", ""))
|
||||
warning_shape = {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []}
|
||||
@@ -424,9 +447,16 @@ def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
warnings.extend(candidate_warnings.values())
|
||||
return {
|
||||
"warnings": warnings,
|
||||
"excluded_items": exclusions,
|
||||
"carried_items": [_public_evidence(item, strict=True) for item in metadata.get("carried_evidence", [])],
|
||||
"excluded_items": excluded,
|
||||
"applied_evidence": applied,
|
||||
"carried_items": carried,
|
||||
"contains_substitutions": contains_substitutions,
|
||||
"contains_carried": contains_carried,
|
||||
"has_approved_missing": has_approved_missing,
|
||||
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
|
||||
"draft_id": metadata.get("draft_id", ""),
|
||||
"draft_version": metadata.get("draft_version", 0),
|
||||
"review_fingerprint": metadata.get("review_fingerprint", ""),
|
||||
"close_status": metadata.get("close_status", ""),
|
||||
"selection_policy": metadata.get("selection_policy", ""),
|
||||
"approval": {} if metadata.get("legacy_closed") else {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")},
|
||||
@@ -1104,6 +1134,13 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
"source": "asset_snapshots",
|
||||
"item_count": len(items),
|
||||
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
|
||||
"draft_id": run_public.get("draft_id", ""),
|
||||
"draft_version": run_public.get("draft_version", 0),
|
||||
"review_fingerprint": run_public.get("review_fingerprint", ""),
|
||||
"contains_substitutions": run_public.get("contains_substitutions", False),
|
||||
"contains_carried": run_public.get("contains_carried", False),
|
||||
"has_approved_missing": run_public.get("has_approved_missing", False),
|
||||
"applied_evidence": run_public.get("applied_evidence", []),
|
||||
"close_status": metadata.get("close_status", ""),
|
||||
"selection_policy": metadata.get("selection_policy", ""),
|
||||
"warnings": run_public["warnings"],
|
||||
|
||||
@@ -75,6 +75,13 @@ def _source_coverage(
|
||||
"excluded_items": asset_report.get("excluded_items", []),
|
||||
"carried_items": asset_report.get("carried_items", asset_report.get("included_items", [])),
|
||||
"monthly_source_snapshot_id": asset_report.get("snapshot_run_id", ""),
|
||||
"draft_id": asset_report.get("close_metadata", {}).get("draft_id", ""),
|
||||
"draft_version": asset_report.get("close_metadata", {}).get("draft_version", 0),
|
||||
"review_fingerprint": asset_report.get("close_metadata", {}).get("review_fingerprint", ""),
|
||||
"contains_substitutions": asset_report.get("close_metadata", {}).get("contains_substitutions", False),
|
||||
"contains_carried": asset_report.get("close_metadata", {}).get("contains_carried", False),
|
||||
"has_approved_missing": asset_report.get("close_metadata", {}).get("has_approved_missing", False),
|
||||
"applied_evidence": asset_report.get("close_metadata", {}).get("applied_evidence", []),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import calendar
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from datetime import date
|
||||
from uuid import uuid4
|
||||
|
||||
from .shared import db, read_db, now_iso, ok, fail
|
||||
|
||||
|
||||
def _valid_version(v):
|
||||
return isinstance(v, int) and not isinstance(v, bool) and v > 0
|
||||
|
||||
|
||||
def _month_end(month):
|
||||
if not isinstance(month, str) or len(month) != 7:
|
||||
raise ValueError('month must be YYYY-MM')
|
||||
y, m = map(int, month.split('-'))
|
||||
if date(y, m, 1).strftime('%Y-%m') != month:
|
||||
raise ValueError('month must be YYYY-MM')
|
||||
return f'{month}-{calendar.monthrange(y,m)[1]:02d}'
|
||||
|
||||
|
||||
def _err(code, message): return fail(code, message)
|
||||
|
||||
|
||||
def _items(conn, draft_id):
|
||||
rows = conn.execute('SELECT * FROM settlement_draft_items WHERE draft_id=? ORDER BY source_type,source_id', (draft_id,)).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
|
||||
|
||||
def _draft(conn, did):
|
||||
r=conn.execute('SELECT * FROM settlement_drafts WHERE id=?',(did,)).fetchone()
|
||||
return dict(r) if r else None
|
||||
|
||||
|
||||
def _current_semantics(conn, item):
|
||||
if item['source_type']=='account':
|
||||
src=conn.execute('SELECT * FROM accounts WHERE id=?',(item['source_id'],)).fetchone()
|
||||
val=conn.execute('SELECT * FROM account_valuations WHERE id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None
|
||||
child=conn.execute('SELECT id FROM account_valuations WHERE supersedes_id=?',(item.get('valuation_id',''),)).fetchone() if item.get('valuation_id') else None
|
||||
return {'source':dict(src) if src else None,'valuation':dict(val) if val else None,'child':dict(child) if child else None}
|
||||
src=conn.execute('SELECT * FROM manual_assets WHERE id=?',(item['source_id'],)).fetchone()
|
||||
return {'source':dict(src) if src else None}
|
||||
|
||||
|
||||
def _nonempty(value):
|
||||
return isinstance(value, str) and bool(value.strip())
|
||||
|
||||
|
||||
def _latest_close(conn, basis):
|
||||
from .assets_service import _snapshot_rows_for_latest_run
|
||||
_, metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||
return metadata.get('snapshot_run_id', '')
|
||||
|
||||
|
||||
def _freeze(conn, source_type, source_id, valuation_id=''):
|
||||
return _current_semantics(conn, {'source_type': source_type, 'source_id': source_id, 'valuation_id': valuation_id})
|
||||
|
||||
|
||||
def _review(conn, draft):
|
||||
stored = _items(conn, draft['id'])
|
||||
items, included, excluded, missing, warnings, states = [], [], [], [], [], []
|
||||
for item in stored:
|
||||
current = _current_semantics(conn, item)
|
||||
frozen = json.loads(item.get('evidence_json') or '{}')
|
||||
# No permissive fallback from malformed/absent frozen evidence to live authority.
|
||||
source = frozen.get('source')
|
||||
if source is None and item['source_type'] != 'account' and 'valuation_amount' in frozen:
|
||||
source = frozen # development draft compatibility, not legacy run backfill
|
||||
source = source or {}
|
||||
row = {k: v for k, v in item.items() if k != 'evidence_json'}
|
||||
row['item_version'] = item['version']
|
||||
row.update(source_type='account' if item['source_type'] == 'account' else 'manual_asset',
|
||||
included=bool(item['included']), basis_date=draft['basis_date'],
|
||||
name=source.get('name', ''), currency=source.get('currency', 'KRW'),
|
||||
liquidity_bucket='loan' if source.get('type') == 'loan' else source.get('liquidity_bucket', 'other'),
|
||||
authority_source='account_valuations' if item['source_type'] == 'account' else 'manual_assets')
|
||||
row['is_liability'] = row['liquidity_bucket'] == 'loan'
|
||||
row['state_hash'] = hashlib.sha256(json.dumps(frozen, sort_keys=True).encode()).hexdigest()
|
||||
source_drift = source != (current.get('source') or {})
|
||||
value = frozen.get('valuation') if item['source_type'] == 'account' else source
|
||||
drift = source_drift or (item['source_type'] == 'account' and value != current.get('valuation'))
|
||||
child = current.get('child')
|
||||
row.update(drift=drift, review_needed=bool(drift or child), amount=None, candidate_amount=None)
|
||||
if child:
|
||||
row['correction_child_id'] = child['id']
|
||||
warnings.append({'code': 'correction_child', 'source_id': item['source_id']})
|
||||
if drift:
|
||||
warnings.append({'code': 'source_drift', 'source_id': item['source_id']})
|
||||
mode = item['selection_mode']
|
||||
if value:
|
||||
actual = value.get('basis_date', '') if item['source_type'] == 'account' else value.get('valuation_date', '')
|
||||
delta = (date.fromisoformat(actual) - date.fromisoformat(draft['basis_date'])).days if actual else None
|
||||
candidate = value.get('amount') if item['source_type'] == 'account' else value.get('valuation_amount')
|
||||
status = value.get('verification_status', '') if item['source_type'] == 'account' else 'verified'
|
||||
row.update(valuation_date=actual, date_difference_days=delta,
|
||||
date_difference_direction='after' if delta is not None and delta > 0 else 'before' if delta is not None and delta < 0 else 'same' if delta == 0 else 'unknown',
|
||||
age_days=-delta if delta is not None else None, candidate_amount=candidate,
|
||||
verification_status=status, recorded_at=value.get('recorded_at', ''),
|
||||
source_ref=value.get('source_ref', ''), valuation_kind=value.get('valuation_kind', ''),
|
||||
representative_liability_definition=value.get('representative_liability_definition'),
|
||||
carried_forward=mode == 'carried_before', evidence_kind='manual_substitute' if mode == 'manual_substitute' else 'carried_before' if mode == 'carried_before' else 'exact')
|
||||
eligible_date = delta == 0 if mode == 'exact' else delta is not None and delta < 0 if mode == 'carried_before' else item['source_type'] == 'account' and delta is not None
|
||||
if status == 'verified' and eligible_date and source.get('active') and row['currency'] == 'KRW' and not row['review_needed']:
|
||||
row['amount'] = candidate if row['included'] else None
|
||||
elif item['source_type'] == 'account':
|
||||
# Candidate visibility does not make it selected or authoritative.
|
||||
candidate = conn.execute('SELECT amount FROM account_valuations WHERE account_id=? AND basis_date<=? ORDER BY recorded_at DESC,rowid DESC LIMIT 1', (item['source_id'], draft['basis_date'])).fetchone()
|
||||
if candidate:
|
||||
row['candidate_amount'] = candidate['amount']
|
||||
if row['currency'] != 'KRW':
|
||||
warnings.append({'code': 'non_krw', 'source_id': item['source_id']})
|
||||
row['status'] = 'excluded' if not row['included'] else 'ready' if row['amount'] is not None else 'missing'
|
||||
if row['status'] == 'missing':
|
||||
row['reason'] = 'review_needed' if row['review_needed'] else 'missing_evidence'
|
||||
missing.append(row)
|
||||
elif row['status'] == 'excluded':
|
||||
row['reason'] = row['exclusion_reason']
|
||||
excluded.append(row)
|
||||
else:
|
||||
included.append(row)
|
||||
items.append(row)
|
||||
# All candidate changes and source/selected evidence are bound privately; never exposed as raw JSON.
|
||||
candidates = [dict(v) for v in conn.execute('SELECT * FROM account_valuations WHERE account_id=? ORDER BY id', (item['source_id'],))] if item['source_type'] == 'account' else []
|
||||
states.append({'stored': item, 'current': current, 'candidates': candidates})
|
||||
assets = sum(x['amount'] for x in included if not x['is_liability'])
|
||||
liabilities = sum(x['amount'] for x in included if x['is_liability'])
|
||||
latest = _latest_close(conn, draft['basis_date'])
|
||||
serial = json.dumps({'draft_id': draft['id'], 'version': draft['version'], 'basis_date': draft['basis_date'], 'states': states, 'warnings': warnings, 'latest_run': latest, 'correction_parent': draft['correction_of_run_id']}, sort_keys=True, ensure_ascii=False)
|
||||
totals = {'total_assets': assets, 'total_liabilities': liabilities, 'net_worth': assets - liabilities}
|
||||
return {'draft_id': draft['id'], 'version': draft['version'], 'review_fingerprint': hashlib.sha256(serial.encode()).hexdigest(),
|
||||
'items': items, 'included_items': included, 'excluded_items': excluded, 'missing_items': missing, 'warnings': warnings,
|
||||
'approval_required': True, 'totals': totals, **totals, 'complete': bool(items) and not missing,
|
||||
'unregistered_liabilities': not any(x['is_liability'] for x in items),
|
||||
'contains_substitutions': any(x['selection_mode'] == 'manual_substitute' for x in included),
|
||||
'contains_carried': any(x['selection_mode'] == 'carried_before' for x in included),
|
||||
'has_approved_missing': bool(missing or excluded)}
|
||||
|
||||
|
||||
def _result(conn,did):
|
||||
d=_draft(conn,did)
|
||||
if not d:return _err('not_found','settlement draft not found')
|
||||
if d['status'] == 'completed' and d['final_response']:
|
||||
final = json.loads(d['final_response'])
|
||||
return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, items=final.get('items', []) + final.get('excluded_items', []), final_response=final)
|
||||
review = _review(conn, d)
|
||||
return ok(draft={k: v for k, v in d.items() if k != 'final_response'}, items=review['items'], review=review)
|
||||
|
||||
|
||||
def create_settlement_draft(month: str, created_by: str, idempotency_key: str, basis_date: str = '', memo: str = '', correction_of_run_id: str = '', correction_reason: str = '', db_path: str|None=None):
|
||||
try:
|
||||
basis=_month_end(month)
|
||||
if basis_date and basis_date != basis: raise ValueError('basis_date must be month end')
|
||||
if not _nonempty(created_by) or not _nonempty(idempotency_key): raise ValueError('creator and idempotency key required')
|
||||
if not all(isinstance(v, str) for v in (basis_date, memo, correction_of_run_id, correction_reason)): raise ValueError('draft metadata must be strings')
|
||||
if correction_of_run_id and not _nonempty(correction_reason): raise ValueError('correction reason required')
|
||||
with db(db_path) as c:
|
||||
c.execute('BEGIN IMMEDIATE')
|
||||
payload_hash=hashlib.sha256(json.dumps([month,created_by,basis,memo,correction_of_run_id,correction_reason],separators=(',',':')).encode()).hexdigest()
|
||||
old=c.execute('SELECT * FROM settlement_drafts WHERE create_key=?',(idempotency_key,)).fetchone()
|
||||
if old:
|
||||
if old['original_request_hash']!=payload_hash: return _err('conflict','idempotency key payload conflict')
|
||||
return _result(c,old['id'])
|
||||
if c.execute("SELECT 1 FROM settlement_drafts WHERE month=? AND status='active'",(month,)).fetchone(): return _err('conflict','active draft already exists for month')
|
||||
latest = _latest_close(c, basis)
|
||||
if correction_of_run_id != latest:
|
||||
return _err('validation_error', 'latest closed run must be linked explicitly with correction reason')
|
||||
did=uuid4().hex; ts=now_iso()
|
||||
c.execute('INSERT INTO settlement_drafts(id,month,basis_date,status,version,created_by,created_at,updated_at,memo,create_key,correction_of_run_id,correction_reason,original_request_hash) VALUES(?,?,? ,\'active\',1,?,?,?,?,?,?,?,?)',(did,month,basis,created_by,ts,ts,memo,idempotency_key,correction_of_run_id,correction_reason,payload_hash))
|
||||
# Freeze initial membership; account selections may use only verified, non-future evidence.
|
||||
from .assets_service import _account_valuation_items
|
||||
eligible, _, _ = _account_valuation_items(c, basis, 'exact')
|
||||
selected = {x['source_id']: x.get('valuation_id', '') for x in eligible}
|
||||
for a in c.execute("SELECT * FROM accounts WHERE active=1 ORDER BY id").fetchall():
|
||||
vid = selected.get(a['id'], '')
|
||||
ev = _freeze(c, 'account', a['id'], vid)
|
||||
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'account',a['id'],vid,'exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True)))
|
||||
for a in c.execute("SELECT * FROM manual_assets WHERE active=1 ORDER BY id").fetchall():
|
||||
ev = _freeze(c, 'manual_asset', a['id'])
|
||||
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,?,?,?,?,?)',(uuid4().hex,did,'manual_asset',a['id'],'','exact',1,1,created_by,ts,json.dumps(ev,sort_keys=True)))
|
||||
return _result(c,did)
|
||||
except (ValueError,TypeError,sqlite3.Error) as e: return _err('validation_error' if isinstance(e,(ValueError,TypeError)) else 'db_error','invalid draft request' if isinstance(e,(ValueError,TypeError)) else 'database error')
|
||||
|
||||
|
||||
def list_settlement_drafts(month: str='',db_path: str|None=None):
|
||||
try:
|
||||
with read_db(db_path) as c:
|
||||
rows=c.execute('SELECT * FROM settlement_drafts'+(' WHERE month=?' if month else '')+' ORDER BY created_at DESC',(month,) if month else ()).fetchall()
|
||||
return ok(drafts=[dict(x) for x in rows])
|
||||
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
|
||||
|
||||
|
||||
def get_settlement_draft(draft_id: str,db_path: str|None=None):
|
||||
try:
|
||||
with read_db(db_path) as c:return _result(c,draft_id)
|
||||
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
|
||||
|
||||
|
||||
def _mutate(did, version, actor, sql, params, db_path):
|
||||
if not _valid_version(version) or not _nonempty(actor):return _err('validation_error','positive integer version and actor required')
|
||||
try:
|
||||
with db(db_path) as c:
|
||||
c.execute('BEGIN IMMEDIATE'); d=_draft(c,did)
|
||||
if not d:return _err('not_found','draft not found')
|
||||
if d['status']!='active' or d['version']!=version:return _err('conflict','draft state/version conflict')
|
||||
c.execute(sql,(*params,did)); c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?",(now_iso(),did))
|
||||
return _result(c,did)
|
||||
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
|
||||
|
||||
|
||||
def update_settlement_draft(draft_id: str,expected_version: int,updated_by: str,memo: str,db_path: str|None=None):
|
||||
if not isinstance(memo, str): return _err('validation_error', 'memo must be a string')
|
||||
return _mutate(draft_id,expected_version,updated_by,'UPDATE settlement_drafts SET memo=? WHERE id=?',(memo,),db_path)
|
||||
|
||||
|
||||
def refresh_settlement_draft_targets(draft_id: str,expected_version: int,updated_by: str,db_path: str|None=None):
|
||||
if not _valid_version(expected_version) or not _nonempty(updated_by):
|
||||
return _err('validation_error', 'version and actor required')
|
||||
try:
|
||||
with db(db_path) as c:
|
||||
c.execute('BEGIN IMMEDIATE')
|
||||
d = _draft(c, draft_id)
|
||||
if not d: return _err('not_found', 'draft not found')
|
||||
if d['status'] != 'active' or d['version'] != expected_version:
|
||||
return _err('conflict', 'draft state/version conflict')
|
||||
ts = now_iso()
|
||||
from .assets_service import _account_valuation_items
|
||||
eligible, _, _ = _account_valuation_items(c, d['basis_date'], 'exact')
|
||||
selected = {x['source_id']: x.get('valuation_id', '') for x in eligible}
|
||||
existing = {(x['source_type'], x['source_id']): x for x in _items(c, draft_id)}
|
||||
active = set()
|
||||
for typ, table in [('account', 'accounts'), ('manual_asset', 'manual_assets')]:
|
||||
for source in c.execute(f'SELECT id FROM {table} WHERE active=1 ORDER BY id').fetchall():
|
||||
key = (typ, source['id'])
|
||||
active.add(key)
|
||||
if key not in existing:
|
||||
vid = selected.get(source['id'], '') if typ == 'account' else ''
|
||||
ev = _freeze(c, typ, source['id'], vid)
|
||||
c.execute('INSERT INTO settlement_draft_items(id,draft_id,source_type,source_id,valuation_id,selection_mode,included,version,selected_by,selected_at,evidence_json) VALUES(?,?,?,?,?,?,1,1,?,?,?)',
|
||||
(uuid4().hex, draft_id, typ, source['id'], vid, 'exact', updated_by, ts, json.dumps(ev, sort_keys=True)))
|
||||
elif typ == 'manual_asset':
|
||||
# This explicit action can refresh manual values; it must never
|
||||
# silently replace an account's selected valuation with a child.
|
||||
ev = _freeze(c, typ, source['id'])
|
||||
c.execute('UPDATE settlement_draft_items SET evidence_json=?,version=version+1,selected_by=?,selected_at=? WHERE id=?',
|
||||
(json.dumps(ev, sort_keys=True), updated_by, ts, existing[key]['id']))
|
||||
for key, item in existing.items():
|
||||
if key not in active:
|
||||
# Preserve the retired row and frozen evidence as an audited exclusion.
|
||||
c.execute("UPDATE settlement_draft_items SET included=0,exclusion_reason='target retired by explicit refresh',version=version+1,selected_by=?,selected_at=? WHERE id=?",
|
||||
(updated_by, ts, item['id']))
|
||||
c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?", (ts, draft_id))
|
||||
return _result(c, draft_id)
|
||||
except sqlite3.Error: return _err('db_error', 'database error')
|
||||
except (TypeError, ValueError): return _err('validation_error', 'invalid persisted draft evidence')
|
||||
|
||||
|
||||
def set_settlement_draft_item(draft_id: str,source_type: str,source_id: str,expected_version: int,selected_by: str,valuation_id: str='',selection_mode: str='exact',selection_reason: str='',included: bool=True,exclusion_reason: str='',db_path: str|None=None):
|
||||
source_type = 'manual_asset' if source_type == 'manual' else source_type
|
||||
if type(included) is not bool or source_type not in ('account', 'manual_asset') or selection_mode not in ('exact', 'carried_before', 'manual_substitute'):
|
||||
return _err('validation_error', 'invalid item fields')
|
||||
if not _valid_version(expected_version) or not _nonempty(selected_by) or not _nonempty(source_id) or not isinstance(valuation_id, str) or not isinstance(selection_reason, str) or not isinstance(exclusion_reason, str):
|
||||
return _err('validation_error', 'valid version, source and actor required')
|
||||
if not included and not _nonempty(exclusion_reason): return _err('validation_error', 'exclusion reason required')
|
||||
if selection_mode in ('carried_before', 'manual_substitute') and not _nonempty(selection_reason): return _err('validation_error', 'selection reason required')
|
||||
try:
|
||||
with db(db_path) as c:
|
||||
c.execute('BEGIN IMMEDIATE')
|
||||
d = _draft(c, draft_id)
|
||||
if not d: return _err('not_found', 'draft not found')
|
||||
if d['status'] != 'active' or d['version'] != expected_version: return _err('conflict', 'draft state/version conflict')
|
||||
item = c.execute('SELECT * FROM settlement_draft_items WHERE draft_id=? AND source_type=? AND source_id=?', (draft_id, source_type, source_id)).fetchone()
|
||||
if not item: return _err('validation_error', 'source is not a draft target; refresh targets first')
|
||||
ev = _freeze(c, source_type, source_id, valuation_id)
|
||||
source = ev.get('source')
|
||||
if included and (not source or not source.get('active')): return _err('validation_error', 'included target must be active')
|
||||
if source_type == 'account' and valuation_id:
|
||||
v = ev.get('valuation')
|
||||
if not v or v['account_id'] != source_id: return _err('validation_error', 'valuation does not belong to account')
|
||||
if v['currency'] != source['currency']: return _err('validation_error', 'valuation currency mismatch')
|
||||
kind, loan = v['valuation_kind'], source['type'] == 'loan'
|
||||
valid_kind = kind in ('loan', 'loan_balance', 'balance') if loan else kind in ('total_value', 'cash_balance', 'balance') if source['type'] == 'investment' else kind == 'balance'
|
||||
if not valid_kind or (loan and v['representative_liability_definition'] not in ('principal_only', 'principal_interest_fees')) or (not loan and v['representative_liability_definition']):
|
||||
return _err('validation_error', 'valuation purpose or representative liability is invalid')
|
||||
status = v['verification_status']
|
||||
if status not in ('verified', 'unverified') or (status != 'verified' and selection_mode != 'manual_substitute'):
|
||||
return _err('validation_error', 'selection requires confirmed evidence except review-only manual substitute')
|
||||
delta = (date.fromisoformat(v['basis_date']) - date.fromisoformat(d['basis_date'])).days
|
||||
if selection_mode == 'exact' and delta != 0: return _err('validation_error', 'exact requires basis-date valuation')
|
||||
if selection_mode == 'carried_before' and delta >= 0: return _err('validation_error', 'carried_before requires earlier valuation')
|
||||
if ev.get('child') and included: return _err('conflict', 'select corrected child explicitly or exclude target')
|
||||
elif source_type == 'account':
|
||||
if selection_mode != 'exact': return _err('validation_error', 'an explicit valuation ID is required')
|
||||
else:
|
||||
if valuation_id or selection_mode == 'manual_substitute': return _err('validation_error', 'manual asset supports exact or carried_before only')
|
||||
if included and source.get('valuation_date'):
|
||||
delta = (date.fromisoformat(source['valuation_date']) - date.fromisoformat(d['basis_date'])).days
|
||||
if (selection_mode == 'exact' and delta != 0) or (selection_mode == 'carried_before' and delta >= 0): return _err('validation_error', 'manual evidence date does not match selection mode')
|
||||
ts = now_iso()
|
||||
c.execute('UPDATE settlement_draft_items SET valuation_id=?,selection_mode=?,selection_reason=?,included=?,exclusion_reason=?,selected_by=?,selected_at=?,version=version+1,evidence_json=? WHERE id=?', (valuation_id, selection_mode, selection_reason, int(included), exclusion_reason, selected_by, ts, json.dumps(ev, sort_keys=True), item['id']))
|
||||
c.execute("UPDATE settlement_drafts SET version=version+1,updated_at=?,approval_fingerprint='',approval_version=0,approved_by='',approved_at='',approval_reason='',approval_reference='',approval_flags='{}' WHERE id=?", (ts, draft_id))
|
||||
return _result(c, draft_id)
|
||||
except (ValueError, TypeError): return _err('validation_error', 'invalid selection evidence')
|
||||
except sqlite3.Error: return _err('db_error', 'database error')
|
||||
|
||||
|
||||
def review_settlement_draft(draft_id: str,db_path: str|None=None):
|
||||
try:
|
||||
with read_db(db_path) as c:
|
||||
d=_draft(c,draft_id)
|
||||
if not d:return _err('not_found','draft not found')
|
||||
return ok(**_review(c,d))
|
||||
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
|
||||
|
||||
|
||||
def approve_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,approved_by: str,approval_reason: str,approval_reference: str,allow_missing_exclusions: bool=False,allow_carried_before: bool=False,allow_manual_substitute: bool=False,db_path: str|None=None):
|
||||
if not _valid_version(expected_version) or not all(_nonempty(x) for x in (approved_by, approval_reason, approval_reference, review_fingerprint)):return _err('validation_error','approval metadata required')
|
||||
if any(type(x) is not bool for x in (allow_missing_exclusions, allow_carried_before, allow_manual_substitute)): return _err('validation_error', 'approval flags must be booleans')
|
||||
try:
|
||||
with db(db_path) as c:
|
||||
c.execute('BEGIN IMMEDIATE'); d=_draft(c,draft_id)
|
||||
if not d or d['version']!=expected_version or d['status']!='active':return _err('conflict','draft version/state conflict')
|
||||
r=_review(c,d)
|
||||
if r['review_fingerprint']!=review_fingerprint:return _err('conflict','review fingerprint changed')
|
||||
if any(x['included'] and x.get('review_needed') for x in r['items']): return _err('conflict', 'changed evidence requires explicit selection or exclusion')
|
||||
if r['has_approved_missing'] and not allow_missing_exclusions:return _err('validation_error','missing/excluded items require approval')
|
||||
if r['contains_carried'] and not allow_carried_before:return _err('validation_error','carried values require approval')
|
||||
if r['contains_substitutions'] and not allow_manual_substitute:return _err('validation_error','substitutes require approval')
|
||||
flags=json.dumps({'allow_missing_exclusions':allow_missing_exclusions,'allow_carried_before':allow_carried_before,'allow_manual_substitute':allow_manual_substitute})
|
||||
c.execute('UPDATE settlement_drafts SET approval_fingerprint=?,approval_version=?,approved_by=?,approved_at=?,approval_reason=?,approval_reference=?,approval_flags=? WHERE id=?',(review_fingerprint,expected_version,approved_by,now_iso(),approval_reason,approval_reference,flags,draft_id))
|
||||
return _result(c,draft_id)
|
||||
except Exception as e:return _err('db_not_initialized' if 'not initialized' in str(e).lower() else 'db_error','ledger database is not initialized' if 'not initialized' in str(e).lower() else 'database error')
|
||||
|
||||
|
||||
def finalize_settlement_draft(draft_id: str,expected_version: int,review_fingerprint: str,idempotency_key: str,db_path: str|None=None):
|
||||
if not _valid_version(expected_version) or not _nonempty(idempotency_key) or not _nonempty(review_fingerprint):return _err('validation_error','version and close key required')
|
||||
try:
|
||||
with db(db_path) as c:
|
||||
c.execute('BEGIN IMMEDIATE')
|
||||
previous=c.execute('SELECT id,request_fingerprint FROM asset_snapshot_runs WHERE idempotency_key=?',(idempotency_key,)).fetchone()
|
||||
d=_draft(c,draft_id)
|
||||
req=hashlib.sha256(f'{draft_id}:{expected_version}:{review_fingerprint}'.encode()).hexdigest()
|
||||
if previous:
|
||||
if previous['request_fingerprint']!=req:return _err('conflict','idempotency key payload conflict')
|
||||
saved=c.execute('SELECT final_response FROM settlement_drafts WHERE id=?',(draft_id,)).fetchone()
|
||||
if saved and saved[0]:return json.loads(saved[0])
|
||||
return _err('conflict','persisted replay unavailable')
|
||||
if not d or d['status']!='active' or d['version']!=expected_version:return _err('conflict','draft state/version conflict')
|
||||
r=_review(c,d)
|
||||
if not r['items']: return _err('validation_error', 'an empty draft cannot create a close run')
|
||||
if any(x['included'] and x.get('review_needed') for x in r['items']): return _err('conflict', 'changed evidence requires explicit selection or exclusion')
|
||||
if _latest_close(c, d['basis_date']) != d['correction_of_run_id']: return _err('conflict', 'correction parent changed')
|
||||
if r['review_fingerprint']!=review_fingerprint or d['approval_fingerprint']!=review_fingerprint or d['approval_version']!=expected_version:return _err('conflict','review/approval stale')
|
||||
flags=json.loads(d['approval_flags'] or '{}')
|
||||
if r['missing_items'] and not flags.get('allow_missing_exclusions'):return _err('validation_error','missing items not approved')
|
||||
if r['contains_substitutions'] and not flags.get('allow_manual_substitute'):return _err('validation_error','substitutes not approved')
|
||||
if r['contains_carried'] and not flags.get('allow_carried_before'):return _err('validation_error','carried items not approved')
|
||||
from .assets_service import _canonical_provenance, _evidence_provenance, _ok_with_provenance_aliases, _summarize_valuation_items
|
||||
approval = {k: d[k] for k in ('approved_by', 'approved_at', 'approval_reason', 'approval_reference')}
|
||||
approval.update(flags)
|
||||
correction = {'is_correction': bool(d['correction_of_run_id']), 'correction_of_run_id': d['correction_of_run_id'], 'correction_reason': d['correction_reason']}
|
||||
applied = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval} for x in r['included_items']]
|
||||
excluded = [{**x, 'draft_id': draft_id, 'draft_version': expected_version, 'review_fingerprint': review_fingerprint, 'approval': approval, 'reason': 'approved_missing', 'exclusion_reason': x.get('exclusion_reason') or x.get('reason', 'missing_evidence')} for x in r['excluded_items'] + r['missing_items']]
|
||||
carried = [x for x in applied if x['selection_mode'] == 'carried_before']
|
||||
run=uuid4().hex; ts=now_iso(); basis=d['basis_date']; status='conditional' if excluded or r['contains_substitutions'] or r['contains_carried'] else 'complete_exact'
|
||||
c.execute('INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,idempotency_key,approved_by,approved_at,approval_reason,approval_reference,correction_of_run_id,correction_reason,applied_evidence,excluded_evidence,carried_evidence,request_fingerprint,created_at,draft_id,draft_version,review_fingerprint) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(run,basis,status,'exact',idempotency_key,d['approved_by'],d['approved_at'],d['approval_reason'],d['approval_reference'],d['correction_of_run_id'],d['correction_reason'],json.dumps(applied,sort_keys=True),json.dumps(excluded,sort_keys=True),json.dumps(carried,sort_keys=True),req,ts,draft_id,expected_version,review_fingerprint))
|
||||
snapshot_ids = []
|
||||
for x in applied + excluded:
|
||||
sid = uuid4().hex
|
||||
snapshot_ids.append(sid)
|
||||
c.execute('INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,snapshot_run_id,correction_of_run_id,correction_reason,is_correction,closed_at,created_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)',(sid,basis,x['source_type'],x['source_id'],x.get('name',''),x['amount'],x['currency'],x['liquidity_bucket'],x['selection_mode'],'','',run,d['correction_of_run_id'],d['correction_reason'],int(bool(d['correction_of_run_id'])),ts,ts,x.get('valuation_id') or '',x['selection_mode'],x.get('verification_status','unverified'),'excluded' if x in excluded else 'applied',x.get('exclusion_reason','')))
|
||||
evidence = _evidence_provenance(applied, basis)
|
||||
response = _ok_with_provenance_aliases(
|
||||
draft_id=draft_id, draft_version=expected_version, review_fingerprint=review_fingerprint,
|
||||
snapshot_run_id=run, run_id=run, basis_date=basis, close_status=status, closed_at=ts,
|
||||
source='snapshot', is_closed_snapshot=True, selection_policy='exact',
|
||||
inserted_count=len(snapshot_ids), inserted_snapshot_ids=snapshot_ids,
|
||||
items=applied, excluded_items=excluded, totals=r['totals'],
|
||||
contains_substitutions=r['contains_substitutions'], contains_carried=r['contains_carried'], has_approved_missing=bool(excluded),
|
||||
settlement_label='대체 평가자료를 포함한 월정산' if r['contains_substitutions'] else '조건부 월정산' if status == 'conditional' else '정확한 월말 평가 월정산',
|
||||
warnings=r['warnings'], carried_items=carried, approval=approval, correction=correction, **correction,
|
||||
provenance=_canonical_provenance(valuation_date=evidence['valuation_date'], age_days=evidence['age_days'], carried=bool(carried), verification_status=evidence['verification_status'], evidence_kind=evidence['evidence_kind'], basis_date=basis, snapshot_run_id=run, close_status=status, selection_policy='exact', warnings=r['warnings'], exclusions=excluded, approval=approval, correction=correction),
|
||||
**{k: v for k, v in _summarize_valuation_items(applied, excluded).items() if k != 'excluded_items'},
|
||||
)
|
||||
c.execute("UPDATE settlement_drafts SET status='completed',version=version+1,final_run_id=?,final_response=?,updated_at=? WHERE id=?",(run,json.dumps(response),ts,draft_id))
|
||||
return response
|
||||
except sqlite3.Error:return _err('db_error','database error')
|
||||
except (TypeError, ValueError):return _err('validation_error','invalid persisted draft evidence')
|
||||
|
||||
|
||||
def cancel_settlement_draft(draft_id: str,expected_version: int,cancelled_by: str,reason: str,db_path: str|None=None):
|
||||
if not _nonempty(reason):return _err('validation_error','cancellation reason required')
|
||||
return _mutate(draft_id,expected_version,cancelled_by,"UPDATE settlement_drafts SET status='cancelled',cancelled_by=?,cancellation_reason=?,cancelled_at=? WHERE id=?",(cancelled_by,reason,now_iso()),db_path)
|
||||
@@ -18,6 +18,31 @@ def _add_missing_columns(conn: sqlite3.Connection, table: str, columns: dict[str
|
||||
|
||||
def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
"""Apply additive, idempotent migrations only."""
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_drafts (
|
||||
id TEXT PRIMARY KEY, month TEXT NOT NULL, basis_date TEXT NOT NULL,
|
||||
status TEXT NOT NULL, version INTEGER NOT NULL, created_by TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL, updated_at TEXT NOT NULL, memo TEXT NOT NULL DEFAULT '',
|
||||
create_key TEXT NOT NULL UNIQUE, correction_of_run_id TEXT NOT NULL DEFAULT '',
|
||||
correction_reason TEXT NOT NULL DEFAULT '', final_run_id TEXT NOT NULL DEFAULT '',
|
||||
final_response TEXT NOT NULL DEFAULT '', cancelled_by TEXT NOT NULL DEFAULT '',
|
||||
cancellation_reason TEXT NOT NULL DEFAULT '', cancelled_at TEXT NOT NULL DEFAULT '',
|
||||
approval_fingerprint TEXT NOT NULL DEFAULT '', approval_version INTEGER NOT NULL DEFAULT 0,
|
||||
approved_by TEXT NOT NULL DEFAULT '', approved_at TEXT NOT NULL DEFAULT '',
|
||||
approval_reason TEXT NOT NULL DEFAULT '', approval_reference TEXT NOT NULL DEFAULT '',
|
||||
approval_flags TEXT NOT NULL DEFAULT '{}'
|
||||
)""")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_settlement_drafts_active_month ON settlement_drafts(month) WHERE status='active'")
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_settlement_drafts_month ON settlement_drafts(month,created_at)")
|
||||
_add_missing_columns(conn, "settlement_drafts", {"original_request_hash": "TEXT NOT NULL DEFAULT ''"})
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS settlement_draft_items (
|
||||
id TEXT PRIMARY KEY, draft_id TEXT NOT NULL REFERENCES settlement_drafts(id),
|
||||
source_type TEXT NOT NULL, source_id TEXT NOT NULL, valuation_id TEXT NOT NULL DEFAULT '',
|
||||
selection_mode TEXT NOT NULL DEFAULT 'exact', selection_reason TEXT NOT NULL DEFAULT '',
|
||||
included INTEGER NOT NULL DEFAULT 1, exclusion_reason TEXT NOT NULL DEFAULT '',
|
||||
version INTEGER NOT NULL DEFAULT 1, selected_by TEXT NOT NULL DEFAULT '',
|
||||
selected_at TEXT NOT NULL DEFAULT '', evidence_json TEXT NOT NULL DEFAULT '{}',
|
||||
UNIQUE(draft_id,source_type,source_id)
|
||||
)""")
|
||||
_add_missing_columns(conn, "accounts", ACCOUNT_ADDITIVE_COLUMNS)
|
||||
_add_missing_columns(conn, "transactions", TRANSACTION_ADDITIVE_COLUMNS)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_transactions_datetime ON transactions(transaction_datetime)")
|
||||
@@ -116,7 +141,7 @@ def migrate_db(conn: sqlite3.Connection) -> None:
|
||||
applied_evidence TEXT NOT NULL DEFAULT '[]', excluded_evidence TEXT NOT NULL DEFAULT '[]',
|
||||
carried_evidence TEXT NOT NULL DEFAULT '[]', request_fingerprint TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL
|
||||
)""")
|
||||
_add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''"})
|
||||
_add_missing_columns(conn, "asset_snapshot_runs", {"request_fingerprint": "TEXT NOT NULL DEFAULT ''", "draft_id": "TEXT NOT NULL DEFAULT ''", "draft_version": "INTEGER NOT NULL DEFAULT 0", "review_fingerprint": "TEXT NOT NULL DEFAULT ''"})
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_idempotency ON asset_snapshot_runs(idempotency_key) WHERE idempotency_key <> ''")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_initial_basis ON asset_snapshot_runs(basis_date) WHERE correction_of_run_id = ''")
|
||||
conn.execute("CREATE UNIQUE INDEX IF NOT EXISTS idx_asset_snapshot_runs_correction_parent ON asset_snapshot_runs(correction_of_run_id) WHERE correction_of_run_id <> ''")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
SCHEMA_VERSION = "12"
|
||||
SCHEMA_VERSION = "13"
|
||||
|
||||
SCHEMA_SQL = """
|
||||
PRAGMA journal_mode=WAL;
|
||||
|
||||
@@ -14,6 +14,7 @@ from .application import cross_transfer_service as _cross_transfer_service
|
||||
from .application import imports_service as _imports_service
|
||||
from .application import import_transfer_services as _import_transfer_services
|
||||
from .application import monthly_settlement_service as _monthly_settlement_service
|
||||
from .application import settlement_draft_service as _settlement_draft_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
|
||||
@@ -38,6 +39,7 @@ _PROXY_MODULES = (
|
||||
_reconciliation_service,
|
||||
_review_decision_service,
|
||||
_monthly_settlement_service,
|
||||
_settlement_draft_service,
|
||||
_imports_service,
|
||||
_import_transfer_services,
|
||||
_cross_transfer_service,
|
||||
|
||||
@@ -17,7 +17,7 @@ def test_account_valuation_schema_is_v12_and_idempotent(tmp_path):
|
||||
with sqlite3.connect(path) as conn:
|
||||
cols = {row[1] for row in conn.execute("PRAGMA table_info(account_valuations)")}
|
||||
version = conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0]
|
||||
assert version == "12"
|
||||
assert version == "13"
|
||||
assert {"id", "account_id", "basis_date", "amount", "currency", "valuation_kind", "source_type", "source_ref", "verification_status", "recorded_at", "memo", "idempotency_key", "supersedes_id", "correction_reason", "as_of_at", "timezone", "cost_basis", "net_contributed", "reported_principal_kind", "reported_principal_label", "profit", "return_rate", "return_basis", "principal", "interest", "fee", "representative_liability_definition"} <= cols
|
||||
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ def test_schema12_migration_preserves_legacy_rows_indexes_and_is_idempotent(tmp_
|
||||
assert conn.execute("SELECT name FROM asset_snapshots WHERE id='s'").fetchone()
|
||||
indexes = {r[1] for r in conn.execute("PRAGMA index_list(transactions)")}
|
||||
assert {"legacy_transactions_date", "idx_transactions_datetime"} <= indexes
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "12"
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == "13"
|
||||
|
||||
|
||||
def test_snapshot_close_idempotency_fingerprint_and_concurrent_first_close(tmp_path):
|
||||
|
||||
@@ -6,7 +6,7 @@ from jarca_ledger_mcp import service
|
||||
def test_ledger_category_create_list_delete_reactivate(tmp_path):
|
||||
db_path = str(tmp_path / "ledger.sqlite3")
|
||||
health = service.health_check(db_path)
|
||||
assert health["schema_version"] == "12"
|
||||
assert health["schema_version"] == "13"
|
||||
|
||||
created = service.create_ledger_category("반려동물", "사료", sort_order=250, memo="test", db_path=db_path)
|
||||
assert created["success"] is True
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
from __future__ import annotations
|
||||
|
||||
"""Acceptance contract for the approved monthly settlement draft lifecycle.
|
||||
|
||||
These tests intentionally resolve the API at runtime: before implementation the
|
||||
suite must fail as an assertion (RED), not fail during pytest collection.
|
||||
All data is synthetic and each test owns a temporary SQLite database.
|
||||
"""
|
||||
import json
|
||||
import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
API = (
|
||||
"create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
|
||||
"update_settlement_draft", "refresh_settlement_draft_targets",
|
||||
"set_settlement_draft_item", "review_settlement_draft",
|
||||
"approve_settlement_draft", "finalize_settlement_draft",
|
||||
"cancel_settlement_draft",
|
||||
)
|
||||
|
||||
|
||||
def api(name):
|
||||
# Deliberately runtime checked so absent API produces a clear RED assertion.
|
||||
fn = getattr(service, name, None)
|
||||
assert callable(fn), f"required settlement-draft API missing: service.{name}"
|
||||
return fn
|
||||
|
||||
|
||||
def dbfile(tmp_path):
|
||||
path = str(tmp_path / "synthetic-ledger.sqlite3")
|
||||
init_db(path)
|
||||
return path
|
||||
|
||||
|
||||
def create(path, month="2026-09", key="create-1", **kwargs):
|
||||
return api("create_settlement_draft")(month, "tester", key, db_path=path, **kwargs)
|
||||
|
||||
|
||||
def assert_ok(result):
|
||||
assert result.get("success") is True, result
|
||||
return result
|
||||
|
||||
|
||||
def draft_of(result):
|
||||
assert isinstance(result.get("draft"), dict), result
|
||||
return result["draft"]
|
||||
|
||||
|
||||
def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
result = assert_ok(create(path))
|
||||
draft = draft_of(result)
|
||||
assert draft["month"] == "2026-09" and draft["basis_date"] == "2026-09-30"
|
||||
assert draft["status"] == "active" and draft["version"] == 1
|
||||
assert result["items"] == []
|
||||
assert isinstance(result["review"], dict)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
fetched = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
assert fetched["draft"]["id"] == draft["id"]
|
||||
listed = assert_ok(api("list_settlement_drafts")(month="2026-09", db_path=path))
|
||||
assert any(row.get("id") == draft["id"] for row in listed.get("drafts", []))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("month", ["", "2026-9", "2026-13", "2026/09", "2026-09-30", " 2026-09"])
|
||||
def test_month_and_basis_are_strictly_validated(tmp_path, month):
|
||||
path = dbfile(tmp_path)
|
||||
result = api("create_settlement_draft")(month, "tester", "strict", db_path=path)
|
||||
assert result.get("success") is False
|
||||
assert result.get("error_code") in {"validation_error", "invalid_argument"}
|
||||
|
||||
|
||||
def test_basis_must_be_calendar_month_end_and_create_key_is_idempotent(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
bad = api("create_settlement_draft")("2026-09", "tester", "bad-date", basis_date="2026-09-29", db_path=path)
|
||||
assert bad.get("success") is False
|
||||
first = assert_ok(create(path, key="same-key", memo="first"))
|
||||
replay = assert_ok(create(path, key="same-key", memo="first"))
|
||||
assert draft_of(first)["id"] == draft_of(replay)["id"]
|
||||
conflict = create(path, key="same-key", memo="different payload")
|
||||
assert conflict.get("success") is False
|
||||
|
||||
|
||||
def test_one_active_draft_across_normal_and_correction_and_cancellation_reopens_month(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
first = draft_of(assert_ok(create(path, key="one")))
|
||||
duplicate = create(path, key="two")
|
||||
assert duplicate.get("success") is False
|
||||
assert api("cancel_settlement_draft")(first["id"], first["version"], "tester", "test cancellation", db_path=path).get("success") is True
|
||||
reopened = draft_of(assert_ok(create(path, key="three")))
|
||||
assert reopened["id"] != first["id"]
|
||||
assert reopened["correction_of_run_id"] in (None, "")
|
||||
|
||||
|
||||
def test_input_validation_versions_actors_and_include_exclude_reason(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
update = api("update_settlement_draft")
|
||||
for bad_version in (0, -1, True, 1.5, None):
|
||||
result = update(draft["id"], bad_version, "tester", "memo", db_path=path)
|
||||
assert result.get("success") is False
|
||||
for actor in ("", " "):
|
||||
result = update(draft["id"], 1, actor, "memo", db_path=path)
|
||||
assert result.get("success") is False
|
||||
assert api("refresh_settlement_draft_targets")(draft["id"], 1, "tester", db_path=path).get("success") is True
|
||||
current = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))["draft"]
|
||||
assert current["version"] >= 2
|
||||
|
||||
|
||||
def test_targets_are_frozen_until_explicit_refresh_and_review_is_read_only(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
acct = service.create_ledger_account("Synthetic cash", last_balance=10, liquidity_bucket="cash", db_path=path)
|
||||
assert acct["success"]
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
before = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
service.create_ledger_account("Added later", last_balance=20, liquidity_bucket="cash", db_path=path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
snapshot = conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] if conn.execute("SELECT 1 FROM sqlite_master WHERE name='settlement_draft_items'").fetchone() else None
|
||||
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
assert {"version", "review_fingerprint", "items", "included_items", "excluded_items", "missing_items", "warnings", "approval_required", "totals"} <= review.keys()
|
||||
assert {"total_assets", "total_liabilities", "net_worth"} <= review["totals"].keys()
|
||||
assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
assert before["draft"]["version"] == draft["version"]
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
if snapshot is not None:
|
||||
assert conn.execute("SELECT COUNT(*) FROM settlement_draft_items").fetchone()[0] == snapshot
|
||||
|
||||
|
||||
def test_explicit_future_substitute_and_late_recorded_exact_are_distinguished(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
a = service.create_ledger_account("Future account", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
|
||||
b = service.create_ledger_account("Late exact", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
|
||||
future = service.register_account_valuation(a, "2026-10-05", 500, valuation_kind="total_value", verification_status="verified", idempotency_key="future", db_path=path)
|
||||
late = service.register_account_valuation(b, "2026-09-30", 700, valuation_kind="total_value", verification_status="verified", idempotency_key="late-exact", db_path=path)
|
||||
assert late.get("success")
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE account_valuations SET recorded_at=? WHERE id=?", ("2026-10-08T00:00:00Z", late["valuation"]["id"]))
|
||||
d = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
|
||||
rows = review["items"]
|
||||
auto_future = [x for x in rows if x.get("source_id") == a]
|
||||
assert not any(x.get("valuation_id") == future.get("valuation", {}).get("id") and x.get("selection_mode") == "exact" for x in auto_future)
|
||||
item = next(x for x in rows if x.get("source_id") == b)
|
||||
assert item.get("valuation_id") == late["valuation"]["id"]
|
||||
assert item.get("selection_mode", "exact") == "exact"
|
||||
assert api("set_settlement_draft_item")(d["id"], "account", a, d["version"], "tester", valuation_id=future["valuation"]["id"], selection_mode="manual_substitute", selection_reason="synthetic future evidence", db_path=path).get("success") is True
|
||||
revised = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
|
||||
selected = next(x for x in revised["items"] if x.get("source_id") == a)
|
||||
assert selected.get("valuation_date") == "2026-10-05"
|
||||
assert selected.get("date_difference_days") == 5
|
||||
assert selected.get("selection_mode") == "manual_substitute"
|
||||
|
||||
|
||||
def test_unverified_candidate_has_no_authority_and_verified_child_requires_reselection(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
acct = service.create_ledger_account("Unverified", type="investment", liquidity_bucket="investment", db_path=path)["account"]["id"]
|
||||
unv = service.register_account_valuation(acct, "2026-09-30", 900, valuation_kind="total_value", verification_status="unverified", idempotency_key="unv", db_path=path)
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
row = next(x for x in review["items"] if x.get("source_id") == acct)
|
||||
assert row.get("amount") is None
|
||||
assert row.get("candidate_amount") == 900
|
||||
child = service.correct_account_valuation(unv["valuation"]["id"], amount=950, verification_status="verified", correction_reason="synthetic verification", idempotency_key="verified-child", db_path=path)
|
||||
assert child.get("success")
|
||||
changed = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
assert any(x.get("source_id") == acct for x in changed["missing_items"])
|
||||
assert api("set_settlement_draft_item")(draft["id"], "account", acct, draft["version"], "tester", valuation_id=child["valuation"]["id"], selection_mode="exact", db_path=path).get("success") is True
|
||||
|
||||
|
||||
def test_manual_sources_drift_requires_explicit_refresh_and_future_not_auto_selected(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
manual = service.create_manual_asset("Synthetic asset", 100, "2026-09-30", "real_estate", db_path=path)["manual_asset"]
|
||||
future = service.create_manual_asset("Future asset", 200, "2026-10-07", "deposit", db_path=path)["manual_asset"]
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
assert not any(x.get("source_id") == future["id"] and x.get("amount") is not None for x in review["items"])
|
||||
service.update_manual_asset(manual["id"], valuation_amount=110, db_path=path)
|
||||
drift = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
assert any("drift" in json.dumps(x).lower() or x.get("review_needed") for x in drift["items"])
|
||||
refreshed = api("refresh_settlement_draft_targets")(draft["id"], draft["version"], "tester", db_path=path)
|
||||
assert refreshed.get("success") is True
|
||||
|
||||
|
||||
def test_approval_fingerprint_mutation_and_external_drift_block_finalize(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "reviewed synthetic evidence", "ref-1", allow_missing_exclusions=True, db_path=path)
|
||||
assert approved.get("success") is True
|
||||
mutated = api("update_settlement_draft")(draft["id"], draft["version"], "tester", "changed after approval", db_path=path)
|
||||
assert mutated.get("success") is True
|
||||
stale = api("finalize_settlement_draft")(draft["id"], mutated["draft"]["version"], review["review_fingerprint"], "close-key", db_path=path)
|
||||
assert stale.get("success") is False
|
||||
assert stale.get("error_code") in {"conflict", "validation_error"}
|
||||
|
||||
|
||||
def test_finalize_persists_immutable_provenance_replays_exact_response_and_conflicts_key(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
assert_ok(service.create_ledger_account("Synthetic cash", last_balance=321, liquidity_bucket="cash", db_path=path))
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
approval = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "approval-1", allow_missing_exclusions=True, db_path=path)
|
||||
assert approval.get("success")
|
||||
final = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
|
||||
assert final.get("success")
|
||||
replay = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
|
||||
assert replay == final
|
||||
conflict = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "final-key", db_path=path)
|
||||
assert conflict == final
|
||||
with sqlite3.connect(path) as conn:
|
||||
run_count = conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0]
|
||||
row_count = conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0]
|
||||
assert run_count == 1 and row_count >= 1
|
||||
assert final["draft_id"] == draft["id"]
|
||||
assert final["draft_version"] == draft["version"]
|
||||
assert final["review_fingerprint"] == review["review_fingerprint"]
|
||||
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
|
||||
series = service.get_net_worth_timeseries(start_date="2026-09-01", end_date="2026-09-30", db_path=path)
|
||||
monthly = service.get_monthly_settlement_summary("2026-09", db_path=path)
|
||||
assert report["success"] and series["success"] and monthly["success"]
|
||||
assert report["close_metadata"].get("draft_id") == draft["id"]
|
||||
|
||||
|
||||
def test_sqlite_trigger_failure_rolls_back_run_rows_and_completion(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
assert_ok(service.create_ledger_account("Synthetic", last_balance=5, liquidity_bucket="cash", db_path=path))
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
approved = api("approve_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "approver", "approved", "ref", allow_missing_exclusions=True, db_path=path)
|
||||
assert approved.get("success")
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("CREATE TRIGGER fail_snapshot_insert BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'injected failure'); END")
|
||||
result = api("finalize_settlement_draft")(draft["id"], draft["version"], review["review_fingerprint"], "failure-key", db_path=path)
|
||||
assert result.get("success") is False
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshot_runs").fetchone()[0] == 0
|
||||
assert conn.execute("SELECT COUNT(*) FROM asset_snapshots").fetchone()[0] == 0
|
||||
after = assert_ok(api("get_settlement_draft")(draft["id"], db_path=path))
|
||||
assert after["draft"]["status"] == "active"
|
||||
|
||||
|
||||
def test_schema13_migration_is_additive_and_idempotent(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,created_at) VALUES('legacy-run','2026-08-31','complete_exact','2026-09-01T00:00:00Z')")
|
||||
conn.commit()
|
||||
init_db(path)
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute("SELECT id FROM asset_snapshot_runs WHERE id='legacy-run'").fetchone()[0] == 'legacy-run'
|
||||
assert {r[0] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")} >= {'settlement_drafts','settlement_draft_items','asset_snapshot_runs','asset_snapshots'}
|
||||
assert conn.execute("SELECT value FROM schema_meta WHERE key='schema_version'").fetchone()[0] == '13'
|
||||
|
||||
|
||||
def test_concurrent_create_and_stale_version_finalization_are_safe(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
fn = api("create_settlement_draft")
|
||||
def attempt(i):
|
||||
return fn("2026-09", "tester", f"concurrent-{i}", db_path=path)
|
||||
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||
results = list(pool.map(attempt, range(4)))
|
||||
successes = [r for r in results if r.get("success")]
|
||||
assert len(successes) == 1
|
||||
assert all(r.get("success") or r.get("error_code") in {"conflict", "validation_error"} for r in results)
|
||||
d = draft_of(successes[0])
|
||||
review = assert_ok(api("review_settlement_draft")(d["id"], db_path=path))
|
||||
approved = api("approve_settlement_draft")(d["id"], d["version"], review["review_fingerprint"], "approver", "approved", "r", allow_missing_exclusions=True, db_path=path)
|
||||
assert approved.get("success")
|
||||
stale = api("finalize_settlement_draft")(d["id"], d["version"] + 1, review["review_fingerprint"], "stale", db_path=path)
|
||||
assert stale.get("success") is False
|
||||
|
||||
|
||||
def test_approval_is_mandatory_even_for_complete_exact_evidence(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
draft = draft_of(assert_ok(create(path)))
|
||||
review = assert_ok(api("review_settlement_draft")(draft["id"], db_path=path))
|
||||
finalized = api("finalize_settlement_draft")(
|
||||
draft["id"], draft["version"], review["review_fingerprint"], "unapproved",
|
||||
db_path=path,
|
||||
)
|
||||
assert finalized.get("success") is False
|
||||
@@ -21,31 +21,35 @@ def test_requirement_docs_are_canonical_and_traceable() -> None:
|
||||
"schema": "gitea-agent.requirement/v1",
|
||||
"id": "REQ-003",
|
||||
"title": "계좌 기준일 평가 이력과 자산 마감 연동",
|
||||
"revision": "1.0",
|
||||
"revision": "1.1",
|
||||
"status": "approved",
|
||||
"depends_on": [],
|
||||
"applies_to": ["ledger", "dashboard", "mcp"],
|
||||
"language": "ko",
|
||||
"issue": "#12",
|
||||
"implementation_pr": "#13",
|
||||
"issue": "#15",
|
||||
"implementation_pr": "미정",
|
||||
}
|
||||
assert re.search(r"^# REQ-003 — ", body, re.M)
|
||||
assert "${" not in text
|
||||
requirements = re.findall(r"^### REQ-003\.(\d+) —", body, re.M)
|
||||
assert requirements == [f"{n:02d}" for n in range(1, 11)]
|
||||
assert requirements == [f"{n:02d}" for n in range(1, 12)]
|
||||
acs = re.findall(r"^### REQ-003\.\d+-AC(\d+) —", body, re.M)
|
||||
assert len(acs) == 10
|
||||
assert body.count("**영향 범위**") == 10
|
||||
assert len(acs) == 27
|
||||
baseline_acs = re.findall(r"^### REQ-003\.(0[1-9]|10)-AC01 —", body, re.M)
|
||||
assert baseline_acs == [f"{n:02d}" for n in range(1, 11)]
|
||||
assert body.count("**영향 범위**") == 11 # Impact is per requirement, not per AC.
|
||||
assert body.count("**Cases (정상/경계/실패)**") == 10
|
||||
assert body.count("**State Invariants**") == 10
|
||||
assert body.count("**판정 기준**") == 10
|
||||
assert body.count("**검증**") == 10
|
||||
assert body.count("**판정 기준**") == 27
|
||||
assert body.count("**검증**") == 27
|
||||
classification = re.findall(
|
||||
r"^\*\*검증 분류\*\*: level=(unit|integration|static); backend=(sqlite|application|dashboard)$",
|
||||
body,
|
||||
re.M,
|
||||
)
|
||||
assert len(classification) == 10
|
||||
assert body.count("- 수준: integration") == 17
|
||||
assert len(re.findall(r"^- 백엔드: (sqlite|application|dashboard)$", body, re.M)) == 17
|
||||
assert {level for level, _ in classification} >= {"unit", "integration", "static"}
|
||||
assert {backend for _, backend in classification} >= {"sqlite", "application", "dashboard"}
|
||||
for marker in (
|
||||
@@ -142,7 +146,7 @@ def test_req003_has_exact_canonical_numbered_sections() -> None:
|
||||
headings = re.findall(r"^## (\d+)\. ", body, re.M)
|
||||
assert headings == [str(n) for n in range(1, 12)]
|
||||
assert not re.search(r"^## (?:8|9)\.(?: D| C)", body, re.M)
|
||||
assert body.count("### REQ-003.") == 20
|
||||
assert body.count("### REQ-003.") == 38
|
||||
assert "### REQ-003.01-AC01" in body
|
||||
assert "### REQ-003.10-AC01" in body
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ def test_review_decision_and_reconciliation_schema_are_additive_idempotent(tmp_p
|
||||
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"] == "12"
|
||||
assert service.health_check(db_path)["schema_version"] == "13"
|
||||
|
||||
|
||||
def test_review_decision_round_trip_payload_sanitizes_sensitive_fields(tmp_path: Path) -> None:
|
||||
|
||||
@@ -10,7 +10,7 @@ def test_health_check_initializes_db(tmp_path):
|
||||
db_path = tmp_path / "ledger.sqlite3"
|
||||
result = service.health_check(str(db_path))
|
||||
assert result["success"] is True
|
||||
assert result["schema_version"] == "12"
|
||||
assert result["schema_version"] == "13"
|
||||
assert db_path.exists()
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Contract tests for the Issue 15 Streamlit gateway and MCP surface.
|
||||
|
||||
Fixtures are synthetic and isolated: each test receives an empty temporary SQLite
|
||||
path; no production DB or external service is used. These assertions intentionally
|
||||
fail until the approved adapter/API is implemented.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import inspect
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
OPERATIONS = {
|
||||
"create_settlement_draft": ["month", "created_by", "idempotency_key", "basis_date", "memo", "correction_of_run_id", "correction_reason", "db_path"],
|
||||
"list_settlement_drafts": ["month", "db_path"],
|
||||
"get_settlement_draft": ["draft_id", "db_path"],
|
||||
"update_settlement_draft": ["draft_id", "expected_version", "updated_by", "memo", "db_path"],
|
||||
"refresh_settlement_draft_targets": ["draft_id", "expected_version", "updated_by", "db_path"],
|
||||
"set_settlement_draft_item": ["draft_id", "source_type", "source_id", "expected_version", "selected_by", "valuation_id", "selection_mode", "selection_reason", "included", "exclusion_reason", "db_path"],
|
||||
"review_settlement_draft": ["draft_id", "db_path"],
|
||||
"approve_settlement_draft": ["draft_id", "expected_version", "review_fingerprint", "approved_by", "approval_reason", "approval_reference", "allow_missing_exclusions", "allow_carried_before", "allow_manual_substitute", "db_path"],
|
||||
"finalize_settlement_draft": ["draft_id", "expected_version", "review_fingerprint", "idempotency_key", "db_path"],
|
||||
"cancel_settlement_draft": ["draft_id", "expected_version", "cancelled_by", "reason", "db_path"],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_path(tmp_path: Path) -> Path:
|
||||
return tmp_path / "synthetic-settlement.sqlite3"
|
||||
|
||||
|
||||
def test_gateway_exports_exact_contract_signature_and_delegates(monkeypatch, db_path):
|
||||
module = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
|
||||
service = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service")
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
|
||||
calls = []
|
||||
for name, expected_parameters in OPERATIONS.items():
|
||||
gateway_fn = getattr(module, name)
|
||||
service_fn = getattr(service, name)
|
||||
assert list(inspect.signature(gateway_fn).parameters) == expected_parameters
|
||||
assert inspect.signature(gateway_fn) == inspect.signature(service_fn)
|
||||
marker = {"success": True, "operation": name}
|
||||
def stub(*args, _marker=marker, _name=name, **kwargs):
|
||||
calls.append((_name, kwargs.get("db_path")))
|
||||
return _marker
|
||||
monkeypatch.setattr(service, name, stub)
|
||||
kwargs = {p: "synthetic" for p in expected_parameters}
|
||||
kwargs.update(db_path=str(db_path))
|
||||
kwargs["correction_of_run_id"] = ""
|
||||
kwargs["correction_reason"] = ""
|
||||
if "expected_version" in kwargs:
|
||||
kwargs["expected_version"] = 1
|
||||
if "included" in kwargs:
|
||||
kwargs["included"] = True
|
||||
if name == "create_settlement_draft":
|
||||
kwargs.update(month="2026-09", basis_date="2026-09-30")
|
||||
assert gateway_fn(**kwargs) == marker
|
||||
assert calls[-1] == (name, str(db_path))
|
||||
|
||||
|
||||
def test_gateway_exposes_existing_valuation_evidence_operations():
|
||||
gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
|
||||
for name in ("register_account_valuation", "list_account_valuations", "correct_account_valuation"):
|
||||
assert callable(getattr(gateway, name)), name
|
||||
|
||||
|
||||
def test_mcp_contract_and_fastmcp_tool_calls_preserve_service_result(monkeypatch, db_path):
|
||||
contracts = importlib.import_module("jarca_ledger_mcp.adapters.mcp.tool_contracts")
|
||||
server = importlib.import_module("jarca_ledger_mcp.adapters.mcp.server")
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
|
||||
calls = []
|
||||
tools = getattr(server, "mcp", getattr(server, "server", None))._tool_manager._tools
|
||||
for name in OPERATIONS:
|
||||
assert name in contracts.TOOL_CONTRACTS
|
||||
assert name in tools
|
||||
marker = {"success": True, "draft": {"id": "fixture-draft", "version": 1}}
|
||||
def stub(*args, _marker=marker, _name=name, **kwargs):
|
||||
calls.append(_name)
|
||||
return _marker
|
||||
monkeypatch.setattr(contracts, name, stub)
|
||||
tool = tools[name]
|
||||
call = getattr(tool, "fn", getattr(tool, "function", None))
|
||||
assert call is not None
|
||||
kwargs = {p: "synthetic" for p in OPERATIONS[name]}
|
||||
kwargs.pop("db_path", None)
|
||||
if "expected_version" in kwargs: kwargs["expected_version"] = 1
|
||||
if "included" in kwargs: kwargs["included"] = True
|
||||
if name == "create_settlement_draft": kwargs.update(month="2026-09", basis_date="2026-09-30")
|
||||
assert call(**kwargs) == marker
|
||||
assert calls[-1] == name
|
||||
|
||||
|
||||
def test_read_only_gateway_calls_do_not_create_missing_database(monkeypatch, db_path):
|
||||
gateway = importlib.import_module("jarca_ledger_mcp.adapters.streamlit.settlement_gateway")
|
||||
# Real delegation is required here: read paths must not create a missing file.
|
||||
for name in ("list_settlement_drafts",):
|
||||
getattr(gateway, name)(db_path=str(db_path))
|
||||
assert not db_path.exists()
|
||||
|
||||
|
||||
|
||||
def test_dashboard_authentication_entrypoint_precedes_database_resolution():
|
||||
app_source = (Path(__file__).parents[1] / "dashboard" / "app.py").read_text(encoding="utf-8")
|
||||
assert app_source.index("require_dashboard_authentication()") < app_source.index("get_db_path()")
|
||||
|
||||
|
||||
def test_service_reexports_every_lifecycle_operation():
|
||||
service = importlib.import_module("jarca_ledger_mcp.application.settlement_draft_service")
|
||||
assert all(callable(getattr(service, name, None)) for name in OPERATIONS)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Executable reproductions reconcile static audit hypotheses with real behavior."""
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def ok(result):
|
||||
assert result['success'], result
|
||||
return result
|
||||
|
||||
|
||||
def test_empty_draft_persists_without_closing_and_has_consistent_public_shape(tmp_path):
|
||||
path = str(tmp_path / 'empty.sqlite3')
|
||||
init_db(path)
|
||||
result = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))
|
||||
draft = result['draft']
|
||||
assert draft['status'] == 'active' and draft['version'] == 1
|
||||
assert draft['basis_date'] == '2026-09-30' and draft['correction_of_run_id'] == ''
|
||||
assert result['items'] == [] and not result['review']['complete']
|
||||
assert result['review']['totals'] == {'total_assets': 0, 'total_liabilities': 0, 'net_worth': 0}
|
||||
assert ok(s.get_settlement_draft(draft['id'], db_path=path)) == result
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert conn.execute('SELECT COUNT(*) FROM settlement_drafts').fetchone()[0] == 1
|
||||
assert conn.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
|
||||
assert conn.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_initial_draft_freezes_exact_verified_selector_compatibly(tmp_path):
|
||||
path = str(tmp_path / 'selection.sqlite3')
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account('Synthetic cash', db_path=path))['account']['id']
|
||||
verified = ok(s.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='verified', db_path=path))['valuation']['id']
|
||||
unverified = ok(s.register_account_valuation(account, '2026-09-30', 999, verification_status='unverified', idempotency_key='unverified', db_path=path))['valuation']['id']
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE account_valuations SET recorded_at='2026-10-01T00:00:00Z' WHERE id=?", (verified,))
|
||||
conn.execute("UPDATE account_valuations SET recorded_at='2026-10-02T00:00:00Z' WHERE id=?", (unverified,))
|
||||
selected = ok(s.select_account_valuation(account, '2026-09-30', as_of=False, db_path=path))
|
||||
assert selected['valuation']['id'] == verified
|
||||
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))
|
||||
item = next(x for x in draft['items'] if x['source_id'] == account)
|
||||
assert item['valuation_id'] == verified and item['amount'] == 100 and item['verification_status'] == 'verified'
|
||||
|
||||
|
||||
def test_refresh_manual_target_preserves_retired_history_and_allows_explicit_refreeze(tmp_path):
|
||||
path = str(tmp_path / 'retired.sqlite3')
|
||||
init_db(path)
|
||||
asset = ok(s.create_manual_asset('Synthetic property', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id']
|
||||
created = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))
|
||||
draft, original = created['draft'], created['items'][0]
|
||||
ok(s.deactivate_manual_asset(asset, db_path=path))
|
||||
retired = ok(s.refresh_settlement_draft_targets(draft['id'], draft['version'], 'refresher', db_path=path))
|
||||
row = next(x for x in retired['items'] if x['source_id'] == asset)
|
||||
assert row['id'] == original['id'] and not row['included'] and row['exclusion_reason']
|
||||
assert row['state_hash'] == original['state_hash'] and row['candidate_amount'] == 100
|
||||
refrozen = ok(s.set_settlement_draft_item(draft['id'], 'manual_asset', asset, retired['draft']['version'], 'refreezer', included=False, exclusion_reason='Synthetic retired source confirmed', db_path=path))
|
||||
row = refrozen['items'][0]
|
||||
assert row['id'] == original['id'] and not row['included'] and row['amount'] is None
|
||||
assert row['state_hash'] != original['state_hash'] and not row['review_needed']
|
||||
assert row['selected_by'] == 'refreezer'
|
||||
invalid = s.set_settlement_draft_item(draft['id'], 'manual_asset', asset, refrozen['draft']['version'], 'selector', included=True, db_path=path)
|
||||
assert not invalid['success']
|
||||
assert ok(s.get_settlement_draft(draft['id'], db_path=path))['draft']['version'] == refrozen['draft']['version']
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Synthetic regressions for frozen evidence and reviewed authority."""
|
||||
import sqlite3
|
||||
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def ok(result):
|
||||
assert result['success'], result
|
||||
return result
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def case(tmp_path):
|
||||
path = str(tmp_path / 'authority.sqlite3')
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account('Synthetic investment', type='investment', liquidity_bucket='investment', db_path=path))['account']['id']
|
||||
val = ok(s.register_account_valuation(account, '2026-09-30', 500, valuation_kind='total_value', verification_status='verified', idempotency_key='evidence', db_path=path))['valuation']['id']
|
||||
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft']
|
||||
return path, account, val, draft
|
||||
|
||||
|
||||
def approve(path, draft, **flags):
|
||||
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
|
||||
ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'reviewer', 'synthetic reviewed', 'ref', db_path=path, **flags))
|
||||
return review
|
||||
|
||||
|
||||
def counts(path):
|
||||
with sqlite3.connect(path) as conn:
|
||||
return tuple(conn.execute('SELECT COUNT(*) FROM ' + t).fetchone()[0] for t in ('asset_snapshot_runs', 'asset_snapshots'))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('sql', [
|
||||
"UPDATE account_valuations SET amount=600 WHERE id=?",
|
||||
"UPDATE account_valuations SET memo='changed' WHERE id=?",
|
||||
"UPDATE account_valuations SET principal=123 WHERE id=?",
|
||||
])
|
||||
def test_full_valuation_drift_changes_fingerprint_and_blocks_write(case, sql):
|
||||
path, account, val, draft = case
|
||||
review = approve(path, draft)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute(sql, (val,))
|
||||
changed = ok(s.review_settlement_draft(draft['id'], db_path=path))
|
||||
assert changed['review_fingerprint'] != review['review_fingerprint']
|
||||
assert any(x.get('review_needed') for x in changed['items'])
|
||||
result = s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)
|
||||
assert not result['success'], result
|
||||
assert counts(path) == (0, 0)
|
||||
|
||||
|
||||
def test_classification_drift_requires_explicit_reselection(case):
|
||||
path, account, val, draft = case
|
||||
review = approve(path, draft)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE accounts SET liquidity_bucket='cash' WHERE id=?", (account,))
|
||||
changed = ok(s.review_settlement_draft(draft['id'], db_path=path))
|
||||
assert changed['review_fingerprint'] != review['review_fingerprint']
|
||||
assert any(x.get('review_needed') for x in changed['items'])
|
||||
assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)['success']
|
||||
assert counts(path) == (0, 0)
|
||||
|
||||
|
||||
def test_corrected_parent_cannot_finalize_without_explicit_child_decision(case):
|
||||
path, account, val, draft = case
|
||||
previous = approve(path, draft)
|
||||
child = ok(s.correct_account_valuation(val, amount=550, verification_status='verified', correction_reason='synthetic correction', idempotency_key='child', db_path=path))['valuation']['id']
|
||||
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
|
||||
assert review['review_fingerprint'] != previous['review_fingerprint']
|
||||
result = s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'reviewer', 'review again', 'ref2', db_path=path)
|
||||
if result['success']:
|
||||
assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final', db_path=path)['success']
|
||||
assert counts(path) == (0, 0)
|
||||
updated = ok(s.set_settlement_draft_item(draft['id'], 'account', account, draft['version'], 'selector', valuation_id=child, db_path=path))['draft']
|
||||
fresh = approve(path, updated)
|
||||
final = ok(s.finalize_settlement_draft(updated['id'], updated['version'], fresh['review_fingerprint'], 'final2', db_path=path))
|
||||
assert final['totals']['net_worth'] == 550
|
||||
|
||||
|
||||
def test_create_replay_uses_original_payload_after_memo_edit(case):
|
||||
path, account, val, draft = case
|
||||
ok(s.update_settlement_draft(draft['id'], draft['version'], 'editor', 'edited', db_path=path))
|
||||
replay = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))
|
||||
assert replay['draft']['id'] == draft['id']
|
||||
assert not s.create_settlement_draft('2026-09', 'creator', 'create', memo='different original request', db_path=path)['success']
|
||||
|
||||
|
||||
def test_empty_draft_cannot_create_empty_close(tmp_path):
|
||||
path = str(tmp_path / 'empty.sqlite3')
|
||||
init_db(path)
|
||||
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'empty', db_path=path))['draft']
|
||||
review = approve(path, draft, allow_missing_exclusions=True)
|
||||
assert review['complete'] is False
|
||||
assert not s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'empty-final', db_path=path)['success']
|
||||
assert counts(path) == (0, 0)
|
||||
|
||||
|
||||
def test_manual_reselection_explicitly_refreezes_semantic_evidence(tmp_path):
|
||||
path = str(tmp_path / 'manual.sqlite3')
|
||||
init_db(path)
|
||||
asset = ok(s.create_manual_asset('Synthetic home', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id']
|
||||
draft = ok(s.create_settlement_draft('2026-09', 'creator', 'manual', db_path=path))['draft']
|
||||
old = approve(path, draft)
|
||||
ok(s.update_manual_asset(asset, valuation_amount=110, db_path=path))
|
||||
drift = ok(s.review_settlement_draft(draft['id'], db_path=path))
|
||||
assert drift['review_fingerprint'] != old['review_fingerprint']
|
||||
assert not s.finalize_settlement_draft(draft['id'], draft['version'], old['review_fingerprint'], 'stale', db_path=path)['success']
|
||||
refreshed = ok(s.set_settlement_draft_item(draft['id'], 'manual', asset, draft['version'], 'selector', db_path=path))['draft']
|
||||
new = approve(path, refreshed)
|
||||
assert not any(x.get('review_needed') for x in new['items'])
|
||||
final = ok(s.finalize_settlement_draft(refreshed['id'], refreshed['version'], new['review_fingerprint'], 'manual-final', db_path=path))
|
||||
assert final['totals']['net_worth'] == 110
|
||||
|
||||
|
||||
@pytest.mark.parametrize('operation,args', [
|
||||
('list_settlement_drafts', ()), ('get_settlement_draft', ('missing',)), ('review_settlement_draft', ('missing',)),
|
||||
])
|
||||
def test_draft_reads_preserve_missing_db_error_and_no_create(tmp_path, operation, args):
|
||||
path = tmp_path / 'absent-parent' / 'db.sqlite3'
|
||||
result = getattr(s, operation)(*args, db_path=str(path))
|
||||
assert result['error_code'] == 'db_not_initialized', result
|
||||
assert not path.parent.exists()
|
||||
|
||||
|
||||
def test_target_refresh_refreezes_manual_retains_account_selection_and_retires_targets(tmp_path):
|
||||
path = str(tmp_path / 'refresh.sqlite3')
|
||||
init_db(path)
|
||||
manual = ok(s.create_manual_asset('Synthetic property', 100, '2026-09-30', 'real_estate', db_path=path))['manual_asset']['id']
|
||||
account = ok(s.create_ledger_account('Synthetic investment', type='investment', liquidity_bucket='investment', db_path=path))['account']['id']
|
||||
val = ok(s.register_account_valuation(account, '2026-09-30', 50, valuation_kind='total_value', verification_status='verified', idempotency_key='initial', db_path=path))['valuation']['id']
|
||||
d = ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft']
|
||||
ok(s.update_manual_asset(manual, valuation_amount=110, db_path=path))
|
||||
child = ok(s.correct_account_valuation(val, amount=60, verification_status='verified', correction_reason='synthetic', idempotency_key='child', db_path=path))['valuation']['id']
|
||||
added = ok(s.create_manual_asset('Synthetic new', 20, '2026-09-30', 'other', db_path=path))['manual_asset']['id']
|
||||
result = ok(s.refresh_settlement_draft_targets(d['id'], d['version'], 'refresh actor', db_path=path))
|
||||
review = result['review']
|
||||
property_row = next(x for x in review['items'] if x['source_id'] == manual)
|
||||
assert property_row['amount'] == 110 and not property_row['review_needed']
|
||||
account_row = next(x for x in review['items'] if x['source_id'] == account)
|
||||
assert account_row['valuation_id'] == val and account_row['valuation_id'] != child
|
||||
assert account_row['review_needed']
|
||||
assert any(x['source_id'] == added and x['amount'] == 20 for x in review['items'])
|
||||
ok(s.deactivate_manual_asset(added, db_path=path))
|
||||
refreshed = ok(s.refresh_settlement_draft_targets(d['id'], result['draft']['version'], 'refresh actor', db_path=path))
|
||||
retired = next(x for x in refreshed['items'] if x['source_id'] == added)
|
||||
assert retired['included'] is False and retired['exclusion_reason']
|
||||
assert retired['selected_by'] == 'refresh actor'
|
||||
|
||||
|
||||
@pytest.mark.parametrize('action', ['memo', 'cancel', 'refresh', 'selection'])
|
||||
def test_any_draft_mutation_clears_all_approval_version_state(case, action):
|
||||
path, account, val, draft = case
|
||||
approve(path, draft)
|
||||
before = ok(s.get_settlement_draft(draft['id'], db_path=path))['draft']
|
||||
assert before['approval_version'] == draft['version'] and before['approved_at']
|
||||
if action == 'memo':
|
||||
result = s.update_settlement_draft(draft['id'], draft['version'], 'editor', 'edited', db_path=path)
|
||||
elif action == 'cancel':
|
||||
result = s.cancel_settlement_draft(draft['id'], draft['version'], 'editor', 'synthetic cancellation', db_path=path)
|
||||
elif action == 'refresh':
|
||||
result = s.refresh_settlement_draft_targets(draft['id'], draft['version'], 'editor', db_path=path)
|
||||
else:
|
||||
result = s.set_settlement_draft_item(draft['id'], 'account', account, draft['version'], 'editor', valuation_id=val, db_path=path)
|
||||
changed = ok(result)['draft']
|
||||
assert changed['version'] == draft['version'] + 1
|
||||
assert changed['approval_version'] == 0 and changed['approved_at'] == ''
|
||||
assert all(changed[key] == '' for key in ('approved_by', 'approval_fingerprint', 'approval_reason', 'approval_reference'))
|
||||
assert changed['approval_flags'] == '{}'
|
||||
|
||||
|
||||
def test_finalize_same_key_different_payload_conflicts_without_partial_write(case):
|
||||
path, account, val, draft = case
|
||||
review = approve(path, draft)
|
||||
final = ok(s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final-key', db_path=path))
|
||||
before = counts(path)
|
||||
assert s.finalize_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'final-key', db_path=path) == final
|
||||
conflict = s.finalize_settlement_draft(draft['id'], draft['version'] + 1, review['review_fingerprint'], 'final-key', db_path=path)
|
||||
assert not conflict['success'] and conflict['error_code'] == 'conflict'
|
||||
assert counts(path) == before
|
||||
@@ -0,0 +1,173 @@
|
||||
"""Clause-level acceptance uses real synthetic SQLite and public consumers."""
|
||||
import sqlite3
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def ok(result):
|
||||
assert result['success'], result
|
||||
return result
|
||||
|
||||
|
||||
def account(path, name, **fields):
|
||||
return ok(s.create_ledger_account(name, db_path=path, **fields))['account']['id']
|
||||
|
||||
|
||||
def value(path, aid, key, when, amount, **fields):
|
||||
return ok(s.register_account_valuation(aid, when, amount, idempotency_key=key, verification_status=fields.pop('verification_status', 'verified'), db_path=path, **fields))['valuation']['id']
|
||||
|
||||
|
||||
def choose(path, draft, aid, vid, **fields):
|
||||
return ok(s.set_settlement_draft_item(draft['id'], 'account', aid, draft['version'], 'synthetic selector', valuation_id=vid, db_path=path, **fields))['draft']
|
||||
|
||||
|
||||
def approve(path, draft, **flags):
|
||||
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
|
||||
ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'synthetic reviewer', 'synthetic review reason', 'synthetic reference', db_path=path, **flags))
|
||||
return review
|
||||
|
||||
|
||||
def test_date_metadata_public_contract_names_survive_all_closed_consumers(tmp_path):
|
||||
path = str(tmp_path / 'dates.sqlite3')
|
||||
init_db(path)
|
||||
aid = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan')
|
||||
vid = value(path, aid, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only')
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute("UPDATE account_valuations SET recorded_at='2026-10-08T00:00:00Z' WHERE id=?", (vid,))
|
||||
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
|
||||
d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic late loan statement')
|
||||
review = approve(path, d, allow_manual_substitute=True)
|
||||
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
|
||||
report = ok(s.get_asset_snapshot_report('2026-09-30', db_path=path))
|
||||
series = ok(s.get_net_worth_timeseries('2026-09-01', '2026-09-30', db_path=path))
|
||||
monthly = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
|
||||
expected = {'valuation_date': '2026-10-07', 'date_difference_days': 7, 'date_difference_direction': 'after', 'age_days': -7, 'recorded_at': '2026-10-08T00:00:00Z', 'representative_liability_definition': 'principal_only'}
|
||||
rows = [review['included_items'][0], final['items'][0], report['close_metadata']['applied_evidence'][0], series['points'][0]['applied_evidence'][0], monthly['source_coverage']['assets']['applied_evidence'][0]]
|
||||
for row in rows:
|
||||
assert {key: row.get(key) for key in expected} == expected
|
||||
for row in rows[1:]:
|
||||
assert row['approval']['allow_manual_substitute'] is True
|
||||
assert row['approval']['allow_missing_exclusions'] is False
|
||||
assert row['approval']['allow_carried_before'] is False
|
||||
|
||||
|
||||
def test_representative_integer_precision_no_double_sum_and_scope_rejection(tmp_path):
|
||||
path = str(tmp_path / 'precision.sqlite3')
|
||||
init_db(path)
|
||||
aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
|
||||
other = account(path, 'Synthetic other')
|
||||
amount = 9007199254740993
|
||||
representative = value(path, aid, 'total', '2026-09-30', amount, valuation_kind='total_value')
|
||||
value(path, aid, 'cash', '2026-09-30', 100, valuation_kind='cash_balance')
|
||||
wrong = value(path, other, 'other', '2026-09-30', 10)
|
||||
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
|
||||
review = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
item = next(x for x in review['included_items'] if x['source_id'] == aid)
|
||||
assert item['valuation_id'] == representative and type(item['amount']) is int and item['amount'] == amount
|
||||
assert review['totals']['net_worth'] == amount + 10
|
||||
before = ok(s.get_settlement_draft(d['id'], db_path=path))
|
||||
bad = s.set_settlement_draft_item(d['id'], 'account', aid, d['version'], 'selector', valuation_id=wrong, db_path=path)
|
||||
assert not bad['success'] and bad['error_code'] == 'validation_error'
|
||||
assert ok(s.get_settlement_draft(d['id'], db_path=path)) == before
|
||||
|
||||
|
||||
def test_unverified_substitute_only_finalizes_as_explicit_approved_missing(tmp_path):
|
||||
path = str(tmp_path / 'unverified.sqlite3')
|
||||
init_db(path)
|
||||
aid = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
|
||||
vid = value(path, aid, 'candidate', '2026-10-05', 100, valuation_kind='total_value', verification_status='unverified')
|
||||
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
|
||||
d = choose(path, d, aid, vid, selection_mode='manual_substitute', selection_reason='Synthetic review-only candidate')
|
||||
review = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
assert not review['included_items'] and review['missing_items'][0]['amount'] is None
|
||||
bad = s.approve_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'reviewer', 'reason', 'ref', allow_manual_substitute=True, db_path=path)
|
||||
assert not bad['success']
|
||||
before = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
|
||||
assert not before['success']
|
||||
review = approve(path, d, allow_missing_exclusions=True)
|
||||
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
|
||||
assert not final['items'] and final['excluded_items'][0]['amount'] is None
|
||||
assert final['excluded_items'][0]['candidate_amount'] == 100
|
||||
assert final['totals']['net_worth'] == 0 and final['has_approved_missing']
|
||||
assert not final['contains_substitutions'] and final['close_status'] == 'conditional'
|
||||
|
||||
|
||||
def test_transaction_readiness_is_independent_of_asset_draft_close(tmp_path):
|
||||
path = str(tmp_path / 'separation.sqlite3')
|
||||
init_db(path)
|
||||
aid = account(path, 'Synthetic cash')
|
||||
value(path, aid, 'cash', '2026-09-30', 100)
|
||||
tx = ok(s.create_ledger_transaction('2026-09-15', amount=5, account_id=aid, category='미분류', db_path=path))
|
||||
assert tx['success']
|
||||
ok(s.upsert_review_decision('transaction', tx['transaction']['id'], status='pending', operator='synthetic reviewer', db_path=path))
|
||||
before = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
|
||||
assert before['unresolved_blocker_count'] > 0
|
||||
with sqlite3.connect(path) as c:
|
||||
original = c.execute('SELECT * FROM transactions').fetchall()
|
||||
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
|
||||
review = approve(path, d)
|
||||
final = ok(s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path))
|
||||
after = ok(s.get_monthly_settlement_summary('2026-09', db_path=path))
|
||||
assert final['totals']['net_worth'] == 100 and after['source_coverage']['assets']['is_closed_snapshot']
|
||||
assert after['unresolved_blockers'] == before['unresolved_blockers'] and after['unresolved_blocker_count'] > 0
|
||||
with sqlite3.connect(path) as c:
|
||||
assert c.execute('SELECT * FROM transactions').fetchall() == original
|
||||
|
||||
|
||||
def test_synthetic_september_future_pension_loan_late_exact_warnings_stale_rollback_concurrent_retry(tmp_path):
|
||||
path = str(tmp_path / 'september.sqlite3')
|
||||
init_db(path)
|
||||
cash = account(path, 'Synthetic cash')
|
||||
pension = account(path, 'Synthetic pension', type='investment', liquidity_bucket='investment')
|
||||
loan = account(path, 'Synthetic loan', type='loan', liquidity_bucket='loan')
|
||||
rejected = account(path, 'Synthetic rejected')
|
||||
foreign = account(path, 'Synthetic USD', currency='USD')
|
||||
unknown = ok(s.create_manual_asset('Synthetic undated property', None, '', 'real_estate', db_path=path))['manual_asset']['id']
|
||||
cv = value(path, cash, 'cash', '2026-09-30', 100)
|
||||
pv = value(path, pension, 'pension', '2026-10-05', 120, valuation_kind='total_value')
|
||||
lv = value(path, loan, 'loan', '2026-10-07', 40, valuation_kind='loan_balance', representative_liability_definition='principal_only')
|
||||
value(path, rejected, 'rejected', '2026-09-30', 999, verification_status='rejected')
|
||||
value(path, foreign, 'foreign', '2026-09-30', 77, currency='USD')
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute("UPDATE account_valuations SET recorded_at='2026-10-10T00:00:00Z' WHERE id=?", (cv,))
|
||||
d = ok(s.create_settlement_draft('2026-09', 'creator', 'initial', db_path=path))['draft']
|
||||
first = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
assert first['totals']['net_worth'] == 100
|
||||
d = choose(path, d, pension, pv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 05 pension')
|
||||
d = choose(path, d, loan, lv, selection_mode='manual_substitute', selection_reason='Synthetic Oct 07 loan')
|
||||
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
|
||||
assert review['totals']['net_worth'] == 180
|
||||
assert {x['source_id'] for x in review['missing_items']} == {rejected, foreign, unknown}
|
||||
assert all(x['amount'] is None for x in review['missing_items'])
|
||||
assert any(w['code'] == 'non_krw' for w in review['warnings'])
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute("UPDATE account_valuations SET memo='Synthetic evidence drift' WHERE id=?", (cv,))
|
||||
stale = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
|
||||
assert not stale['success'] and stale['error_code'] == 'conflict'
|
||||
d = choose(path, d, cash, cv)
|
||||
review = approve(path, d, allow_missing_exclusions=True, allow_manual_substitute=True)
|
||||
with sqlite3.connect(path) as c:
|
||||
c.execute("CREATE TRIGGER synthetic_failure BEFORE INSERT ON asset_snapshots BEGIN SELECT RAISE(ABORT, 'synthetic rollback'); END")
|
||||
failed = s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
|
||||
assert not failed['success'] and failed['error_code'] == 'db_error'
|
||||
with sqlite3.connect(path) as c:
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 0
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == 0
|
||||
c.execute('DROP TRIGGER synthetic_failure')
|
||||
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['status'] == 'active'
|
||||
def finish():
|
||||
return s.finalize_settlement_draft(d['id'], d['version'], review['review_fingerprint'], 'final', db_path=path)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
futures = [pool.submit(finish) for _ in range(2)]
|
||||
results = [ok(f.result()) for f in futures]
|
||||
assert results[0] == results[1]
|
||||
final = results[0]
|
||||
assert final['contains_substitutions'] and final['has_approved_missing'] and not final['contains_carried']
|
||||
assert final['totals']['net_worth'] == 180 and len(final['items']) == 3
|
||||
assert {x['valuation_date'] for x in final['items']} == {'2026-09-30', '2026-10-05', '2026-10-07'}
|
||||
assert finish() == final
|
||||
with sqlite3.connect(path) as c:
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshot_runs').fetchone()[0] == 1
|
||||
assert c.execute('SELECT COUNT(*) FROM asset_snapshots').fetchone()[0] == final['inserted_count']
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Acceptance contract for immutable, reason-linked settlement corrections."""
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def ok(r):
|
||||
assert r['success'], r
|
||||
return r
|
||||
|
||||
|
||||
def counts(path):
|
||||
with sqlite3.connect(path) as c:
|
||||
return {table: c.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
|
||||
for table in ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')}
|
||||
|
||||
|
||||
def finish(path, draft):
|
||||
review = ok(s.review_settlement_draft(draft['id'], db_path=path))
|
||||
approved = ok(s.approve_settlement_draft(draft['id'], draft['version'], review['review_fingerprint'], 'synthetic reviewer', 'approved synthetic correction', 'acceptance', db_path=path))
|
||||
return ok(s.finalize_settlement_draft(draft['id'], approved['draft']['version'], review['review_fingerprint'], 'close-' + draft['id'], db_path=path))
|
||||
|
||||
|
||||
def test_correction_links_reason_preserves_prior_rows_and_replays_after_later_correction(tmp_path):
|
||||
path = str(tmp_path / 'correction.sqlite3')
|
||||
init_db(path)
|
||||
aid = ok(s.create_ledger_account('Synthetic cash', db_path=path))['account']['id']
|
||||
first_value = ok(s.register_account_valuation(aid, '2026-09-30', 100, verification_status='verified', idempotency_key='v1', db_path=path))['valuation']['id']
|
||||
original_draft = ok(s.create_settlement_draft('2026-09', 'synthetic', 'draft-1', db_path=path))['draft']
|
||||
original = finish(path, original_draft)
|
||||
original_rows = counts(path)
|
||||
corrected_value = ok(s.correct_account_valuation(first_value, 125, 'synthetic valuation correction', idempotency_key='v2', db_path=path))['valuation']['id']
|
||||
assert counts(path)['asset_snapshot_runs'] == original_rows['asset_snapshot_runs']
|
||||
assert counts(path)['asset_snapshots'] == original_rows['asset_snapshots']
|
||||
|
||||
run_id = original['snapshot_run_id']
|
||||
bad_requests = [('', 'reason'), (run_id, ''), ('does-not-exist', 'reason')]
|
||||
before = counts(path)
|
||||
for i, (parent, reason) in enumerate(bad_requests):
|
||||
result = s.create_settlement_draft('2026-09', 'synthetic', f'invalid-{i}', correction_of_run_id=parent, correction_reason=reason, db_path=path)
|
||||
assert not result['success']
|
||||
assert counts(path) == before
|
||||
ordinary = s.create_settlement_draft('2026-09', 'synthetic', 'ordinary-after-close', db_path=path)
|
||||
assert not ordinary['success'] and ordinary['error_code'] == 'validation_error'
|
||||
assert counts(path) == before
|
||||
|
||||
correction = ok(s.create_settlement_draft('2026-09', 'synthetic', 'correction-1', correction_of_run_id=run_id, correction_reason='synthetic close correction', db_path=path))['draft']
|
||||
active_conflict = s.create_settlement_draft('2026-09', 'synthetic', 'active-conflict', correction_of_run_id=run_id, correction_reason='another linked correction', db_path=path)
|
||||
assert not active_conflict['success'] and active_conflict['error_code'] == 'conflict'
|
||||
assert counts(path)['settlement_drafts'] == before['settlement_drafts'] + [counts(path)['settlement_drafts'][-1]]
|
||||
assert correction['correction_of_run_id'] == run_id
|
||||
selected = ok(s.set_settlement_draft_item(correction['id'], 'account', aid, correction['version'], 'synthetic selector', valuation_id=corrected_value, db_path=path))['draft']
|
||||
corrected = finish(path, selected)
|
||||
assert corrected['correction']['correction_of_run_id'] == run_id
|
||||
assert corrected['correction']['correction_reason'] == 'synthetic close correction'
|
||||
for table, rows in original_rows.items():
|
||||
assert counts(path)[table][:len(rows)] == rows
|
||||
|
||||
third_value = ok(s.correct_account_valuation(corrected_value, 150, 'second synthetic correction', idempotency_key='v3', db_path=path))['valuation']['id']
|
||||
later = ok(s.create_settlement_draft('2026-09', 'synthetic', 'correction-2', correction_of_run_id=corrected['snapshot_run_id'], correction_reason='second synthetic close correction', db_path=path))['draft']
|
||||
later = ok(s.set_settlement_draft_item(later['id'], 'account', aid, later['version'], 'synthetic selector', valuation_id=third_value, db_path=path))['draft']
|
||||
finish(path, later)
|
||||
before_replay = counts(path)
|
||||
replay = s.finalize_settlement_draft(correction['id'], selected['version'], corrected['review_fingerprint'], 'close-' + correction['id'], db_path=path)
|
||||
assert replay == corrected
|
||||
assert counts(path) == before_replay
|
||||
@@ -0,0 +1,106 @@
|
||||
"""Dashboard lifecycle contract tests using Streamlit AppTest when available.
|
||||
|
||||
All data is synthetic; renders must not mutate/create state or databases. This
|
||||
suite deliberately asserts the Issue 15 lifecycle UI is present before it exists.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
VIEW = Path(__file__).parents[1] / "dashboard" / "page_views" / "settlement_drafts.py"
|
||||
|
||||
|
||||
def _app_wrapper(tmp_path: Path) -> Path:
|
||||
wrapper = tmp_path / "settlement_panel_app.py"
|
||||
wrapper.write_text(
|
||||
"import os\nfrom dashboard.page_views.settlement_drafts import render_settlement_drafts\n"
|
||||
"render_settlement_drafts(db_path=os.environ.get(\"JARCA_LEDGER_DB_PATH\"))\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return wrapper
|
||||
|
||||
|
||||
def test_reusable_settlement_draft_panel_exists_and_is_importable(tmp_path):
|
||||
assert VIEW.is_file(), "shared Dashboard settlement draft panel is missing"
|
||||
from streamlit.testing.v1 import AppTest
|
||||
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
|
||||
assert not app.exception
|
||||
|
||||
|
||||
def test_monthly_and_assets_pages_invoke_shared_panel():
|
||||
root = VIEW.parents[1]
|
||||
pages = [p for p in root.rglob("*.py") if p.name in ("monthly_settlement.py", "assets.py")]
|
||||
assert len(pages) == 2
|
||||
for page in pages:
|
||||
assert "render_settlement_drafts" in page.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_streamlit_panel_renders_lifecycle_without_writing_or_creating_db(monkeypatch, tmp_path):
|
||||
pytest.importorskip("streamlit.testing.v1")
|
||||
from streamlit.testing.v1 import AppTest
|
||||
db_path = tmp_path / "dashboard-missing.sqlite3"
|
||||
# Explicit synthetic DB path and no authenticated actor: render only.
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
|
||||
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
|
||||
assert not app.exception
|
||||
page = "\n".join([e.value for e in app.markdown] + [e.value for e in app.caption] + [e.value for e in app.info] + [e.label for e in app.button])
|
||||
for expected in ("초안", "검토", "승인", "최종", "취소", "정정", "평가자료", "제외 사유"):
|
||||
assert expected in page
|
||||
assert not db_path.exists(), "render-only Dashboard created a database"
|
||||
|
||||
|
||||
def test_panel_actions_are_explicit_and_selection_state_bound_to_draft_version():
|
||||
source = VIEW.read_text(encoding="utf-8")
|
||||
for action in ("create_settlement_draft", "update_settlement_draft", "cancel_settlement_draft",
|
||||
"refresh_settlement_draft_targets", "set_settlement_draft_item", "review_settlement_draft",
|
||||
"approve_settlement_draft", "finalize_settlement_draft", "register_account_valuation",
|
||||
"list_account_valuations", "correct_account_valuation"):
|
||||
assert action in source, f"missing explicit action {action}"
|
||||
assert "session_state" in source
|
||||
assert "review_fingerprint" in source
|
||||
assert "expected_version" in source or "version, actor" in source
|
||||
assert "transaction" in source.lower() and "거래" in source
|
||||
|
||||
|
||||
def test_closed_draft_view_exposes_immutable_evidence_and_correction_creation():
|
||||
source = VIEW.read_text(encoding="utf-8")
|
||||
for immutable in ("draft_version", "review_fingerprint", "final_run_id", "correction_of_run_id",
|
||||
"basis_date", "created_by", "created_at", "updated_at", "memo"):
|
||||
assert immutable in source, f"closed view omits {immutable}"
|
||||
assert "correction_reason" in source
|
||||
assert "completion" in source or "completed" in source
|
||||
|
||||
|
||||
def test_render_path_contains_no_implicit_lifecycle_mutations():
|
||||
source = VIEW.read_text(encoding="utf-8")
|
||||
# All lifecycle mutations must be behind explicit submit/button branches;
|
||||
# import/render must never invoke them unconditionally.
|
||||
assert "if st.button" in source or "form_submit_button" in source
|
||||
assert "render_settlement_drafts" in source
|
||||
|
||||
|
||||
def test_app_test_create_review_approve_finalize_roundtrip(tmp_path, monkeypatch):
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
from jarca_ledger_mcp.application.assets_service import create_manual_asset
|
||||
db_path = tmp_path / "lifecycle.sqlite3"
|
||||
init_db(str(db_path))
|
||||
seeded = create_manual_asset("Synthetic asset", 125, "2026-09-30", "other", source_note="fixture", db_path=str(db_path))
|
||||
assert seeded.get("success"), seeded
|
||||
monkeypatch.setenv("JARCA_LEDGER_DB_PATH", str(db_path))
|
||||
app = AppTest.from_file(str(_app_wrapper(tmp_path))).run()
|
||||
assert not app.exception
|
||||
app.button[0].click().run()
|
||||
assert not app.exception
|
||||
assert app.button[0].label == "초안 생성 / 정정 초안"
|
||||
assert len(app.selectbox[0].options) == 1
|
||||
assert "active" in " ".join(item.value for item in app.caption)
|
||||
# Exercise read-only review through the rendered explicit button.
|
||||
review = next(button for button in app.button if button.label == "검토 / 새로고침 대상")
|
||||
review.click().run(timeout=10)
|
||||
assert not app.exception
|
||||
assert any("review_fingerprint" in item.value for item in app.caption)
|
||||
assert not app.exception
|
||||
@@ -0,0 +1,137 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).parents[1]
|
||||
DOCS = ROOT / "docs"
|
||||
REQ = DOCS / "requirements/req-003_account-valuation-history.md"
|
||||
|
||||
|
||||
def text(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_settlement_requirement_revision_is_additive_and_traceable() -> None:
|
||||
source = REQ.read_text(encoding="utf-8")
|
||||
_, raw, body = source.split("---", 2)
|
||||
metadata = json.loads(raw)
|
||||
assert metadata["id"] == "REQ-003"
|
||||
assert metadata["revision"] == "1.1"
|
||||
assert metadata["issue"] == "#15"
|
||||
assert metadata["implementation_pr"] == "미정"
|
||||
assert "#12" in body and "#13" in body
|
||||
assert re.findall(r"^### REQ-003\.(\d+) —", body, re.M) == [f"{i:02d}" for i in range(1, 12)]
|
||||
assert re.findall(r"^## (\d+)\. ", body, re.M) == [str(i) for i in range(1, 12)]
|
||||
assert "schema 11→12" in body and "schema 12→13" in body
|
||||
|
||||
|
||||
def test_draft_docs_cover_authority_dates_atomicity_and_read_only_contracts() -> None:
|
||||
docs = "\n".join(text(path) for path in (
|
||||
"docs/requirements/req-003_account-valuation-history.md", "docs/data-model.md",
|
||||
"docs/interface-contract.md", "docs/project-structure.md", "docs/runtime-flow.md",
|
||||
))
|
||||
for marker in ("date_difference_days = valuation_date - basis_date", "age_days", "manual_substitute",
|
||||
"candidate_amount", "authority amount NULL", "refresh_settlement_draft_targets",
|
||||
"BEGIN IMMEDIATE", "persisted response", "one active draft", "correction_reason",
|
||||
"unverified", "no-create", "transaction readiness"):
|
||||
assert marker in docs, marker
|
||||
|
||||
|
||||
def test_new_public_operations_are_fully_listed_from_contract() -> None:
|
||||
interface = text("docs/interface-contract.md")
|
||||
for operation in ("create_settlement_draft", "list_settlement_drafts", "get_settlement_draft",
|
||||
"update_settlement_draft", "refresh_settlement_draft_targets", "set_settlement_draft_item",
|
||||
"review_settlement_draft", "approve_settlement_draft", "finalize_settlement_draft",
|
||||
"cancel_settlement_draft"):
|
||||
assert f"{operation}(" in interface, operation
|
||||
assert "review_fingerprint" in interface and "selection_mode" in interface
|
||||
|
||||
|
||||
def test_settlement_acceptance_cases_are_individually_named_and_real() -> None:
|
||||
import ast
|
||||
req = REQ.read_text(encoding="utf-8").split("---", 2)[2]
|
||||
section = req.split("## 7. 인수 기준", 1)[1].split("## 8. 오류", 1)[0]
|
||||
cases = re.findall(r"^### REQ-003\.11-AC(\d+) — (.+)$", section, re.M)
|
||||
assert len(cases) == 17
|
||||
assert [n for n, _ in cases] == [f"{i:02}" for i in range(1, 18)]
|
||||
files = {str(p.relative_to(ROOT)): p for p in (ROOT / "tests").glob("test_*.py")}
|
||||
for block in re.split(r"(?=^### REQ-003\.11-AC)", section, flags=re.M)[1:]:
|
||||
ref = re.search(r"`(tests/[^`]+\.py)::(test_[A-Za-z0-9_]+)`", block)
|
||||
assert ref, block.splitlines()[0]
|
||||
path, func = ref.groups()
|
||||
assert path in files, path
|
||||
names = {n.name for n in ast.walk(ast.parse(files[path].read_text(encoding="utf-8"))) if isinstance(n, ast.FunctionDef)}
|
||||
assert func in names, (path, func)
|
||||
for label in ("판정 기준", "검증 분류", "상태 불변조건"):
|
||||
assert label in block, (block.splitlines()[0], label)
|
||||
assert "## 12." not in req
|
||||
requirements = req.split("## 5. 요구사항", 1)[1].split("## 6.", 1)[0]
|
||||
assert requirements.count("**영향 범위**") == 11
|
||||
new_requirement = requirements.split("### REQ-003.11 —", 1)[1]
|
||||
for dimension in ("인터페이스", "영속성", "후속 사용 주체", "마이그레이션", "호환성", "배포 전제 조건"):
|
||||
assert f"- {dimension}:" in new_requirement
|
||||
|
||||
|
||||
def test_settlement_metadata_names_match_actual_public_contract() -> None:
|
||||
interface = text('docs/interface-contract.md').split('## settlement-draft-service', 1)[1]
|
||||
model = text('docs/data-model.md').split('## settlement-drafts', 1)[1]
|
||||
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'representative_liability_definition'):
|
||||
assert field in interface and field in model, field
|
||||
for field in ('allow_missing_exclusions', 'allow_carried_before', 'allow_manual_substitute'):
|
||||
assert field in interface
|
||||
assert 'test_date_metadata_public_contract_names_survive_all_closed_consumers' in interface
|
||||
|
||||
|
||||
def test_draft_mcp_signature_documents_internal_database_boundary() -> None:
|
||||
interface = text("docs/interface-contract.md").split("## settlement-draft-service", 1)[1]
|
||||
assert re.search(r"db_path.*(?:internal|service|gateway).*(?:not|omitted|제외).*(?:MCP|inputSchema)", interface, re.I | re.S), \
|
||||
"document must distinguish internal db_path from MCP inputSchema"
|
||||
assert "db_path: str | None = None" in interface
|
||||
service = ast.parse(text("jarca_ledger_mcp/application/settlement_draft_service.py"))
|
||||
names = {n.name for n in ast.walk(service) if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))}
|
||||
assert "create_settlement_draft" in names
|
||||
|
||||
|
||||
def test_legacy_close_fields_are_scoped_separately_from_draft_finalize() -> None:
|
||||
interface = text("docs/interface-contract.md")
|
||||
legacy = interface.split("## D04 snapshot close output contract", 1)[1].split("## settlement-draft-service", 1)[0]
|
||||
draft = interface.split("## settlement-draft-service", 1)[1]
|
||||
assert re.search(r"legacy direct close", legacy, re.I), "exact field list must be scoped to legacy close"
|
||||
assert re.search(r"additive settlement-draft finalize response", legacy, re.I) and re.search(r"preserving common aliases", draft, re.I), "draft finalize output extension and retained common aliases must be distinguished"
|
||||
assert not re.search(r"new.*(?:identical|same).*shape", draft, re.I)
|
||||
|
||||
|
||||
def test_unregistered_liability_contract_separates_registration_from_unknown_debt() -> None:
|
||||
interface = text("docs/interface-contract.md")
|
||||
clause = interface.split("### Draft evidence public fields", 1)[1].split("## ", 1)[0]
|
||||
assert re.search(r"unregistered_liabilities.*(?:no|not).*registered.*liabilit", clause, re.I | re.S)
|
||||
assert re.search(r"not.*(?:unknown|real).*debt", clause, re.I | re.S)
|
||||
assert re.search(r"not.*(?:proof.*debt.free|prove.*debt.free)", clause, re.I | re.S)
|
||||
assert re.search(r"exclu.*(?:missing|completeness)|(?:missing|completeness).*exclu", clause, re.I | re.S)
|
||||
|
||||
|
||||
def test_requirement_authority_scope_distinguishes_approved_drafts_and_automatic_selection() -> None:
|
||||
req = REQ.read_text(encoding="utf-8")
|
||||
constraints = req.split("## 6. 제약과 불변조건", 1)[1].split("## 7.", 1)[0]
|
||||
assert re.search(r"automatic.*`exact`.*`as_of`", constraints, re.I), "future exclusion must explicitly scope automatic exact/as_of selection"
|
||||
assert re.search(r"legacy direct close", constraints, re.I), \
|
||||
"future exclusion must explicitly scope legacy direct close"
|
||||
assert re.search(r"verified.*future.*manual_substitute", constraints, re.I | re.S), \
|
||||
"verified future valuation may only enter an explicitly approved draft substitute"
|
||||
assert all(term in constraints for term in ("reason", "review", "approval", "original")), \
|
||||
"draft substitute must preserve reason, review, approval, and original dates"
|
||||
assert re.search(r"unverified.*rejected.*unknown.*(?:any|all).*path", constraints, re.I | re.S), \
|
||||
"unverified, rejected, or unknown amounts are never authority on any path"
|
||||
assert "future_manual_asset" not in constraints
|
||||
|
||||
|
||||
def test_legacy_metadata_field_lists_are_required_retained_fields_not_exhaustive_response_keys() -> None:
|
||||
interface = text("docs/interface-contract.md")
|
||||
legacy = interface.split("## D04 snapshot close output contract", 1)[1].split("## settlement-draft-service", 1)[0]
|
||||
assert re.search(r"legacy direct close", legacy, re.I)
|
||||
assert re.search(r"required top-level metadata fields", legacy, re.I)
|
||||
assert re.search(r"not (?:an )?exhaustive", legacy, re.I)
|
||||
assert "additive settlement-draft finalize response" in legacy
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Additive draft lineage must preserve legacy persisted evidence shapes."""
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp.application.assets_service import _closed_run_metadata
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field,public_field', [
|
||||
('excluded_evidence', 'excluded_items'), ('carried_evidence', 'carried_items'),
|
||||
])
|
||||
def test_legacy_run_evidence_does_not_gain_draft_identity_keys(field, public_field):
|
||||
evidence = {'source_type': 'manual_asset', 'source_id': 'synthetic', 'reason': 'missing_valuation_amount', 'currency': 'KRW'}
|
||||
metadata = {'snapshot_run_id': 'legacy-run', 'draft_id': '', 'draft_version': 0, 'review_fingerprint': '', field: [evidence]}
|
||||
result = _closed_run_metadata(metadata)
|
||||
assert result[public_field] == [evidence]
|
||||
assert 'blocking' not in result[public_field][0]
|
||||
assert not {'draft_id', 'draft_version', 'review_fingerprint'} & result[public_field][0].keys()
|
||||
|
||||
|
||||
def test_draft_evidence_retains_its_persisted_lineage_and_selection_reason():
|
||||
evidence = {'source_type': 'account', 'source_id': 'synthetic', 'draft_id': 'draft', 'draft_version': 2, 'review_fingerprint': 'fingerprint', 'selection_mode': 'manual_substitute', 'selection_reason': 'synthetic statement', 'item_version': 2}
|
||||
metadata = {'snapshot_run_id': 'draft-run', 'applied_evidence': [evidence]}
|
||||
result = _closed_run_metadata(metadata)
|
||||
assert result['applied_evidence'] == [evidence]
|
||||
assert result['contains_substitutions']
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Registration semantics for draft unregistered-liability status."""
|
||||
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def ok(result):
|
||||
assert result["success"], result
|
||||
return result
|
||||
|
||||
|
||||
def test_unregistered_liabilities_tracks_registered_targets_not_evidence_completeness(tmp_path):
|
||||
path = str(tmp_path / "synthetic-liability-registration.sqlite3")
|
||||
init_db(path)
|
||||
|
||||
empty = ok(s.create_settlement_draft("2026-09", "synthetic", "empty", db_path=path))["draft"]
|
||||
empty_review = ok(s.review_settlement_draft(empty["id"], db_path=path))
|
||||
assert empty_review["unregistered_liabilities"] is True
|
||||
|
||||
loan = ok(s.create_ledger_account("Synthetic loan", type="loan", liquidity_bucket="loan", db_path=path))["account"]["id"]
|
||||
loan_draft = ok(s.create_settlement_draft("2026-10", "synthetic", "loan", db_path=path))["draft"]
|
||||
loan_review = ok(s.review_settlement_draft(loan_draft["id"], db_path=path))
|
||||
loan_item = next(item for item in loan_review["items"] if item["source_id"] == loan)
|
||||
assert loan_item["amount"] is None
|
||||
assert loan_review["unregistered_liabilities"] is False
|
||||
|
||||
manual = ok(s.create_manual_asset("Synthetic manual liability", 50, "2026-10-31", "loan", db_path=path))["manual_asset"]["id"]
|
||||
refreshed = ok(s.refresh_settlement_draft_targets(loan_draft["id"], loan_draft["version"], "synthetic", db_path=path))["draft"]
|
||||
manual_item = next(item for item in ok(s.get_settlement_draft(refreshed["id"], db_path=path))["items"] if item["source_id"] == manual)
|
||||
excluded = ok(s.set_settlement_draft_item(refreshed["id"], "manual_asset", manual, refreshed["version"], "synthetic", included=False, exclusion_reason="synthetic excluded", db_path=path))["draft"]
|
||||
excluded_review = ok(s.review_settlement_draft(excluded["id"], db_path=path))
|
||||
assert manual_item["amount"] == 50
|
||||
assert next(item for item in excluded_review["excluded_items"] if item["source_id"] == manual)
|
||||
assert excluded_review["unregistered_liabilities"] is False
|
||||
assert excluded_review["missing_items"]
|
||||
|
||||
|
||||
def test_manual_only_frozen_liability_registration_and_missing_exclusion_are_independent(tmp_path):
|
||||
path = str(tmp_path / "manual-only-frozen-liability.sqlite3")
|
||||
init_db(path)
|
||||
manual = ok(s.create_manual_asset("Manual-only debt", 75, "2026-10-31", "loan", db_path=path))["manual_asset"]["id"]
|
||||
draft = ok(s.create_settlement_draft("2026-10", "synthetic", "manual-only", db_path=path))["draft"]
|
||||
# Freeze the target list without any registered loan account.
|
||||
frozen = ok(s.refresh_settlement_draft_targets(draft["id"], draft["version"], "synthetic", db_path=path))["draft"]
|
||||
frozen_items = ok(s.get_settlement_draft(frozen["id"], db_path=path))["items"]
|
||||
item = next(item for item in frozen_items if item["source_id"] == manual)
|
||||
assert item["amount"] == 75
|
||||
assert not any(item.get("source_type") == "account" for item in frozen_items)
|
||||
review = ok(s.review_settlement_draft(frozen["id"], db_path=path))
|
||||
assert review["unregistered_liabilities"] is False
|
||||
assert isinstance(review["missing_items"], list)
|
||||
excluded = ok(s.set_settlement_draft_item(frozen["id"], "manual_asset", manual, frozen["version"], "synthetic", included=False, exclusion_reason="not registered", db_path=path))["draft"]
|
||||
excluded_review = ok(s.review_settlement_draft(excluded["id"], db_path=path))
|
||||
assert any(item["source_id"] == manual for item in excluded_review["excluded_items"])
|
||||
assert excluded_review["unregistered_liabilities"] is False
|
||||
assert isinstance(excluded_review["missing_items"], list)
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Exercise settlement MCP lifecycle through the repository's real stdio server."""
|
||||
import asyncio
|
||||
from contextlib import closing
|
||||
import inspect
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
mcp = pytest.importorskip('mcp')
|
||||
from mcp import ClientSession, StdioServerParameters
|
||||
from mcp.client.stdio import stdio_client
|
||||
|
||||
OPS = ('create_settlement_draft', 'list_settlement_drafts', 'get_settlement_draft',
|
||||
'update_settlement_draft', 'refresh_settlement_draft_targets',
|
||||
'set_settlement_draft_item', 'review_settlement_draft',
|
||||
'approve_settlement_draft', 'finalize_settlement_draft', 'cancel_settlement_draft')
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def payload(result):
|
||||
assert not result.isError, result
|
||||
assert len(result.content) == 1 and result.content[0].type == 'text'
|
||||
data = json.loads(result.content[0].text)
|
||||
assert isinstance(data, dict) and isinstance(data.get('success'), bool)
|
||||
return data
|
||||
|
||||
|
||||
def require_fields(value, fields, context):
|
||||
missing = set(fields) - set(value)
|
||||
assert not missing, f'{context} missing required public fields: {sorted(missing)}'
|
||||
|
||||
|
||||
def test_required_public_field_assertion_detects_omission():
|
||||
synthetic_contract_result = {'success': True, 'draft': {}}
|
||||
require_fields(synthetic_contract_result, {'success', 'draft'}, 'stdio result')
|
||||
dropped_field_copy = dict(synthetic_contract_result)
|
||||
dropped_field_copy.pop('draft')
|
||||
with pytest.raises(AssertionError, match='missing required public fields.*draft'):
|
||||
require_fields(dropped_field_copy, {'success', 'draft'}, 'dropped-field mutation control')
|
||||
|
||||
|
||||
def rows(db, table):
|
||||
assert table in {'asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items'}
|
||||
with closing(sqlite3.connect(db)) as connection:
|
||||
return connection.execute(f'SELECT * FROM {table} ORDER BY rowid').fetchall()
|
||||
|
||||
|
||||
def test_real_stdio_startup_and_all_ten_lifecycle_tools(tmp_path):
|
||||
asyncio.run(_exercise_stdio_lifecycle(tmp_path))
|
||||
|
||||
|
||||
async def _exercise_stdio_lifecycle(tmp_path):
|
||||
from jarca_ledger_mcp import service as service
|
||||
db = tmp_path / 'mcp-runtime.sqlite3'
|
||||
env = os.environ.copy()
|
||||
env.update(JARCA_LEDGER_DB_PATH=str(db), JARCA_MCP_TRANSPORT='stdio', PYTHONDONTWRITEBYTECODE='1')
|
||||
# The test owns this bounded stdio child; stdio_client reaps it on context exit.
|
||||
params = StdioServerParameters(command=sys.executable, args=['-B', '-m', 'jarca_ledger_mcp.server'], env=env, cwd=str(ROOT))
|
||||
async with stdio_client(params) as (read, write):
|
||||
async with ClientSession(read, write) as session:
|
||||
initialized = await session.initialize()
|
||||
assert initialized.serverInfo.name == 'jarca-ledger-mcp'
|
||||
listed = await session.list_tools()
|
||||
tool_map = {tool.name: tool for tool in listed.tools}
|
||||
assert set(OPS) <= set(tool_map)
|
||||
for name in OPS:
|
||||
schema = tool_map[name].inputSchema
|
||||
props = schema.get('properties', {})
|
||||
assert 'db_path' not in props
|
||||
parameters = inspect.signature(getattr(service, name)).parameters
|
||||
assert set(props) == set(parameters) - {'db_path'}
|
||||
assert set(schema.get('required', [])) == {key for key, value in parameters.items()
|
||||
if key != 'db_path' and value.default is inspect.Parameter.empty}
|
||||
|
||||
# Seed through a real service database setup, never through MCP stubs.
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
init_db(str(db))
|
||||
account = service.create_ledger_account('Synthetic MCP cash', db_path=str(db))['account']['id']
|
||||
service.register_account_valuation(account, '2026-09-30', 100, verification_status='verified', idempotency_key='mcp-value', db_path=str(db))
|
||||
|
||||
async def call(name, args):
|
||||
return payload(await session.call_tool(name, args))
|
||||
|
||||
made = await call('create_settlement_draft', {'month':'2026-09','created_by':'stdio-test','idempotency_key':'mcp-draft'})
|
||||
assert made['success']
|
||||
require_fields(made, {'success', 'draft', 'items', 'review'}, 'create stdio result')
|
||||
assert made['draft']['status'] == 'active' and made['draft']['version'] == 1
|
||||
draft_id = made['draft']['id']
|
||||
listed_drafts = await call('list_settlement_drafts', {'month':'2026-09'})
|
||||
require_fields(listed_drafts, {'success', 'drafts'}, 'list stdio result')
|
||||
assert listed_drafts['success'] and listed_drafts['drafts'][0]['id'] == draft_id
|
||||
fetched = await call('get_settlement_draft', {'draft_id':draft_id})
|
||||
require_fields(fetched, {'success', 'draft', 'items', 'review'}, 'get stdio result')
|
||||
assert fetched['success'] and fetched['draft']['id'] == draft_id and fetched['draft']['version'] == 1
|
||||
draft = fetched['draft']
|
||||
updated = await call('update_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test','memo':'transport lifecycle'})
|
||||
require_fields(updated, {'success', 'draft', 'items', 'review'}, 'update stdio result')
|
||||
assert updated['success'] and updated['draft']['memo'] == 'transport lifecycle' and updated['draft']['version'] == 2
|
||||
draft = updated['draft']
|
||||
refreshed = await call('refresh_settlement_draft_targets', {'draft_id':draft_id,'expected_version':draft['version'],'updated_by':'stdio-test'})
|
||||
require_fields(refreshed, {'success', 'draft', 'items', 'review'}, 'refresh stdio result')
|
||||
assert refreshed['success'] and refreshed['draft']['version'] == 3
|
||||
draft = refreshed['draft']
|
||||
changed = await call('set_settlement_draft_item', {'draft_id':draft_id,'source_type':'account','source_id':account,'expected_version':draft['version'],'selected_by':'stdio-test','valuation_id':service.list_account_valuations(account, db_path=str(db))['valuations'][0]['id']})
|
||||
require_fields(changed, {'success', 'draft', 'items', 'review'}, 'set-item stdio result')
|
||||
assert changed['success'] and changed['draft']['version'] == 4
|
||||
draft = changed['draft']
|
||||
reviewed = await call('review_settlement_draft', {'draft_id':draft_id})
|
||||
assert reviewed['success'], reviewed
|
||||
require_fields(reviewed, {'success', 'draft_id', 'version', 'review_fingerprint', 'items', 'included_items', 'excluded_items', 'missing_items', 'warnings', 'approval_required', 'totals', 'complete', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'unregistered_liabilities'}, 'review stdio result')
|
||||
selected = reviewed['included_items'][0]
|
||||
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'selection_mode', 'selection_reason', 'selected_by', 'selected_at', 'item_version'):
|
||||
assert field in selected, f'review selected evidence missing {field}'
|
||||
assert selected['valuation_date'] == '2026-09-30' and selected['age_days'] == 0 and selected['date_difference_days'] == 0
|
||||
fingerprint = reviewed['review_fingerprint']
|
||||
approved = await call('approve_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'approved_by':'stdio-test','approval_reason':'transport acceptance','approval_reference':'test'})
|
||||
require_fields(approved, {'success', 'draft', 'items', 'review'}, 'approve stdio result')
|
||||
require_fields(approved['draft'], {'approved_by', 'approved_at', 'approval_reason', 'approval_reference', 'approval_flags'}, 'approved draft')
|
||||
assert approved['success']
|
||||
assert approved['draft']['approved_by'] == 'stdio-test' and approved['draft']['approval_reason'] == 'transport acceptance' and approved['draft']['approval_reference'] == 'test'
|
||||
closed = await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'})
|
||||
require_fields(closed, {'success', 'draft_id', 'draft_version', 'review_fingerprint', 'snapshot_run_id', 'run_id', 'monthly_source_snapshot_id', 'basis_date', 'close_status', 'closed_at', 'source', 'is_closed_snapshot', 'selection_policy', 'inserted_count', 'inserted_snapshot_ids', 'items', 'excluded_items', 'totals', 'contains_substitutions', 'contains_carried', 'has_approved_missing', 'settlement_label', 'warnings', 'carried_items', 'approval', 'correction', 'is_correction', 'correction_of_run_id', 'correction_reason', 'provenance', 'total_assets', 'total_liabilities', 'net_worth'}, 'finalize stdio result')
|
||||
assert closed['success'] and closed['totals']['net_worth'] == 100
|
||||
assert closed['source'] == 'snapshot' and closed['is_closed_snapshot'] is True
|
||||
assert closed['snapshot_run_id'] == closed['run_id'] == closed['monthly_source_snapshot_id']
|
||||
assert closed['approval']['approved_by'] == 'stdio-test'
|
||||
assert closed['approval']['allow_missing_exclusions'] is False and closed['approval']['allow_carried_before'] is False and closed['approval']['allow_manual_substitute'] is False
|
||||
item = closed['items'][0]
|
||||
for field in ('valuation_date', 'recorded_at', 'date_difference_days', 'date_difference_direction', 'age_days', 'verification_status', 'evidence_kind', 'approval', 'draft_id', 'draft_version', 'review_fingerprint'):
|
||||
assert field in item, f'finalized selected evidence missing {field}'
|
||||
provenance = closed['provenance']
|
||||
for field in ('valuation_date', 'age_days', 'carried_forward', 'verification_status', 'evidence_kind', 'approval', 'correction', 'warnings', 'exclusions'):
|
||||
assert field in provenance, f'final provenance missing {field}'
|
||||
assert provenance['valuation_date'] == item['valuation_date'] and provenance['age_days'] == item['age_days']
|
||||
before = (await call('get_settlement_draft', {'draft_id':draft_id}))
|
||||
tables = ('asset_snapshot_runs', 'asset_snapshots', 'settlement_drafts', 'settlement_draft_items')
|
||||
before_replay = {table: rows(db, table) for table in tables}
|
||||
assert await call('finalize_settlement_draft', {'draft_id':draft_id,'expected_version':draft['version'],'review_fingerprint':fingerprint,'idempotency_key':'mcp-close'}) == closed
|
||||
archived = await call('get_settlement_draft', {'draft_id':draft_id})
|
||||
require_fields(archived, {'success', 'draft', 'items', 'final_response'}, 'archived get stdio result')
|
||||
assert archived['final_response'] == closed
|
||||
assert archived == before
|
||||
assert {table: rows(db, table) for table in tables} == before_replay
|
||||
|
||||
second = await call('create_settlement_draft', {'month':'2026-10','created_by':'stdio-test','idempotency_key':'mcp-cancel'})
|
||||
assert second['success']
|
||||
cancelled = await call('cancel_settlement_draft', {'draft_id':second['draft']['id'],'expected_version':second['draft']['version'],'cancelled_by':'stdio-test','reason':'synthetic cancellation'})
|
||||
require_fields(cancelled, {'success', 'draft', 'items', 'review'}, 'cancel stdio result')
|
||||
assert cancelled['success'] and cancelled['draft']['status'] == 'cancelled'
|
||||
assert cancelled['draft']['cancelled_by'] == 'stdio-test' and cancelled['draft']['cancellation_reason'] == 'synthetic cancellation'
|
||||
|
||||
# Legacy close retains required contract fields; this is a subset check, not exact whole-key equality.
|
||||
legacy_account = service.create_ledger_account('Synthetic legacy cash', db_path=str(db))['account']['id']
|
||||
service.register_account_valuation(legacy_account, '2026-11-30', 31, verification_status='verified', idempotency_key='legacy-value', db_path=str(db))
|
||||
preview = await call('preview_asset_snapshot_close', {'basis_date':'2026-11-30','selection_policy':'exact'})
|
||||
assert preview['success'] and preview['source'] == 'live_authority' and preview['is_closed_snapshot'] is False
|
||||
legacy = await call('close_asset_snapshot', {'basis_date':'2026-11-30','selection_policy':'exact','idempotency_key':'legacy-close','allow_missing_exclusions':True,'approved_by':'stdio-test','approval_reason':'legacy transport test','approval_reference':'legacy-test'})
|
||||
legacy_fields = {'snapshot_run_id', 'close_status', 'approval', 'correction', 'warnings', 'excluded_items', 'carried_items', 'provenance', 'valuation_date', 'age_days', 'verification_status', 'evidence_kind', 'monthly_source_snapshot_id'}
|
||||
require_fields(legacy, {'success'} | legacy_fields, 'legacy close stdio result')
|
||||
assert not ({'draft_id', 'draft_version', 'review_fingerprint', 'contains_substitutions', 'contains_carried', 'has_approved_missing'} & set(legacy))
|
||||
assert legacy['monthly_source_snapshot_id'] == legacy['snapshot_run_id']
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Operator feedback distinguishes read failures and partially committed saves."""
|
||||
import pytest
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def panel(monkeypatch, path):
|
||||
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
|
||||
app = AppTest.from_string('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n').run(timeout=20)
|
||||
assert not app.exception
|
||||
return app
|
||||
|
||||
|
||||
def control(elements, label):
|
||||
return next(element for element in elements if element.label == label)
|
||||
|
||||
|
||||
def test_panel_shows_safe_error_when_draft_listing_fails(tmp_path, monkeypatch):
|
||||
path = tmp_path / 'missing.sqlite3'
|
||||
monkeypatch.setattr(settlement_gateway, 'list_settlement_drafts', lambda *args, **kwargs: {
|
||||
'success': False, 'error_code': 'storage_error', 'message': 'SYNTHETIC_PRIVATE_DIAGNOSTIC',
|
||||
})
|
||||
app = panel(monkeypatch, path)
|
||||
assert app.error and 'storage_error' in ' '.join(element.value for element in app.error)
|
||||
assert not any('초안이 없습니다' in element.value for element in app.info)
|
||||
visible = ' '.join(element.value for elements in (app.error, app.info, app.caption) for element in elements)
|
||||
assert 'SYNTHETIC_PRIVATE_DIAGNOSTIC' not in visible
|
||||
assert not path.exists()
|
||||
|
||||
|
||||
def test_panel_reports_partial_save_when_item_update_fails(tmp_path, monkeypatch):
|
||||
path = str(tmp_path / 'partial.sqlite3')
|
||||
init_db(path)
|
||||
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
|
||||
assert s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified',
|
||||
idempotency_key='synthetic-value', db_path=path)['success']
|
||||
app = panel(monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
|
||||
review_key = f"review_result_{draft['id']}"
|
||||
assert app.session_state[review_key]['success']
|
||||
control(app.text_input, '초안 메모 저장').set_value('Synthetic persisted memo')
|
||||
control(app.text_input, '대상 ID').set_value('synthetic-nonexistent-target')
|
||||
control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20)
|
||||
assert not app.exception
|
||||
current = s.get_settlement_draft(draft['id'], db_path=path)
|
||||
assert current['draft']['memo'] == 'Synthetic persisted memo'
|
||||
assert current['draft']['version'] == draft['version'] + 1
|
||||
assert len(current['items']) == 1 and current['items'][0]['source_id'] == account
|
||||
assert app.error
|
||||
assert any('메모는 저장되었지만 항목 변경은 실패했습니다' in element.value for element in app.warning)
|
||||
with pytest.raises(KeyError):
|
||||
app.session_state[review_key]
|
||||
assert not any(element.label in {'검토 결과 승인', '최종 마감'} for element in app.button)
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Real AppTest controls use synthetic SQLite, not mocked gateway results.
|
||||
|
||||
These are lower-level panel tests; existing dashboard authentication suites own
|
||||
entrypoint authentication acceptance.
|
||||
"""
|
||||
import pytest
|
||||
from streamlit.testing.v1 import AppTest
|
||||
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def control(elements, label):
|
||||
return next(e for e in elements if e.label == label)
|
||||
|
||||
|
||||
def app_for(tmp_path, monkeypatch, path):
|
||||
monkeypatch.setenv('JARCA_LEDGER_DB_PATH', str(path))
|
||||
appfile = tmp_path / 'panel.py'
|
||||
appfile.write_text('from dashboard.page_views.settlement_drafts import render_settlement_drafts\nrender_settlement_drafts()\n')
|
||||
app = AppTest.from_file(str(appfile)).run(timeout=20)
|
||||
assert not app.exception
|
||||
return app
|
||||
|
||||
|
||||
def test_panel_selects_after_date_evidence_approves_and_finalizes_actual_database(tmp_path, monkeypatch):
|
||||
path = str(tmp_path / 'panel.sqlite3')
|
||||
init_db(path)
|
||||
account = s.create_ledger_account('Synthetic retirement', type='investment', liquidity_bucket='investment', db_path=path)['account']['id']
|
||||
value = s.register_account_valuation(account, '2026-10-05', 100, valuation_kind='total_value', verification_status='verified', idempotency_key='future', db_path=path)['valuation']['id']
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.text_input, '대상 ID').set_value(account)
|
||||
control(app.text_input, '평가자료 ID (비우면 해제)').set_value(value)
|
||||
control(app.selectbox, '선택 방식').select('manual_substitute')
|
||||
control(app.text_input, '선택 사유').set_value('Synthetic late statement')
|
||||
control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20)
|
||||
assert not app.exception and not app.error
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
assert any('대체 평가자료를 포함한 월정산' in w.value for w in app.warning)
|
||||
control(app.text_input, '승인 사유').set_value('Synthetic reviewer accepted date')
|
||||
control(app.text_input, '승인 근거').set_value('Synthetic reference')
|
||||
control(app.checkbox, '대체자료 승인').check()
|
||||
control(app.button, '검토 결과 승인').click().run(timeout=20)
|
||||
assert not app.exception and not app.error
|
||||
control(app.button, '최종 마감').click().run(timeout=20)
|
||||
assert not app.exception and not app.error
|
||||
# AppTest 1.58 retains pre-rerun controls in the click-run delta tree,
|
||||
# including in a flat synthetic app. Check immediate history here;
|
||||
# control absence below uses a fresh instance, not a claim about live DOM.
|
||||
assert any('불변 이력' in e.value for e in app.markdown)
|
||||
drafts = s.list_settlement_drafts('2026-09', db_path=path)['drafts']
|
||||
assert len(drafts) == 1 and drafts[0]['status'] == 'completed'
|
||||
final = s.get_settlement_draft(drafts[0]['id'], db_path=path)['final_response']
|
||||
assert final['totals']['net_worth'] == 100 and final['contains_substitutions']
|
||||
assert final['items'][0]['selection_reason'] == 'Synthetic late statement'
|
||||
# Reusing the stale tree also raises KeyError for removed selectbox state.
|
||||
# Reopen the real panel against the same completed SQLite draft instead.
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
assert not app.exception and '불변 이력' in ' '.join(e.value for e in app.markdown)
|
||||
assert not any(e.label == '최종 마감' for e in app.button)
|
||||
assert not any(e.label == '검토 결과 승인' for e in app.button)
|
||||
assert not any('현재 승인 대상 fingerprint:' in e.value for e in app.caption)
|
||||
|
||||
|
||||
def test_panel_valuation_listing_shows_real_selected_evidence_ids(tmp_path, monkeypatch):
|
||||
path = str(tmp_path / 'list.sqlite3')
|
||||
init_db(path)
|
||||
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
|
||||
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.text_input, '대상 ID').set_value(account)
|
||||
control(app.button, '평가자료 조회').click().run(timeout=20)
|
||||
assert not app.exception
|
||||
assert vid in ' '.join(e.value for e in app.json)
|
||||
|
||||
|
||||
def test_panel_supports_investment_total_and_explicit_loan_definition(tmp_path, monkeypatch):
|
||||
path = str(tmp_path / 'register.sqlite3')
|
||||
init_db(path)
|
||||
loan = s.create_ledger_account('Synthetic loan', type='loan', liquidity_bucket='loan', db_path=path)['account']['id']
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
assert {'total_value', 'cash_balance'} <= set(control(app.selectbox, '평가 종류').options)
|
||||
control(app.text_input, '평가 등록 계좌 ID').set_value(loan)
|
||||
control(app.text_input, '평가일 YYYY-MM-DD').set_value('2026-09-30')
|
||||
control(app.number_input, '금액').set_value(40)
|
||||
control(app.selectbox, '평가 종류').select('loan_balance')
|
||||
control(app.selectbox, '대표 부채 정의').select('principal_only')
|
||||
control(app.selectbox, '확인 상태').select('verified')
|
||||
control(app.text_input, '등록 idempotency key').set_value('synthetic-loan-value')
|
||||
control(app.button, '평가자료 등록').click().run(timeout=20)
|
||||
assert not app.exception and not app.error
|
||||
values = s.list_account_valuations(loan, db_path=path)['valuations']
|
||||
assert len(values) == 1 and values[0]['representative_liability_definition'] == 'principal_only'
|
||||
assert values[0]['amount'] == 40
|
||||
|
||||
|
||||
def test_panel_edit_invalidates_cached_review_before_approval(tmp_path, monkeypatch):
|
||||
path = str(tmp_path / 'edit.sqlite3')
|
||||
init_db(path)
|
||||
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
|
||||
s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
|
||||
key = f"review_result_{draft['id']}"
|
||||
old = app.session_state[key]['review_fingerprint']
|
||||
assert any(old in e.value and '현재 승인 대상' in e.value for e in app.caption)
|
||||
control(app.text_input, '초안 메모 저장').set_value('Synthetic edited memo')
|
||||
control(app.button, '저장 / 지정 / 포함·제외').click().run(timeout=20)
|
||||
assert not app.exception and not app.error
|
||||
assert not any(old in e.value and '현재 승인 대상' in e.value for e in app.caption)
|
||||
assert not any(e.label == '검토 결과 승인' for e in app.button)
|
||||
assert not any(e.label == '최종 마감' for e in app.button)
|
||||
with pytest.raises(KeyError):
|
||||
app.session_state[key]
|
||||
current = s.get_settlement_draft(draft['id'], db_path=path)['draft']
|
||||
assert current['version'] == draft['version'] + 1 and current['memo'] == 'Synthetic edited memo'
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
control(app.text_input, '승인 사유').set_value('Synthetic fresh review')
|
||||
control(app.text_input, '승인 근거').set_value('Synthetic reference')
|
||||
control(app.button, '검토 결과 승인').click().run(timeout=20)
|
||||
assert not app.exception and not app.error
|
||||
control(app.button, '최종 마감').click().run(timeout=20)
|
||||
assert not app.exception and not app.error
|
||||
assert s.get_settlement_draft(draft['id'], db_path=path)['draft']['status'] == 'completed'
|
||||
|
||||
|
||||
def test_panel_correction_invalidates_review_without_registration_account_input(tmp_path, monkeypatch):
|
||||
path = str(tmp_path / 'correction.sqlite3')
|
||||
init_db(path)
|
||||
account = s.create_ledger_account('Synthetic cash', db_path=path)['account']['id']
|
||||
vid = s.register_account_valuation(account, '2026-09-30', 10, verification_status='verified', idempotency_key='value', db_path=path)['valuation']['id']
|
||||
app = app_for(tmp_path, monkeypatch, path)
|
||||
control(app.button, '초안 생성 / 정정 초안').click().run(timeout=20)
|
||||
control(app.button, '검토 / 새로고침 대상').click().run(timeout=20)
|
||||
draft = s.list_settlement_drafts('2026-09', db_path=path)['drafts'][0]
|
||||
old = app.session_state[f"review_result_{draft['id']}"]['review_fingerprint']
|
||||
assert control(app.text_input, '평가 등록 계좌 ID').value == ''
|
||||
control(app.text_input, '정정할 valuation ID').set_value(vid)
|
||||
control(app.number_input, '정정 금액').set_value(20)
|
||||
control(app.text_input, '평가 정정 사유').set_value('Synthetic correction')
|
||||
control(app.text_input, '평가 정정 키').set_value('synthetic-child')
|
||||
control(app.selectbox, '정정 확인 상태').select('verified')
|
||||
control(app.button, '평가자료 정정').click().run(timeout=20)
|
||||
assert not app.exception and not app.error
|
||||
assert not any(old in e.value and '현재 승인 대상' in e.value for e in app.caption)
|
||||
assert not any(e.label == '검토 결과 승인' for e in app.button)
|
||||
detail = s.get_settlement_draft(draft['id'], db_path=path)
|
||||
assert detail['items'][0]['review_needed'] and detail['items'][0]['valuation_id'] == vid
|
||||
assert len(s.list_account_valuations(account, db_path=path)['valuations']) == 2
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Consumer contracts for immutable draft-close evidence."""
|
||||
import json
|
||||
import sqlite3
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def test_closed_draft_evidence_is_additive_and_strictly_redacted(tmp_path):
|
||||
path = str(tmp_path / "synthetic.sqlite3")
|
||||
init_db(path)
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("INSERT INTO asset_snapshot_runs(id,basis_date,close_status,selection_policy,draft_id,draft_version,review_fingerprint,applied_evidence,excluded_evidence,carried_evidence,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (
|
||||
"run-draft", "2026-09-30", "conditional", "exact", "draft-1", 7, "fingerprint-1",
|
||||
json.dumps([{"source_type":"manual_asset","source_id":"asset-1","name":"Synthetic","amount":100,"currency":"KRW","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1","source_attributes":{"bucket":"real_estate","secret":"hidden"},"approval":{"reference":"approval-1","memo":"hidden"},"unknown":{"credential":"hidden"}}]),
|
||||
json.dumps([{"source_type":"account","source_id":"acct-1","reason":"approved_missing","approval_reference":"ref-2","unknown_secret":"hidden"}]),
|
||||
json.dumps([{"source_type":"manual_asset","source_id":"asset-1","valuation_date":"2026-09-29","age_days":1,"carried_forward":True,"selection_mode":"carried_before","reason":"accepted","selected_by":"reviewer","selected_at":"2026-09-30T01:00:00Z","item_version":3,"verification_status":"verified","source_ref":"ref-1"}]),
|
||||
"2026-09-30T02:00:00Z"))
|
||||
conn.execute("INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,snapshot_run_id,valuation_id,created_at) VALUES('snap-1','2026-09-30','manual_asset','asset-1','Synthetic',100,'KRW','real_estate','run-draft','','2026-09-30T02:00:00Z')")
|
||||
conn.commit()
|
||||
report = service.get_asset_snapshot_report("2026-09-30", db_path=path)
|
||||
assert report["success"]
|
||||
metadata = report["close_metadata"]
|
||||
assert (metadata["draft_id"], metadata["draft_version"], metadata["review_fingerprint"]) == ("draft-1", 7, "fingerprint-1")
|
||||
assert metadata["contains_carried"] is True and metadata["contains_substitutions"] is False and metadata["has_approved_missing"] is True
|
||||
evidence = metadata["applied_evidence"][0]
|
||||
for key in ("valuation_date", "age_days", "selection_mode", "reason", "selected_by", "selected_at", "item_version", "verification_status", "source_ref", "source_attributes", "approval"):
|
||||
assert key in evidence
|
||||
serialized = json.dumps(report)
|
||||
assert "hidden" not in serialized and "credential" not in serialized and "unknown" not in serialized
|
||||
series = service.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path)
|
||||
assert series["points"][0]["draft_id"] == "draft-1"
|
||||
assert series["points"][0]["contains_carried"] is True
|
||||
monthly = service.get_monthly_settlement_summary("2026-09", db_path=path)
|
||||
assert monthly["source_coverage"]["assets"]["draft_id"] == "draft-1"
|
||||
|
||||
|
||||
def test_active_cancelled_drafts_do_not_create_timeseries_points(tmp_path):
|
||||
path = str(tmp_path / "synthetic.sqlite3")
|
||||
init_db(path)
|
||||
result = service.get_net_worth_timeseries("2026-09-01", "2026-09-30", db_path=path)
|
||||
assert result["success"] and result["points"] == []
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Strict selection, conditional authority and public close regressions."""
|
||||
import pytest
|
||||
|
||||
from jarca_ledger_mcp import service as s
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
|
||||
|
||||
def ok(r):
|
||||
assert r['success'], r
|
||||
return r
|
||||
|
||||
|
||||
def setup(tmp_path, currency='KRW'):
|
||||
path = str(tmp_path / 'scope.sqlite3')
|
||||
init_db(path)
|
||||
account = ok(s.create_ledger_account('Synthetic investment', type='investment', currency=currency, liquidity_bucket='investment', db_path=path))['account']['id']
|
||||
return path, account
|
||||
|
||||
|
||||
def valuation(path, account, key='v', **kwargs):
|
||||
values = dict(basis_date='2026-09-30', amount=100, valuation_kind='total_value', verification_status='verified', idempotency_key=key, db_path=path)
|
||||
values.update(kwargs)
|
||||
return ok(s.register_account_valuation(account, **values))['valuation']['id']
|
||||
|
||||
|
||||
def draft(path):
|
||||
return ok(s.create_settlement_draft('2026-09', 'creator', 'create', db_path=path))['draft']
|
||||
|
||||
|
||||
def select(path, d, account, value, **kwargs):
|
||||
return s.set_settlement_draft_item(d['id'], 'account', account, d['version'], 'selector', valuation_id=value, db_path=path, **kwargs)
|
||||
|
||||
|
||||
def test_automatic_selection_preserves_representative_priority_and_verified_status(tmp_path):
|
||||
path, account = setup(tmp_path)
|
||||
representative = valuation(path, account)
|
||||
valuation(path, account, 'cash', amount=30, valuation_kind='cash_balance')
|
||||
valuation(path, account, 'candidate', amount=900, verification_status='unverified')
|
||||
d = draft(path)
|
||||
review = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
row = next(x for x in review['items'] if x['source_id'] == account)
|
||||
assert row['valuation_id'] == representative
|
||||
assert review['totals']['net_worth'] == 100
|
||||
|
||||
|
||||
def test_automatic_ambiguous_representative_remains_missing_until_explicit_choice(tmp_path):
|
||||
path, account = setup(tmp_path)
|
||||
chosen = valuation(path, account)
|
||||
valuation(path, account, 'other', amount=200)
|
||||
d = draft(path)
|
||||
r = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
assert r['missing_items'] and r['totals']['net_worth'] == 0
|
||||
selected = ok(select(path, d, account, chosen))['draft']
|
||||
assert ok(s.review_settlement_draft(selected['id'], db_path=path))['totals']['net_worth'] == 100
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status,mode', [('unverified','exact'), ('unverified','carried_before'), ('rejected','manual_substitute')])
|
||||
def test_invalid_verification_never_selected_as_confirmed_authority(tmp_path, status, mode):
|
||||
path, account = setup(tmp_path)
|
||||
value = valuation(path, account, verification_status=status, basis_date='2026-09-29' if mode == 'carried_before' else '2026-09-30')
|
||||
d = draft(path)
|
||||
result = select(path, d, account, value, selection_mode=mode, selection_reason='synthetic reason')
|
||||
assert not result['success'], result
|
||||
assert ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['version'] == d['version']
|
||||
|
||||
|
||||
def test_non_krw_candidate_is_missing_null_authority_not_converted(tmp_path):
|
||||
path, account = setup(tmp_path, currency='USD')
|
||||
value = valuation(path, account, currency='USD')
|
||||
d = draft(path)
|
||||
ok(select(path, d, account, value))
|
||||
r = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
assert r['missing_items'] and not r['included_items']
|
||||
assert r['items'][0]['amount'] is None
|
||||
assert r['items'][0]['candidate_amount'] == 100
|
||||
assert r['totals']['net_worth'] == 0 and r['warnings']
|
||||
|
||||
|
||||
def test_manual_loan_is_representative_liability_and_future_manual_is_not_substitute(tmp_path):
|
||||
path = str(tmp_path / 'manual-loan.sqlite3')
|
||||
init_db(path)
|
||||
ok(s.create_manual_asset('Synthetic liability', 40, '2026-09-30', 'loan', db_path=path))
|
||||
future = ok(s.create_manual_asset('Future property', 100, '2026-10-05', 'real_estate', db_path=path))['manual_asset']['id']
|
||||
d = draft(path)
|
||||
r = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
assert r['totals']['total_assets'] == 0 and r['totals']['total_liabilities'] == 40
|
||||
result = s.set_settlement_draft_item(d['id'], 'manual', future, d['version'], 'selector', selection_mode='manual_substitute', selection_reason='not a valuation id', db_path=path)
|
||||
assert not result['success'], result
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', [None, True, 1, [], {}])
|
||||
def test_invalid_creator_is_rejected_before_database_creation(tmp_path, bad):
|
||||
path = tmp_path / 'absent' / 'db.sqlite3'
|
||||
result = s.create_settlement_draft('2026-09', bad, 'key', db_path=str(path))
|
||||
assert not result['success'] and result['error_code'] == 'validation_error', result
|
||||
assert not path.parent.exists()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('bad', ['yes', 1, None])
|
||||
def test_approval_flags_require_actual_boolean_and_preserve_state(tmp_path, bad):
|
||||
path, account = setup(tmp_path)
|
||||
valuation(path, account)
|
||||
d = draft(path)
|
||||
r = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
result = s.approve_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'reviewer', 'synthetic reason', 'ref', allow_missing_exclusions=bad, db_path=path)
|
||||
assert not result['success'] and result['error_code'] == 'validation_error', result
|
||||
assert not ok(s.get_settlement_draft(d['id'], db_path=path))['draft']['approved_by']
|
||||
|
||||
|
||||
def test_finalized_substitute_retains_dates_flags_and_public_consumer_evidence(tmp_path):
|
||||
path, account = setup(tmp_path)
|
||||
v = valuation(path, account, basis_date='2026-10-05')
|
||||
d = draft(path)
|
||||
d = ok(select(path, d, account, v, selection_mode='manual_substitute', selection_reason='synthetic after-date'))['draft']
|
||||
r = ok(s.review_settlement_draft(d['id'], db_path=path))
|
||||
ok(s.approve_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'reviewer', 'substitution accepted', 'ref', allow_manual_substitute=True, db_path=path))
|
||||
final = ok(s.finalize_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'final', db_path=path))
|
||||
assert final['contains_substitutions'] is True
|
||||
assert final['has_approved_missing'] is False
|
||||
assert final['contains_carried'] is False
|
||||
assert final['valuation_date'] and 'age_days' in final and 'verification_status' in final and 'evidence_kind' in final
|
||||
report = ok(s.get_asset_snapshot_report('2026-09-30', db_path=path))
|
||||
assert report['net_worth'] == 100
|
||||
assert report['close_metadata']['contains_substitutions'] is True
|
||||
evidence = report['close_metadata']['applied_evidence'][0]
|
||||
assert evidence['valuation_date'] == '2026-10-05'
|
||||
assert evidence['date_difference_days'] == 5 and evidence['selection_mode'] == 'manual_substitute'
|
||||
assert evidence['selection_reason'] == 'synthetic after-date'
|
||||
assert s.finalize_settlement_draft(d['id'], d['version'], r['review_fingerprint'], 'final', db_path=path) == final
|
||||
Reference in New Issue
Block a user