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
@@ -1,6 +1,6 @@
|
||||
# money
|
||||
|
||||
Local tool that loads bank/card statements (Chase Visa, Ally, CVFCU PDFs; CVFCU "Account Details" page saved as PDF for recent activity; Capital One CSV) into `Bills_Tracker.xlsx` and shows a web dashboard.
|
||||
Local tool that loads bank/card statements (Chase Visa, Ally, CVFCU PDFs; CVFCU "Account Details" page saved as PDF for recent activity; Capital One CSV) into `Bills_Tracker.xlsx` and shows a web dashboard. Imported statements are filed into `Statements/year/month/` and never read again.
|
||||
Everything runs locally; statements and the workbook are **never** committed or baked into the image.
|
||||
|
||||
## Run on ZimaOS / CasaOS
|
||||
|
||||
@@ -68,10 +68,10 @@ def state():
|
||||
rows = [dict(date=f"{r.txn.date:%Y-%m-%d}", account=r.txn.account, desc=r.txn.desc[:60], amount=r.txn.amount, kind=r.kind,
|
||||
payee=r.payee, what=r.what, reason=r.reason, new_payee=r.is_new_payee) for r in new]
|
||||
sm = L.summarize(new)
|
||||
return dict(coverage=coverage(res["statements"]), skipped=[dict(file=f, why=w) for f, w in res["skipped"]],
|
||||
return dict(coverage=coverage(res["statements"] + L.archived_statements()), skipped=[dict(file=f, why=w) for f, w in res["skipped"]],
|
||||
duplicates=[dict(file=a, same_as=b) for a, b in res["dups"]],
|
||||
bad=[dict(file=f, parsed=p, stated=s) for f, p, s, ok in res["checks"] if not ok],
|
||||
already=res["already"], new=rows, summary=sm, statements=len(res["statements"]),
|
||||
already=res["already"], new=rows, archivable=(0 if new else len([x for x in res["statements"] if x.file not in res["bad"]])), summary=sm, statements=len(res["statements"]),
|
||||
excel_open=(DATA / "~$Bills_Tracker.xlsx").exists() or (DATA / ".~lock.Bills_Tracker.xlsx#").exists(),
|
||||
backups=sorted(p.name for p in L.BACKUPS.glob("*.xlsx"))[-5:] if L.BACKUPS.exists() else [])
|
||||
|
||||
@@ -151,6 +151,12 @@ class H(BaseHTTPRequestHandler):
|
||||
if not L.WORKBOOK.exists(): return self._send(200, {"setup": True})
|
||||
return self._send(200, B.compute(workbook_data(), month))
|
||||
except Exception as e: return self._send(500, {"error": str(e)})
|
||||
if u.path == "/api/budget_history":
|
||||
with LOCK:
|
||||
try:
|
||||
if not L.WORKBOOK.exists(): return self._send(200, dict(months=[], envelopes=[]))
|
||||
return self._send(200, B.history(workbook_data()))
|
||||
except Exception as e: return self._send(500, {"error": str(e)})
|
||||
if u.path == "/api/payees":
|
||||
with LOCK:
|
||||
try: return self._send(200, L.uncategorized())
|
||||
@@ -193,15 +199,24 @@ class H(BaseHTTPRequestHandler):
|
||||
res = L.run_preview(DATA)
|
||||
if not res["new"]: return self._send(200, {"message": "Nothing new to import."})
|
||||
out = L.apply(res["new"])
|
||||
return self._send(200, {"message": f"Added {out['log_rows']} rows to the Bill Log and {out['excluded_rows']} to Excluded Transfers.",
|
||||
try: filed = L.archive_statements()["moved"]
|
||||
except Exception: filed = 0
|
||||
tail = f" Filed {filed} statement(s) into Statements/year/month." if filed else ""
|
||||
return self._send(200, {"message": f"Added {out['log_rows']} rows to the Bill Log and {out['excluded_rows']} to Excluded Transfers.{tail}",
|
||||
"backup": Path(out["backup"]).name, "new_payees": out["new_payees"]})
|
||||
if u.path == "/api/archive":
|
||||
try:
|
||||
with LOCK: out = L.archive_statements()
|
||||
except ValueError as e: return self._send(400, {"error": str(e)})
|
||||
return self._send(200, {"message": f"Filed {out['moved']} statement(s) into Statements/year/month. They won't be read again."})
|
||||
if u.path == "/api/undo":
|
||||
with LOCK:
|
||||
b = sorted(L.BACKUPS.glob("*.xlsx")) if L.BACKUPS.exists() else []
|
||||
if not b: return self._send(200, {"message": "No backups to restore."})
|
||||
keep = L.BACKUPS / f"before_undo_{dt.datetime.now():%Y%m%d_%H%M%S}.xlsx"
|
||||
shutil.copy2(L.WORKBOOK, keep); shutil.copy2(b[-1], L.WORKBOOK)
|
||||
return self._send(200, {"message": f"Restored the workbook from {b[-1].name}."})
|
||||
back = L.unarchive_last()
|
||||
return self._send(200, {"message": f"Restored the workbook from {b[-1].name}." + (f" Moved {back} statement(s) back to the data folder." if back else "")})
|
||||
if u.path == "/api/create_blank":
|
||||
tpl = HERE / "template" / "Bills_Tracker.template.xlsx"
|
||||
with LOCK:
|
||||
|
||||
+25
-3
@@ -25,11 +25,15 @@ input.amt{width:110px;text-align:right}
|
||||
.pill{display:inline-block;padding:1px 9px;border-radius:99px;font-size:12px;font-weight:600}.pill.ok{background:var(--okbg);color:var(--ok)}.pill.bad{background:var(--badbg);color:var(--bad)}.pill.zero{background:var(--none);color:var(--mute)}
|
||||
.alert{padding:10px 12px;border-radius:8px;margin:12px 0;background:var(--okbg);color:var(--ok)}.alert.bad{background:var(--badbg);color:var(--bad)}
|
||||
#move{display:none;margin-top:12px}tr.empty td{color:var(--mute)}
|
||||
.tabs{position:sticky;top:0;z-index:5;display:flex;gap:4px;margin:0 0 16px;padding:10px 0;background:var(--bg)}
|
||||
.tabs a{flex:1;max-width:170px;text-align:center;padding:8px 14px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--mute);font-weight:600;font-size:14px;text-decoration:none}
|
||||
.tabs a:hover{color:var(--ink)}.tabs a.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="row"><h1>Envelope Budget</h1><div class="spacer"></div><a href="/dashboard">Dashboard</a><a href="/">Statement Loader</a></div>
|
||||
<nav class="tabs"><a href="/">Statements</a><a href="/budget" class="on" aria-current="page">Budget</a><a href="/dashboard">Dashboard</a></nav>
|
||||
<div class="row"><h1>Envelope Budget</h1><div class="spacer"></div></div>
|
||||
<div class="row" style="margin:14px 0 0">
|
||||
<button id="prev">‹</button><b id="mlabel" style="min-width:120px;text-align:center;font-size:16px"></b><button id="next">›</button>
|
||||
<select id="mpick"></select>
|
||||
@@ -58,6 +62,12 @@ input.amt{width:110px;text-align:right}
|
||||
<table id="tbl"></table>
|
||||
<p class="mute" style="margin:10px 0 0">Available = everything you’ve budgeted since the first budget month minus everything spent, so leftovers roll over and overspending carries forward. Ready to assign = income since then minus everything budgeted. Income paid in pieces (like the 15th and the last day)? Type the month’s full total in “Expecting”, and it counts as soon as you budget it. Aim for $0.</p>
|
||||
</div>
|
||||
<div class="card" id="histcard" style="margin-top:16px;display:none">
|
||||
<div class="row" style="margin-bottom:6px"><h2 style="margin:0">History by month</h2><div class="spacer"></div>
|
||||
<select id="hmetric"><option value="2">Left over (rolls forward)</option><option value="0">Budgeted</option><option value="1">Spent</option></select></div>
|
||||
<div style="overflow-x:auto"><table id="hist"></table></div>
|
||||
<p class="mute" style="margin:10px 0 0">Left over is what the envelope held at the end of that month: everything budgeted so far minus everything spent. It is worked out from the Budget tab and Bill Log, so it updates if you edit a past month.</p>
|
||||
</div>
|
||||
</main>
|
||||
<script>
|
||||
const TOKEN="__TOKEN__";
|
||||
@@ -101,6 +111,18 @@ function softRender(active){ // update totals without losing focus in the input
|
||||
const pos=active.selectionStart,i=active.dataset.i;render();
|
||||
const n=document.querySelector(`input.amt[data-i="${i}"]`);if(n){n.focus();try{n.setSelectionRange(pos,pos)}catch(e){}}
|
||||
}
|
||||
let H=null;
|
||||
async function loadHist(){try{H=await (await fetch("/api/budget_history")).json()}catch(e){H=null}renderHist()}
|
||||
function renderHist(){
|
||||
const c=$("#histcard");if(!H||!H.months.length){c.style.display="none";return}c.style.display="block";
|
||||
const k=+$("#hmetric").value,ms=H.months.slice(-12),off=H.months.length-ms.length,cur=k===2;
|
||||
const cell=v=>`<td class="n"><span style="${cur&&v<-0.005?"color:var(--bad)":cur&&v>0.005?"color:var(--ok)":""}">${Math.abs(v)<0.005?"–":money(v)}</span></td>`;
|
||||
const tot=ms.map((_,i)=>H.envelopes.reduce((a,e)=>a+e.cells[off+i][k],0));
|
||||
$("#hist").innerHTML="<tr><th>Envelope</th>"+ms.map(m=>`<th class="n">${MON[+m.slice(5)-1]} ${m.slice(2,4)}</th>`).join("")+"</tr>"+
|
||||
H.envelopes.map(e=>`<tr><td>${esc(e.envelope)}</td>${ms.map((_,i)=>cell(e.cells[off+i][k])).join("")}</tr>`).join("")+
|
||||
`<tr><td><b>Total</b></td>${tot.map(v=>`<td class="n"><b>${money(v)}</b></td>`).join("")}</tr>`;
|
||||
}
|
||||
$("#hmetric").onchange=renderHist;
|
||||
function go(m){if(dirty&&!confirm("Discard unsaved changes?"))return;load(m)}
|
||||
$("#prev").onclick=()=>go(add(month,-1));$("#next").onclick=()=>go(add(month,1));$("#mpick").onchange=e=>go(e.target.value);
|
||||
$("#showall").onchange=render;
|
||||
@@ -119,10 +141,10 @@ $("#mgo").onclick=()=>{const f=rows.find(r=>r.envelope==$("#mfrom").value),t=row
|
||||
$("#save").onclick=async()=>{
|
||||
const budgets={};rows.forEach(r=>{if(+r.edit)budgets[r.envelope]=+r.edit});if(exp>0)budgets["Expected income"]=exp;
|
||||
try{const r=await fetch("/api/budget",{method:"POST",headers:{"X-Token":TOKEN},body:JSON.stringify({month,budgets})});const j=await r.json();if(!r.ok)throw new Error(j.error);
|
||||
await load(month);$("#msg").innerHTML=`<div class="alert">${esc(j.message)}</div>`}catch(e){$("#msg").innerHTML=`<div class="alert bad">${esc(e.message)}</div>`}
|
||||
await load(month);loadHist();$("#msg").innerHTML=`<div class="alert">${esc(j.message)}</div>`}catch(e){$("#msg").innerHTML=`<div class="alert bad">${esc(e.message)}</div>`}
|
||||
};
|
||||
addEventListener("beforeunload",e=>{if(dirty){e.preventDefault();e.returnValue=""}});
|
||||
load(month);
|
||||
load(month);loadHist();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
+5
-1
@@ -25,12 +25,16 @@ td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}.scroll{max-height:
|
||||
.up{color:var(--up)}.down{color:var(--down)}
|
||||
#tip{position:fixed;pointer-events:none;background:var(--ink);color:var(--bg);padding:6px 9px;border-radius:6px;font-size:12px;display:none;z-index:9;max-width:260px}
|
||||
.bar{fill:var(--accent)}.bar:hover{opacity:.8}
|
||||
.tabs{position:sticky;top:0;z-index:5;display:flex;gap:4px;margin:0 0 16px;padding:10px 0;background:var(--bg)}
|
||||
.tabs a{flex:1;max-width:170px;text-align:center;padding:8px 14px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--mute);font-weight:600;font-size:14px;text-decoration:none}
|
||||
.tabs a:hover{color:var(--ink)}.tabs a.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav class="tabs"><a href="/">Statements</a><a href="/budget">Budget</a><a href="/dashboard" class="on" aria-current="page">Dashboard</a></nav>
|
||||
<div class="row"><h1>Bills Dashboard</h1><span class="mute" id="updated"></span><div class="spacer"></div>
|
||||
<label class="mute">Year <select id="year"></select></label><a href="/budget">Budget</a><a href="/">← Statement Loader</a></div>
|
||||
<label class="mute">Year <select id="year"></select></label></div>
|
||||
<div class="kpis" id="kpis"></div>
|
||||
<div class="grid">
|
||||
<div class="card"><h2 id="t0">Income vs spending</h2><div id="c0"></div><div class="legend" id="l0"></div></div>
|
||||
|
||||
+7
-1
@@ -24,11 +24,15 @@ th{font-size:12px;color:var(--mute);font-weight:600}td.num{text-align:right;font
|
||||
.row{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.spacer{flex:1}.mute{color:var(--mute)}code{background:var(--none);padding:1px 5px;border-radius:4px}
|
||||
.legend span{margin-right:14px;font-size:12px;color:var(--mute)}
|
||||
#help{display:none;margin-top:10px}
|
||||
.tabs{position:sticky;top:0;z-index:5;display:flex;gap:4px;margin:0 0 16px;padding:10px 0;background:var(--bg)}
|
||||
.tabs a{flex:1;max-width:170px;text-align:center;padding:8px 14px;border:1px solid var(--line);border-radius:8px;background:var(--card);color:var(--mute);font-weight:600;font-size:14px;text-decoration:none}
|
||||
.tabs a:hover{color:var(--ink)}.tabs a.on{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<div class="row"><h1 style="margin:0">Statement Loader</h1><div class="spacer"></div><a href="/budget" style="color:var(--accent);font-weight:600;text-decoration:none;margin-right:14px">Budget</a><a href="/dashboard" style="color:var(--accent);font-weight:600;text-decoration:none">Dashboard →</a></div>
|
||||
<nav class="tabs"><a href="/" class="on" aria-current="page">Statements</a><a href="/budget">Budget</a><a href="/dashboard">Dashboard</a></nav>
|
||||
<div class="row"><h1 style="margin:0">Statement Loader</h1></div>
|
||||
<div class="sub">Runs only on this Mac. Drop in statements, check what’s covered, then add the new transactions to <b>Bills_Tracker.xlsx</b>. A backup is saved first, every time.</div>
|
||||
|
||||
<div id="alerts"></div>
|
||||
@@ -77,6 +81,8 @@ async function refresh(msg){
|
||||
if(s.setup){$("#catcard").style.display="none";$("#coverage").innerHTML="";$("#preview").innerHTML="";$("#import").disabled=true;
|
||||
$("#summary").innerHTML="";alertBox("warn",`<b>First-time setup:</b> <code>Bills_Tracker.xlsx</code> isn’t in the data folder (<code>${esc(s.data_dir)}</code>). Drop your existing one into the box above, or <button id="blank" style="margin-left:6px">Create a blank workbook</button>`+(s.rules?"":" Also add <code>loader_rules.json</code> (see loader_rules.example.json: your name, so transfers between your own accounts aren’t counted as spending)."));
|
||||
const b=$("#blank");if(b)b.onclick=async()=>{try{const j=await post("/api/create_blank","");await refresh(esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};return}
|
||||
if(s.archivable>0)alertBox("good",`${s.archivable} statement file(s) are already in the workbook. <button id="filebtn" style="margin-left:6px">File them into Statements/year/month</button>`);
|
||||
const fb=$("#filebtn");if(fb)fb.onclick=async()=>{try{const j=await post("/api/archive","");await refresh(esc(j.message))}catch(e){alertBox("bad",esc(e.message))}};
|
||||
s.bad.forEach(b=>alertBox("bad",`<b>${esc(b.file)}</b>: the transactions I read add up to ${fmt(b.parsed)} but the statement says ${fmt(b.stated)}. It will <b>not</b> be imported until that matches.`));
|
||||
s.skipped.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b>: ${esc(b.why)}`));
|
||||
s.duplicates.forEach(b=>alertBox("warn",`<b>${esc(b.file)}</b> is a duplicate of ${esc(b.same_as)}; counted once.`));
|
||||
|
||||
@@ -61,3 +61,17 @@ def compute(D, month):
|
||||
ready=round(inc_cum - bud_cum, 2), ready_before=round(inc_prev - bud_prev, 2),
|
||||
expected=exp_default, expected_saved=month in expected, budgeted_month=round(sum(r["budgeted"] for r in rows), 2),
|
||||
has_budget=bool(budget_months), months=months, prev_has=any(v for (c, m), v in bud.items() if m == ym_add(month, -1)))
|
||||
|
||||
def history(D):
|
||||
"""Every envelope's budgeted / spent / left-over for each month since the first budget month (left-over rolls forward)."""
|
||||
first = min((m for m, env, _ in D.get("budget", [])), default=None)
|
||||
if not first: return dict(months=[], envelopes=[])
|
||||
months, m = [], first
|
||||
while m <= this_month(): months.append(m); m = ym_add(m, 1)
|
||||
env = {}
|
||||
for m in months:
|
||||
for r in compute(D, m)["envelopes"]:
|
||||
env.setdefault(r["envelope"], {})[m] = [r["budgeted"], r["spent"], r["available"]]
|
||||
rows = [dict(envelope=e, cells=[v.get(m, [0, 0, 0]) for m in months]) for e, v in env.items()
|
||||
if any(c[0] or c[1] or c[2] for c in v.values())]
|
||||
return dict(months=months, envelopes=rows)
|
||||
+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