Preserve inactive-category fallback and saved settlement header evidence
This commit is contained in:
@@ -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 []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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<?
|
||||
ORDER BY t.transaction_date,t.id""", (start.isoformat(), end.isoformat())).fetchall()
|
||||
savings = {(x["category"], x["subcategory"]) for x in policy["savings_categories"]}
|
||||
|
||||
@@ -440,6 +440,37 @@ def test_saved_reader_retains_asset_authority_and_liability_flags(tmp_path):
|
||||
assert asset.get("status") == "ready"
|
||||
|
||||
|
||||
def test_saved_reader_projects_safe_header_exclusion_carry_and_approval_evidence(tmp_path):
|
||||
path = dbfile(tmp_path)
|
||||
saved = ok(close_draft(path, new_draft(path)))
|
||||
excluded = [{"source_type":"account", "source_id":"missing-account-41", "reason":"missing_verified_exact_valuation",
|
||||
"valuation_date":"2026-09-27", "arbitrary_metadata":{"password":"DO-NOT-LEAK"}}]
|
||||
carried = [{"source_type":"account", "source_id":"carried-account-42", "reason":"as_of_carry_forward",
|
||||
"valuation_date":"2026-09-27", "arbitrary_metadata":{"token":"DO-NOT-LEAK"}}]
|
||||
approval = {"approved_by":"synthetic-reviewer", "approval_reason":"synthetic conditional approval",
|
||||
"approval_reference":"synthetic-ref", "allow_missing_exclusions":True,
|
||||
"allow_as_of_carry_forward":True, "private_note":"DO-NOT-LEAK"}
|
||||
with sqlite3.connect(path) as conn:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET close_status='conditional',excluded_evidence=?,carried_evidence=?,approved_by=?,approval_reason=?,approval_reference=? WHERE id=?",
|
||||
(json.dumps(excluded), json.dumps(carried), approval["approved_by"], approval["approval_reason"], approval["approval_reference"], saved["run_id"]))
|
||||
conn.commit()
|
||||
before = list(conn.iterdump())
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
latest = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
|
||||
assert exact == latest
|
||||
assert exact["run_id"] == saved["run_id"] and exact["close_status"] == "conditional"
|
||||
assert exact["excluded_items"][0]["source_id"] == "missing-account-41"
|
||||
assert exact["excluded_items"][0]["reason"] == "missing_verified_exact_valuation"
|
||||
assert exact["carried_items"][0]["source_id"] == "carried-account-42"
|
||||
assert exact["carried_items"][0]["valuation_date"] == "2026-09-27"
|
||||
assert exact["applied_evidence"]
|
||||
assert exact["approval"]["approval_reason"] == "synthetic conditional approval"
|
||||
encoded = json.dumps(exact, sort_keys=True)
|
||||
assert "DO-NOT-LEAK" not in encoded and "private_note" not in encoded and "arbitrary_metadata" not in encoded
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema_version,aggregation_version", [(1, None), (1, ""), (1, "unknown-version"), (2, "comprehensive-v1")])
|
||||
def test_saved_reader_rejects_unsupported_comprehensive_versions_without_writes(tmp_path, schema_version, aggregation_version):
|
||||
path = dbfile(tmp_path)
|
||||
@@ -476,3 +507,106 @@ def test_saved_reader_does_not_mislabel_partial_version_metadata_as_legacy(tmp_p
|
||||
legacy = ok(service.get_monthly_settlement_snapshot(run_id=saved["run_id"], db_path=path))
|
||||
assert legacy["legacy"] and not legacy["is_comprehensive"]
|
||||
assert all(value is None for value in legacy["totals"].values())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema_thirteen_conditional_close(tmp_path):
|
||||
"""Execute immutable pre-comprehensive public APIs, without another checkout."""
|
||||
import importlib
|
||||
import importlib.abc
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
revision = "f67c07adf01d1f17415bf7cf836f46997ca6f21c"
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
namespace = "_legacy_schema_thirteen"
|
||||
names = subprocess.check_output(
|
||||
["git", "ls-tree", "-r", "--name-only", revision, "jarca_ledger_mcp"],
|
||||
cwd=root, text=True,
|
||||
).splitlines()
|
||||
blobs = {
|
||||
name: subprocess.check_output(["git", "show", f"{revision}:{name}"], cwd=root)
|
||||
for name in names if name.endswith(".py")
|
||||
}
|
||||
|
||||
class BaselineModules(importlib.abc.MetaPathFinder, importlib.abc.Loader):
|
||||
def find_spec(self, fullname, path=None, target=None):
|
||||
if fullname != namespace and not fullname.startswith(namespace + "."):
|
||||
return None
|
||||
relative = fullname[len(namespace):].replace(".", "/")
|
||||
candidate = "jarca_ledger_mcp" + relative
|
||||
package = candidate + "/__init__.py"
|
||||
module = candidate + ".py"
|
||||
if package in blobs:
|
||||
return importlib.util.spec_from_loader(fullname, self, is_package=True)
|
||||
if module in blobs:
|
||||
return importlib.util.spec_from_loader(fullname, self)
|
||||
return None
|
||||
|
||||
def create_module(self, spec):
|
||||
return None
|
||||
|
||||
def exec_module(self, module):
|
||||
relative = module.__name__[len(namespace):].replace(".", "/")
|
||||
candidate = "jarca_ledger_mcp" + relative
|
||||
filename = candidate + ("/__init__.py" if hasattr(module, "__path__") else ".py")
|
||||
module.__file__ = f"git:{revision}:{filename}"
|
||||
exec(compile(blobs[filename], module.__file__, "exec"), module.__dict__)
|
||||
|
||||
finder = BaselineModules()
|
||||
sys.meta_path.insert(0, finder)
|
||||
try:
|
||||
old = importlib.import_module(namespace + ".service")
|
||||
old_db = importlib.import_module(namespace + ".db")
|
||||
path = str(tmp_path / "baseline-schema-thirteen.sqlite3")
|
||||
old_db.init_db(path)
|
||||
missing = ok(old.create_ledger_account("Synthetic missing", "bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
carried = ok(old.create_ledger_account("Synthetic carry", "bank", liquidity_bucket="cash", db_path=path))["account"]["id"]
|
||||
ok(old.register_account_valuation(carried, "2026-09-27", 100, verification_status="verified", idempotency_key="baseline-carry", db_path=path))
|
||||
closed = ok(old.close_asset_snapshot(
|
||||
"2026-09-30", selection_policy="as_of", idempotency_key="baseline-close",
|
||||
allow_missing_exclusions=True, allow_as_of_carry_forward=True,
|
||||
approved_by="synthetic-reviewer", approval_reason="synthetic conditional approval",
|
||||
approval_reference="synthetic-ref", db_path=path,
|
||||
))
|
||||
original = ok(old.get_asset_snapshot_report("2026-09-30", db_path=path))
|
||||
yield path, missing, carried, closed, original
|
||||
finally:
|
||||
sys.meta_path.remove(finder)
|
||||
for name in list(sys.modules):
|
||||
if name == namespace or name.startswith(namespace + "."):
|
||||
del sys.modules[name]
|
||||
|
||||
|
||||
def test_schema_thirteen_saved_reader_retains_real_conditional_header_without_writes(schema_thirteen_conditional_close):
|
||||
path, missing, carried, closed, original = schema_thirteen_conditional_close
|
||||
init_db(path)
|
||||
assert ok(service.get_asset_snapshot_report("2026-09-30", db_path=path)) == original
|
||||
with sqlite3.connect(path) as conn:
|
||||
before = list(conn.iterdump())
|
||||
exact = ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path))
|
||||
latest = ok(service.get_monthly_settlement_snapshot(month="2026-09", db_path=path))
|
||||
assert exact == latest
|
||||
assert exact["legacy"] and not exact["is_comprehensive"]
|
||||
assert all(value is None for value in exact["totals"].values())
|
||||
assert exact["close_status"] == "conditional"
|
||||
excluded = next(item for item in exact["excluded_items"] if item["source_id"] == missing)
|
||||
assert excluded["reason"]
|
||||
carry = next(item for item in exact["carried_items"] if item["source_id"] == carried)
|
||||
assert carry["valuation_date"] == "2026-09-27"
|
||||
assert exact["applied_evidence"]
|
||||
assert exact["approval"]["approved_by"] == "synthetic-reviewer"
|
||||
with sqlite3.connect(path) as conn:
|
||||
assert list(conn.iterdump()) == before
|
||||
|
||||
|
||||
def test_saved_evidence_privacy_contract_filters_keys_not_allowlisted_scalar_values():
|
||||
from pathlib import Path
|
||||
from jarca_ledger_mcp.application.assets_service import _public_evidence
|
||||
value = {"candidate": {"name": "synthetic public scalar", "memo": "private", "source_attributes": {"reason": "synthetic reason", "private_key": "private"}}, "unrecognized": "private"}
|
||||
assert _public_evidence(value, strict=True) == {"candidate": {"name": "synthetic public scalar", "source_attributes": {"reason": "synthetic reason"}}}
|
||||
contract = (Path(__file__).resolve().parents[1] / "docs/interface-contract.md").read_text()
|
||||
assert "allowlisted scalar values are preserved" in contract
|
||||
assert "not a content-based secret redactor" in contract
|
||||
|
||||
@@ -4,6 +4,7 @@ import inspect
|
||||
from pathlib import Path
|
||||
|
||||
from jarca_ledger_mcp import service
|
||||
from test_comprehensive_settlement import schema_thirteen_conditional_close # noqa: F401
|
||||
|
||||
POLICY = "set_settlement_cashflow_policy"
|
||||
READER = "get_monthly_settlement_snapshot"
|
||||
@@ -256,6 +257,43 @@ def test_authenticated_dashboard_renders_saved_comprehensive_snapshot(tmp_path,
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_saved_monthly_panel_renders_real_conditional_exclusion_carry_and_approval(schema_thirteen_conditional_close, monkeypatch): # noqa: F811 - imported pytest fixture
|
||||
import json
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.db import init_db
|
||||
from jarca_ledger_mcp.adapters.streamlit import overview_gateway, reconciliation_gateway, settlement_gateway
|
||||
|
||||
path, missing, carried, closed, _ = schema_thirteen_conditional_close
|
||||
# Migrate the authentic schema-13 baseline in place, then exercise the real
|
||||
# service through its public Streamlit gateway; no saved response is mocked.
|
||||
init_db(path)
|
||||
snapshot = settlement_gateway.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path)
|
||||
assert snapshot["success"]
|
||||
excluded = next(row for row in snapshot["excluded_items"] if row["source_id"] == missing)
|
||||
carry = next(row for row in snapshot["carried_items"] if row["source_id"] == carried)
|
||||
approval = snapshot["approval"]
|
||||
assert approval
|
||||
|
||||
monkeypatch.setattr(overview_gateway, "get_monthly_settlement_summary", lambda *a, **k: {"success": True, "month": "2026-09", "reports": {}})
|
||||
monkeypatch.setattr(overview_gateway, "list_cross_transfer_review_queue", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(overview_gateway, "list_ledger_import_batches", lambda *a, **k: {"success": True, "import_batches": []})
|
||||
monkeypatch.setattr(overview_gateway, "get_asset_snapshot_report", lambda *a, **k: {"success": False})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_review_operating_indicators", lambda *a, **k: {"success": True, "indicators": {}})
|
||||
monkeypatch.setattr(reconciliation_gateway, "get_balance_reconciliation_report", lambda *a, **k: {"success": True, "items": []})
|
||||
monkeypatch.setattr(settlement_gateway, "list_settlement_drafts", lambda *a, **k: {"success": True, "drafts": []})
|
||||
app = AppTest.from_string(
|
||||
f"from dashboard.page_views.monthly_settlement import page_monthly_settlement\n"
|
||||
f"page_monthly_settlement({path!r})", default_timeout=30).run()
|
||||
assert not app.exception, f"saved panel AppTest failed before field assertions: {app.exception}"
|
||||
body = "\\n".join(str(x.value) for x in list(app.caption) + list(app.markdown) + list(app.text) + list(app.warning) + list(app.info))
|
||||
body += "\\n" + json.dumps([frame.value.to_dict("records") for frame in app.dataframe], ensure_ascii=False, default=str)
|
||||
assert missing in body and excluded["reason"] in body, "saved panel must render excluded_items source_id and reason"
|
||||
assert carried in body and carry["valuation_date"] in body, "saved panel must render carried_items source_id and historical valuation_date"
|
||||
for key in ("approved_by", "approval_reason", "approval_reference"):
|
||||
assert approval[key] in body, f"saved panel must render approval.{key} from the saved response"
|
||||
|
||||
|
||||
def test_draft_cashflow_blocker_renders_safe_transaction_id_and_reason(monkeypatch):
|
||||
from streamlit.testing.v1 import AppTest
|
||||
from jarca_ledger_mcp.adapters.streamlit import settlement_gateway
|
||||
|
||||
@@ -244,3 +244,67 @@ def test_nonstring_cashflow_decision_kind_has_validation_error():
|
||||
except Exception as caught:
|
||||
error = caught
|
||||
assert isinstance(error, ValueError), "malformed kind must have the validator's ValueError contract, not an unhandled TypeError"
|
||||
|
||||
|
||||
def test_inactive_category_uses_raw_fallback_through_public_close(tmp_path):
|
||||
path = str(tmp_path / "inactive-category-fallback.sqlite3")
|
||||
init_db(path)
|
||||
# A close run requires a nonempty asset valuation; cashflow-only drafts cannot finalize.
|
||||
_ok(service.create_manual_asset("Synthetic close cash", 100, "2026-09-30", "cash", db_path=path))
|
||||
cases = [
|
||||
# SQL fallback is category-based: ordinary legacy categories become expense,
|
||||
# while canonical legacy categories retain their established classifications.
|
||||
("Old exclude", "A", "exclude", "expense", 11, "expense"),
|
||||
("Old income", "B", "income", "expense", 13, "expense"),
|
||||
("Old transfer", "C", "transfer", "expense", 17, "expense"),
|
||||
("Old unknown", "D", "unknown", "income", 19, "expense"),
|
||||
("Old refund", "E", "exclude", "refund", 5, "expense"),
|
||||
("Old movement", "F", "exclude", "transfer", 7, "excluded"),
|
||||
("수입", "Legacy", "exclude", "income", 29, "income"),
|
||||
("Old expense", "H", "expense", "expense", 37, "expense"),
|
||||
("Old investment", "G", "investment", "expense", 31, "expense"),
|
||||
]
|
||||
ids = {}
|
||||
for index, (category, subcategory, kind, raw_type, amount, expected) in enumerate(cases):
|
||||
_add_category(category, subcategory, kind, path)
|
||||
txid = _add_tx(f"2026-09-{index + 1:02d}", raw_type, amount, category, subcategory, path)
|
||||
assert service.delete_ledger_category(category, subcategory, force=True, db_path=path)["success"]
|
||||
ids[txid] = (expected, kind, raw_type, amount)
|
||||
draft = _draft(path)
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
by_id = {row["id"]: row for row in reviewed["source_snapshot"]["transactions"]}
|
||||
for txid, (expected, _kind, raw_type, amount) in ids.items():
|
||||
row = by_id[txid]
|
||||
applied = -amount if raw_type == "refund" else amount
|
||||
assert row["cashflow_kind"] == expected
|
||||
assert row["originalcashflow_kind"] == ("income" if row["category"] == "수입" else ("unknown" if row["category"] == "미분류" else "expense"))
|
||||
assert row["applied_amount"] == applied # excluded movement retains its amount as evidence
|
||||
assert row["sourceprovenance"] == "fallback"
|
||||
assert row["category_cashflow_kind"] is None and row["category_active"] is None
|
||||
assert reviewed["expense_total"] == 123 # 11 + 13 + 17 + 19 - 5 + 31 + 37
|
||||
assert reviewed["income_total"] == 29
|
||||
approved = _ok(service.approve_settlement_draft(draft["id"], draft["version"], reviewed["review_fingerprint"], "reviewer", "synthetic", "ref", db_path=path))
|
||||
closed = _ok(service.finalize_settlement_draft(draft["id"], approved["draft"]["version"], reviewed["review_fingerprint"], "inactive-category-close", db_path=path))
|
||||
snapshot = _ok(service.get_monthly_settlement_snapshot(run_id=closed["snapshot_run_id"], db_path=path))
|
||||
persisted = {row["id"]: row for row in snapshot["source_snapshot"]["transactions"]}
|
||||
for txid in ids:
|
||||
assert persisted[txid]["sourceprovenance"] == "fallback"
|
||||
assert persisted[txid]["category_active"] is None
|
||||
assert persisted[txid]["category_cashflow_kind"] is None
|
||||
assert snapshot["expense_total"] == 123 and snapshot["income_total"] == 29
|
||||
|
||||
|
||||
def test_explicit_decision_classifies_only_unresolved_inactive_fallback(tmp_path):
|
||||
path = str(tmp_path / "inactive-explicit-decision.sqlite3")
|
||||
init_db(path)
|
||||
_add_category("미분류", "Retired", "unknown", path)
|
||||
unresolved = _add_tx("2026-09-10", "expense", 23, "미분류", "Retired", path)
|
||||
assert service.delete_ledger_category("미분류", "Retired", force=True, db_path=path)["success"]
|
||||
draft = _draft(path)
|
||||
policy = _policy([{"category": "미분류", "subcategory": "Retired", "cashflow_kind": "income", "reason": "synthetic explicit decision"}])
|
||||
_ok(_capability("set_settlement_cashflow_policy")(draft["id"], draft["version"], "operator", policy, db_path=path))
|
||||
reviewed = _ok(service.review_settlement_draft(draft["id"], db_path=path))
|
||||
row = next(x for x in reviewed["source_snapshot"]["transactions"] if x["id"] == unresolved)
|
||||
assert row["cashflow_kind"] == "income" and row["decision"]["cashflow_kind"] == "income"
|
||||
assert row["originalcashflow_kind"] == "unknown" and row["sourceprovenance"] == "fallback"
|
||||
assert reviewed["income_total"] == 23
|
||||
|
||||
Reference in New Issue
Block a user