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:
VasanthandClaude Opus 5.5 committed 2026-10-28 15:01:42 +05:30
1 parent 3d2db6aca6
commit 3942fad6cd
143 files changed
+11815 -2

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
BILLING_API_URL=https://billing.example.test/api/v2
BILLING_API_KEY=sk_test_FAKE_1234567890abcd
+5
View File
@@ -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
View File
@@ -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"}
]
+2
View File
@@ -0,0 +1,2 @@
invoice_no,customer_id,customer_name,amount,currency
INV-7781,C-19,Acme Placeholder Ltd,120.00,USD
1 invoice_no customer_id customer_name amount currency
2 INV-7781 C-19 Acme Placeholder Ltd 120.00 USD
+4
View File
@@ -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
View File
@@ -0,0 +1,2 @@
requests
python-dotenv
+85
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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`.
+6
View File
@@ -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
View File
@@ -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
+13
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
pywinauto
openpyxl
python-dotenv
+4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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()
+6
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
pypdf
python-dotenv
+2
View File
@@ -0,0 +1,2 @@
LEDGER_USER=bot01
LEDGER_PASSWORD=Lp-Fake-77
+6
View File
@@ -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).
+5
View File
@@ -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
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
placeholder png
+34
View File
@@ -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()
+2
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
pyautogui
opencv-python
openpyxl
pyinstaller
+6
View File
@@ -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.
+114
View File
@@ -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
View File
@@ -0,0 +1,2 @@
portal_url: https://portal.example-payer.test
api_token: tok_live_ZZ99fakefake
+4
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
selenium
openpyxl
pyyaml