stampli-bulk-editor/edit_pay_dates.py

187 lines
6 KiB
Python
Raw Permalink Normal View History

#!/usr/bin/env python3
"""
Stampli Bulk Pay Date Editor (CLI)
Usage:
python edit_pay_dates.py --dry-run
python edit_pay_dates.py
python edit_pay_dates.py --filter-only
python edit_pay_dates.py --inspect
"""
import argparse
from pathlib import Path
from playwright.sync_api import sync_playwright
from stampli_core import (
load_config,
active_page,
launch_browser,
navigate,
wait_for_login,
needs_login,
collect_edit_rows,
compute_changes,
apply_edits,
filter_select_and_pay,
ROW_SELECTOR,
EDIT_COLUMNS,
)
def inspect_mode(browser):
print("\n" + "=" * 60)
print("INSPECT MODE")
print("=" * 60)
print("""
Type 'snap' to save page HTML, 'scan' to preview rows, 'done' to exit.
""")
while True:
cmd = input(">>> ").strip().lower()
page = active_page(browser)
try:
if cmd == "snap":
html = page.content()
snap_path = Path(__file__).parent / "page_snapshot.html"
with open(snap_path, "w") as f:
f.write(html)
print(f"Saved page HTML to {snap_path}")
elif cmd == "scan":
rows = page.query_selector_all(ROW_SELECTOR)
print(f"Found {len(rows)} rendered rows (virtual scroll)")
for i, row in enumerate(rows[:10]):
due_cell = row.query_selector(EDIT_COLUMNS["due"])
pay_cell = row.query_selector(EDIT_COLUMNS["pay"])
due_text = (
due_cell.inner_text().strip().split("\n")[0]
if due_cell
else "?"
)
pay_text = (
pay_cell.inner_text().strip().split("\n")[0]
if pay_cell
else "?"
)
vendor_cell = row.query_selector(EDIT_COLUMNS["vendor"])
vendor = (
vendor_cell.inner_text().strip()[:30] if vendor_cell else "?"
)
inv_cell = row.query_selector(EDIT_COLUMNS["invoice"])
inv = inv_cell.inner_text().strip()[:20] if inv_cell else "?"
print(
f" Row {i}: inv={inv} vendor={vendor} due={due_text} pay={pay_text}"
)
elif cmd == "columns":
headers = page.query_selector_all("[role='columnheader']")
for i, h in enumerate(headers):
field = h.get_attribute("data-field") or ""
text = h.inner_text().strip().replace("\n", " ")[:40]
print(f" Col {i}: field={field:30s} label='{text}'")
elif cmd in ("done", "quit", "exit", ""):
break
else:
print("Commands: snap, scan, columns, done")
except Exception as e:
print(f"Error (page may have changed): {e}")
def bulk_edit(page, config, dry_run=False):
date_fmt = config["date_format"]
days_before = config["days_before_due"]
print("\nScanning all rows (scrolling through virtual grid)...")
all_rows = collect_edit_rows(page, on_progress=lambda n: None)
print(f"Found {len(all_rows)} total rows")
changes = compute_changes(all_rows, date_fmt, days_before)
if not changes:
print("\nAll rows already have correct pay dates. Nothing to do.")
return
print(f"\n{'DRY RUN: ' if dry_run else ''}{len(changes)} rows to update:\n")
print(
f" {'Invoice':<20s} {'Vendor':<30s} {'Amount':<12s} {'Due':<12s} {'Current Pay':<16s} {'New Pay':<12s}"
)
print(f" {'-' * 20} {'-' * 30} {'-' * 12} {'-' * 12} {'-' * 16} {'-' * 12}")
for c in changes:
print(
f" {c['invoice']:<20s} {c['vendor']:<30s} {c.get('amount', ''):<12s} {c['due_text']:<12s} "
f"{c['pay_text'] or '(empty)':<16s} {c['new_pay']:<12s}"
)
if dry_run:
return
confirm = input(f"\nProceed with {len(changes)} updates? [y/N]: ").strip().lower()
if confirm != "y":
print("Aborted.")
return
def on_progress(i, total, inv, ok, info=""):
if i == -1:
prefix = " RETRY"
else:
prefix = f" [{i}/{total}]"
status = "Updated" if ok else "MISSED"
print(f"{prefix} {status} {inv} {info}")
success, failed = apply_edits(page, changes, on_progress=on_progress)
print(f"\nDone: {success}/{len(changes)} rows updated.")
if success > 0:
submit = (
input(
"\nFilter to error-free rows, select all, and click Pay Invoices? [y/N]: "
)
.strip()
.lower()
)
if submit == "y":
filter_select_and_pay(page, on_status=print)
def main():
parser = argparse.ArgumentParser(description="Stampli Bulk Pay Date Editor")
parser.add_argument("--inspect", action="store_true")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--filter-only", action="store_true")
parser.add_argument("--url", type=str)
args = parser.parse_args()
config = load_config()
url = args.url or config["stampli_url"]
with sync_playwright() as p:
browser = launch_browser(p)
navigate(browser, url)
import time
time.sleep(3)
page = active_page(browser)
if needs_login(page):
if not wait_for_login(browser, on_status=print):
browser.close()
return
print("Waiting for page to settle...")
time.sleep(3)
if args.inspect:
inspect_mode(browser)
elif args.filter_only:
page = active_page(browser)
filter_select_and_pay(page, on_status=print)
else:
page = active_page(browser)
bulk_edit(page, config, dry_run=args.dry_run)
input("\nPress Enter to close the browser...")
browser.close()
if __name__ == "__main__":
main()