Files
ecw-charge-validation/CHANGELOG.md
T
ManikandanandClaude Opus 5.5 ba15a574ab 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>
2026-10-23 18:38:17 +05:30

341 lines
17 KiB
Markdown

# Changelog — Salud eCW Charge Entry Bot
What changed, when, where, and **why**. The why matters more than the what: most
of the decisions below look strange until you know which eCW behaviour forced
them, and without that a future change quietly undoes them.
Dates are the working dates on this machine (IST).
---
## Day 1 — 2026-09-02
### Status at end of day
| Phase | State |
|---|---|
| Login (proxy + 2FA) | **working, proven end to end** |
| Navigate to Billing → Claims | **working, proven** |
| Claim Lookup filters | **working, proven** |
| Lookup results | click proven; `Total Counts` never captured |
| Charge entry | not started |
Last verified run: `Logs/run_20260902_181115.log`.
### What was built
**`src/config.py`** — every setting, selector and timeout in one place, loaded
from `.env`. Written without `python-dotenv` on purpose: these machines are
locked down and each new dependency is one more thing to get approved.
**`src/auth.py`** — the login. Navigation retries, the security-image page,
CAPTCHA escalation, two-factor, and the landing page.
**`src/otp.py`** — reads the second factor out of the mailbox over IMAP and
reports *what kind* of challenge it is, so `auth.py` can drive the browser
accordingly.
**`src/nav.py`** — post-login navigation to the Claim Lookup screen.
**`src/claims.py`** — the Claim Lookup form: status, date type, date range,
balance, Lookup.
**`src/guard.py`** — refuses to run when attempts come too close together.
**`src/ui.py`** — operator message boxes that always time out.
**`src/runlog.py`** — tees the run to `Logs/run_<timestamp>.log`, flushed per
line so a killed run still leaves a readable log.
**`src/check_net.py`, `src/check_imap.py`** — preflight probes. Run these before
blaming the bot.
### Decisions that are load-bearing
**The proxy is per-browser, not system-wide.** `chromium.launch(proxy=…)` in
`auth.py`. This is the single thing that makes the project possible. Measured:
| | no proxy | via proxy |
|---|---|---|
| eCW portal | HTTP 400 | **200** |
| approval link | HTTP 400 → "restricted" | **200** |
| webmail site (:2096) | **200** | 403, blocked by proxy |
| mailbox IMAP :993 | **connects** | not proxied at all |
A human is deadlocked by that table — proxy on for eCW, off for the mail, on
again to confirm, and no ordering works. The bot escapes it by never opening the
webmail *website*: the browser is proxied, and mail is read by `imaplib` over a
raw socket that ignores the proxy entirely. The Windows system proxy stays off.
**"Access to the application has been restricted" is an IP check, not a
lockout.** Same link: 400 unproxied, 200 proxied, seconds apart. So it almost
always means the browser lost the proxy — not that the account is locked. This
was initially misdiagnosed as rate limiting; the comments and the 30-minute
lockout that came from that were corrected.
**The second factor is a push-approval link, not a 6-digit code.** The mail
contains no digits at all. An earlier build assumed a code and "found" `000000`,
scraped out of `color: #000000` in the mail's stylesheet. Fixed by stripping
`<style>`/`<script>` before any digit hunting, deleting the bare-6-digit
fallback, and modelling the challenge as `Challenge(kind, value)`.
**cPanel's webmail 2FA does not apply to IMAP.** Roundcube now demands a
security code; IMAP/POP3/SMTP are exempt — verified live (`CAPABILITY` offers
`AUTH=PLAIN`, `AUTH=LOGIN`, no 2FA mechanism). No code change was needed. Noted
so nobody later "fixes" the bot by automating Roundcube.
**eCW's push mail is delayed, not dropped.** Proven by delivery order: the
message eCW stamped `11:19:10` arrived *after* the one stamped `11:26:10` —
about seven minutes late, and it has been seen taking ten. An earlier diagnosis
claimed eCW simply was not sending, and the "Resend OTP" work built on that made
things worse by shortening the first poll to 60s. Now: 300 + 300 + 300s, resend
late rather than early, because a second mail only joins the same slow queue.
The send-to-deliver lag is logged from IMAP's `INTERNALDATE` so this stays
visible instead of being guessed at again.
**The left-nav toggle is a toggle.** `#jellybean-panelLink4` runs
`launchLeftPanel()`, which flips a class — clicking it on an open panel *closes*
it. Its id is also duplicated in the DOM, so every locator needs `.first`, and
the panel's contents are injected after `DOMContentLoaded`, so they must be
waited for.
**Menu and dropdown options are matched by label, never by generated id.**
`claimLookupLink2ngR1` is an ngRepeat *index* — it means "the second option",
not "Claim Dt(s)". If eCW reorders the list, an id-based click selects a
different filter and the search still succeeds. A wrong answer with no error is
worse than a crash.
**Claim Status runs before everything else on the form.** It decides whether a
claim-based date type can match anything, and whether the Balance row is
editable (`ng-change="enableInsurance()"` sets `enableBalanceFlag`). It is
chosen by option *value* (`108ME`) with the *label* verified after — "Medcor
Ready" sits one line from "Medcor Testing" (`113ME`), and picking the wrong one
returns the wrong queue without any error.
**Date type must match Claim Status.** With "Encounters Without Claims" — rows
that have no claim — `Claim Dt(s)` returns 0, because those rows have no claim
date. Measured on the same range: 936 rows on `Service Dt(s)`, 0 on
`Claim Dt(s)`. It does not look like a failure: the grid keeps rendering the
*previous* result set while `Total Counts` silently drops to 0. Hence the
warning, and hence `Total Counts` now goes into the log.
**A disabled control is not an inactive one.** The Balance row arrives holding
`> 0.0` with all five controls `disabled`, and eCW builds its query from the
Angular scope rather than from the inputs — so the filter was narrowing every
search while being impossible to click. When the row is disabled,
`_force_clear_balance()` clears the models inside `$apply`. Inside `$apply`
matters: Angular re-renders the inputs, so what the operator confirms is what
eCW will search on. Writing `el.value` would do the opposite.
**Dates go in with `fill()`, not `evaluate`.** `fill()` fires the `input` event
that `ng-model` listens for. Setting `.value` directly updates the display and
leaves Angular's scope stale, so eCW would search on the old value. The jQuery
calendar is also closed explicitly and checked — left open it covers the grid
and a later click can land on a date cell.
### Mistakes made and corrected, same day
- Diagnosed the restriction page as rate limiting. It is an IP check. Corrected
in `config.py`, `guard.py`, and the lockout duration.
- Diagnosed the missing 2FA mail as "eCW never sent it". It was delayed. The
resend feature built on that diagnosis initially made the problem worse.
- Added a rule skipping links older than 9 minutes as expired. With a 10-minute
mail delay that would discard a link the moment it arrived; it now warns and
clicks anyway.
- Built the fallback claims URL as `index.jsp/#/…`, which loads nothing.
### Operational notes
- `LOGIN_COOLDOWN_SECONDS=300`, `LOGIN_MAX_PER_HOUR=4` — roughly two clean
attempts per half hour. `ECW_FORCE=1` overrides and is usually the wrong
reflex.
- CAPTCHA still needs a human, so unattended scheduling is not possible yet.
- Screenshots land in `Screenshots/<MM-DD-YYYY>/` and are the input for the next
step's selectors.
---
## Day 2 — 2026-09-03
The claim lookup and the Excel export were finished, and three of the four bugs
below were found in the bot's own screenshots rather than by guessing.
**Claim Status first.** The lookup was returning 0 rows against a grid showing
936. Cause: `Encounters Without Claims` returns rows with no claim, so they have
no claim date and `Claim Dt(s)` cannot match. `Medcor Ready` (`108ME`) is now
selected before anything else, which also enables the Balance row.
**`wait_for_load_state("networkidle")` never returns on this page.** eCW's
Angular shell polls in the background, so the network is never quiet for the
500 ms Playwright wants. It printed nothing for its full 5-minute timeout, every
run looked hung and was killed at exactly that line — which is why the export
had never once run. Replaced with a poll of the grid's own "Total Counts" that
narrates the wait.
**A single changed count is not enough.** eCW blanks Total Counts to 0 the
instant Lookup is pressed, so the first poll saw `0 != 894` and logged a row
count of 0 for a search that returned 894. A figure now has to hold across
consecutive reads, and a `0` for twice as long — it is both a legitimate answer
and the transient.
**`download.save_as()` copies.** Chromium's `_pw_downloads\<guid>` survives it,
byte-identical to the saved report. The orphan sweep at the top of the next
export would have adopted it and written a second copy of a report already
saved. `download.delete()` now runs after the save. A real 552 KB xlsx was found
sitting in that folder unnamed, from a run killed before `save_as`.
**The page was left scrolled.** Playwright scrolls an element into view before
clicking it, so reaching the toolbar's Billing menu scrolled the filter form off
the top. Reported as "the portal looks broken" when the form was simply above
the viewport. The first fix used `window.scrollTo(0, 0)` and **did nothing** —
on this SPA the document does not scroll, an inner container does. Now
`scroll_into_view_if_needed()` lets Playwright find the real container, and the
sweep logs which element it was.
**The guard was refusing runs it should have waited out.** Five of six
consecutive runs died on the cooldown, the last with **3 seconds** left.
Re-running by hand until the clock clears is exactly the pattern the cooldown
exists to discourage, so `wait_if_cooling()` now sleeps a plain cooldown and
proceeds. The burst limit and the restriction lockout still refuse. Writing the
test caught a bug in the fix itself: it measured with `time.time()` while
`check()` measured with `datetime.now()`, and two clocks for one deadline is how
a run waits out its cooldown and is then refused for being a fraction early.
Also: the confirm box before Lookup no longer aborts on a timeout (it only
reads, and aborting threw away a completed login), then was turned off
entirely; the Balance row is left untouched when it is already empty; and a
message box reports the export.
---
## Day 3 — 2026-09-07
**Restructured into a package.** No behaviour change — this was moves and
imports, verified rather than assumed.
It had grown to eleven flat modules in `src\`, 3,253 lines, with `claims.py`
alone at 952 (nearly a third of the project in one file) and `config.py` mixing
43 DOM selectors in with the date range and the proxy. Those change for
different reasons — a selector when eCW ships a build, a setting when the
process changes, a flow when the work changes — so in one file, editing a date
range and editing a selector were edits to the same place. The charge-entry
phases would have pushed `claims.py` past 1,500 lines.
Now `run.py` plus `src\ecw_bot\` with six layers, none importing a layer above
it: `settings` → `selectors` → `core` → `auth` / `pages` → `flows`. Diagnostics
moved to `tools\`, and `tests\` exists for the first time.
`run.py` is the real fix hiding inside the cosmetics. `python src\main.py`
worked *only* because Python puts a script's own directory on `sys.path`, so
`import config` resolved and no other way of starting the bot did — not VS
Code's "Run File" on a different file, not Task Scheduler, not pytest. The
entry point no longer depends on which file you happened to launch.
Verified: 31 files compile with `SyntaxWarning` as an error (which caught five
Windows paths in docstrings needing raw strings), all 24 modules import, an AST
pass found 0 undefined names across the split, every one of the old
definitions is accounted for in a new home, `tests\test_settings.py` 3/3,
`tests\test_guard.py` 6/6, and `tools\check_imap.py` still reaches the mailbox
through the new import path.
**Not yet run live against eCW.** The next run is the first real test of the
new layout, and of the scroll and Total Counts fixes from Day 2.
---
## Day 4 — 2026-09-15
**Automated the payer categorization step.** Reviewed against
`Salud-Charge Entry_Medcor Ready.mp4` (2:45-10:00): after the Medcor Ready
export, an operator was filtering the report by `PAYER` and hand-filling a
`Group Name` column (`Commercial`, `Medicaid`, `Medicare`, `Dentaquest`,
`Program`, ...) — the same manual, repetitive, rule-based step every other
phase of this bot exists to remove.
`core/payer_groups.py` does the lookup: exact string match against
`payer_groups.json`, on purpose — eCW's own PAYER values already differ by
case and spacing for what is really the same carrier (`United Healthcare` /
`United Health Care` / `United healthcare` are three separate values in one
real export), so anything looser has no safe way to pick which known string
a new one is closest to. An unrecognized payer gets a **blank** Group Name,
not a guess, and is logged and message-boxed — this is billing data, and
wrong is worse than blank.
`core/spreadsheet.py` gained a write side (`write_xlsx()`) to go with its
read side, built the same way — stdlib only, no openpyxl — but with the full
minimal OOXML part set (`[Content_Types].xml`, both `.rels` files,
`workbook.xml`) the reader doesn't need but real Excel does, since this file
is meant to be opened by a person.
The categorized output is a **new** file, `Output\Medcor Ready_<date>.xlsx`
— `Input\Claim Report_<date>.xlsx` is never modified, because `claim_file.py`
and the charge-entry phase read its columns by position.
**Seeded, not complete.** The mapping table ships with 23 payers covering
roughly 95% of a real export's row volume (`Medicaid`, `DentaQuest`, and the
clearly-commercial carriers), derived from the actual current export rather
than guessed from the video. 17 payers (`BH COLORADO ACCESS`, the Delta
Dental variants, `Tricare`, `Devoted`, several TPA names, ...) are
deliberately left out — first run will flag them, and filling them in is a
one-line JSON edit, no redeploy.
**Out of scope for this round:** splitting rows into the `Patient` /
`Pending with Error` / `Program` sibling workbooks the video also shows, and
writing anything to the network `Charge\Allocation\...` folder — both stay
manual for now.
---
## Day 5 — 2026-09-15
**The bot's first write to eCW.** Reviewed the rest of the same video
(10:00-45:32): the actual "Charge Validation" work is a per-claim clinical
audit — open the UB form, cross-check ICD/CPT/modifiers against the
Progress Note and a payer-specific rules sheet, fix one of a handful of
standard defect types, save, and log the outcome. That's a much bigger
surface than the categorization step, so it's being built one condition at
a time rather than all at once — this is the first.
**Condition 1: telehealth visit → POS=11, M1=GT.** `claim_form.py` gained
three functions: `note_mentions_telehealth()` (a plain case-insensitive
check against the Progress Note's own text — deliberately not narrower,
since the note's own heading for this is written inconsistently:
`TELEHEALTH:`, `BHTelehealth Visit`, `Session Setting (PoS): Telehealth`),
`set_cpt_pos_modifier()` (sets POS and M1 on every real CPT/HCPCS row —
skipping eCW's blank padding rows and the trailing `*` entry row, the same
filter `extract()` already applies — and reads every field back so a write
that silently didn't take is a loud failure, not a quiet gap), and
`save_claim()` (the first place in this codebase that clicks OK instead of
Cancel — `saveAllData(true)`).
**Nothing saves without an explicit per-claim confirmation box.** This is
the first write path this bot has ever had, so the new `APPLY_CORRECTIONS`
setting gates whether the check even runs, and even then every fix stops
for an OK/Cancel box naming exactly what changed on which rows before
`save_claim()` is ever called. Declining leaves the claim exactly as it
was — the field changes only ever live in the DOM until OK is clicked, so
Cancel discards them for free.
The selectors needed for the write (`uBClaimPOS*`, `uBClaimMod1*` inside
each `CPTROW_*`) needed no new live-eCW inspection — they fell out of
`extract()`'s own existing id-prefix reader, which already had to name them
to read the grid.
`tests/test_telehealth_fix.py` reuses the existing `ub_claim.html` /
`progress_note.html` fixtures rather than adding new ones — the real note
eCW served on 2026-09-07 already documents a Telehealth visit, so the
positive case needs no new fixture, and detection never touches the DOM at
all, so the negative case is a plain string. `ub_claim.html`'s Cancel and
OK buttons needed a few lines of script to actually hide the modal on
click, which they never did before — the original fixture only ever had to
test reading, never closing, so this is the first thing to exercise that
path either.
**Not yet run live.** `CLAIM_PROCESS_LIMIT=1` is still the default, so the
first real run after this lands tests on exactly one real claim — the
first live proof that the CPT/HCPCS tab is still the active tab-pane by
the time the fix runs (nothing between `open_cpt_hcpcs_tab()` and here
switches away from it, but this is the one assumption the fixture cannot
prove, since Bootstrap's tab-pane visibility isn't something a static test
page reproduces the same way live Angular does).