Merge pull request #3 from Sea-Haven-Industries/feature/bulk-edit-improvements

Add CSV import/export, settings, stop button, and post-edit audit
This commit is contained in:
Adam Moussa 2026-05-08 10:28:38 -04:00 • committed by GitHub
commit 0a75d4e7ce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 823 additions and 37 deletions

78
README.md Normal file
View file

@ -0,0 +1,78 @@
# Stampli Bulk Editor
Mac desktop app for bulk-editing invoice pay dates in Stampli. Uses Playwright to automate the Stampli web UI through a real Chromium browser session.
## What It Does
Two operations, available via GUI or CLI:
- **Edit Pay Dates** — Scans the "Ready to Pay" grid, calculates the correct pay date for each invoice (5 business days before the due date), and bulk-updates any rows that differ. After editing, optionally filters to error-free rows, selects all, and clicks Pay Invoices.
- **Scan Pending** — Reads the "Pending Approval" grid and flags invoices where the pay date doesn't match the expected calculation.
## Setup
```bash
# Create a virtual environment and install dependencies
uv venv && source venv/bin/activate
uv pip install -r requirements.txt
# Install Chromium for Playwright
playwright install chromium
```
## Usage
### GUI (recommended)
```bash
python stampli_gui.py
```
Select an operation, optionally enable Dry Run, and click Run. The app launches a persistent Chromium browser — log in to Stampli on first use and the session is saved for future runs.
### CLI
```bash
# Preview changes without editing
python edit_pay_dates.py --dry-run
# Edit pay dates
python edit_pay_dates.py
# Only run the filter/select/pay step
python edit_pay_dates.py --filter-only
# Interactive inspect mode (debug)
python edit_pay_dates.py --inspect
# Scan pending approvals for incorrect dates
python scan_pending.py
```
## Building the Mac App
```bash
pyinstaller stampli_gui.spec
```
The bundled `.app` is written to `dist/Stampli Bulk Editor.app`. It expects Playwright's Chromium to be installed at `~/Library/Caches/ms-playwright/` (the standard Playwright cache location).
## Configuration
Config is stored at `~/Library/Application Support/Stampli Bulk Editor/config.json`. Defaults:
| Key | Default | Description |
|-----|---------|-------------|
| `stampli_url` | Ready to Pay dashboard URL | Starting page for edit mode |
| `date_format` | `%m/%d/%Y` | Date format used in the grid |
| `days_before_due` | `5` | Business days before due date to set pay date |
## Project Structure
```
stampli_core.py # Shared logic: browser management, grid scanning, date calculation, editing
stampli_gui.py # tkinter GUI
edit_pay_dates.py # CLI for editing pay dates
scan_pending.py # CLI for scanning pending approvals
stampli_gui.spec # PyInstaller build spec (arm64 macOS)
```

View file

@ -83,10 +83,10 @@ def bulk_edit(page, config, dry_run=False):
return return
print(f"\n{'DRY RUN: ' if dry_run else ''}{len(changes)} rows to update:\n") print(f"\n{'DRY RUN: ' if dry_run else ''}{len(changes)} rows to update:\n")
print(f" {'Invoice':<20s} {'Vendor':<30s} {'Due':<12s} {'Current Pay':<16s} {'New Pay':<12s}") print(f" {'Invoice':<20s} {'Vendor':<30s} {'Amount':<12s} {'Due':<12s} {'Current Pay':<16s} {'New Pay':<12s}")
print(f" {'-'*20} {'-'*30} {'-'*12} {'-'*16} {'-'*12}") print(f" {'-'*20} {'-'*30} {'-'*12} {'-'*12} {'-'*16} {'-'*12}")
for c in changes: for c in changes:
print(f" {c['invoice']:<20s} {c['vendor']:<30s} {c['due_text']:<12s} " 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}") f"{c['pay_text'] or '(empty)':<16s} {c['new_pay']:<12s}")
if dry_run: if dry_run:

2
requirements.txt Normal file
View file

@ -0,0 +1,2 @@
playwright>=1.40
pyinstaller>=6.0

View file

@ -1,16 +1,15 @@
"""Shared logic for Stampli bulk editor — used by both CLI scripts and the GUI.""" """Shared logic for Stampli bulk editor — used by both CLI scripts and the GUI."""
import csv
import json import json
import logging
import os import os
import re
import time import time
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path from pathlib import Path
# Use system-wide Playwright browser cache so the bundled app doesn't import subprocess
# look for browsers inside its own .app bundle.
_pw_cache = Path.home() / "Library" / "Caches" / "ms-playwright"
if _pw_cache.is_dir():
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(_pw_cache))
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
@ -18,8 +17,22 @@ PROJECT_DIR = Path(__file__).parent
_APP_SUPPORT = Path.home() / "Library" / "Application Support" / "Stampli Bulk Editor" _APP_SUPPORT = Path.home() / "Library" / "Application Support" / "Stampli Bulk Editor"
_APP_SUPPORT.mkdir(parents=True, exist_ok=True) _APP_SUPPORT.mkdir(parents=True, exist_ok=True)
CONFIG_PATH = _APP_SUPPORT / "config.json" CONFIG_PATH = _APP_SUPPORT / "config.json"
LOG_PATH = _APP_SUPPORT / "debug.log"
log = logging.getLogger("stampli")
log.setLevel(logging.DEBUG)
_fh = logging.FileHandler(LOG_PATH, mode="w")
_fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S"))
log.addHandler(_fh)
CHROME_USER_DATA = Path.home() / "Library" / "Application Support" / "Google" / "Chrome"
BROWSER_DATA_DIR = _APP_SUPPORT / "browser_data" BROWSER_DATA_DIR = _APP_SUPPORT / "browser_data"
# Playwright Chromium cache for standalone mode
_pw_cache = Path.home() / "Library" / "Caches" / "ms-playwright"
if _pw_cache.is_dir():
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(_pw_cache))
READY_TO_PAY_URL = "https://app.stampli.com/v265n2/dashboard.html#t=ready_to_pay" READY_TO_PAY_URL = "https://app.stampli.com/v265n2/dashboard.html#t=ready_to_pay"
PENDING_APPROVAL_URL = "https://app.stampli.com/v265n2/dashboard.html#t=payments_to_approve" PENDING_APPROVAL_URL = "https://app.stampli.com/v265n2/dashboard.html#t=payments_to_approve"
@ -34,6 +47,7 @@ EDIT_COLUMNS = {
"pay": "div[data-field='invoiceRequestedDate']", "pay": "div[data-field='invoiceRequestedDate']",
"vendor": "div[data-field='vendorName']", "vendor": "div[data-field='vendorName']",
"invoice": "div[data-field='invoiceNumber']", "invoice": "div[data-field='invoiceNumber']",
"amount": "div[data-field='amountDue']",
} }
SCAN_COLUMNS = ["invoicesNumbers", "vendorName", "dueDate", "sendPaymentOn", SCAN_COLUMNS = ["invoicesNumbers", "vendorName", "dueDate", "sendPaymentOn",
@ -43,6 +57,8 @@ DEFAULT_CONFIG = {
"stampli_url": READY_TO_PAY_URL, "stampli_url": READY_TO_PAY_URL,
"date_format": DATE_FMT, "date_format": DATE_FMT,
"days_before_due": DAYS_BEFORE_DUE, "days_before_due": DAYS_BEFORE_DUE,
"browser_mode": "chromium",
"chrome_profile": "",
} }
@ -58,6 +74,38 @@ def load_config():
return dict(DEFAULT_CONFIG) return dict(DEFAULT_CONFIG)
def save_config(config):
with open(CONFIG_PATH, "w") as f:
json.dump(config, f, indent=2)
# ---------------------------------------------------------------------------
# Chrome profile discovery
# ---------------------------------------------------------------------------
def discover_chrome_profiles():
"""Return list of {'dir': str, 'name': str, 'email': str} for each Chrome profile."""
profiles = []
if not CHROME_USER_DATA.is_dir():
return profiles
for entry in sorted(CHROME_USER_DATA.iterdir()):
prefs_file = entry / "Preferences"
if not prefs_file.exists():
continue
dirname = entry.name
if dirname != "Default" and not dirname.startswith("Profile "):
continue
try:
prefs = json.loads(prefs_file.read_text())
name = prefs.get("profile", {}).get("name", dirname)
accounts = prefs.get("account_info", [])
email = accounts[0].get("email", "") if accounts else ""
profiles.append({"dir": dirname, "name": name, "email": email})
except (json.JSONDecodeError, OSError):
profiles.append({"dir": dirname, "name": dirname, "email": ""})
return profiles
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Date helpers # Date helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -78,17 +126,79 @@ def calc_pay_date(due_date: datetime, days_before: int = DAYS_BEFORE_DUE) -> dat
return next_business_day(due_date - timedelta(days=days_before)) return next_business_day(due_date - timedelta(days=days_before))
def clean_amount(text: str) -> str:
"""Strip unicode currency formatting down to plain number + currency code."""
cleaned = re.sub(r"[^\d.,A-Za-z\s-]", "", text)
return re.sub(r"\s+", " ", cleaned).strip()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Browser helpers # Browser helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _chrome_is_running():
try:
result = subprocess.run(
["pgrep", "-x", "Google Chrome"],
capture_output=True, text=True,
)
return result.returncode == 0
except OSError:
return False
def _clean_browser_profile(data_dir, profile_subdir=None):
"""Remove stale lock files and crash markers left by unclean shutdowns."""
base = Path(data_dir)
for name in ("SingletonLock", "SingletonCookie", "SingletonSocket"):
lock = base / name
if lock.exists() or lock.is_symlink():
lock.unlink(missing_ok=True)
prefs_dir = base / profile_subdir if profile_subdir else base / "Default"
prefs_file = prefs_dir / "Preferences"
if prefs_file.exists():
try:
prefs = json.loads(prefs_file.read_text())
profile = prefs.get("profile", {})
if profile.get("exit_type", "") != "Normal":
profile["exit_type"] = "Normal"
profile["exited_cleanly"] = True
prefs["profile"] = profile
prefs_file.write_text(json.dumps(prefs))
except (json.JSONDecodeError, OSError):
pass
def launch_browser(pw): def launch_browser(pw):
return pw.chromium.launch_persistent_context( config = load_config()
user_data_dir=str(BROWSER_DATA_DIR), mode = config.get("browser_mode", "chromium")
headless=False, log.info("Launching browser: mode=%s", mode)
viewport={"width": 1400, "height": 900},
args=["--disable-gpu"], if mode == "chrome":
) chrome_profile = config.get("chrome_profile", "Default")
log.info("Chrome profile: %s", chrome_profile)
if _chrome_is_running():
raise RuntimeError(
"Google Chrome is currently running. "
"Please close Chrome before launching the editor."
)
_clean_browser_profile(CHROME_USER_DATA, chrome_profile)
return pw.chromium.launch_persistent_context(
channel="chrome",
user_data_dir=str(CHROME_USER_DATA),
headless=False,
viewport={"width": 1400, "height": 900},
args=["--disable-gpu", f"--profile-directory={chrome_profile}"],
)
else:
_clean_browser_profile(BROWSER_DATA_DIR)
return pw.chromium.launch_persistent_context(
user_data_dir=str(BROWSER_DATA_DIR),
headless=False,
viewport={"width": 1400, "height": 900},
args=["--disable-gpu"],
)
def active_page(browser): def active_page(browser):
@ -158,6 +268,7 @@ def collect_edit_rows(page, on_progress=None):
pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) pay_cell = row.query_selector(EDIT_COLUMNS["pay"])
vendor_cell = row.query_selector(EDIT_COLUMNS["vendor"]) vendor_cell = row.query_selector(EDIT_COLUMNS["vendor"])
inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) inv_cell = row.query_selector(EDIT_COLUMNS["invoice"])
amt_cell = row.query_selector(EDIT_COLUMNS["amount"])
all_rows.append({ all_rows.append({
"row_id": row_id, "row_id": row_id,
@ -165,6 +276,7 @@ def collect_edit_rows(page, on_progress=None):
"vendor": (vendor_cell.inner_text().strip()[:40] if vendor_cell else ""), "vendor": (vendor_cell.inner_text().strip()[:40] if vendor_cell else ""),
"due_text": (due_cell.inner_text().strip().split("\n")[0] if due_cell else ""), "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 ""), "pay_text": (pay_cell.inner_text().strip().split("\n")[0] if pay_cell else ""),
"amount": clean_amount(amt_cell.inner_text().strip().split("\n")[0]) if amt_cell else "",
}) })
if new_this_scroll == 0: if new_this_scroll == 0:
@ -252,6 +364,38 @@ def compute_changes(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE):
return changes return changes
def parse_import_csv(path, date_fmt=DATE_FMT):
"""Read an exported CSV and return a changes list matched by invoice number."""
with open(path, newline="", encoding="utf-8-sig") as f:
reader = csv.DictReader(f)
headers = reader.fieldnames or []
for required in ("Invoice", "New Pay"):
if required not in headers:
raise ValueError(f"CSV missing required column: {required}")
changes = []
for i, row in enumerate(reader, start=2):
invoice = row["Invoice"].strip()
new_pay = row["New Pay"].strip()
if not invoice or not new_pay:
continue
try:
datetime.strptime(new_pay, date_fmt)
except ValueError:
raise ValueError(f"Row {i}: invalid date '{new_pay}' (expected {date_fmt})")
changes.append({
"row_id": row.get("Row ID", "").strip(),
"invoice": invoice,
"vendor": row.get("Vendor", "").strip(),
"amount": row.get("Amount", "").strip(),
"due_text": row.get("Due Date", "").strip(),
"pay_text": row.get("Current Pay", "").strip(),
"new_pay": new_pay,
})
return changes
def compute_scan_results(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE): def compute_scan_results(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE):
"""Pure function: compare actual vs expected pay dates. Returns (results, incorrect).""" """Pure function: compare actual vs expected pay dates. Returns (results, incorrect)."""
results = [] results = []
@ -300,18 +444,46 @@ def find_row(page, scroller, row_id, start_scroll=0):
return None, scroll_top return None, scroll_top
def _to_iso_date(date_str, date_fmt=DATE_FMT):
"""Convert MM/DD/YYYY to YYYY-MM-DD for native date input .fill()."""
dt = datetime.strptime(date_str, date_fmt)
return dt.strftime("%Y-%m-%d")
def try_edit_cell(page, row, new_date_str): def try_edit_cell(page, row, new_date_str):
pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) pay_cell = row.query_selector(EDIT_COLUMNS["pay"])
if not pay_cell: if not pay_cell:
log.warning(" pay cell not found on row")
return False return False
pay_text_before = pay_cell.inner_text().strip().split("\n")[0]
log.debug(" pay cell found, current value: %r", pay_text_before)
pay_cell.scroll_into_view_if_needed()
time.sleep(0.15)
calendar_icon = pay_cell.query_selector("i.fa-calendar, [data-test-id='test-icon-calendar']") calendar_icon = pay_cell.query_selector("i.fa-calendar, [data-test-id='test-icon-calendar']")
if calendar_icon: if calendar_icon:
log.debug(" clicking calendar icon")
calendar_icon.click() calendar_icon.click()
else: else:
log.debug(" no calendar icon, double-clicking cell")
pay_cell.dblclick() pay_cell.dblclick()
time.sleep(0.5) time.sleep(0.4)
# Log what the editing cell looks like
editing_cell = page.query_selector(".MuiDataGrid-cell--editing")
if editing_cell:
all_inputs = editing_cell.query_selector_all("input")
log.debug(" editing cell has %d input(s)", len(all_inputs))
for idx, inp in enumerate(all_inputs):
inp_info = inp.evaluate(
"el => JSON.stringify({type: el.type, value: el.value, placeholder: el.placeholder})"
)
log.debug(" input[%d]: %s", idx, inp_info)
else:
log.warning(" no .MuiDataGrid-cell--editing found")
date_input = page.query_selector( date_input = page.query_selector(
".MuiDataGrid-cell--editing input, " ".MuiDataGrid-cell--editing input, "
@ -324,23 +496,50 @@ def try_edit_cell(page, row, new_date_str):
) )
if date_input: if date_input:
date_input.fill("") input_type = date_input.evaluate("el => el.type")
date_input.fill(new_date_str) log.info(" date input type=%s", input_type)
time.sleep(0.3)
date_input.press("Enter") if input_type == "date":
# Native date input: .fill() requires ISO format YYYY-MM-DD
iso_date = _to_iso_date(new_date_str)
log.info(" using .fill() with ISO date: %s", iso_date)
date_input.fill(iso_date)
else:
# Text input (Pikaday or similar): clear and type MM/DD/YYYY
log.info(" using triple-click + type for text input")
date_input.click(click_count=3)
time.sleep(0.15)
page.keyboard.type(new_date_str, delay=30)
time.sleep(0.2)
log.debug(" pressing Enter")
page.keyboard.press("Enter")
else: else:
page.keyboard.type(new_date_str) log.warning(" no date input found — trying keyboard type as fallback")
page.keyboard.type(new_date_str, delay=30)
time.sleep(0.15)
page.keyboard.press("Enter") page.keyboard.press("Enter")
time.sleep(0.5) time.sleep(0.4)
# Dismiss any date picker popup that may still be open
page.keyboard.press("Escape") page.keyboard.press("Escape")
time.sleep(0.3) time.sleep(0.2)
# Click away from the cell to deselect it
page.mouse.click(0, 0) page.mouse.click(0, 0)
time.sleep(0.3) time.sleep(0.2)
# Verify the edit took effect
try:
pay_cell_after = row.query_selector(EDIT_COLUMNS["pay"])
if pay_cell_after:
pay_text_after = pay_cell_after.inner_text().strip().split("\n")[0]
log.debug(" after edit: %r (wanted %r)", pay_text_after, new_date_str)
if pay_text_after != new_date_str:
log.warning(" MISMATCH: cell shows %r but wanted %r", pay_text_after, new_date_str)
else:
log.debug(" row detached after edit (expected with virtual scroll)")
except Exception:
log.debug(" row detached after edit (expected with virtual scroll)")
return True return True
@ -361,13 +560,16 @@ def edit_single_row(page, row_id, new_date_str, last_scroll=0, max_attempts=3):
return False, scroll_pos return False, scroll_pos
def apply_edits(page, changes, on_progress=None): def apply_edits(page, changes, on_progress=None, stop_check=None):
"""Edit rows and retry failures. Returns (success_count, failed_list).""" """Edit rows and retry failures. Returns (success_count, failed_list)."""
success = 0 success = 0
failed = [] failed = []
last_scroll = 0 last_scroll = 0
for i, c in enumerate(changes): for i, c in enumerate(changes):
if stop_check and stop_check():
failed.extend(changes[i:])
break
try: try:
ok, last_scroll = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll) ok, last_scroll = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll)
if ok: if ok:
@ -383,9 +585,12 @@ def apply_edits(page, changes, on_progress=None):
if on_progress: if on_progress:
on_progress(i + 1, len(changes), c["invoice"], False, str(e)) on_progress(i + 1, len(changes), c["invoice"], False, str(e))
if failed: if failed and not (stop_check and stop_check()):
retry_failed = [] retry_failed = []
for c in failed: for c in failed:
if stop_check and stop_check():
retry_failed.extend(failed[failed.index(c):])
break
try: try:
ok, _ = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll=0) ok, _ = edit_single_row(page, c["row_id"], c["new_pay"], last_scroll=0)
if ok: if ok:
@ -405,6 +610,213 @@ def apply_edits(page, changes, on_progress=None):
return success, failed return success, failed
def apply_edits_by_invoice(page, changes, on_progress=None, stop_check=None):
"""Scroll through the grid sequentially, matching rows by invoice number.
After each edit the grid re-renders, so we re-query rows at the current
scroll position instead of continuing with stale element handles.
stop_check: callable returning True if the user requested a stop.
"""
pending = {c["invoice"]: c["new_pay"] for c in changes if c.get("invoice")}
if not pending:
log.info("No invoices to process")
return 0, list(changes)
scroller = page.query_selector(SCROLLER_SELECTOR)
if not scroller:
log.error("Scroller element not found")
return 0, list(changes)
success = 0
seen_invoices = set()
scroll_top = 0
stale_count = 0
total = len(pending)
log.info("Starting invoice-match edit: %d invoices to process", total)
while pending:
if stop_check and stop_check():
log.info("Stop requested by user")
break
scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top)
time.sleep(0.3)
visible_rows = page.query_selector_all(ROW_SELECTOR)
log.debug("scroll_top=%d, %d visible rows, %d pending, stale_count=%d",
scroll_top, len(visible_rows), len(pending), stale_count)
edited_this_position = True
while edited_this_position:
if stop_check and stop_check():
log.info("Stop requested by user (inner loop)")
break
edited_this_position = False
rows = page.query_selector_all(ROW_SELECTOR)
log.debug(" re-queried %d rows at scroll_top=%d", len(rows), scroll_top)
visible_invoices = []
for row in rows:
try:
inv_cell = row.query_selector(EDIT_COLUMNS["invoice"])
if not inv_cell:
continue
invoice = inv_cell.inner_text().strip()
visible_invoices.append(invoice)
except Exception as e:
log.debug(" row read failed (detached?): %s", e)
continue
if invoice in seen_invoices:
continue
if invoice not in pending:
seen_invoices.add(invoice)
continue
new_pay = pending[invoice]
log.info("MATCH: invoice=%s, setting pay=%s", invoice, new_pay)
time.sleep(0.15)
try:
if try_edit_cell(page, row, new_pay):
success += 1
del pending[invoice]
seen_invoices.add(invoice)
if on_progress:
on_progress(success, total, invoice, True)
edited_this_position = True
log.info(" edit OK (%d/%d done, %d remaining)", success, total, len(pending))
break
else:
seen_invoices.add(invoice)
log.warning(" edit returned False for %s", invoice)
if on_progress:
on_progress(success, total, invoice, False, "edit failed")
except Exception as e:
seen_invoices.add(invoice)
log.error(" edit exception for %s: %s", invoice, e)
if on_progress:
on_progress(success, total, invoice, False, str(e))
page.keyboard.press("Escape")
time.sleep(0.2)
break
if not edited_this_position:
log.debug(" no edits at scroll_top=%d, visible invoices: %s",
scroll_top, visible_invoices[:5])
found_new = False
for row in page.query_selector_all(ROW_SELECTOR):
try:
inv_cell = row.query_selector(EDIT_COLUMNS["invoice"])
if inv_cell:
inv = inv_cell.inner_text().strip()
if inv and inv not in seen_invoices:
found_new = True
break
except Exception:
continue
if found_new:
stale_count = 0
else:
stale_count += 1
log.debug(" no new invoices at scroll_top=%d (stale_count=%d)", scroll_top, stale_count)
if stale_count >= 3:
log.info("End of grid reached (3 consecutive stale scrolls)")
break
scroll_top += 200
failed = [c for c in changes if c.get("invoice") in pending]
log.info("Finished: %d/%d succeeded, %d failed", success, total, len(failed))
if failed:
log.info("Failed invoices: %s", [c["invoice"] for c in failed])
return success, failed
# ---------------------------------------------------------------------------
# Post-edit audit
# ---------------------------------------------------------------------------
def audit_edits(page, changes, on_progress=None, stop_check=None):
"""Scroll through the grid and verify that edited invoices kept their new pay dates."""
expected = {c["invoice"]: c["new_pay"] for c in changes if c.get("invoice")}
if not expected:
return [], []
scroller = page.query_selector(SCROLLER_SELECTOR)
if not scroller:
log.error("Scroller element not found for audit")
return [], list(changes)
confirmed = []
reverted = []
seen = set()
scroll_top = 0
stale_count = 0
log.info("Audit: verifying %d invoices", len(expected))
while len(seen) < len(expected):
if stop_check and stop_check():
log.info("Audit stopped by user")
break
scroller.evaluate("(el, top) => el.scrollTop = top", scroll_top)
time.sleep(0.3)
rows = page.query_selector_all(ROW_SELECTOR)
found_new = False
for row in rows:
try:
inv_cell = row.query_selector(EDIT_COLUMNS["invoice"])
if not inv_cell:
continue
invoice = inv_cell.inner_text().strip()
if not invoice or invoice in seen:
continue
if invoice not in expected:
continue
found_new = True
seen.add(invoice)
pay_cell = row.query_selector(EDIT_COLUMNS["pay"])
actual = pay_cell.inner_text().strip().split("\n")[0] if pay_cell else ""
want = expected[invoice]
if actual == want:
confirmed.append({"invoice": invoice, "expected": want, "actual": actual})
log.debug(" AUDIT OK: %s = %s", invoice, actual)
else:
reverted.append({"invoice": invoice, "expected": want, "actual": actual})
log.warning(" AUDIT REVERTED: %s expected=%s actual=%s", invoice, want, actual)
if on_progress:
on_progress(len(seen), len(expected), len(reverted))
except Exception as e:
log.debug(" audit row read failed: %s", e)
continue
if found_new:
stale_count = 0
else:
stale_count += 1
if stale_count >= 3:
log.info("Audit: end of grid reached")
break
scroll_top += 200
not_found = [inv for inv in expected if inv not in seen]
if not_found:
log.warning("Audit: %d invoices not found in grid: %s", len(not_found), not_found[:10])
log.info("Audit complete: %d confirmed, %d reverted, %d not found",
len(confirmed), len(reverted), len(not_found))
return confirmed, reverted, not_found
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Filter / Select / Pay # Filter / Select / Pay
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -3,6 +3,7 @@
VERSION = "1.0.0" VERSION = "1.0.0"
import csv
import os import os
import sys import sys
import queue import queue
@ -26,7 +27,8 @@ from playwright.sync_api import sync_playwright
from stampli_core import ( from stampli_core import (
launch_browser, navigate, active_page, wait_for_login, needs_login, launch_browser, navigate, active_page, wait_for_login, needs_login,
collect_edit_rows, collect_scan_rows, compute_changes, compute_scan_results, collect_edit_rows, collect_scan_rows, compute_changes, compute_scan_results,
apply_edits, filter_select_and_pay, load_config, apply_edits, apply_edits_by_invoice, audit_edits, filter_select_and_pay,
load_config, save_config, parse_import_csv, discover_chrome_profiles,
READY_TO_PAY_URL, PENDING_APPROVAL_URL, DATE_FMT, READY_TO_PAY_URL, PENDING_APPROVAL_URL, DATE_FMT,
) )
@ -44,6 +46,7 @@ class StampliApp:
self.pw_ctx = None self.pw_ctx = None
self._confirm_result = False self._confirm_result = False
self._running = False self._running = False
self._stop_requested = False
self._build_ui() self._build_ui()
self.root.after(100, self._poll_queue) self.root.after(100, self._poll_queue)
@ -72,10 +75,22 @@ class StampliApp:
self.run_btn = ttk.Button(ctrl, text="Run", command=self.on_run) self.run_btn = ttk.Button(ctrl, text="Run", command=self.on_run)
self.run_btn.pack(side="left", padx=5) self.run_btn.pack(side="left", padx=5)
self.stop_btn = ttk.Button(ctrl, text="Stop", command=self.on_stop, state="disabled")
self.stop_btn.pack(side="left", padx=5)
self.export_btn = ttk.Button(ctrl, text="Export CSV", command=self.on_export,
state="disabled")
self.export_btn.pack(side="left", padx=5)
self.import_btn = ttk.Button(ctrl, text="Import CSV", command=self.on_import)
self.import_btn.pack(side="left", padx=5)
self.close_btn = ttk.Button(ctrl, text="Close Browser", command=self.on_close_browser, self.close_btn = ttk.Button(ctrl, text="Close Browser", command=self.on_close_browser,
state="disabled") state="disabled")
self.close_btn.pack(side="left", padx=5) self.close_btn.pack(side="left", padx=5)
ttk.Button(ctrl, text="Settings", command=self.on_settings).pack(side="right", padx=5)
# --- Status bar --- # --- Status bar ---
self.status_var = tk.StringVar(value="Ready") self.status_var = tk.StringVar(value="Ready")
status_bar = ttk.Label(self.root, textvariable=self.status_var, relief="sunken", status_bar = ttk.Label(self.root, textvariable=self.status_var, relief="sunken",
@ -159,8 +174,13 @@ class StampliApp:
def _on_done(self, message): def _on_done(self, message):
self._running = False self._running = False
self._stop_requested = False
self.run_btn.configure(state="normal") self.run_btn.configure(state="normal")
self.stop_btn.configure(state="disabled")
self.import_btn.configure(state="normal")
self.close_btn.configure(state="normal") self.close_btn.configure(state="normal")
if self.tree.get_children():
self.export_btn.configure(state="normal")
self.mode_var.set(self.mode_var.get()) # re-enable radios self.mode_var.set(self.mode_var.get()) # re-enable radios
self.status_var.set(message or "Done") self.status_var.set(message or "Done")
@ -170,23 +190,25 @@ class StampliApp:
def _populate_edit_table(self, changes): def _populate_edit_table(self, changes):
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
cols = ("invoice", "vendor", "due", "current_pay", "new_pay") cols = ("invoice", "vendor", "amount", "due", "current_pay", "new_pay")
self.tree["columns"] = cols self.tree["columns"] = cols
self.tree.heading("invoice", text="Invoice") self.tree.heading("invoice", text="Invoice")
self.tree.heading("vendor", text="Vendor") self.tree.heading("vendor", text="Vendor")
self.tree.heading("amount", text="Amount")
self.tree.heading("due", text="Due Date") self.tree.heading("due", text="Due Date")
self.tree.heading("current_pay", text="Current Pay") self.tree.heading("current_pay", text="Current Pay")
self.tree.heading("new_pay", text="New Pay") self.tree.heading("new_pay", text="New Pay")
self.tree.column("invoice", width=140, minwidth=80) self.tree.column("invoice", width=130, minwidth=80)
self.tree.column("vendor", width=250, minwidth=120) self.tree.column("vendor", width=220, minwidth=120)
self.tree.column("amount", width=100, minwidth=70)
self.tree.column("due", width=100, minwidth=80) self.tree.column("due", width=100, minwidth=80)
self.tree.column("current_pay", width=120, minwidth=80) self.tree.column("current_pay", width=110, minwidth=80)
self.tree.column("new_pay", width=100, minwidth=80) self.tree.column("new_pay", width=100, minwidth=80)
for c in changes: for c in changes:
self.tree.insert("", "end", values=( self.tree.insert("", "end", iid=c.get("row_id", ""), values=(
c["invoice"], c["vendor"], c["due_text"], c["invoice"], c["vendor"], c.get("amount", ""),
c["pay_text"] or "(empty)", c["new_pay"], c["due_text"], c["pay_text"] or "(empty)", c["new_pay"],
)) ))
def _populate_scan_table(self, results): def _populate_scan_table(self, results):
@ -231,15 +253,123 @@ class StampliApp:
self._post("manual_fallback", (prompt, event)) self._post("manual_fallback", (prompt, event))
event.wait() event.wait()
# ------------------------------------------------------------------
# Settings
# ------------------------------------------------------------------
def on_settings(self):
config = load_config()
dlg = tk.Toplevel(self.root)
dlg.title("Settings")
dlg.geometry("420x200")
dlg.resizable(False, False)
dlg.transient(self.root)
dlg.grab_set()
frame = ttk.Frame(dlg, padding=15)
frame.pack(fill="both", expand=True)
# Browser mode
ttk.Label(frame, text="Browser:").grid(row=0, column=0, sticky="w", pady=(0, 8))
mode_var = tk.StringVar(value=config.get("browser_mode", "chromium"))
mode_frame = ttk.Frame(frame)
mode_frame.grid(row=0, column=1, sticky="w", pady=(0, 8))
ttk.Radiobutton(mode_frame, text="Chromium (standalone)",
variable=mode_var, value="chromium",
command=lambda: profile_combo.configure(state="disabled")).pack(anchor="w")
ttk.Radiobutton(mode_frame, text="Chrome (existing profile)",
variable=mode_var, value="chrome",
command=lambda: profile_combo.configure(state="readonly")).pack(anchor="w")
# Chrome profile picker
ttk.Label(frame, text="Chrome Profile:").grid(row=1, column=0, sticky="w", pady=(0, 8))
profiles = discover_chrome_profiles()
profile_labels = []
profile_dirs = []
for p in profiles:
label = p["email"] or p["name"]
if p["email"] and p["name"] != p["dir"]:
label = f"{p['email']}"
profile_labels.append(f"{p['dir']} — {label}")
profile_dirs.append(p["dir"])
profile_var = tk.StringVar()
saved_profile = config.get("chrome_profile", "")
if saved_profile in profile_dirs:
profile_var.set(profile_labels[profile_dirs.index(saved_profile)])
elif profile_labels:
profile_var.set(profile_labels[0])
profile_combo = ttk.Combobox(frame, textvariable=profile_var,
values=profile_labels, state="readonly", width=35)
profile_combo.grid(row=1, column=1, sticky="w", pady=(0, 8))
if mode_var.get() != "chrome":
profile_combo.configure(state="disabled")
def on_save():
config["browser_mode"] = mode_var.get()
sel = profile_var.get()
for i, label in enumerate(profile_labels):
if label == sel:
config["chrome_profile"] = profile_dirs[i]
break
save_config(config)
self._append_log(f"Settings saved: browser={config['browser_mode']}"
+ (f", profile={config.get('chrome_profile', '')}"
if config["browser_mode"] == "chrome" else ""))
dlg.destroy()
btn_frame = ttk.Frame(frame)
btn_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0))
ttk.Button(btn_frame, text="Save", command=on_save).pack(side="left", padx=5)
ttk.Button(btn_frame, text="Cancel", command=dlg.destroy).pack(side="left", padx=5)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Run button # Run button
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def on_export(self):
from tkinter import filedialog
cols = self.tree["columns"]
headers = ["Row ID"] + [self.tree.heading(c, "text") for c in cols]
iids = self.tree.get_children()
rows = [[iid] + list(self.tree.item(iid, "values")) for iid in iids]
if not rows:
return
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
default_name = f"stampli_export_{timestamp}.csv"
path = filedialog.asksaveasfilename(
defaultextension=".csv",
filetypes=[("CSV files", "*.csv")],
initialfile=default_name,
)
if not path:
return
with open(path, "w", newline="", encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(headers)
writer.writerows(rows)
self._append_log(f"Exported {len(rows)} rows to {path}")
self.status_var.set(f"Exported to {os.path.basename(path)}")
def on_stop(self):
if self._running:
self._stop_requested = True
self.stop_btn.configure(state="disabled")
self._append_log("Stop requested — finishing current edit...")
self.status_var.set("Stopping...")
def on_run(self): def on_run(self):
if self._running: if self._running:
return return
self._running = True self._running = True
self._stop_requested = False
self.run_btn.configure(state="disabled") self.run_btn.configure(state="disabled")
self.stop_btn.configure(state="normal")
self.export_btn.configure(state="disabled")
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
self.log_text.configure(state="normal") self.log_text.configure(state="normal")
self.log_text.delete("1.0", "end") self.log_text.delete("1.0", "end")
@ -335,10 +465,27 @@ class StampliApp:
if i > 0: if i > 0:
self._post("status", f"Editing... {i}/{total}") self._post("status", f"Editing... {i}/{total}")
success, failed = apply_edits(page, changes, on_progress=on_edit_progress) success, failed = apply_edits(page, changes, on_progress=on_edit_progress,
stop_check=lambda: self._stop_requested)
self._post("log", f"Done: {success}/{len(changes)} updated, {len(failed)} failed.") self._post("log", f"Done: {success}/{len(changes)} updated, {len(failed)} failed.")
if success > 0: if success > 0 and not self._stop_requested:
self._post("status", "Auditing edits...")
self._post("log", "Verifying edits stuck (scrolling through grid)...")
edited = [c for c in changes if c not in failed]
def on_audit_progress(checked, total, reverted_count):
self._post("status", f"Auditing... {checked}/{total} ({reverted_count} reverted)")
confirmed, reverted, not_found = audit_edits(
page, edited,
on_progress=on_audit_progress,
stop_check=lambda: self._stop_requested,
)
self._post("log", f"Audit: {len(confirmed)} confirmed, {len(reverted)} reverted, {len(not_found)} not found")
for r in reverted:
self._post("log", f" REVERTED: {r['invoice']} — expected {r['expected']}, shows {r['actual']}")
if self._ask_confirm("Filter to error-free rows, select all, and click Pay Invoices?"): if self._ask_confirm("Filter to error-free rows, select all, and click Pay Invoices?"):
self._post("status", "Running filter/select/pay...") self._post("status", "Running filter/select/pay...")
filter_select_and_pay( filter_select_and_pay(
@ -366,13 +513,147 @@ class StampliApp:
f" WRONG: {r['invoicesNumbers']} — " f" WRONG: {r['invoicesNumbers']} — "
f"pay={r['sendPaymentOn']} expected={r['expected']}") f"pay={r['sendPaymentOn']} expected={r['expected']}")
# ------------------------------------------------------------------
# Import CSV
# ------------------------------------------------------------------
def on_import(self):
if self._running:
return
from tkinter import filedialog
path = filedialog.askopenfilename(
filetypes=[("CSV files", "*.csv")],
)
if not path:
return
try:
changes = parse_import_csv(path)
except ValueError as e:
messagebox.showerror("Import Error", str(e))
return
if not changes:
messagebox.showinfo("Import", "No rows found in CSV.")
return
self._imported_changes = changes
self._post("table_edit", changes)
self._append_log(f"Imported {len(changes)} rows from {os.path.basename(path)}")
self.export_btn.configure(state="normal")
if not messagebox.askyesno("Confirm", f"Apply {len(changes)} imported pay dates?"):
self._append_log("Import aborted by user.")
self.status_var.set("Import cancelled.")
return
self._running = True
self._stop_requested = False
self.run_btn.configure(state="disabled")
self.stop_btn.configure(state="normal")
self.import_btn.configure(state="disabled")
self.export_btn.configure(state="disabled")
self.worker_thread = threading.Thread(
target=self._run_import,
args=(changes,),
daemon=True,
)
self.worker_thread.start()
def _run_import(self, changes):
try:
if not self.browser_ctx:
self._post("status", "Launching browser...")
self._post("log", "Launching browser...")
self.pw_ctx = sync_playwright().start()
self.browser_ctx = launch_browser(self.pw_ctx)
navigate(self.browser_ctx, READY_TO_PAY_URL)
self._post("log", f"Navigating to {READY_TO_PAY_URL}")
time.sleep(3)
page = active_page(self.browser_ctx)
if needs_login(page):
self._post("status", "Waiting for login...")
self._post("log", "Please log in to Stampli in the browser window.")
if not wait_for_login(self.browser_ctx, on_status=lambda m: self._post("log", m)):
self._post("done", "Login timed out.")
return
self._post("log", "Waiting for page to settle...")
time.sleep(3)
page = active_page(self.browser_ctx)
self._post("status", "Scrolling through grid, matching invoices...")
self._post("log", f"Applying {len(changes)} imported pay dates (matching by invoice)...")
self._post("table_edit", changes)
def on_edit_progress(done, total, inv, ok, info=""):
status = "Updated" if ok else "MISSED"
msg = f"[{done}/{total}] {status}: {inv}"
if info:
msg += f" ({info})"
self._post("log", msg)
self._post("status", f"Applying... {done}/{total}")
success, failed = apply_edits_by_invoice(
page, changes,
on_progress=on_edit_progress,
stop_check=lambda: self._stop_requested,
)
if self._stop_requested:
self._post("log", f"Stopped: {success}/{len(changes)} updated before stop.")
else:
self._post("log", f"Done: {success}/{len(changes)} updated, {len(failed)} failed.")
if success > 0 and not self._stop_requested:
self._post("status", "Auditing edits...")
self._post("log", "Verifying edits stuck (scrolling through grid)...")
edited = [c for c in changes if c.get("invoice") not in {f["invoice"] for f in failed}]
def on_audit_progress(checked, total, reverted_count):
self._post("status", f"Auditing... {checked}/{total} ({reverted_count} reverted)")
confirmed, reverted, not_found = audit_edits(
page, edited,
on_progress=on_audit_progress,
stop_check=lambda: self._stop_requested,
)
self._post("log", f"Audit: {len(confirmed)} confirmed, {len(reverted)} reverted, {len(not_found)} not found")
for r in reverted:
self._post("log", f" REVERTED: {r['invoice']} — expected {r['expected']}, shows {r['actual']}")
if self._ask_confirm("Filter to error-free rows, select all, and click Pay Invoices?"):
self._post("status", "Running filter/select/pay...")
filter_select_and_pay(
page,
on_status=lambda m: (self._post("log", m), self._post("status", m)),
on_manual_fallback=self._manual_fallback,
)
self._post("done", "Complete — browser still open. Click 'Close Browser' when done.")
self.root.after(0, lambda: self.close_btn.configure(state="normal"))
except Exception as e:
self._post("log", f"ERROR: {e}")
self._post("log", traceback.format_exc())
self._post("done", f"Error: {e}")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Browser cleanup # Browser cleanup
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def on_close_browser(self): def on_close_browser(self):
# Stop any running operation first
if self._running:
self._stop_requested = True
if self.browser_ctx: if self.browser_ctx:
try: try:
for pg in self.browser_ctx.pages:
try:
pg.close()
except Exception:
pass
self.browser_ctx.close() self.browser_ctx.close()
except Exception: except Exception:
pass pass
@ -383,6 +664,19 @@ class StampliApp:
except Exception: except Exception:
pass pass
self.pw_ctx = None self.pw_ctx = None
# Kill any lingering browser processes
import subprocess
try:
subprocess.run(["pkill", "-f", "chromium.*--remote-debugging"], capture_output=True, timeout=3)
except Exception:
pass
self._running = False
self._stop_requested = False
self.run_btn.configure(state="normal")
self.stop_btn.configure(state="disabled")
self.import_btn.configure(state="normal")
self.close_btn.configure(state="disabled") self.close_btn.configure(state="disabled")
self.status_var.set("Browser closed.") self.status_var.set("Browser closed.")
self._append_log("Browser closed.") self._append_log("Browser closed.")