From fd480fa874d9016feea0acb14a44d4fb15909a87 Mon Sep 17 00:00:00 2001 From: Vasanth Date: Tue, 27 Oct 2026 17:38:27 +0530 Subject: [PATCH] MEK Charge Reconciliation: Waystar + Aquilaehr download, per-location reconciliation, web app - mek_recon.py: Playwright download for both sites in parallel (headless on server) - consolidate.py: Patient Name + DOS match, per-location and All Locations Excel - app.py: sign-in (server-side password), Run button, per-file and ZIP downloads - Dockerfile for Dokploy Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 12 ++ .gitignore | 11 ++ Dockerfile | 13 ++ app.py | 141 +++++++++++++++ consolidate.py | 180 +++++++++++++++++++ mek_recon.py | 325 ++++++++++++++++++++++++++++++++++ process_steps.md | 91 ++++++++++ requirements.txt | 5 + templates/index.html | 151 ++++++++++++++++ templates/login.html | 406 +++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 1335 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 consolidate.py create mode 100644 mek_recon.py create mode 100644 process_steps.md create mode 100644 requirements.txt create mode 100644 templates/index.html create mode 100644 templates/login.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d31f92e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# secrets +.env +# patient data (PHI) - never commit +processing/ +output/ +logs/ +# browser profiles / caches +.chrome_profile*/ +__pycache__/ +.ruff_cache/ +.git/ +.claude/ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7d137b --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# secrets +.env +# patient data (PHI) - never commit +processing/ +output/ +logs/ +# browser profiles / caches +.chrome_profile*/ +__pycache__/ +.ruff_cache/ +.claude/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8c9df9d --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Playwright image ships Chromium + its OS deps for this exact Playwright version +FROM mcr.microsoft.com/playwright/python:v1.49.0-noble + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY . . + +# headless, bundled Chromium (no Google Chrome in the image) +ENV HEADLESS=1 CHROME_CHANNEL= PYTHONUNBUFFERED=1 TZ=America/New_York +EXPOSE 8000 +# one worker: run state is kept in memory +CMD ["gunicorn", "-w", "1", "--threads", "8", "-b", "0.0.0.0:8000", "--timeout", "120", "app:app"] diff --git a/app.py b/app.py new file mode 100644 index 0000000..86120c6 --- /dev/null +++ b/app.py @@ -0,0 +1,141 @@ +"""Web front end: sign in -> Run -> download per-location reports or a ZIP of all. + +Env: APP_PASSWORD (sign-in password), SECRET_KEY (session signing; random if unset), + COOKIE_SECURE=1 behind HTTPS, plus the Waystar/Aquilaehr logins mek_recon.py reads. +Run locally: python app.py -> http://127.0.0.1:8000 +""" +import hmac +import io +import os +import secrets +import subprocess +import sys +import threading +import time +import zipfile +from functools import wraps +from pathlib import Path + +from flask import Flask, abort, jsonify, redirect, render_template, request, send_file, session + +import consolidate +import mek_recon + +ROOT = Path(__file__).parent +OUT = ROOT / "output" +LOG = ROOT / "logs" / "run.log" + +mek_recon.load_env() # local .env; on the server the values come from the environment +app = Flask(__name__) +app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32) +app.config.update( + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=os.environ.get("COOKIE_SECURE") == "1", + PERMANENT_SESSION_LIFETIME=12 * 3600, +) + +# ponytail: one run at a time, state in memory - fine for one gunicorn worker; restart forgets the last run +run = {"proc": None, "started": None, "finished": None, "code": None} +lock = threading.Lock() + + +def signed_in(view): + @wraps(view) + def wrapper(*a, **kw): + if not session.get("ok"): + return (jsonify(error="sign in"), 401) if request.path != "/" else render_template("login.html") + return view(*a, **kw) + return wrapper + + +@app.post("/login") +def login(): + expected = os.environ.get("APP_PASSWORD", "") + given = request.form.get("password", "") + if not expected or not hmac.compare_digest(given.encode(), expected.encode()): + time.sleep(1) # slow down guessing + return jsonify(error="wrong password"), 401 + session.clear() + session.permanent = True + session["ok"] = True + return jsonify(ok=True) + + +@app.post("/logout") +def logout(): + session.clear() + return redirect("/") + + +@app.get("/") +@signed_in +def index(): + return render_template("index.html") + + +def reports(): + return sorted(OUT.glob("*.xlsx"), key=lambda f: (f.name != consolidate.ALL_FILE, f.name.lower())) + + +@app.post("/run") +@signed_in +def start_run(): + with lock: + if run["proc"] and run["proc"].poll() is None: + return jsonify(error="already running"), 409 + # start clean: a location that fails must not leave last run's file looking current + for f in [*ROOT.glob("processing/*/*.csv"), *OUT.glob("*.xlsx")]: + f.unlink() + LOG.parent.mkdir(exist_ok=True) + env = {**os.environ, "HEADLESS": os.environ.get("HEADLESS", "1"), + "PYTHONUNBUFFERED": "1", "PYTHONIOENCODING": "utf-8"} + with LOG.open("w", encoding="utf-8") as log: + run["proc"] = subprocess.Popen([sys.executable, "mek_recon.py"], cwd=ROOT, env=env, + stdout=log, stderr=subprocess.STDOUT) + run.update(started=time.time(), finished=None, code=None) + return jsonify(ok=True) + + +@app.get("/status") +@signed_in +def status(): + proc = run["proc"] + running = bool(proc and proc.poll() is None) + if proc and not running and run["finished"] is None: + run.update(finished=time.time(), code=proc.returncode) + log = LOG.read_text(encoding="utf-8", errors="replace") if LOG.exists() else "" + return jsonify( + running=running, started=run["started"], finished=run["finished"], code=run["code"], + log="\n".join(log.splitlines()[-300:]), + files=[{"name": f.name, "size": f.stat().st_size, "mtime": f.stat().st_mtime} for f in reports()], + ) + + +@app.get("/download/") +@signed_in +def download(name): + match = next((f for f in reports() if f.name == name), None) # only files we list + if not match: + abort(404) + return send_file(match, as_attachment=True, download_name=match.name) + + +@app.get("/download-all") +@signed_in +def download_all(): + files = reports() + if not files: + abort(404) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + for f in files: + z.write(f, f.name) + buf.seek(0) + stamp = time.strftime("%Y-%m-%d_%H%M", time.localtime(max(f.stat().st_mtime for f in files))) + return send_file(buf, as_attachment=True, download_name=f"MEK_Charge_Reconciliation_{stamp}.zip", + mimetype="application/zip") + + +if __name__ == "__main__": + app.run(host="127.0.0.1", port=int(os.environ.get("PORT", 8000))) diff --git a/consolidate.py b/consolidate.py new file mode 100644 index 0000000..d6ac2d8 --- /dev/null +++ b/consolidate.py @@ -0,0 +1,180 @@ +"""Per-location reconciliation: Aquilaehr visits vs Waystar claims, matched on +Patient Name + Date of Service. Writes output/.xlsx. + +Aquilaehr: Patient ("Mary Lopez Garcia") + Visit Date +Waystar: Patient Name ("LOPEZ, MARY") + From Date +Match = same date, same first name, and at least one last-name word in common. + +Run: python consolidate.py +""" +from pathlib import Path + +import pandas as pd + +ROOT = Path(__file__).parent +WAYSTAR_DIR = ROOT / "processing" / "Waystar" +AQUILAEHR_DIR = ROOT / "processing" / "Aquilaehr" +OUT_DIR = ROOT / "output" +SUFFIXES = {"JR", "SR", "II", "III", "IV"} + + +def words(s): + return [w for w in str(s).upper().replace(",", " ").replace(".", " ").split() if w not in SUFFIXES] + + +def join(values): + return ", ".join(dict.fromkeys(v for v in values if isinstance(v, str) and v.strip())) + + +def load_aquilaehr(path): + a = pd.read_csv(path, dtype=str).fillna("") + a["DOS"] = pd.to_datetime(a["Visit Date"], errors="coerce").dt.date + a["Name"] = a["Patient"].str.split().str.join(" ") + # one row per note/template -> one row per patient visit + v = a.groupby(["Account #", "Name", "DOS"], as_index=False).agg( + **{"Aquilaehr Claim ID": ("Claim ID", join), "Note Status": ("Note Status", join), + "Claim Status": ("Claim Status", join), "Provider": ("Provider", lambda s: join(" ".join(x.split()) for x in s))} + ) + t = v["Name"].map(words) + v["first"] = t.str[0].fillna("") + v["last"] = t.str[1:].map(set) + return v + + +def load_waystar(path): + w = pd.read_csv(path, skiprows=7, dtype=str, index_col=False).fillna("") + for c in w.columns: # Excel-friendly CSV wraps values like ="..." + w[c] = w[c].str.replace(r'^="(.*)"$', r"\1", regex=True) + w["DOS"] = pd.to_datetime(w["From Date"], errors="coerce").dt.date + w["Charges"] = pd.to_numeric(w["Charges"].str.replace(r"[$,]", "", regex=True), errors="coerce").fillna(0) + w["Claim ID ref"] = w["Claim Number"].str.split().str[0] + v = w.groupby(["Patient Name", "DOS"], as_index=False).agg( + **{"Waystar Claim Number": ("Claim Number", join), "Charges": ("Charges", "sum"), + "Waystar Status": ("Status", join), "Claim ID ref": ("Claim ID ref", join)} + ) + parts = v["Patient Name"].str.partition(",") # "LAST, FIRST MI" + v["first"] = [(words(f) or [""])[0] for f in parts[2]] + v["last"] = [set(words(n)) for n in parts[0]] + return v + + +def reconcile(a, w): + rows, used = [], set() + a_by_key = {} + for i, r in a.iterrows(): + a_by_key.setdefault((r["first"], r["DOS"]), []).append(i) + a_claim_dates = {} + for _, r in a.iterrows(): + for cid in r["Aquilaehr Claim ID"].split(", "): + if cid: + a_claim_dates.setdefault(cid, set()).add(r["DOS"]) + w_claim_dates = {} + for _, r in w.iterrows(): + for cid in r["Claim ID ref"].split(", "): + if cid: + w_claim_dates.setdefault(cid, set()).add(r["DOS"]) + + def fmt(dates): + return ", ".join(d.strftime("%m/%d/%Y") for d in sorted(dates) if pd.notna(d)) + + for _, r in w.iterrows(): + same = [i for i in a_by_key.get((r["first"], r["DOS"]), []) if a.at[i, "last"] & r["last"]] + hit = next((i for i in same if i not in used), None) + row = {"Patient Name": r["Patient Name"], "DOS": r["DOS"], + "Waystar Claim Number": r["Waystar Claim Number"], "Charges": r["Charges"], + "Waystar Status": r["Waystar Status"]} + if hit is not None: + used.add(hit) + row.update(Result="Matched", **a.loc[hit, ["Aquilaehr Claim ID", "Note Status", "Claim Status", "Provider"]]) + row["Aquilaehr Patient"] = a.at[hit, "Name"] + elif same: # visit already matched to another Waystar claim + row.update(Result="Duplicate in Waystar", **a.loc[same[0], ["Aquilaehr Claim ID", "Claim Status"]]) + row["Aquilaehr Patient"] = a.at[same[0], "Name"] + row["Note"] = "Same patient + DOS already matched to another Waystar claim" + else: + row["Result"] = "Missing in Aquilaehr" + other = set().union(*(a_claim_dates.get(c, set()) for c in r["Claim ID ref"].split(", "))) + if other: + row["Note"] = f"Claim ID {r['Claim ID ref']} is in Aquilaehr with Visit Date {fmt(other)}" + rows.append(row) + + for i, r in a.iterrows(): + if i in used: + continue + row = {"Patient Name": r["Name"], "DOS": r["DOS"], "Result": "Missing in Waystar", + "Aquilaehr Patient": r["Name"], **r[["Aquilaehr Claim ID", "Note Status", "Claim Status", "Provider"]]} + other = set().union(*(w_claim_dates.get(c, set()) for c in r["Aquilaehr Claim ID"].split(", ") if c)) + if other: + row["Note"] = f"Claim ID {r['Aquilaehr Claim ID']} is in Waystar with DOS {fmt(other)}" + rows.append(row) + + cols = ["Result", "Patient Name", "DOS", "Aquilaehr Patient", "Aquilaehr Claim ID", "Note Status", + "Claim Status", "Provider", "Waystar Claim Number", "Charges", "Waystar Status", "Note"] + order = {"Missing in Waystar": 0, "Missing in Aquilaehr": 1, "Duplicate in Waystar": 2, "Matched": 3} + df = pd.DataFrame(rows).reindex(columns=cols) + return df.sort_values(["Result", "Patient Name", "DOS"], key=lambda s: s.map(order) if s.name == "Result" else s) + + +def write_report(df, location, out): + counts = df["Result"].value_counts() + summary = pd.DataFrame({ + "Item": ["Location", "Aquilaehr visits", "Waystar visits", "Matched", + "Missing in Waystar", "Missing in Aquilaehr", "Duplicate in Waystar"], + "Value": [location, int(counts.get("Matched", 0) + counts.get("Missing in Waystar", 0)), + int((df["Result"] != "Missing in Waystar").sum()), int(counts.get("Matched", 0)), + int(counts.get("Missing in Waystar", 0)), int(counts.get("Missing in Aquilaehr", 0)), + int(counts.get("Duplicate in Waystar", 0))], + }) + write_xlsx(out, summary, df) + return summary + + +def write_xlsx(out, summary, df): + with pd.ExcelWriter(out, engine="openpyxl") as xw: + summary.to_excel(xw, sheet_name="Summary", index=False) + df.to_excel(xw, sheet_name="Reconciliation", index=False) + for ws in xw.book.worksheets: + ws.freeze_panes = "A2" + ws.auto_filter.ref = ws.dimensions + for col in ws.columns: + width = max(len(str(c.value or "")) for c in col[:200]) + ws.column_dimensions[col[0].column_letter].width = min(max(width + 2, 10), 60) + ws = xw.book["Reconciliation"] + for col in ws.iter_cols(min_row=1, max_row=ws.max_row): + fmt = {"DOS": "mm/dd/yyyy", "Charges": '"$"#,##0.00'}.get(col[0].value) + for c in col[1:] if fmt else (): + c.number_format = fmt + + +ALL_FILE = "All Locations.xlsx" + + +def main(): + OUT_DIR.mkdir(exist_ok=True) + summaries, frames = [], [] + for wf in sorted(WAYSTAR_DIR.glob("*.csv")): + af = AQUILAEHR_DIR / wf.name + if not af.exists(): + print(f"SKIP {wf.stem}: no Aquilaehr file") + continue + df = reconcile(load_aquilaehr(af), load_waystar(wf)) + summary = write_report(df, wf.stem, OUT_DIR / f"{wf.stem}.xlsx") + s = summary["Value"].tolist() + print(f"{wf.stem:40} Aquilaehr={s[1]:5} Waystar={s[2]:5} Matched={s[3]:5} " + f"Missing in Waystar={s[4]:4} Missing in Aquilaehr={s[5]:4} Duplicate in Waystar={s[6]:3}") + if s[1] and s[2] and s[3] < 0.5 * min(s[1], s[2]): + print(f" WARNING {wf.stem}: under half the visits match - wrong practice data downloaded?") + summaries.append(summary.set_index("Item")["Value"]) + frames.append(df.assign(Location=wf.stem)) + + if frames: # one overall workbook: a row per location + every location's rows + overall = pd.DataFrame(summaries).reset_index(drop=True) + total = overall.drop(columns="Location").sum().to_dict() + overall = pd.concat([overall, pd.DataFrame([{"Location": "TOTAL", **total}])], ignore_index=True) + rows = pd.concat(frames, ignore_index=True) + write_xlsx(OUT_DIR / ALL_FILE, overall, rows[["Location", *rows.columns[:-1]]]) + print(f"{'ALL LOCATIONS':40} -> output/{ALL_FILE}") + + +if __name__ == "__main__": + main() diff --git a/mek_recon.py b/mek_recon.py new file mode 100644 index 0000000..1b98aa0 --- /dev/null +++ b/mek_recon.py @@ -0,0 +1,325 @@ +"""MEK Charge reconciliation process. + +Waystar (ZirMed): for each location -> switch account -> Claims -> Status All, +Transaction Date All -> Search -> Download CSV -> processing/Waystar/.csv +Aquilaehr: Reports -> Encounter & Claim Monitor -> pick practice -> Export CSV +-> processing/Aquilaehr/.csv (same file name as Waystar so the pair lines up) +Then consolidate.py matches Patient Name + DOS per location -> output/.xlsx + +Run: python mek_recon.py both sites at the same time, all locations + python mek_recon.py Aquilaehr only Aquilaehr + python mek_recon.py Waystar "Healing Bridge" only Waystar, only that location +Login is automatic from .env (WAYSTAR_USERNAME, WAYSTAR_PASSWORD, WAYSTAR_ANSWER_CAR, +WAYSTAR_ANSWER_JOB, AQUILAEHR_USERNAME, AQUILAEHR_PASSWORD). If something is +missing/unexpected, finish the login by hand in Chrome; the script waits. +""" +import os +import re +import sys +import time +from multiprocessing import Process +from pathlib import Path + +from playwright.sync_api import sync_playwright + +import consolidate + +ROOT = Path(__file__).parent +WAYSTAR, AQUILAEHR = "Waystar", "Aquilaehr" +OUT = {site: ROOT / "processing" / site for site in (WAYSTAR, AQUILAEHR)} + +# Waystar account name -> Aquilaehr practice dropdown names. +# Several Aquilaehr practices are exported and merged into one file. +# Adelman Health is skipped on both sites (no Aquilaehr practice). +LOCATIONS = { + "Alpha Well-Being LLC": ["ALPHA WELL-BEING LLC"], + "ARC Wound Care": ["ARC Woundcare LLC"], + "Cynthia DM Concierge NP Services LLC": ["Moona Concierge Nurse Practitioner Services LLC"], + "Healing Bridge": ["HEALING BRIDGE LLC"], + "Joy Solutions LLC": ["Joy Solutions CORP"], + "Lunas Home NP LLC": ["LUNAS HOME NP LLC"], + "Stem Regen NJ": ["Stem Regen Medical"], + "Sunwave Woundcare": ["Sunwave Woundcare"], + "WoundDocs LLC": ["WOUNDOCS LLC", "WOUNDOCS LLC - 2"], + "Wound Pros of FL": ["Wound Professionals of Florida LLC"], +} + +WAYSTAR_LOGIN = "https://login.zirmed.com/ui" +WAYSTAR_CLAIMS = "https://claims.zirmed.com/Claims/Listing/Index?appid=1" +AQUILAEHR_REPORTS = "https://platform.aquilaehr.com/#/reports/clinical" +# time to finish a login by hand; headless (server) runs have no hand, so fail sooner +LOGIN_WAIT_MS = (3 if os.environ.get("HEADLESS") == "1" else 10) * 60 * 1000 + + +def load_env(): + env = ROOT / ".env" + if env.exists(): + for line in env.read_text(encoding="utf-8").splitlines(): + if "=" in line and not line.lstrip().startswith("#"): + k, v = line.split("=", 1) + os.environ.setdefault(k.strip(), v.strip().strip('"')) + + +def log(site, msg): + print(f"{site:9} {msg}", flush=True) + + +# ---------------- Waystar ---------------- + +def waystar_login(page): + page.goto(WAYSTAR_LOGIN) + account_box = page.locator("input.header-account-search-text") + if os.environ.get("WAYSTAR_USERNAME") and page.locator("#loginName").is_visible(): + page.fill("#loginName", os.environ["WAYSTAR_USERNAME"]) + page.fill("#password", os.environ.get("WAYSTAR_PASSWORD", "")) + page.click("#loginButton") + log(WAYSTAR, "login submitted") + + # pages Waystar may show after "Log in" (ids taken from the G2 ChargeBilledAutomation bot) + answers = {"first car": os.environ.get("WAYSTAR_ANSWER_CAR"), "first job": os.environ.get("WAYSTAR_ANSWER_JOB")} + question = page.locator("#authDiv strong").first + alert = page.locator("#continueButton") # "Proactive Alert" page + sec_info = page.locator("h2", has_text="Security Information") + end = time.time() + LOGIN_WAIT_MS / 1000 + while time.time() < end: + if account_box.is_visible(): + log(WAYSTAR, "logged in") + return + if alert.is_visible(): + alert.click() + log(WAYSTAR, "proactive alert continued") + elif question.is_visible(): + text = question.inner_text().lower() + key = next((k for k in answers if k in text), None) + if not key or not answers[key]: + raise RuntimeError(f"unknown Waystar security question: {text}") + page.fill("#verifyAnswer", answers[key]) + page.click("#VerifyButton") + log(WAYSTAR, f"security question ({key}) answered") + elif sec_info.is_visible(): + page.click("#NextButton") + page.get_by_text("Skip This Step", exact=True).click() + log(WAYSTAR, "security information page skipped") + page.wait_for_timeout(1000) + raise RuntimeError("Waystar login did not finish in time") + + +def waystar_switch_account(page, location): + box = page.locator("input.header-account-search-text") + box.click() + box.fill("") + box.press_sequentially(location, delay=50) + items = page.locator("ul.ui-autocomplete li:visible") + # dropdown text looks like "Healing Bridge (313858)"; it shows the first 25 + # accounts before the typed filter kicks in, so wait for the actual match + match = items.filter(has_text=location).first + try: + match.wait_for(timeout=15000) + except Exception: + raise RuntimeError(f"no account match in dropdown: {items.all_inner_texts()}") + match.click() + page.wait_for_url("**/SuccessfulAccountChange/**", timeout=30000) + + +def waystar_download(page, location): + waystar_switch_account(page, location) + page.goto(WAYSTAR_CLAIMS) + page.select_option("#SearchCriteria_Status", label="All") + page.select_option("#SearchCriteria_TransDate", label="All") + page.click("#ClaimListingSearchButtonTop") + page.get_by_text("Transaction Date: All").first.wait_for(timeout=60000) + page.wait_for_load_state("networkidle") + with page.expect_download(timeout=120000) as dl: + page.click("#downloadCsvBtn") + out = OUT[WAYSTAR] / f"{location}.csv" + dl.value.save_as(out) + return out + + +# ---------------- Aquilaehr ---------------- + +AQUILAEHR_PRACTICE = ".ant-select[style*='200px']" # practice dropdown at the top of the page +AQUILAEHR_EXPORT = "a[download]" # inside the "Export CSV" button (blob URL) + + +def aquilaehr_login(page): + page.goto(AQUILAEHR_REPORTS) + email = page.locator("#normal_login_email") + logged_in = page.get_by_text("Reports", exact=True).first + submitted = False + end = time.time() + LOGIN_WAIT_MS / 1000 + # SPA: either the login form or the app shows up after a few seconds + while time.time() < end: + if logged_in.is_visible(): + log(AQUILAEHR, "logged in") + return + if not submitted and os.environ.get("AQUILAEHR_USERNAME") and email.is_visible(): + email.fill(os.environ["AQUILAEHR_USERNAME"]) + page.fill("#normal_login_password", os.environ.get("AQUILAEHR_PASSWORD", "")) + page.click("button[type=submit]") + submitted = True + log(AQUILAEHR, "login submitted") + page.wait_for_timeout(1000) + raise RuntimeError("Aquilaehr login did not finish in time") + + +def aquilaehr_download(page, location): + """Export every practice for this location and merge into one CSV (header once).""" + out = OUT[AQUILAEHR] / f"{location}.csv" + lines = [] + for practice in LOCATIONS[location]: + part = aquilaehr_export(page, practice, OUT[AQUILAEHR] / f"{location}.part.csv") + rows = part.read_text(encoding="utf-8-sig").splitlines() + part.unlink() + lines += rows if not lines else rows[1:] + log(AQUILAEHR, f" {practice}: {len(rows) - 1} rows") + out.write_text("\n".join(lines) + "\n", encoding="utf-8-sig") + return out + + +def norm(s): + return " ".join(s.split()).lower() + + +def aquilaehr_select_practice(page, practice): + # practice dropdown is a virtual list: scroll the open one until the option renders + page.locator(AQUILAEHR_PRACTICE).click() + page.locator(".ant-select-dropdown:not(.ant-select-dropdown-hidden)").wait_for() + found = page.evaluate( + """async (title) => { + const norm = s => (s || '').split(/\\s+/).join(' ').trim().toLowerCase(); + const dd = document.querySelector('.ant-select-dropdown:not(.ant-select-dropdown-hidden)'); + const h = dd.querySelector('.rc-virtual-list-holder'); + for (let y = 0; y <= h.scrollHeight + 100; y += 80) { + h.scrollTop = y; + await new Promise(r => setTimeout(r, 150)); + const o = [...dd.querySelectorAll('.ant-select-item-option')] + .find(o => norm(o.getAttribute('title')) === norm(title)); + if (o) { o.click(); return true; } + } + return false; + }""", + practice, + ) + if not found: + raise RuntimeError(f"practice '{practice}' not in dropdown") + + +def aquilaehr_export(page, practice, out): + # practice ids whose report data has arrived (GET .../reports/clinical_notes?practice_id=) + loaded = [] + + def on_response(r): + m = re.search(r"/reports/clinical_notes\?practice_id=([\w-]+)", r.url) + if m and r.ok: + loaded.append(m.group(1)) + + page.on("response", on_response) + try: + # Reports -> Encounter & Claim Monitor tab + page.goto(AQUILAEHR_REPORTS) + page.get_by_text("Encounter & Claim Monitor", exact=True).first.click() + page.wait_for_load_state("networkidle") + current = page.locator(f"{AQUILAEHR_PRACTICE} .ant-select-selection-item") + if norm(current.inner_text()) != norm(practice): + before = page.url + aquilaehr_select_practice(page, practice) + page.wait_for_url(lambda u: u != before and "practiceId=" in u, timeout=30000) + if norm(current.inner_text()) != norm(practice): + raise RuntimeError(f"dropdown shows '{current.inner_text()}', expected '{practice}'") + + # wait for THIS practice's data; data for the previous practice can still be arriving + pid = re.search(r"practiceId=([\w-]+)", page.url) + end = time.time() + 120 + while not (pid.group(1) in loaded if pid else loaded): + if time.time() > end: + raise RuntimeError(f"report data for '{practice}' did not load") + page.wait_for_timeout(500) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(2000) # let the table + export link re-render + + with page.expect_download(timeout=120000) as dl: + page.locator(AQUILAEHR_EXPORT).click() + dl.value.save_as(out) + finally: + page.remove_listener("response", on_response) + return out + + +# ---------------- runner ---------------- + +SITES = { + WAYSTAR: (waystar_login, waystar_download), + AQUILAEHR: (aquilaehr_login, aquilaehr_download), +} + + +def run_site(site, locations): + """One site in its own Chrome (own profile), so both sites can run at the same time.""" + load_env() + OUT[site].mkdir(parents=True, exist_ok=True) + login, download = SITES[site] + failed = [] + seen = {} # file content -> location; two locations with identical exports = stale data + with sync_playwright() as p: + # server/Docker: HEADLESS=1 and CHROME_CHANNEL="" (Playwright's bundled Chromium) + headless = os.environ.get("HEADLESS") == "1" + ctx = p.chromium.launch_persistent_context( + str(ROOT / f".chrome_profile_{site.lower()}"), + channel=os.environ.get("CHROME_CHANNEL", "chrome") or None, headless=headless, + accept_downloads=True, no_viewport=not headless, + viewport={"width": 1440, "height": 900} if headless else None, + ) + page = ctx.pages[0] if ctx.pages else ctx.new_page() + login(page) + for loc in locations: + try: + out = download(page, loc) + content = out.read_bytes() + if content in seen: + log(site, f"WARNING {loc}: same data as {seen[content]} - stale export?") + seen[content] = loc + log(site, f"OK {loc} -> {out.name} ({content.count(b'\n')} lines)") + except Exception as e: + failed.append(loc) + log(site, f"FAIL {loc}: {e}") + ctx.close() + log(site, f"failed: {failed or 'none'}") + + +def locked_files(): + """Files open in Excel can't be overwritten -> the run would silently keep old data.""" + locked = [] + for f in [*ROOT.glob("processing/*/*.csv"), *ROOT.glob("output/*.xlsx")]: + try: + with open(f, "a"): + pass + except PermissionError: + locked.append(f.relative_to(ROOT)) + return locked + + +def main(): + locked = locked_files() + if locked: + print("Close these files in Excel first, then run again:") + for f in locked: + print(f" {f}") + sys.exit(1) + args = sys.argv[1:] + sites = [args.pop(0)] if args and args[0] in SITES else list(SITES) + locations = [loc for loc in LOCATIONS if not args or loc in args] + if len(sites) == 1: + run_site(sites[0], locations) + else: + procs = [Process(target=run_site, args=(site, locations)) for site in sites] + for pr in procs: + pr.start() + for pr in procs: + pr.join() + print("Consolidating -> output/.xlsx", flush=True) + consolidate.main() + + +if __name__ == "__main__": + main() diff --git a/process_steps.md b/process_steps.md new file mode 100644 index 0000000..c0661cc --- /dev/null +++ b/process_steps.md @@ -0,0 +1,91 @@ +# MEK Charge reconciliation process + +Sites: +- Waystar (ZirMed) -> https://login.zirmed.com/ui +- Aquilaehr -> https://platform.aquilaehr.com/ + +Run: `python mek_recon.py` -> both sites at the same time, each in its own Chrome window +(own profile: .chrome_profile_waystar / .chrome_profile_aquilaehr). +One site only: `python mek_recon.py Waystar` or `python mek_recon.py Aquilaehr`. +Close processing/ and output/ files in Excel/WPS before running (the script stops and lists them otherwise). +Full run verified 10/27/2026: both sites 10/10 locations, reports in output/ (~1 min). + +Logins come from .env (values never stored in code): +- Waystar: WAYSTAR_USERNAME, WAYSTAR_PASSWORD, WAYSTAR_ANSWER_CAR, WAYSTAR_ANSWER_JOB + -> fills #loginName / #password -> "Log in" -> security question page: answer by keyword + ("first car" / "first job") -> click "Verify" +- Aquilaehr: AQUILAEHR_USERNAME, AQUILAEHR_PASSWORD + +## Locations (run the whole process for each, on BOTH sites) +- ~~Adelman Health~~ (skipped) +- Alpha Well-Being LLC +- ARC Wound Care +- Cynthia DM Concierge NP Services LLC +- Healing Bridge +- Joy Solutions LLC +- Lunas Home NP LLC +- Stem Regen NJ +- Sunwave Woundcare +- WoundDocs LLC +- Wound Pros of FL + +## Waystar steps +1. Open https://login.zirmed.com/ui and log in +2. Select location: top "Account" box -> type location name + -> pick from dropdown (e.g. "Healing Bridge (313858)") + -> lands on "Success - You have switched to the following account" page +3. Professional Claims > click "Claims" tab + -> https://claims.zirmed.com/Claims/Listing/Index?appid=1 +4. Left panel filters: + - Status (select#SearchCriteria_Status) -> "All" (default was "All Rejected") + - Transaction Date (select#SearchCriteria_TransDate) -> "All" (default "Last 90 Days"; "All" = last 2 years) +5. Click "Search" +6. Click "Download CSV" (#downloadCsvBtn, "Excel-Friendly CSV" ticked) + -> save as processing/Waystar/.csv + CSV layout: 7 preamble lines (Generated by / Account: / Filters...), then header row: + Trans Date, Patient Name, Claim Number, Claim ID, Instance ID, Payer Name, Payer ID, From Date, To Date, + Rendering Provider, Charges, Status, Sequence, Source, Hidden, Remit #s, Total Remit Amount, Last Note, Last Event Message +7. Repeat 2-6 for every location. + +## Aquilaehr steps +1. Open https://platform.aquilaehr.com/ (login page: #normal_login_email, #normal_login_password, "Login") +2. Sidebar "Reports" -> https://platform.aquilaehr.com/#/reports/clinical +3. Click "Encounter & Claim Monitor" tab +4. Top practice dropdown (antd select, virtual list - scroll to find) -> pick location + -> URL gets ?practiceId=; page reloads data. Date range left empty = all dates. +5. Wait until the data call for the NEW practice finishes + (GET aquila-api-prod-xv7xr.ondigitalocean.app/api/v1/reports/clinical_notes?practice_id=), + and the dropdown shows the chosen practice. (Exporting earlier gives the PREVIOUS practice's data.) + Then click "Export CSV" (it's , contains ALL rows, not just page 1) + -> save as processing/Aquilaehr/.csv + Columns: Account #, Patient, DOB, Visit Date, Provider, Location, Visit Reason, Room, Template, + Plan Name, Claim ID, Note Status, Claim Status +6. Repeat 4-5 for every location. + +Waystar name -> Aquilaehr dropdown name: see LOCATIONS in mek_recon.py +- Adelman Health: SKIPPED on both sites (no Aquilaehr practice) +- Cynthia DM Concierge NP Services LLC -> "Moona Concierge Nurse Practitioner Services LLC" +- WoundDocs LLC -> "WOUNDOCS LLC" + "WOUNDOCS LLC - 2", both exported and merged into one file + +## Consolidation +Match key (verified 10/27/2026, 3938 of 4027 Waystar claims found in Aquilaehr = 97.8%): + Waystar "Claim Number" = =" " e.g. ="42756 35055" + -> first token of Waystar Claim Number == Aquilaehr "Claim ID" +Parsing notes: Waystar CSV = skip 7 preamble lines, rows end with a trailing comma (read with index_col=False), +values wrapped like ="..." (strip them). Aquilaehr CSV: one row per note/template, so a Claim ID can repeat. + +Report (consolidate.py, runs automatically at the end of mek_recon.py, or alone: `python consolidate.py`): +- Match on Patient Name + Date of Service, per location: + Aquilaehr Patient + Visit Date vs Waystar Patient Name + From Date + = same DOS, same first name, at least one last-name word in common + (handles "Mary Lopez Garcia" vs "LOPEZ, MARY", extra spaces, Jr/Sr) +- Aquilaehr rows grouped to one row per visit (Account # + Patient + Visit Date); + Waystar rows grouped per Patient Name + From Date (Charges summed) +- Output: output/.xlsx with 2 sheets + - Summary: Aquilaehr visits, Waystar visits, Matched, Missing in Waystar, Missing in Aquilaehr, Duplicate in Waystar + - Reconciliation: one row per visit, Result column: + Missing in Waystar = visit in Aquilaehr, no Waystar claim for that patient + DOS + Missing in Aquilaehr = Waystar claim, no Aquilaehr visit for that patient + DOS + Duplicate in Waystar = 2nd Waystar claim for an already-matched patient + DOS (e.g. two name spellings) + Matched + - Note column: when the same Claim ID exists on the other side under a different date diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..24fc9bc --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +playwright==1.49.0 +pandas==3.0.3 +openpyxl==3.1.5 +flask==3.1.0 +gunicorn==23.0.0 diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..0213beb --- /dev/null +++ b/templates/index.html @@ -0,0 +1,151 @@ + + + + + +MEK Charge Reconciliation + + + +
+
+

MEK Charge Reconciliation

+
+
+ +
+

Run

+

Downloads Waystar claims and Aquilaehr encounters for every location at the same time, + then builds one reconciliation report per location (Patient Name + Date of Service).

+
+ + +
+

+  
+ +
+ +
+ +
+
+ + + + diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..fd59774 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,406 @@ + + + + + +Sign in · MEK Charge Reconciliation + + + + + +
+
+ + +

MEK Charge Reconciliation

+

Sign in

+ +
+ +
+ + +
+ + + +
+ +
+
+ + + +