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:
commit
ba15a574ab
205 files changed
+56772
No files matched your search
@@ -0,0 +1,760 @@
|
||||
"""
|
||||
Salud eCW Charge Entry Bot — settings.
|
||||
|
||||
Single-client build. Everything that changes between environments lives in
|
||||
`.env` at the project root (never committed). No credentials in code, and no
|
||||
credentials in a shared spreadsheet the way the multi-client NAHMS bot did it.
|
||||
|
||||
Read order for every setting:
|
||||
1. real process environment variable (wins — good for CI / scheduled runs)
|
||||
2. .env file at the project root
|
||||
3. the default in this file
|
||||
|
||||
**Selectors do not live here.** They used to, and a 442-line module mixing DOM
|
||||
ids with operational settings meant changing a date range and changing a
|
||||
selector were edits to the same file. They are in `selectors\\` now; this
|
||||
module holds only values a person would want to change without touching code.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from .selectors.claim_lookup import DATE_TYPE_CLAIM
|
||||
|
||||
# ── Project roots ─────────────────────────────────────────────────────────────
|
||||
# Three parents, not one: this file is src\ecw_bot\settings.py, so
|
||||
# settings.py → ecw_bot → src → the project root that holds .env and Logs\.
|
||||
if getattr(sys, "frozen", False):
|
||||
ROOT = Path(sys.executable).parent
|
||||
else:
|
||||
ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
ENV_PATH = ROOT / ".env"
|
||||
TODAY = datetime.now().strftime("%m-%d-%Y")
|
||||
SCREENSHOTS = ROOT / "Screenshots" / TODAY
|
||||
LOGS = ROOT / "Logs"
|
||||
DOWNLOADS_TMP = ROOT / "Input" / "_pw_downloads"
|
||||
|
||||
|
||||
# ── .env loader ───────────────────────────────────────────────────────────────
|
||||
# Deliberately dependency-free: these machines are locked down and adding
|
||||
# python-dotenv is one more thing to get approved. The format is the usual
|
||||
# KEY=value, one per line, '#' comments, optional surrounding quotes.
|
||||
#
|
||||
# Inline comments: an UNQUOTED value ends at the first whitespace followed by
|
||||
# '#', so `SAVE_CORRECTIONS=0 # verification` reads as "0". Before 2026-10-05
|
||||
# the whole tail was kept, "0 # verification" was not a recognised yes/no
|
||||
# word, and the setting fell back to its code default — which for
|
||||
# SAVE_CORRECTIONS was ON. A comment meant to document "saving is off" turned
|
||||
# saving on, silently (review item P1). `CLAIM_PROCESS_LIMIT=5 # demo` read as
|
||||
# the default 1 the same way.
|
||||
#
|
||||
# A '#' with NO whitespace before it stays part of the value: real values here
|
||||
# contain one — the landing URL's "#/..." fragment, and a mailbox password —
|
||||
# and neither has a space in front of it.
|
||||
#
|
||||
# **Quote a value that needs ` #`** (space then hash) inside it:
|
||||
# `KEY="a # b"` reads as `a # b`. For a quoted value the text between the
|
||||
# quotes is the value and anything after the closing quote (` # note`) is
|
||||
# dropped. A value that is quoted end to end with nothing after it reads
|
||||
# exactly as it always did, inner quotes included (`"pa"ss"` → `pa"ss`,
|
||||
# `"Ab"#12"` → `Ab"#12`): after a closing quote, as everywhere else, a '#'
|
||||
# starts a comment only when whitespace comes before it. The one shape that
|
||||
# cannot be told apart from a trailing comment is an inner quote of the SAME
|
||||
# kind followed by whitespace and '#' (`"a" #b"` reads as `a`); write such a
|
||||
# value in the other kind of quotes (`'a" #b'` reads as `a" #b`).
|
||||
#
|
||||
# Text after the closing quote that is NOT a comment (`"1"0`, `'1'x`) reads
|
||||
# as the quoted part for a text setting, but stops the run (exit 2, before
|
||||
# login) for a yes/no or whole-number one — see _QUOTE_TRAILING.
|
||||
|
||||
# Opening quote, the shortest text that still lets the rest of the line be
|
||||
# nothing but an optional ` #` comment, then the same quote. Shortest, so a
|
||||
# quote inside the comment cannot pull the comment into the value; "still lets
|
||||
# the rest be a comment", so a quote inside the value does not cut it short.
|
||||
# The comment needs whitespace before its '#' — the same rule as an unquoted
|
||||
# value — so `"Ab"#12"` is one value, not `Ab` plus a comment (fix round 1 of
|
||||
# the unit-A review: the first version cut a password there, silently).
|
||||
_QUOTED_VALUE = re.compile(r"""^(["'])(.*?)\1(?:\s+#.*)?$""")
|
||||
_INLINE_COMMENT = re.compile(r"\s+#")
|
||||
|
||||
|
||||
# Keys whose .env value was quoted and then followed by text that is NOT a
|
||||
# comment (`SAVE_CORRECTIONS="1"0`), as {key: the text after the '='}. Such
|
||||
# a value still reads as the quoted part — a password written that way keeps
|
||||
# working as it always did — but a yes/no or whole-number setting must never
|
||||
# be guessed at, so `_env_bool` / `_env_int` record it as a bad value (exit 2
|
||||
# before login) instead of reading `"1"0` as 1 = LIVE-WRITE. The same text
|
||||
# unquoted (`10`, `1x`) was already refused (final review, 2026-10-05).
|
||||
_QUOTE_TRAILING: dict = {}
|
||||
|
||||
|
||||
def _parse_env_value(raw: str) -> tuple:
|
||||
"""
|
||||
`(value, trailing)` for one `KEY=value` line's text after the '='.
|
||||
`trailing` is True when a quoted value has non-comment text after its
|
||||
closing quote — read as the quoted part, but not a clean value.
|
||||
"""
|
||||
text = raw.strip()
|
||||
if text[:1] in ("\"", "'"):
|
||||
m = _QUOTED_VALUE.match(text)
|
||||
if m:
|
||||
return m.group(2), False
|
||||
# Text after the closing quote that is not a comment (`"abc"xyz`):
|
||||
# the quotes still say where the value ends.
|
||||
end = text.find(text[0], 1)
|
||||
if end != -1:
|
||||
return text[1:end], True
|
||||
# No closing quote at all: not a quoted value, so the quote character
|
||||
# is just part of an unquoted one — what the loader always did.
|
||||
# Cut on the raw text, not the stripped one, so `KEY= # nothing yet`
|
||||
# (whitespace right after the '=') reads as blank rather than "# nothing".
|
||||
return _INLINE_COMMENT.split(raw, maxsplit=1)[0].strip(), False
|
||||
|
||||
|
||||
def _env_file_value(raw: str) -> str:
|
||||
"""The value part of one `KEY=value` line, as written after the '='."""
|
||||
return _parse_env_value(raw)[0]
|
||||
|
||||
|
||||
# The keys this process took from .env, not from the real environment. A
|
||||
# child process started from here — the results page's bot run,
|
||||
# web/bot_runner.py — is given the environment WITHOUT them, so its own
|
||||
# settings.py reads .env afresh: an .env edited after the web server started
|
||||
# is seen, as a terminal run sees it (review, 2026-10-14). Inherited, the
|
||||
# server's copy would win over the file through the setdefault below.
|
||||
ENV_FILE_KEYS = set()
|
||||
|
||||
|
||||
def _load_env_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for line in path.read_text(encoding="utf-8-sig").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, raw = line.partition("=")
|
||||
key = key.strip()
|
||||
val, trailing = _parse_env_value(raw)
|
||||
if trailing and key not in os.environ:
|
||||
# Only when the file's value is the one used (real env wins).
|
||||
_QUOTE_TRAILING[key] = raw.strip()
|
||||
# Real environment wins over the file.
|
||||
if key not in os.environ:
|
||||
# As os.environ lists it: Windows stores keys upper-cased, and
|
||||
# the child-env filter walks os.environ.
|
||||
ENV_FILE_KEYS.add(key.upper() if os.name == "nt" else key)
|
||||
os.environ.setdefault(key, val)
|
||||
|
||||
|
||||
_load_env_file(ENV_PATH)
|
||||
|
||||
|
||||
def _env(key: str, default: str = "") -> str:
|
||||
return str(os.environ.get(key, default) or "").strip()
|
||||
|
||||
|
||||
# Every yes/no or whole-number setting whose value could not be read, as
|
||||
# (key, raw value, what it should have been). validate() turns each into a
|
||||
# problem, so the run stops with exit 2 BEFORE login — where stopping costs
|
||||
# nothing — instead of quietly running on the code default.
|
||||
#
|
||||
# Strict on purpose (P1, 2026-10-01 review): the old parsers fell back to the
|
||||
# default for anything they did not recognise, so `SAVE_CORRECTIONS=O` (the
|
||||
# letter O), a stray comment, or a typo all read as the default — which for
|
||||
# SAVE_CORRECTIONS was "save to live eCW". A setting that controls writes must
|
||||
# never be guessed at. Blank or unset is NOT recorded: that genuinely means
|
||||
# "use the default".
|
||||
_BAD_VALUES: list = []
|
||||
|
||||
|
||||
def _record_bad(key: str, raw: str, expected: str) -> None:
|
||||
if (key, raw, expected) not in _BAD_VALUES:
|
||||
_BAD_VALUES.append((key, raw, expected))
|
||||
|
||||
|
||||
def _env_int(key: str, default: int) -> int:
|
||||
if key in _QUOTE_TRAILING:
|
||||
_record_bad(key, _QUOTE_TRAILING[key], "a whole number")
|
||||
return default
|
||||
raw = _env(key)
|
||||
if not raw:
|
||||
return default
|
||||
# isascii() too: "²" is a str.isdigit() digit that int() refuses, and a
|
||||
# crash at import would hide the clear validate() message below.
|
||||
if raw.isascii() and raw.isdigit():
|
||||
return int(raw)
|
||||
_record_bad(key, raw, "a whole number")
|
||||
return default
|
||||
|
||||
|
||||
def _env_bool(key: str, default: bool = False) -> bool:
|
||||
if key in _QUOTE_TRAILING:
|
||||
_record_bad(key, _QUOTE_TRAILING[key], "a valid yes/no value")
|
||||
return default
|
||||
raw = _env(key)
|
||||
word = raw.lower()
|
||||
if word in ("1", "true", "yes", "y", "on"):
|
||||
return True
|
||||
if word in ("0", "false", "no", "n", "off"):
|
||||
return False
|
||||
if raw:
|
||||
_record_bad(key, raw, "a valid yes/no value")
|
||||
return default
|
||||
|
||||
|
||||
# ── eCW portal ────────────────────────────────────────────────────────────────
|
||||
ECW_URL = _env("ECW_URL")
|
||||
ECW_USERNAME = _env("ECW_USERNAME")
|
||||
ECW_PASSWORD = _env("ECW_PASSWORD")
|
||||
# Blank for this client — kept so a TOTP-enabled login needs no code change.
|
||||
ECW_SECRET_KEY = _env("ECW_SECRET_KEY")
|
||||
|
||||
# Optional deep link to open once login is confirmed (the provider schedule
|
||||
# page). eCW drops the URL fragment during the login redirect, so navigating
|
||||
# again after login is the only reliable way to land on it.
|
||||
ECW_LANDING_URL = _env("ECW_LANDING_URL", ECW_URL)
|
||||
|
||||
|
||||
# ── OTP / two-factor ──────────────────────────────────────────────────────────
|
||||
# auto — TOTP if ECW_SECRET_KEY is set, else mail if configured, else manual
|
||||
# totp — authenticator secret only
|
||||
# mail — read the challenge out of the mailbox. eCW sends either a 6-digit
|
||||
# code or a "Push Notification" approval link; auth/mailbox.py detects
|
||||
# which, so one mode covers both. (`imap` is an accepted old alias.)
|
||||
# manual — pause with a message box and let a human handle it
|
||||
OTP_MODE = _env("OTP_MODE", "auto").lower()
|
||||
|
||||
IMAP_HOST = _env("IMAP_HOST")
|
||||
IMAP_PORT = _env_int("IMAP_PORT", 993)
|
||||
IMAP_USERNAME = _env("IMAP_USERNAME")
|
||||
IMAP_PASSWORD = _env("IMAP_PASSWORD")
|
||||
IMAP_FOLDER = _env("IMAP_FOLDER", "INBOX")
|
||||
IMAP_SSL = _env_bool("IMAP_SSL", True)
|
||||
|
||||
# How long to keep polling the mailbox for the OTP mail, and how often.
|
||||
#
|
||||
# Ten minutes. eCW's push mail is DELAYED, not dropped — proven by delivery
|
||||
# order on 2026-09-02: the message eCW stamped 11:19:10 arrived in the mailbox
|
||||
# *after* the one stamped 11:26:10, so roughly seven minutes late, and ten has
|
||||
# been seen. A short window gives up while the mail is still in flight and the
|
||||
# run then blames the mailbox. Quitting early is what costs a login attempt.
|
||||
OTP_WAIT_SECONDS = _env_int("OTP_WAIT_SECONDS", 600)
|
||||
OTP_POLL_SECONDS = _env_int("OTP_POLL_SECONDS", 5)
|
||||
|
||||
# A challenge older than this gets a warning in the log — it is NOT skipped.
|
||||
#
|
||||
# eCW's JWT is good for about ten minutes and the mail itself sometimes takes
|
||||
# ten to arrive, so a link can land already near expiry. Refusing to click it
|
||||
# then guarantees failure, while clicking it costs nothing that has not already
|
||||
# been spent. So this only marks the age, giving the log a reason to point at
|
||||
# when eCW answers "this verification link has expired" — which otherwise reads
|
||||
# exactly like a wrong password.
|
||||
OTP_LINK_AGE_WARN_SECONDS = _env_int("OTP_LINK_AGE_WARN_SECONDS", 540)
|
||||
|
||||
# eCW's push notification screen offers a "Resend OTP" button, and pressing it
|
||||
# beats handing the step to a human.
|
||||
#
|
||||
# But resend LATE, not early — a full ten minutes of waiting first.
|
||||
#
|
||||
# Ten because that is the longest delivery delay actually observed: the mail is
|
||||
# late rather than missing, so before ten minutes have passed "still in flight"
|
||||
# is the likeliest explanation, and a second message would only join the same
|
||||
# slow queue behind the first.
|
||||
#
|
||||
# One resend, so the schedule is 600s → Resend → 600s = 20 minutes before a
|
||||
# human is asked. Raise OTP_RESEND_ATTEMPTS for more rounds, but note the two
|
||||
# ceilings that are NOT ours to raise: eCW's JWT is good for about ten minutes,
|
||||
# and its login page may expire before the wait does — auth/twofactor.py checks
|
||||
# for that and stops with it as the stated reason.
|
||||
OTP_RESEND_ATTEMPTS = _env_int("OTP_RESEND_ATTEMPTS", 1)
|
||||
OTP_RESEND_WAIT_SECONDS = _env_int("OTP_RESEND_WAIT_SECONDS", 600)
|
||||
# Ignore anything already sitting in the inbox from before the login attempt.
|
||||
OTP_MAX_AGE_SECONDS = _env_int("OTP_MAX_AGE_SECONDS", 300)
|
||||
|
||||
|
||||
# ── Proxy ─────────────────────────────────────────────────────────────────────
|
||||
PROXY_IP = _env("PROXY_IP")
|
||||
PROXY_PORT = _env("PROXY_PORT")
|
||||
|
||||
|
||||
def proxy_settings():
|
||||
"""Playwright proxy dict, or None when no usable proxy is configured."""
|
||||
import re
|
||||
if not (PROXY_IP and PROXY_PORT):
|
||||
return None
|
||||
ip = re.sub(r"^https?://", "", PROXY_IP).strip().rstrip("/")
|
||||
port = PROXY_PORT.strip()
|
||||
if not ip or not port.isdigit():
|
||||
return None
|
||||
return {"server": f"http://{ip}:{port}"}
|
||||
|
||||
|
||||
# ── Attempt guard ─────────────────────────────────────────────────────────────
|
||||
# eCW restricts an account that is retried too hard — seven attempts in
|
||||
# forty-five minutes was enough, and afterwards it stopped mailing approval
|
||||
# links while the page still claimed it had sent one. core/guard.py enforces
|
||||
# these.
|
||||
LOGIN_COOLDOWN_SECONDS = _env_int("LOGIN_COOLDOWN_SECONDS", 300) # 5 min
|
||||
LOGIN_MAX_PER_HOUR = _env_int("LOGIN_MAX_PER_HOUR", 4)
|
||||
|
||||
# If a plain cooldown has no more than this left to run, the bot waits it out
|
||||
# rather than exiting 3. Refusing a run over the last few seconds of a cooldown
|
||||
# just moves the retrying into the operator's hands, which is worse: five of six
|
||||
# consecutive runs died on this, one of them with 3s left. A restriction lockout
|
||||
# and the per-hour burst limit are never waited out — those mean stop.
|
||||
# 0 disables waiting and restores the old exit-immediately behaviour.
|
||||
COOLDOWN_MAX_WAIT_SECONDS = _env_int("COOLDOWN_MAX_WAIT_SECONDS",
|
||||
LOGIN_COOLDOWN_SECONDS)
|
||||
|
||||
# How many times to start the login over when eCW's 2FA screen times out.
|
||||
#
|
||||
# "Your log in request has timed out. Please try again" is a dead end: the page
|
||||
# has stopped polling for the approval and the emailed link is spent, so only a
|
||||
# fresh login helps. The browser is closed and the whole login runs again.
|
||||
#
|
||||
# Each retry goes through the guard above — cooldown waited out, burst limit
|
||||
# still enforced — because an unguarded retry loop against this portal is
|
||||
# exactly what earned the account its restriction. With the defaults that means
|
||||
# a retry costs a 5-minute wait and one of the four hourly attempts, so 1 is
|
||||
# deliberately modest: two shots at a factor that is delayed rather than
|
||||
# broken. 0 turns the retry off and the run stops with exit 6.
|
||||
LOGIN_RETRY_ON_TIMEOUT = _env_int("LOGIN_RETRY_ON_TIMEOUT", 1)
|
||||
|
||||
|
||||
# ── Operator prompts ──────────────────────────────────────────────────────────
|
||||
# Every message box times out, so an unattended run fails instead of holding a
|
||||
# browser open indefinitely.
|
||||
MSGBOX_TIMEOUT_SECONDS = _env_int("MSGBOX_TIMEOUT_SECONDS", 180)
|
||||
|
||||
|
||||
# ── Disk space ────────────────────────────────────────────────────────────────
|
||||
# Checked on the system drive before login (2026-10-22): with C: at 0 MB the
|
||||
# browser died mid-login twice on 2026-10-21. Under WARN = a Warning on the
|
||||
# Runs page; under STOP = the Run stops with exit 2, no login attempt spent.
|
||||
DISK_WARN_MB = _env_int("DISK_WARN_MB", 2048)
|
||||
DISK_STOP_MB = _env_int("DISK_STOP_MB", 500)
|
||||
|
||||
|
||||
# ── Claim watchdog ────────────────────────────────────────────────────────────
|
||||
# No progress on a claim for this many minutes = the page froze (2026-10-22:
|
||||
# a run sat 28 minutes on one claim). The driver is closed and the run stops
|
||||
# with exit 8, recorded. Never under a message box's timeout + 2 minutes.
|
||||
# 0 = off. core/watchdog.py.
|
||||
WATCHDOG_MINUTES = _env_int("WATCHDOG_MINUTES", 10)
|
||||
|
||||
|
||||
# ── Browser ───────────────────────────────────────────────────────────────────
|
||||
HEADLESS = _env_bool("HEADLESS", False)
|
||||
CHROME_PATH = _env("CHROME_PATH") # blank → Playwright's bundled chromium
|
||||
|
||||
# Attach to a Chrome the operator started with start_ecw_chrome.bat instead of
|
||||
# launching one, so the run can be watched live (Claude extension in that
|
||||
# Chrome). Blank → launch as before. Loopback only: whatever reaches this port
|
||||
# can drive a browser logged in to eCW. In this mode the proxy is Chrome's own
|
||||
# (the .bat passes it), and eCW's cookies are cleared at the start and end of
|
||||
# every run.
|
||||
BROWSER_CDP_URL = _env("BROWSER_CDP_URL")
|
||||
# The profile that Chrome uses — on D:, because C: running out of space killed
|
||||
# the browser mid-login (2026-10-21).
|
||||
CDP_PROFILE_DIR = _env("CDP_PROFILE_DIR", r"D:\EcwChrome")
|
||||
# A Chrome the bot opened itself (none on the port at Start) is closed again
|
||||
# at the end, so its debug port is not left open. A Chrome the operator
|
||||
# opened is never closed. 0 = leave it open.
|
||||
CDP_CLOSE_AFTER_RUN = _env_bool("CDP_CLOSE_AFTER_RUN", True)
|
||||
|
||||
|
||||
def cdp_url_problem(url: str):
|
||||
"""Why `url` is not a usable BROWSER_CDP_URL, or None if it is."""
|
||||
from urllib.parse import urlsplit
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
port = parts.port
|
||||
except ValueError:
|
||||
return f"BROWSER_CDP_URL={url!r} is not a URL"
|
||||
if parts.scheme != "http" or not port:
|
||||
return f"BROWSER_CDP_URL={url!r} must look like http://127.0.0.1:9222"
|
||||
if parts.hostname not in ("127.0.0.1", "localhost"):
|
||||
return (f"BROWSER_CDP_URL={url!r} must be on 127.0.0.1 — anything that "
|
||||
f"reaches the port can drive the logged-in browser")
|
||||
return None
|
||||
|
||||
|
||||
# ── Timeouts (ms — Playwright's unit) ─────────────────────────────────────────
|
||||
LOGIN_TIMEOUT = _env_int("LOGIN_TIMEOUT_MS", 120_000) # 2 min
|
||||
NAV_TIMEOUT = _env_int("NAV_TIMEOUT_MS", 300_000) # 5 min
|
||||
NAV_RETRIES = _env_int("NAV_RETRIES", 3) # proxy ERR_ABORTED retries
|
||||
|
||||
# Captcha is solved by a human via message box; two tries, then a fresh
|
||||
# incognito browser, then one last try. Same escalation the NAHMS bot settled
|
||||
# on after the OCR route proved unreliable.
|
||||
CAPTCHA_ATTEMPTS = _env_int("CAPTCHA_ATTEMPTS", 2)
|
||||
|
||||
|
||||
# ── Claim lookup: what to search for ──────────────────────────────────────────
|
||||
# Selected by VALUE, with the label checked afterwards. The value is what eCW
|
||||
# submits and the only unambiguous handle — the list holds "Medcor Ready"
|
||||
# (108ME) and "Medcor Testing" (113ME), so a loose text match could pick the
|
||||
# wrong queue and the search would still succeed. The label check then catches
|
||||
# the opposite risk: eCW reassigning a code to a different status.
|
||||
CLAIM_STATUS = _env("CLAIM_STATUS", "108ME")
|
||||
CLAIM_STATUS_LABEL = _env("CLAIM_STATUS_LABEL", "Medcor Ready")
|
||||
|
||||
# Which date the search filters on. The process asks for Claim Dt(s).
|
||||
#
|
||||
# Be aware of what it pairs with: Claim Status "Encounters Without Claims"
|
||||
# returns rows that have no claim, so they have no claim date and this returns
|
||||
# nothing. Measured 2026-09-02, same 06/01/2020 → today range — 936 rows over
|
||||
# 47 pages on Service Dt(s), 0 on Claim Dt(s). claim_filters._warn_if_dateless()
|
||||
# says so in the log rather than leaving an empty grid to be puzzled over.
|
||||
CLAIM_DATE_TYPE = _env("CLAIM_DATE_TYPE", DATE_TYPE_CLAIM)
|
||||
|
||||
# From is fixed by the process; To is "today" unless overridden. MM/DD/YYYY,
|
||||
# matching the inputs' own placeholder and their idt-maxlength="10".
|
||||
CLAIM_FROM_DATE = _env("CLAIM_FROM_DATE", "06/01/2020")
|
||||
CLAIM_TO_DATE = _env("CLAIM_TO_DATE") # blank → today
|
||||
|
||||
# The Balance row arrives holding "> 0.0" with every control disabled, and eCW
|
||||
# queries on the Angular model rather than the inputs — so the filter is live
|
||||
# and unclickable. 1 clears it through the scope; 0 leaves it alone.
|
||||
#
|
||||
# The ng-scope / ng-binding classes in the DOM confirm Angular's debugInfo is
|
||||
# enabled, which is what makes angular.element(el).scope() available. If eCW
|
||||
# ever ships with it disabled this stops working, and it says so rather than
|
||||
# reporting a clear that did not happen.
|
||||
FORCE_CLEAR_BALANCE = _env_bool("FORCE_CLEAR_BALANCE", True)
|
||||
|
||||
# How long to wait for the results grid after Lookup is pressed.
|
||||
# NOT wait_for_load_state("networkidle"): eCW's Angular shell keeps a heartbeat
|
||||
# running, so the network never goes idle and that call sat silent for its whole
|
||||
# timeout while the run looked frozen — every run was killed there, before the
|
||||
# export step was ever reached. claim_results.py polls the grid's own
|
||||
# "Total Counts" and says what it sees, so a slow query reads as slow.
|
||||
LOOKUP_TIMEOUT = _env_int("LOOKUP_TIMEOUT_MS", 180_000) # 3 min
|
||||
LOOKUP_POLL_SECONDS = _env_int("LOOKUP_POLL_SECONDS", 3)
|
||||
|
||||
|
||||
# ── Export ────────────────────────────────────────────────────────────────────
|
||||
CLAIM_REPORT_FORMAT = _env("CLAIM_REPORT_FORMAT", "EXCEL") # EXCEL | CSV | TEXT
|
||||
|
||||
# Where the exported report is saved: `Input\`, not `Output\`.
|
||||
#
|
||||
# It reads like output — the bot downloaded it — but it is the **input to the
|
||||
# charge-entry phase**: the next step walks its claim ids one at a time. Naming
|
||||
# it Output would invite someone to treat it as a finished deliverable and
|
||||
# clear it out between runs, which would strand the phase that consumes it.
|
||||
#
|
||||
# Gitignored, like Output — it is a claims report, so it holds patient data.
|
||||
INPUTS = ROOT / "Input"
|
||||
|
||||
# The saved file is `<CLAIM_REPORT_NAME>_<MM-DD-YYYY>.<ext>`, e.g.
|
||||
# `Claim Report_09-07-2026.xlsx`. A fixed, predictable name because the next
|
||||
# phase has to find it without being told; the date because the queue is a
|
||||
# daily one. The extension is whatever eCW actually sent — its "EXCEL" export
|
||||
# has been both a real xlsx and an HTML table, and renaming HTML to .xlsx makes
|
||||
# Excel refuse to open it.
|
||||
CLAIM_REPORT_NAME = _env("CLAIM_REPORT_NAME", "Claim Report")
|
||||
|
||||
# Where the previous run's report goes when a new one is downloaded.
|
||||
#
|
||||
# Input\ ends up holding EXACTLY ONE report, always the current one. Six of
|
||||
# them accumulated over three days of testing, and once more than one is
|
||||
# sitting there "which file is current?" stops being obvious - to a person
|
||||
# reading the folder, and to any code that has to find it without being told.
|
||||
# Moving the old ones out is what makes the answer unambiguous instead of a
|
||||
# rule someone has to remember.
|
||||
#
|
||||
# Moved, not deleted: they are patient data and the earlier run's evidence.
|
||||
INPUTS_ARCHIVE = INPUTS / "Archive"
|
||||
|
||||
# 0 leaves old reports in Input\ alongside the new one. Only useful if
|
||||
# something outside this bot is watching that folder for every file.
|
||||
ARCHIVE_OLD_INPUTS = _env_bool("ARCHIVE_OLD_INPUTS", True)
|
||||
|
||||
# After the export, read the claim ids back out of the saved file and show them
|
||||
# in a message box — count, the first and last few, and any duplicates or blank
|
||||
# rows skipped. The full list always goes to the log regardless of this.
|
||||
#
|
||||
# Worth doing as its own step: it proves the file the next phase will consume is
|
||||
# actually readable and holds what it should, at the point where the browser is
|
||||
# still open and a problem is cheap to look at. Set 0 for unattended runs.
|
||||
SHOW_CLAIM_IDS = _env_bool("SHOW_CLAIM_IDS", True)
|
||||
|
||||
# After categorizing by payer, tell the operator about any PAYER value with
|
||||
# no entry in payer_groups.json — those rows are saved with a blank Group
|
||||
# Name rather than a guess. Informational only, same reasoning as
|
||||
# NOTIFY_AFTER_EXPORT above. Set 0 for unattended runs; the full list still
|
||||
# goes to the log either way.
|
||||
NOTIFY_UNMAPPED_PAYERS = _env_bool("NOTIFY_UNMAPPED_PAYERS", True)
|
||||
|
||||
# TEMPORARY testing override. Blank (the default) leaves the live search +
|
||||
# export flow above untouched. Set to a file path and `_claims_phase` skips
|
||||
# the live bulk lookup and export entirely, reading claim ids straight from
|
||||
# that file instead — the claim-status/date/balance filters are still set
|
||||
# and every id is still searched and opened exactly the way a live-queue
|
||||
# claim is, only the *source of the id list* changes.
|
||||
#
|
||||
# Built for a hand-maintained "standard" sheet listing one known claim per
|
||||
# implemented condition: a run against it exercises every condition against
|
||||
# a real claim in one short pass, so a new condition (or a regression in an
|
||||
# old one) shows up in minutes instead of after a full live-queue run.
|
||||
# `claim_file.claim_ids()` already reads any file with a recognizable
|
||||
# claim-id header or column — this just points it at a fixed file instead
|
||||
# of the report the export step just saved.
|
||||
#
|
||||
# Set, it also marks the run as a TEST run (review item D1, 2026-10-05):
|
||||
# `[MODE]`/`[TEST MODE]` log lines say so, the Charge Summary goes to
|
||||
# Charge Summary_TEST_<date>.xlsx (never the live summary's name), a path
|
||||
# that does not exist stops the run before login (exit 2), and a sheet that
|
||||
# exists but cannot be read stops it with exit 5 instead of a quiet exit 0.
|
||||
# The exit-2 check lives in validate(), so it also stops
|
||||
# tools/check_claims_from_excel.py, which does not use this setting: a stale
|
||||
# path left here must be cleared (or fixed) before that tool will run either.
|
||||
# Accepted on purpose — a path in .env that points at nothing is a config
|
||||
# mistake whichever program reads the file (unit B fix round 1, 2026-10-05).
|
||||
TEST_CLAIM_IDS_FILE = _env("TEST_CLAIM_IDS_FILE", "")
|
||||
|
||||
|
||||
# ── Per-claim processing ──────────────────────────────────────────────────────
|
||||
# How many claim ids to search, one at a time, after the export. 0 = all of
|
||||
# them.
|
||||
#
|
||||
# **The default is 1 on purpose.** The report holds 541 ids and each one is a
|
||||
# full server-side lookup, so "all" is a run measured in hours — not something
|
||||
# to discover by accident while testing a new step. Raise it deliberately once
|
||||
# the per-claim work actually does something.
|
||||
CLAIM_PROCESS_LIMIT = _env_int("CLAIM_PROCESS_LIMIT", 1)
|
||||
|
||||
# A message box after each claim. Fine at a limit of 1 or 2; at 50 it is 50
|
||||
# dialogs, so raise CLAIM_PROCESS_LIMIT and set this to 0 together. The
|
||||
# end-of-run summary box appears either way, and every claim is logged.
|
||||
#
|
||||
# At most one box per claim (review item D9, 2026-10-05): a claim whose
|
||||
# REVIEW_BEFORE_CLOSE box was shown (a condition changed it, saving off) has
|
||||
# had its one box, with this box's header in it, and gets no second one.
|
||||
# Dental claims show their HIGH RISK / sliding fee results here too — so 0
|
||||
# also turns off the Dental box, which used to appear whatever was set (P6).
|
||||
SHOW_EACH_CLAIM = _env_bool("SHOW_EACH_CLAIM", True)
|
||||
# Information boxes go to the log as one "[BOX] title: first line" line
|
||||
# instead of the screen. The results page sets it for the runs it starts
|
||||
# (2026-10-14). Boxes a person must act on — CAPTCHA, 2FA manual step,
|
||||
# security image, LIVE-WRITE confirm — are never quieted.
|
||||
QUIET_BOXES = _env_bool("QUIET_BOXES", False)
|
||||
|
||||
# A failure screenshot (`page_utils.shot` with a failure tag) also saves the
|
||||
# page's DOM beside it as .html (2026-10-21): the HTML to fix a changed
|
||||
# selector from, without a re-run or a hand copy into Dom.txt. PHI, like the
|
||||
# screenshots — it never leaves the Screenshots folder. SAVE_DOM_LIMIT caps
|
||||
# the files per run (an eCW page is ~1 MB with its frames).
|
||||
SAVE_DOM_ON_FAILURE = _env_bool("SAVE_DOM_ON_FAILURE", True)
|
||||
SAVE_DOM_LIMIT = _env_int("SAVE_DOM_LIMIT", 30)
|
||||
|
||||
# Pre-flight selector check (pages/preflight.py, 2026-10-21): before the
|
||||
# first claim the claims search page's selectors, and on the first claim of
|
||||
# each screen type (UB / HCFA / Dental) the claim form's, are checked. A
|
||||
# missing one is named in the log, its DOM saved, and — PREFLIGHT_STOP on —
|
||||
# the run stops (exit 7) instead of sending every claim after it to Review.
|
||||
PREFLIGHT = _env_bool("PREFLIGHT", True)
|
||||
PREFLIGHT_STOP = _env_bool("PREFLIGHT_STOP", True)
|
||||
|
||||
|
||||
# For each claim, open the UB Claim form, read it, and close it again.
|
||||
#
|
||||
# Closed with Cancel (#UBClaimBtn17) unless SAVE_CORRECTIONS=1 and the
|
||||
# operator clicks OK on that claim's save box (shown only when a condition
|
||||
# fired), in which case that one claim closes with OK instead (#UBClaimBtn18,
|
||||
# saveAllData(true)). See SAVE_CORRECTIONS for the guard.
|
||||
OPEN_CLAIM_FORM = _env_bool("OPEN_CLAIM_FORM", True)
|
||||
|
||||
# Apply the write-path corrections in pages/claim_form.py (telehealth
|
||||
# POS/modifier is the first one) once their trigger condition is detected.
|
||||
#
|
||||
# This gates whether the conditions RUN at all — detect, then fill the open
|
||||
# form in. It does not click OK: whether a filled-in form is ever saved is
|
||||
# SAVE_CORRECTIONS below, and with that off the form is closed with Cancel.
|
||||
# 0 restores the pre-correction behaviour: read, extract, always close with
|
||||
# Cancel, check nothing.
|
||||
#
|
||||
# Default stays ON (operator ruling, 2026-10-05), unlike SAVE_CORRECTIONS.
|
||||
# Applying only fills in a form that is then closed with Cancel while saving
|
||||
# is off. Review item P16 is answered (audit log, 2026-10-16): typed edits
|
||||
# (POS, modifiers, fees, pointers) are discarded by Cancel, but ticking a
|
||||
# bill-to checkbox (updateBillToIns()) and adding a CPT row
|
||||
# (addClaimFSCPTCode('save')) save on the server at once. So with
|
||||
# SAVE_CORRECTIONS off neither is done — see SAVE_CORRECTIONS below. The demo
|
||||
# .env does not set this, so defaulting it off would make every such run
|
||||
# check nothing at all.
|
||||
APPLY_CORRECTIONS = _env_bool("APPLY_CORRECTIONS", True)
|
||||
|
||||
# The actual write gate, one level down from APPLY_CORRECTIONS — asked
|
||||
# through `pages/claim_form.writes_allowed()`. With this at 0 (VERIFY) a
|
||||
# condition still fires and fills the form ON SCREEN — so it stays visible
|
||||
# and in the per-claim summary box, for review — but nothing reaches eCW:
|
||||
# a bill-to box is set on screen with no click (the click saves at once), no
|
||||
# CPT row is added (the picker's OK saves at once; the claim is Review, "add
|
||||
# by hand"), the confirmation box never appears and the claim is always
|
||||
# closed with Cancel. At 1 (LIVE-WRITE) box clicks and row adds save in eCW
|
||||
# the moment they are made, before the per-claim OK, and Cancel does not
|
||||
# undo them. Dental claims are never written in either mode.
|
||||
#
|
||||
# 2026-09-17: turned off on purpose in .env. A real claim (9103000) got saved
|
||||
# for real during a test run because someone answered the confirmation box —
|
||||
# this is the change that makes that impossible while conditions are still
|
||||
# being verified against live claims.
|
||||
#
|
||||
# 2026-10-05: the CODE default is off too (review item P1). Until then only
|
||||
# the .env line kept saving off, and the default behind it was ON — so a
|
||||
# missing line, a blank value, a typo, or an inline comment the loader did not
|
||||
# strip all turned writes back on without a word. Now a missing or blank line
|
||||
# can never turn saving (the OK click) on; only an explicit SAVE_CORRECTIONS=1
|
||||
# can, and the run then asks for an OK at start (the LIVE-WRITE box in
|
||||
# flows/export_medcor_ready.py) and before every save. P16 is answered (see
|
||||
# APPLY_CORRECTIONS above): while this is off nothing is written (option C,
|
||||
# 2026-10-16). Turn it on only once a condition has been confirmed correct
|
||||
# and the client is ready for it to write.
|
||||
SAVE_CORRECTIONS = _env_bool("SAVE_CORRECTIONS", False)
|
||||
|
||||
# Pause on a message box before the claim form is closed, so the corrections a
|
||||
# condition just filled in can be checked on screen while they are still there.
|
||||
#
|
||||
# Only reached when SAVE_CORRECTIONS is off. With it on, the save confirmation
|
||||
# box is already the pause before closing — this is the one that was missing
|
||||
# underneath it. Without this, SAVE_CORRECTIONS=0 fills the form in and then
|
||||
# clicks Cancel in the same breath, so "applied for review" was only ever
|
||||
# reviewable in the log and the screenshot, never on the actual screen.
|
||||
#
|
||||
# The box is informational: nothing is saved either way, and the claim still
|
||||
# closes with Cancel the moment it is answered. It is app-modal to the bot's
|
||||
# own thread, not to Chrome, so the form behind it stays scrollable and
|
||||
# inspectable while it is up. Times out on MSGBOX_TIMEOUT_SECONDS like every
|
||||
# other prompt — raise that if a claim needs longer than 180s to check. Set 0
|
||||
# for unattended runs, or it adds up to that per corrected claim.
|
||||
#
|
||||
# It is the corrected claim's ONE box (review item D9, 2026-10-05): it carries
|
||||
# the SHOW_EACH_CLAIM header and Summary line, and the per-claim box after
|
||||
# close is then skipped. The "N condition(s) matched — about to apply" box
|
||||
# that used to come first is now a log line only.
|
||||
REVIEW_BEFORE_CLOSE = _env_bool("REVIEW_BEFORE_CLOSE", True)
|
||||
|
||||
# Also click "Prog. Notes" and capture whatever screen it opens.
|
||||
#
|
||||
# Separate from OPEN_CLAIM_FORM because it is the one part whose destination
|
||||
# has never been seen: the button carries ng-disabled="!FormData.
|
||||
# EnableProgressNote", so on some claims it does nothing at all, and what it
|
||||
# opens (a modal or a new tab) is discovered at run time rather than assumed.
|
||||
OPEN_PROGRESS_NOTES = _env_bool("OPEN_PROGRESS_NOTES", True)
|
||||
|
||||
# How long, after clicking Prog. Notes, to keep polling (every 250 ms) for the
|
||||
# note document before reporting "nothing". Replaces a fixed 3s sleep and one
|
||||
# probe, which missed 3 of ~115 notes in the logs — every one a viewer that had
|
||||
# opened on a document still loading, so the claim went out Clean with
|
||||
# telehealth never checked (review item D8, 2026-10-01). A note that loads
|
||||
# normally is accepted in well under a second; only a slow or missing one uses
|
||||
# the full wait. 20s sits in the review's suggested 15-25s.
|
||||
PROGRESS_NOTE_WAIT_MS = _env_int("PROGRESS_NOTE_WAIT_MS", 20_000)
|
||||
|
||||
# Dental claims only (condition #8, "All Dental Payer"): open Info ->
|
||||
# Sliding Fee Schedule and check the patient's sliding fee is still valid on the claim's DOS (active / expired / not_started / none).
|
||||
# Read-only: Patient Information closes with Cancel, never Save. A temporary
|
||||
# process for now (2026-09-30); the Excel output column comes next.
|
||||
CHECK_SLIDING_FEE = _env_bool("CHECK_SLIDING_FEE", True)
|
||||
|
||||
# Dental claims only (condition #15): report whether the Billing Notes say
|
||||
# "HIGH RISK" — such claims go through the DentaQuest web portal. Read-only,
|
||||
# shown with the sliding fee in the claim's SHOW_EACH_CLAIM box.
|
||||
CHECK_HIGH_RISK = _env_bool("CHECK_HIGH_RISK", True)
|
||||
|
||||
# At the end of every claims run, write Output\Charge Summary_<date>.xlsx in
|
||||
# the operator's 24-column format: the run's report rows (A-R) plus what the
|
||||
# run found per claim (S-X). See core/charge_summary.py. A TEST_CLAIM_IDS_FILE
|
||||
# run writes Charge Summary_TEST_<date>.xlsx instead. If that day's file is
|
||||
# open in Excel, the new sheet is saved as <name>__<HHMMSS>.xlsx next to it.
|
||||
WRITE_CHARGE_SUMMARY = _env_bool("WRITE_CHARGE_SUMMARY", True)
|
||||
# "Assigned To" on every claim the run read.
|
||||
SUMMARY_ASSIGNED_TO = _env("SUMMARY_ASSIGNED_TO", "eCW Bot")
|
||||
|
||||
# ── Results database (SQL Server) ─────────────────────────────────────────────
|
||||
# Also save the Charge Summary rows to SQL Server (core/results_db.py) for the
|
||||
# results web page (web/). Off by default; a database error is a log line
|
||||
# only — the run and its Excel summary carry on. The default connection is
|
||||
# the Windows login (no password) to the operator's Charge_Validation DB.
|
||||
RESULTS_DB = _env_bool("RESULTS_DB", False)
|
||||
RESULTS_DB_CONN = _env(
|
||||
"RESULTS_DB_CONN",
|
||||
"DRIVER={ODBC Driver 18 for SQL Server};SERVER=MECARE331\\MECARE331;"
|
||||
"DATABASE=Charge_Validation;Trusted_Connection=yes;"
|
||||
"TrustServerCertificate=yes;")
|
||||
|
||||
# eCW builds the file server-side over a wide date range, so this is generous.
|
||||
DOWNLOAD_TIMEOUT = _env_int("DOWNLOAD_TIMEOUT_MS", 300_000) # 5 min
|
||||
|
||||
|
||||
# ── Operator checkpoints ──────────────────────────────────────────────────────
|
||||
# Show the operator the filters and wait for an OK before running the search.
|
||||
# Cancel aborts; a TIMEOUT carries on, because aborting there threw away a
|
||||
# completed login and a mailed 2FA approval link when nobody was at the desk,
|
||||
# and the lookup only reads. 0 skips the box entirely.
|
||||
CONFIRM_BEFORE_LOOKUP = _env_bool("CONFIRM_BEFORE_LOOKUP", True)
|
||||
|
||||
# Tell the operator how the export finished — row count, file name, size, and
|
||||
# where it landed. Informational only, so it never blocks the result: the file
|
||||
# is already saved by the time it appears. Set 0 for unattended runs, or it adds
|
||||
# up to MSGBOX_TIMEOUT_SECONDS to every run with nobody there to click OK.
|
||||
NOTIFY_AFTER_EXPORT = _env_bool("NOTIFY_AFTER_EXPORT", True)
|
||||
|
||||
|
||||
def validate() -> list:
|
||||
"""Return a list of human-readable problems with the current config."""
|
||||
problems = []
|
||||
if not ENV_PATH.exists():
|
||||
problems.append(f".env not found at {ENV_PATH} — copy .env.example and fill it in")
|
||||
for key in ("ECW_URL", "ECW_USERNAME", "ECW_PASSWORD"):
|
||||
if not globals()[key]:
|
||||
problems.append(f"{key} is not set")
|
||||
if OTP_MODE in ("mail", "imap") and not (IMAP_HOST and IMAP_USERNAME and IMAP_PASSWORD):
|
||||
problems.append(f"OTP_MODE={OTP_MODE} but IMAP_HOST / IMAP_USERNAME / IMAP_PASSWORD are incomplete")
|
||||
if OTP_MODE == "totp" and not ECW_SECRET_KEY:
|
||||
problems.append("OTP_MODE=totp but ECW_SECRET_KEY is not set")
|
||||
if OTP_MODE not in ("auto", "totp", "mail", "imap", "manual"):
|
||||
problems.append(f"OTP_MODE={OTP_MODE!r} is not one of auto/totp/mail/manual")
|
||||
# Checked here, before login, because the run cannot do anything useful
|
||||
# without it: the test path skips the live export, so a missing sheet
|
||||
# used to cost a login and then exit 0 having processed nothing (review
|
||||
# item D1). Resolved exactly as the flow resolves it (Path(...) as
|
||||
# written, a relative path against the working folder), so this checks
|
||||
# the same file the run would open. A file that exists but cannot be
|
||||
# read still fails later, with exit 5.
|
||||
if TEST_CLAIM_IDS_FILE and not Path(TEST_CLAIM_IDS_FILE).exists():
|
||||
problems.append(f"TEST_CLAIM_IDS_FILE={TEST_CLAIM_IDS_FILE} does not exist")
|
||||
if BROWSER_CDP_URL and cdp_url_problem(BROWSER_CDP_URL):
|
||||
problems.append(cdp_url_problem(BROWSER_CDP_URL))
|
||||
# Values the strict parsers could not read (see _BAD_VALUES). Every one
|
||||
# stops the run here, before login: running on a guessed default is how
|
||||
# SAVE_CORRECTIONS=O used to mean "save".
|
||||
for key, raw, expected in _BAD_VALUES:
|
||||
hint = (" (use 1/0, true/false, yes/no or on/off)"
|
||||
if expected == "a valid yes/no value" else " (digits only)")
|
||||
# This list is printed to the console and the run log. No PASSWORD/
|
||||
# SECRET/KEY setting is a yes/no or a number today, but a typo there
|
||||
# must still never put the value itself on screen.
|
||||
if any(s in key.upper() for s in ("PASSWORD", "SECRET", "KEY")):
|
||||
problems.append(f"{key} (value hidden) is not {expected}{hint}")
|
||||
else:
|
||||
problems.append(f"{key}={raw!r} is not {expected}{hint}")
|
||||
return problems
|
||||
|
||||
|
||||
def resolved_otp_mode() -> str:
|
||||
"""Turn OTP_MODE=auto into the concrete mode that will actually be used."""
|
||||
if OTP_MODE == "imap": # pre-push-link name for the same thing
|
||||
return "mail"
|
||||
if OTP_MODE != "auto":
|
||||
return OTP_MODE
|
||||
if ECW_SECRET_KEY:
|
||||
return "totp"
|
||||
if IMAP_HOST and IMAP_USERNAME and IMAP_PASSWORD:
|
||||
return "mail"
|
||||
return "manual"
|
||||
Reference in new issue
Block a user