mirror of
https://github.com/Sea-Haven-Industries/stampli-bulk-editor.git
synced 2026-10-01 15:33:14 +00:00
Compare commits
35 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
040f7db2df | ||
|
|
d819370d58 | ||
|
|
4266f159bd | ||
|
|
d57a50c1da | ||
|
|
47133d4482 | ||
|
|
4441025263 | ||
|
|
578b61291f | ||
|
|
5a6d2bd337 | ||
|
|
682c6b9e7f | ||
|
|
01158da49e | ||
|
|
5eed812fc7 | ||
|
|
9ca9407dab | ||
|
|
89850aba1a | ||
|
|
53342c13ff | ||
|
|
29787ad02d | ||
|
|
5bfd824b53 | ||
|
|
2e52e28944 | ||
|
|
c832f798e4 | ||
|
|
420a7661ee | ||
|
|
411dd9fea1 | ||
|
|
b8cb084200 | ||
|
|
3434211b70 | ||
|
|
a7e837b3a0 | ||
|
|
a58f1662ca | ||
|
|
51f1f59cdd | ||
|
|
a1253d0651 | ||
|
|
9ee0784aa5 | ||
|
|
51ed9c4066 | ||
|
|
d0709a0f50 | ||
|
|
f3fee37e80 | ||
|
|
0a75d4e7ce | ||
|
|
b737a5aa80 | ||
|
|
215f16a150 | ||
|
|
4a7416b769 | ||
|
|
1789eae0b5 |
12 changed files with 1283 additions and 116 deletions
15
.github/workflows/ci.yaml
vendored
Normal file
15
.github/workflows/ci.yaml
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
name: CI
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
merge_group:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/ci-python-app.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||||
|
with:
|
||||||
|
source-dirs: "."
|
||||||
|
collect-only: false
|
||||||
10
.github/workflows/dependency-review.yml
vendored
Normal file
10
.github/workflows/dependency-review.yml
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
name: Dependency Review
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
review:
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-dependency-review.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||||
13
.github/workflows/labeler.yml
vendored
Normal file
13
.github/workflows/labeler.yml
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
name: Labeler
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
label:
|
||||||
|
uses: Sea-Haven-Industries/.github/.github/workflows/callable-labeler.yaml@af0f002e14a08cdbfd879c1183bfe7eb2604bce9 # v1.0.8
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -21,3 +21,6 @@ page_snapshot.html
|
||||||
# Old manual app bundle and command launcher (replaced by PyInstaller)
|
# Old manual app bundle and command launcher (replaced by PyInstaller)
|
||||||
Stampli Bulk Editor.app/
|
Stampli Bulk Editor.app/
|
||||||
Stampli Bulk Editor.command
|
Stampli Bulk Editor.command
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
|
||||||
38
AGENTS.md
Normal file
38
AGENTS.md
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
# Sea Haven Governance
|
||||||
|
|
||||||
|
**Standards authority:** engineering-handbook · **Status authority:** Jira
|
||||||
|
|
||||||
|
## Routing
|
||||||
|
|
||||||
|
- Product / feature work → DEV
|
||||||
|
- Infrastructure and platform → PLAT
|
||||||
|
- Security → SEC
|
||||||
|
|
||||||
|
## Branches
|
||||||
|
|
||||||
|
`feature/`, `fix/`, `hotfix/`, `chore/`, `docs/`, `refactor/`, `release/` + kebab-case description.
|
||||||
|
No Jira keys in branch names.
|
||||||
|
|
||||||
|
## Pull Requests
|
||||||
|
|
||||||
|
**Title:** `type(scope): description (DEV-123)` — every non-exempt PR ends with its Jira key.
|
||||||
|
|
||||||
|
**Body sections (in order):** Summary · Validation · Tests · Notes — use "None." when a section is empty.
|
||||||
|
State verifiable facts only. Do not cite the handbook to justify changes.
|
||||||
|
|
||||||
|
Allowed types: `feat` `fix` `docs` `style` `refactor` `perf` `test` `build` `ci` `chore` `revert` `release`.
|
||||||
|
|
||||||
|
## CI and SHA Pins
|
||||||
|
|
||||||
|
Pin every GitHub Actions ref to a full commit SHA with an inline version comment:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
uses: actions/checkout@abc123def456 # v4.1.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The deterministic global pre-push security hook must not be bypassed (`--no-verify` requires
|
||||||
|
explicit approval). Linting stays in CI; do not gate on it locally.
|
||||||
|
|
||||||
|
## Repository Note
|
||||||
|
|
||||||
|
Generic governance applies. Route product work to DEV, platform/infra to PLAT.
|
||||||
81
README.md
Normal file
81
README.md
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
# 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)
|
||||||
|
```
|
||||||
|
|
@ -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} {'Due':<12s} {'Current Pay':<16s} {'New Pay':<12s}")
|
print(
|
||||||
print(f" {'-'*20} {'-'*30} {'-'*12} {'-'*16} {'-'*12}")
|
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:
|
for c in changes:
|
||||||
print(f" {c['invoice']:<20s} {c['vendor']:<30s} {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):
|
||||||
|
|
|
||||||
|
|
@ -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__":
|
||||||
|
|
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
playwright>=1.62.0
|
||||||
|
pyinstaller>=6.22.2
|
||||||
|
|
@ -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(
|
||||||
print(f" {'-'*20} {'-'*35} {'-'*12} {'-'*12} {'-'*12} {'-'*8}")
|
f" {'Invoice':<20s} {'Vendor':<35s} {'Due':<12s} {'Pay Date':<12s} {'Expected':<12s} {'Status'}"
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
|
||||||
596
stampli_core.py
596
stampli_core.py
|
|
@ -1,19 +1,43 @@
|
||||||
"""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 re
|
||||||
import time
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from playwright.sync_api import sync_playwright
|
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
|
||||||
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"
|
||||||
_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"
|
||||||
|
)
|
||||||
|
|
||||||
DATE_FMT = "%m/%d/%Y"
|
DATE_FMT = "%m/%d/%Y"
|
||||||
DAYS_BEFORE_DUE = 5
|
DAYS_BEFORE_DUE = 5
|
||||||
|
|
@ -26,15 +50,25 @@ 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 = [
|
||||||
"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,
|
||||||
"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": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -42,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:
|
||||||
|
|
@ -50,10 +85,44 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|
@ -70,16 +139,81 @@ 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},
|
|
||||||
)
|
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):
|
||||||
|
|
@ -120,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)
|
||||||
|
|
@ -149,14 +284,26 @@ 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,
|
{
|
||||||
"invoice": inv_cell.inner_text().strip() if inv_cell else "",
|
"row_id": row_id,
|
||||||
"vendor": (vendor_cell.inner_text().strip()[:40] if vendor_cell else ""),
|
"invoice": inv_cell.inner_text().strip() if inv_cell else "",
|
||||||
"due_text": (due_cell.inner_text().strip().split("\n")[0] if due_cell else ""),
|
"vendor": (
|
||||||
"pay_text": (pay_cell.inner_text().strip().split("\n")[0] if pay_cell else ""),
|
vendor_cell.inner_text().strip()[:40] if vendor_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
|
||||||
|
|
@ -223,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 = []
|
||||||
|
|
@ -243,6 +391,42 @@ 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 = []
|
||||||
|
|
@ -268,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}']"
|
||||||
|
|
@ -291,18 +476,48 @@ 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
|
||||||
|
|
||||||
calendar_icon = pay_cell.query_selector("i.fa-calendar, [data-test-id='test-icon-calendar']")
|
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']"
|
||||||
|
)
|
||||||
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, "
|
||||||
|
|
@ -315,23 +530,54 @@ 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
|
||||||
|
|
||||||
|
|
@ -352,15 +598,20 @@ 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:
|
||||||
success += 1
|
success += 1
|
||||||
if on_progress:
|
if on_progress:
|
||||||
|
|
@ -374,9 +625,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:
|
||||||
|
|
@ -386,7 +640,9 @@ def apply_edits(page, changes, on_progress=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:
|
||||||
|
|
@ -396,18 +652,264 @@ 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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -431,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:
|
||||||
|
|
@ -457,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: ")
|
||||||
|
|
||||||
|
|
@ -476,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)
|
||||||
|
|
@ -486,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
|
||||||
|
|
@ -498,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)
|
||||||
|
|
|
||||||
527
stampli_gui.py
527
stampli_gui.py
|
|
@ -1,8 +1,7 @@
|
||||||
#!/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 os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import queue
|
import queue
|
||||||
|
|
@ -11,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")]:
|
||||||
|
|
@ -18,16 +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, filter_select_and_pay, load_config,
|
active_page,
|
||||||
READY_TO_PAY_URL, PENDING_APPROVAL_URL, DATE_FMT,
|
wait_for_login,
|
||||||
|
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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -44,6 +60,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)
|
||||||
|
|
@ -60,26 +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.close_btn = ttk.Button(ctrl, text="Close Browser", command=self.on_close_browser,
|
self.stop_btn = ttk.Button(
|
||||||
state="disabled")
|
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, 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(
|
||||||
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 ---
|
||||||
|
|
@ -91,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)
|
||||||
|
|
@ -105,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)
|
||||||
|
|
@ -159,8 +208,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,24 +224,35 @@ 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(
|
||||||
c["invoice"], c["vendor"], 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())
|
||||||
|
|
@ -208,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
|
||||||
|
|
@ -231,15 +305,146 @@ 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")
|
||||||
|
|
@ -273,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
|
||||||
|
|
||||||
|
|
@ -286,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:
|
||||||
|
|
@ -335,11 +545,46 @@ 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(
|
||||||
self._post("log", f"Done: {success}/{len(changes)} updated, {len(failed)} failed.")
|
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."
|
||||||
|
)
|
||||||
|
|
||||||
if success > 0:
|
if success > 0 and not self._stop_requested:
|
||||||
if self._ask_confirm("Filter to error-free rows, select all, and click Pay Invoices?"):
|
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?"
|
||||||
|
):
|
||||||
self._post("status", "Running filter/select/pay...")
|
self._post("status", "Running filter/select/pay...")
|
||||||
filter_select_and_pay(
|
filter_select_and_pay(
|
||||||
page,
|
page,
|
||||||
|
|
@ -362,17 +607,189 @@ 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(
|
||||||
f" WRONG: {r['invoicesNumbers']} — "
|
"log",
|
||||||
f"pay={r['sendPaymentOn']} expected={r['expected']}")
|
f" WRONG: {r['invoicesNumbers']} — "
|
||||||
|
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 +800,24 @@ 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.")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue