Initial import: eCW Charge Validation bot and results Portal

Snapshot of the working project as of 2026-10-23 (local 2d4d4fc), one
commit with no earlier history: the tracked files were de-identified
first (synthetic claim ids and patient data only; see Context.md
section 7). Credentials, run logs, screenshots, claim reports and client
workbooks are gitignored and not included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ManikandanandClaude Opus 5.5 committed 2026-10-23 18:38:17 +05:30
commit ba15a574ab
205 files changed
+56772

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
"""
Add a Portal User from this PC — the way the first Admin is made, when
nobody can sign in to the Portal yet (spec 2026-10-22 Stage 2, EEWA-32).
python tools\\add_portal_user.py (asks for everything)
python tools\\add_portal_user.py asha Admin "Asha R"
The password is typed hidden and twice; it is never shown, logged or
passed on the command line. Needs `tools\\sql\\create_tables.sql` run once
(CV_PortalUser / CV_PortalSignIn) and RESULTS_DB_CONN in .env.
"""
# Run standalone from the project root: python tools\add_portal_user.py
import sys as _sys
from pathlib import Path as _Path
_ROOT = _Path(__file__).resolve().parent.parent
_sys.path.insert(0, str(_ROOT / "src"))
_sys.path.insert(0, str(_ROOT))
for _s in (_sys.stdout, _sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import sys
import getpass
from ecw_bot import settings as cfg
from web.users import Accounts, ROLES, SqlUserStore, UserError
def main(argv) -> int:
username = argv[0] if argv else input("Username: ").strip()
role = argv[1] if len(argv) > 1 else (
input(f"Role ({' / '.join(ROLES)}) [Admin]: ").strip() or "Admin")
name = argv[2] if len(argv) > 2 else input("Name shown on the Portal: ")
password = getpass.getpass("Password (10+ characters, hidden): ")
if getpass.getpass("Password again: ") != password:
print("[USER] The two passwords are not the same — nothing added")
return 2
accounts = Accounts(SqlUserStore(cfg.RESULTS_DB_CONN))
try:
user = accounts.create_user(username, name, role, password)
except UserError as exc:
print(f"[USER] {exc} — nothing added")
return 2
except Exception as exc:
print(f"[USER] Could not reach the database ({type(exc).__name__}) "
f"— run tools\\sql\\create_tables.sql once and check "
f"RESULTS_DB_CONN in .env")
return 1
print(f"[USER] {user['username']} added as {user['role']}. Sign in at "
f"http://127.0.0.1:8000/signin")
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))
+228
View File
@@ -0,0 +1,228 @@
r"""
Ad hoc check: read claim numbers straight out of a Medcor-Ready-style
Excel export and open exactly those claims, one by one, to see what our
conditions do to them — without touching the live Medcor Ready queue or
the export step at all.
python tools\check_claims_from_excel.py
python tools\check_claims_from_excel.py "Input\Some Other Claims.xlsx"
Exists because the full pipeline (`ecw_bot.flows.export_medcor_ready.main`)
always re-exports and walks the WHOLE current Medcor Ready queue, in
whatever order eCW returns it — there is no way to target specific claim
numbers through it, and CLAIM_PROCESS_LIMIT only says how many of that
queue's own claims to touch, not which ones.
The point of reading claim numbers from an Excel file instead of typing
them is: when we already know from the Excel (its own "Comments" column
says so) that a claim is eligible for a specific condition, we can hand
this script that one file and watch that exact condition fire — instead
of downloading the live queue and hunting through it one claim at a time.
Once every condition has been proven against its own known-eligible
claim(s) this way, the real workflow goes back to the full download/export
pipeline.
This calls the exact same per-claim primitives the real pipeline calls
(`claim_filters`, `claim_results.search_claim`, and the flow module's own
`_process_claims`) — so what this proves about these claims is what a
real run would show for them, not a re-implementation of the logic.
`SAVE_CORRECTIONS` / `APPLY_CORRECTIONS` are read from `.env` exactly like
a normal run: this script does not turn saving on or off, it only skips
the queue-wide filter/lookup/export/categorize steps. Leave
SAVE_CORRECTIONS=0 in `.env` while testing.
With no arguments, reads "Input\Medcor Ready Claims.xlsx" (the standard
input sheet). If that file is password-protected, you'll be prompted for
the password at the console — it is never read from a file or hardcoded
here.
"""
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent / "src"))
# Windows hands these scripts a cp1252 console, which cannot encode the
# non-ASCII text (em dashes and the like) some of the reused functions print.
for _s in (_sys.stdout, _sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import getpass
import io
import sys
import zipfile
from playwright.sync_api import sync_playwright
from ecw_bot import settings as cfg
from ecw_bot.core import guard, runlog
from ecw_bot.auth.login import login
from ecw_bot.auth.dead_end import LoginTimedOut
from ecw_bot.auth.restricted import Restricted
from ecw_bot.pages import leftnav, claim_filters
from ecw_bot.flows.export_medcor_ready import (
_process_claims, GuardRefused, CLIENT_NAME, start_mode_gate)
DEFAULT_EXCEL_PATH = (
_Path(__file__).resolve().parent.parent / "Input" / "Medcor Ready Claims.xlsx")
CLAIM_COLUMN = "CLAIMS#"
COMMENT_COLUMN = "Comments"
def _load_workbook(path: _Path):
import openpyxl
try:
return openpyxl.load_workbook(path, data_only=True)
except zipfile.BadZipFile:
pass # password-protected legacy OLE container — fall through
try:
import msoffcrypto
except ImportError:
raise SystemExit(
f"{path} is password-protected. Install msoffcrypto-tool "
"(pip install msoffcrypto-tool) to read it.")
password = getpass.getpass(f"Password for {path.name}: ")
with open(path, "rb") as f:
office_file = msoffcrypto.OfficeFile(f)
office_file.load_key(password=password)
decrypted = io.BytesIO()
office_file.decrypt(decrypted)
return openpyxl.load_workbook(decrypted, data_only=True)
def load_claims_from_excel(path: _Path) -> list:
"""
Returns [(claim_id, comment), ...] read from the sheet's own
"CLAIMS#" / "Comments" columns — never a guess at column position,
since a Medcor Ready export's column order isn't guaranteed to be
stable.
"""
if not path.exists():
raise SystemExit(f"Excel file not found: {path}")
wb = _load_workbook(path)
ws = wb.worksheets[0]
rows = list(ws.iter_rows(values_only=True))
if not rows:
return []
header = [str(h).strip() if h is not None else "" for h in rows[0]]
if CLAIM_COLUMN not in header:
raise SystemExit(
f"Could not find a '{CLAIM_COLUMN}' column in {path}. "
f"Found columns: {header}")
claim_col = header.index(CLAIM_COLUMN)
comment_col = header.index(COMMENT_COLUMN) if COMMENT_COLUMN in header else None
out = []
for row in rows[1:]:
raw_id = row[claim_col]
if raw_id is None or str(raw_id).strip() == "":
continue
claim_id = str(int(raw_id)) if isinstance(raw_id, float) else str(raw_id).strip()
comment = (row[comment_col] or "") if comment_col is not None else ""
out.append((claim_id, comment))
return out
def main(excel_path: _Path) -> int:
log_path = runlog.start()
problems = cfg.validate()
if problems:
print("[CONFIG] Not ready:")
for p in problems:
print(f" - {p}")
return 2
claims = load_claims_from_excel(excel_path)
if not claims:
print(f"[CHECK] No claim numbers found in {excel_path}")
return 5
claim_ids = [claim_id for claim_id, _ in claims]
print(f"[CONFIG] user={cfg.ECW_USERNAME} "
f"apply_corrections={cfg.APPLY_CORRECTIONS} "
f"save_corrections={cfg.SAVE_CORRECTIONS}")
print(f"[CONFIG] log={log_path}")
print(f"[CHECK] Loaded {len(claim_ids)} claim(s) from {excel_path}:")
for claim_id, comment in claims:
note = f" — {comment}" if comment else ""
print(f" {claim_id}{note}")
if cfg.SAVE_CORRECTIONS:
print("[CHECK] WARNING: SAVE_CORRECTIONS is ON — a confirmed "
"condition WILL write to eCW. Set it to 0 in .env to only "
"observe.")
# The same [MODE] banner and LIVE-WRITE start box as the full run: this
# tool logs in and reaches the flow's real _process_claims, so it can
# save too (final review, 2026-10-05). Before login, so Cancel costs
# nothing.
if not start_mode_gate():
return 2
# No Charge Summary for an ad hoc check: it has no downloaded claim
# report to build one from (the flow's writer raised KeyError on the
# missing "file" and showed an error box), and a sheet under the live
# summary's name would push the day's real one aside (review item D1).
if cfg.WRITE_CHARGE_SUMMARY:
print("[CHECK] Charge Summary not written for an ad hoc check")
cfg.WRITE_CHARGE_SUMMARY = False
# Same for the results database: no report means no rows to build, and
# the flow's DB hook would only log a KeyError for the missing "file".
if cfg.RESULTS_DB:
print("[CHECK] Results database not written for an ad hoc check")
cfg.RESULTS_DB = False
with sync_playwright() as pw:
browser = None
try:
guard.wait_if_cooling()
refusal = guard.check()
if refusal:
raise GuardRefused(refusal)
guard.record_attempt()
browser, context, page = login(pw, CLIENT_NAME)
guard.record_success()
print(f"[OK] Logged in. Landed on: {page.url}")
leftnav.open_claims(page)
print(f"[OK] On: {page.url}")
# Same order _claims_phase uses — Claim Status first, since it
# drives whether the other filters can match anything.
claim_filters.set_claim_status(page)
claim_filters.set_date_type(page)
claim_filters.set_date_range(page)
claim_filters.clear_balance(page)
_process_claims(page, {"ids": claim_ids})
claim_filters.clear_claim_no(page)
try:
input("\nBrowser open. Press Enter to close...\n")
except EOFError:
print("[INFO] No console attached — closing")
return 0
except GuardRefused as exc:
print(f"[GUARD] {exc}")
return 3
except LoginTimedOut as exc:
print(f"[TIMEOUT] {exc}")
return 6
except Restricted as exc:
print(f"[RESTRICTED] {exc}")
return 4
except Exception as exc:
print(f"[FAIL] {exc}")
return 1
finally:
if browser:
browser.close()
if __name__ == "__main__":
_path = _Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_EXCEL_PATH
sys.exit(main(_path))
+114
View File
@@ -0,0 +1,114 @@
"""
Is the bot allowed to run right now, and if not, when?
python tools\\check_guard.py
Exists because "SystemExit: 3" kept coming up and the only way to find out why
was to start a whole run — which then refused, told you nothing about the clock,
and left you guessing whether to try again in one minute or twenty. This reads
`Logs\\attempts.json` and prints the answer. It starts no browser, touches eCW
not at all, and **records no attempt**, so running it is free.
Exit code mirrors the bot's: 0 = a run would proceed, 3 = it would be refused.
"""
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent / "src"))
# Windows hands these scripts a cp1252 console, which cannot encode the arrows
# below. See runlog._force_utf8_console.
for _s in (_sys.stdout, _sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
from datetime import datetime, timezone, timedelta
from ecw_bot import settings as cfg
from ecw_bot.core import guard
def _local(dt):
return dt.astimezone().strftime("%H:%M:%S") if dt else "-"
def _ago(dt, now):
if not dt:
return ""
secs = (now - dt).total_seconds()
return f"{secs/60:.0f} min ago" if secs >= 90 else f"{secs:.0f}s ago"
def main() -> int:
state = guard._read()
now = datetime.now(timezone.utc)
print(f"Now : {_local(now)}")
print(f"State file : {guard.STATE_PATH}")
print()
last = guard._parse(state.get("last_attempt"))
success = guard._parse(state.get("last_success"))
locked = guard._parse(state.get("locked_until"))
window = now - timedelta(hours=1)
recent = sorted(d for d in (guard._parse(x) for x in state.get("recent", []))
if d and d > window)
print(f"Last attempt : {_local(last)} {_ago(last, now)}")
print(f"Last success : {_local(success)} {_ago(success, now)}")
print()
# Rule 1 — the 5 minute cooldown between attempts. Waited out, not refused.
print(f"Cooldown ({cfg.LOGIN_COOLDOWN_SECONDS}s between attempts)")
if last:
left = cfg.LOGIN_COOLDOWN_SECONDS - (now - last).total_seconds()
if left > 0:
waits = left <= cfg.COOLDOWN_MAX_WAIT_SECONDS
print(f" {left:.0f}s left"
+ (" → the bot WAITS this out and then runs"
if waits else " → too long to wait, it would exit 3"))
else:
print(" clear")
else:
print(" clear (no attempt recorded yet)")
print()
# Rule 2 — the rolling hour. This one refuses; it is the real protection.
print(f"Hourly limit ({len(recent)} of {cfg.LOGIN_MAX_PER_HOUR} in the "
f"last hour)")
for d in recent:
print(f" {_local(d)} leaves the window at "
f"{_local(d + timedelta(hours=1))}")
if len(recent) >= cfg.LOGIN_MAX_PER_HOUR:
clears = min(recent) + timedelta(hours=1)
print(f" FULL. Clears at {_local(clears)} — "
f"{(clears - now).total_seconds()/60:.0f} min from now.")
else:
print(f" room for {cfg.LOGIN_MAX_PER_HOUR - len(recent)} more")
print()
# Rule 3 — eCW showed its restriction page.
print("Restriction lockout")
if locked and now < locked:
print(f" eCW refused us at "
f"{state.get('restricted_at', '?')[:19]} UTC. "
f"Locked until {_local(locked)}.")
print(" Usual cause is the browser losing the proxy, not the account "
"— check tools\\check_net.py first.")
else:
print(" none")
print()
refusal = guard.check()
if refusal:
print(f"VERDICT: a run would be REFUSED (exit 3)")
print(f" {refusal}")
return 3
print("VERDICT: clear — a run would proceed")
return 0
if __name__ == "__main__":
_sys.exit(main())
+169
View File
@@ -0,0 +1,169 @@
"""
Mailbox probe — run this before trusting `OTP_MODE=imap`.
IMAP_HOST in `.env` is a guess. All we were handed is a Roundcube webmail link
on `cpanel1.methodhosting.net`; cPanel serves IMAP from the cPanel host on some
setups and from `mail.<domain>` on others, and the only way to know which is to
try. This connects to each candidate in turn, logs in, and prints the newest
subjects so it is obvious the eCW code actually lands in this inbox.
Nothing is modified — mail is opened with readonly=True, so nothing gets marked
as read.
Run:
python "D:\\Ecw Process\\src\\check_imap.py"
Then copy the host it reports as WORKING into IMAP_HOST in `.env`.
"""
# Run standalone from the project root: python tools\check_imap.py
# The package lives under src\ , which is not on the path for a loose script.
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent / "src"))
# Windows hands these scripts a cp1252 console, which cannot encode the box
# characters and arrows below — check_imap.py died on its own output with
# UnicodeEncodeError. See runlog._force_utf8_console.
for _s in (_sys.stdout, _sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import ssl
import sys
import email
import socket
import imaplib
from email.header import decode_header, make_header
from ecw_bot import settings as cfg
from ecw_bot.auth.mailbox import _extract_code
# Order matters — the configured value is tried first so a already-correct
# .env passes on the first attempt instead of hammering the other hosts.
def _candidates() -> list:
domain = cfg.IMAP_USERNAME.partition("@")[2].strip()
hosts = [cfg.IMAP_HOST]
if domain:
hosts += [f"mail.{domain}", domain]
# cpanel1.methodhosting.net → methodhosting.net is not a mail host, but
# mail.cpanel1... occasionally is. Cheap to include, harmless if it fails.
if cfg.IMAP_HOST:
hosts.append(f"mail.{cfg.IMAP_HOST}")
seen, out = set(), []
for h in hosts:
h = (h or "").strip().rstrip("/")
if h and h not in seen:
seen.add(h)
out.append(h)
return out
def _subject(msg) -> str:
try:
return str(make_header(decode_header(msg.get("Subject") or "")))
except Exception:
return str(msg.get("Subject") or "")
def _connect(host: str, port: int, use_ssl: bool, timeout: int = 15):
# imaplib has no timeout parameter before 3.9 and ignores it for IMAP4_SSL
# on some builds, so set it globally — otherwise a wrong host hangs for the
# full TCP retry window instead of failing in a few seconds.
socket.setdefaulttimeout(timeout)
if use_ssl:
return imaplib.IMAP4_SSL(host, port, ssl_context=ssl.create_default_context())
conn = imaplib.IMAP4(host, port)
conn.starttls(ssl_context=ssl.create_default_context())
return conn
def _try_host(host: str) -> bool:
"""Connect, log in, and print recent subjects. True if the host worked."""
label = f"{host}:{cfg.IMAP_PORT} ssl={cfg.IMAP_SSL}"
print(f"\n── trying {label}")
conn = None
try:
conn = _connect(host, cfg.IMAP_PORT, cfg.IMAP_SSL)
except Exception as exc:
print(f" connect failed: {type(exc).__name__}: {exc}")
return False
try:
conn.login(cfg.IMAP_USERNAME, cfg.IMAP_PASSWORD)
except Exception as exc:
# Reaching the server but failing the login is a different problem from
# a wrong host — say so, because it means the host is probably right.
print(f" connected, but LOGIN failed: {exc}")
print(f" → host looks right; check IMAP_USERNAME / IMAP_PASSWORD")
# Specifically NOT the webmail 2FA. cPanel's Two-Factor Authentication
# guards the cPanel and Roundcube web interfaces only; IMAP/POP3/SMTP
# are exempt, verified on this account (CAPABILITY offers AUTH=PLAIN and
# AUTH=LOGIN, no 2FA mechanism). So a failure here is the password.
print(f" → not the webmail security code: cPanel 2FA does not apply "
f"to IMAP")
try:
conn.logout()
except Exception:
pass
return False
print(" login OK")
try:
typ, data = conn.list()
if typ == "OK":
names = [l.decode(errors="replace").split(' "." ')[-1].strip('"')
for l in (data or [])]
print(f" folders: {', '.join(names[:12])}")
except Exception:
pass
try:
typ, data = conn.select(cfg.IMAP_FOLDER, readonly=True)
if typ != "OK":
print(f" cannot select folder {cfg.IMAP_FOLDER!r}: {data}")
return False
total = int(data[0]) if data and data[0] else 0
print(f" {cfg.IMAP_FOLDER}: {total} message(s)")
typ, data = conn.search(None, "ALL")
ids = data[0].split() if typ == "OK" and data and data[0] else []
if not ids:
print(" inbox is empty — trigger a login so eCW mails a code, then rerun")
else:
print(" newest messages:")
for num in reversed(ids[-10:]):
typ, raw = conn.fetch(num, "(RFC822.HEADER)")
if typ != "OK" or not raw or not raw[0]:
continue
msg = email.message_from_bytes(raw[0][1])
subj = _subject(msg)
code = _extract_code(subj)
hit = f" [code in subject: {code}]" if code else ""
print(f" {msg.get('Date', '?')[:31]:<32} {subj[:60]}{hit}")
return True
finally:
try:
conn.logout()
except Exception:
pass
def main() -> int:
if not (cfg.IMAP_USERNAME and cfg.IMAP_PASSWORD):
print("IMAP_USERNAME / IMAP_PASSWORD not set in .env — nothing to probe")
return 2
print(f"mailbox : {cfg.IMAP_USERNAME}")
print(f"port : {cfg.IMAP_PORT} (ssl={cfg.IMAP_SSL})")
print(f"folder : {cfg.IMAP_FOLDER}")
print(f"configured IMAP_HOST: {cfg.IMAP_HOST or '(blank)'}")
for host in _candidates():
if _try_host(host):
print(f"\n✓ WORKING host: {host}")
if host != cfg.IMAP_HOST:
print(f" → set IMAP_HOST={host} in .env")
else:
print(" → .env is already correct, OTP_MODE=imap is good to go")
return 0
print("\n✗ No candidate host worked.")
print(" Ask hosting for the IMAP server name, or read it from Roundcube:")
print(" Settings → Mail Accounts / Configure → IMAP server.")
print(" Port 143 with IMAP_SSL=false is worth a try if 993 is blocked.")
return 1
if __name__ == "__main__":
sys.exit(main())
+153
View File
@@ -0,0 +1,153 @@
"""
Network preflight — proves the two halves of the login can run at the same time.
Manually, this account is a deadlock. The eCW portal only answers through the
corporate proxy, and the proxy refuses the webmail host outright, so a human
has to flip the system proxy on to log in, off to read the approval mail, and
on again to confirm it.
The bot sidesteps that by never touching the webmail *website*:
eCW portal + approval link → Playwright, launched with an explicit proxy
the approval mail → imaplib, a raw TCP socket to port 993
Two different network paths, so both work concurrently and nothing gets
toggled. The one rule that keeps it true: the SYSTEM proxy must stay OFF.
Playwright is handed the proxy per-browser; imaplib ignores WinINET and the
HTTP(S)_PROXY environment variables because it never speaks HTTP.
Run:
python "D:\\Ecw Process\\src\\check_net.py"
"""
# Run standalone from the project root: python tools\check_net.py
# The package lives under src\ , which is not on the path for a loose script.
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent / "src"))
# Windows hands these scripts a cp1252 console, which cannot encode the box
# characters and arrows below — check_imap.py died on its own output with
# UnicodeEncodeError. See runlog._force_utf8_console.
for _s in (_sys.stdout, _sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import re
import ssl
import sys
import time
import socket
import urllib.request as urlreq
from urllib.parse import urlsplit
from ecw_bot import settings as cfg
TIMEOUT = 25
def _host_of(url: str) -> str:
return urlsplit(url).hostname or url
def _try_http(url: str, proxy: str) -> tuple:
"""(ok, detail). proxy="" forces a direct connection."""
handler = urlreq.ProxyHandler({"http": proxy, "https": proxy} if proxy else {})
opener = urlreq.build_opener(handler)
start = time.time()
try:
resp = opener.open(url, timeout=TIMEOUT)
return True, f"HTTP {resp.status} ({time.time() - start:.1f}s)"
except Exception as exc:
return False, f"{type(exc).__name__}: {str(exc)[:80]} ({time.time() - start:.1f}s)"
def _try_tcp(host: str, port: int) -> tuple:
start = time.time()
sock = socket.socket()
sock.settimeout(TIMEOUT)
try:
sock.connect((host, port))
return True, f"connected ({time.time() - start:.1f}s)"
except Exception as exc:
return False, f"{type(exc).__name__}: {exc}"
finally:
sock.close()
def _row(label: str, ok: bool, detail: str) -> None:
print(f" {label:<22} {'OK ' if ok else 'FAIL'} {detail}")
def _system_proxy_enabled():
"""True/False, or None when it cannot be read (non-Windows)."""
try:
import winreg
key = winreg.OpenKey(
winreg.HKEY_CURRENT_USER,
r"Software\Microsoft\Windows\CurrentVersion\Internet Settings")
with key:
return bool(winreg.QueryValueEx(key, "ProxyEnable")[0])
except Exception:
return None
def main() -> int:
proxy = cfg.proxy_settings()
if not proxy:
print("No proxy configured in .env — set PROXY_IP / PROXY_PORT")
return 2
proxy_url = proxy["server"]
problems = []
print(f"proxy : {proxy_url}")
print(f"portal : {_host_of(cfg.ECW_URL)}")
print(f"mailbox : {cfg.IMAP_HOST}:{cfg.IMAP_PORT}\n")
# ── the proxy itself ──────────────────────────────────────────────────────
m = re.match(r"https?://([^:/]+):(\d+)", proxy_url)
print("proxy reachability")
if m:
ok, detail = _try_tcp(m.group(1), int(m.group(2)))
_row("TCP to proxy", ok, detail)
if not ok:
problems.append("the proxy itself is unreachable — check VPN / network")
print()
# ── eCW: must need the proxy ──────────────────────────────────────────────
print("eCW portal (browser goes through the proxy)")
direct_ok, direct_detail = _try_http(cfg.ECW_URL, "")
proxy_ok, proxy_detail = _try_http(cfg.ECW_URL, proxy_url)
_row("without proxy", direct_ok, direct_detail)
_row("through proxy", proxy_ok, proxy_detail)
if not proxy_ok:
problems.append("eCW is unreachable through the proxy — the bot cannot log in")
print()
# ── mailbox: must NOT need the proxy ──────────────────────────────────────
print("mailbox (imaplib — raw socket, never proxied)")
imap_ok, imap_detail = _try_tcp(cfg.IMAP_HOST, cfg.IMAP_PORT)
_row(f"TCP :{cfg.IMAP_PORT}", imap_ok, imap_detail)
if imap_ok:
start = time.time()
try:
conn = (imap_ssl(cfg) if cfg.IMAP_SSL else None)
conn.login(cfg.IMAP_USERNAME, cfg.IMAP_PASSWORD)
conn.logout()
_row("IMAP login", True, f"{cfg.IMAP_USERNAME} ({time.time() - start:.1f}s)")
except Exception as exc:
_row("IMAP login", False, f"{type(exc).__name__}: {str(exc)[:80]}")
problems.append("IMAP login failed — run check_imap.py")
else:
problems.append("port 993 is blocked — the bot cannot read the approval mail")
print()
# ── the rule that makes it work ───────────────────────────────────────────
print("system proxy (must stay OFF)")
sysproxy = _system_proxy_enabled()
if sysproxy is None:
_row("ProxyEnable", True, "not readable (non-Windows?) — skipped")
elif sysproxy:
_row("ProxyEnable", False, "1 — system proxy is ON")
problems.append(
"the system proxy is ON. Turn it OFF: the bot proxies only its own "
"browser, and a system-wide proxy is what blocks the webmail host")
else:
_row("ProxyEnable", True, "0 — off, correct")
print()
if problems:
print("PROBLEMS")
for p in problems:
print(f" - {p}")
return 1
print("All good — the browser can reach eCW through the proxy while imaplib")
print("reads the mailbox directly. No proxy toggling needed.")
return 0
def imap_ssl(c):
import imaplib
return imaplib.IMAP4_SSL(c.IMAP_HOST, c.IMAP_PORT,
ssl_context=ssl.create_default_context())
if __name__ == "__main__":
sys.exit(main())
+140
View File
@@ -0,0 +1,140 @@
r"""
Refresh src\ecw_bot\payer_groups.json from the team's Claim Status Dashboard.
python tools\import_payer_groups.py
python tools\import_payer_groups.py "<path to the dashboard .xlsx>"
python tools\import_payer_groups.py --dry-run
The dashboard (default: \\mecare2\Production\Salud-eCW RPA\Claim Status
Dashboard Details 1001.xlsx) is the team's own template for which payer goes
in which group: its "Payer Name" and "Payer Group Name" columns. This keeps
ONLY those two columns — distinct pairs, payer names and group names — so no
patient data from the dashboard ever reaches the repo.
Rules:
* Every sheet with both headers is read; a payer seen with two different
groups keeps the one it has on most rows, and the conflict is printed.
* A payer already in payer_groups.json but NOT in the dashboard is kept (the
document's RAE spellings, for one), with its earlier short group name
translated to the dashboard's own ("RAE payer" -> "RAE (Behavioral
Health)", ...), and listed so a person can check it.
* Nothing is written with --dry-run; otherwise the old file is replaced and
every added / changed / kept payer is listed.
Reads a network file only — never logs into eCW. openpyxl is used here (it is
a developer tool, not the bot) because the dashboard is a 5 MB, 25,000-row
workbook.
"""
import sys
import json
import argparse
import collections
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "src"))
for _s in (sys.stdout, sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
DEFAULT_DASHBOARD = (r"\\mecare2\Production\Salud-eCW RPA"
r"\Claim Status Dashboard Details 1001.xlsx")
CONFIG = ROOT / "src" / "ecw_bot" / "payer_groups.json"
# The bot's earlier short group names -> the dashboard's names, for payers
# the dashboard does not list.
_EARLIER_TO_DASHBOARD = {
"Commercial": "Commercial Ins",
"RAE payer": "RAE (Behavioral Health)",
"Dentaquest": "Dental - CHP+/DentaQuest",
"Program": "Self Pay",
}
def read_pairs(path) -> dict:
"""{payer: Counter({group: rows})} from every sheet with both headers."""
import openpyxl
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
pairs = collections.defaultdict(collections.Counter)
try:
for ws in wb.worksheets:
rows = ws.iter_rows(values_only=True)
header = [str(h or "").strip() for h in (next(rows, None) or [])]
if "Payer Name" not in header or "Payer Group Name" not in header:
continue
pi, gi = header.index("Payer Name"), header.index("Payer Group Name")
for row in rows:
if not row or len(row) <= max(pi, gi):
continue
payer = str(row[pi] or "").strip()
group = str(row[gi] or "").strip()
if payer and group:
pairs[payer][group] += 1
finally:
wb.close()
return pairs
def build(pairs: dict, current: dict):
"""(new mapping, conflicts, kept-from-current)."""
mapping, conflicts = {}, {}
for payer, groups in pairs.items():
mapping[payer] = groups.most_common(1)[0][0]
if len(groups) > 1:
conflicts[payer] = dict(groups)
kept = {}
for payer, group in current.items():
if payer not in mapping:
mapping[payer] = _EARLIER_TO_DASHBOARD.get(group, group)
kept[payer] = mapping[payer]
return dict(sorted(mapping.items(), key=lambda kv: kv[0].lower())), \
conflicts, kept
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description=__doc__.splitlines()[1])
ap.add_argument("dashboard", nargs="?", default=DEFAULT_DASHBOARD)
ap.add_argument("--dry-run", action="store_true")
args = ap.parse_args(argv)
current = json.loads(CONFIG.read_text(encoding="utf-8")) \
if CONFIG.exists() else {}
print(f"[IMPORT] Reading {args.dashboard}")
pairs = read_pairs(args.dashboard)
if not pairs:
print("[IMPORT] No sheet with 'Payer Name' and 'Payer Group Name' — "
"nothing written")
return 1
mapping, conflicts, kept = build(pairs, current)
added = sorted(p for p in mapping if p not in current)
changed = sorted(p for p in mapping
if p in current and current[p] != mapping[p])
groups = collections.Counter(mapping.values())
print(f"[IMPORT] {len(pairs)} payer(s) in the dashboard, "
f"{len(mapping)} in the new table")
for g, n in groups.most_common():
print(f"[IMPORT] {n:4d} {g}")
for p, gs in sorted(conflicts.items()):
print(f"[IMPORT] CONFLICT {p!r}: {gs} -> kept {mapping[p]!r}")
for p in sorted(kept):
print(f"[IMPORT] KEPT (not in the dashboard) {p!r} -> {kept[p]!r}")
for p in changed:
print(f"[IMPORT] CHANGED {p!r}: {current[p]!r} -> {mapping[p]!r}")
print(f"[IMPORT] {len(added)} added, {len(changed)} changed, "
f"{len(kept)} kept from the old table")
if args.dry_run:
print("[IMPORT] --dry-run: nothing written")
return 0
CONFIG.write_text(json.dumps(mapping, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
print(f"[IMPORT] Wrote {CONFIG}")
return 0
if __name__ == "__main__":
sys.exit(main())
+98
View File
@@ -0,0 +1,98 @@
-- eCW Charge Validation bot: results table for the Charge Summary rows.
-- Run once in SSMS (as a login allowed to create tables) against
-- MECARE331\MECARE331. Safe to run again: it skips an existing table.
-- Every table of this project starts with CV_ (operator, 2026-10-12).
--
-- The 24 columns follow the team's Charge Summary File Format, plus the
-- run columns and details_json. See src/ecw_bot/core/results_db.py.
USE Charge_Validation;
GO
IF OBJECT_ID('dbo.CV_ChargeSummary', 'U') IS NULL
BEGIN
CREATE TABLE dbo.CV_ChargeSummary (
id INT IDENTITY(1,1) PRIMARY KEY,
run_at DATETIME2 NOT NULL,
run_mode NVARCHAR(20) NOT NULL, -- VERIFY / LIVE-WRITE, or TEST VERIFY / TEST LIVE-WRITE
s_no INT NULL,
claim_no NVARCHAR(20) NOT NULL,
service_date DATE NULL,
pvdr NVARCHAR(50) NULL,
patient NVARCHAR(200) NULL,
payer NVARCHAR(200) NULL,
group_name NVARCHAR(100) NULL,
status NVARCHAR(100) NULL,
charges DECIMAL(12,2) NULL,
pmts_adjs DECIMAL(12,2) NULL,
pmts_refunds DECIMAL(12,2) NULL,
adjustment DECIMAL(12,2) NULL,
withheld DECIMAL(12,2) NULL,
balance DECIMAL(12,2) NULL,
visit_type NVARCHAR(100) NULL,
account_number NVARCHAR(50) NULL,
provider_name NVARCHAR(200) NULL,
guarantor_name NVARCHAR(200) NULL,
received_date DATE NULL,
assigned_to NVARCHAR(100) NULL,
completed_on DATE NULL,
comments NVARCHAR(MAX) NULL,
current_status NVARCHAR(200) NULL,
category NVARCHAR(20) NULL,
details_json NVARCHAR(MAX) NULL
);
CREATE INDEX IX_CV_ChargeSummary_run_at ON dbo.CV_ChargeSummary (run_at);
CREATE INDEX IX_CV_ChargeSummary_claim_no ON dbo.CV_ChargeSummary (claim_no);
END
GO
-- Started By of the Run that wrote the row (2026-10-22, EEWA-33): the
-- signed-in Portal User, or the Windows user for a terminal run. Added to a
-- table made before then; the bot writes it only once it exists.
IF COL_LENGTH('dbo.CV_ChargeSummary', 'started_by') IS NULL
ALTER TABLE dbo.CV_ChargeSummary ADD started_by NVARCHAR(50) NULL;
GO
-- Portal Users and their Sign-in history (2026-10-22, EEWA-32; terms in
-- GLOSSARY.md). Passwords only as PBKDF2-SHA256 hashes with their own salt
-- (web/users.py) — never in plain. Users are disabled, never deleted.
IF OBJECT_ID('dbo.CV_PortalUser', 'U') IS NULL
BEGIN
CREATE TABLE dbo.CV_PortalUser (
username NVARCHAR(50) NOT NULL PRIMARY KEY, -- lower case
display_name NVARCHAR(100) NOT NULL,
role NVARCHAR(20) NOT NULL, -- Admin / Operator / Viewer
pw_hash CHAR(64) NOT NULL, -- hex
pw_salt CHAR(32) NOT NULL, -- hex
pw_iterations INT NOT NULL,
must_change BIT NOT NULL DEFAULT 0,
disabled BIT NOT NULL DEFAULT 0,
failed_count INT NOT NULL DEFAULT 0,
locked_until DATETIME2 NULL,
created_at DATETIME2 NOT NULL,
created_by NVARCHAR(50) NOT NULL,
changed_at DATETIME2 NOT NULL,
CONSTRAINT CK_CV_PortalUser_role
CHECK (role IN ('Admin', 'Operator', 'Viewer'))
);
END
GO
IF OBJECT_ID('dbo.CV_PortalSignIn', 'U') IS NULL
BEGIN
CREATE TABLE dbo.CV_PortalSignIn (
id INT IDENTITY(1,1) PRIMARY KEY,
at DATETIME2 NOT NULL,
username NVARCHAR(50) NOT NULL, -- as typed, for a wrong one
event NVARCHAR(30) NOT NULL, -- sign_in, sign_out, wrong_password,
-- locked, timeout, user_created, ...
by_user NVARCHAR(50) NULL -- the Admin, for an Admin change
);
CREATE INDEX IX_CV_PortalSignIn_at ON dbo.CV_PortalSignIn (at);
END
GO
-- So the bot can connect with your Windows login (no password), give that
-- login read/write here. Uncomment and run once, as an admin (e.g. sa):
--
-- CREATE USER [MFILESERVER\EC10980] FOR LOGIN [MFILESERVER\EC10980];
-- ALTER ROLE db_datareader ADD MEMBER [MFILESERVER\EC10980];
-- ALTER ROLE db_datawriter ADD MEMBER [MFILESERVER\EC10980];
+28
View File
@@ -0,0 +1,28 @@
-- One-off (2026-10-12): every table of this project starts with CV_.
-- Renames the results table created before that rule, and its two indexes,
-- to the names tools/sql/create_tables.sql now uses. Run once in SSMS as a
-- login allowed to alter the table (e.g. sa). Data, columns and the
-- Windows login's read/write grant are kept. Safe to run again: each step
-- only runs when the old name is still there.
USE Charge_Validation;
GO
IF OBJECT_ID('dbo.ChargeSummary', 'U') IS NOT NULL
AND OBJECT_ID('dbo.CV_ChargeSummary', 'U') IS NULL
EXEC sp_rename 'dbo.ChargeSummary', 'CV_ChargeSummary';
GO
IF EXISTS (SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID('dbo.CV_ChargeSummary')
AND name = 'IX_ChargeSummary_run_at')
EXEC sp_rename 'dbo.CV_ChargeSummary.IX_ChargeSummary_run_at',
'IX_CV_ChargeSummary_run_at', 'INDEX';
GO
IF EXISTS (SELECT 1 FROM sys.indexes
WHERE object_id = OBJECT_ID('dbo.CV_ChargeSummary')
AND name = 'IX_ChargeSummary_claim_no')
EXEC sp_rename 'dbo.CV_ChargeSummary.IX_ChargeSummary_claim_no',
'IX_CV_ChargeSummary_claim_no', 'INDEX';
GO
+32
View File
@@ -0,0 +1,32 @@
-- eCW Charge Validation: look at the Portal tables and Started By (read-only).
-- Run in SSMS against MECARE331\MECARE331; the bot's Windows login is enough.
-- Added 2026-10-22 (EEWA-32 / EEWA-33). The password hash and salt are left
-- out of the user query on purpose, so a screenshot of it shares nothing.
USE Charge_Validation;
GO
-- Portal Users
SELECT username, display_name, role, must_change, disabled,
failed_count, locked_until, created_at, created_by, changed_at
FROM dbo.CV_PortalUser
ORDER BY username;
-- Sign-in history, newest first
SELECT TOP 200 at, username, event, by_user
FROM dbo.CV_PortalSignIn
ORDER BY id DESC;
-- Results with Started By, newest run first (NULL before 2026-10-22)
SELECT TOP 200 run_at, run_mode, started_by, s_no, claim_no, category,
current_status
FROM dbo.CV_ChargeSummary
ORDER BY run_at DESC, s_no;
-- One row per run: who started it and its counts
SELECT run_at, MAX(started_by) AS started_by, COUNT(*) AS claims,
SUM(CASE WHEN category = 'Pass' THEN 1 ELSE 0 END) AS pass_count,
SUM(CASE WHEN category = 'Fail' THEN 1 ELSE 0 END) AS fail_count,
SUM(CASE WHEN category = 'Review' THEN 1 ELSE 0 END) AS review_count
FROM dbo.CV_ChargeSummary
GROUP BY run_at
ORDER BY run_at DESC;
+69
View File
@@ -0,0 +1,69 @@
"""
Open the Chrome the bot attaches to when BROWSER_CDP_URL is set.
start_ecw_chrome.bat (or: python tools\\start_ecw_chrome.py)
Not needed for a run any more (2026-10-22): with no Chrome on the port, the
bot opens it itself when the run starts, and closes it at the end. This is
for opening it by hand — to add or sign in to the Claude extension before a
run, or to keep one open across runs (the bot never closes a Chrome it did
not open).
Google Chrome, not Playwright's Chromium, started exactly as the bot starts
it (core/browser.py `start_chrome`):
* its own profile, CDP_PROFILE_DIR (default D:\\EcwChrome): Chrome refuses a
debug port on the default profile, and C: running out of space killed the
browser mid-login once (2026-10-21);
* the debug port from BROWSER_CDP_URL, on 127.0.0.1 only;
* the eCW proxy, except for claude.ai — the extension's traffic goes
direct (the Windows proxy stays off);
* the same flags as the bot's own launch (core/browser.py CHROME_ARGS).
Close this Chrome when you are done: while it is open, any program on this PC
can drive it — a browser that has been logged in to eCW — through the port.
The bot clears eCW's cookies at the start and end of every run, not the rest
of the profile.
"""
# Run standalone from the project root: python tools\start_ecw_chrome.py
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent / "src"))
for _s in (_sys.stdout, _sys.stderr):
try:
_s.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
import sys
from ecw_bot import settings as cfg
from ecw_bot.core.browser import (PROXY_BYPASS, cdp_ready, # noqa: F401
chrome_command, find_chrome, start_chrome)
def main() -> int:
url = cfg.BROWSER_CDP_URL or "http://127.0.0.1:9222"
problem = cfg.cdp_url_problem(url)
if problem:
print(f"[CHROME] {problem}")
return 2
if cdp_ready(url):
print(f"[CHROME] Already running at {url} — nothing to do")
return 0
if not start_chrome(url):
return 1
proxy = cfg.proxy_settings()
print(f"[CHROME] Running at {url}")
print(f"[CHROME] Profile {cfg.CDP_PROFILE_DIR}")
print(f"[CHROME] Proxy {proxy['server'] if proxy else '(none)'}"
f"{' (direct: ' + PROXY_BYPASS + ')' if proxy else ''}")
if not cfg.BROWSER_CDP_URL:
print(f"[CHROME] Note: BROWSER_CDP_URL is blank in .env, so the bot "
f"will still launch its own browser. Set BROWSER_CDP_URL={url}")
print("[CHROME] Close this Chrome when you are done with it.")
return 0
if __name__ == "__main__":
sys.exit(main())