Add CI workflow and apply ruff formatting

This commit is contained in:
Adam Moussa 2026-05-08 15:44:35 -04:00
parent 0a75d4e7ce
commit f3fee37e80
6 changed files with 460 additions and 159 deletions

11
.github/workflows/ci.yaml vendored Normal file
View file

@ -0,0 +1,11 @@
name: CI
on:
pull_request:
branches: [main]
jobs:
ci:
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-sam.yaml@main
with:
source-dirs: "."
run-sam-validate: false

View file

@ -10,16 +10,22 @@ Usage:
""" """
import argparse import argparse
import sys
from datetime import datetime
from pathlib import Path from pathlib import Path
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
from stampli_core import ( from stampli_core import (
load_config, calc_pay_date, active_page, launch_browser, navigate, load_config,
wait_for_login, needs_login, collect_edit_rows, compute_changes, active_page,
apply_edits, filter_select_and_pay, READY_TO_PAY_URL, launch_browser,
ROW_SELECTOR, EDIT_COLUMNS, SCROLLER_SELECTOR, navigate,
wait_for_login,
needs_login,
collect_edit_rows,
compute_changes,
apply_edits,
filter_select_and_pay,
ROW_SELECTOR,
EDIT_COLUMNS,
) )
@ -47,13 +53,25 @@ Type 'snap' to save page HTML, 'scan' to preview rows, 'done' to exit.
for i, row in enumerate(rows[:10]): for i, row in enumerate(rows[:10]):
due_cell = row.query_selector(EDIT_COLUMNS["due"]) due_cell = row.query_selector(EDIT_COLUMNS["due"])
pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) pay_cell = row.query_selector(EDIT_COLUMNS["pay"])
due_text = due_cell.inner_text().strip().split("\n")[0] if due_cell else "?" due_text = (
pay_text = pay_cell.inner_text().strip().split("\n")[0] if pay_cell else "?" 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_cell = row.query_selector(EDIT_COLUMNS["vendor"])
vendor = vendor_cell.inner_text().strip()[:30] if vendor_cell else "?" vendor = (
vendor_cell.inner_text().strip()[:30] if vendor_cell else "?"
)
inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) inv_cell = row.query_selector(EDIT_COLUMNS["invoice"])
inv = inv_cell.inner_text().strip()[:20] if inv_cell else "?" 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}") print(
f" Row {i}: inv={inv} vendor={vendor} due={due_text} pay={pay_text}"
)
elif cmd == "columns": elif cmd == "columns":
headers = page.query_selector_all("[role='columnheader']") headers = page.query_selector_all("[role='columnheader']")
for i, h in enumerate(headers): for i, h in enumerate(headers):
@ -83,11 +101,15 @@ 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} {'Amount':<12s} {'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} {'-' * 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.get('amount', ''):<12s} {c['due_text']:<12s} " print(
f"{c['pay_text'] or '(empty)':<16s} {c['new_pay']:<12s}") 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: if dry_run:
return return
@ -109,7 +131,13 @@ def bulk_edit(page, config, dry_run=False):
print(f"\nDone: {success}/{len(changes)} rows updated.") print(f"\nDone: {success}/{len(changes)} rows updated.")
if success > 0: if success > 0:
submit = input("\nFilter to error-free rows, select all, and click Pay Invoices? [y/N]: ").strip().lower() submit = (
input(
"\nFilter to error-free rows, select all, and click Pay Invoices? [y/N]: "
)
.strip()
.lower()
)
if submit == "y": if submit == "y":
filter_select_and_pay(page, on_status=print) filter_select_and_pay(page, on_status=print)
@ -130,6 +158,7 @@ def main():
navigate(browser, url) navigate(browser, url)
import time import time
time.sleep(3) time.sleep(3)
page = active_page(browser) page = active_page(browser)
if needs_login(page): if needs_login(page):

View file

@ -9,6 +9,7 @@ from PIL import Image, ImageDraw, ImageFont
SIZE = 1024 SIZE = 1024
def draw_icon(size=SIZE): def draw_icon(size=SIZE):
img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(img) draw = ImageDraw.Draw(img)
@ -73,8 +74,12 @@ def draw_icon(size=SIZE):
dollar_y = int(660 * s) dollar_y = int(660 * s)
dollar_size = int(110 * s) dollar_size = int(110 * s)
draw.ellipse( draw.ellipse(
[dollar_x - dollar_size, dollar_y - dollar_size, [
dollar_x + dollar_size, dollar_y + dollar_size], dollar_x - dollar_size,
dollar_y - dollar_size,
dollar_x + dollar_size,
dollar_y + dollar_size,
],
fill=accent, fill=accent,
) )
try: try:
@ -88,8 +93,12 @@ def draw_icon(size=SIZE):
badge_cy = int(750 * s) badge_cy = int(750 * s)
badge_r = int(85 * s) badge_r = int(85 * s)
draw.ellipse( draw.ellipse(
[badge_cx - badge_r, badge_cy - badge_r, [
badge_cx + badge_r, badge_cy + badge_r], badge_cx - badge_r,
badge_cy - badge_r,
badge_cx + badge_r,
badge_cy + badge_r,
],
fill=(46, 204, 113), fill=(46, 204, 113),
outline=bg_color, outline=bg_color,
width=int(12 * s), width=int(12 * s),
@ -133,7 +142,9 @@ def make_icns(img, output_path):
if sz <= 512: if sz <= 512:
double = img.resize((sz * 2, sz * 2), Image.LANCZOS) double = img.resize((sz * 2, sz * 2), Image.LANCZOS)
double.save(os.path.join(iconset, f"icon_{sz}x{sz}@2x.png")) double.save(os.path.join(iconset, f"icon_{sz}x{sz}@2x.png"))
subprocess.run(["iconutil", "-c", "icns", iconset, "-o", output_path], check=True) subprocess.run(
["iconutil", "-c", "icns", iconset, "-o", output_path], check=True
)
if __name__ == "__main__": if __name__ == "__main__":

View file

@ -13,8 +13,14 @@ from pathlib import Path
from playwright.sync_api import sync_playwright 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,
collect_scan_rows, compute_scan_results, PENDING_APPROVAL_URL, DATE_FMT, navigate,
active_page,
wait_for_login,
needs_login,
collect_scan_rows,
compute_scan_results,
PENDING_APPROVAL_URL,
) )
@ -51,18 +57,26 @@ def main():
results, incorrect = compute_scan_results(all_rows) results, incorrect = compute_scan_results(all_rows)
print(f" {'Invoice':<20s} {'Vendor':<35s} {'Due':<12s} {'Pay Date':<12s} {'Expected':<12s} {'Status'}") print(
f" {'Invoice':<20s} {'Vendor':<35s} {'Due':<12s} {'Pay Date':<12s} {'Expected':<12s} {'Status'}"
)
print(f" {'-' * 20} {'-' * 35} {'-' * 12} {'-' * 12} {'-' * 12} {'-' * 8}") print(f" {'-' * 20} {'-' * 35} {'-' * 12} {'-' * 12} {'-' * 12} {'-' * 8}")
for r in results: for r in results:
print(f" {r['invoicesNumbers']:<20s} {r['vendorName'][:35]:<35s} " print(
f"{r['dueDate']:<12s} {r['sendPaymentOn']:<12s} {r['expected']:<12s} {r['status']}") f" {r['invoicesNumbers']:<20s} {r['vendorName'][:35]:<35s} "
f"{r['dueDate']:<12s} {r['sendPaymentOn']:<12s} {r['expected']:<12s} {r['status']}"
)
print(f"\n{len(incorrect)} rows with incorrect pay dates out of {len(all_rows)} total.") print(
f"\n{len(incorrect)} rows with incorrect pay dates out of {len(all_rows)} total."
)
if incorrect: if incorrect:
print(f"\nIncorrect rows:") print("\nIncorrect rows:")
for r in incorrect: for r in incorrect:
print(f" {r['invoicesNumbers']:<20s} {r['vendorName'][:35]:<35s} " print(
f"pay={r['sendPaymentOn']} (due={r['dueDate']})") f" {r['invoicesNumbers']:<20s} {r['vendorName'][:35]:<35s} "
f"pay={r['sendPaymentOn']} (due={r['dueDate']})"
)
input("\nPress Enter to close the browser...") input("\nPress Enter to close the browser...")
browser.close() browser.close()

View file

@ -11,7 +11,6 @@ from pathlib import Path
import subprocess import subprocess
from playwright.sync_api import sync_playwright
PROJECT_DIR = Path(__file__).parent 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"
@ -22,7 +21,9 @@ LOG_PATH = _APP_SUPPORT / "debug.log"
log = logging.getLogger("stampli") log = logging.getLogger("stampli")
log.setLevel(logging.DEBUG) log.setLevel(logging.DEBUG)
_fh = logging.FileHandler(LOG_PATH, mode="w") _fh = logging.FileHandler(LOG_PATH, mode="w")
_fh.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")) _fh.setFormatter(
logging.Formatter("%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")
)
log.addHandler(_fh) log.addHandler(_fh)
CHROME_USER_DATA = Path.home() / "Library" / "Application Support" / "Google" / "Chrome" CHROME_USER_DATA = Path.home() / "Library" / "Application Support" / "Google" / "Chrome"
@ -34,7 +35,9 @@ if _pw_cache.is_dir():
os.environ.setdefault("PLAYWRIGHT_BROWSERS_PATH", str(_pw_cache)) 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"
)
DATE_FMT = "%m/%d/%Y" DATE_FMT = "%m/%d/%Y"
DAYS_BEFORE_DUE = 5 DAYS_BEFORE_DUE = 5
@ -50,8 +53,15 @@ EDIT_COLUMNS = {
"amount": "div[data-field='amountDue']", "amount": "div[data-field='amountDue']",
} }
SCAN_COLUMNS = ["invoicesNumbers", "vendorName", "dueDate", "sendPaymentOn", SCAN_COLUMNS = [
"paymentMethod", "amountDue", "amount"] "invoicesNumbers",
"vendorName",
"dueDate",
"sendPaymentOn",
"paymentMethod",
"amountDue",
"amount",
]
DEFAULT_CONFIG = { DEFAULT_CONFIG = {
"stampli_url": READY_TO_PAY_URL, "stampli_url": READY_TO_PAY_URL,
@ -66,6 +76,7 @@ DEFAULT_CONFIG = {
# Config # Config
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def load_config(): def load_config():
if CONFIG_PATH.exists(): if CONFIG_PATH.exists():
with open(CONFIG_PATH) as f: with open(CONFIG_PATH) as f:
@ -83,6 +94,7 @@ def save_config(config):
# Chrome profile discovery # Chrome profile discovery
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def discover_chrome_profiles(): def discover_chrome_profiles():
"""Return list of {'dir': str, 'name': str, 'email': str} for each Chrome profile.""" """Return list of {'dir': str, 'name': str, 'email': str} for each Chrome profile."""
profiles = [] profiles = []
@ -110,6 +122,7 @@ def discover_chrome_profiles():
# Date helpers # Date helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def next_business_day(d: datetime) -> datetime: def next_business_day(d: datetime) -> datetime:
weekday = d.weekday() weekday = d.weekday()
if weekday == 5: if weekday == 5:
@ -136,11 +149,13 @@ def clean_amount(text: str) -> str:
# Browser helpers # Browser helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _chrome_is_running(): def _chrome_is_running():
try: try:
result = subprocess.run( result = subprocess.run(
["pgrep", "-x", "Google Chrome"], ["pgrep", "-x", "Google Chrome"],
capture_output=True, text=True, capture_output=True,
text=True,
) )
return result.returncode == 0 return result.returncode == 0
except OSError: except OSError:
@ -239,6 +254,7 @@ def needs_login(page):
# Virtual-scroll grid scanning # Virtual-scroll grid scanning
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def collect_edit_rows(page, on_progress=None): def collect_edit_rows(page, on_progress=None):
"""Scan the Select to Pay grid. Returns list of dicts with row_id, invoice, vendor, due_text, pay_text.""" """Scan the Select to Pay grid. Returns list of dicts with row_id, invoice, vendor, due_text, pay_text."""
scroller = page.query_selector(SCROLLER_SELECTOR) scroller = page.query_selector(SCROLLER_SELECTOR)
@ -270,14 +286,24 @@ def collect_edit_rows(page, on_progress=None):
inv_cell = row.query_selector(EDIT_COLUMNS["invoice"]) inv_cell = row.query_selector(EDIT_COLUMNS["invoice"])
amt_cell = row.query_selector(EDIT_COLUMNS["amount"]) amt_cell = row.query_selector(EDIT_COLUMNS["amount"])
all_rows.append({ all_rows.append(
{
"row_id": row_id, "row_id": row_id,
"invoice": inv_cell.inner_text().strip() if inv_cell else "", "invoice": inv_cell.inner_text().strip() if inv_cell else "",
"vendor": (vendor_cell.inner_text().strip()[:40] if vendor_cell else ""), "vendor": (
"due_text": (due_cell.inner_text().strip().split("\n")[0] if due_cell else ""), vendor_cell.inner_text().strip()[:40] if vendor_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 "", "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 ""
),
"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:
stale_count += 1 stale_count += 1
@ -344,6 +370,7 @@ def collect_scan_rows(page, on_progress=None):
# Change computation # Change computation
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def compute_changes(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE): def compute_changes(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DUE):
"""Pure function: given collected rows, return list of rows that need updating.""" """Pure function: given collected rows, return list of rows that need updating."""
changes = [] changes = []
@ -383,8 +410,11 @@ def parse_import_csv(path, date_fmt=DATE_FMT):
try: try:
datetime.strptime(new_pay, date_fmt) datetime.strptime(new_pay, date_fmt)
except ValueError: except ValueError:
raise ValueError(f"Row {i}: invalid date '{new_pay}' (expected {date_fmt})") raise ValueError(
changes.append({ f"Row {i}: invalid date '{new_pay}' (expected {date_fmt})"
)
changes.append(
{
"row_id": row.get("Row ID", "").strip(), "row_id": row.get("Row ID", "").strip(),
"invoice": invoice, "invoice": invoice,
"vendor": row.get("Vendor", "").strip(), "vendor": row.get("Vendor", "").strip(),
@ -392,7 +422,8 @@ def parse_import_csv(path, date_fmt=DATE_FMT):
"due_text": row.get("Due Date", "").strip(), "due_text": row.get("Due Date", "").strip(),
"pay_text": row.get("Current Pay", "").strip(), "pay_text": row.get("Current Pay", "").strip(),
"new_pay": new_pay, "new_pay": new_pay,
}) }
)
return changes return changes
@ -421,6 +452,7 @@ def compute_scan_results(all_rows, date_fmt=DATE_FMT, days_before=DAYS_BEFORE_DU
# Editing # Editing
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def find_row(page, scroller, row_id, start_scroll=0): def find_row(page, scroller, row_id, start_scroll=0):
row = page.query_selector( row = page.query_selector(
f".MuiDataGrid-virtualScrollerRenderZone div.MuiDataGrid-row[data-id='{row_id}']" f".MuiDataGrid-virtualScrollerRenderZone div.MuiDataGrid-row[data-id='{row_id}']"
@ -462,7 +494,9 @@ def try_edit_cell(page, row, new_date_str):
pay_cell.scroll_into_view_if_needed() pay_cell.scroll_into_view_if_needed()
time.sleep(0.15) 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") log.debug(" clicking calendar icon")
calendar_icon.click() calendar_icon.click()
@ -535,7 +569,11 @@ def try_edit_cell(page, row, new_date_str):
pay_text_after = pay_cell_after.inner_text().strip().split("\n")[0] 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) log.debug(" after edit: %r (wanted %r)", pay_text_after, new_date_str)
if 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) log.warning(
" MISMATCH: cell shows %r but wanted %r",
pay_text_after,
new_date_str,
)
else: else:
log.debug(" row detached after edit (expected with virtual scroll)") log.debug(" row detached after edit (expected with virtual scroll)")
except Exception: except Exception:
@ -571,7 +609,9 @@ def apply_edits(page, changes, on_progress=None, stop_check=None):
failed.extend(changes[i:]) failed.extend(changes[i:])
break 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:
success += 1 success += 1
if on_progress: if on_progress:
@ -600,7 +640,9 @@ def apply_edits(page, changes, on_progress=None, stop_check=None):
else: else:
retry_failed.append(c) retry_failed.append(c)
if on_progress: if on_progress:
on_progress(-1, len(failed), c["invoice"], False, "retry failed") on_progress(
-1, len(failed), c["invoice"], False, "retry failed"
)
except Exception as e: except Exception as e:
retry_failed.append(c) retry_failed.append(c)
if on_progress: if on_progress:
@ -642,8 +684,13 @@ def apply_edits_by_invoice(page, changes, on_progress=None, stop_check=None):
time.sleep(0.3) time.sleep(0.3)
visible_rows = page.query_selector_all(ROW_SELECTOR) visible_rows = page.query_selector_all(ROW_SELECTOR)
log.debug("scroll_top=%d, %d visible rows, %d pending, stale_count=%d", log.debug(
scroll_top, len(visible_rows), len(pending), stale_count) "scroll_top=%d, %d visible rows, %d pending, stale_count=%d",
scroll_top,
len(visible_rows),
len(pending),
stale_count,
)
edited_this_position = True edited_this_position = True
while edited_this_position: while edited_this_position:
@ -684,7 +731,12 @@ def apply_edits_by_invoice(page, changes, on_progress=None, stop_check=None):
if on_progress: if on_progress:
on_progress(success, total, invoice, True) on_progress(success, total, invoice, True)
edited_this_position = True edited_this_position = True
log.info(" edit OK (%d/%d done, %d remaining)", success, total, len(pending)) log.info(
" edit OK (%d/%d done, %d remaining)",
success,
total,
len(pending),
)
break break
else: else:
seen_invoices.add(invoice) seen_invoices.add(invoice)
@ -701,8 +753,11 @@ def apply_edits_by_invoice(page, changes, on_progress=None, stop_check=None):
break break
if not edited_this_position: if not edited_this_position:
log.debug(" no edits at scroll_top=%d, visible invoices: %s", log.debug(
scroll_top, visible_invoices[:5]) " no edits at scroll_top=%d, visible invoices: %s",
scroll_top,
visible_invoices[:5],
)
found_new = False found_new = False
for row in page.query_selector_all(ROW_SELECTOR): for row in page.query_selector_all(ROW_SELECTOR):
@ -720,7 +775,11 @@ def apply_edits_by_invoice(page, changes, on_progress=None, stop_check=None):
stale_count = 0 stale_count = 0
else: else:
stale_count += 1 stale_count += 1
log.debug(" no new invoices at scroll_top=%d (stale_count=%d)", scroll_top, stale_count) log.debug(
" no new invoices at scroll_top=%d (stale_count=%d)",
scroll_top,
stale_count,
)
if stale_count >= 3: if stale_count >= 3:
log.info("End of grid reached (3 consecutive stale scrolls)") log.info("End of grid reached (3 consecutive stale scrolls)")
break break
@ -738,6 +797,7 @@ def apply_edits_by_invoice(page, changes, on_progress=None, stop_check=None):
# Post-edit audit # Post-edit audit
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def audit_edits(page, changes, on_progress=None, stop_check=None): 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.""" """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")} expected = {c["invoice"]: c["new_pay"] for c in changes if c.get("invoice")}
@ -782,15 +842,26 @@ def audit_edits(page, changes, on_progress=None, stop_check=None):
found_new = True found_new = True
seen.add(invoice) seen.add(invoice)
pay_cell = row.query_selector(EDIT_COLUMNS["pay"]) pay_cell = row.query_selector(EDIT_COLUMNS["pay"])
actual = pay_cell.inner_text().strip().split("\n")[0] if pay_cell else "" actual = (
pay_cell.inner_text().strip().split("\n")[0] if pay_cell else ""
)
want = expected[invoice] want = expected[invoice]
if actual == want: if actual == want:
confirmed.append({"invoice": invoice, "expected": want, "actual": actual}) confirmed.append(
{"invoice": invoice, "expected": want, "actual": actual}
)
log.debug(" AUDIT OK: %s = %s", invoice, actual) log.debug(" AUDIT OK: %s = %s", invoice, actual)
else: else:
reverted.append({"invoice": invoice, "expected": want, "actual": actual}) reverted.append(
log.warning(" AUDIT REVERTED: %s expected=%s actual=%s", invoice, want, actual) {"invoice": invoice, "expected": want, "actual": actual}
)
log.warning(
" AUDIT REVERTED: %s expected=%s actual=%s",
invoice,
want,
actual,
)
if on_progress: if on_progress:
on_progress(len(seen), len(expected), len(reverted)) on_progress(len(seen), len(expected), len(reverted))
@ -810,10 +881,16 @@ def audit_edits(page, changes, on_progress=None, stop_check=None):
not_found = [inv for inv in expected if inv not in seen] not_found = [inv for inv in expected if inv not in seen]
if not_found: if not_found:
log.warning("Audit: %d invoices not found in grid: %s", len(not_found), not_found[:10]) 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", log.info(
len(confirmed), len(reverted), len(not_found)) "Audit complete: %d confirmed, %d reverted, %d not found",
len(confirmed),
len(reverted),
len(not_found),
)
return confirmed, reverted, not_found return confirmed, reverted, not_found
@ -821,14 +898,18 @@ def audit_edits(page, changes, on_progress=None, stop_check=None):
# Filter / Select / Pay # Filter / Select / Pay
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def filter_select_and_pay(page, on_status=None, on_manual_fallback=None): def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
"""Apply 'No errors' filter, select all, click Pay Invoices, click Google auth.""" """Apply 'No errors' filter, select all, click Pay Invoices, click Google auth."""
def status(msg): def status(msg):
if on_status: if on_status:
on_status(msg) on_status(msg)
status("Opening filter drawer...") status("Opening filter drawer...")
filter_toggle = page.query_selector("button[data-test-id='selectToPay-filter-toggle']") filter_toggle = page.query_selector(
"button[data-test-id='selectToPay-filter-toggle']"
)
if not filter_toggle: if not filter_toggle:
status("ERROR: Could not find filter toggle button.") status("ERROR: Could not find filter toggle button.")
return return
@ -852,14 +933,20 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
) )
if status_input: if status_input:
status_input.evaluate("el => el.scrollIntoView({block: 'center', behavior: 'instant'})") status_input.evaluate(
"el => el.scrollIntoView({block: 'center', behavior: 'instant'})"
)
time.sleep(0.3) time.sleep(0.3)
status_input.click(force=True) status_input.click(force=True)
time.sleep(0.8) time.sleep(0.8)
no_errors = page.query_selector(".MuiAutocomplete-listbox >> text=No errors") no_errors = page.query_selector(
".MuiAutocomplete-listbox >> text=No errors"
)
if not no_errors: if not no_errors:
no_errors = page.query_selector(".MuiAutocomplete-listbox >> text=No Errors") no_errors = page.query_selector(
".MuiAutocomplete-listbox >> text=No Errors"
)
if not no_errors: if not no_errors:
options = page.query_selector_all(".MuiAutocomplete-option") options = page.query_selector_all(".MuiAutocomplete-option")
for opt in options: for opt in options:
@ -878,7 +965,9 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
if not applied: if not applied:
status("Could not auto-apply filter.") status("Could not auto-apply filter.")
if on_manual_fallback: if on_manual_fallback:
on_manual_fallback("Please apply Status -> 'No errors' manually in the browser, then click OK.") on_manual_fallback(
"Please apply Status -> 'No errors' manually in the browser, then click OK."
)
else: else:
input(" Apply the filter manually, then press Enter: ") input(" Apply the filter manually, then press Enter: ")
@ -897,7 +986,9 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
status("Selecting all rows...") status("Selecting all rows...")
select_all = page.query_selector("input[aria-label='Select all rows']") select_all = page.query_selector("input[aria-label='Select all rows']")
if not select_all: if not select_all:
select_all = page.query_selector(".MuiDataGrid-columnHeaderCheckbox .MuiCheckbox-root") select_all = page.query_selector(
".MuiDataGrid-columnHeaderCheckbox .MuiCheckbox-root"
)
if select_all: if select_all:
select_all.click() select_all.click()
time.sleep(1) time.sleep(1)
@ -907,7 +998,9 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
return return
status("Clicking Pay Invoices...") status("Clicking Pay Invoices...")
pay_button = page.query_selector("button[data-test-id='selectToPay-action-payInvoices']") pay_button = page.query_selector(
"button[data-test-id='selectToPay-action-payInvoices']"
)
if not pay_button: if not pay_button:
status("ERROR: Could not find Pay Invoices button.") status("ERROR: Could not find Pay Invoices button.")
return return
@ -919,7 +1012,9 @@ def filter_select_and_pay(page, on_status=None, on_manual_fallback=None):
on_manual_fallback("Please select rows manually, then click OK.") on_manual_fallback("Please select rows manually, then click OK.")
else: else:
input(" Select rows manually, then press Enter: ") input(" Select rows manually, then press Enter: ")
pay_button = page.query_selector("button[data-test-id='selectToPay-action-payInvoices']") pay_button = page.query_selector(
"button[data-test-id='selectToPay-action-payInvoices']"
)
pay_button.click() pay_button.click()
time.sleep(2) time.sleep(2)

View file

@ -1,8 +1,6 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Stampli Bulk Editor — tkinter GUI.""" """Stampli Bulk Editor — tkinter GUI."""
VERSION = "1.0.0"
import csv import csv
import os import os
import sys import sys
@ -12,6 +10,8 @@ import time
import traceback import traceback
from datetime import datetime from datetime import datetime
VERSION = "1.0.0"
# Set Tcl/Tk library paths before importing tkinter # Set Tcl/Tk library paths before importing tkinter
_py_base = os.path.dirname(os.path.dirname(os.path.realpath(sys.executable))) _py_base = os.path.dirname(os.path.dirname(os.path.realpath(sys.executable)))
for _var, _subdir in [("TCL_LIBRARY", "tcl8.6"), ("TK_LIBRARY", "tk8.6")]: for _var, _subdir in [("TCL_LIBRARY", "tcl8.6"), ("TK_LIBRARY", "tk8.6")]:
@ -19,17 +19,31 @@ for _var, _subdir in [("TCL_LIBRARY", "tcl8.6"), ("TK_LIBRARY", "tk8.6")]:
if os.path.isdir(_path) and _var not in os.environ: if os.path.isdir(_path) and _var not in os.environ:
os.environ[_var] = _path os.environ[_var] = _path
import tkinter as tk import tkinter as tk # noqa: E402
from tkinter import ttk, messagebox from tkinter import ttk, messagebox # noqa: E402
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright # noqa: E402
from stampli_core import ( from stampli_core import ( # noqa: E402
launch_browser, navigate, active_page, wait_for_login, needs_login, launch_browser,
collect_edit_rows, collect_scan_rows, compute_changes, compute_scan_results, navigate,
apply_edits, apply_edits_by_invoice, audit_edits, filter_select_and_pay, active_page,
load_config, save_config, parse_import_csv, discover_chrome_profiles, wait_for_login,
READY_TO_PAY_URL, PENDING_APPROVAL_URL, DATE_FMT, needs_login,
collect_edit_rows,
collect_scan_rows,
compute_changes,
compute_scan_results,
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,
) )
@ -63,38 +77,49 @@ class StampliApp:
ttk.Label(ctrl, text="Operation:").pack(side="left") ttk.Label(ctrl, text="Operation:").pack(side="left")
self.mode_var = tk.StringVar(value="edit") self.mode_var = tk.StringVar(value="edit")
ttk.Radiobutton(ctrl, text="Edit Pay Dates", variable=self.mode_var, ttk.Radiobutton(
value="edit").pack(side="left", padx=(5, 10)) ctrl, text="Edit Pay Dates", variable=self.mode_var, value="edit"
ttk.Radiobutton(ctrl, text="Scan Pending", variable=self.mode_var, ).pack(side="left", padx=(5, 10))
value="scan").pack(side="left", padx=(0, 20)) ttk.Radiobutton(
ctrl, text="Scan Pending", variable=self.mode_var, value="scan"
).pack(side="left", padx=(0, 20))
self.dry_run_var = tk.BooleanVar(value=False) self.dry_run_var = tk.BooleanVar(value=False)
self.dry_run_cb = ttk.Checkbutton(ctrl, text="Dry Run", variable=self.dry_run_var) self.dry_run_cb = ttk.Checkbutton(
ctrl, text="Dry Run", variable=self.dry_run_var
)
self.dry_run_cb.pack(side="left", padx=(0, 20)) self.dry_run_cb.pack(side="left", padx=(0, 20))
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 = ttk.Button(
ctrl, text="Stop", command=self.on_stop, state="disabled"
)
self.stop_btn.pack(side="left", padx=5) self.stop_btn.pack(side="left", padx=5)
self.export_btn = ttk.Button(ctrl, text="Export CSV", command=self.on_export, self.export_btn = ttk.Button(
state="disabled") ctrl, text="Export CSV", command=self.on_export, state="disabled"
)
self.export_btn.pack(side="left", padx=5) self.export_btn.pack(side="left", padx=5)
self.import_btn = ttk.Button(ctrl, text="Import CSV", command=self.on_import) self.import_btn = ttk.Button(ctrl, text="Import CSV", command=self.on_import)
self.import_btn.pack(side="left", padx=5) 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(
state="disabled") ctrl, text="Close Browser", command=self.on_close_browser, 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) 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(
padding=(10, 4)) self.root, textvariable=self.status_var, relief="sunken", padding=(10, 4)
)
status_bar.pack(fill="x", padx=10) status_bar.pack(fill="x", padx=10)
# --- Paned window for table + log --- # --- Paned window for table + log ---
@ -106,9 +131,15 @@ class StampliApp:
pane.add(table_frame, weight=3) pane.add(table_frame, weight=3)
self.tree = ttk.Treeview(table_frame, show="headings", selectmode="none") self.tree = ttk.Treeview(table_frame, show="headings", selectmode="none")
tree_scroll_y = ttk.Scrollbar(table_frame, orient="vertical", command=self.tree.yview) tree_scroll_y = ttk.Scrollbar(
tree_scroll_x = ttk.Scrollbar(table_frame, orient="horizontal", command=self.tree.xview) table_frame, orient="vertical", command=self.tree.yview
self.tree.configure(yscrollcommand=tree_scroll_y.set, xscrollcommand=tree_scroll_x.set) )
tree_scroll_x = ttk.Scrollbar(
table_frame, orient="horizontal", command=self.tree.xview
)
self.tree.configure(
yscrollcommand=tree_scroll_y.set, xscrollcommand=tree_scroll_x.set
)
tree_scroll_y.pack(side="right", fill="y") tree_scroll_y.pack(side="right", fill="y")
tree_scroll_x.pack(side="bottom", fill="x") tree_scroll_x.pack(side="bottom", fill="x")
self.tree.pack(fill="both", expand=True) self.tree.pack(fill="both", expand=True)
@ -120,9 +151,12 @@ class StampliApp:
log_frame = ttk.Frame(pane) log_frame = ttk.Frame(pane)
pane.add(log_frame, weight=1) pane.add(log_frame, weight=1)
self.log_text = tk.Text(log_frame, height=8, state="disabled", wrap="word", self.log_text = tk.Text(
font=("Menlo", 11)) log_frame, height=8, state="disabled", wrap="word", font=("Menlo", 11)
log_scroll = ttk.Scrollbar(log_frame, orient="vertical", command=self.log_text.yview) )
log_scroll = ttk.Scrollbar(
log_frame, orient="vertical", command=self.log_text.yview
)
self.log_text.configure(yscrollcommand=log_scroll.set) self.log_text.configure(yscrollcommand=log_scroll.set)
log_scroll.pack(side="right", fill="y") log_scroll.pack(side="right", fill="y")
self.log_text.pack(fill="both", expand=True) self.log_text.pack(fill="both", expand=True)
@ -206,10 +240,19 @@ class StampliApp:
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", iid=c.get("row_id", ""), values=( self.tree.insert(
c["invoice"], c["vendor"], c.get("amount", ""), "",
c["due_text"], c["pay_text"] or "(empty)", c["new_pay"], "end",
)) iid=c.get("row_id", ""),
values=(
c["invoice"],
c["vendor"],
c.get("amount", ""),
c["due_text"],
c["pay_text"] or "(empty)",
c["new_pay"],
),
)
def _populate_scan_table(self, results): def _populate_scan_table(self, results):
self.tree.delete(*self.tree.get_children()) self.tree.delete(*self.tree.get_children())
@ -230,10 +273,19 @@ class StampliApp:
for r in results: for r in results:
tag = "wrong" if r["status"] == "WRONG" else "ok" tag = "wrong" if r["status"] == "WRONG" else "ok"
self.tree.insert("", "end", values=( self.tree.insert(
r["invoicesNumbers"], r["vendorName"][:35], r["dueDate"], "",
r["sendPaymentOn"], r["expected"], r["status"], "end",
), tags=(tag,)) values=(
r["invoicesNumbers"],
r["vendorName"][:35],
r["dueDate"],
r["sendPaymentOn"],
r["expected"],
r["status"],
),
tags=(tag,),
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Worker helpers # Worker helpers
@ -274,15 +326,25 @@ class StampliApp:
mode_var = tk.StringVar(value=config.get("browser_mode", "chromium")) mode_var = tk.StringVar(value=config.get("browser_mode", "chromium"))
mode_frame = ttk.Frame(frame) mode_frame = ttk.Frame(frame)
mode_frame.grid(row=0, column=1, sticky="w", pady=(0, 8)) mode_frame.grid(row=0, column=1, sticky="w", pady=(0, 8))
ttk.Radiobutton(mode_frame, text="Chromium (standalone)", ttk.Radiobutton(
variable=mode_var, value="chromium", mode_frame,
command=lambda: profile_combo.configure(state="disabled")).pack(anchor="w") text="Chromium (standalone)",
ttk.Radiobutton(mode_frame, text="Chrome (existing profile)", variable=mode_var,
variable=mode_var, value="chrome", value="chromium",
command=lambda: profile_combo.configure(state="readonly")).pack(anchor="w") 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 # Chrome profile picker
ttk.Label(frame, text="Chrome Profile:").grid(row=1, column=0, sticky="w", pady=(0, 8)) ttk.Label(frame, text="Chrome Profile:").grid(
row=1, column=0, sticky="w", pady=(0, 8)
)
profiles = discover_chrome_profiles() profiles = discover_chrome_profiles()
profile_labels = [] profile_labels = []
profile_dirs = [] profile_dirs = []
@ -300,8 +362,13 @@ class StampliApp:
elif profile_labels: elif profile_labels:
profile_var.set(profile_labels[0]) profile_var.set(profile_labels[0])
profile_combo = ttk.Combobox(frame, textvariable=profile_var, profile_combo = ttk.Combobox(
values=profile_labels, state="readonly", width=35) frame,
textvariable=profile_var,
values=profile_labels,
state="readonly",
width=35,
)
profile_combo.grid(row=1, column=1, sticky="w", pady=(0, 8)) profile_combo.grid(row=1, column=1, sticky="w", pady=(0, 8))
if mode_var.get() != "chrome": if mode_var.get() != "chrome":
profile_combo.configure(state="disabled") profile_combo.configure(state="disabled")
@ -314,15 +381,22 @@ class StampliApp:
config["chrome_profile"] = profile_dirs[i] config["chrome_profile"] = profile_dirs[i]
break break
save_config(config) save_config(config)
self._append_log(f"Settings saved: browser={config['browser_mode']}" self._append_log(
+ (f", profile={config.get('chrome_profile', '')}" f"Settings saved: browser={config['browser_mode']}"
if config["browser_mode"] == "chrome" else "")) + (
f", profile={config.get('chrome_profile', '')}"
if config["browser_mode"] == "chrome"
else ""
)
)
dlg.destroy() dlg.destroy()
btn_frame = ttk.Frame(frame) btn_frame = ttk.Frame(frame)
btn_frame.grid(row=2, column=0, columnspan=2, pady=(10, 0)) 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="Save", command=on_save).pack(side="left", padx=5)
ttk.Button(btn_frame, text="Cancel", command=dlg.destroy).pack(side="left", padx=5) ttk.Button(btn_frame, text="Cancel", command=dlg.destroy).pack(
side="left", padx=5
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Run button # Run button
@ -330,6 +404,7 @@ class StampliApp:
def on_export(self): def on_export(self):
from tkinter import filedialog from tkinter import filedialog
cols = self.tree["columns"] cols = self.tree["columns"]
headers = ["Row ID"] + [self.tree.heading(c, "text") for c in cols] headers = ["Row ID"] + [self.tree.heading(c, "text") for c in cols]
iids = self.tree.get_children() iids = self.tree.get_children()
@ -403,7 +478,9 @@ class StampliApp:
if needs_login(page): if needs_login(page):
self._post("status", "Waiting for login...") self._post("status", "Waiting for login...")
self._post("log", "Please log in to Stampli in the browser window.") 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)): if not wait_for_login(
self.browser_ctx, on_status=lambda m: self._post("log", m)
):
self._post("done", "Login timed out.") self._post("done", "Login timed out.")
return return
@ -416,7 +493,10 @@ class StampliApp:
else: else:
self._run_scan(page) self._run_scan(page)
self._post("done", "Complete — browser still open. Click 'Close Browser' when done.") self._post(
"done",
"Complete — browser still open. Click 'Close Browser' when done.",
)
self.root.after(0, lambda: self.close_btn.configure(state="normal")) self.root.after(0, lambda: self.close_btn.configure(state="normal"))
except Exception as e: except Exception as e:
@ -465,9 +545,15 @@ 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(
stop_check=lambda: self._stop_requested) page,
self._post("log", f"Done: {success}/{len(changes)} updated, {len(failed)} failed.") changes,
on_progress=on_edit_progress,
stop_check=lambda: self._stop_requested,
)
self._post(
"log", f"Done: {success}/{len(changes)} updated, {len(failed)} failed."
)
if success > 0 and not self._stop_requested: if success > 0 and not self._stop_requested:
self._post("status", "Auditing edits...") self._post("status", "Auditing edits...")
@ -475,18 +561,30 @@ class StampliApp:
edited = [c for c in changes if c not in failed] edited = [c for c in changes if c not in failed]
def on_audit_progress(checked, total, reverted_count): def on_audit_progress(checked, total, reverted_count):
self._post("status", f"Auditing... {checked}/{total} ({reverted_count} reverted)") self._post(
"status",
f"Auditing... {checked}/{total} ({reverted_count} reverted)",
)
confirmed, reverted, not_found = audit_edits( confirmed, reverted, not_found = audit_edits(
page, edited, page,
edited,
on_progress=on_audit_progress, on_progress=on_audit_progress,
stop_check=lambda: self._stop_requested, stop_check=lambda: self._stop_requested,
) )
self._post("log", f"Audit: {len(confirmed)} confirmed, {len(reverted)} reverted, {len(not_found)} not found") self._post(
"log",
f"Audit: {len(confirmed)} confirmed, {len(reverted)} reverted, {len(not_found)} not found",
)
for r in reverted: for r in reverted:
self._post("log", f" REVERTED: {r['invoice']} — expected {r['expected']}, shows {r['actual']}") 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(
page, page,
@ -509,9 +607,11 @@ class StampliApp:
self._post("log", f"{len(incorrect)} incorrect out of {len(all_rows)} total.") self._post("log", f"{len(incorrect)} incorrect out of {len(all_rows)} total.")
for r in incorrect: for r in incorrect:
self._post("log", self._post(
"log",
f" WRONG: {r['invoicesNumbers']} — " f" WRONG: {r['invoicesNumbers']} — "
f"pay={r['sendPaymentOn']} expected={r['expected']}") f"pay={r['sendPaymentOn']} expected={r['expected']}",
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Import CSV # Import CSV
@ -521,6 +621,7 @@ class StampliApp:
if self._running: if self._running:
return return
from tkinter import filedialog from tkinter import filedialog
path = filedialog.askopenfilename( path = filedialog.askopenfilename(
filetypes=[("CSV files", "*.csv")], filetypes=[("CSV files", "*.csv")],
) )
@ -542,7 +643,9 @@ class StampliApp:
self._append_log(f"Imported {len(changes)} rows from {os.path.basename(path)}") self._append_log(f"Imported {len(changes)} rows from {os.path.basename(path)}")
self.export_btn.configure(state="normal") self.export_btn.configure(state="normal")
if not messagebox.askyesno("Confirm", f"Apply {len(changes)} imported pay dates?"): if not messagebox.askyesno(
"Confirm", f"Apply {len(changes)} imported pay dates?"
):
self._append_log("Import aborted by user.") self._append_log("Import aborted by user.")
self.status_var.set("Import cancelled.") self.status_var.set("Import cancelled.")
return return
@ -576,7 +679,9 @@ class StampliApp:
if needs_login(page): if needs_login(page):
self._post("status", "Waiting for login...") self._post("status", "Waiting for login...")
self._post("log", "Please log in to Stampli in the browser window.") 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)): if not wait_for_login(
self.browser_ctx, on_status=lambda m: self._post("log", m)
):
self._post("done", "Login timed out.") self._post("done", "Login timed out.")
return return
self._post("log", "Waiting for page to settle...") self._post("log", "Waiting for page to settle...")
@ -584,7 +689,10 @@ class StampliApp:
page = active_page(self.browser_ctx) page = active_page(self.browser_ctx)
self._post("status", "Scrolling through grid, matching invoices...") self._post("status", "Scrolling through grid, matching invoices...")
self._post("log", f"Applying {len(changes)} imported pay dates (matching by invoice)...") self._post(
"log",
f"Applying {len(changes)} imported pay dates (matching by invoice)...",
)
self._post("table_edit", changes) self._post("table_edit", changes)
def on_edit_progress(done, total, inv, ok, info=""): def on_edit_progress(done, total, inv, ok, info=""):
@ -596,41 +704,69 @@ class StampliApp:
self._post("status", f"Applying... {done}/{total}") self._post("status", f"Applying... {done}/{total}")
success, failed = apply_edits_by_invoice( success, failed = apply_edits_by_invoice(
page, changes, page,
changes,
on_progress=on_edit_progress, on_progress=on_edit_progress,
stop_check=lambda: self._stop_requested, stop_check=lambda: self._stop_requested,
) )
if self._stop_requested: if self._stop_requested:
self._post("log", f"Stopped: {success}/{len(changes)} updated before stop.") self._post(
"log", f"Stopped: {success}/{len(changes)} updated before stop."
)
else: else:
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 and not self._stop_requested: if success > 0 and not self._stop_requested:
self._post("status", "Auditing edits...") self._post("status", "Auditing edits...")
self._post("log", "Verifying edits stuck (scrolling through grid)...") 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}] 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): def on_audit_progress(checked, total, reverted_count):
self._post("status", f"Auditing... {checked}/{total} ({reverted_count} reverted)") self._post(
"status",
f"Auditing... {checked}/{total} ({reverted_count} reverted)",
)
confirmed, reverted, not_found = audit_edits( confirmed, reverted, not_found = audit_edits(
page, edited, page,
edited,
on_progress=on_audit_progress, on_progress=on_audit_progress,
stop_check=lambda: self._stop_requested, stop_check=lambda: self._stop_requested,
) )
self._post("log", f"Audit: {len(confirmed)} confirmed, {len(reverted)} reverted, {len(not_found)} not found") self._post(
"log",
f"Audit: {len(confirmed)} confirmed, {len(reverted)} reverted, {len(not_found)} not found",
)
for r in reverted: for r in reverted:
self._post("log", f" REVERTED: {r['invoice']} — expected {r['expected']}, shows {r['actual']}") 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(
page, page,
on_status=lambda m: (self._post("log", m), self._post("status", m)), on_status=lambda m: (
self._post("log", m),
self._post("status", m),
),
on_manual_fallback=self._manual_fallback, on_manual_fallback=self._manual_fallback,
) )
self._post("done", "Complete — browser still open. Click 'Close Browser' when done.") self._post(
"done",
"Complete — browser still open. Click 'Close Browser' when done.",
)
self.root.after(0, lambda: self.close_btn.configure(state="normal")) self.root.after(0, lambda: self.close_btn.configure(state="normal"))
except Exception as e: except Exception as e:
@ -667,8 +803,13 @@ class StampliApp:
# Kill any lingering browser processes # Kill any lingering browser processes
import subprocess import subprocess
try: try:
subprocess.run(["pkill", "-f", "chromium.*--remote-debugging"], capture_output=True, timeout=3) subprocess.run(
["pkill", "-f", "chromium.*--remote-debugging"],
capture_output=True,
timeout=3,
)
except Exception: except Exception:
pass pass