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>
185 lines
6.9 KiB
Python
185 lines
6.9 KiB
Python
"""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()
|