Budget history by month, shared tab navigation, file imported statements into Statements/year/month and stop re-reading them
build-container / build (push) Successful in 40s
build-container / build (push) Successful in 40s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d7808a3925
commit
20317b14c9
7 files changed
+147
-12
No files matched your search
+76
-2
@@ -217,12 +217,32 @@ def parse_capone_csv(path):
|
||||
return st
|
||||
|
||||
_CACHE = {}
|
||||
_DISK = DATA / ".statement_cache.pkl" # parsed statements survive restarts (re-reading ~30 PDFs takes several seconds)
|
||||
_CACHE_VERSION = 1
|
||||
_loaded = False
|
||||
|
||||
def _load_cache():
|
||||
global _loaded
|
||||
if _loaded: return
|
||||
_loaded = True
|
||||
try:
|
||||
import pickle
|
||||
v, data = pickle.loads(_DISK.read_bytes())
|
||||
if v == _CACHE_VERSION: _CACHE.update(data)
|
||||
except Exception: pass
|
||||
|
||||
def _save_cache():
|
||||
try:
|
||||
import pickle
|
||||
tmp = _DISK.with_suffix(".tmp"); tmp.write_bytes(pickle.dumps((_CACHE_VERSION, _CACHE))); tmp.replace(_DISK)
|
||||
except Exception: pass
|
||||
def load_statements(folder=DATA):
|
||||
"""Parse every statement file in folder. Returns (statements, skipped, duplicates)."""
|
||||
sts, skipped, dups, seen = [], [], [], {}
|
||||
_load_cache(); parsed_new = False
|
||||
for p in sorted(Path(folder).glob("*")):
|
||||
if p.suffix.lower() not in (".pdf", ".csv"): continue
|
||||
ck = (str(p), p.stat().st_mtime_ns, p.stat().st_size)
|
||||
ck = (p.name, p.stat().st_mtime_ns, p.stat().st_size)
|
||||
if ck in _CACHE:
|
||||
st, err = _CACHE[ck]
|
||||
if err: skipped.append((p.name, err))
|
||||
@@ -244,12 +264,16 @@ def load_statements(folder=DATA):
|
||||
st = parse_capone_csv(p)
|
||||
except Exception as e:
|
||||
err = f"could not read: {e}"
|
||||
_CACHE[ck] = (st, err)
|
||||
_CACHE[ck] = (st, err); parsed_new = True
|
||||
if err: skipped.append((p.name, err)); continue
|
||||
if st is None: continue
|
||||
key = (st.account, st.end, st.kind)
|
||||
if key in seen and st.kind == "pdf": dups.append((st.file, seen[key])); continue
|
||||
seen[key] = st.file; sts.append(st)
|
||||
if parsed_new:
|
||||
live = {(p.name, p.stat().st_mtime_ns, p.stat().st_size) for p in Path(folder).glob("*")}
|
||||
for k in [k for k in _CACHE if k not in live]: del _CACHE[k] # forget deleted / replaced files
|
||||
_save_cache()
|
||||
return sts, skipped, dups
|
||||
|
||||
# ---------------------------------------------------------------- classification
|
||||
@@ -603,6 +627,56 @@ def summarize(rows):
|
||||
return dict(log=sum(r.kind == "log" for r in rows), excluded=sum(r.kind == "excluded" for r in rows),
|
||||
new_payees=sorted({r.payee for r in rows if r.is_new_payee}))
|
||||
|
||||
# ---------------------------------------------------------------- filing statements away
|
||||
ARCHIVE = DATA / "Statements" # Statements/YYYY/MM/<file>, by statement end date
|
||||
INDEX = DATA / "statements_index.json" # what each filed statement covered, so coverage needs no re-reading
|
||||
|
||||
def _index():
|
||||
try: return json.loads(INDEX.read_text())
|
||||
except Exception: return []
|
||||
|
||||
def archived_statements():
|
||||
"""Light Statement objects (no transactions) for everything already filed away."""
|
||||
out = []
|
||||
for i in _index():
|
||||
try: out.append(Statement(i["file"], i["account"], dt.date.fromisoformat(i["start"]) if i.get("start") else None,
|
||||
dt.date.fromisoformat(i["end"]), kind=i["kind"]))
|
||||
except Exception: pass
|
||||
return out
|
||||
|
||||
def archive_statements():
|
||||
"""Once everything read is in the workbook, move the statement files into Statements/YYYY/MM and remember what they covered."""
|
||||
res = run_preview(DATA)
|
||||
if res["new"]: raise ValueError("Add the new transactions to the workbook first.")
|
||||
idx, batch, moved = _index(), f"{dt.datetime.now():%Y%m%d%H%M%S}", 0
|
||||
for st in res["statements"]:
|
||||
if st.file in res["bad"]: continue
|
||||
src = DATA / st.file
|
||||
if not src.exists(): continue
|
||||
dest = ARCHIVE / f"{st.end.year}" / f"{st.end.month:02d}"; dest.mkdir(parents=True, exist_ok=True)
|
||||
target, n = dest / src.name, 1
|
||||
while target.exists(): target = dest / f"{src.stem}_{n}{src.suffix}"; n += 1
|
||||
shutil.move(str(src), str(target))
|
||||
if st.kind == "pdf": idx = [i for i in idx if (i["account"], i["end"], i["kind"]) != (st.account, st.end.isoformat(), st.kind)]
|
||||
idx.append(dict(file=str(target.relative_to(DATA)), account=st.account, start=st.start.isoformat() if st.start else None,
|
||||
end=st.end.isoformat(), kind=st.kind, batch=batch)); moved += 1
|
||||
INDEX.write_text(json.dumps(idx, indent=1))
|
||||
if moved and res["dups"]: moved += archive_statements()["moved"] # identical copies read as duplicates until the original is filed
|
||||
return dict(moved=moved)
|
||||
|
||||
def unarchive_last():
|
||||
"""Put the most recently filed batch of statements back in the data folder (used by Undo)."""
|
||||
idx = _index()
|
||||
if not idx or not any(i.get("batch") for i in idx): return 0
|
||||
last = max(i.get("batch", "") for i in idx); n = 0
|
||||
for i in [i for i in idx if i.get("batch") == last]:
|
||||
src = DATA / i["file"]
|
||||
if src.exists():
|
||||
target = DATA / src.name
|
||||
if not target.exists(): shutil.move(str(src), str(target)); n += 1
|
||||
INDEX.write_text(json.dumps([i for i in idx if i.get("batch") != last], indent=1))
|
||||
return n
|
||||
|
||||
def run_preview(folder=DATA):
|
||||
from openpyxl import load_workbook
|
||||
sts, skipped, dups = load_statements(folder)
|
||||
|
||||
Reference in new issue
Block a user