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 <noreply@anthropic.com>
This commit is contained in:
commit
fd480fa874
10 files changed
+1335
No files matched your search
+180
@@ -0,0 +1,180 @@
|
||||
"""Per-location reconciliation: Aquilaehr visits vs Waystar claims, matched on
|
||||
Patient Name + Date of Service. Writes output/<Location>.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()
|
||||
Reference in new issue
Block a user