mirror of
https://github.com/Sea-Haven-Industries/stampli-bulk-editor.git
synced 2026-09-30 08:03:13 +00:00
186 lines
6 KiB
Python
186 lines
6 KiB
Python
#!/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()
|