Initial import: eCW Charge Validation bot and results Portal

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

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

No files matched your search

+10
View File
@@ -0,0 +1,10 @@
"""
Every CSS selector the bot uses, grouped by screen.
Deliberately separate from `settings.py`. Both used to live in one 442-line
config module, so changing a date range and changing a DOM selector meant
editing the same file — and they change for entirely different reasons: these
move when eCW ships a new build, settings move when the process changes.
No module in here imports anything. They are data.
"""
+42
View File
@@ -0,0 +1,42 @@
r"""
Account Inquiry ("Patient Inquiry Detail") — opened from the claim form's
side menu, read for a patient's earlier claims and their CPT lines
(condition #27-#29, `conditions/medicare_wellness_history.py`).
From Dom.txt (operator's capture, 2026-10-20); the launcher from the
claim form DOM the operator pasted earlier (side panel, `vm.panelItems`).
"""
# The claim form's side panel item:
# <a class="cursor ng-scope" title="Account Inquiry" ng-click=
# "vm.loadSmartPanel(item);"> ... <label class="ft-label">Account
# Inquiry</label></a>
# Every panel item shares the ng-click; the title tells them apart.
SEL_LAUNCHER = 'a[title="Account Inquiry"][ng-click^="vm.loadSmartPanel("]'
# The modal's close (X) button — `(false)`: close without saving anything.
# `(true)` is the footer OK, which is not used.
SEL_CLOSE = ('button.close[ng-click='
'"validateandclosePatientInquiryDetailModal(false)"]')
# The modal's own loading overlay.
SEL_LOADER = "#accountsLoadingImg"
# The filter checkboxes are one ng-repeat; "Show CPT Payments" is told
# apart by its label text.
SEL_SHOW_CPT_PAYMENTS = ('label:has-text("Show CPT Payments") '
'input[type="checkbox"]')
# One per claim; holds the claim's main row, its payment rows and their
# CPT rows in nested tables.
SEL_CLAIM_ROW = 'tr[ng-repeat^="claim in claims"]'
SEL_CLAIM_NO = '[ng-bind="claim.ClaimId"]'
SEL_CLAIM_DATE = '[ng-bind^="claim.ServiceDate"]'
# "G0438:ANNUAL WELLNESS…" — the code before the colon. Under each posted
# payment / adjustment (`payment.Charges`), and in the CPT totals rows.
SEL_CPT_DESCRIPTION = ('tr.cptPaymentRow td, tr.cptPaymentSubTotalRow td')
# Paging.
SEL_PAGE_NO = '[ng-model="curpage"]'
SEL_PAGE_COUNT = '[ng-bind="totalPage"]'
SEL_NEXT = '[ng-click="pibuttonnext()"]'
+394
View File
@@ -0,0 +1,394 @@
"""
The UB Claim form — the modal that opens when a claim number is clicked.
# The one rule for this screen: NEVER use an id with a number in it.
eCW builds this modal with a per-open timestamp baked into most ids:
<div id="UBClaimDiv1788790287089" ...> the backdrop
<div id="claimedi1788790287089"> a tab pane
<select id="claimStatusSel1788790287089"> claim status
<table id="claimCPTCodesTable1788790287089"> the CPT grid
`1788790287089` is `Date.now()` at the moment the modal opened. It is
**different for every claim, and different for the same claim opened twice**.
Any selector containing it works exactly once and then silently matches
nothing — the worst failure shape, because the run looks fine and the data
comes back empty.
So everything below is matched on one of three stable things:
* an id with no number in it — `#UBClaimProgressNoteBtn`, `#BillType`
* an `ng-model` / `ng-bind` — `[ng-model="ClaimData.ClaimDate"]`
* an `ng-click` — `a[ng-click*="cptTabUpdate('cptHcpcs')"]`
Angular attributes are the best handles here: they are what eCW's own code
binds to, so they change only when the underlying data model changes, which is
far rarer than a markup reshuffle.
Note the pairs that look interchangeable and are not:
* `#UBClaimDiv1788790287089` (backdrop, timestamped) vs **`#UBClaimDiv`**
(the modal-dialog inside it, no timestamp). Use the latter.
* `#UBClaimBtn18` is **OK — it saves** (`saveAllData(true)`).
`#UBClaimBtn17` is Cancel (`closeUBClaimScreen()`). Reading a claim must
close with Cancel; clicking OK writes to eCW.
"""
# ── The modal itself ──────────────────────────────────────────────────────────
# The inner modal-dialog. Its id carries no timestamp, unlike the backdrop's.
SEL_MODAL = "#UBClaimDiv"
SEL_MODAL_TITLE = "#UBClaimDiv h4.modal-title"
# Claim No is text in a cell, not an input — `<span title="9104000">9104000</span>`.
SEL_CLAIM_NO_TEXT = "#UBClaimDiv .ubClaimNumberText span"
# Cancel closes without saving. **Not** #UBClaimBtn18, which is OK and saves.
# Fallback (core/fallback.py): Cancel's ng-click, closeUBClaimScreen() —
# never the X (`.close`, #UBClaimBtn3), never OK (saveAllData).
CANCEL_CHAIN = ("#UBClaimBtn17",
'#UBClaimDiv button[ng-click*="closeUBClaimScreen("]'
':not(.close):not(#UBClaimBtn3)')
SEL_CANCEL = ", ".join(CANCEL_CHAIN)
SEL_OK = "#UBClaimBtn18"
SEL_CLOSE_X = "#UBClaimBtn3"
# A stable marker for the UB Claim form specifically (institutional claims —
# Medicaid, in practice). eCW opens a DIFFERENT screen with a different
# button set entirely for professional/HCFA claims (Commercial payers), so
# this is checked before anything below assumes it is looking at a UB form.
SEL_IS_UB_FORM = "#UBClaimBtn17, #BillType"
# The professional/HCFA "Claim" screen's own Cancel — verified against a live
# claim (billingClaimBtn36, ng-click="closeClaimScreen()"). Matched on
# ng-click rather than the id: the numbered `billingClaimBtnNN` ids look
# sequential rather than per-open, but only one capture has been seen, and a
# hidden/shown button elsewhere on that toolbar (isERClaim, etc.) could shift
# the numbering — ng-click is what actually fires the close, so it is the
# more trustworthy handle. Used only as a fallback in close_claim(): this
# module's own write path (save_claim, set_cpt_pos_modifier) is UB-form-only
# for now, so nothing here targets this screen's OK/Prog.Notes buttons yet.
SEL_CANCEL_HCFA = 'button[ng-click="closeClaimScreen()"]'
# The Dental Claim screen's own Cancel — a THIRD claim-form shape entirely,
# distinct from both the UB form and the professional/HCFA "Claim" screen
# (verified against a live claim, 9125000): its own modal
# (`id="dentalClaim<timestamp>"`), `closeDntl()` instead of
# `closeUBClaimScreen()`/`closeClaimScreen()`, `saveData()` instead of
# `saveAllData(true)`/`saveAllData()`, and a completely different tab set
# ("ICD & CDT" / "Services Rendered" rather than "ICD & CPT" / "CPT/HCPCS").
# Its own Cancel carries a stable id with no per-open timestamp, so it is
# matched directly rather than by ng-click the way SEL_CANCEL_HCFA is.
# Fallback (core/fallback.py): its closeDntl() ng-click, never the X.
CANCEL_DENTAL_CHAIN = ("#cancelDentalClaimButton",
'button[ng-click*="closeDntl("]:not(.close)')
SEL_CANCEL_DENTAL = ", ".join(CANCEL_DENTAL_CHAIN)
# A stable marker for the Dental Claim screen specifically — reused as both
# "is this a Dental claim" and "how do I close it", the same way SEL_CANCEL
# doubles as half of SEL_IS_UB_FORM.
SEL_IS_DENTAL_FORM = SEL_CANCEL_DENTAL
# eCW's own "Account Inquiry" smart panel — NOT anything this codebase opens
# on purpose. Seen live (claim 9128000, Logs\run_20260915_195515.log):
# `id="claimModal<timestamp>" class="modal fade bluetheme billing-width
# right-panel-mode in"`, whose backdrop div (`window-class="claimModalBackdrop"`,
# also carrying `ng-click="close($event)"` — it is designed to be dismissible)
# sat over the results grid and blocked the click meant for the claim-number
# link underneath, for the full 30s click timeout, before open_claim() ever
# got a chance to open the form. Matched on the class Playwright's own retry
# trace named, not the id — timestamped, same as everything else on this
# screen (see the module docstring).
SEL_STRAY_MODAL_BACKDROP = ".claimModalBackdrop"
# The HCFA "Claim" form's OWN angular-ui-bootstrap window carries that same
# class (`window-class="claimModalBackdrop"`, verified live on claims 9109000
# and 9119000, 2026-09-29) — the 9128000 element above, `id="claimModal<ts>"
# ... right-panel-mode`, is this form's inner modal. Escape dismisses the
# window WITHOUT running closeClaimScreen(), so only a window with no HCFA
# Cancel inside it is stray — before a claim is opened too: a form still up
# then is Cancelled by claim_form._close_leftover_form() (review,
# 2026-10-14; eCW opens the next HCFA claim's form by itself after the
# Lookup, live run 2026-10-13).
SEL_STRAY_MODAL_OVER_CLAIM = (
f"{SEL_STRAY_MODAL_BACKDROP}:not(:has({SEL_CANCEL_HCFA}))")
# What open_claim() actually waits for: ANY of the three screens' own
# live-verified markers, since a claim id can open any one of them and
# there is no single container id common to all three (the UB form's
# backdrop carries a per-open timestamp same as everything else on that
# screen — see the module docstring — and no equivalent has been captured
# for the HCFA or Dental screens). Waiting on #UBClaimDiv alone cost 5 real
# minutes (NAV_TIMEOUT) per HCFA claim before this: the form was genuinely
# open on screen the whole time, just never matching what the wait was
# looking for. The same bug shape applies to Dental claims (9125000) —
# SEL_CANCEL_DENTAL is included for the same reason SEL_CANCEL_HCFA is.
SEL_ANY_CLAIM_FORM_OPEN = f"{SEL_IS_UB_FORM}, {SEL_CANCEL_HCFA}, {SEL_CANCEL_DENTAL}"
# ── "Has Angular finished filling it?" (claim_form.wait_claim_loaded) ────────
# open_claim() returns the moment a screen's Cancel is visible, and on the
# HCFA and Dental screens that is BEFORE Angular has filled the form: a live
# claim read 0 CPT rows, Prog. Notes "disabled" and blank Billing Notes, and
# went to the summary as Pass (run_20260930_163006; review item D7). The UB
# form already waits on SEL_CLAIM_NO_TEXT; these give the other two screens
# the same identity check plus "the service lines have codes in them".
#
# HCFA claim number — `<span ng-bind="ClaimData.InvId">9103000</span>`,
# mapped from the live HCFA screen (claim 9103000; tests/fixtures/
# hcfa_claim.html reproduces it). Text, not an input.
SEL_HCFA_CLAIM_NO = '[ng-bind="ClaimData.InvId"]'
# HCFA CPT code inputs: the CPTROW_ rows' `data-fieldname="claimCPTCode"`
# cell — the same HCFA fallback pair extract()'s HCFA_FIELDNAME map reads.
SEL_HCFA_CPT_CODE = 'tr[id^="CPTROW_"] [data-fieldname="claimCPTCode"]'
# Dental claim number and service-line code inputs — mapped from the live
# Dental DOM (claim 9125000's screen), scoped under the Dental modal
# (`id="dentalClaim<timestamp>"`, matched by prefix for that reason). The
# code values are Angular properties: read `.value`, never the attribute.
SEL_DENTAL_CLAIM_NO = '[id^="dentalClaim"] [ng-bind="ClaimData.claimNo"]'
SEL_DENTAL_CODE = '[id^="dentalClaim"] [data-fieldname="web_cptcode"]'
# The window each screen's form lives in, found by walking up (`closest()`)
# from that screen's VISIBLE Cancel — so the claim number and codes are read
# from the form actually on screen, never from a hidden copy of an earlier
# claim's form left in the DOM (eCW does leave hidden forms behind: see
# claim_form.any_claim_form_open). The HCFA form's own angular-ui-bootstrap
# window is `.claimModalBackdrop` (SEL_STRAY_MODAL_BACKDROP above, verified
# live on claims 9109000 and 9119000); the Dental form's is the `.modal`
# element whose id is `dentalClaim<timestamp>` (claim 9125000; tests/
# fixtures/dental_claim.html reproduces it). The `.modal` class is part of
# the selector on purpose: `closest()` stops at the NEAREST match, and the
# id prefix alone already matches the inner `dentalClaimModalContent<ts>`
# (harmless — it holds the whole form) and would match any other
# `dentalClaim…`-id wrapper eCW puts around the footer or Cancel. Stopping
# there, the window would hold neither the claim number nor the service
# lines, and every Dental claim would wait out the full load timeout and come
# back not evaluated (review of unit C, 2026-10-05). Only the modal itself
# carries both the id prefix and the `modal` class.
SEL_HCFA_WINDOW = SEL_STRAY_MODAL_BACKDROP
SEL_DENTAL_WINDOW = '.modal[id^="dentalClaim"]'
# ── Tabs across the middle ────────────────────────────────────────────────────
# Matched on ng-click, because every data-target carries the timestamp.
SEL_TAB_CPT_REV = "a[ng-click*=\"cptTabUpdate('cptRev')\"]"
SEL_TAB_CPT_HCPCS = "a[ng-click*=\"cptTabUpdate('cptHcpcs')\"]"
# The CPT/HCPCS grid's own wrapper — a rare stable id on this screen.
SEL_CPT_GRID = "#CPTCodesDiv"
# The four upper tabs (ICD/CPT/Revenue, Occurrence, Insurance, Additional).
# `data-target` is timestamped, so these go by their stable link ids.
SEL_TAB_ICD_CPT_REV = "#UBClaimLink4"
SEL_TAB_OCCURRENCE = "#UBClaimLink3"
SEL_TAB_INSURANCE = "#UBClaimLink2"
SEL_TAB_ADDITIONAL = "#UBClaimLink1"
# ── Claim Status (document #13) ───────────────────────────────────────────────
# The claim form's Claim Status dropdown, from the claim form DOMs the
# operator pasted: HCFA `select#claimStatusSel<ts>[ng-model="ClaimData.
# ClaimStatus"]`, Dental `select#DentalClaimStatus<ts>` (same ng-model), UB
# `select#claimStatusSel<ts>[ng-model="selectedClaimStatusCodes"]`. Option
# values are the status codes ("PAT" = Patient) on all three. NOT
# `select#status[ng-model="claimRP.claimRPStatus"]`, a different field
# every form also has.
SEL_CLAIM_STATUS = ('select[ng-model="ClaimData.ClaimStatus"], '
'select[ng-model="selectedClaimStatusCodes"]')
# ── CPT/HCPCS row-level write targets ─────────────────────────────────────────
# Matched by id PREFIX, not the full id: every CPT row's per-field ids end in
# the row index (`uBClaimPOS0`, `uBClaimPOS1`, ...) with no timestamp — the
# same prefix-matching claim_form.extract()'s own JS already relies on for
# reading. `SEL_CPT_ROWS` finds the real rows only: eCW's blank padding rows
# and the trailing "*" entry row carry no `CPTROW_` id at all. `CPTROW_` itself
# is shared by both the UB form and the professional/HCFA screen (verified
# live, claim 9103000) — only the row's OWN field ids differ between them.
#
# The Dental Claim screen (2026-10-12: Dental claims run the conditions too)
# has its own grid shape, from Dom.txt's capture: rows are
# `tr[ng-repeat="CPT in CPTCodes track by $index"]` with no CPTROW_ id, and
# every field goes by `data-fieldname` (web_cptcode, web_icd1-4, web_units,
# web_billedFee) — the third alternative in each selector below. No POS and
# no modifier columns at all. Scoped to the Dental window ON SCREEN
# (Playwright's `:visible`): eCW leaves closed forms in the DOM, and a
# hidden Dental grid must never add rows to a UB/HCFA claim's walk.
_DENTAL_ROW = ('.modal[id^="dentalClaim"]:visible '
'tr[ng-repeat^="CPT in CPTCodes"]')
SEL_CPT_ROWS = f'tr[id^="CPTROW_"], {_DENTAL_ROW}'
# The HCFA screen's per-field ids (`billingClaimIpt4ngR0`, ...) are a single
# sequential numbering shared across the ENTIRE modal, not just this grid —
# the same numbering-can-shift risk already noted on SEL_CANCEL_HCFA — so
# they are not safe to match by prefix the way the UB form's are.
# `data-fieldname` is what is actually stable there, verified live: every
# alternative below is the second, HCFA half of each pair; the first half is
# the UB form's, unchanged. Within one row only one half of a pair is ever
# present, so `.first` still resolves to the single real match either way.
SEL_CPT_CODE = ('[id^="uBClaimCPTCode"], [data-fieldname="claimCPTCode"], '
'[data-fieldname="web_cptcode"]')
SEL_CPT_POS = '[id^="uBClaimPOS"], [data-fieldname="ClaimCPTPOS"]'
SEL_CPT_MOD1 = '[id^="uBClaimMod1"], [data-fieldname="ClaimCPTMOD1"]'
SEL_CPT_MOD2 = '[id^="uBClaimMod2"], [data-fieldname="ClaimCPTMOD2"]'
SEL_CPT_MOD3 = '[id^="uBClaimMod3"], [data-fieldname="ClaimCPTMOD3"]'
SEL_CPT_MOD4 = '[id^="uBClaimMod4"], [data-fieldname="ClaimCPTMOD4"]'
# In M1-M4 order — what condition #12 (bh_h_code_modifier.py) clears.
SEL_CPT_MOD = (SEL_CPT_MOD1, SEL_CPT_MOD2, SEL_CPT_MOD3, SEL_CPT_MOD4)
SEL_CPT_ICD1 = ('[id^="uBClaimICD1"], [data-fieldname="ClaimCPTICD1"], '
'[data-fieldname="web_icd1"]')
SEL_CPT_ICD2 = ('[id^="uBClaimICD2"], [data-fieldname="ClaimCPTICD2"], '
'[data-fieldname="web_icd2"]')
SEL_CPT_ICD3 = ('[id^="uBClaimICD3"], [data-fieldname="ClaimCPTICD3"], '
'[data-fieldname="web_icd3"]')
SEL_CPT_ICD4 = ('[id^="uBClaimICD4"], [data-fieldname="ClaimCPTICD4"], '
'[data-fieldname="web_icd4"]')
# In ICD1-4 order — the order a CPT row's diagnosis-pointer list is read and
# rewritten in, condition #6 (depression_dx_pointer.py).
SEL_CPT_ICD = (SEL_CPT_ICD1, SEL_CPT_ICD2, SEL_CPT_ICD3, SEL_CPT_ICD4)
# The CPT row's own "Assign To Patient" checkbox — what condition #6 needs
# to re-check on a G8510/G8431 row that was previously excluded from the
# claim. Matched on ng-click, timestamp-free like the CPT/HCPCS tab links,
# rather than the id (`claimCPTBillToInsChk<timestamp><row>`, verified live
# on the HCFA screen, claim 9103000). Not yet independently confirmed on
# the UB form — `ensure_cpt_rows_checked()` treats a row with none of these
# as nothing to do rather than a failure, since this only ever adds a
# check and never removes one.
SEL_CPT_ASSIGN_TO_PATIENT = '[ng-click*="updateBillToIns("]'
# The row's own Billed Fee — combined UB-id-prefix + HCFA-data-fieldname
# pattern, same shape as the ICD1-4 selectors above. Needed by condition
# #22's "change the fee amount as Zero" half (medicare_telehealth.py).
# Dental: `web_billedFee`, whose blur runs eCW's editCPTRow('BILLED_FEE').
SEL_CPT_BILLED_FEE = ('[id^="uBClaimBillFee"], '
'[data-fieldname="ClaimCPTBilledFee"], '
'[data-fieldname="web_billedFee"]')
# The row's own Units — same UB-id-prefix + HCFA-data-fieldname pair as its
# neighbours (both halves are the ones extract()'s own HCFA_FIELDNAME map
# already reads). Needed by condition #18 (vaccine_admin_codes.py) to set the
# "each additional" admin code's Units to the vaccine count - 1. It carries
# `ng-blur="padZero($index);editCPTRow('UNITS')"` — that blur is what makes
# eCW re-price the line, so a write here must blur, the same as M1.
SEL_CPT_UNITS = ('[id^="uBClaimUnits"], [data-fieldname="ClaimCPTUnits"], '
'[data-fieldname="web_units"]')
# Adding a CPT/HCPCS line item is NOT done by typing into the grid's
# trailing "*" row — that field is for something else. The real flow,
# verified against a live "Claim" screen (claim 9109000): click "Add" to
# open eCW's shared Fee Schedule picker, search it by code, select the
# matching row, and click the picker's own OK.
#
# "Add" itself is timestamped like everything else on the claim screen
# (`claimAddCPTBtn1789730090463` there, `addClaimProcCode1789729056266` on
# the Dental Claim screen), so it is matched on its shared, timestamp-free
# ng-click instead — the same reasoning as SEL_CANCEL_HCFA.
SEL_CPT_ADD_BTN = 'button[ng-click*="addProcedureCode("]'
# The Fee Schedule picker is a shared, reusable eCW component: unlike
# everything else on the claim screen, ITS OWN controls carry stable ids
# with no per-open timestamp at all — verified live (claim 9109000).
SEL_FEE_SCHEDULE_CPT_SEARCH = "#claimFeeScheduleIpt6" # ng-model="claimFS.cptcode"
SEL_FEE_SCHEDULE_RESULTS = ".claimFSCPTTbl" # ng-repeat="obj in claimFS.procedures"
SEL_FEE_SCHEDULE_OK = "#claimFeeSchOkBtn" # ng-click="addClaimFSCPTCode('save');addCptClaimEdit();"
SEL_FEE_SCHEDULE_CANCEL = "#claimFeeScheduleBtn8" # ng-click="addClaimFSCPTCode('cancel');"
# ── Progress Notes ────────────────────────────────────────────────────────────
# `ng-click="viewProgNote()"`, and `ng-disabled="!FormData.EnableProgressNote"` —
# so it can be present and disabled, and clicking it then does nothing at all.
# claim_form.open_progress_notes() checks enabled, not just visible.
#
# Matched on ng-click, not the UB form's stable `#UBClaimProgressNoteBtn` id:
# verified against a live HCFA/professional claim that this same ng-click
# sits on a DIFFERENT, per-open-timestamped id there
# (`claimProgressNoteBtn<timestamp>`) — the id is not shared across screens,
# but the click handler is, so this is what makes Prog. Notes work on both.
# Fallback (core/fallback.py): the ids described above — UB's stable one
# and the HCFA screen's timestamped one.
PROGRESS_NOTES_CHAIN = ('button[ng-click="viewProgNote()"]',
"#UBClaimProgressNoteBtn",
'button[id^="claimProgressNoteBtn"]')
SEL_PROGRESS_NOTES = ", ".join(PROGRESS_NOTES_CHAIN)
# The professional/HCFA "Claim" screen's own OK/Save — verified against a
# live claim: `id="claimScreenOkBtn<timestamp>"`,
# `ng-click="currentButtonAction='';saveAllData()"` (no `true` argument,
# unlike the UB form's `saveAllData(true)`). Matched on the substring both
# share rather than the id, which is timestamped here. Used only as a
# fallback in save_claim(), after the UB-specific #UBClaimBtn18 — this is
# the one place both screens' write paths actually diverge in shape, since
# everything else about "close the form" already has a shared or per-screen
# selector.
SEL_OK_HCFA = 'button[ng-click*="saveAllData("]'
# ── Tables read during extraction ─────────────────────────────────────────────
# Stable ids, all of them verified against the live DOM on 2026-09-07 — UB
# form only, unlike SEL_TBL_ICD below.
SEL_TBL_INSURANCE = "#UBClaimTbl3" # InvInsList
SEL_TBL_PAYMENTS = "#UBClaimTbl4" # PayAdjRefund
SEL_TBL_REVENUE = "#UBClaimTbl10" # UB92CPTDetails
SEL_TBL_ERRORS = "#UBClaimTbl17" # ChkCurUB92Batch
# Matched by id PREFIX, not the full id: the UB form's own icdCodesTable
# carries no timestamp (verified 2026-09-07), but the professional/HCFA
# screen's copy of the SAME id is timestamped (`icdCodesTable1789643824545`,
# claim 9103000) — a plain prefix match reaches both without needing a
# second, HCFA-only selector the way the CPT row fields did.
SEL_TBL_ICD = '[id^="icdCodesTable"]' # ICDCodes
# ── Progress Notes viewer ─────────────────────────────────────────────────────
# Close is matched on its ng-click, and **the typo is eCW's own**:
# `closeProgressNoteViwer()` — Viwer, not Viewer. Correcting it here would match
# nothing. Left exactly as shipped, with this note so nobody "fixes" it.
SEL_PN_CLOSE = 'button[ng-click="closeProgressNoteViwer()"]'
# The viewer's own iframe, from the live DOM (run 2026-10-12): it is there
# — blank — while the note document is still loading, before the viewer's
# Close button is drawn. While it is on screen the first Prog. Notes click
# DID take, so it must never be clicked again (that stacked a second viewer
# over the first; see claim_form._wait_for_note).
SEL_PN_VIEWER_FRAME = ('iframe#ProgNoteViwerFrame, '
'iframe[name="ProgNoteViwerFrame"]')
# The progress note is a whole printable document with its own <html>, so it
# arrives in an iframe or a separate tab rather than inline. These are how a
# candidate frame/tab is recognised as holding the note:
# #mainThead the repeating patient header row
# [prisma-section] eCW tags every section of the note with one of these
SEL_PN_MARKER = "#mainThead, [prisma-section]"
SEL_PN_SECTION = "[prisma-section]"
SEL_PN_HEADER = "#mainThead"
# Hidden span carrying "true" when the chart is locked. Read with textContent,
# never innerText: it is display:none, and innerText returns "" for that.
SEL_PN_LOCKED = "#chartLockedId"
# ── UB Condition Codes (FL 18-28) ────────────────────────────────────────────
# Mapped from Dom.txt (a UB form, 2026-10-16): eleven `select-claim-code`
# inputs, ids uBClaimCondtnCode1..11 (`search-record="Ub92Data.ConditionCode
# N"`), 2 characters each. Plain text inputs: ng-focus loads the code list,
# ng-keyup shows it; no ng-blur / ng-change. Format with n = 1..11. The UB
# form only — the HCFA screen's are not mapped, and need not be: Medicare is
# billed on UB (document #2; operator, 2026-10-21). On the
# form ON SCREEN only: eCW leaves closed forms in the DOM (review,
# 2026-10-19). claim_form._EXTRACT_JS reads the same eleven boxes (its own
# copy, from the shown #UBClaimDiv) — change both together.
SEL_UB_CONDITION_CODE = '#UBClaimDiv:visible [id="uBClaimCondtnCode{n}"]'
UB_CONDITION_CODE_BOXES = 11
# ── Patient Info → Sliding Fee Schedule (read-only expiry check) ─────────────
# Mapped from Dom.txt (UB claim 9124000, 2026-09-30). The claim header's
# "Info" button carries a timestamped id (claimPatientInfo<ts>), so it is
# matched on its ng-click instead.
SEL_CLAIM_PATIENT_INFO = "button[ng-click=\"loadPatientHubnInfo('Info');\"]"
SEL_PT_INFO_MODAL = "#ptInfo"
# Patient Information's own Cancel (bottom right). Never its Save.
SEL_PT_INFO_CANCEL = 'button[ng-click="cancelbtn()"]'
# The "You have unsaved data" prompt Cancel can raise: its "Don't Save".
SEL_PT_INFO_DONT_SAVE = "#savePrompt-tplBtn4"
# ng-disabled="slidingfeedisable"; green (useGreenClass) when one is assigned.
SEL_SLIDING_FEE_BTN = "#btnSlidingFeeschedule"
SEL_SLIDING_FEE_MODAL = "#slidingfeescheduleDailog" # eCW's spelling
SEL_SLIDING_FEE_CLOSE = (f'{SEL_SLIDING_FEE_MODAL} '
'button[ng-click="closeSlidingFeeScheduleModal()"]')
+127
View File
@@ -0,0 +1,127 @@
"""
The Claim Lookup screen (`claimLookup.jsp`) — filters, results grid, export.
The single most expensive lesson on this screen: **eCW reuses ids.** Both the
CSV and the EXCEL buttons are `#claimLookupBtn1002`, so `.first` on that id
exports CSV while the log looks perfectly clean. Anywhere an id is not unique,
the selector below matches on `ng-click` or on visible text instead, and says
why.
"""
# ── Date type ─────────────────────────────────────────────────────────────────
# The dropdown's trigger. Matched on ng-bind rather than the class, because
# `.toggledropdown` appears several times on this screen — the claim-no type
# next to it is another one. Its own text IS the current value
# (ng-bind="selectedDateType"), which makes it the thing to assert on after a
# selection instead of guessing at some other confirmation.
SEL_DATE_TYPE_TOGGLE = "span.toggledropdown[ng-bind='selectedDateType']"
# The options list. Text-matched inside it, never by the ngR<N> ids: those are
# generated from the ngRepeat index, so `claimLookupLink2ngR1` only means
# "Claim Dt(s)" for as long as eCW keeps the list in this exact order.
SEL_DATE_TYPE_MENU = "#claimLookupUl2"
# Option labels. Here rather than in settings because they are DOM text — what
# the option actually reads — not a preference.
DATE_TYPE_CLAIM = "Claim Dt(s)"
DATE_TYPE_SERVICE = "Service Dt(s)"
# ── Claim Status ──────────────────────────────────────────────────────────────
# A plain <select>, so no dropdown dance. Two things hang off it: the balance
# row's enableBalanceFlag (via ng-change="enableInsurance()"), and whether a
# claim-based date type can match anything at all.
SEL_CLAIM_STATUS = "#claimStatusCodeId"
CLAIM_STATUS_NO_CLAIM = "EncMissingClaims" # the default; has no claim date
# The mode selector beside it (Claim Status / User - (PRS) / PRS / …). Left
# alone, but #claimStatusCodeId only exists while this is on "Claim Status"
# (ng-show="ClaimStatusFlag"), so a missing select means the mode moved.
SEL_CLAIM_STATUS_MODE = "span.toggledropdown[ng-bind='selectedClaimStatus']"
# ── Date range ────────────────────────────────────────────────────────────────
# Both carry jQuery UI's `hasDatepicker`, so focusing one opens a calendar
# overlay that can sit on top of the next control.
SEL_DATE_FROM = "#fromdate"
SEL_DATE_TO = "#todate"
# jQuery UI parks one shared calendar here. Escape alone does not close it —
# observed 2026-09-02 still open over the results grid at Lookup time — so
# claim_filters.py blurs the field and checks this went away.
SEL_DATEPICKER = "#ui-datepicker-div"
# ── Balance filter ────────────────────────────────────────────────────────────
# Five controls, not two: operator, value, And/Or, second operator, second
# value. All of them carry ng-disabled="!enableBalanceFlag", which eCW drives
# off the Claim Status selection — so they are often disabled and there is
# nothing to type into.
SEL_BAL_OP1 = "#claimLookupSel6"
SEL_BAL_VAL1 = "#claimLookupIpt11"
SEL_BAL_ANDOR = "#claimLookupSel7"
SEL_BAL_OP2 = "#claimLookupSel8"
SEL_BAL_VAL2 = "#claimLookupIpt12"
# The ng-model names behind the five controls above, in the same order.
#
# Needed because the row arrives holding "> 0.0" while every control is
# disabled: nothing can be clicked or typed, yet eCW builds the query from the
# scope values, not from the inputs — so the filter is live and unreachable.
# Writing the scope is the only way to actually clear it.
BAL_MODELS = ("_condition1", "_conditionVal1", "andOrCondition",
"_condition2", "_conditionVal2")
# ── Claim No ──────────────────────────────────────────────────────────────────
# The single-claim search box. `ng-model="_InvId"`, so it must be written with
# `fill()`: that dispatches the `input` event ng-model listens for, and eCW
# builds its query from the scope, not from the DOM. Setting `.value` through
# `evaluate` would show the right number on screen and search for nothing.
#
# `autocomplete="one-time-code"` on it is eCW's doing, not a hint about the
# field — it is how they stop the browser offering past claim numbers.
# Fallback (core/fallback.py): its ng-model, which is what eCW reads.
CLAIM_NO_CHAIN = ("#claimLookupIpt10", 'input[ng-model="_InvId"]')
SEL_CLAIM_NO = ", ".join(CLAIM_NO_CHAIN)
# The type selector beside it decides what that box means. eCW switches the
# date-type dropdown's pointer-events off while this reads "Rule Engine ID#",
# which is the one observable sign that the box is not searching claim numbers.
# No selector for it has been captured, so nothing here reads it directly —
# `claim_results.search_claim` verifies the *result* instead, by checking that
# the claim id it typed actually comes back in the grid.
# ── Search ────────────────────────────────────────────────────────────────────
# ng-click="getClaimsListFromLookup('lookup')". Distinct from
# #btnclaimlookupClear right beside it, which wipes the whole form.
# Fallback (core/fallback.py): that ng-click — not the Clear button's.
LOOKUP_BTN_CHAIN = ("#btnclaimlookup",
"[ng-click=\"getClaimsListFromLookup('lookup')\"]")
SEL_LOOKUP_BTN = ", ".join(LOOKUP_BTN_CHAIN)
# ── Export: Billing → View Claims Report → EXCEL ──────────────────────────────
# The toolbar above the results grid. Note there are TWO "Billing" controls on
# this screen — the left-nav item pages/leftnav.py uses, and this one.
SEL_BILLING_BTN = "#claimLookupBtn17"
SEL_BILLING_MENU = "#claimLookupUl10"
# "View Claims Report" opens a Bootstrap `dropdown-submenu`, which normally
# reveals on hover rather than click — the button carries no ng-click at all.
# claim_report.py hovers first and only clicks if hovering does not open it.
SEL_VIEW_REPORT = "#claimLookupBtn71"
SEL_REPORT_MENU = "#claimLookupUl1000"
def report_item(fmt: str) -> str:
"""
The format button inside the report submenu.
Matched on ng-click and deliberately **not** on the button id: eCW gives CSV
and EXCEL the *same* id, `#claimLookupBtn1002`. Clicking that id with
`.first` exports CSV — a file downloads, the run looks clean, and the format
is wrong. `mnuViewReport_Click('EXCEL');` is unique per format.
"""
return f'{SEL_REPORT_MENU} button[ng-click*="{fmt}"]'
+19
View File
@@ -0,0 +1,19 @@
"""
The fallback chains `core/fallback.report()` checks (2026-10-21): name →
(selectors module, chain), the chain's first entry the primary. Each chain
is defined beside its selector, which is the chain joined as a CSS list.
"""
from . import claim_form as form_sel
from . import claim_lookup as lookup_sel
from . import login as login_sel
CHAINS = {
"Password field": ("login", login_sel.PASSWORD_CHAIN),
"Log In button": ("login", login_sel.LOGIN_BTN_CHAIN),
"Claim No box": ("claim_lookup", lookup_sel.CLAIM_NO_CHAIN),
"Lookup button": ("claim_lookup", lookup_sel.LOOKUP_BTN_CHAIN),
"UB Cancel button": ("claim_form", form_sel.CANCEL_CHAIN),
"Dental Cancel button": ("claim_form", form_sel.CANCEL_DENTAL_CHAIN),
"Prog. Notes button": ("claim_form", form_sel.PROGRESS_NOTES_CHAIN),
}
+113
View File
@@ -0,0 +1,113 @@
"""
Login, captcha and second-factor selectors (eCW V12).
Several of these are comma-separated selector *lists* rather than single ids.
That is not laziness: eCW gives these controls no stable id across builds, so a
label match with id fallbacks is what actually survives an upgrade. The ones
written as a bare id are the ones observed to be stable.
"""
SEL_USERNAME = "#doctorID"
SEL_NEXT = "#nextStep"
# Fallback chains (core/fallback.py, 2026-10-21): the primary first, then
# what the live login page showed — the field is `type="password"`, the
# button `<input type="button" value="Log In" class="btn btn-emr btn-sm
# agentButton">` (network log of 2026-10-20).
PASSWORD_CHAIN = ("#passwordField", 'input[type="password"]')
SEL_PASSWORD = ", ".join(PASSWORD_CHAIN)
LOGIN_BTN_CHAIN = ("#Login", 'input[type="button"][value="Log In" i]')
SEL_LOGIN_BTN = ", ".join(LOGIN_BTN_CHAIN)
SEL_OTP_INPUT = "#OTPCode"
SEL_OTP_SUBMIT = "#Submit"
SEL_HOME = "#jellybean-panelLink4"
SEL_USERNAME_ERR = "#err"
SEL_ALT_TFA = "#altTFAMsg"
SEL_ALT_TFA_SEND = "#altTFASend"
# "Resend OTP" on the push-notification screen.
#
# The real id is **#resendOtpBtn** — `<button id="resendOtpBtn"
# onclick="resendPushNT();">Resend OTP</button>`. That is why the resend branch
# failed on 2026-09-04 with `Locator.click: Timeout 30000ms exceeded`: the list
# here guessed `#resendOTP`, `#ResendOTP` and `#resend`, and eCW spells it with
# a lowercase `tp` and a `Btn` suffix. The guesses are kept behind the real id
# as a hedge against a rename, not because any of them has ever matched.
#
# It is often `disabled="disabled"`, so visibility is not enough to click it:
# eCW disables it on the timed-out page, and `setReSendOtpMsg()` disables it for
# good once `leftOtpCount` hits 0 — three resends per login, not unlimited.
SEL_RESEND_OTP = (
"#resendOtpBtn, "
"input[value*='Resend' i], button:has-text('Resend'), "
"a:has-text('Resend'), #resendOTP, #ResendOTP, #resend"
)
# The status line on the 2FA screen. **Read its text, never just its
# visibility.** eCW reuses this one element for the "a notification has been
# sent to your email" notice AND for every dead end — so treating it as "the
# push notice is showing" makes a timed-out login look like a healthy one that
# is still waiting for mail.
SEL_PUSH_NOTICE = "#dispMessage"
SEL_OTP_MESSAGE = "#dispMessage"
# "Try Again" — `onclick="logoutSeamLessSSO()"`, which navigates to
# `/login/authenticate/logOut`. eCW reveals it alongside every dead-end message.
# The bot does not click it: a fresh browser is cleaner than a logged-out
# session in a browser that has already been through a failed handshake.
SEL_TRY_AGAIN = "#backToLoginTryAgain"
# The three dead ends `initCheckTokenTimer()` can write into #dispMessage. It
# polls `checkSecureToken` every second and, on any of these, stops polling —
# so the page will never move on and waiting is pointless. Matched on text
# because the id is the same for all three and for the healthy notice.
#
# isTokenExpired → the approval link was not clicked in time
# isVerified === "no" → somebody answered "No, it wasn't me"
# leftOtpCount <= 0 → resends exhausted (three per login)
TXT_LOGIN_TIMED_OUT = "log in request has timed out"
TXT_NOT_ME = "weren't trying to Log in"
TXT_NO_ATTEMPTS = "No attempt left"
# Confirm button on the emailed openLoginConfirmation page. eCW gives it no
# stable id across builds, so match the label and fall back to generic buttons.
SEL_APPROVE_YES = (
"button:has-text(\"it's me\"), input[value*=\"it's me\" i], "
"a:has-text(\"it's me\"), "
"#yesItsMe, #btnYes, #yes, "
"button:has-text('Yes'), input[type='button'][value*='Yes' i], "
"input[type='submit'][value*='Yes' i]"
)
SEL_CAPTCHA_INPUT = "#captchaVal, #captchaVal1, #captchaField, #txtCaptcha"
SEL_CAPTCHA_IMG = (
"img#imageCaptcha, img#captchaImage, img#captchaImg, "
"img[id*='captcha']:not([id*='security']):not([id*='landmark'])"
)
# eCW's refusal page: "Access to the application has been restricted."
#
# Measured 2026-09-01: this is an IP check, not a rate limit. The same approval
# link answers HTTP 400 unproxied and HTTP 200 through the proxy, from the same
# machine, seconds apart. So for the bot this page almost always means "the
# browser is not actually going through the proxy" — not "the account is locked".
# It is what makes the manual flow impossible: the webmail host is blocked BY
# the proxy, and the portal is blocked WITHOUT it, so a human has to toggle.
#
# Detecting it explicitly matters because the portal keeps rendering the normal
# 2FA screen afterwards and still claims it emailed an approval link while
# silently sending nothing — otherwise the bot polls an empty mailbox for two
# minutes and blames the mailbox.
TXT_RESTRICTED = "has been restricted"
# eCW's post-login "This password expires in 14 days. Do you want to change
# the password now?" box, with OK (= go and change it now) and Cancel (= not
# now). Seen live on 2026-10-05: it sat over the grey page after the 2FA
# approval, the home screen never came up, and both runs that day reported
# "Login not confirmed". Its DOM has not been captured yet, so it is found by
# its TEXT, and only a button labelled exactly "Cancel" inside the same box is
# ever clicked — never OK, which opens the change-password screen.
TXT_PASSWORD_EXPIRES = r"password\s+expires\s+in\s+(\d+)\s+day"
# A password that has already expired cannot be waved away with Cancel: eCW
# makes the user set a new one, which the bot must never do.
TXT_PASSWORD_EXPIRED = r"password\s+(?:has\s+)?expired"
+35
View File
@@ -0,0 +1,35 @@
"""
The eCW application shell: left navigation panel and the route spinner.
Three facts from the live DOM shape every locator built on these, and all three
cost a debugging session to find:
* `#JellyBeanCountCntrl` renders **twice**, so `SEL_NAV_TOGGLE` matches two
elements and Playwright's strict mode errors out without `.first`.
* the toggle's handler `launchLeftPanel()` is a **toggle**, not an "open" —
clicking it on an already-open panel closes it. State first, click second.
* the panel's contents are injected by `getleftnavhtml()` on
`DOMContentLoaded`, so `SEL_MENU_BILLING` does not exist at the moment
login is confirmed. Wait for it; do not assume it.
"""
SEL_NAV_TOGGLE = "#jellybean-panelLink4"
# launchLeftPanel() flips the `visible` class on .response-nav-lp (#leftnavApp).
SEL_NAV_PANEL = "#leftnavApp"
SEL_NAV_OPEN = "#leftnavApp.visible"
SEL_MENU_BILLING = "#Billing_menu > a"
SEL_MENU_BILLING_OPEN = "li#Billing_menu.open"
# Click the LABEL span, not the anchor: the anchor also holds #favorite_16,
# whose own ng-click stars the menu item instead of navigating.
SEL_CLAIMS = "#CLAIMS_BILLING"
SEL_CLAIMS_LABEL = "#CLAIMS_BILLING span.padl10"
# What that item's ng-click openURL() actually does. Kept here because it is
# also the fallback route when the menu markup shifts.
CLAIMS_HASH = "#/mobiledoc/jsp/webemr/webpm/claimLookup.jsp"
# Angular's route spinner — hidden once the requested view has rendered.
SEL_ROUTE_LOADER = "#routeLoader"