fix: address account valuation review findings
This commit is contained in:
@@ -13,6 +13,8 @@ from .shared import (
|
||||
current_kst_date,
|
||||
date,
|
||||
db,
|
||||
read_db,
|
||||
read_operation,
|
||||
fail,
|
||||
new_id,
|
||||
normalize_date,
|
||||
@@ -34,6 +36,61 @@ def _valuation_policy() -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _canonical_provenance(*, valuation_date: str = "", basis_date: str = "", carried: bool = False,
|
||||
verification_status: str = "", evidence_kind: str = "", warnings: list[dict[str, Any]] | None = None,
|
||||
exclusions: list[dict[str, Any]] | None = None, snapshot_run_id: str = "", close_status: str = "",
|
||||
selection_policy: str = "", approval: dict[str, Any] | None = None,
|
||||
correction: dict[str, Any] | None = None, age_days: int | None = None) -> dict[str, Any]:
|
||||
valuation_date = str(valuation_date or "")[:10]
|
||||
basis_date = str(basis_date or "")[:10]
|
||||
calculated_age_days = None
|
||||
if valuation_date and basis_date:
|
||||
try:
|
||||
calculated_age_days = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days
|
||||
except ValueError:
|
||||
pass
|
||||
if age_days is None:
|
||||
age_days = calculated_age_days
|
||||
return {"valuation_date": valuation_date, "age_days": age_days, "carried": bool(carried), "carried_forward": bool(carried),
|
||||
"verification_status": verification_status, "evidence_kind": evidence_kind,
|
||||
"warnings": warnings or [], "exclusions": exclusions or [], "snapshot_run_id": snapshot_run_id,
|
||||
"monthly_source_snapshot_id": snapshot_run_id,
|
||||
"close_status": close_status, "selection_policy": selection_policy,
|
||||
"approval": approval or {}, "correction": correction or {}}
|
||||
|
||||
|
||||
def _public_evidence(item: dict[str, Any], strict: bool = False) -> dict[str, Any]:
|
||||
"""Return evidence metadata using only the canonical public shape."""
|
||||
allowed = {
|
||||
"id", "source_id", "source_type", "authority_source", "valuation_source", "name", "type", "amount", "currency",
|
||||
"liquidity_bucket", "valuation_date", "basis_date", "age_days", "carried", "carried_forward", "verification_status", "evidence_kind",
|
||||
"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",
|
||||
}
|
||||
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)))}
|
||||
if isinstance(item, list):
|
||||
return [_public_evidence(value, strict) for value in item]
|
||||
return item
|
||||
|
||||
|
||||
def _evidence_provenance(evidence: list[dict[str, Any]], basis_date: str) -> dict[str, Any]:
|
||||
values = [item for item in evidence if item]
|
||||
dates = {str(item.get("valuation_date") or "")[:10] for item in values if item.get("valuation_date")}
|
||||
ages = {item.get("age_days") for item in values if item.get("age_days") is not None}
|
||||
carried = {bool(item.get("carried_forward")) for item in values}
|
||||
statuses = {str(item.get("verification_status") or "") for item in values}
|
||||
kinds = {str(item.get("evidence_kind") or "") for item in values}
|
||||
return {
|
||||
"valuation_date": next(iter(dates), "") if len(dates) == 1 else "mixed" if dates else "",
|
||||
"age_days": next(iter(ages), None) if len(ages) == 1 else None,
|
||||
"carried_forward": next(iter(carried), False) if len(carried) == 1 else bool(any(carried)),
|
||||
"verification_status": next(iter(statuses), "") if len(statuses) == 1 else "mixed" if statuses else "",
|
||||
"evidence_kind": next(iter(kinds), "") if len(kinds) == 1 else "mixed" if kinds else "",
|
||||
}
|
||||
|
||||
|
||||
def _evidence_state_hash(item: dict[str, Any]) -> str:
|
||||
"""Bind idempotency to evidence state without exposing sensitive text."""
|
||||
fields = (
|
||||
@@ -84,6 +141,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
|
||||
"last_synced_at": account.get("last_synced_at", ""),
|
||||
"source_note": account.get("memo", ""),
|
||||
"memo": account.get("memo", ""),
|
||||
"evidence_kind": "account_balance",
|
||||
}
|
||||
if base["currency"] != "KRW":
|
||||
excluded.append({**base, "reason": "non_krw_without_fx"})
|
||||
@@ -145,7 +203,7 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
|
||||
})
|
||||
continue
|
||||
balance = valuation["amount"]
|
||||
base.update({"valuation_id": valuation["id"], "verification_status": "verified", "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "currency": valuation["currency"], "carried_forward": valuation["basis_date"] != basis_date})
|
||||
base.update({"valuation_id": valuation["id"], "verification_status": "verified", "valuation_kind": selected_purpose, "valuation_date": valuation["basis_date"], "currency": valuation["currency"], "carried_forward": valuation["basis_date"] != basis_date, "evidence_kind": "account_valuation", "age_days": (date.fromisoformat(basis_date) - date.fromisoformat(str(valuation["basis_date"])[:10])).days})
|
||||
if candidate_rows:
|
||||
base["unverified_candidates"] = [_public_candidate(v) for v in candidate_rows if v["basis_date"] <= basis_date]
|
||||
|
||||
@@ -177,11 +235,14 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
|
||||
"source_type": "manual_asset",
|
||||
"authority_source": "manual_assets",
|
||||
"name": asset.get("name", ""),
|
||||
"currency": str(asset.get("currency") or "KRW").upper(),
|
||||
"liquidity_bucket": bucket,
|
||||
"valuation_date": asset.get("valuation_date", ""),
|
||||
"source_note": asset.get("source_note", ""),
|
||||
"memo": asset.get("memo", ""),
|
||||
"active": asset.get("active", True),
|
||||
"verification_status": "verified",
|
||||
"evidence_kind": "manual_confirmed",
|
||||
}
|
||||
value = asset.get("valuation_amount")
|
||||
if basis_date:
|
||||
@@ -190,6 +251,7 @@ def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool =
|
||||
excluded.append({**base, "reason": "missing_exact_manual_asset_valuation" if selection_policy == "exact" else "future_or_unknown_manual_asset_valuation"})
|
||||
continue
|
||||
base["carried_forward"] = valuation_date != basis_date
|
||||
base["age_days"] = (date.fromisoformat(basis_date) - date.fromisoformat(valuation_date)).days
|
||||
if value is None:
|
||||
excluded.append({**base, "reason": "missing_valuation_amount"})
|
||||
continue
|
||||
@@ -208,7 +270,11 @@ def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) ->
|
||||
|
||||
|
||||
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
|
||||
rows, _metadata = _snapshot_rows_for_latest_run(conn, basis_date)
|
||||
rows, metadata = _snapshot_rows_for_latest_run(conn, basis_date)
|
||||
evidence_by_key = {}
|
||||
for evidence in metadata.get("applied_evidence", []):
|
||||
key = (evidence.get("source_type", ""), evidence.get("source_id", ""), evidence.get("valuation_id", ""))
|
||||
evidence_by_key[key] = evidence
|
||||
items: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
snap = row_to_dict(row)
|
||||
@@ -216,8 +282,14 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
|
||||
bucket = normalize_liquidity_bucket(snap.get("liquidity_bucket"))
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
evidence = evidence_by_key.get(
|
||||
(snap.get("source_type", ""), snap.get("source_id", ""), snap.get("valuation_id", "")),
|
||||
evidence_by_key.get((snap.get("source_type", ""), snap.get("source_id", ""), ""), {}),
|
||||
)
|
||||
valuation_date = evidence.get("valuation_date") or snap.get("basis_date", "")
|
||||
carried_forward = bool(evidence.get("carried_forward", False))
|
||||
items.append(
|
||||
{
|
||||
_public_evidence({
|
||||
"id": snap.get("id", ""),
|
||||
"source_id": snap.get("source_id", ""),
|
||||
"source_type": snap.get("source_type", ""),
|
||||
@@ -225,25 +297,98 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
|
||||
"valuation_source": snap.get("valuation_source", ""),
|
||||
"name": snap.get("name", ""),
|
||||
"liquidity_bucket": bucket,
|
||||
"amount": int(snap.get("amount") or 0),
|
||||
"valuation_date": snap.get("basis_date", ""),
|
||||
"amount": int(snap["amount"]) if snap.get("amount") is not None else None,
|
||||
"valuation_date": valuation_date,
|
||||
"age_days": evidence.get("age_days"),
|
||||
"carried_forward": carried_forward,
|
||||
"verification_status": evidence.get("verification_status") or snap.get("verification_status", ""),
|
||||
"evidence_kind": evidence.get("evidence_kind") or snap.get("evidence_kind", ""),
|
||||
"source_note": snap.get("source_note", ""),
|
||||
"memo": snap.get("memo", ""),
|
||||
"snapshot_run_id": snap.get("snapshot_run_id", ""),
|
||||
"is_correction": bool(snap.get("is_correction", False)),
|
||||
"correction_of_run_id": snap.get("correction_of_run_id", ""),
|
||||
"correction_reason": snap.get("correction_reason", ""),
|
||||
}
|
||||
})
|
||||
)
|
||||
return items
|
||||
|
||||
|
||||
def _closed_run_metadata(metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Expose immutable run evidence without source notes or memos."""
|
||||
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:
|
||||
if item.get("candidate"):
|
||||
key = (item.get("source_type", ""), item.get("source_id", ""))
|
||||
warning = candidate_warnings.setdefault(key, {"code": "unverified_candidate", "source_type": key[0], "source_id": key[1], "candidates": []})
|
||||
warning["candidates"].append(item["candidate"])
|
||||
else:
|
||||
warnings.append({"code": item.get("reason", "excluded_item"), "source_type": item.get("source_type", ""), "source_id": item.get("source_id", ""), "currency": item.get("currency", "KRW")})
|
||||
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", [])],
|
||||
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
|
||||
"close_status": metadata.get("close_status", ""),
|
||||
"selection_policy": metadata.get("selection_policy", ""),
|
||||
"approval": {k: metadata.get(k, "") for k in ("approved_by", "approved_at", "approval_reason", "approval_reference")},
|
||||
"correction": {k: metadata.get(k, "") for k in ("is_correction", "correction_of_run_id", "correction_reason")},
|
||||
}
|
||||
|
||||
|
||||
def _preview_fingerprint(preview: dict[str, Any], basis: str, selection_policy: str) -> str:
|
||||
"""Opaque authority-state token shared by preview and close."""
|
||||
included = preview.get("_included_items", preview.get("included_items", []))
|
||||
payload = {
|
||||
"basis_date": basis,
|
||||
"selection_policy": selection_policy,
|
||||
"included": [{**{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(item)} for item in included],
|
||||
"excluded": [{**{k: item.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(item)} for item in preview.get("excluded_items", [])],
|
||||
"warnings": [warning for warning in preview.get("warnings", []) if warning.get("code") != "existing_snapshot_rows"],
|
||||
"candidate_state_hashes": preview.get("_candidate_state_hashes", []),
|
||||
}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
||||
|
||||
|
||||
def _provenance_aliases(provenance: dict[str, Any], snapshot_run_id: str = "") -> dict[str, Any]:
|
||||
return {key: provenance.get(key) for key in ("valuation_date", "age_days", "verification_status", "evidence_kind")} | {
|
||||
"monthly_source_snapshot_id": provenance.get("monthly_source_snapshot_id") or snapshot_run_id,
|
||||
}
|
||||
|
||||
|
||||
def _ok_with_provenance_aliases(**payload: Any) -> dict[str, Any]:
|
||||
result = ok(**payload)
|
||||
provenance = result.get("provenance")
|
||||
if isinstance(provenance, dict):
|
||||
result.update(_provenance_aliases(provenance, result.get("snapshot_run_id", "")))
|
||||
return result
|
||||
|
||||
|
||||
def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
|
||||
warnings = _closed_run_metadata(metadata)["warnings"]
|
||||
row_by_key = {(row["source_type"], row["source_id"]): row_to_dict(row) for row in rows}
|
||||
for evidence in metadata.get("applied_evidence", []):
|
||||
if not evidence.get("carried_forward"):
|
||||
continue
|
||||
row = row_by_key.get((evidence.get("source_type", ""), evidence.get("source_id", "")), {})
|
||||
valuation_date = str(evidence.get("valuation_date") or "")[:10]
|
||||
if valuation_date and valuation_date < basis_date:
|
||||
warnings.append({"code": "stale_valuation", "source_type": evidence.get("source_type", ""), "source_id": evidence.get("source_id", ""), "name": row.get("name", ""), "valuation_date": valuation_date, "basis_date": basis_date})
|
||||
return warnings
|
||||
|
||||
|
||||
def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_items: list[dict[str, Any]] | None = None) -> dict[str, Any]:
|
||||
bucket_totals = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
||||
bucket_counts = {bucket: 0 for bucket in ASSET_BUCKET_ORDER}
|
||||
for item in included_items:
|
||||
bucket = normalize_liquidity_bucket(item.get("liquidity_bucket"))
|
||||
amount = int(item.get("amount") or 0)
|
||||
raw_amount = item.get("amount")
|
||||
if raw_amount is None:
|
||||
continue
|
||||
amount = int(raw_amount)
|
||||
bucket_counts[bucket] += 1
|
||||
bucket_totals[bucket] += amount
|
||||
total_assets = sum(bucket_totals[bucket] for bucket in ASSET_BUCKETS)
|
||||
@@ -298,9 +443,10 @@ def create_manual_asset(name: str, valuation_amount: int | None, valuation_date:
|
||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||
|
||||
|
||||
@read_operation
|
||||
def list_manual_assets(active_only: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
where = "WHERE active=1" if active_only else ""
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
||||
assets = [{**row_to_dict(row), "authority_source": "manual_assets"} for row in rows]
|
||||
return ok(manual_assets=assets, count=len(assets), valuation_policy=_valuation_policy())
|
||||
@@ -404,19 +550,23 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_
|
||||
"id": candidate_id,
|
||||
"state_hash": _valuation_row_state_hash(candidate_row),
|
||||
})
|
||||
public_excluded = [_public_evidence(item) for item in excluded]
|
||||
return {
|
||||
"basis_date": basis,
|
||||
"source": "live_authority",
|
||||
"provenance": _canonical_provenance(basis_date=basis, warnings=warnings, exclusions=public_excluded, selection_policy=selection_policy),
|
||||
"is_closed_snapshot": False,
|
||||
"snapshot_count": 0,
|
||||
"existing_snapshot_row_count": int(existing_rows),
|
||||
"would_be_correction": bool(existing_rows),
|
||||
"warnings": warnings,
|
||||
"_candidate_state_hashes": candidate_state_hashes,
|
||||
**summary,
|
||||
"_included_items": included,
|
||||
**{**summary, "included_items": [_public_evidence(item) for item in included], "excluded_items": public_excluded},
|
||||
}
|
||||
|
||||
|
||||
@read_operation
|
||||
def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]:
|
||||
if selection_policy not in {"exact", "as_of"}:
|
||||
return fail("validation_error", "selection_policy must be exact or as_of")
|
||||
@@ -424,8 +574,9 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
|
||||
preview.pop("_included_items", None)
|
||||
preview["selection_policy"] = selection_policy
|
||||
approval_eligible = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
|
||||
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
|
||||
@@ -433,14 +584,15 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
|
||||
if other_blocking:
|
||||
preview["close_status"] = "incomplete_draft"
|
||||
elif approval_eligible or carried_items:
|
||||
preview["close_status"] = "conditional"
|
||||
preview["close_status"] = "incomplete_draft"
|
||||
else:
|
||||
preview["close_status"] = "complete_exact"
|
||||
preview["preview_fingerprint"] = _preview_fingerprint(preview, basis, selection_policy)
|
||||
preview.pop("_candidate_state_hashes", None)
|
||||
return ok(**preview)
|
||||
|
||||
|
||||
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None, selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "") -> dict[str, Any]:
|
||||
def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str = "", correction_reason: str = "", db_path: str | None = None, selection_policy: str = "exact", idempotency_key: str = "", allow_missing_exclusions: bool = False, allow_as_of_carry_forward: bool = False, approved_by: str = "", approval_reason: str = "", approval_reference: str = "", preview_fingerprint: str = "") -> dict[str, Any]:
|
||||
if selection_policy not in {"exact", "as_of"}:
|
||||
return fail("validation_error", "selection_policy must be exact or as_of")
|
||||
try:
|
||||
@@ -452,6 +604,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
|
||||
if preview_fingerprint and preview_fingerprint != _preview_fingerprint(preview, basis, selection_policy):
|
||||
return fail("authority_drift", "asset authority changed since preview", basis_date=basis)
|
||||
latest_rows, latest_meta = _snapshot_rows_for_latest_run(conn, basis)
|
||||
legacy_snapshot_rows = bool(latest_rows and not latest_meta.get("snapshot_run_id"))
|
||||
if legacy_snapshot_rows and not correction_reason.strip():
|
||||
@@ -464,7 +618,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
missing_exclusions = [w for w in preview["warnings"] if w.get("blocking", True) and w.get("code", "").startswith(("missing_", "future_", "unknown_", "unverified", "ambiguous_"))]
|
||||
carried_items = [item for item in preview["included_items"] if item.get("carried_forward")]
|
||||
conditional = bool(missing_exclusions or carried_items)
|
||||
fingerprint_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["included_items"]], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]}
|
||||
fingerprint_items = preview.get("_included_items", preview["included_items"])
|
||||
fingerprint_payload = {"basis_date": basis, "source_note": source_note, "memo": memo, "correction_reason": correction_reason, "selection_policy": selection_policy, "allow_missing_exclusions": allow_missing_exclusions, "allow_as_of_carry_forward": allow_as_of_carry_forward, "approved_by": approved_by, "approval_reason": approval_reason, "approval_reference": approval_reference, "included": [{**{k: i.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in fingerprint_items], "excluded": [{**{k: i.get(k) for k in ("source_type", "source_id", "reason", "valuation_date", "verification_status")}, "state_hash": _evidence_state_hash(i)} for i in preview["excluded_items"]], "warnings": [warning for warning in preview["warnings"] if warning.get("code") != "existing_snapshot_rows"], "candidate_state_hashes": preview["_candidate_state_hashes"]}
|
||||
fingerprint = hashlib.sha256(json.dumps(fingerprint_payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()).hexdigest()
|
||||
if missing_exclusions and (not allow_missing_exclusions or not approved_by.strip() or not approval_reason.strip() or not approval_reference.strip()):
|
||||
return fail("conditional_approval_required", "missing exclusions require explicit approval metadata and allow_missing_exclusions")
|
||||
@@ -474,9 +629,46 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
existing_run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE idempotency_key=?", (idempotency_key,)).fetchone()
|
||||
if existing_run:
|
||||
if existing_run["request_fingerprint"] == fingerprint:
|
||||
return ok(reused=True, snapshot_run_id=existing_run["id"], close_status=existing_run["close_status"], basis_date=basis)
|
||||
replay_rows, replay_meta = _snapshot_rows_for_latest_run(conn, basis)
|
||||
replay_items = [_public_evidence(item) for item in replay_meta.get("applied_evidence", [])] or _snapshot_items(conn, basis)
|
||||
replay_warnings = _closed_warnings(replay_meta, basis, replay_rows)
|
||||
replay_public = _closed_run_metadata(replay_meta)
|
||||
replay_public["warnings"] = replay_warnings
|
||||
replay_summary = _summarize_valuation_items(replay_items, replay_public["excluded_items"])
|
||||
replay_provenance = _evidence_provenance(replay_meta.get("applied_evidence", []), basis)
|
||||
approval = replay_public["approval"]
|
||||
correction = replay_public["correction"]
|
||||
return _ok_with_provenance_aliases(
|
||||
basis_date=basis, source="snapshot", is_closed_snapshot=True,
|
||||
snapshot_run_id=existing_run["id"],
|
||||
correction_of_run_id=correction["correction_of_run_id"],
|
||||
correction_reason=correction["correction_reason"],
|
||||
is_correction=correction["is_correction"],
|
||||
closed_at=existing_run["created_at"],
|
||||
close_status=existing_run["close_status"],
|
||||
selection_policy=existing_run["selection_policy"],
|
||||
inserted_count=len(replay_rows),
|
||||
inserted_snapshot_ids=[row["id"] for row in replay_rows],
|
||||
warnings=replay_warnings,
|
||||
carried_items=replay_public["carried_items"],
|
||||
approval=approval, correction=correction,
|
||||
provenance=_canonical_provenance(
|
||||
valuation_date=replay_provenance["valuation_date"],
|
||||
age_days=replay_provenance["age_days"],
|
||||
carried=replay_provenance["carried_forward"],
|
||||
verification_status=replay_provenance["verification_status"],
|
||||
evidence_kind=replay_provenance["evidence_kind"],
|
||||
basis_date=basis, snapshot_run_id=existing_run["id"],
|
||||
close_status=existing_run["close_status"],
|
||||
selection_policy=existing_run["selection_policy"],
|
||||
warnings=replay_warnings,
|
||||
exclusions=replay_public["excluded_items"],
|
||||
approval=approval, correction=correction,
|
||||
),
|
||||
**replay_summary,
|
||||
)
|
||||
return fail("idempotency_conflict", "snapshot close idempotency payload differs")
|
||||
included = preview["included_items"]
|
||||
included = preview.pop("_included_items", preview["included_items"])
|
||||
# A conditional close may legitimately contain zero snapshot rows.
|
||||
# The run record, not row count, is the authoritative close marker.
|
||||
is_correction = bool(latest_meta.get("snapshot_run_id") or legacy_snapshot_rows)
|
||||
@@ -497,7 +689,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
item.get("source_type", ""),
|
||||
item.get("source_id", item.get("id", "")),
|
||||
item.get("name", ""),
|
||||
int(item.get("amount") or 0),
|
||||
int(item["amount"]) if item.get("amount") is not None else None,
|
||||
item.get("currency", "KRW"),
|
||||
normalize_liquidity_bucket(item.get("liquidity_bucket")),
|
||||
item.get("authority_source", item.get("source_type", "")),
|
||||
@@ -517,8 +709,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
),
|
||||
)
|
||||
snapshot_ids.append(snapshot_id)
|
||||
summary = _summarize_valuation_items(included, [])
|
||||
applied_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")} for item in included]
|
||||
applied_evidence = [{k: item.get(k) for k in ("id", "source_type", "source_id", "authority_source", "name", "type", "liquidity_bucket", "active", "amount", "valuation_amount", "last_balance", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind") if item.get(k) is not None} for item in included]
|
||||
excluded_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "reason", "currency")} for item in preview.get("excluded_items", [])]
|
||||
excluded_evidence.extend(
|
||||
{
|
||||
@@ -536,13 +727,27 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
if warning.get("code") == "unverified_candidate"
|
||||
for candidate in warning.get("candidates", [])
|
||||
)
|
||||
carried_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "verification_status")} for item in included if item.get("carried_forward")]
|
||||
carried_evidence = [{k: item.get(k) for k in ("source_type", "source_id", "amount", "currency", "valuation_id", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind")} for item in included if item.get("carried_forward")]
|
||||
summary = _summarize_valuation_items([_public_evidence(item) for item in included], excluded_evidence)
|
||||
try:
|
||||
conn.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) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (run_id, basis, "conditional" if conditional else "complete_exact", selection_policy, idempotency_key, approved_by, ts if approved_by else "", approval_reason, approval_reference, correction_of_run_id, correction_reason.strip(), json.dumps(applied_evidence, ensure_ascii=False, sort_keys=True), json.dumps(excluded_evidence, ensure_ascii=False, sort_keys=True), json.dumps(carried_evidence, ensure_ascii=False, sort_keys=True), fingerprint, ts))
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis)
|
||||
return ok(
|
||||
approval = {
|
||||
"approved_by": approved_by,
|
||||
"approved_at": ts if approved_by else "",
|
||||
"approval_reason": approval_reason,
|
||||
"approval_reference": approval_reference,
|
||||
}
|
||||
correction = {
|
||||
"is_correction": is_correction,
|
||||
"correction_of_run_id": correction_of_run_id,
|
||||
"correction_reason": correction_reason.strip(),
|
||||
}
|
||||
verification_statuses = {str(item.get("verification_status") or "") for item in included}
|
||||
evidence_kinds = {str(item.get("evidence_kind") or "") for item in included}
|
||||
return _ok_with_provenance_aliases(
|
||||
basis_date=basis,
|
||||
source="snapshot",
|
||||
is_closed_snapshot=True,
|
||||
@@ -556,6 +761,23 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
inserted_count=len(snapshot_ids),
|
||||
inserted_snapshot_ids=snapshot_ids,
|
||||
warnings=preview["warnings"],
|
||||
carried_items=carried_evidence,
|
||||
approval=approval,
|
||||
correction=correction,
|
||||
provenance=_canonical_provenance(
|
||||
valuation_date=(preview["included_items"][0].get("valuation_date", "") if preview["included_items"] else ""),
|
||||
basis_date=basis,
|
||||
carried=bool(carried_evidence),
|
||||
verification_status=(verification_statuses.pop() if len(verification_statuses) == 1 else "mixed" if verification_statuses else ""),
|
||||
evidence_kind=(evidence_kinds.pop() if len(evidence_kinds) == 1 else "mixed" if evidence_kinds else ""),
|
||||
snapshot_run_id=run_id,
|
||||
close_status="conditional" if conditional else "complete_exact",
|
||||
selection_policy=selection_policy,
|
||||
warnings=preview["warnings"],
|
||||
exclusions=excluded_evidence,
|
||||
approval=approval,
|
||||
correction=correction,
|
||||
),
|
||||
**summary,
|
||||
)
|
||||
|
||||
@@ -583,58 +805,99 @@ def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, nam
|
||||
return ok(asset_snapshot=row_to_dict(row))
|
||||
|
||||
|
||||
@read_operation
|
||||
def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None, selection_policy: str = "exact") -> dict[str, Any]:
|
||||
if selection_policy not in {"exact", "as_of"}:
|
||||
return fail("validation_error", "selection_policy must be exact or as_of")
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
with db(db_path) as conn:
|
||||
try:
|
||||
basis = normalize_date(basis_date, default_today=True) if basis_date else current_kst_date().isoformat()
|
||||
except Exception:
|
||||
return fail("validation_error", f"invalid date: {basis_date}")
|
||||
with read_db(db_path) as conn:
|
||||
rows, close_metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||
has_close_run = bool(close_metadata.get("snapshot_run_id"))
|
||||
snapshots = _snapshot_items(conn, basis) if rows else []
|
||||
legacy_snapshots = snapshots if rows and not has_close_run else []
|
||||
if has_close_run:
|
||||
summary = _summarize_valuation_items(snapshots, [])
|
||||
return ok(
|
||||
run_public = _closed_run_metadata(close_metadata)
|
||||
run_public["warnings"] = _closed_warnings(close_metadata, basis, rows)
|
||||
summary = _summarize_valuation_items(snapshots, run_public["excluded_items"])
|
||||
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
|
||||
return _ok_with_provenance_aliases(
|
||||
basis_date=basis,
|
||||
source="asset_snapshots",
|
||||
is_closed_snapshot=True,
|
||||
snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
|
||||
snapshot_count=len(snapshots),
|
||||
close_metadata=close_metadata,
|
||||
close_metadata=run_public,
|
||||
selection_policy=close_metadata.get("selection_policy", selection_policy),
|
||||
close_status=close_metadata.get("close_status", "complete_exact"),
|
||||
warnings=run_public["warnings"],
|
||||
carried_items=run_public["carried_items"],
|
||||
approval=run_public["approval"],
|
||||
correction=run_public["correction"],
|
||||
provenance=_canonical_provenance(
|
||||
valuation_date=evidence_provenance["valuation_date"],
|
||||
age_days=evidence_provenance["age_days"],
|
||||
carried=evidence_provenance["carried_forward"],
|
||||
verification_status=evidence_provenance["verification_status"],
|
||||
evidence_kind=evidence_provenance["evidence_kind"],
|
||||
basis_date=basis, snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
|
||||
close_status=close_metadata.get("close_status", "complete_exact"),
|
||||
selection_policy=close_metadata.get("selection_policy", selection_policy),
|
||||
warnings=run_public["warnings"], exclusions=run_public["excluded_items"],
|
||||
approval=run_public["approval"], correction=run_public["correction"],
|
||||
),
|
||||
**summary,
|
||||
)
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn, basis, selection_policy)
|
||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn, basis_date=basis, selection_policy=selection_policy)
|
||||
included = account_items + manual_items
|
||||
excluded = account_excluded + manual_excluded
|
||||
if not included and not excluded and legacy_snapshots:
|
||||
summary = _summarize_valuation_items(legacy_snapshots, [])
|
||||
return ok(
|
||||
basis_date=basis,
|
||||
source="legacy_asset_snapshots",
|
||||
selection_policy=selection_policy,
|
||||
close_status="legacy_read_only",
|
||||
is_closed_snapshot=False,
|
||||
snapshot_count=len(legacy_snapshots),
|
||||
legacy_snapshot_row_count=len(legacy_snapshots),
|
||||
close_metadata={"legacy": True, "snapshot_run_id": ""},
|
||||
**summary,
|
||||
preview = _snapshot_preview_from_conn(conn, basis, selection_policy)
|
||||
included = preview.get("_included_items", [])
|
||||
if not included and not preview["excluded_items"] and legacy_snapshots:
|
||||
public_legacy = [_public_evidence(item) for item in legacy_snapshots]
|
||||
null_items = [item for item in public_legacy if item.get("amount") is None]
|
||||
null_excluded = [{**item, "reason": "missing_amount"} for item in null_items]
|
||||
summary = _summarize_valuation_items(public_legacy, null_excluded)
|
||||
return _ok_with_provenance_aliases(
|
||||
basis_date=basis, source="legacy_asset_snapshots", selection_policy=selection_policy,
|
||||
close_status="incomplete_draft" if null_items else close_metadata.get("close_status", "legacy_read_only"), is_closed_snapshot=False,
|
||||
provenance=_canonical_provenance(basis_date=basis, snapshot_run_id="", close_status="legacy_read_only", selection_policy=selection_policy),
|
||||
snapshot_count=len(legacy_snapshots), legacy_snapshot_row_count=len(legacy_snapshots),
|
||||
close_metadata={"legacy": True, "snapshot_run_id": ""}, **summary,
|
||||
)
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
return ok(
|
||||
public_included = [_public_evidence(item) for item in included]
|
||||
public_excluded = preview["excluded_items"]
|
||||
carried_items = [item for item in public_included if item.get("carried_forward")]
|
||||
warnings = preview["warnings"]
|
||||
approval = {}
|
||||
correction = {}
|
||||
evidence_provenance = _evidence_provenance(included, basis)
|
||||
summary = _summarize_valuation_items(public_included, public_excluded)
|
||||
return _ok_with_provenance_aliases(
|
||||
basis_date=basis,
|
||||
source="live_authority",
|
||||
selection_policy=selection_policy,
|
||||
close_status="incomplete_draft" if excluded else "complete_exact",
|
||||
close_status=preview["close_status"] if "close_status" in preview else ("incomplete_draft" if (public_excluded or carried_items) else "complete_exact"),
|
||||
provenance=_canonical_provenance(
|
||||
valuation_date=evidence_provenance["valuation_date"], age_days=evidence_provenance["age_days"],
|
||||
carried=evidence_provenance["carried_forward"], verification_status=evidence_provenance["verification_status"],
|
||||
evidence_kind=evidence_provenance["evidence_kind"], basis_date=basis,
|
||||
close_status=preview["close_status"] if "close_status" in preview else "",
|
||||
selection_policy=selection_policy, warnings=warnings, exclusions=public_excluded,
|
||||
approval=approval, correction=correction,
|
||||
),
|
||||
is_closed_snapshot=False,
|
||||
snapshot_count=0,
|
||||
legacy_snapshot_row_count=len(legacy_snapshots),
|
||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||
**summary,
|
||||
invalid_bucket_accounts=[warning for warning in warnings if warning.get("code") == "invalid_liquidity_bucket"],
|
||||
warnings=warnings,
|
||||
carried_items=carried_items,
|
||||
approval=approval,
|
||||
correction=correction,
|
||||
**{**summary, "included_items": public_included, "excluded_items": public_excluded},
|
||||
)
|
||||
|
||||
|
||||
@read_operation
|
||||
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
|
||||
try:
|
||||
months_int = max(1, min(int(months), 120))
|
||||
@@ -649,7 +912,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
start_norm = date(year, month, 1).isoformat()
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
basis_rows = conn.execute(
|
||||
"""SELECT DISTINCT basis_date FROM asset_snapshots
|
||||
WHERE basis_date>=? AND basis_date<=?
|
||||
@@ -660,7 +923,11 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
for row in basis_rows:
|
||||
basis = row["basis_date"]
|
||||
items = _snapshot_items(conn, basis)
|
||||
summary = _summarize_valuation_items(items, [])
|
||||
_, metadata = _snapshot_rows_for_latest_run(conn, basis)
|
||||
run_public = _closed_run_metadata(metadata)
|
||||
run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall())
|
||||
run_provenance = _evidence_provenance(metadata.get("applied_evidence", []), basis)
|
||||
summary = _summarize_valuation_items(items, run_public["excluded_items"])
|
||||
points.append(
|
||||
{
|
||||
"basis_date": basis,
|
||||
@@ -669,18 +936,42 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
"net_worth": summary["net_worth"],
|
||||
"source": "asset_snapshots",
|
||||
"item_count": len(items),
|
||||
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
|
||||
"close_status": metadata.get("close_status", ""),
|
||||
"selection_policy": metadata.get("selection_policy", ""),
|
||||
"warnings": run_public["warnings"],
|
||||
"excluded_items": run_public["excluded_items"],
|
||||
"carried_items": run_public["carried_items"],
|
||||
"approval": run_public["approval"],
|
||||
"correction": run_public["correction"],
|
||||
"provenance": _canonical_provenance(
|
||||
valuation_date=run_provenance["valuation_date"],
|
||||
age_days=run_provenance["age_days"],
|
||||
carried=run_provenance["carried_forward"],
|
||||
verification_status=run_provenance["verification_status"],
|
||||
evidence_kind=run_provenance["evidence_kind"],
|
||||
basis_date=basis,
|
||||
snapshot_run_id=metadata.get("snapshot_run_id", ""),
|
||||
close_status=metadata.get("close_status", ""),
|
||||
selection_policy=metadata.get("selection_policy", ""),
|
||||
warnings=run_public["warnings"],
|
||||
exclusions=run_public["excluded_items"],
|
||||
approval=run_public["approval"],
|
||||
correction=run_public["correction"],
|
||||
),
|
||||
}
|
||||
)
|
||||
return ok(start_date=start_norm, end_date=end_norm, months=months_int, points=points, count=len(points), valuation_policy=_valuation_policy())
|
||||
|
||||
|
||||
@read_operation
|
||||
def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
||||
"""Summarize assets/liabilities from shared valuation authority sources.
|
||||
|
||||
Existing REQ-005 keys are preserved. When manual_assets is empty, totals and
|
||||
source remain equivalent to the original accounts.last_balance behavior.
|
||||
"""
|
||||
with db(db_path) as conn:
|
||||
with read_db(db_path) as conn:
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||
included = account_items + manual_items
|
||||
|
||||
Reference in New Issue
Block a user