diff --git a/dashboard/page_views/monthly_settlement.py b/dashboard/page_views/monthly_settlement.py index a56fb3c..3d3854c 100644 --- a/dashboard/page_views/monthly_settlement.py +++ b/dashboard/page_views/monthly_settlement.py @@ -284,6 +284,8 @@ def page_monthly_settlement(db_path: str) -> None: st.write(label, snapshot.get(key) if snapshot.get(key) is not None else "미보존") rows = snapshot.get("cashflow_items", []) if isinstance(rows, list): st.dataframe([{k: row.get(k) for k in ("cashflow_kind", "category", "subcategory", "amount", "transaction_count", "currency")} for row in rows if isinstance(row, dict)]) + st.markdown("**저장된 헤더 근거**") + _render_asset_evidence({key: snapshot.get(key) for key in ("excluded_items", "carried_items", "approval")}) source_snapshot = snapshot.get("source_snapshot") if isinstance(snapshot.get("source_snapshot"), dict) else {} asset_evidence = snapshot.get("items") if isinstance(snapshot.get("items"), list) else [] source_assets = source_snapshot.get("assets") if isinstance(source_snapshot.get("assets"), list) else [] diff --git a/docs/interface-contract.md b/docs/interface-contract.md index 62543da..f2dbe22 100644 --- a/docs/interface-contract.md +++ b/docs/interface-contract.md @@ -499,6 +499,21 @@ The six totals are integer root fields in comprehensive review and saved-run rea A review response has root-level `review_fingerprint`, `cashflow_policy`, `cashflow_items`, `cashflow_blockers`, and `source_snapshot`. The active draft getter nests its current review under `review`. For a completed draft, `get_settlement_draft` exposes the persisted close response under `final_response`; `finalize_settlement_draft` returns that close response at root, including on idempotent replay. A saved-run reader instead returns the stored snapshot at root with `source: settlement_snapshot`, `run_id`, and six totals. `cashflow_items` is a required list in comprehensive review, close and saved-reader results (empty is valid): each entry has `cashflow_kind` (income/expense/excluded string), `category` and `subcategory` (strings, including blanks), signed integer `amount`, positive integer `transaction_count`, and string `currency`. `cashflow_blockers` is a required review/close list with string `transaction_id` and `reason` entries; successful close requires it to be empty. The saved reader does not synthesize a blocker list from old records. `cashflow_policy` is the normalized policy object for comprehensive responses, but null for the legacy saved reader. `source_snapshot` is the preserved transaction/asset input object for comprehensive responses and an empty object for legacy reads. Version metadata is preserved, not inferred from today's ledger. The following JSON examples show selected fields rather than exhaustive response payloads. +#### Saved-reader additive asset metadata + +`get_monthly_settlement_snapshot(month="", run_id="", db_path=None)` adds the following four root fields to a successful saved-run response, using the same strict public evidence projection as the asset close reader: + +| Field | Type | Meaning / privacy | +|---|---|---| +| `excluded_items` | list of objects | Persisted excluded evidence projected through the public evidence allowlist; may include source identity, reason/status/date, candidate metadata and state hash when present. | +| `carried_items` | list of objects | Persisted carried evidence projected through the same allowlist. This is the persisted carried-evidence collection, not a recomputation from current valuations. | +| `applied_evidence` | list of objects | Persisted applied evidence projected through the same allowlist, including only allowlisted evidence attributes when present. | +| `approval` | object | Approval projection: `approved_by`, `approved_at`, `approval_reason`, `approval_reference`; legacy row-only records without run-header approval metadata return `{}`. | + +Evidence objects are not a fixed full-row schema: keys are optional. `_closed_run_metadata` uses the same key allowlist recursively for nested dictionaries and lists; unknown keys (including `source_note`, `memo`, credential and raw-payload keys) are dropped, while allowlisted scalar values are preserved. Candidate metadata and source attributes may therefore retain persisted values under allowlisted keys. This is not a content-based secret redactor: callers must not persist sensitive values under public keys such as `name` or `reason`. Do not treat these fields as permission to expose a complete database row. The approval object is limited to `approved_by`, `approved_at`, `approval_reason`, and `approval_reference`; row-only legacy records return `{}`, while run headers preserve the stored approval values, including empty or null values. + +For run-header-backed records, these collections and approval are projected from the immutable run metadata, rather than inferred from snapshot rows. Thus rows are not the origin of `applied_evidence`, `excluded_items`, or `carried_items`. Exact `run_id` and latest-month reads use the saved run/rows and stored metadata only: they do not write, initialize/migrate the database, consult live ledger authority, or recompute current selections. Legacy schema-13 snapshots with row-only evidence and no run header remain readable: the additive four fields use sensible empty arrays (`excluded_items`, `carried_items`, `applied_evidence`) and empty approval object, while preserving legacy status/version behavior and the six stored-total fields as `null` (they were not persisted). This additive metadata does not change the asset-only `get_asset_snapshot_report` result or its existing schema/version contract. + Successful root review example, including a negative signed expense (refund): ```json diff --git a/jarca_ledger_mcp/application/monthly_settlement_service.py b/jarca_ledger_mcp/application/monthly_settlement_service.py index ddd8b0f..0c083e2 100644 --- a/jarca_ledger_mcp/application/monthly_settlement_service.py +++ b/jarca_ledger_mcp/application/monthly_settlement_service.py @@ -248,7 +248,10 @@ def get_monthly_settlement_snapshot(month: str = "", run_id: str = "", db_path: if not isinstance(policy, dict): raise ValueError() policy = _normalize_cashflow_policy(policy) - return ok(source="settlement_snapshot", snapshot_run_id=rid, run_id=rid, settlement_month=field("settlement_month") or basis_date[:7], basis_date=basis_date, currency=field("currency"), is_closed_snapshot=True, is_comprehensive=comprehensive, legacy=legacy, aggregation_version=field("aggregation_version") if comprehensive else None, snapshot_schema_version=field("snapshot_schema_version") if comprehensive else None, cashflow_policy=policy, cashflow_items=cashflows, items=safe_items, source_snapshot=source, totals=totals, **totals, close_status=(field("close_status") or metadata.get("close_status", "legacy_read_only")), correction={"is_correction": bool(field("correction_of_run_id") or metadata.get("is_correction")), "correction_of_run_id": field("correction_of_run_id") or metadata.get("correction_of_run_id", ""), "correction_reason": field("correction_reason") or metadata.get("correction_reason", "")}) + from .assets_service import _closed_run_metadata + public_metadata = _closed_run_metadata(metadata) + header_evidence = {key: public_metadata[key] for key in ("excluded_items", "carried_items", "applied_evidence", "approval")} + return ok(**header_evidence, source="settlement_snapshot", snapshot_run_id=rid, run_id=rid, settlement_month=field("settlement_month") or basis_date[:7], basis_date=basis_date, currency=field("currency"), is_closed_snapshot=True, is_comprehensive=comprehensive, legacy=legacy, aggregation_version=field("aggregation_version") if comprehensive else None, snapshot_schema_version=field("snapshot_schema_version") if comprehensive else None, cashflow_policy=policy, cashflow_items=cashflows, items=safe_items, source_snapshot=source, totals=totals, **totals, close_status=(field("close_status") or metadata.get("close_status", "legacy_read_only")), correction={"is_correction": bool(field("correction_of_run_id") or metadata.get("is_correction")), "correction_of_run_id": field("correction_of_run_id") or metadata.get("correction_of_run_id", ""), "correction_reason": field("correction_reason") or metadata.get("correction_reason", "")}) except DatabaseNotInitializedError: return fail("db_not_initialized", "ledger database is not initialized") except Exception: diff --git a/jarca_ledger_mcp/application/settlement_cashflow_service.py b/jarca_ledger_mcp/application/settlement_cashflow_service.py index 313dff2..ff56502 100644 --- a/jarca_ledger_mcp/application/settlement_cashflow_service.py +++ b/jarca_ledger_mcp/application/settlement_cashflow_service.py @@ -51,7 +51,7 @@ def _review_cashflow(conn, month: str, policy: dict) -> dict: {CASHFLOW_KIND_SQL} AS originalcashflow_kind, lc.cashflow_kind AS category_cashflow_kind, lc.active AS category_active FROM transactions t LEFT JOIN ledger_categories lc - ON lc.category=t.category AND lc.subcategory=t.subcategory + ON lc.category=t.category AND lc.subcategory=t.subcategory AND lc.active=1 WHERE t.status='active' AND t.transaction_date>=? AND t.transaction_date