Snapshot of D:\Ecw Process a11c9ff (2026-10-30). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
792 lines
42 KiB
Python
792 lines
42 KiB
Python
"""
|
|
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)
|
|
|
|
# The agent's email to the operator (core/notify.py, operator 2026-10-30):
|
|
# sent from the 2FA mailbox's own account over SMTP, so no new credential.
|
|
# Blank NOTIFY_EMAIL_TO = no mail at all.
|
|
NOTIFY_EMAIL_TO = _env("NOTIFY_EMAIL_TO")
|
|
SMTP_HOST = _env("SMTP_HOST") or IMAP_HOST
|
|
SMTP_PORT = _env_int("SMTP_PORT", 465)
|
|
SMTP_USERNAME = _env("SMTP_USERNAME") or IMAP_USERNAME
|
|
SMTP_PASSWORD = _env("SMTP_PASSWORD") or IMAP_PASSWORD
|
|
|
|
# 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)
|
|
# A link approved but never picked up by the login page (live 2026-10-29: mail
|
|
# 6 to 8.5 minutes late, page still on "sent a notification", Resend still
|
|
# there): wait this long for the page to move on, then Resend and approve the
|
|
# new mail, up to OTP_PICKUP_RESENDS times. eCW allows three resends a login.
|
|
OTP_PICKUP_SECONDS = _env_int("OTP_PICKUP_SECONDS", 60)
|
|
OTP_PICKUP_RESENDS = _env_int("OTP_PICKUP_RESENDS", 2)
|
|
# 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", "")
|
|
# Set by the agent (flows/supervisor.py), not by hand: the claims of today's
|
|
# live queue still pending after an earlier pass stopped. Run like the live
|
|
# queue — no TEST label, the live Charge Summary — but from this list, with
|
|
# no fresh export.
|
|
RESUME_CLAIM_IDS_FILE = _env("RESUME_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;")
|
|
|
|
# The Portal on a server (Dokploy, 2026-10-27). PORTAL_URLS: the address(es)
|
|
# people type, comma separated (e.g. https://cv.example.com); empty = this
|
|
# PC only (127.0.0.1:8000). PORTAL_SERVER=1: no Start / Stop — the bot runs
|
|
# only on the Windows PC.
|
|
PORTAL_URLS = _env("PORTAL_URLS")
|
|
PORTAL_SERVER = _env_bool("PORTAL_SERVER", False)
|
|
# The Ecare office sign-in service (2026-10-27), e.g.
|
|
# https://auth-wfm.ecareindia.com/api/login/token. Empty: Portal passwords
|
|
# only.
|
|
AUTH_TOKEN_API_URL = _env("AUTH_TOKEN_API_URL")
|
|
|
|
# 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"
|