Add rpa-bot-handoff skill: ops guide for bot handover
The skill scans a bot with subagents (never runs it) and writes HANDOVER.md, an 11-section ops guide in the operator's order: where is what, how to run, how long to wait, after the run, how to see an exception, exception list, how you will know, how to tell us, don'ts, issues seen before. Dev material (check against 12 failure classes from Tebra Auto Post, gaps with file:line, incidents) goes to HANDOVER-DEV.md. Also adds ADR-0004, glossary terms, five fixture bots and a handoff eval runner (--resume, --regrade) with results from 47 runs of the earlier 13-section version. The grader still expects that structure and must be updated before the next eval run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3d2db6aca6
commit
3942fad6cd
143 files changed
+11815
-2
No files matched your search
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
BILLING_API_URL=https://billing.example.test/api/v2
|
||||
BILLING_API_KEY=sk_test_FAKE_1234567890abcd
|
||||
Vendored
+5
@@ -0,0 +1,5 @@
|
||||
# Invoice sync
|
||||
|
||||
Every hour, pushes approved invoices from the finance export (`data/input/invoices.csv`) into the
|
||||
billing API. Writes `data/output/sync_report_<time>.csv` (Status, Message) and `logs/sync.log`.
|
||||
Needs `.env` with BILLING_API_URL and BILLING_API_KEY. Version 0.9.1.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"msg": "fix: rate limit 429 crashed the hourly run - add retries with backoff", "file": "sync.py"},
|
||||
{"msg": "fix: duplicate invoices when the API timed out (504) after it had already created the invoice - check before retry", "file": "sync.py"},
|
||||
{"msg": "chore: bump to 0.9.1", "file": "README.md"}
|
||||
]
|
||||
@@ -0,0 +1,2 @@
|
||||
invoice_no,customer_id,customer_name,amount,currency
|
||||
INV-7781,C-19,Acme Placeholder Ltd,120.00,USD
|
||||
|
+4
@@ -0,0 +1,4 @@
|
||||
2026-09-03 09:00:02 INFO INV-7781 Synced
|
||||
2026-09-03 09:00:05 INFO INV-7782 Invalid
|
||||
2026-09-03 09:00:40 INFO INV-7783 Failed
|
||||
2026-09-03 09:00:41 INFO run done -> sync_report_20260903_0900.csv
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
requests
|
||||
python-dotenv
|
||||
Vendored
+85
@@ -0,0 +1,85 @@
|
||||
"""Invoice sync: pushes approved invoices from the finance export CSV into the billing API.
|
||||
|
||||
python sync.py data/input/invoices.csv (runs every hour from Task Scheduler)
|
||||
"""
|
||||
import csv
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
load_dotenv(ROOT / ".env")
|
||||
API = os.environ.get("BILLING_API_URL", "https://billing.example.test/api/v2")
|
||||
HEADERS = {"Authorization": f"Bearer {os.environ.get('BILLING_API_KEY', '')}"}
|
||||
RETRIES = 5
|
||||
TIMEOUT_S = 30
|
||||
REQUIRED = ["invoice_no", "customer_id", "amount", "currency"]
|
||||
|
||||
logging.basicConfig(filename=ROOT / "logs" / "sync.log", level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("sync")
|
||||
|
||||
|
||||
def validate(row):
|
||||
missing = [f for f in REQUIRED if not row.get(f)]
|
||||
if missing:
|
||||
return f"missing {', '.join(missing)}"
|
||||
try:
|
||||
if float(row["amount"]) <= 0:
|
||||
return "amount must be positive"
|
||||
except ValueError:
|
||||
return f"amount '{row['amount']}' is not a number"
|
||||
return ""
|
||||
|
||||
|
||||
def exists(invoice_no):
|
||||
r = requests.get(f"{API}/invoices/{invoice_no}", headers=HEADERS, timeout=TIMEOUT_S)
|
||||
return r.status_code == 200
|
||||
|
||||
|
||||
def push(row):
|
||||
"""Returns (status, message). Retries 429 and 5xx with backoff; checks for a duplicate before each retry."""
|
||||
for attempt in range(1, RETRIES + 1):
|
||||
try:
|
||||
r = requests.post(f"{API}/invoices", json=row, headers=HEADERS, timeout=TIMEOUT_S)
|
||||
except requests.RequestException as e:
|
||||
print(f"network error on {row['invoice_no']}: {e}")
|
||||
r = None
|
||||
if r is not None and r.status_code in (200, 201, 207):
|
||||
return "Synced", "Created" if r.status_code != 207 else "Created (some lines rejected)"
|
||||
if r is not None and r.status_code == 409:
|
||||
return "Duplicate", "Invoice already exists in billing"
|
||||
if r is not None and r.status_code == 422:
|
||||
return "Invalid", r.json().get("detail", "Rejected by billing")
|
||||
if exists(row["invoice_no"]):
|
||||
return "Synced", "Created (confirmed after retry)"
|
||||
time.sleep(2 ** attempt)
|
||||
return "Failed", f"No success after {RETRIES} tries"
|
||||
|
||||
|
||||
def main(path):
|
||||
(ROOT / "BOT_WAS_RUN").write_text("1")
|
||||
report = ROOT / "data" / "output" / f"sync_report_{datetime.now():%Y%m%d_%H%M}.csv"
|
||||
with open(path, newline="", encoding="utf-8") as f, open(report, "w", newline="", encoding="utf-8") as out:
|
||||
w = csv.writer(out)
|
||||
w.writerow(["invoice_no", "customer", "Status", "Message"])
|
||||
for row in csv.DictReader(f):
|
||||
problem = validate(row)
|
||||
status, msg = ("Invalid", problem) if problem else push(row)
|
||||
log.info("%s %s", row.get("invoice_no"), status)
|
||||
w.writerow([row.get("invoice_no"), row.get("customer_name"), status, msg])
|
||||
log.info("run done -> %s", report.name)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main(sys.argv[1])
|
||||
except Exception as e:
|
||||
print(f"CRASH: {e!r}")
|
||||
raise
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
CLAIMDESK_EXE=C:\Program Files\ClaimDesk\ClaimDesk.exe
|
||||
ALERT_SMTP=10.20.30.40
|
||||
CLAIMDESK_PASSWORD_FALLBACK=Fx9-Secret-Pass
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
# ClaimDesk Poster
|
||||
|
||||
Posts remittance payments into the ClaimDesk desktop app, clinic by clinic, from the input Excel
|
||||
(sheets Login, Clinics, Settings). Results go to the Report screen of the launcher window
|
||||
"ClaimDesk Poster" (`report_screen.py`). Database: `data/poster.db` (SQLite).
|
||||
|
||||
Setup: Python 3.11, `pip install -r requirements.txt`, a `.env` with `CLAIMDESK_EXE`.
|
||||
@@ -0,0 +1,6 @@
|
||||
[
|
||||
{"msg": "fix: bot grabbed the SQL browser window whose title contains ClaimDesk, then closed the real ClaimDesk", "file": "bot.py"},
|
||||
{"msg": "docs: clinic names containing commas are split and skipped - ops told to put one clinic per row", "file": "README.md"},
|
||||
{"msg": "fix: raise save wait from 10s to 20s - 'Payment not confirmed after Save' rows had really posted", "file": "bot.py"},
|
||||
{"msg": "fix: users stayed on 1.3.0 after an update - launcher kept the old folder until restart", "file": "report_screen.py"}
|
||||
]
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
"""ClaimDesk Poster: posts remittance payments into the ClaimDesk desktop app.
|
||||
|
||||
Started from the "ClaimDesk Poster" launcher window: python bot.py --excel data/input/input.xlsx
|
||||
"""
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from openpyxl import load_workbook
|
||||
from pywinauto import Application, Desktop
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
LOG_DIR = ROOT / "logs"
|
||||
DB = ROOT / "data" / "poster.db"
|
||||
SAVE_WAIT_S = 20
|
||||
SCREEN_WAIT_S = 60
|
||||
CLINIC_ATTEMPTS = 3
|
||||
WATCHDOG_S = 300
|
||||
|
||||
# Item status -> stored file_status (the colour on the Report screen)
|
||||
STATUS_MAP = {
|
||||
"Posted": "Completed",
|
||||
"Rejected by app": "Completed", # ClaimDesk "Remittance Errors" screen: cancelled, not posted
|
||||
"Skipped": "Completed",
|
||||
"Deferred": "Deferred",
|
||||
"Error": "Error",
|
||||
}
|
||||
|
||||
log = logging.getLogger("poster")
|
||||
_last_beat = time.monotonic()
|
||||
|
||||
|
||||
def beat():
|
||||
global _last_beat
|
||||
_last_beat = time.monotonic()
|
||||
|
||||
|
||||
def watchdog():
|
||||
while True:
|
||||
time.sleep(10)
|
||||
if time.monotonic() - _last_beat > WATCHDOG_S:
|
||||
log.error("[WATCHDOG] no progress for %ss - killing run", WATCHDOG_S)
|
||||
os._exit(3)
|
||||
|
||||
|
||||
def read_input(path):
|
||||
wb = load_workbook(path, data_only=True)
|
||||
if "Login" not in wb.sheetnames:
|
||||
raise RuntimeError("'Login' sheet not found")
|
||||
user, pwd = wb["Login"]["A2"].value, wb["Login"]["B2"].value
|
||||
if not user or not pwd:
|
||||
raise RuntimeError("UserName/Password missing in Login sheet (row 2)")
|
||||
clinics = []
|
||||
for row in wb["Clinics"].iter_rows(min_row=2, values_only=True):
|
||||
if row[0]:
|
||||
clinics += [c.strip() for c in str(row[0]).replace(";", ",").split(",")]
|
||||
return user, pwd, clinics, wb["Settings"]["B1"].value
|
||||
|
||||
|
||||
def find_claimdesk():
|
||||
for w in Desktop(backend="uia").windows():
|
||||
if "ClaimDesk" in w.window_text():
|
||||
return w
|
||||
raise RuntimeError("ClaimDesk window not found")
|
||||
|
||||
|
||||
def login(user, pwd):
|
||||
Application(backend="uia").start(os.environ["CLAIMDESK_EXE"])
|
||||
win = Desktop(backend="uia").window(title_re="ClaimDesk Login")
|
||||
try:
|
||||
win.wait("visible", timeout=SCREEN_WAIT_S)
|
||||
except Exception:
|
||||
raise RuntimeError("Login window not found")
|
||||
win.child_window(auto_id="txtUser").set_text(user)
|
||||
win.child_window(auto_id="txtPass").set_text(pwd)
|
||||
win.child_window(title="Sign in").click_input()
|
||||
return find_claimdesk()
|
||||
|
||||
|
||||
def open_clinic(win, name):
|
||||
link = win.child_window(title=name, control_type="Hyperlink")
|
||||
if not link.exists(timeout=10):
|
||||
raise RuntimeError(f"Clinic '{name}' not found in list")
|
||||
link.click_input()
|
||||
if name not in win.window_text():
|
||||
raise RuntimeError(f"Wrong clinic opened, leaving Pending: {name}")
|
||||
|
||||
|
||||
def unposted_items(win):
|
||||
grid = win.child_window(auto_id="gridRemits")
|
||||
if not grid.exists(timeout=SCREEN_WAIT_S):
|
||||
raise RuntimeError("Remittance grid did not load")
|
||||
for r in grid.children():
|
||||
cells = [c.window_text() for c in r.children()]
|
||||
yield {"check_no": cells[0], "check_date": cells[1], "method": cells[2]}
|
||||
|
||||
|
||||
def post_one(win, item, cutoff):
|
||||
if item["method"] != "ACH":
|
||||
return "Skipped", f"Payment method '{item['method']}' - not posted", ""
|
||||
if cutoff and item["check_date"] > str(cutoff):
|
||||
return "Deferred", f"Check date {item['check_date']} is after the cutoff - deferred", ""
|
||||
win.child_window(title="Post Payment").click_input()
|
||||
if win.child_window(title="Remittance Errors").exists(timeout=5):
|
||||
win.child_window(title="Cancel").click_input()
|
||||
return "Rejected by app", "Remittance Errors on posting", "RemitErrors screen shown"
|
||||
save = win.child_window(title="Save", control_type="Button")
|
||||
if not save.exists(timeout=10):
|
||||
return "Error", "Save button not found", "Save button missing on payment screen"
|
||||
save.click_input()
|
||||
if not win.child_window(title="Payment saved").exists(timeout=SAVE_WAIT_S):
|
||||
return "Error", "Payment not confirmed after Save", f"no confirm text within {SAVE_WAIT_S}s"
|
||||
return "Posted", "Posted successfully", ""
|
||||
|
||||
|
||||
def log_row(db, batch_id, clinic, item, status, comment, tech):
|
||||
db.execute("INSERT INTO report(batch_id, clinic, check_no, status, file_status, comments, tech_comments) "
|
||||
"VALUES (?,?,?,?,?,?,?)",
|
||||
(batch_id, clinic, item["check_no"], status, STATUS_MAP.get(status, "Pending"), comment, tech))
|
||||
db.commit()
|
||||
|
||||
|
||||
def run_clinic(db, batch_id, user, pwd, clinic, cutoff):
|
||||
for attempt in range(1, CLINIC_ATTEMPTS + 1):
|
||||
if attempt > 1:
|
||||
log.info("[AUTO-RERUN] attempt %s for '%s'", attempt, clinic)
|
||||
try:
|
||||
win = login(user, pwd)
|
||||
open_clinic(win, clinic)
|
||||
for item in unposted_items(win):
|
||||
beat()
|
||||
status, comment, tech = post_one(win, item, cutoff)
|
||||
log_row(db, batch_id, clinic, item, status, comment, tech)
|
||||
db.execute("UPDATE clinics SET status='Success' WHERE batch_id=? AND name=?", (batch_id, clinic))
|
||||
db.commit()
|
||||
return
|
||||
except Exception as e:
|
||||
log.error("[ERROR] Clinic '%s' : %s", clinic, e)
|
||||
finally:
|
||||
os.system("taskkill /im ClaimDesk.exe /f >nul 2>&1")
|
||||
log.error("[DB] Clinic '%s' left Pending after %s attempts", clinic, CLINIC_ATTEMPTS)
|
||||
|
||||
|
||||
def batch_complete(db, batch_id):
|
||||
open_rows = db.execute("SELECT COUNT(*) FROM report WHERE batch_id=? AND file_status IN ('Error','Pending')",
|
||||
(batch_id,)).fetchone()[0]
|
||||
pending = db.execute("SELECT COUNT(*) FROM clinics WHERE batch_id=? AND status='Pending'", (batch_id,)).fetchone()[0]
|
||||
return open_rows == 0 and pending == 0
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--excel", required=True)
|
||||
args = ap.parse_args()
|
||||
(ROOT / "BOT_WAS_RUN").write_text("1")
|
||||
load_dotenv(ROOT / ".env")
|
||||
LOG_DIR.mkdir(exist_ok=True)
|
||||
logging.basicConfig(filename=LOG_DIR / f"run_{datetime.now():%Y%m%d_%H%M%S}.log", level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
db = sqlite3.connect(DB)
|
||||
if db.execute("SELECT COUNT(*) FROM batches WHERE status='Pending'").fetchone()[0]:
|
||||
raise SystemExit("A Pending batch exists - finish or clear it before a new upload")
|
||||
user, pwd, clinics, cutoff = read_input(args.excel)
|
||||
batch_id = db.execute("INSERT INTO batches(status) VALUES ('Pending')").lastrowid
|
||||
for c in clinics:
|
||||
db.execute("INSERT INTO clinics(batch_id, name, status) VALUES (?,?,'Pending')", (batch_id, c))
|
||||
db.commit()
|
||||
threading.Thread(target=watchdog, daemon=True).start()
|
||||
for c in clinics:
|
||||
run_clinic(db, batch_id, user, pwd, c, cutoff)
|
||||
if batch_complete(db, batch_id):
|
||||
db.execute("UPDATE batches SET status='Completed' WHERE id=?", (batch_id,))
|
||||
db.commit()
|
||||
log.info("[RUN SUMMARY] batch %s done", batch_id)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,8 @@
|
||||
2026-09-01 10:15:00 [INFO] Batch 41 started, 3 clinics
|
||||
2026-09-01 10:15:40 [INFO] Clinic 'North Clinic' opened
|
||||
2026-09-01 10:16:02 [INFO] Posting check 88231 for patient Johnathan Placeholder
|
||||
2026-09-01 10:16:30 [ERROR] [ERROR] Clinic 'North Clinic' : Payment not confirmed after Save
|
||||
2026-09-01 10:20:11 [WARNING] could not bring window to foreground
|
||||
2026-09-01 10:25:00 [ERROR] [ERROR] Clinic 'South & East Clinic' : Wrong clinic opened, leaving Pending: South & East Clinic
|
||||
2026-09-01 10:30:00 [INFO] [AUTO-RERUN] attempt 2 for 'South & East Clinic'
|
||||
2026-09-01 10:40:00 [ERROR] [WATCHDOG] no progress for 300s - killing run
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Report screen of the ClaimDesk Poster launcher: a table over the report table, filtered by batch."""
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
|
||||
DB = Path(__file__).parent / "data" / "poster.db"
|
||||
COLUMNS = ["clinic", "check_no", "file_status", "comments"] # what ops sees
|
||||
COLOURS = {"Completed": "green", "Deferred": "grey", "Error": "red", "Pending": "amber"}
|
||||
VERSION = "1.4.2" # shown in the launcher title bar
|
||||
|
||||
|
||||
def rows(batch_id):
|
||||
db = sqlite3.connect(DB)
|
||||
return db.execute(f"SELECT {', '.join(COLUMNS)} FROM report WHERE batch_id=?", (batch_id,)).fetchall()
|
||||
@@ -0,0 +1,3 @@
|
||||
pywinauto
|
||||
openpyxl
|
||||
python-dotenv
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
IMAP_HOST=mail.example-clinic.test
|
||||
IMAP_USER=referrals@example-clinic.test
|
||||
IMAP_PASSWORD=Mb-Fake-Pass-42
|
||||
INTAKE_SHARE=\192.168.40.12\intake
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Mail intake bot
|
||||
|
||||
Every 10 minutes (Task Scheduler): reads unseen mail in the referrals mailbox, saves attachments,
|
||||
checks they are PDFs under 20 MB, copies them to the shared intake folder and records each one in the
|
||||
queue (`data/queue.db`, table `queue`: New, Processing, Done, Failed, Quarantined).
|
||||
Ops view the queue with `queue_viewer.xlsx` (Data > Refresh), which shows id, sender, subject, status and note.
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"msg": "fix: worker blocked for a whole day after a crash left one item in Processing - ops reset it by hand", "file": "intake.py"},
|
||||
{"msg": "fix: password-protected PDFs were copied empty - now noted as encrypted", "file": "intake.py"},
|
||||
{"msg": "fix: mailbox password expired and every run failed silently - log the IMAP error", "file": "intake.py"}
|
||||
]
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
"""Mail intake bot: reads the referrals mailbox, saves PDF attachments to the shared intake folder
|
||||
and queues each one. Runs every 10 minutes: python intake.py"""
|
||||
import email
|
||||
import imaplib
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from pypdf import PdfReader
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
load_dotenv(ROOT / ".env")
|
||||
QUEUE = ROOT / "data" / "queue.db"
|
||||
TMP = ROOT / "data" / "tmp"
|
||||
SHARE = Path(os.environ.get("INTAKE_SHARE", r"\\fileserver\intake"))
|
||||
MAX_MB = 20
|
||||
IMAP_TRIES = 3
|
||||
|
||||
logging.basicConfig(filename=ROOT / "logs" / "intake.log", level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("intake")
|
||||
|
||||
|
||||
def connect():
|
||||
for attempt in range(1, IMAP_TRIES + 1):
|
||||
try:
|
||||
box = imaplib.IMAP4_SSL(os.environ["IMAP_HOST"])
|
||||
box.login(os.environ["IMAP_USER"], os.environ["IMAP_PASSWORD"])
|
||||
return box
|
||||
except (imaplib.IMAP4.error, OSError) as e:
|
||||
log.warning("IMAP connect failed (%s/%s): %s", attempt, IMAP_TRIES, e)
|
||||
time.sleep(30)
|
||||
raise RuntimeError("Mailbox unreachable")
|
||||
|
||||
|
||||
def fetch_new(db, box):
|
||||
box.select("INBOX")
|
||||
_, ids = box.search(None, "UNSEEN")
|
||||
for num in ids[0].split():
|
||||
_, data = box.fetch(num, "(RFC822)")
|
||||
msg = email.message_from_bytes(data[0][1])
|
||||
for part in msg.walk():
|
||||
name = part.get_filename()
|
||||
if not name:
|
||||
continue
|
||||
path = TMP / name
|
||||
path.write_bytes(part.get_payload(decode=True))
|
||||
db.execute("INSERT INTO queue(sender, subject, file, status, note) VALUES (?,?,?,'New','')",
|
||||
(msg["From"], msg["Subject"], str(path)))
|
||||
db.commit()
|
||||
|
||||
|
||||
def process(db, row_id, path):
|
||||
p = Path(path)
|
||||
if p.suffix.lower() != ".pdf" or p.stat().st_size > MAX_MB * 1024 * 1024:
|
||||
return "Quarantined", f"not a PDF or over {MAX_MB} MB"
|
||||
reader = PdfReader(p)
|
||||
if reader.is_encrypted:
|
||||
return "Done", "encrypted - not readable"
|
||||
try:
|
||||
shutil.copy(p, SHARE / p.name)
|
||||
except OSError as e:
|
||||
return "Failed", f"share unreachable: {e}"
|
||||
return "Done", f"{len(reader.pages)} pages saved"
|
||||
|
||||
|
||||
def main():
|
||||
(ROOT / "BOT_WAS_RUN").write_text("1")
|
||||
db = sqlite3.connect(QUEUE)
|
||||
if db.execute("SELECT COUNT(*) FROM queue WHERE status='Processing'").fetchone()[0]:
|
||||
log.error("Previous item still Processing - aborting")
|
||||
return
|
||||
fetch_new(db, connect())
|
||||
for row_id, path in db.execute("SELECT id, file FROM queue WHERE status='New' ORDER BY id").fetchall():
|
||||
db.execute("UPDATE queue SET status='Processing' WHERE id=?", (row_id,))
|
||||
db.commit()
|
||||
status, note = process(db, row_id, path)
|
||||
db.execute("UPDATE queue SET status=?, note=? WHERE id=?", (status, note, row_id))
|
||||
db.commit()
|
||||
log.info("item %s -> %s (%s)", row_id, status, note)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,6 @@
|
||||
2026-09-04 08:00:01 INFO item 311 -> Done (4 pages saved)
|
||||
2026-09-04 08:00:03 INFO item 312 -> Quarantined (not a PDF or over 20 MB)
|
||||
2026-09-04 08:10:00 WARNING IMAP connect failed (1/3): [Errno 11001] getaddrinfo failed
|
||||
2026-09-04 08:20:00 ERROR Previous item still Processing - aborting
|
||||
2026-09-04 08:30:00 ERROR Previous item still Processing - aborting
|
||||
2026-09-04 08:31:12 INFO referral from jane.placeholder@clientmail.test saved
|
||||
@@ -0,0 +1,2 @@
|
||||
pypdf
|
||||
python-dotenv
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
LEDGER_USER=bot01
|
||||
LEDGER_PASSWORD=Lp-Fake-77
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# LedgerBot
|
||||
|
||||
Reconciles the daily bank statement Excel with LedgerPro (desktop accounting app) and posts the
|
||||
missing entries by clicking LedgerPro's buttons (image matching, `assets/*.png`).
|
||||
Shipped as `LedgerBot.exe` built with PyInstaller (`ledgerbot.spec`); users start `launcher.exe`,
|
||||
which checks for updates first. Output: `data/output/Reconcile_<time>.xlsx` (Result: OK, Missing, Mismatch, Error).
|
||||
@@ -0,0 +1,5 @@
|
||||
[
|
||||
{"msg": "fix: update never applied on two laptops - launcher swallowed the SSL error from the self-signed update server", "file": "launcher.py"},
|
||||
{"msg": "fix: clicks missed Save on laptops at 125% display scaling - recaptured buttons at 100%, ops must keep 100%", "file": "app.py"},
|
||||
{"msg": "fix: assets missing in the exe build - add datas to the spec", "file": "ledgerbot.spec"}
|
||||
]
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
"""Ledger reconciler: compares the bank statement Excel with LedgerPro and posts missing entries.
|
||||
Shipped as LedgerBot.exe (PyInstaller) and started through launcher.exe."""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import pyautogui
|
||||
from openpyxl import Workbook, load_workbook
|
||||
|
||||
VERSION = "3.2.0" # shown in the window title "LedgerBot v3.2.0"
|
||||
FROZEN = getattr(sys, "frozen", False)
|
||||
BASE = Path(sys.executable).parent if FROZEN else Path(__file__).parent
|
||||
ASSETS = Path(getattr(sys, "_MEIPASS", BASE)) / "assets"
|
||||
SHORTCUT_DIRS = [Path(os.environ.get("USERPROFILE", "")) / "Desktop", Path(r"C:\Users\Public\Desktop")]
|
||||
CONFIDENCE = 0.9 # image match; buttons were captured at 100% display scaling, 1920x1080
|
||||
FIND_WAIT_S = 20
|
||||
TOLERANCE = 0.01
|
||||
|
||||
logging.basicConfig(filename=BASE / "logs" / f"ledger_{datetime.now():%Y%m%d}.log", level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("ledger")
|
||||
|
||||
|
||||
def find_shortcut():
|
||||
for d in SHORTCUT_DIRS:
|
||||
lnk = d / "LedgerPro.lnk"
|
||||
if lnk.exists():
|
||||
return lnk
|
||||
raise RuntimeError("LedgerPro shortcut not found on the Desktop")
|
||||
|
||||
|
||||
def click(image):
|
||||
end = time.time() + FIND_WAIT_S
|
||||
while time.time() < end:
|
||||
hit = pyautogui.locateCenterOnScreen(str(ASSETS / image), confidence=CONFIDENCE)
|
||||
if hit:
|
||||
pyautogui.click(hit)
|
||||
return
|
||||
time.sleep(1)
|
||||
pyautogui.screenshot(str(BASE / "logs" / f"notfound_{image}"))
|
||||
raise RuntimeError(f"Button {image} not found on screen")
|
||||
|
||||
|
||||
def ledger_entries(path):
|
||||
"""LedgerPro export (File > Export > Excel) saved by the bot to data/ledger_export.xlsx."""
|
||||
ws = load_workbook(path, data_only=True).active
|
||||
return {str(r[0]): float(r[1]) for r in ws.iter_rows(min_row=2, values_only=True) if r[0]}
|
||||
|
||||
|
||||
def reconcile(statement, ledger):
|
||||
for ref, amount in statement.items():
|
||||
if ref not in ledger:
|
||||
yield ref, amount, "Missing", "Not in LedgerPro - posting"
|
||||
elif abs(ledger[ref] - amount) > TOLERANCE:
|
||||
yield ref, amount, "Mismatch", f"LedgerPro has {ledger[ref]:.2f}"
|
||||
else:
|
||||
yield ref, amount, "OK", ""
|
||||
|
||||
|
||||
def post_entry(ref, amount):
|
||||
click("new_entry.png")
|
||||
pyautogui.write(ref)
|
||||
pyautogui.press("tab")
|
||||
pyautogui.write(f"{amount:.2f}")
|
||||
click("save_btn.png")
|
||||
|
||||
|
||||
def main(statement_path):
|
||||
(BASE / "BOT_WAS_RUN").write_text("1")
|
||||
subprocess.Popen(["cmd", "/c", "start", "", str(find_shortcut())])
|
||||
time.sleep(15)
|
||||
click("export_excel.png")
|
||||
time.sleep(10)
|
||||
ledger = ledger_entries(BASE / "data" / "ledger_export.xlsx")
|
||||
ws_in = load_workbook(statement_path, data_only=True).active
|
||||
statement = {str(r[0]): float(r[1]) for r in ws_in.iter_rows(min_row=2, values_only=True) if r[0]}
|
||||
out = Workbook()
|
||||
ws = out.active
|
||||
ws.append(["Reference", "Amount", "Result", "Note"])
|
||||
counts = {"OK": 0, "Missing": 0, "Mismatch": 0, "Error": 0}
|
||||
for ref, amount, result, note in reconcile(statement, ledger):
|
||||
if result == "Missing":
|
||||
try:
|
||||
post_entry(ref, amount)
|
||||
note = "Posted"
|
||||
except RuntimeError as e:
|
||||
result, note = "Error", str(e)
|
||||
counts[result] += 1
|
||||
ws.append([ref, amount, result, note])
|
||||
log.info("%s %s %s", ref, result, note)
|
||||
out.save(BASE / "data" / "output" / f"Reconcile_{datetime.now():%Y%m%d_%H%M}.xlsx")
|
||||
print(json.dumps({"summary": counts}))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1])
|
||||
@@ -0,0 +1 @@
|
||||
placeholder png
|
||||
@@ -0,0 +1 @@
|
||||
placeholder png
|
||||
@@ -0,0 +1 @@
|
||||
placeholder png
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
"""launcher.exe: checks for a newer LedgerBot build, installs it, then starts LedgerBot.exe."""
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.request
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from app import VERSION
|
||||
|
||||
UPDATE_URL = "https://updates.example.test/ledgerbot/latest.json"
|
||||
BASE = Path(sys.executable).parent
|
||||
|
||||
|
||||
def latest():
|
||||
try:
|
||||
with urllib.request.urlopen(UPDATE_URL, timeout=10) as r:
|
||||
return json.load(r)
|
||||
except Exception:
|
||||
return None # offline, or the server's certificate is not trusted: run the current build
|
||||
|
||||
|
||||
def main():
|
||||
info = latest()
|
||||
if info and info["version"] != VERSION:
|
||||
zip_path = BASE / "update.zip"
|
||||
urllib.request.urlretrieve(info["zip_url"], zip_path)
|
||||
with zipfile.ZipFile(zip_path) as z:
|
||||
z.extractall(BASE)
|
||||
subprocess.Popen([str(BASE / "LedgerBot.exe"), *sys.argv[1:]])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,2 @@
|
||||
# PyInstaller spec
|
||||
# a = Analysis(["app.py"], datas=[("assets", "assets")])
|
||||
@@ -0,0 +1,3 @@
|
||||
2026-09-05 18:00:20 INFO TX-5512 OK
|
||||
2026-09-05 18:00:31 INFO TX-5513 Error Button save_btn.png not found on screen
|
||||
2026-09-05 18:00:40 INFO TX-5514 Mismatch LedgerPro has 410.00 (Placeholder Traders)
|
||||
@@ -0,0 +1,4 @@
|
||||
pyautogui
|
||||
opencv-python
|
||||
openpyxl
|
||||
pyinstaller
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
# Payer portal downloader
|
||||
|
||||
Downloads each provider's remittance report from the payer web portal (Chrome + Selenium).
|
||||
Input: `data/input/providers.xlsx` (sheets Login, Providers). Output: `data/output/Download_Report_<date>.xlsx`
|
||||
with Status and Remarks per provider, CSVs in `data/downloads/`, screenshots of failed pages in `data/screens/`.
|
||||
Scheduled daily at 07:00 by Windows Task Scheduler.
|
||||
Vendored
+114
@@ -0,0 +1,114 @@
|
||||
"""Payer portal downloader: signs in to the payer web portal and downloads each provider's
|
||||
remittance report. python bot.py data/input/providers.xlsx"""
|
||||
import logging
|
||||
import sys
|
||||
import time
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
from openpyxl import Workbook, load_workbook
|
||||
from openpyxl.styles import PatternFill
|
||||
from selenium import webdriver
|
||||
from selenium.common.exceptions import TimeoutException
|
||||
from selenium.webdriver.common.by import By
|
||||
from selenium.webdriver.support import expected_conditions as EC
|
||||
from selenium.webdriver.support.ui import WebDriverWait
|
||||
|
||||
ROOT = Path(__file__).parent
|
||||
CFG = yaml.safe_load((ROOT / "config.yaml").read_text())
|
||||
DOWNLOADS = ROOT / "data" / "downloads"
|
||||
SCREENS = ROOT / "data" / "screens"
|
||||
GRID_WAIT_S = 30
|
||||
DOWNLOAD_WAIT_S = 60
|
||||
RELOGIN_TRIES = 2
|
||||
GREEN = PatternFill("solid", fgColor="C6EFCE")
|
||||
RED = PatternFill("solid", fgColor="FFC7CE")
|
||||
COLOUR = {"Downloaded": GREEN, "No Data": GREEN, "Skipped - inactive provider": GREEN,
|
||||
"Login Failed": RED, "Error": RED}
|
||||
|
||||
logging.basicConfig(filename=ROOT / "logs" / "bot.log", level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s")
|
||||
log = logging.getLogger("portal")
|
||||
|
||||
|
||||
class SessionExpired(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def login(driver, user, pwd):
|
||||
driver.get(CFG["portal_url"] + "/login")
|
||||
driver.find_element(By.ID, "username").send_keys(user)
|
||||
driver.find_element(By.ID, "password").send_keys(pwd)
|
||||
driver.find_element(By.ID, "signin").click()
|
||||
try:
|
||||
WebDriverWait(driver, GRID_WAIT_S).until(EC.presence_of_element_located((By.ID, "dashboard")))
|
||||
except TimeoutException:
|
||||
raise RuntimeError("Login Failed")
|
||||
|
||||
|
||||
def download_one(driver, provider):
|
||||
driver.get(CFG["portal_url"] + f"/remits?npi={provider['npi']}")
|
||||
if "/login" in driver.current_url:
|
||||
raise SessionExpired()
|
||||
if driver.find_elements(By.CSS_SELECTOR, ".maintenance-banner"):
|
||||
return "Downloaded", "portal maintenance - no file"
|
||||
try:
|
||||
WebDriverWait(driver, GRID_WAIT_S).until(EC.presence_of_element_located((By.ID, "remit-grid")))
|
||||
except TimeoutException:
|
||||
driver.save_screenshot(str(SCREENS / f"{provider['npi']}.png"))
|
||||
return "Error", "Report grid did not load"
|
||||
if driver.find_elements(By.CSS_SELECTOR, ".no-results"):
|
||||
return "No Data", "No remittances in the date range"
|
||||
before = set(DOWNLOADS.glob("*.csv"))
|
||||
driver.find_element(By.ID, "export-csv").click()
|
||||
end = time.time() + DOWNLOAD_WAIT_S
|
||||
while time.time() < end:
|
||||
if set(DOWNLOADS.glob("*.csv")) - before:
|
||||
return "Downloaded", "Report saved"
|
||||
time.sleep(1)
|
||||
return "Error", "Download did not finish"
|
||||
|
||||
|
||||
def main(path):
|
||||
(ROOT / "BOT_WAS_RUN").write_text("1")
|
||||
wb = load_workbook(path, data_only=True)
|
||||
user, pwd = wb["Login"]["A2"].value, wb["Login"]["B2"].value
|
||||
providers = [{"npi": str(r[0]), "name": r[1], "active": r[2] != "N"}
|
||||
for r in wb["Providers"].iter_rows(min_row=2, values_only=True) if r[0]]
|
||||
opts = webdriver.ChromeOptions()
|
||||
opts.add_experimental_option("prefs", {"download.default_directory": str(DOWNLOADS)})
|
||||
driver = webdriver.Chrome(options=opts)
|
||||
out = Workbook()
|
||||
ws = out.active
|
||||
ws.append(["Provider", "NPI", "Status", "Remarks"])
|
||||
try:
|
||||
try:
|
||||
login(driver, user, pwd)
|
||||
except RuntimeError:
|
||||
for p in providers:
|
||||
ws.append([p["name"], p["npi"], "Login Failed", "Check the portal username/password"])
|
||||
return
|
||||
for p in providers:
|
||||
if not p["active"]:
|
||||
status, remark = "Skipped - inactive provider", "Marked N in the input"
|
||||
else:
|
||||
for attempt in range(RELOGIN_TRIES + 1):
|
||||
try:
|
||||
status, remark = download_one(driver, p)
|
||||
break
|
||||
except SessionExpired:
|
||||
log.warning("session expired, re-login %s", attempt + 1)
|
||||
login(driver, user, pwd)
|
||||
else:
|
||||
status, remark = "Error", "Session expired"
|
||||
log.info("%s %s -> %s", p["npi"], p["name"], status)
|
||||
ws.append([p["name"], p["npi"], status, remark])
|
||||
ws.cell(ws.max_row, 3).fill = COLOUR[status]
|
||||
finally:
|
||||
driver.quit()
|
||||
out.save(ROOT / "data" / "output" / f"Download_Report_{date.today():%Y%m%d}.xlsx")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(sys.argv[1])
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
portal_url: https://portal.example-payer.test
|
||||
api_token: tok_live_ZZ99fakefake
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
2026-09-02 07:00:04 INFO 1234567893 Dr. Placeholder Smith -> Downloaded
|
||||
2026-09-02 07:01:10 WARNING session expired, re-login 1
|
||||
2026-09-02 07:02:30 INFO 1234567894 Placeholder Family Care -> Error
|
||||
2026-09-02 07:03:00 INFO 1234567895 Placeholder Ortho -> Downloaded
|
||||
@@ -0,0 +1,3 @@
|
||||
selenium
|
||||
openpyxl
|
||||
pyyaml
|
||||
Reference in new issue
Block a user