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,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:]))
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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];
|
||||
@@ -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
|
||||
@@ -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;
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user