fix: preserve schema 15 compatibility for v0.1.8
This commit is contained in:
@@ -25,15 +25,9 @@ from .shared import (
|
||||
sqlite3,
|
||||
)
|
||||
|
||||
_RUN_HISTORY_UNAVAILABLE = "run history unavailable"
|
||||
|
||||
|
||||
def _snapshot_run_history_unavailable() -> dict[str, Any]:
|
||||
return fail("unsupported", _RUN_HISTORY_UNAVAILABLE)
|
||||
|
||||
def _valuation_policy() -> dict[str, Any]:
|
||||
return {
|
||||
"authority_sources": ["accounts.last_balance"],
|
||||
"authority_sources": ["accounts.last_balance", "manual_assets"],
|
||||
"snapshot_role": "historical_record_layer",
|
||||
"formula": "asset_buckets - loan",
|
||||
"asset_buckets": ASSET_BUCKETS,
|
||||
@@ -166,8 +160,64 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
|
||||
continue
|
||||
balance = account.get("last_balance")
|
||||
if basis_date:
|
||||
base.update({"valuation_id": "", "verification_status": "unavailable", "valuation_kind": "", "valuation_date": "", "evidence_kind": "account_valuation_missing", "source_missing_warning": "account valuation source unavailable"})
|
||||
balance = 0
|
||||
valuation_rows = conn.execute(
|
||||
"SELECT * FROM account_valuations WHERE account_id=? AND UPPER(currency)=? ORDER BY basis_date DESC, recorded_at DESC, id DESC",
|
||||
(account["id"], base["currency"]),
|
||||
).fetchall()
|
||||
leaves = [v for v in valuation_rows if conn.execute("SELECT 1 FROM account_valuations WHERE supersedes_id=?", (v["id"],)).fetchone() is None]
|
||||
if account_type == "investment":
|
||||
# Older imports recorded investment balances as cash_balance;
|
||||
# keep that as a compatibility fallback after the canonical
|
||||
# total_value/balance purposes.
|
||||
purposes = ("total_value", "balance", "cash_balance")
|
||||
elif account_type == "loan":
|
||||
purposes = ("loan_balance", "loan", "balance")
|
||||
else:
|
||||
purposes = ("cash_balance", "balance")
|
||||
selected = None
|
||||
selected_purpose = None
|
||||
candidate_rows = []
|
||||
for purpose in purposes:
|
||||
scoped = [v for v in leaves if v["valuation_kind"] == purpose]
|
||||
if selection_policy == "exact":
|
||||
verified = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] == basis_date]
|
||||
else:
|
||||
eligible = [v for v in scoped if v["verification_status"] == "verified" and v["basis_date"] <= basis_date]
|
||||
latest = max((v["basis_date"] for v in eligible), default="")
|
||||
verified = [v for v in eligible if v["basis_date"] == latest]
|
||||
if len(verified) > 1:
|
||||
invalid.append({**base, "reason": "ambiguous_valuation", "valuation_kind": purpose})
|
||||
verified = []
|
||||
break
|
||||
if verified:
|
||||
selected, selected_purpose = verified[0], purpose
|
||||
break
|
||||
candidate_rows.extend(v for v in scoped if v["verification_status"] == "unverified" and v["basis_date"] <= basis_date and (v["basis_date"] == basis_date if selection_policy == "exact" else True))
|
||||
if selected is None:
|
||||
excluded.append({**base, "reason": "missing_verified_exact_valuation" if selection_policy == "exact" else "missing_verified_valuation", "unverified_candidates": [_public_candidate(v) for v in candidate_rows]})
|
||||
continue
|
||||
valuation = selected
|
||||
candidate_rows.extend(
|
||||
v for v in leaves
|
||||
if v["valuation_kind"] == selected_purpose
|
||||
and v["verification_status"] == "unverified"
|
||||
and v["basis_date"] <= basis_date
|
||||
and (v["basis_date"] == basis_date if selection_policy == "exact" else True)
|
||||
)
|
||||
if account_type == "loan" and not valuation["representative_liability_definition"]:
|
||||
excluded.append({
|
||||
**base,
|
||||
"reason": "missing_representative_liability_definition",
|
||||
"valuation_id": valuation["id"],
|
||||
"valuation_kind": selected_purpose,
|
||||
"valuation_date": valuation["basis_date"],
|
||||
"verification_status": valuation["verification_status"],
|
||||
})
|
||||
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, "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]
|
||||
|
||||
if balance is None:
|
||||
excluded.append({**base, "reason": "missing_last_balance"})
|
||||
@@ -177,6 +227,49 @@ def _account_valuation_items(conn: sqlite3.Connection, basis_date: str = "", sel
|
||||
return included, excluded, invalid
|
||||
|
||||
|
||||
def _manual_asset_valuation_items(conn: sqlite3.Connection, active_only: bool = True, basis_date: str = "", selection_policy: str = "exact") -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||
where = "WHERE active=1" if active_only else ""
|
||||
rows = conn.execute(f"SELECT * FROM manual_assets {where} ORDER BY name").fetchall()
|
||||
included: list[dict[str, Any]] = []
|
||||
excluded: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
asset = row_to_dict(row)
|
||||
if (asset.get("currency") or "KRW").upper() != "KRW":
|
||||
excluded.append({"id": asset.get("id", ""), "source_id": asset.get("id", ""), "source_type": "manual_asset", "reason": "non_krw_without_fx", "currency": asset.get("currency")})
|
||||
continue
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(asset.get("liquidity_bucket"))
|
||||
except ValueError:
|
||||
bucket = "other"
|
||||
base = {
|
||||
"id": asset.get("id", ""),
|
||||
"source_id": asset.get("id", ""),
|
||||
"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:
|
||||
valuation_date = str(asset.get("valuation_date") or "")[:10]
|
||||
if not valuation_date or valuation_date > basis_date or (selection_policy == "exact" and valuation_date != basis_date):
|
||||
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
|
||||
included.append({**base, "amount": int(value), "valuation_amount": int(value)})
|
||||
return included, excluded
|
||||
|
||||
|
||||
def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None:
|
||||
try:
|
||||
@@ -190,11 +283,94 @@ def _persisted_evidence_array(value: Any) -> list[dict[str, Any]] | None:
|
||||
|
||||
def _snapshot_rows_for_run(conn: sqlite3.Connection, run_id: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
|
||||
"""Load one immutable close run and only the rows persisted by that run."""
|
||||
return [], {"run_history_unavailable": True}
|
||||
run = conn.execute(
|
||||
"SELECT rowid AS _run_rowid, * FROM asset_snapshot_runs WHERE id=?", (run_id,)
|
||||
).fetchone()
|
||||
if not run:
|
||||
return [], {}
|
||||
basis_date = run["basis_date"]
|
||||
rows = conn.execute(
|
||||
"SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? AND snapshot_run_id=? ORDER BY created_at ASC, name ASC",
|
||||
(basis_date, run["id"]),
|
||||
).fetchall()
|
||||
total_rows = conn.execute(
|
||||
"SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis_date,)
|
||||
).fetchone()[0]
|
||||
previous_rows = conn.execute(
|
||||
"""SELECT COUNT(*)
|
||||
FROM asset_snapshots AS snapshot
|
||||
LEFT JOIN asset_snapshot_runs AS snapshot_run
|
||||
ON snapshot_run.id=snapshot.snapshot_run_id
|
||||
WHERE snapshot.basis_date=?
|
||||
AND (
|
||||
snapshot.snapshot_run_id IS NULL
|
||||
OR snapshot.snapshot_run_id=''
|
||||
OR snapshot_run.rowid < ?
|
||||
)""",
|
||||
(basis_date, run["_run_rowid"]),
|
||||
).fetchone()[0]
|
||||
evidence = {
|
||||
field: _persisted_evidence_array(run[field])
|
||||
for field in ("applied_evidence", "excluded_evidence", "carried_evidence")
|
||||
}
|
||||
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 "",
|
||||
"close_status": run["close_status"],
|
||||
"selection_policy": run["selection_policy"],
|
||||
"approved_by": run["approved_by"],
|
||||
"approved_at": run["approved_at"],
|
||||
"approval_reason": run["approval_reason"],
|
||||
"approval_reference": run["approval_reference"],
|
||||
"applied_evidence": evidence["applied_evidence"] or [],
|
||||
"excluded_evidence": evidence["excluded_evidence"] or [],
|
||||
"carried_evidence": evidence["carried_evidence"] or [],
|
||||
"evidence_integrity_error": integrity_error,
|
||||
"closed_at": run["created_at"] or "",
|
||||
"previous_snapshot_row_count": previous_rows,
|
||||
"total_snapshot_rows_for_basis_date": total_rows,
|
||||
}
|
||||
return rows, metadata
|
||||
|
||||
|
||||
def _snapshot_rows_for_latest_run(conn: sqlite3.Connection, basis_date: str) -> tuple[list[sqlite3.Row], dict[str, Any]]:
|
||||
return [], {"run_history_unavailable": True}
|
||||
rows = conn.execute("SELECT rowid AS _rowid, * FROM asset_snapshots WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis_date,)).fetchall()
|
||||
run = conn.execute("SELECT * FROM asset_snapshot_runs WHERE basis_date=? ORDER BY rowid DESC LIMIT 1", (basis_date,)).fetchone()
|
||||
if not run:
|
||||
legacy_run_rows = [row for row in rows if row["closed_at"] and row["snapshot_run_id"]]
|
||||
if legacy_run_rows:
|
||||
# Schema 11 persisted run identity only on the snapshot rows. Keep
|
||||
# its rowid-based latest-run rule rather than falling back to live
|
||||
# authority merely because schema 12 has no matching run record.
|
||||
latest = max(
|
||||
legacy_run_rows,
|
||||
key=lambda row: (
|
||||
int(row["_rowid"]),
|
||||
row["closed_at"] or row["created_at"] or "",
|
||||
row["snapshot_run_id"],
|
||||
),
|
||||
)
|
||||
latest_run_id = latest["snapshot_run_id"]
|
||||
latest_rows = [row for row in legacy_run_rows if row["snapshot_run_id"] == latest_run_id]
|
||||
return latest_rows, {
|
||||
"snapshot_run_id": latest_run_id,
|
||||
"legacy_closed": True,
|
||||
"is_correction": bool(latest["is_correction"]),
|
||||
"correction_of_run_id": latest["correction_of_run_id"] or "",
|
||||
"correction_reason": latest["correction_reason"] or "",
|
||||
"close_status": "legacy_read_only",
|
||||
"selection_policy": latest["selection_policy"] or "exact",
|
||||
"closed_at": latest["closed_at"] or latest["created_at"] or "",
|
||||
"total_snapshot_rows_for_basis_date": len(rows),
|
||||
}
|
||||
return rows, {"snapshot_run_id": "", "is_correction": False, "correction_of_run_id": "", "correction_reason": "", "total_snapshot_rows_for_basis_date": len(rows)} if rows else {}
|
||||
return _snapshot_rows_for_run(conn, run["id"])
|
||||
|
||||
|
||||
def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str, Any]]:
|
||||
@@ -220,7 +396,7 @@ def _snapshot_items(conn: sqlite3.Connection, basis_date: str) -> list[dict[str,
|
||||
"id": snap.get("id", ""),
|
||||
"source_id": snap.get("source_id", ""),
|
||||
"source_type": snap.get("source_type", ""),
|
||||
"authority_source": "account_snapshot",
|
||||
"authority_source": "asset_snapshots",
|
||||
"valuation_source": snap.get("valuation_source", ""),
|
||||
"name": snap.get("name", ""),
|
||||
"currency": snap.get("currency", ""),
|
||||
@@ -317,8 +493,6 @@ def _ok_with_provenance_aliases(**payload: Any) -> dict[str, Any]:
|
||||
|
||||
|
||||
def _closed_warnings(metadata: dict[str, Any], basis_date: str, rows: list[sqlite3.Row]) -> list[dict[str, Any]]:
|
||||
if isinstance(metadata.get("frozen_warnings"), list):
|
||||
return metadata["frozen_warnings"]
|
||||
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", []):
|
||||
@@ -362,7 +536,8 @@ def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_it
|
||||
"liquidity_bucket": bucket,
|
||||
"amount": amount,
|
||||
"account_count": sum(1 for item in included_items if item.get("source_type") == "account" and item.get("liquidity_bucket") == bucket),
|
||||
"item_count": bucket_counts[bucket],
|
||||
"manual_asset_count": sum(1 for item in included_items if item.get("source_type") == "manual_asset" and item.get("liquidity_bucket") == bucket),
|
||||
"item_count": bucket_counts[bucket],
|
||||
"asset_share": round(amount / total_assets, 4) if bucket not in LIABILITY_BUCKETS and total_assets else None,
|
||||
"liability_share": round(abs(amount) / total_liabilities, 4) if bucket in LIABILITY_BUCKETS and total_liabilities else None,
|
||||
"share": round(abs(amount) / denominator, 4) if denominator else None,
|
||||
@@ -380,29 +555,102 @@ def _summarize_valuation_items(included_items: list[dict[str, Any]], excluded_it
|
||||
}
|
||||
|
||||
|
||||
|
||||
def create_manual_asset(*args, **kwargs):
|
||||
return fail("unsupported", "manual assets are no longer supported")
|
||||
def create_manual_asset(name: str, valuation_amount: int | None, valuation_date: str, liquidity_bucket: str, source_note: str = "", memo: str = "", currency: str = "KRW", active: bool = True, db_path: str | None = None) -> dict[str, Any]:
|
||||
if not name.strip():
|
||||
return fail("validation_error", "manual asset name is required")
|
||||
try:
|
||||
bucket = normalize_liquidity_bucket(liquidity_bucket)
|
||||
amount = int(valuation_amount) if valuation_amount is not None else None
|
||||
vdate = normalize_date(valuation_date, default_today=True)
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
ts = now_iso()
|
||||
asset_id = new_id("asset")
|
||||
with db(db_path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO manual_assets(id,name,liquidity_bucket,valuation_amount,valuation_date,currency,source_note,memo,active,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(asset_id, name.strip(), bucket, amount, vdate, currency, source_note, memo, int(active), ts, ts),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||
|
||||
|
||||
def list_manual_assets(*args, **kwargs):
|
||||
return fail("unsupported", "manual assets are no longer supported")
|
||||
@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 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())
|
||||
|
||||
|
||||
def update_manual_asset(*args, **kwargs):
|
||||
return fail("unsupported", "manual assets are no longer supported")
|
||||
def update_manual_asset(asset_id: str, name: str | None = None, valuation_amount: int | None = None, valuation_date: str | None = None, liquidity_bucket: str | None = None, source_note: str | None = None, memo: str | None = None, currency: str | None = None, active: bool | None = None, db_path: str | None = None) -> dict[str, Any]:
|
||||
allowed_updates: list[str] = []
|
||||
params: list[Any] = []
|
||||
try:
|
||||
if name is not None:
|
||||
if not name.strip():
|
||||
return fail("validation_error", "manual asset name is required")
|
||||
allowed_updates.append("name=?")
|
||||
params.append(name.strip())
|
||||
if valuation_amount is not None:
|
||||
allowed_updates.append("valuation_amount=?")
|
||||
params.append(int(valuation_amount))
|
||||
if valuation_date is not None:
|
||||
allowed_updates.append("valuation_date=?")
|
||||
params.append(normalize_date(valuation_date, default_today=True))
|
||||
if liquidity_bucket is not None:
|
||||
allowed_updates.append("liquidity_bucket=?")
|
||||
params.append(normalize_liquidity_bucket(liquidity_bucket))
|
||||
if source_note is not None:
|
||||
allowed_updates.append("source_note=?")
|
||||
params.append(source_note)
|
||||
if memo is not None:
|
||||
allowed_updates.append("memo=?")
|
||||
params.append(memo)
|
||||
if currency is not None:
|
||||
allowed_updates.append("currency=?")
|
||||
params.append(currency or "KRW")
|
||||
if active is not None:
|
||||
allowed_updates.append("active=?")
|
||||
params.append(int(bool(active)))
|
||||
except Exception as e:
|
||||
return fail("validation_error", str(e))
|
||||
if not allowed_updates:
|
||||
return fail("validation_error", "no fields to update")
|
||||
allowed_updates.append("updated_at=?")
|
||||
params.extend([now_iso(), asset_id])
|
||||
with db(db_path) as conn:
|
||||
cur = conn.execute(f"UPDATE manual_assets SET {', '.join(allowed_updates)} WHERE id=?", params)
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "manual asset not found")
|
||||
row = conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
return ok(manual_asset={**row_to_dict(row), "authority_source": "manual_assets"})
|
||||
|
||||
|
||||
def deactivate_manual_asset(*args, **kwargs):
|
||||
return fail("unsupported", "manual assets are no longer supported")
|
||||
def deactivate_manual_asset(asset_id: str, hard_delete: bool = False, db_path: str | None = None) -> dict[str, Any]:
|
||||
with db(db_path) as conn:
|
||||
if hard_delete:
|
||||
cur = conn.execute("DELETE FROM manual_assets WHERE id=?", (asset_id,))
|
||||
else:
|
||||
cur = conn.execute("UPDATE manual_assets SET active=0, updated_at=? WHERE id=?", (now_iso(), asset_id))
|
||||
if cur.rowcount == 0:
|
||||
return fail("not_found", "manual asset not found")
|
||||
row = None if hard_delete else conn.execute("SELECT * FROM manual_assets WHERE id=?", (asset_id,)).fetchone()
|
||||
payload = {"manual_asset_id": asset_id, "deactivated": True, "hard_delete": hard_delete}
|
||||
if row is not None:
|
||||
payload["manual_asset"] = {**row_to_dict(row), "authority_source": "manual_assets"}
|
||||
return ok(**payload)
|
||||
|
||||
|
||||
def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_policy: str = "exact") -> dict[str, Any]:
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn, basis, selection_policy)
|
||||
included = account_items
|
||||
excluded = account_excluded
|
||||
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
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
existing_rows = conn.execute("SELECT COUNT(*) FROM account_snapshot WHERE basis_date=?", (basis,)).fetchone()[0]
|
||||
existing_rows = conn.execute("SELECT COUNT(*) FROM asset_snapshots WHERE basis_date=?", (basis,)).fetchone()[0]
|
||||
warnings: list[dict[str, Any]] = []
|
||||
for item in excluded:
|
||||
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")})
|
||||
@@ -425,8 +673,16 @@ def _snapshot_preview_from_conn(conn: sqlite3.Connection, basis: str, selection_
|
||||
for candidate in warning.get("candidates", [])
|
||||
if candidate.get("id")
|
||||
})
|
||||
# The removed valuation table is never queried, including for preview hashes.
|
||||
candidate_state_hashes = []
|
||||
for candidate_id in candidate_ids:
|
||||
candidate_row = conn.execute(
|
||||
"SELECT * FROM account_valuations WHERE id=?", (candidate_id,),
|
||||
).fetchone()
|
||||
if candidate_row is not None:
|
||||
candidate_state_hashes.append({
|
||||
"id": candidate_id,
|
||||
"state_hash": _valuation_row_state_hash(candidate_row),
|
||||
})
|
||||
public_excluded = [_public_evidence(item) for item in excluded]
|
||||
return {
|
||||
"basis_date": basis,
|
||||
@@ -472,7 +728,6 @@ def preview_asset_snapshot_close(basis_date: str = "", db_path: str | None = Non
|
||||
|
||||
|
||||
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]:
|
||||
return _snapshot_run_history_unavailable()
|
||||
if selection_policy not in {"exact", "as_of"}:
|
||||
return fail("validation_error", "selection_policy must be exact or as_of")
|
||||
try:
|
||||
@@ -483,12 +738,6 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
run_id = new_id("snaprun")
|
||||
with db(db_path) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
month_row = conn.execute("SELECT id FROM monthly_settlement WHERE month=?", (basis[:7],)).fetchone()
|
||||
if month_row:
|
||||
monthly_settlement_id = month_row["id"]
|
||||
else:
|
||||
monthly_settlement_id = new_id("month")
|
||||
conn.execute("INSERT INTO monthly_settlement(id,month,basis_date,status,version,created_by,created_at,updated_by,updated_at) VALUES(?,?,?,?,?,?,?,?,?)", (monthly_settlement_id,basis[:7],basis,"draft",1,approved_by or "asset-close",ts,approved_by or "asset-close",ts))
|
||||
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)
|
||||
@@ -618,7 +867,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
row_note = source_note or item.get("source_note", "") or "month-end close"
|
||||
row_memo = memo or item.get("memo", "")
|
||||
conn.execute(
|
||||
"""INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,monthly_settlement_id,correction_of_run_id,correction_reason,is_correction,closed_at,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason,created_at)
|
||||
"""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,valuation_id,selection_policy,verification_status,evidence_kind,exclusion_reason,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
snapshot_id,
|
||||
@@ -632,7 +881,7 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
item.get("authority_source", item.get("source_type", "")),
|
||||
row_note,
|
||||
row_memo,
|
||||
monthly_settlement_id,
|
||||
run_id,
|
||||
correction_of_run_id,
|
||||
correction_reason.strip(),
|
||||
int(is_correction),
|
||||
@@ -645,10 +894,6 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
ts,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE account_snapshot SET monthly_settlement_id=? WHERE id=? AND monthly_settlement_id IS NULL",
|
||||
(monthly_settlement_id, snapshot_id),
|
||||
)
|
||||
snapshot_ids.append(snapshot_id)
|
||||
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", "last_synced_at", "currency", "valuation_id", "valuation_kind", "valuation_date", "age_days", "carried_forward", "verification_status", "evidence_kind", "unverified_candidates") 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", [])]
|
||||
@@ -676,8 +921,6 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
return fail("snapshot_conflict", "another snapshot close already owns this basis date", basis_date=basis)
|
||||
if "source_snapshot_json" in {r["name"] for r in conn.execute("PRAGMA table_info(asset_snapshot_runs)") }:
|
||||
conn.execute("UPDATE asset_snapshot_runs SET source_snapshot_json=? WHERE id=?", (json.dumps({"snapshot_row_ids": snapshot_ids, "frozen_warnings": preview["warnings"]}, ensure_ascii=False, sort_keys=True), run_id))
|
||||
approval = {
|
||||
"approved_by": approved_by,
|
||||
"approved_at": ts if approved_by else "",
|
||||
@@ -727,8 +970,8 @@ def close_asset_snapshot(basis_date: str = "", source_note: str = "", memo: str
|
||||
|
||||
|
||||
def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, name: str, amount: int | None, liquidity_bucket: str, valuation_source: str = "", source_note: str = "", memo: str = "", currency: str = "KRW", db_path: str | None = None) -> dict[str, Any]:
|
||||
if source_type != "account":
|
||||
return fail("unsupported", "manual asset snapshots are no longer supported")
|
||||
if source_type not in {"account", "manual_asset"}:
|
||||
return fail("validation_error", "source_type must be one of ['account', 'manual_asset']")
|
||||
if not name.strip():
|
||||
return fail("validation_error", "snapshot item name is required")
|
||||
try:
|
||||
@@ -741,17 +984,16 @@ def create_asset_snapshot(basis_date: str, source_type: str, source_id: str, nam
|
||||
snapshot_id = new_id("snap")
|
||||
with db(db_path) as conn:
|
||||
conn.execute(
|
||||
"""INSERT INTO account_snapshot(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
|
||||
"""INSERT INTO asset_snapshots(id,basis_date,source_type,source_id,name,amount,currency,liquidity_bucket,valuation_source,source_note,memo,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(snapshot_id, basis, source_type, source_id, name.strip(), amount_int, currency, bucket, valuation_source or source_type, source_note, memo, ts),
|
||||
)
|
||||
row = conn.execute("SELECT * FROM account_snapshot WHERE id=?", (snapshot_id,)).fetchone()
|
||||
row = conn.execute("SELECT * FROM asset_snapshots WHERE id=?", (snapshot_id,)).fetchone()
|
||||
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]:
|
||||
return _snapshot_run_history_unavailable()
|
||||
if selection_policy not in {"exact", "as_of"}:
|
||||
return fail("validation_error", "selection_policy must be exact or as_of")
|
||||
try:
|
||||
@@ -775,7 +1017,7 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
|
||||
evidence_provenance = _evidence_provenance(close_metadata.get("applied_evidence", []), basis)
|
||||
return _ok_with_provenance_aliases(
|
||||
basis_date=basis,
|
||||
source="legacy_asset_snapshots" if legacy_closed else "account_snapshot",
|
||||
source="legacy_asset_snapshots" if legacy_closed else "asset_snapshots",
|
||||
is_closed_snapshot=True,
|
||||
snapshot_run_id=close_metadata.get("snapshot_run_id", ""),
|
||||
snapshot_count=len(snapshots),
|
||||
@@ -849,7 +1091,6 @@ def get_asset_snapshot_report(basis_date: str = "", db_path: str | None = None,
|
||||
|
||||
@read_operation
|
||||
def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: int = 12, db_path: str | None = None) -> dict[str, Any]:
|
||||
return ok(timeseries=[], warnings=[{"code": "run_history_unavailable", "message": _RUN_HISTORY_UNAVAILABLE}])
|
||||
try:
|
||||
months_int = max(1, min(int(months), 120))
|
||||
end_norm = normalize_date(end_date, default_today=True) if end_date else current_kst_date().isoformat()
|
||||
@@ -865,7 +1106,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
return fail("validation_error", str(e))
|
||||
with read_db(db_path) as conn:
|
||||
basis_rows = conn.execute(
|
||||
"""SELECT basis_date FROM account_snapshot
|
||||
"""SELECT basis_date FROM asset_snapshots
|
||||
WHERE basis_date>=? AND basis_date<=?
|
||||
UNION
|
||||
SELECT basis_date FROM asset_snapshot_runs
|
||||
@@ -881,7 +1122,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
return fail("data_integrity_error", "snapshot evidence is malformed", snapshot_run_id=metadata.get("snapshot_run_id", ""))
|
||||
items = _snapshot_items(conn, basis)
|
||||
run_public = _closed_run_metadata(metadata)
|
||||
run_public["warnings"] = _closed_warnings(metadata, basis, conn.execute("SELECT rowid AS _rowid, * FROM account_snapshot WHERE basis_date=? ORDER BY created_at ASC, name ASC", (basis,)).fetchall())
|
||||
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(
|
||||
@@ -890,7 +1131,7 @@ def get_net_worth_timeseries(start_date: str = "", end_date: str = "", months: i
|
||||
"total_assets": summary["total_assets"],
|
||||
"total_liabilities": summary["total_liabilities"],
|
||||
"net_worth": summary["net_worth"],
|
||||
"source": "account_snapshot",
|
||||
"source": "asset_snapshots",
|
||||
"item_count": len(items),
|
||||
"snapshot_run_id": metadata.get("snapshot_run_id", ""),
|
||||
"draft_id": run_public.get("draft_id", ""),
|
||||
@@ -936,15 +1177,16 @@ def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
||||
"""
|
||||
with read_db(db_path) as conn:
|
||||
account_items, account_excluded, invalid_bucket_accounts = _account_valuation_items(conn)
|
||||
included = account_items
|
||||
excluded = account_excluded
|
||||
manual_items, manual_excluded = _manual_asset_valuation_items(conn)
|
||||
included = account_items + manual_items
|
||||
excluded = account_excluded + manual_excluded
|
||||
summary = _summarize_valuation_items(included, excluded)
|
||||
source_accounts = [item for item in included if item.get("source_type") == "account"]
|
||||
source = "accounts.last_balance"
|
||||
source = "accounts.last_balance" if not manual_items and not manual_excluded else "accounts.last_balance+manual_assets"
|
||||
return ok(
|
||||
buckets=summary["buckets"],
|
||||
accounts=source_accounts,
|
||||
manual_assets=[],
|
||||
manual_assets=manual_items,
|
||||
total_assets=summary["total_assets"],
|
||||
total_liabilities=summary["total_liabilities"],
|
||||
net_worth=summary["net_worth"],
|
||||
@@ -954,7 +1196,7 @@ def get_asset_liquidity_report(db_path: str | None = None) -> dict[str, Any]:
|
||||
],
|
||||
missing_valuation_items=excluded,
|
||||
invalid_bucket_accounts=invalid_bucket_accounts,
|
||||
manual_asset_count=0,
|
||||
manual_asset_count=len(manual_items),
|
||||
included_items=included,
|
||||
excluded_items=excluded,
|
||||
valuation_policy=summary["valuation_policy"],
|
||||
|
||||
Reference in New Issue
Block a user